DEV Community

Roberto Luna
Roberto Luna

Posted on

Implementing Organization‑Scoped Multi‑Tenancy in a NestJS API (Phase 4, Block 4)

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
Enter fullscreen mode Exit fullscreen mode

Admins of Org A were receiving notifications about events that happened in Org B.

The root cause was two‑fold:

  1. Missing organization_id column on several tables (e.g., audit_log) and no index to filter efficiently.
  2. 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
`);
Enter fullscreen mode Exit fullscreen mode

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;
  },
);
Enter fullscreen mode Exit fullscreen mode
// 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());
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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') ?? '';
}
Enter fullscreen mode Exit fullscreen mode

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]);
   }
 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
marcusykim profile image
Marcus Kim •

notifyAdmins() emailing admins across every organization makes notification recipients part of the isolation boundary too. The examples still read X-Org-Id directly, 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.