Tenancy & 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
| Gotcha | Without the fix | Fix |
|---|---|---|
| Superusers bypass RLS | policies silently ignored → cross-tenant leak | SET LOCAL ROLE app_user (non-superuser) |
| Owner bypasses RLS | the table owner isn't constrained | ALTER TABLE … FORCE ROW LEVEL SECURITY |
current_setting throws when unset | a request with no tenant errors | current_setting('app.tenant_id', true) |
SET leaks across pooled requests | one request's tenant bleeds into another | set_config(…, true) + SET LOCAL (txn-scoped) |
Swap pglite for a Postgres pool and the pattern is identical.