Implementing Organization‑Scoped Multi‑Tenancy in a NestJS API – Phase 4, Block 4
TL;DR: I added organization_id scoping to the BI and Call‑Center endpoints, fixed a cross‑tenant data leak, and wired the new scope through DTOs, guards, and the TypeORM repository. The change isolates each tenant’s data without touching the existing public routes.
The Problem
Our BI dashboard started returning leads from other organizations. The symptom was a JSON payload like:
{
"leadId": 1024,
"organization_id": 3,
"ownerId": 57,
"name": "Acme Corp"
}
Even though the request came from a user belonging to organization_id = 1. The root cause was that the repository query didn’t filter by organization_id, so the WHERE clause was missing. This broke the promise of multi‑tenancy we announced in Phase 3.
Error logged by NestJS:
[Nest] 12 - 2026/09/09 14:22:31 ERROR [ExceptionHandler]
Cross‑tenant data leak detected in GET /api/bi/leads
What I Tried First
My first attempt was to add the organization_id field to the DTOs and manually pass it from the controller to the service:
// src/modules/bi/dto/get-leads.dto.ts
export class GetLeadsDto {
@IsOptional()
@IsNumber()
organization_id?: number; // added
}
Then in the controller:
@Get('leads')
async getLeads(@Query() query: GetLeadsDto) {
return this.biService.findLeads(query);
}
The service simply spread the DTO into the repository find call:
async findLeads(dto: GetLeadsDto) {
return this.leadRepo.find({ where: { ...dto } });
}
What went wrong?
- The
organization_idwas optional, so callers could omit it and still get data. - The controller relied on the client to send the correct ID; a malicious user could spoof any tenant.
- The same pattern had to be duplicated across every module (Call‑Center, Reporting, etc.), leading to boilerplate and easy mistakes.
The Implementation
1. Centralizing the tenant extraction
I introduced an OrganizationScopeGuard that reads the JWT payload (already contains orgId) and injects it into the request context.
// src/common/guards/organization-scope.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
@Injectable()
export class OrganizationScopeGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user; // set by JwtAuthGuard
if (!user?.orgId) {
return false;
}
// attach orgId to request for later use
request.organizationId = user.orgId;
return true;
}
}
The guard is applied globally in main.ts:
// src/main.ts
import { OrganizationScopeGuard } from './common/guards/organization-scope.guard';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new OrganizationScopeGuard(new Reflector()));
await app.listen(3000);
}
bootstrap();
2. Removing organization_id from public DTOs
Now the DTOs no longer expose organization_id. They stay focused on business filters.
// src/modules/bi/dto/get-leads.dto.ts
export class GetLeadsDto {
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsDateString()
fromDate?: string;
}
3. Repository wrapper that always scopes by tenant
I created a TenantAwareRepository extending TypeORM’s Repository. It automatically adds organization_id to every find* operation.
// src/common/repositories/tenant-aware.repository.ts
import { Repository, SelectQueryBuilder } from 'typeorm';
import { Injectable, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
@Injectable({ scope: Scope.REQUEST })
export class TenantAwareRepository<T> extends Repository<T> {
constructor(@Inject(REQUEST) private readonly request: Request) {
super();
}
private get orgId(): number {
return this.request.organizationId;
}
createQueryBuilder(alias?: string): SelectQueryBuilder<T> {
const qb = super.createQueryBuilder(alias);
return qb.andWhere(`${alias}.organization_id = :orgId`, { orgId: this.orgId });
}
// other shortcuts (find, findOne, etc.) delegate to the scoped qb
}
All module repositories now extend this class:
// src/modules/bi/lead.repository.ts
import { EntityRepository } from 'typeorm';
import { TenantAwareRepository } from '../../common/repositories/tenant-aware.repository';
import { Lead } from './lead.entity';
@EntityRepository(Lead)
export class LeadRepository extends TenantAwareRepository<Lead> {}
4. Updating the service to use the new repository
No more manual organization_id handling; the repository guarantees isolation.
// src/modules/bi/bi.service.ts
@Injectable()
export class BiService {
constructor(private readonly leadRepo: LeadRepository) {}
async findLeads(filter: GetLeadsDto) {
const qb = this.leadRepo.createQueryBuilder('lead');
if (filter.status) {
qb.andWhere('lead.status = :status', { status: filter.status });
}
if (filter.fromDate) {
qb.andWhere('lead.created_at >= :from', { from: filter.fromDate });
}
return qb.getMany();
}
}
5. Adding a unit test that proves the tenant boundary
// test/bi/bi.service.spec.ts
it('should never return leads from another organization', async () => {
const mockReq = { organizationId: 1 } as any;
const service = new BiService(new LeadRepository(mockReq as any));
const leads = await service.findLeads({});
expect(leads.every(l => l.organization_id === 1)).toBe(true);
});
6. Updating the automation script
The content‑automation repo now generates a placeholder markdown entry for each sprint day. The commit diff shows the new entry in content/2026/09/09/VS/changelog.md:
+## 2026-09-09 VS
+
+### Added
+- **Multi‑tenancy scope** for BI and Call‑Center APIs: ahora los endpoints usan `organization_id` para filtrar los datos.
While not code, this step ensures the weekly dev‑log stays in
Part of my Build in Public series — sharing the real process of building SaaS projects from Playa del Carmen, México.
Repo: zaerohell/content-automation · 2026-09-10
#playadev #buildinpublic
Top comments (0)