Multi-tenancy

Tenant isolation is enforced by Postgres Row-Level Security, not application-level filtering.

Every tenant-scoped table is isolated at the database layer, not by remembering to add WHERE tenantId = ... to every query. This is the single most important pattern in the backend to understand before touching data access.

How it's wired

  • prisma/setup-db.ts creates a non-superuser peragraph_app role and, for every tenant-scoped table, a tenant_isolation RLS policy: tenantId = current_setting('app.current_tenant_id'). The runtime connects as this restricted role, never as a superuser.
  • nestjs-cls carries the current tenantId through the request via AsyncLocalStorage. JwtAuthGuard sets it once per HTTP request from the JWT payload; BullMQ job handlers set it once per job.
  • src/database/tenant-prisma.ts wraps the base Prisma client in a $extends that intercepts every model operation: it opens a transaction, runs SELECT set_config('app.current_tenant_id', ...), then dispatches the real query inside that transaction. Every service that needs tenant-scoped data is injected this wrapped client, never the raw one.

Where it doesn't apply, on purpose

Two categories of code intentionally bypass the tenant-scoped extension, and both have to open their own set_config call manually if they touch tenant data at all:

  • Raw SQL. dedup.service.ts and scheduler.service.ts run $queryRaw/$executeRaw directly, which skips the $extends interceptor entirely.
  • Boot-time cross-tenant scans. SchedulerService uses a separate superuser connection (DIRECT_DATABASE_URL) at startup to scan recurring configs across every tenant at once: there is no request context yet to scope a single tenant from, since nothing has been requested.

Every tenant-facing service also calls a tenantId() helper that reads from ClsService and throws NotFoundException('No tenant context') if it's absent. This is a deliberate fail-closed guard, not a formality: an absent tenant context should never silently resolve to "no filter," it should refuse to run.

Testing it

test/rls-isolation.int-spec.ts runs against a live database and is the test to run if you touch anything in this area: it's the thing that actually proves one tenant can't read another's rows, rather than trusting the policy definitions by inspection.

On this page