src/common/interceptors/tenant-header.interceptor.ts
TenantHeaderInterceptor (v119 — defense-in-depth for multi-tenant subdomains).
Why an interceptor, not a guard?
Global guards run BEFORE controller-level guards in NestJS. JwtAuthGuard
is applied per-controller with @UseGuards, so if we used a global guard
here req.user wouldn't be set yet. Interceptors run AFTER all guards,
so by the time this runs, the JWT strategy has populated req.user.
This interceptor must run BEFORE TenantContextInterceptor so that if the header/JWT mismatch triggers a 403, we never open the RLS transaction.
Behavior:
X-Tenant header (case-insensitive) from the request.req.user (public endpoint like /auth/login,
/health) → allow. The JWT isn't validated for these routes; we
can't match against a user that doesn't exist.req.user present:Postgres RLS remains the immovable security boundary regardless of what this interceptor does.
Methods |
constructor(tenantLookup: TenantLookupService)
|
||||||
|
Parameters :
|
| intercept | |||||||||
intercept(context: ExecutionContext, next: CallHandler)
|
|||||||||
|
Parameters :
Returns :
Observable<any>
|
import {
CallHandler,
ExecutionContext,
ForbiddenException,
Injectable,
Logger,
NestInterceptor,
} from '@nestjs/common';
import { Observable, from, switchMap } from 'rxjs';
import { TenantLookupService } from '../services/tenant-lookup.service';
/**
* TenantHeaderInterceptor (v119 — defense-in-depth for multi-tenant subdomains).
*
* Why an interceptor, not a guard?
* Global guards run BEFORE controller-level guards in NestJS. JwtAuthGuard
* is applied per-controller with `@UseGuards`, so if we used a global guard
* here `req.user` wouldn't be set yet. Interceptors run AFTER all guards,
* so by the time this runs, the JWT strategy has populated `req.user`.
*
* This interceptor must run BEFORE TenantContextInterceptor so that if the
* header/JWT mismatch triggers a 403, we never open the RLS transaction.
*
* Behavior:
* 1. Reads `X-Tenant` header (case-insensitive) from the request.
* 2. No header → allow (backwards compat for legacy clients).
* 3. Header present + no `req.user` (public endpoint like /auth/login,
* /health) → allow. The JWT isn't validated for these routes; we
* can't match against a user that doesn't exist.
* 4. Header present + `req.user` present:
* - Look up org by subdomain.
* - Not found → 403 "Unknown tenant".
* - Found but orgId != user.organizationId → 403 "Tenant mismatch".
* - Otherwise allow.
*
* Postgres RLS remains the immovable security boundary regardless of
* what this interceptor does.
*/
@Injectable()
export class TenantHeaderInterceptor implements NestInterceptor {
private readonly logger = new Logger(TenantHeaderInterceptor.name);
constructor(private readonly tenantLookup: TenantLookupService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest();
const raw =
req.headers['x-tenant'] ||
req.headers['X-Tenant'] ||
req.headers['x-Tenant'];
const tenantHeader = Array.isArray(raw) ? raw[0] : raw;
// No header → backwards compat path.
if (!tenantHeader || typeof tenantHeader !== 'string') {
return next.handle();
}
const subdomain = tenantHeader.toLowerCase().trim();
// Sanity: alphanumeric + hyphens, <= 63 chars.
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(subdomain)) {
throw new ForbiddenException('Invalid tenant identifier');
}
// Unauthenticated request → allow. Login, health, etc.
if (!req.user?.organizationId) {
return next.handle();
}
return from(this.tenantLookup.getOrgIdBySubdomain(subdomain)).pipe(
switchMap((orgId) => {
if (!orgId) {
this.logger.warn(
`Unknown tenant subdomain in X-Tenant header: ${subdomain}`,
);
throw new ForbiddenException('Unknown tenant');
}
if (orgId !== req.user.organizationId) {
this.logger.warn(
`Tenant mismatch: X-Tenant=${subdomain} (${orgId}) vs JWT org ${req.user.organizationId}`,
);
throw new ForbiddenException(
'Tenant mismatch — this account belongs to a different organization',
);
}
return next.handle();
}),
);
}
}