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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 17:19:00 -06:00
parent a671697d35
commit ff7fc41449
4 changed files with 136 additions and 0 deletions

View File

@ -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 {}

1
src/common/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './interceptors';

View File

@ -0,0 +1 @@
export * from './tenant-context.interceptor';

View File

@ -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<Observable<unknown>> {
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<void> {
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'}`,
);
}
}
}