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 <noreply@anthropic.com>
5.6 KiB
RLS Context Usage Guide
This guide explains how to use the RLS (Row-Level Security) context system in services.
Overview
The RLS context system automatically sets PostgreSQL session variables for authenticated requests:
app.current_user_id- Set from the authenticated userapp.current_workspace_id- Set from the workspace context (if present)
These session variables enable PostgreSQL RLS policies to automatically filter queries based on user permissions.
How It Works
- RlsContextInterceptor runs after AuthGuard and WorkspaceGuard
- It wraps the request in a Prisma transaction (30s timeout, 10s max wait for connection)
- Inside the transaction, it executes
SET LOCALto set session variables - The transaction client is propagated via AsyncLocalStorage
- Services access it using
getRlsClient()
Transaction Timeout
The interceptor configures a 30-second transaction timeout and 10-second max wait for connection acquisition. This supports:
- File uploads
- Complex queries with joins
- Bulk operations
- Report generation
If you need longer-running operations, consider moving them to background jobs instead of synchronous HTTP requests.
Usage in Services
Basic Pattern
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { getRlsClient } from "../prisma/rls-context.provider";
@Injectable()
export class TasksService {
constructor(private readonly prisma: PrismaService) {}
async findAll(workspaceId: string) {
// Use RLS client if available, otherwise fall back to standard client
const client = getRlsClient() ?? this.prisma;
// This query is automatically filtered by RLS policies
return client.task.findMany({
where: { workspaceId },
});
}
}
Why Use This Pattern?
With RLS context:
- Queries are automatically filtered by user/workspace permissions
- Defense in depth: Even if application logic fails, database RLS enforces security
- No need to manually add
whereclauses for user/workspace filtering
Fallback to standard client:
- Supports unauthenticated routes (public endpoints)
- Supports system operations that need full database access
- Graceful degradation if RLS context isn't set
Advanced: Explicit Transaction Control
For operations that need multiple queries in a single transaction:
async createWithRelations(workspaceId: string, data: CreateTaskDto) {
const client = getRlsClient() ?? this.prisma;
// If using RLS client, we're already in a transaction
// If not, we need to create one
if (getRlsClient()) {
// Already in a transaction with RLS context
return this.performCreate(client, data);
} else {
// Need to manually wrap in transaction
return this.prisma.$transaction(async (tx) => {
return this.performCreate(tx, data);
});
}
}
private async performCreate(client: PrismaClient, data: CreateTaskDto) {
const task = await client.task.create({ data });
await client.activity.create({
data: {
type: "TASK_CREATED",
taskId: task.id,
},
});
return task;
}
Unauthenticated Routes
For public endpoints (no AuthGuard), getRlsClient() returns undefined:
@Get("public/stats")
async getPublicStats() {
// No RLS context - uses standard Prisma client
const client = getRlsClient() ?? this.prisma;
// This query has NO RLS filtering (public data)
return client.task.count();
}
Testing
When testing services, you can mock the RLS context:
import { vi } from "vitest";
import * as rlsContext from "../prisma/rls-context.provider";
describe("TasksService", () => {
it("should use RLS client when available", () => {
const mockClient = {} as PrismaClient;
vi.spyOn(rlsContext, "getRlsClient").mockReturnValue(mockClient);
// Service will use mockClient instead of prisma
});
});
Security Considerations
- Always use the pattern:
getRlsClient() ?? this.prisma - Don't bypass RLS unless absolutely necessary (e.g., system operations)
- Trust the interceptor: It sets context automatically - no manual setup needed
- Test with and without RLS: Ensure services work in both contexts
Architecture
Request → AuthGuard → WorkspaceGuard → RlsContextInterceptor → Service
↓
Prisma.$transaction
↓
SET LOCAL app.current_user_id
SET LOCAL app.current_workspace_id
↓
AsyncLocalStorage
↓
Service (getRlsClient())
Related Files
/apps/api/src/common/interceptors/rls-context.interceptor.ts- Main interceptor/apps/api/src/prisma/rls-context.provider.ts- AsyncLocalStorage provider/apps/api/src/lib/db-context.ts- Legacy RLS utilities (reference only)/apps/api/src/prisma/prisma.service.ts- Prisma service with RLS helpers
Migration from Legacy Pattern
If you're migrating from the legacy withUserContext() pattern:
Before:
return withUserContext(userId, async (tx) => {
return tx.task.findMany({ where: { workspaceId } });
});
After:
const client = getRlsClient() ?? this.prisma;
return client.task.findMany({ where: { workspaceId } });
The interceptor handles transaction management automatically, so you no longer need to wrap every query.