feat(#351): Implement RLS context interceptor (fix SEC-API-4)
Implements Row-Level Security (RLS) context propagation via NestJS interceptor and AsyncLocalStorage. Core Implementation: - RlsContextInterceptor sets PostgreSQL session variables (app.current_user_id, app.current_workspace_id) within transaction boundaries - Uses SET LOCAL for transaction-scoped variables, preventing connection pool leakage - AsyncLocalStorage propagates transaction-scoped Prisma client to services - Graceful handling of unauthenticated routes - 30-second transaction timeout with 10-second max wait Security Features: - Error sanitization prevents information disclosure to clients - TransactionClient type provides compile-time safety, prevents invalid method calls - Defense-in-depth security layer for RLS policy enforcement Quality Rails Compliance: - Fixed 154 lint errors in llm-usage module (package-level enforcement) - Added proper TypeScript typing for Prisma operations - Resolved all type safety violations Test Coverage: - 19 tests (7 provider + 9 interceptor + 3 integration) - 95.75% overall coverage (100% statements on implementation files) - All tests passing, zero lint errors Documentation: - Comprehensive RLS-CONTEXT-USAGE.md with examples and migration guide Files Created: - apps/api/src/common/interceptors/rls-context.interceptor.ts - apps/api/src/common/interceptors/rls-context.interceptor.spec.ts - apps/api/src/common/interceptors/rls-context.integration.spec.ts - apps/api/src/prisma/rls-context.provider.ts - apps/api/src/prisma/rls-context.provider.spec.ts - apps/api/src/prisma/RLS-CONTEXT-USAGE.md Fixes #351 Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* 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"
|
||||
>;
|
||||
|
||||
/**
|
||||
* AsyncLocalStorage for propagating RLS-scoped Prisma client through the call chain.
|
||||
* This allows the RlsContextInterceptor to set a transaction-scoped client that
|
||||
* services can access via getRlsClient() without explicit dependency injection.
|
||||
*
|
||||
* The RLS client is a Prisma transaction client that has SET LOCAL app.current_user_id
|
||||
* and app.current_workspace_id executed, enabling Row-Level Security policies.
|
||||
*
|
||||
* @see docs/design/credential-security.md for RLS architecture
|
||||
*/
|
||||
const rlsContext = new AsyncLocalStorage<TransactionClient>();
|
||||
|
||||
/**
|
||||
* Gets the current RLS-scoped Prisma client from AsyncLocalStorage.
|
||||
* Returns undefined if no RLS context is set (e.g., unauthenticated routes).
|
||||
*
|
||||
* Services should use this pattern:
|
||||
* ```typescript
|
||||
* const client = getRlsClient() ?? this.prisma;
|
||||
* ```
|
||||
*
|
||||
* This ensures they use the RLS-scoped client when available (for authenticated
|
||||
* requests) and fall back to the standard client otherwise.
|
||||
*
|
||||
* @returns The RLS-scoped Prisma transaction client, or undefined
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @Injectable()
|
||||
* export class TasksService {
|
||||
* constructor(private readonly prisma: PrismaService) {}
|
||||
*
|
||||
* async findAll() {
|
||||
* const client = getRlsClient() ?? this.prisma;
|
||||
* return client.task.findMany(); // Automatically filtered by RLS
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function getRlsClient(): TransactionClient | undefined {
|
||||
return rlsContext.getStore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a function with an RLS-scoped Prisma client available via getRlsClient().
|
||||
* The client is propagated through the call chain using AsyncLocalStorage and is
|
||||
* automatically cleared after the function completes.
|
||||
*
|
||||
* This is used by RlsContextInterceptor to wrap request handlers.
|
||||
*
|
||||
* @param client - The RLS-scoped Prisma transaction client
|
||||
* @param fn - The function to execute with RLS context
|
||||
* @returns The result of the function
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await prisma.$transaction(async (tx) => {
|
||||
* await tx.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
|
||||
*
|
||||
* return runWithRlsClient(tx, async () => {
|
||||
* // getRlsClient() now returns tx
|
||||
* return handler();
|
||||
* });
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function runWithRlsClient<T>(client: TransactionClient, fn: () => T): T {
|
||||
return rlsContext.run(client, fn);
|
||||
}
|
||||
Reference in New Issue
Block a user