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
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;
}
}
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;
}
}
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 });
}
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 });
}
I repeated this pattern for the following controllers (all in the same commit):
apps/api/src/administradoras/administradoras.controller.tsapps/api/src/assemblies/assemblies.controller.tsapps/api/src/condo-announcements/condo-announcements.controller.tsapps/api/src/condo-budgets/condo-budgets.controller.tsapps/api/src/condo-fees/condo-fees.controller.tsapps/api/src/access-control/access-control.controller.tsapps/api/src/calendar/calendar.controller.tsapps/api/src/key-inventory/key-inventory.controller.tsapps/api/src/incidents/incidents.controller.tsapps/api/src/incidents/incidents-export.controller.tsapps/api/src/guest-reviews/guest-reviews.controller.tsapps/api/src/prospects/prospects.controller.tsapps/api/src/protection-civil/protection-civil.controller.tsapps/api/src/processes/processes.controller.tsapps/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();
}
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",
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');
});
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
TenantScopeservice that builds the base query (where organization_id = :orgId) to avoid repetition across services. -
Enforce header presence with a dedicated
OrganizationGuardthat throws a 400 before hitting any controller. -
Add tenant‑aware migrations to enforce
organization_idforeign 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)