Tenancy & RLS

Tenant isolation by construction — no tenantId threading — and the same pattern against real Postgres RLS.

The model: bound by construction

roost's request scope makes multi-tenancy disappear from your method signatures. The per-request RequestContext (tenant + user) is registered as an injectable value, and a SCOPED, tenant-bound repo is built from it once, per request:

@Injectable({ scope: 'scoped' })
export class ProjectsRepo {
  constructor(
    private readonly store: ProjectStore,
    private readonly ctx: RequestContext, // ← the tenant, injected
  ) {}
  all() {
    return this.store.rows.get(this.ctx.tenantId) ?? [] // scoped here, once
  }
}

Because the boundary is established at construction, not re-checked at each call site, no controller, service, or repo method ever takes a tenantId. It flows entirely through the scope. This is the DI analogue of Postgres row-level security.

The endgame: real Postgres RLS

The pglite-rls example proves the same pattern against real Postgres row-level security, running on embedded pglite (no external database). The repo issues no WHERE tenant_id and INSERTs pass no tenant — yet each tenant sees only its own rows, enforced by the database:

private async withTenant(fn) {
  return this.db.transaction(async (tx) => {
    await tx.execute(sql`select set_config('app.tenant_id', ${this.ctx.tenantId}, true)`)
    await tx.execute(sql`set local role app_user`) // RLS doesn't constrain superusers!
    return fn(tx)
  })
}

all() { return this.withTenant((tx) => tx.select().from(projects)) } // no where(tenant)

RLS gotchas the example gets right

GotchaWithout the fixFix
Superusers bypass RLSpolicies silently ignored → cross-tenant leakSET LOCAL ROLE app_user (non-superuser)
Owner bypasses RLSthe table owner isn't constrainedALTER TABLE … FORCE ROW LEVEL SECURITY
current_setting throws when unseta request with no tenant errorscurrent_setting('app.tenant_id', true)
SET leaks across pooled requestsone request's tenant bleeds into anotherset_config(…, true) + SET LOCAL (txn-scoped)

Swap pglite for a Postgres pool and the pattern is identical.

Copyright © 2026