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'}`, + ); + } + } +}