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",…}]
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();
}
}
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;
}
}
Both controllers now use the guard:
// apps/api/src/brokers/broker-portal.controller.ts
@UseGuards(AuthGuard, TenantGuard)
@Controller('broker-portal')
export class BrokerPortalController {
// …
}
// apps/api/src/tenant-scoring/tenant-scoring.controller.ts
@UseGuards(AuthGuard, TenantGuard)
@Controller('tenant-scoring')
export class TenantScoringController {
// …
}
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();
}
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);
}
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();
}
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);
}
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 }),
]);
});
});
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
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)