File

src/common/interceptors/tenant-context.interceptor.ts

Description

Per-request tenant context interceptor.

For every authenticated request:

  1. Reads request.user.organizationId (set by the JWT auth guard)
  2. Opens a Prisma $transaction (one connection pinned to this request)
  3. Runs SET LOCAL app.current_org_id = '<orgId>' on that connection
  4. Stores the tx client in AsyncLocalStorage so PrismaService routes all model accesses through it
  5. Awaits the route handler
  6. Commits the transaction

Postgres RLS policies on tenant tables filter rows by current_org_id(), giving us mathematical isolation between tenants — even if the application code has a buggy findUnique({where:{id}}) that doesn't filter by org, Postgres will enforce it.

Unauthenticated requests (login, register, health, public endpoints) skip the wrap entirely and run with no tenant context. Tables that have RLS enabled return zero rows in that case, so login/auth flows must use tables without RLS (users, audit_logs, organizations) for their bootstrap queries.

Index

Methods

Constructor

constructor(prisma: PrismaService)
Parameters :
Name Type Optional
prisma PrismaService No

Methods

intercept
intercept(context: ExecutionContext, next: CallHandler)
Parameters :
Name Type Optional
context ExecutionContext No
next CallHandler No
Returns : Observable<any>
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
  Logger,
} from '@nestjs/common';
import { Observable, defer, from, lastValueFrom } from 'rxjs';
import { tenantStorage } from '../../prisma/tenant-context';
import { PrismaService } from '../../prisma/prisma.service';

/**
 * Per-request tenant context interceptor.
 *
 * For every authenticated request:
 * 1. Reads `request.user.organizationId` (set by the JWT auth guard)
 * 2. Opens a Prisma `$transaction` (one connection pinned to this request)
 * 3. Runs `SET LOCAL app.current_org_id = '<orgId>'` on that connection
 * 4. Stores the tx client in AsyncLocalStorage so PrismaService routes
 *    all model accesses through it
 * 5. Awaits the route handler
 * 6. Commits the transaction
 *
 * Postgres RLS policies on tenant tables filter rows by current_org_id(),
 * giving us mathematical isolation between tenants — even if the
 * application code has a buggy `findUnique({where:{id}})` that doesn't
 * filter by org, Postgres will enforce it.
 *
 * Unauthenticated requests (login, register, health, public endpoints)
 * skip the wrap entirely and run with no tenant context. Tables that
 * have RLS enabled return zero rows in that case, so login/auth flows
 * must use tables without RLS (users, audit_logs, organizations) for
 * their bootstrap queries.
 */
@Injectable()
export class TenantContextInterceptor implements NestInterceptor {
  private readonly logger = new Logger(TenantContextInterceptor.name);

  constructor(private readonly prisma: PrismaService) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const req = context.switchToHttp().getRequest();
    const orgId = req?.user?.organizationId;

    // No org context → run without RLS wrapping (login, health, etc.)
    if (!orgId) {
      return next.handle();
    }

    // Validate the org id format defensively. If something downstream sets
    // a malformed orgId via spoofing, fail loudly rather than building
    // SQL with bad input.
    if (!/^[0-9a-f-]{36}$/i.test(orgId)) {
      this.logger.warn(`Rejected request with malformed orgId: ${orgId}`);
      throw new Error('Invalid organization context');
    }

    // Wrap the entire downstream pipeline in a single Prisma transaction.
    // The tx client we get is pinned to one connection, so SET LOCAL
    // applies to all queries that go through it. We pass that tx into
    // AsyncLocalStorage; PrismaService's proxy reads from there.
    return defer(() =>
      from(
        this.prisma.$transaction(
          async (tx) => {
            // Set the org context for this connection. Quote-escape just
            // in case (orgId is already validated as UUID format above).
            await tx.$executeRawUnsafe(
              `SET LOCAL app.current_org_id = '${orgId.replace(/'/g, "''")}'`,
            );
            // Run the entire request handler inside the tx context.
            // Convert the downstream Observable to a Promise so the
            // transaction commits only after the handler completes.
            return tenantStorage.run({ organizationId: orgId, tx: tx as any }, () =>
              lastValueFrom(next.handle()),
            );
          },
          {
            timeout: 30000,
            maxWait: 5000,
          },
        ),
      ),
    );
  }
}

results matching ""

    No results matching ""