How the Open‑SEO Project Service Manages Multi‑Tenant Access
The Open‑SEO Project Service enforces multi‑tenant isolation by requiring an organizationId parameter at every layer—middleware authentication, service functions, and repository queries—to ensure organizations can never access each other's data.
Every request to the Project Service originates from a specific tenant (an organization). The codebase prevents cross‑tenant data leakage through explicit tenant scoping rather than hidden session state. This article breaks down the three architectural layers that enforce this isolation, with direct references to the source code in every-app/open-seo.
Middleware Layer: Resolving the Tenant from Authentication
Before any project operation executes, the ensure-user middleware determines which organization the authenticated user belongs to. This middleware extracts the tenant identifier from either an access token or a Cloudflare Access session, then attaches it to the request context.
All downstream route handlers receive a validated organizationId through request.context.organizationId. This design guarantees that the tenant is known and verified before the service layer ever sees the request.
The middleware implementations live in src/middleware/ensure-user/ (including resolve.ts, hosted.ts, and delegated.ts). Once attached, the organization ID flows through every subsequent layer unchanged.
Service Layer: Explicit Tenant Parameter in Every Function
The Project Service does not rely on ambient context or global state. Instead, organizationId: string is the required first argument of every exported function.
In src/server/features/projects/services/ProjectService.ts, the module simply re‑exports concrete implementations without adding logic—the tenant requirement is baked into each function signature.
The concrete implementations in src/server/features/projects/services/projects.ts include:
// Tenant is mandatory and positional
export async function listProjects(organizationId: string)
export async function createProject(organizationId: string, input: CreateProjectInput)
export async function updateProject(organizationId: string, input: UpdateProjectInput)
export async function setProjectDomain(organizationId: string, input: SetProjectDomainInput)
export async function setProjectMarket(organizationId: string, input: SetProjectMarketInput)
export async function archiveProject(organizationId: string, input: ArchiveProjectInput)
export async function restoreProject(organizationId: string, input: RestoreProjectInput)
export async function listArchivedProjects(organizationId: string)
export async function getProjectForOrganization(organizationId: string, projectId: string)
Because TypeScript enforces these signatures at compile time, developers cannot accidentally omit the tenant identifier. This explicit parameter passing makes multi‑tenant access control auditable and testable.
Service Usage Examples
// 1️⃣ List all projects for the current tenant
const projects = await ProjectService.listProjects(request.context.organizationId);
// 2️⃣ Create a new project under the tenant
await ProjectService.createProject(request.context.organizationId, {
name: "My New Site",
domain: "example.com",
locationCode: 2840, // US
languageCode: "en",
});
// 3️⃣ Update a project (still scoped by tenant)
await ProjectService.updateProject(request.context.organizationId, {
projectId: "proj_123",
name: "Renamed Site",
domain: "newdomain.com",
locationCode: 2840,
languageCode: "en",
});
// 4️⃣ Archive a project – fails if it’s the only project for the tenant
await ProjectService.archiveProject(request.context.organizationId, {
projectId: "proj_123",
});
Repository Layer: SQL Queries Scoped to the Tenant
The final isolation barrier lives in src/server/features/projects/repositories/ProjectRepository.ts. Every database query includes an eq(projects.organizationId, organizationId) clause, ensuring rows from other organizations are invisible.
Read Operations with Tenant Filtering
// List only projects for the given organization
return db.query.projects.findMany({
where: and(
eq(projects.organizationId, organizationId),
isNull(projects.archivedAt),
),
});
The and combinator applies both conditions: the tenant match and the active‑project check (excluding archived rows when appropriate).
Write Operations with Tenant Enforcement
Updates apply the same tenant filter, preventing modification of records outside the caller's organization:
// Updates in ProjectRepository.ts include tenant scoping:
// - updateProject
// - updateProjectDomain
// - updateProjectMarket
// - archiveProject
// - restoreProject
If a query targets a project that does not belong to the supplied organizationId, the repository returns null. The service layer then throws a NOT_FOUND AppError. This behavior masks cross‑tenant access attempts as "not found" rather than revealing that another organization's data exists—a security best practice.
Summary
- Middleware (
ensure-user/*) resolves and validates the tenant from authentication, attachingorganizationIdto the request context. - Service layer (
projects.ts) requiresorganizationIdas the first parameter of every function, making tenant context explicit and compile‑time enforced. - Repository layer (
ProjectRepository.ts) scopes all SQL queries witheq(projects.organizationId, organizationId), ensuring database‑level isolation. - Failure mode returns
nullfor cross‑tenant queries, surfaced to clients asNOT_FOUND—no data leakage or enumeration vulnerabilities.
This three‑layer approach achieves strict multi‑tenant isolation without relying on hidden session state or ambient context, making the system both secure and maintainable.
Frequently Asked Questions
What happens if a user tries to access a project from another organization?
The repository returns null because the SQL query includes eq(projects.organizationId, organizationId) and the project row belongs to a different tenant. The service layer converts this to a NOT_FOUND AppError, preventing any cross‑tenant data exposure.
Why does the service layer pass organizationId explicitly instead of using a global context?
Explicit parameters make dependencies visible at the function signature level. This improves testability (mock any tenant), enables static analysis, and prevents accidental omission of tenant scoping. The ProjectService.ts re‑export pattern keeps this explicit while maintaining a clean public API.
Where is the tenant identifier sourced from in production?
The ensure-user middleware extracts it from the authenticated session—either a signed access token or Cloudflare Access headers. The resolved organizationId is attached to request.context and flows unchanged through every project operation.
Does the repository ever skip the tenant filter for administrative operations?
According to the source analysis, no repository function omits the eq(projects.organizationId, organizationId) clause. Administrative cross‑tenant access would require a different code path or elevated privilege system not present in the analyzed Project Service implementation.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →