Files
stack/apps/api/src/common/interceptors/rls-context.interceptor.ts
Jason Woltje 8424a28faa
All checks were successful
ci/woodpecker/push/api Pipeline was successful
fix(auth): use set_config for transaction-scoped RLS context
2026-02-18 23:23:15 -06:00

156 lines
6.0 KiB
TypeScript

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<unknown> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
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"));
});
});
}
}