Implementing Organization‑Scoped Multi‑Tenancy in a NestJS API (Phase 4, Block 4)
TL;DR: Added organization_id scoping to all public endpoints (BI, Call‑Center, Reports, Search, Priority‑Actions, etc.) and fixed cross‑tenant leaks. The change required schema migration, request‑context propagation, and a robust DB pool error handler.
The Problem
Our monolithic NestJS API started to serve multiple SaaS customers from the same database. The first sign of trouble was a cross‑tenant data leak:
ERROR [NotificationsService] notifyAdmins() sent email to admins of ALL organizations
Admins of Org A were receiving notifications about events that happened in Org B.
The root cause was two‑fold:
-
Missing
organization_idcolumn on several tables (e.g.,audit_log) and no index to filter efficiently. -
No request‑level tenant context – controllers built queries without restricting by
organization_id.
Additionally, the DB connection pool had no error listener, causing the whole process to crash on transient network errors.
What I Tried First
My initial attempt was to add a global NestJS guard (TenantGuard) that read a X-Org-Id header and injected it into the request object. I then patched a few services to read req.organizationId and added WHERE organization_id = :orgId to the query builder.
// early version of the guard
@Injectable()
export class TenantGuard implements CanActivate {
canActivate(context: ExecutionContext) {
const req = context.switchToHttp().getRequest();
req.organizationId = req.headers['x-org-id'];
return true;
}
}
Problems with this approach:
- The guard was applied only to routes that used
@UseGuards(TenantGuard). Many controllers (e.g.,BiController,CallCenterController) were missed, leaving them unprotected. - Some services built raw SQL strings (
query('SELECT * FROM ...')) that ignored the request object entirely. - The guard added overhead to every request and made unit testing harder because the header had to be manually set.
The result was partial isolation – a few endpoints were safe, but the majority still leaked data.
The Implementation
1. Schema Migration
First I added the missing organization_id column to the audit_log table and created a covering index. This lives in apps/api/src/db/db.ts where we execute raw DDL on startup.
// apps/api/src/db/db.ts (excerpt)
await db.query(`
CREATE TABLE IF NOT EXISTS audit_log (
id UUID PRIMARY KEY,
action TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
organization_id UUID REFERENCES organizations(id)
);
`);
await db.query(`
CREATE INDEX IF NOT EXISTS idx_audit_log_org
ON audit_log (organization_id);
`);
The migration is idempotent (IF NOT EXISTS) so it can run on every deployment without breaking existing schemas.
2. Central Tenant Context
Instead of a guard, I introduced a custom decorator and interceptor that extracts the tenant ID once per request and stores it in a request‑scoped AsyncLocalStorage. This guarantees the value is available to any service, even deep inside utility functions.
// apps/api/src/common/tenant.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const TenantId = createParamDecorator(
(data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest();
return request.headers['x-org-id'] as string;
},
);
// apps/api/src/common/tenant.interceptor.ts
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { AsyncLocalStorage } from 'async_hooks';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
export const tenantStorage = new AsyncLocalStorage<Map<string, any>>();
@Injectable()
export class TenantInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const store = new Map<string, any>();
store.set('organizationId', request.headers['x-org-id']);
return tenantStorage.run(store, () => next.handle());
}
}
We register the interceptor globally in main.ts:
// apps/api/src/main.ts
import { TenantInterceptor } from './common/tenant.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new TenantInterceptor());
await app.listen(3000);
}
bootstrap();
Now any service can retrieve the current tenant without touching the request object:
// apps/api/src/common/tenant.helper.ts
import { tenantStorage } from './tenant.interceptor';
export function getCurrentOrgId(): string {
const store = tenantStorage.getStore();
return store?.get('organizationId') ?? '';
}
3. Scoping Controllers
All public controllers were updated to include the tenant filter. Below are the most representative changes.
bi.controller.ts
@@ -8,7 +8,7 @@
-import { Controller, Get, UseGu
+import { Controller, Get, UseGuards, Req } from '@nestjs/common';
import { query } from '../db/db.js';
@Controller('bi')
export class BiController {
@Get('summary')
- async getSummary(@Query() q: any) {
+ async getSummary(@Req() req: any, @Query() q: any) {
const orgId = req.headers['x-org-id'];
const sql = `
SELECT *
- FROM bi_summary
- WHERE ${conditions}
+ FROM bi_summary
+ WHERE organization_id = $1 AND ${conditions}
`;
- return query(sql, [...params]);
+ return query(sql, [orgId, ...params]);
}
}
call-center.controller.ts
diff
@@ -24,9 +24,9 @@ export class CallCenterController {
const ps = Math.min(100, parseInt(pageSize) || 20);
const offset = (p - 1) * ps;
- const conditions: string[] = ["cl.id IS NOT NULL
+ const orgId = req.headers['x-org-id'];
+ const conditions: string[] = ["cl.organization_id = $1", "cl.id IS NOT NULL"];
// …
- const rows = await query(`
- SELECT * FROM call_center cl
- WHERE ${conditions.join(' AND ')}
- LIMIT $2 OFFSET $3
- `, [ps, offset]);
+ const rows = await query(`
+ SELECT * FROM call_center cl
+ WHERE ${conditions.join(' AND ')}
+ LIMIT $2 OFFSET $3
+ `, [orgId,
---
*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building Building PlayaMXCRM from Playa del Carmen, México.*
*Repo: `zaerohell/VS` · 2026-09-09*
\#playadev #buildinpublic
Top comments (1)
notifyAdmins()emailing admins across every organization makes notification recipients part of the isolation boundary too. The examples still readX-Org-Iddirectly, so I'd make the authenticated user's membership check explicit before that value enters AsyncLocalStorage or a query. One regression test I'd keep is an Org A user sending Org B's header, asserting both that access is denied and that no notification is sent. Filtering correctly for a supplied organization only helps once the caller is allowed to select it.