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.