Naalya Handbook
Multi-Tenancy & AsyncLocalStorage

Repository Scoping

How BaseRepository injects the school_id filter and stamp on every read and write — fail-closed — and how @TenantScoped opts an entity in.

This is the payoff of the tenant context: BaseRepository reads it on every operation and either scopes the query, skips scoping, or throws. The whole point is that a developer can't forget — the engine does it, transparently.

resolveTenant

BaseRepository is the class every concrete repository extends, and it consults the context on every operation through one private method, resolveTenant(). That method returns one of three resolutions: inject (scope it), bypass (don't), or fail (throw).

libs/shared/src/database/base.repository.ts
private resolveTenant(): TenantResolution {
  const meta = getTenantMeta(this.entityClass);
  if (!meta) return { mode: 'bypass' };        // entity isn't tenant-scoped

  const ctx = getTenantContext();
  if (!ctx) return { mode: 'fail' };            // no context at all
  if (ctx.bypass) return { mode: 'bypass' };    // operator / runUnscoped
  if (ctx.schoolId == null) return { mode: 'fail' };  // fail-closed default

  return { mode: 'inject', schoolId: ctx.schoolId, tenantKey: meta.tenantKey };
}

Read it top to bottom: an entity that isn't tenant-scoped is never touched; a tenant-scoped entity with no usable context fails; otherwise the school id gets injected. The fail mode is converted to a thrown error at each call site via failClosed(), which raises TenantContextMissingError.

Scoping reads

On reads, the resolution merges a { schoolId } predicate into your where clause. Whatever you searched for, the engine adds AND school_id = :sid:

libs/shared/src/database/base.repository.ts
private scopeWhere(where?) {
  const t = this.resolveTenant();
  if (t.mode === 'fail') this.failClosed();
  if (t.mode === 'bypass') return where;
  return this.mergeTenantWhere(where, t.schoolId, t.tenantKey); // adds { schoolId }
}

The engine injects only the school predicate. The academic year is never auto-injected — year-scoped entities carry campusAcademicYearId as explicit, required request data instead. See Academic-Year Scoping.

Stamping writes

On writes, it stamps the schoolId onto the payload — and if the payload already carries a different school, it throws CrossTenantWriteError. You cannot write a row into someone else's tenant, even by passing the id explicitly:

libs/shared/src/database/base.repository.ts
private stampTenant<D extends Record<string, unknown>>(data: D): D {
  const t = this.resolveTenant();
  if (t.mode === 'fail') this.failClosed();
  if (t.mode === 'bypass') return data;
  this.assertSameTenant(data, t.schoolId, t.tenantKey); // throws on mismatch
  (data as Record<string, unknown>)[t.tenantKey] = t.schoolId;
  return data;
}

Query builder guard

The query-builder path needs an extra guard. TypeORM's .where() resets the accumulated where-clauses, which would silently drop the tenant predicate the engine just added. So createQueryBuilder reroutes .where to .andWhere on tenant-injected builders — isolation can't be clobbered by a later .where() call:

libs/shared/src/database/base.repository.ts
if (t.mode === 'inject') {
  qb.andWhere(`${qb.alias}.${t.tenantKey} = :__tenantSchoolId`, {
    __tenantSchoolId: t.schoolId,
  });
  // .where() would reset wheres and drop the tenant predicate — reroute it:
  const boundAndWhere = qb.andWhere.bind(qb);
  qb.where = ((where, params?) => boundAndWhere(where, params)) as typeof qb.where;
}

The result, when both scoping layers are active, is SQL like this — tenant isolation from this engine, campus scope from CASL, plus whatever your service asked for:

resulting query shape
WHERE school_id = :sid                        -- tenant isolation (this engine)
  AND (campus_id = :c OR campus_id IS NULL)   -- CASL campus scope (separate layer)
  AND (… your service filters …)

@TenantScoped

How does the repository know TransactionEntity is tenant-owned but SchoolEntity isn't? A decorator. @TenantScoped() registers the entity class in a central Map alongside its tenant key (which property holds the school id, defaulting to schoolId). resolveTenant() just looks the class up in that map.

libs/shared/src/database/tenant/tenant-scoped.decorator.ts
export function TenantScoped(options?: { tenantKey?: string }): ClassDecorator {
  return (target: Function) => {
    TENANT_REGISTRY.set(target, { tenantKey: options?.tenantKey ?? 'schoolId' });
  };
}

In practice you add two things to the entity — the decorator and the school_id column — and nothing to the repository:

apps/server/src/app/transaction/entities/transaction.entity.ts
@Entity('transaction')
@TenantScoped()
export class TransactionEntity extends DatabaseEntity {
  @Column({ name: 'school_id', type: 'uuid', nullable: true })
  schoolId?: string;
  // ...
}

The repository extends BaseRepository exactly as it would without scoping — the engine is entirely transparent to it:

apps/server/src/app/transaction/transaction.repository.ts
export class TransactionRepository extends BaseRepository<TransactionEntity> {
  constructor() { super(TransactionEntity); }
}

Deliberately global entities

Some entities are deliberately global — undecorated, never scoped — because scoping them would be circular or wrong:

EntityWhy it is NOT tenant-scoped
SchoolEntityIt is the tenant root — scoping by its own id is circular.
platform_admin_profileOperators are school-less; must be readable without a tenant.
refresh_tokens, addressCross-cutting, not owned by any one school.
permission registry, role_x_permissionGlobal catalog; only role assignments are per-school.

To find the current authoritative list, grep -rl "@TenantScoped" apps/server/src.

Where to go next

On this page