# How the Open‑SEO Project Service Manages Multi‑Tenant Access

> Discover how the Open-SEO Project Service ensures multi-tenant isolation. Learn how the organizationId parameter at every layer prevents data access across organizations.

- Repository: [Every App/open-seo](https://github.com/every-app/open-seo)
- Tags: internals
- Published: 2026-08-18

---

**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`](https://github.com/every-app/open-seo/blob/main/resolve.ts), [`hosted.ts`](https://github.com/every-app/open-seo/blob/main/hosted.ts), and [`delegated.ts`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/src/server/features/projects/services/projects.ts) include:

```ts
// 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

```ts
// 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`](https://github.com/every-app/open-seo/blob/main/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

```ts
// 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:

```ts
// 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, attaching `organizationId` to the request context.
- **Service layer** ([`projects.ts`](https://github.com/every-app/open-seo/blob/main/projects.ts)) requires `organizationId` as the first parameter of every function, making tenant context explicit and compile‑time enforced.
- **Repository layer** ([`ProjectRepository.ts`](https://github.com/every-app/open-seo/blob/main/ProjectRepository.ts)) scopes all SQL queries with `eq(projects.organizationId, organizationId)`, ensuring database‑level isolation.
- **Failure mode** returns `null` for cross‑tenant queries, surfaced to clients as `NOT_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`](https://github.com/every-app/open-seo/blob/main/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.