CP-04: Multi-Tenant Repository with Row-Level Security
| Field |
Value |
| Pattern ID |
CP-04 |
| Type |
Data Access / Security |
| ADR Reference |
ADR-0010: Multi-Tenancy RLS Strategy |
| Language |
TypeScript / NestJS / TypeORM + PostgreSQL |
| Last Review |
2026-05-15 |
Intent
Enforce tenant data isolation at the database level using Row-Level Security (RLS), preventing any query — regardless of ORM or direct SQL — from returning rows belonging to a different tenant. The application sets the tenant context once per request; the DB enforces isolation automatically.
PostgreSQL RLS Setup
-- Enable RLS on tenant-scoped tables
ALTER TABLE ums.users ENABLE ROW LEVEL SECURITY;
ALTER TABLE ums.roles ENABLE ROW LEVEL SECURITY;
ALTER TABLE ums.permissions ENABLE ROW LEVEL SECURITY;
-- Policy: users can only see their own tenant's rows
CREATE POLICY tenant_isolation ON ums.users
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation ON ums.roles
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- Service account must NOT be superuser (superusers bypass RLS)
-- GRANT SELECT, INSERT, UPDATE, DELETE ON ums.users TO ums_app;
TypeORM Subscriber — Set Tenant Context
// infrastructure/database/subscribers/tenant-context.subscriber.ts
@Injectable()
export class TenantContextSubscriber implements EntitySubscriberInterface {
constructor(
@InjectDataSource() private readonly ds: DataSource,
private readonly tenantContext: TenantContext,
) {
this.ds.subscribers.push(this);
}
async beforeQuery(event: BeforeQueryEvent<unknown>): Promise<void> {
const tenantId = this.tenantContext.getTenantId();
if (tenantId) {
await event.connection.query(
`SELECT set_config('app.current_tenant_id', $1, true)`,
[tenantId],
);
}
}
}
Tenant Context (Request-Scoped)
// infrastructure/context/tenant-context.ts
@Injectable({ scope: Scope.REQUEST })
export class TenantContext {
private _tenantId: string | null = null;
setTenantId(tenantId: string): void {
this._tenantId = tenantId;
}
getTenantId(): string | null {
return this._tenantId;
}
}
JWT Guard — Extract and Set Tenant
// infrastructure/guards/tenant.guard.ts
@Injectable()
export class TenantGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly tenantContext: TenantContext,
) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new UnauthorizedException();
const payload = this.jwtService.verify(token);
if (!payload.tenantId) throw new ForbiddenException('No tenant claim in token.');
this.tenantContext.setTenantId(payload.tenantId);
return true;
}
}
Repository Usage
// infrastructure/database/repositories/typeorm-user.repository.ts
@Injectable()
export class TypeOrmUserRepository implements IUserRepository {
constructor(
@InjectRepository(UserEntity)
private readonly repo: Repository<UserEntity>,
// TenantContext is NOT injected here — RLS enforces isolation at DB level
) {}
async findById(id: string): Promise<User | null> {
// No WHERE tenant_id = ? needed — RLS filters automatically
const entity = await this.repo.findOneBy({ id });
return entity ? UserMapper.toDomain(entity) : null;
}
async findAll(): Promise<User[]> {
// Returns only rows for the current tenant — RLS enforces this
const entities = await this.repo.find();
return entities.map(UserMapper.toDomain);
}
}
Testing RLS in Integration Tests
// test/integration/rls.spec.ts
describe('RLS enforcement', () => {
it('does not leak cross-tenant data', async () => {
// Create users for two tenants
await createUser(ds, { tenantId: 'tenant-A', email: 'a@a.com' });
await createUser(ds, { tenantId: 'tenant-B', email: 'b@b.com' });
// Set context to tenant-A
await ds.query(`SELECT set_config('app.current_tenant_id', 'tenant-A', true)`);
const users = await ds.getRepository(UserEntity).find();
expect(users).toHaveLength(1);
expect(users[0].email).toBe('a@a.com'); // tenant-B row must not appear
});
});
Security Rules
- The DB service account (
ums_app) must not be a superuser — superusers bypass RLS
- The
app.current_tenant_id setting is transaction-local (true as third argument to set_config) — it resets after each transaction
- Admin / migration tasks use a separate migration role (
ums_migrator) with BYPASSRLS privilege
- Every integration test must assert that cross-tenant queries return zero rows
What NOT to Do
// ❌ Manual WHERE tenant_id filter — fragile, easy to forget
const users = await this.repo.find({ where: { tenantId: ctx.getTenantId() } });
// ❌ Superuser connection for application queries — bypasses RLS
// DATABASE_URL=postgres://postgres:pass@db/ums ← postgres is superuser
// ❌ Setting tenant context after queries have started
// TenantContext must be set in guard, before any repository call