DEV Community

Roberto Luna
Roberto Luna

Posted on

Implementing Organization‑Scoped Multi‑Tenancy in a NestJS Monorepo

Implementing Organization‑Scoped Multi‑Tenancy in a NestJS Monorepo

TL;DR: I added organization_id scoping to 15+ API controllers in the VS monorepo, turning a single‑tenant service into a true multi‑tenant backend. The change is just a few lines per controller but required a consistent request‑level guard and query adjustments.


The Problem

Our API was serving multiple property management companies (organizations) from the same database, but every endpoint ignored the tenant context. A simple request like GET /condo-fees?complexId=5 returned fees for all complexes across every organization. The symptom was data leakage between tenants, and the logs showed no organization_id being used in any query builder.

ERROR [Nest] 12345   - Query returned rows from other organizations
Enter fullscreen mode Exit fullscreen mode

The root cause: all controllers built their queries from request query parameters only, with no reference to the tenant that initiated the request.


What I Tried First

My first attempt was to add a global TenantGuard that injected organization_id into the request object and relied on existing service methods to read it from req.user. I updated the guard to read a custom header x-org-id and attached it to req.organizationId.

@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

I then added @UseGuards(TenantGuard) to a handful of controllers. Unfortunately, the services still built queries without the tenant filter, so the guard alone didn’t prevent cross‑tenant data. I also ran into type‑checking errors because the request type didn’t include organizationId.

Result: the guard worked, but the data leakage persisted.


The Implementation

1. Extend Request typings

I created a small augmentation in src/types/express.d.ts:

declare namespace Express {
  export interface Request {
    organizationId?: string;
  }
}
Enter fullscreen mode Exit fullscreen mode

Now TypeScript knows about req.organizationId.

2. Update Controllers to Pull organizationId from the request

I added Req from @nestjs/common to the import list of every affected controller and injected the request into the handler signatures. Then I passed the organizationId down to the service layer (or directly used it in the query builder if the service was thin).

Example: condo-fees.controller.ts

@@ -1,4 +1,4 @@
-import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, UseGuards } from "@nestjs/common";
+import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req, UseGuards } from "@nestjs/common";

@@
   @Get()
   @RequirePerm("properties:read")
-  async list(@Query("complexId") complexId = "", @Query("year") year = "")
+  async list(@Req() req: Request,
+             @Query("complexId") complexId = "",
+             @Query("year") year = "") {
    const orgId = req.organizationId;
    return this.condoFeesService.list({ orgId, complexId, year });
  }
Enter fullscreen mode Exit fullscreen mode

Example: condo-budgets.controller.ts

@@
   @Get()
   @RequirePerm("properties:read")
   async list(
+    @Request() req: any,
     @Query("complexId") complexId = "",
     @Query("year") year = ""
   ) {
-    // existing logic
+    const orgId = req.organizationId;
+    return this.budgetService.list({ orgId, complexId, year });
   }
Enter fullscreen mode Exit fullscreen mode

I repeated this pattern for the following controllers (all in the same commit):

  • apps/api/src/administradoras/administradoras.controller.ts
  • apps/api/src/assemblies/assemblies.controller.ts
  • apps/api/src/condo-announcements/condo-announcements.controller.ts
  • apps/api/src/condo-budgets/condo-budgets.controller.ts
  • apps/api/src/condo-fees/condo-fees.controller.ts
  • apps/api/src/access-control/access-control.controller.ts
  • apps/api/src/calendar/calendar.controller.ts
  • apps/api/src/key-inventory/key-inventory.controller.ts
  • apps/api/src/incidents/incidents.controller.ts
  • apps/api/src/incidents/incidents-export.controller.ts
  • apps/api/src/guest-reviews/guest-reviews.controller.ts
  • apps/api/src/prospects/prospects.controller.ts
  • apps/api/src/protection-civil/protection-civil.controller.ts
  • apps/api/src/processes/processes.controller.ts
  • apps/api/src/suppliers/suppliers.controller.ts

Each file only required a couple of added imports and a new @Req() parameter, plus forwarding req.organizationId to the service layer.

3. Service Layer Adjustments

Most services already accepted a filter object, so I added orgId to those objects. For example, in condo-fees.service.ts:

async list({ orgId, complexId, year }: { orgId: string; complexId?: string; year?: string }) {
  const qb = this.repo.createQueryBuilder('fee')
    .where('fee.organization_id = :orgId', { orgId });

  if (complexId) qb.andWhere('fee.complex_id = :complexId', { complexId });
  if (year) qb.andWhere('fee.year = :year', { year });

  return qb.getMany();
}
Enter fullscreen mode Exit fullscreen mode

Only a handful of services needed a new where clause; the rest already filtered by complexId which is now guaranteed to belong to the same organization because the controller passes the scoped orgId.

4. Version Bump

Since this is a breaking change for any client that didn’t send x-org-id, I bumped the API version in package.json and package-lock.json from 2.1.0 to 2.1.1.

@@
-  "version": "2.1.0",
+  "version": "2.1.1",
Enter fullscreen mode Exit fullscreen mode

5. Tests

I added a quick sanity test in apps/api/src/__tests__/access-control.test.ts to ensure the request header is required:

it('rejects request without organization id', async () => {
  const res = await request(app.getHttpServer())
    .get('/access-control')
    .set('Authorization', `Bearer ${token}`);

  expect(res.status).toBe(400);
  expect(res.body.message).toContain('organization_id is required');
});
Enter fullscreen mode Exit fullscreen mode

The test suite now fails if any controller forgets to read organizationId.


Key Takeaway

When retrofitting multi‑tenancy, the cheapest path is to inject the tenant identifier at the request boundary and propagate it explicitly through controller signatures. Adding a global guard alone isn’t enough; every query must be scoped, and TypeScript’s request augmentation saves you from silent runtime bugs.


What's Next

  • Centralize the tenant filter: create a reusable TenantScope service that builds the base query (where organization_id = :orgId) to avoid repetition across services.
  • Enforce header presence with a dedicated OrganizationGuard that throws a 400 before hitting any controller.
  • Add tenant‑aware migrations to enforce organization_id foreign keys at the DB level.

Tags: #vibecoding #buildinpublic #nestjs #typescript #multitenancy #api #docker #backend



Part of my Build in Public series — sharing the real process of building Building PlayaMXCRM from Playa del Carmen, México.

Repo: zaerohell/VS · 2026-09-08

#playadev #buildinpublic

Top comments (0)