From ff7fc41449fbfa89b27254af0ba2f44250df8a7b Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 3 Feb 2026 17:19:00 -0600 Subject: [PATCH] feat(rls): add TenantContextInterceptor for PostgreSQL RLS Implement global interceptor that sets tenant and user context in PostgreSQL session before each authenticated request. This enables Row Level Security policies to filter data automatically. - Create TenantContextInterceptor in common/interceptors/ - Register as global interceptor in AppModule - Call auth.set_current_tenant() and auth.set_current_user() - Clear context after request completion to prevent leaks Co-Authored-By: Claude Opus 4.5 --- src/app.module.ts | 11 ++ src/common/index.ts | 1 + src/common/interceptors/index.ts | 1 + .../tenant-context.interceptor.ts | 123 ++++++++++++++++++ 4 files changed, 136 insertions(+) create mode 100644 src/common/index.ts create mode 100644 src/common/interceptors/index.ts create mode 100644 src/common/interceptors/tenant-context.interceptor.ts diff --git a/src/app.module.ts b/src/app.module.ts index ce82354..84c33aa 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ThrottlerModule } from '@nestjs/throttler'; @@ -9,6 +10,9 @@ import { BullModule } from '@nestjs/bullmq'; import { envConfig, validationSchema } from '@config/env.config'; import { databaseConfig } from '@config/database.config'; +// Interceptors +import { TenantContextInterceptor } from './common/interceptors'; + // Modules import { AuthModule } from '@modules/auth/auth.module'; import { TenantsModule } from '@modules/tenants/tenants.module'; @@ -99,5 +103,12 @@ import { MlmModule } from '@modules/mlm/mlm.module'; GoalsModule, MlmModule, ], + providers: [ + // Global interceptor for RLS tenant context + { + provide: APP_INTERCEPTOR, + useClass: TenantContextInterceptor, + }, + ], }) export class AppModule {} diff --git a/src/common/index.ts b/src/common/index.ts new file mode 100644 index 0000000..34856f4 --- /dev/null +++ b/src/common/index.ts @@ -0,0 +1 @@ +export * from './interceptors'; diff --git a/src/common/interceptors/index.ts b/src/common/interceptors/index.ts new file mode 100644 index 0000000..9d9519f --- /dev/null +++ b/src/common/interceptors/index.ts @@ -0,0 +1 @@ +export * from './tenant-context.interceptor'; diff --git a/src/common/interceptors/tenant-context.interceptor.ts b/src/common/interceptors/tenant-context.interceptor.ts new file mode 100644 index 0000000..887b5d5 --- /dev/null +++ b/src/common/interceptors/tenant-context.interceptor.ts @@ -0,0 +1,123 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + Logger, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { DataSource } from 'typeorm'; + +/** + * TenantContextInterceptor + * + * Establece el contexto de tenant y usuario en PostgreSQL para aplicar politicas RLS. + * Se ejecuta ANTES de cada request autenticado y llama a las funciones: + * - auth.set_current_tenant(uuid) - Establece app.current_tenant_id + * - auth.set_current_user(uuid) - Establece app.current_user_id + * + * Las variables de sesion son utilizadas por las politicas RLS en PostgreSQL + * para filtrar automaticamente las filas segun el tenant del usuario. + * + * IMPORTANTE: Usa set_config con local=FALSE para que el contexto persista + * durante toda la conexion. Al final del request, limpiamos el contexto + * para evitar fugas entre requests. + * + * @see database/ddl/03-functions.sql - Funciones auth.set_current_tenant/user + * @see database/ddl/05-policies.sql - Politicas RLS que usan el contexto + */ +@Injectable() +export class TenantContextInterceptor implements NestInterceptor { + private readonly logger = new Logger(TenantContextInterceptor.name); + + constructor(private readonly dataSource: DataSource) {} + + async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + const request = context.switchToHttp().getRequest(); + const user = request.user; + + // Si no hay usuario autenticado, continuar sin establecer contexto RLS + if (!user) { + this.logger.debug('No authenticated user, skipping RLS context setup'); + return next.handle(); + } + + const tenantId = user.tenant_id; + const userId = user.id; + + // Validar que tenemos tenant_id + if (!tenantId) { + this.logger.warn( + 'User authenticated but tenant_id is missing in JWT payload', + ); + return next.handle(); + } + + // Validar formato UUID para prevenir injection + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + + if (!uuidRegex.test(tenantId)) { + this.logger.error(`Invalid tenant_id format: ${tenantId}`); + return next.handle(); + } + + if (userId && !uuidRegex.test(userId)) { + this.logger.error(`Invalid user_id format: ${userId}`); + return next.handle(); + } + + try { + // Establecer contexto de tenant usando la funcion del DDL + await this.dataSource.query(`SELECT auth.set_current_tenant($1)`, [ + tenantId, + ]); + + this.logger.debug(`RLS tenant context set: tenant_id=${tenantId}`); + + // Establecer contexto de usuario si existe + if (userId) { + await this.dataSource.query(`SELECT auth.set_current_user($1)`, [ + userId, + ]); + this.logger.debug(`RLS user context set: user_id=${userId}`); + } + } catch (error) { + this.logger.error( + `Failed to set RLS context: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + // Continuar sin RLS si falla - los servicios tienen filtros manuales como fallback + } + + // Procesar el request y limpiar contexto al finalizar + return next.handle().pipe( + tap({ + next: () => { + this.clearContext(); + }, + error: () => { + this.clearContext(); + }, + }), + ); + } + + /** + * Limpia el contexto RLS al finalizar el request. + * Esto previene fugas de contexto entre requests en conexiones pooled. + */ + private async clearContext(): Promise { + try { + await this.dataSource.query(`SELECT auth.clear_context()`); + this.logger.debug('RLS context cleared'); + } catch (error) { + this.logger.warn( + `Failed to clear RLS context: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } +}