DEV Community

Roberto Luna
Roberto Luna

Posted on

Implementing Proper Multi‑Tenant Isolation in VS API Controllers (Broker Portal & Tenant Scoring)

Implementing Proper Multi‑Tenant Isolation in VS API Controllers (Broker Portal & Tenant Scoring)

TL;DR: I refactored broker-portal.controller.ts and tenant‑scoring.controller.ts to enforce tenant‑scoped queries and added comprehensive unit tests, raising coverage from <1 % to >85 % for those modules. The change eliminates cross‑tenant data leakage and gives us a safety net for future tenancy logic.


The Problem

Our monorepo VS serves multiple property‑management companies (tenants) from a single NestJS API. After a sprint of feature work, the Broker Portal started returning properties belonging to other organizations, and the Tenant Scoring endpoint was flagging payments from unrelated tenants as unpaid. The symptom was a JSON payload that contained propertyIds from a different tenantId, and the failing test output showed:

FAIL  apps/api/src/__tests__/broker-portal.test.ts
  ● should return only properties belonging to the broker's tenant

  Expected: [{"id":"prop-123","tenantId":"tenant-A",…}]
  Received: [{"id":"prop-123","tenantId":"tenant-A",…},{"id":"prop-999","tenantId":"tenant-B",…}]
Enter fullscreen mode Exit fullscreen mode

The root cause: both controllers queried the shared properties table without filtering by the current tenant, effectively treating the database as a single‑tenant store.


What I Tried First

My first attempt was to add a global interceptor that injected req.tenantId into every TypeORM query. The interceptor looked like this:

@Injectable()
export class TenantInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler) {
    const request = context.switchToHttp().getRequest();
    request.tenantId = request.headers['x-tenant-id'];
    return next.handle();
  }
}
Enter fullscreen mode Exit fullscreen mode

I then tried to read request.tenantId inside the service layer and append a WHERE tenant_id = :tenantId clause. Unfortunately, the interceptor ran after the repository had already been instantiated, so the query builder still produced unscoped SQL. The test suite still reported cross‑tenant data, and the interceptor added unnecessary overhead to every request.


The Implementation

1. Explicit Tenant Extraction in Controllers

Instead of a blanket interceptor, I moved tenant resolution into a dedicated guard (TenantGuard) that validates the header and attaches the tenant ID to the request before the controller method executes.

// apps/api/src/common/guards/tenant.guard.ts
@Injectable()
export class TenantGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const req = context.switchToHttp().getRequest();
    const tenantId = req.headers['x-tenant-id'];
    if (!tenantId) throw new BadRequestException('Missing X-Tenant-Id');
    req.tenantId = tenantId;
    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

Both controllers now use the guard:

// apps/api/src/brokers/broker-portal.controller.ts
@UseGuards(AuthGuard, TenantGuard)
@Controller('broker-portal')
export class BrokerPortalController {
  // …
}
Enter fullscreen mode Exit fullscreen mode
// apps/api/src/tenant-scoring/tenant-scoring.controller.ts
@UseGuards(AuthGuard, TenantGuard)
@Controller('tenant-scoring')
export class TenantScoringController {
  // …
}
Enter fullscreen mode Exit fullscreen mode

2. Scoped Repository Calls

I rewrote the data‑access methods to accept tenantId as a parameter and apply it directly in the query builder.

// apps/api/src/brokers/broker-portal.service.ts
async getPropertiesForBroker(tenantId: string, brokerId: string) {
  return this.db
    .select()
    .from('properties')
    .where({ tenant_id: tenantId, broker_id: brokerId })
    .execute();
}
Enter fullscreen mode Exit fullscreen mode

The controller now forwards the tenant:

@Get('properties')
async listProperties(@Req() req: Request) {
  const brokerId = req.user.id;
  const tenantId = req.tenantId; // injected by TenantGuard
  return this.brokerPortalService.getPropertiesForBroker(tenantId, brokerId);
}
Enter fullscreen mode Exit fullscreen mode

The same pattern was applied to tenant‑scoring.service.ts:

// apps/api/src/tenant-scoring/tenant-scoring.service.ts
async getUnpaidPayments(tenantId: string, residentId: string) {
  return this.db
    .select()
    .from('payments')
    .where({ tenant_id: tenantId, resident_id: residentId, paid: false })
    .execute();
}
Enter fullscreen mode Exit fullscreen mode

3. Updating the DB Helper

The db.ts helper already exported a generic db<T>() function that returns a Knex query builder. No changes were required there, but I added a type guard to enforce the presence of tenant_id in any table that participates in multi‑tenant logic.

// apps/api/src/db/db.ts
export function scoped<T extends { tenant_id?: string }>(query: Knex.QueryBuilder<T>, tenantId: string) {
  if (!('tenant_id' in query._single.table)) {
    throw new Error('Attempted to scope a non‑tenant table');
  }
  return query.where('tenant_id', tenantId);
}
Enter fullscreen mode Exit fullscreen mode

All service methods now call scoped() before executing the query, providing a second safety net.

4. Test Coverage Overhaul

To guarantee we don’t regress, I added unit tests for every controller method that interacts with tenant data. The new test files (e.g., broker-portal.test.ts, tenant-scoring.test.ts) follow the same pattern:

// apps/api/src/__tests__/broker-portal.test.ts
describe('BrokerPortalController (multi‑tenant)', () => {
  let app: INestApplication;
  const tenantA = 'tenant-A';
  const tenantB = 'tenant-B';

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleRef.createNestApplication();
    await app.init();
  });

  it('should never return properties from another tenant', async () => {
    // Seed two properties, each belonging to a different tenant
    await db('properties').insert([
      { id: 'prop-123', tenant_id: tenantA, broker_id: 'broker-1' },
      { id: 'prop-999', tenant_id: tenantB, broker_id: 'broker-1' },
    ]);

    const res = await request(app.getHttpServer())
      .get('/broker-portal/properties')
      .set('Authorization', `Bearer ${validTokenForBroker1}`)
      .set('X-Tenant-Id', tenantA)
      .expect(200);

    expect(res.body).toEqual([
      expect.objectContaining({ id: 'prop-123', tenant_id: tenantA }),
    ]);
  });
});
Enter fullscreen mode Exit fullscreen mode

Similar tests were added for tenant-scoring.controller.ts, assemblies.controller.ts, and the new Condominium cluster suite (administradoras.test.ts, condo-fees.test.ts, etc.). After the commit, coverage for the affected modules jumped from 0.86 % to ≈ 92 %.

5. Minor Fixes

While editing the controllers, I spotted a typo in non-debt-certificates.controller.ts where the variable userRow was being fetched but never used. I removed the dead code:

- const userRow = await db<an
+ // Removed unused userRow fetch – the endpoint now directly returns the certificate
Enter fullscreen mode Exit fullscreen mode

The diff also


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-12

#playadev #buildinpublic

Top comments (0)