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

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

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

Then in the controller:

@Get('leads')
async getLeads(@Query() query: GetLeadsDto) {
  return this.biService.findLeads(query);
}
Enter fullscreen mode Exit fullscreen mode

The service simply spread the DTO into the repository find call:

async findLeads(dto: GetLeadsDto) {
  return this.leadRepo.find({ where: { ...dto } });
}
Enter fullscreen mode Exit fullscreen mode

What went wrong?

  • The organization_id was 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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)