Naalya Handbook
Multi-Tenancy & AsyncLocalStorage

Multi-Tenancy & AsyncLocalStorage

How a School becomes the tenant, and how a per-request AsyncLocalStorage context auto-scopes every query — fail-closed.

Naalya-API is a multi-tenant SaaS, and the tenant is a School. One database, one schema, many schools sharing the same tables — what keeps School A from ever seeing School B's data is a single column, school_id, on every tenant-owned row, plus an engine that makes sure you can never forget to filter by it.

That last part is the whole point. In most apps, "scope this query to the current tenant" is something a developer has to remember on every single query. Forget once and you leak data across tenants — a quiet, dangerous bug. Naalya flips that around: the scoping is structural and fail-closed, and a developer who forgets to scope gets a loud 500, never a silent cross-tenant read.

The tree is flat. A School is the root; everything else hangs off it by school_id:

tenant tree
platform_admin   (school-less; cross-tenant operator)
└─ School        ← THE TENANT. Its id is the tenant key.
     └─ Campus
          └─ all domain data: users, roles, classes, enrollments …  (school_id)

Three scoping dimensions, one query

Tenant scoping (this section), CASL campus scoping, and academic-year scoping are independent layers that compose. Tenant answers "which School?"; CASL answers "which campuses within that School?"; the year answers "which year's records?". The first two are ambient and automatic — the year is deliberately explicit request data. See RBAC & Scopes and Academic-Year Scoping.

The core problem

The repository runs deep inside a service, several function calls away from the HTTP request that started it. It needs the current schoolId, but nobody passed it one. We could thread schoolId through every method signature — findStudents(schoolId, …), createInvoice(schoolId, …) — but that's exactly the "remember it every time" trap we're trying to escape, and it pollutes every function in the codebase.

The answer is AsyncLocalStorage (ALS), a Node built-in. Think of it as a variable scoped to "everything that happens during this request" rather than to a block or a module. You call store.run(value, fn), and for the entire async lifetime of fn — through every await, every nested call — store.getStore() returns that value. Two requests handled at the same time each see their own store, with no leakage between them.

The tenant context

The store holds a small TenantContext, and the middleware opens one run() per request so the repository, however deep, just calls getStore() to learn its tenant.

libs/shared/src/database/tenant/tenant-context.store.ts
export interface TenantContext {
  schoolId: string | null;
  isPlatformOperator: boolean;
  bypass: boolean;
}

export const tenantContextStore = new AsyncLocalStorage<TenantContext>();

export function getTenantContext(): TenantContext | undefined {
  return tenantContextStore.getStore();
}

The context has exactly three fields, and the combination of schoolId and bypass encodes three meaningful states:

Context stateMeaningWhat the repository does
{ schoolId: 'X', bypass: false }pinned to School Xfilter every query to school_id = 'X', stamp writes
{ bypass: true }scoping disabledno filter (operators, seeders, login lookups)
absent, or { schoolId: null, bypass: false }nothing was setthrow TenantContextMissingError → 500

That third row is the fail-closed default, and it's the safety net the whole design rests on.

Setting it per request

Context arrives in two steps: a middleware pins an empty, fail-closed slot at the very start of the request, and the JWT guard fills it in once it knows who you are.

tenantContextMiddleware runs in main.ts, before any controller. All it does is open a run() block for the whole request with a deliberately empty context — no school, no bypass:

apps/server/src/common/middleware/tenant-context.middleware.ts
export function tenantContextMiddleware(
  _req: Request, _res: Response, next: NextFunction,
): void {
  tenantContextStore.run(
    { schoolId: null, isPlatformOperator: false, bypass: false },
    () => next(),
  );
}

That default is fail-closed on purpose. A request that never gets a real tenant — say a @Public() route that skips auth — lands here with schoolId: null and bypass: false, which is precisely the "throw" state. A public endpoint cannot accidentally read tenant data; it has to opt in explicitly.

Once Passport validates the Bearer token, JwtAuthGuard.handleRequest reads the JWT payload and mutates the context the middleware already bound. The school comes straight from the token — the frontend never sends a schoolId on normal requests; it's baked into the access token at login:

apps/server/src/app/auth/guards/jwt-auth.guard.ts
private enterTenantContext(payload: JwtPayload): void {
  const ctx = tenantContextStore.getStore();
  if (!ctx) return;

  if (payload.type === UserType.PLATFORM_ADMIN) {
    ctx.schoolId = null;
    ctx.isPlatformOperator = true;
    ctx.bypass = true;          // operators see across tenants
    return;
  }

  ctx.schoolId = payload.schoolId ?? null;
  ctx.isPlatformOperator = false;
  ctx.bypass = false;           // ordinary user: pinned to their school
}

Why mutate the object instead of calling enterWith?

handleRequest runs inside Passport's verify callback — an async branch. If the guard called tenantContextStore.enterWith(...) there, the new value would not propagate back to the handler chain. Because the middleware bound the context with run() at the top, mutating that same object is visible everywhere downstream. Same trick PermissionsGuard uses for the CASL ability.

The platform operator lives above tenants

platform_admin is the one user type that lives above tenants — SaaS operators, distinct from a school's own super admin. Their JWT carries no schoolId, so the guard resolves them to { isPlatformOperator: true, bypass: true }: cross-tenant read access by default. When such an endpoint acts on one specific school it still pins with withSchool(targetSchoolId, …) so writes land in the right tenant — see Escape Hatches.

Where to go next

On this page