DEV Community

Roberto Luna
Roberto Luna

Posted on

Implementing Multi‑Tenancy Scoping Across the VS API – Phase 4 Code Walkthrough

Implementing Multi‑Tenancy Scoping Across the VS API – Phase 4 Code Walkthrough

TL;DR: I finished Phase 4 of the multi‑tenancy migration by centralizing organization scoping in a guard and a service, then wiring every controller and repository to use it. The change eliminates accidental data leaks and makes future tenant‑specific features a one‑line addition.


The Problem

Our VS API started as a single‑tenant monolith. When we began splitting the product for multiple organizations, the first three phases added an organization_id column to a handful of tables and patched a few endpoints. By the end of Phase 3, the API still had silent leaks: some GET /stats and POST /share-links routes ignored the tenant context, returning data from other customers. The symptom showed up in the logs as:

ERROR  [TenantGuard] Missing organizationId in request context
Enter fullscreen mode Exit fullscreen mode

and, more importantly, a client reported seeing another company’s construction projects in their dashboard. The root cause was that tenancy checks were scattered, duplicated, and easy to forget.


What I Tried First

My first instinct was to sprinkle organization_id checks directly inside each controller method:

// apps/api/src/construction/construction.controller.ts (initial attempt)
@Get()
async findAll(@Req() req: Request) {
  const orgId = req.headers['x-org-id'];
  return this.constructionService.findAll(orgId);
}
Enter fullscreen mode Exit fullscreen mode

I added the header extraction to a few controllers, updated the service signatures, and ran the test suite. It worked for those endpoints, but I quickly ran into two problems:

  1. Missing in many places – I forgot to add the guard to stats.controller.ts and share-links.controller.ts. The API still exposed cross‑tenant data.
  2. Boilerplate explosion – Every method now required a orgId argument, making the code noisy and error‑prone.

I also tried a global interceptor that injected organizationId into the request object based on the JWT payload. The interceptor ran before routing, but it broke pagination because the interceptor mutated the query parameters after NestJS had already parsed them, leading to TypeError: Cannot read property 'skip' of undefined.

Both approaches failed to give me a reliable, maintainable solution.


The Implementation

1. Central guard – OrganizationGuard

I created a dedicated guard that extracts the tenant identifier from the JWT (or the x-org-id header for internal services) and stores it in the request’s locals object. The guard is applied globally in the AppModule, guaranteeing that every request carries a validated organizationId.

// apps/api/src/common/guards/organization.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class OrganizationGuard implements CanActivate {
  constructor(private readonly jwtService: JwtService) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const authHeader = request.headers.authorization;

    if (!authHeader) {
      throw new UnauthorizedException('Missing Authorization header');
    }

    const token = authHeader.split(' ')[1];
    const payload = this.jwtService.verify(token);

    const orgId = payload.organizationId ?? request.headers['x-org-id'];
    if (!orgId) {
      throw new UnauthorizedException('Organization ID not found in token or header');
    }

    // Store for downstream services
    request.locals = { ...request.locals, organizationId: orgId };
    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

Why a guard?

Guards run before route handlers, can abort the request early, and integrate cleanly with NestJS’s built‑in authentication flow. By placing the tenant extraction here, we avoid repeating the same logic in every controller.

2. Scoping service – OrganizationScopeService

To keep the guard thin, I introduced a service that provides the current tenant ID and a helper to build scoped where clauses for TypeORM queries.

// apps/api/src/common/services/organization-scope.service.ts
import { Injectable, Scope } from '@nestjs/common';
import { Request } from 'express';
import { REQUEST } from '@nestjs/core';

@Injectable({ scope: Scope.REQUEST })
export class OrganizationScopeService {
  constructor(@Inject(REQUEST) private readonly request: Request) {}

  get organizationId(): string {
    return this.request.locals?.organizationId;
  }

  /** Returns a TypeORM-compatible filter object */
  scope<T extends { organizationId?: string }>(extra?: Partial<T>) {
    return { organizationId: this.organizationId, ...extra } as T;
  }
}
Enter fullscreen mode Exit fullscreen mode

Because the service is request‑scoped, each HTTP request gets its own instance, and we can safely read request.locals without worrying about cross‑request contamination.

3. Updating Controllers

All controllers now inject OrganizationScopeService and delegate tenant filtering to the service. Below is the final version of the construction controller that was added in Phase 4 (see the diff entry apps/api/src/construction/construction.controller.ts).


ts
// apps/api/src/construction/construction.controller.ts
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { ConstructionService } from './construction.service';
import { OrganizationScopeService } from '../../common/services/organization-scope.service

---

*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building SaaS projects from Playa del Carmen, México.*

*Repo: `zaerohell/content-automation` · 2026-09-11*

\#playadev #buildinpublic
Enter fullscreen mode Exit fullscreen mode

Top comments (0)