import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger, InternalServerErrorException, } from "@nestjs/common"; import { Observable } from "rxjs"; import { finalize } from "rxjs/operators"; import type { PrismaClient } from "@prisma/client"; import { PrismaService } from "../../prisma/prisma.service"; import { runWithRlsClient } from "../../prisma/rls-context.provider"; import type { AuthenticatedRequest } from "../types/user.types"; /** * Transaction-safe Prisma client type that excludes methods not available on transaction clients. * This prevents services from accidentally calling $connect, $disconnect, $transaction, etc. * on a transaction client, which would cause runtime errors. */ export type TransactionClient = Omit< PrismaClient, "$connect" | "$disconnect" | "$transaction" | "$on" | "$use" >; /** * RlsContextInterceptor sets Row-Level Security (RLS) session variables for authenticated requests. * * This interceptor runs after AuthGuard and WorkspaceGuard, extracting the authenticated user * and workspace from the request and setting PostgreSQL session variables within a transaction: * - SET LOCAL app.current_user_id = '...' * - SET LOCAL app.current_workspace_id = '...' * * The transaction-scoped Prisma client is then propagated via AsyncLocalStorage, allowing * services to access it via getRlsClient() without explicit dependency injection. * * ## Security Design * * SET LOCAL is used instead of SET to ensure session variables are transaction-scoped. * This is critical for connection pooling safety - without transaction scoping, variables * would leak between requests that reuse the same connection from the pool. * * The entire request handler is executed within the transaction boundary, ensuring all * queries inherit the RLS context. * * ## Usage * * Registered globally as APP_INTERCEPTOR in AppModule (after TelemetryInterceptor). * Services access the RLS client via: * * ```typescript * const client = getRlsClient() ?? this.prisma; * return client.task.findMany(); // Filtered by RLS * ``` * * ## Unauthenticated Routes * * Routes without AuthGuard (public endpoints) will not have request.user set. * The interceptor gracefully handles this by skipping RLS context setup. * * @see docs/design/credential-security.md for RLS architecture */ @Injectable() export class RlsContextInterceptor implements NestInterceptor { private readonly logger = new Logger(RlsContextInterceptor.name); // Transaction timeout configuration // Longer timeout to support file uploads, complex queries, and bulk operations private readonly TRANSACTION_TIMEOUT_MS = 30000; // 30 seconds private readonly TRANSACTION_MAX_WAIT_MS = 10000; // 10 seconds to acquire connection constructor(private readonly prisma: PrismaService) {} /** * Intercept HTTP requests and set RLS context if user is authenticated. * * @param context - The execution context * @param next - The next call handler * @returns Observable of the response with RLS context applied */ intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); const user = request.user; // Skip RLS context setup for unauthenticated requests if (!user?.id) { this.logger.debug("Skipping RLS context: no authenticated user"); return next.handle(); } const userId = user.id; const workspaceId = request.workspace?.id ?? user.workspaceId; this.logger.debug( `Setting RLS context: user=${userId}${workspaceId ? `, workspace=${workspaceId}` : ""}` ); // Execute the entire request within a transaction with RLS context set return new Observable((subscriber) => { this.prisma .$transaction( async (tx) => { // Use set_config(..., true) so values are transaction-local and parameterized safely. // Direct SET LOCAL with bind parameters produces invalid SQL on PostgreSQL. await tx.$executeRaw`SELECT set_config('app.current_user_id', ${userId}, true)`; if (workspaceId) { await tx.$executeRaw`SELECT set_config('app.current_workspace_id', ${workspaceId}, true)`; } // Propagate the transaction client via AsyncLocalStorage // This allows services to access it via getRlsClient() // Use TransactionClient type to maintain type safety return runWithRlsClient(tx as TransactionClient, () => { return new Promise((resolve, reject) => { next .handle() .pipe( finalize(() => { this.logger.debug("RLS context cleared"); }) ) .subscribe({ next: (value) => { subscriber.next(value); resolve(value); }, error: (error: unknown) => { const err = error instanceof Error ? error : new Error(String(error)); subscriber.error(err); reject(err); }, complete: () => { subscriber.complete(); resolve(undefined); }, }); }); }); }, { timeout: this.TRANSACTION_TIMEOUT_MS, maxWait: this.TRANSACTION_MAX_WAIT_MS, } ) .catch((error: unknown) => { const err = error instanceof Error ? error : new Error(String(error)); this.logger.error(`Failed to set RLS context: ${err.message}`, err.stack); // Sanitize error before sending to client to prevent information disclosure // (schema info, internal variable names, connection details, etc.) subscriber.error(new InternalServerErrorException("Request processing failed")); }); }); } }