🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
44 KiB
ET-AUTH-001: RBAC (Role-Based Access Control) para Construcción
📋 Metadata
| Campo | Valor |
|---|---|
| ID | ET-AUTH-001 |
| Épica | MAI-001 - Fundamentos |
| Módulo | Autenticación y Autorización |
| Tipo | Especificación Técnica |
| Estado | 🚧 Planificado |
| Versión | 1.0 |
| Fecha creación | 2025-11-17 |
| Última actualización | 2025-11-17 |
| Esfuerzo estimado | 20h (vs 25h GAMILIT - 20% ahorro por reutilización de infraestructura) |
🔗 Referencias
Requerimiento Funcional
📄 RF-AUTH-001: Sistema de Roles de Construcción
Origen (GAMILIT)
♻️ Reutilización: 80%
- Catálogo de referencia:
shared/catalog/auth/(Patrón RBAC reutilizado) - Componentes reutilizables:
- Arquitectura general de guards y decorators
- RLS infrastructure
- Frontend role-based components
- Testing patterns
- Adaptaciones:
- 3 roles → 7 roles de construcción
- Agregar contexto multi-tenancy (constructora_id)
- Permisos específicos de construcción
- RLS policies adaptadas al dominio
Implementación DDL
🗄️ ENUM Principal:
-- apps/database/ddl/00-prerequisites.sql
DO $$ BEGIN
CREATE TYPE auth_management.construction_role AS ENUM (
'director', -- Director general/proyectos - Acceso total
'engineer', -- Ingeniero/Planeación - Presupuestos, programación
'resident', -- Residente de obra - Supervisión en campo
'purchases', -- Compras/Almacén - Órdenes de compra
'finance', -- Administración/Finanzas - Presupuestos, flujo
'hr', -- Recursos Humanos - Asistencias, nómina
'post_sales' -- Postventa/Garantías - Atención a clientes
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
COMMENT ON TYPE auth_management.construction_role IS
'Roles de usuario en el sistema de gestión de obra: director, engineer, resident, purchases, finance, hr, post_sales';
🗄️ Tablas que usan el ENUM:
auth_management.user_constructoras.role- Rol del usuario en cada constructoraprojects.project_team_assignments.role- Rol del usuario en cada proyecto específico
🗄️ Funciones de Contexto:
-- Obtener rol del usuario en constructora actual
CREATE OR REPLACE FUNCTION auth_management.get_current_user_role()
RETURNS auth_management.construction_role
LANGUAGE sql STABLE SECURITY DEFINER
AS $$
SELECT role
FROM auth_management.user_constructoras
WHERE user_id = auth_management.get_current_user_id()
AND constructora_id = auth_management.get_current_constructora_id()
AND status = 'active'
LIMIT 1;
$$;
-- Verificar si usuario tiene uno de los roles requeridos
CREATE OR REPLACE FUNCTION auth_management.user_has_any_role(
p_roles auth_management.construction_role[]
) RETURNS BOOLEAN
LANGUAGE sql STABLE SECURITY DEFINER
AS $$
SELECT EXISTS (
SELECT 1
FROM auth_management.user_constructoras
WHERE user_id = auth_management.get_current_user_id()
AND constructora_id = auth_management.get_current_constructora_id()
AND role = ANY(p_roles)
AND status = 'active'
);
$$;
-- Verificar si usuario es admin (director)
CREATE OR REPLACE FUNCTION auth_management.is_director()
RETURNS BOOLEAN
LANGUAGE sql STABLE SECURITY DEFINER
AS $$
SELECT auth_management.get_current_user_role() = 'director';
$$;
Backend
💻 Archivos de Implementación:
- Enum:
apps/backend/src/modules/auth/enums/construction-role.enum.ts - Guards:
apps/backend/src/modules/auth/guards/roles.guard.ts - Decorators:
apps/backend/src/modules/auth/decorators/roles.decorator.ts - Constructora Guard:
apps/backend/src/modules/auth/guards/constructora.guard.ts - Utilities:
apps/backend/src/modules/auth/utils/role-level.util.ts
Frontend
🎨 Componentes:
- Types:
apps/frontend/src/types/auth.types.ts - RoleBasedRoute:
apps/frontend/src/components/auth/RoleBasedRoute.tsx - RoleBadge:
apps/frontend/src/components/ui/RoleBadge.tsx - usePermissions:
apps/frontend/src/hooks/usePermissions.ts - PermissionGate:
apps/frontend/src/components/auth/PermissionGate.tsx
Trazabilidad
🏗️ Arquitectura de RBAC Multi-tenancy
Diseño General
┌────────────────────────────────────────────────────────────────────┐
│ CAPA FRONTEND │
│ ┌──────────────┐ ┌────────────────┐ ┌─────────────────────┐ │
│ │ RoleBadge │ │ PermissionGate │ │ RoleBasedRoute │ │
│ │ (director) │ │ (can:view: │ │ (allowedRoles: │ │
│ │ │ │ budgets) │ │ [director,engineer])│ │
│ └──────────────┘ └────────────────┘ └─────────────────────┘ │
└────────────────────────┬───────────────────────────────────────────┘
│ HTTP + JWT (role + constructoraId claims)
┌────────────────────────▼───────────────────────────────────────────┐
│ CAPA BACKEND │
│ ┌──────────────┐ ┌────────────────┐ ┌─────────────────────┐ │
│ │ RolesGuard │ │ @Roles() │ │ ConstructoraGuard │ │
│ │ │ │ decorator │ │ │ │
│ └──────────────┘ └────────────────┘ └─────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ SetRlsContextInterceptor │ │
│ │ - set_config('app.current_user_id', userId) │ │
│ │ - set_config('app.current_constructora_id', constructoraId) │ │
│ │ - set_config('app.current_user_role', role) │ │
│ └──────────────────────────────────────────────────────────────┘ │
└────────────────────────┬───────────────────────────────────────────┘
│ SQL Queries con RLS
┌────────────────────────▼───────────────────────────────────────────┐
│ CAPA DATABASE (PostgreSQL + RLS) │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ RLS POLICIES (Row Level Security) │ │
│ │ - directors_view_all_projects │ │
│ │ - engineers_view_budgets │ │
│ │ - residents_view_own_projects │ │
│ │ - hr_view_employees │ │
│ │ + ALWAYS: constructora_id isolation │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ ENUM: auth_management.construction_role │ │
│ │ VALUES: director | engineer | resident | purchases | │ │
│ │ finance | hr | post_sales │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ CONTEXT FUNCTIONS: │ │
│ │ - get_current_user_role() → construction_role │ │
│ │ - get_current_constructora_id() → UUID │ │
│ │ - user_has_any_role(roles[]) → BOOLEAN │ │
│ └──────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
Flujo de Request:
- Frontend envía request con JWT que incluye
{ role: 'engineer', constructoraId: 'abc-123' } - JwtAuthGuard extrae y valida JWT, inyecta
useren request - ConstructoraGuard valida que usuario tenga acceso activo a esa constructora
- SetRlsContextInterceptor configura variables de sesión de PostgreSQL
- RolesGuard valida que usuario tenga rol requerido en endpoint
- Controller ejecuta lógica de negocio
- TypeORM/Prisma ejecuta queries
- PostgreSQL aplica RLS automáticamente usando contexto configurado
- Solo retorna datos de la constructora actual con permisos del rol
📐 Matriz de Permisos Completa
Tabla Detallada de Permisos por Módulo
| Recurso | Acción | director | engineer | resident | purchases | finance | hr | post_sales |
|---|---|---|---|---|---|---|---|---|
| Perfil Propio | ||||||||
| Ver perfil | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | |
| Editar perfil | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | |
| Cambiar rol | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | |
| Proyectos/Obras | ||||||||
| Ver todos los proyectos | ✅ | ✅ | ❌ (solo asignados) | ❌ | ✅ | ❌ | ❌ | |
| Crear proyecto | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | |
| Editar proyecto | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | |
| Archivar proyecto | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | |
| Ver márgenes de utilidad | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | |
| Presupuestos | ||||||||
| Ver presupuestos | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | |
| Crear presupuesto | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | |
| Editar presupuesto | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | |
| Aprobar presupuesto | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | |
| Ver costos reales | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | |
| Avances de Obra | ||||||||
| Capturar avance físico | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | |
| Editar avance | ✅ | ✅ | ✅ (solo hoy) | ❌ | ❌ | ❌ | ❌ | |
| Ver historial avances | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | |
| Aprobar avance | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | |
| Compras | ||||||||
| Ver órdenes de compra | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | |
| Crear orden de compra | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | |
| Aprobar orden compra | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | |
| Ver inventario | ✅ | ✅ | ✅ (vista) | ✅ | ❌ | ❌ | ❌ | |
| Gestionar inventario | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | |
| Finanzas | ||||||||
| Ver flujo de efectivo | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | |
| Ver cuentas por pagar | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | |
| Aprobar pagos | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | |
| Generar reportes fin. | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | |
| Recursos Humanos | ||||||||
| Ver empleados | ✅ | ✅ (asignados) | ✅ (asignados) | ❌ | ❌ | ✅ | ❌ | |
| Crear empleado | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | |
| Registrar asistencia | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | |
| Ver nómina | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | |
| Exportar IMSS/INFONAVIT | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | |
| Postventa/Garantías | ||||||||
| Ver incidencias | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | |
| Crear incidencia | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | |
| Asignar incidencia | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | |
| Cerrar incidencia | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | |
| Sistema | ||||||||
| Ver usuarios constructora | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | |
| Invitar usuarios | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | |
| Suspender usuarios | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | |
| Ver audit logs | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | |
| Config. constructora | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
Leyenda:
- ✅ Acceso completo
- ✅ (condición) Acceso con restricción
- ❌ Sin acceso
🔧 Implementación Técnica Completa
1. Backend - Enum TypeScript
Ubicación: apps/backend/src/modules/auth/enums/construction-role.enum.ts
/**
* Roles de usuario en el Sistema de Gestión de Obra
*
* IMPORTANTE: Debe estar sincronizado con:
* - Database ENUM: auth_management.construction_role
* - DDL: apps/database/ddl/00-prerequisites.sql
* - Frontend: apps/frontend/src/types/auth.types.ts
*/
export enum ConstructionRole {
/** Director general/proyectos - Acceso total, visión estratégica */
DIRECTOR = 'director',
/** Ingeniero/Planeación - Presupuestos, programación, control de obra */
ENGINEER = 'engineer',
/** Residente de obra - Supervisión en campo, captura de avances */
RESIDENT = 'resident',
/** Compras/Almacén - Órdenes de compra, gestión de inventario */
PURCHASES = 'purchases',
/** Administración/Finanzas - Presupuestos, flujo de efectivo, pagos */
FINANCE = 'finance',
/** Recursos Humanos - Asistencias, nómina, IMSS/INFONAVIT */
HR = 'hr',
/** Postventa/Garantías - Atención a clientes, seguimiento post-entrega */
POST_SALES = 'post_sales',
}
/**
* Nivel jerárquico de cada rol (para comparaciones)
*
* Nivel más alto = más permisos
*/
export const RoleLevel: Record<ConstructionRole, number> = {
[ConstructionRole.DIRECTOR]: 7, // Máximo nivel
[ConstructionRole.ENGINEER]: 6,
[ConstructionRole.FINANCE]: 5,
[ConstructionRole.HR]: 4,
[ConstructionRole.PURCHASES]: 3,
[ConstructionRole.POST_SALES]: 2,
[ConstructionRole.RESIDENT]: 1, // Mínimo nivel
};
/**
* Descripción legible de cada rol
*/
export const RoleDisplayName: Record<ConstructionRole, string> = {
[ConstructionRole.DIRECTOR]: 'Director',
[ConstructionRole.ENGINEER]: 'Ingeniero',
[ConstructionRole.RESIDENT]: 'Residente de Obra',
[ConstructionRole.PURCHASES]: 'Compras',
[ConstructionRole.FINANCE]: 'Finanzas',
[ConstructionRole.HR]: 'Recursos Humanos',
[ConstructionRole.POST_SALES]: 'Postventa',
};
/**
* Verifica si un rol tiene al menos el nivel requerido
*/
export function hasMinimumRole(
userRole: ConstructionRole,
requiredRole: ConstructionRole
): boolean {
return RoleLevel[userRole] >= RoleLevel[requiredRole];
}
/**
* Verifica si usuario tiene uno de los roles permitidos
*/
export function hasAnyRole(
userRole: ConstructionRole,
allowedRoles: ConstructionRole[]
): boolean {
return allowedRoles.includes(userRole);
}
/**
* Roles con acceso a presupuestos
*/
export const BUDGET_ACCESS_ROLES: ConstructionRole[] = [
ConstructionRole.DIRECTOR,
ConstructionRole.ENGINEER,
ConstructionRole.FINANCE,
];
/**
* Roles con acceso a órdenes de compra
*/
export const PURCHASE_ORDER_ROLES: ConstructionRole[] = [
ConstructionRole.DIRECTOR,
ConstructionRole.ENGINEER,
ConstructionRole.PURCHASES,
ConstructionRole.FINANCE,
];
/**
* Roles que pueden capturar avances de obra
*/
export const PROGRESS_CAPTURE_ROLES: ConstructionRole[] = [
ConstructionRole.DIRECTOR,
ConstructionRole.ENGINEER,
ConstructionRole.RESIDENT,
];
/**
* Roles con acceso a RRHH
*/
export const HR_ACCESS_ROLES: ConstructionRole[] = [
ConstructionRole.DIRECTOR,
ConstructionRole.HR,
];
2. Backend - Guards
RolesGuard (Validación de Roles)
Ubicación: apps/backend/src/modules/auth/guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ConstructionRole } from '../enums/construction-role.enum';
/**
* Guard que valida roles de usuario en endpoints
*
* Uso:
* @Roles(ConstructionRole.DIRECTOR, ConstructionRole.ENGINEER)
* @Get('budgets')
* getBudgets() { ... }
*/
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// Obtener roles permitidos del decorator @Roles()
const requiredRoles = this.reflector.getAllAndOverride<ConstructionRole[]>('roles', [
context.getHandler(),
context.getClass(),
]);
// Si no hay roles requeridos, permitir acceso
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
// Obtener usuario del request (inyectado por JwtStrategy)
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
throw new ForbiddenException('Usuario no autenticado');
}
// Validar que usuario tenga alguno de los roles permitidos
const hasRole = requiredRoles.some((role) => user.role === role);
if (!hasRole) {
throw new ForbiddenException({
statusCode: 403,
message: `Acceso denegado. Requiere uno de estos roles: ${requiredRoles.join(', ')}`,
errorCode: 'INSUFFICIENT_ROLE',
userRole: user.role,
requiredRoles,
});
}
return true;
}
}
ConstructoraGuard (Validación de Acceso a Constructora)
Ubicación: apps/backend/src/modules/auth/guards/constructora.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserConstructora } from '../entities/user-constructora.entity';
import { UserStatus } from '../enums/user-status.enum';
/**
* Guard que valida que usuario tenga acceso activo a la constructora
*
* Valida:
* 1. Usuario tiene relación con constructora
* 2. Status es 'active'
* 3. Constructora está activa
*/
@Injectable()
export class ConstructoraGuard implements CanActivate {
constructor(
@InjectRepository(UserConstructora)
private readonly userConstructoraRepo: Repository<UserConstructora>,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
throw new ForbiddenException('Usuario no autenticado');
}
if (!user.constructoraId) {
throw new ForbiddenException({
statusCode: 403,
message: 'No se ha seleccionado una constructora',
errorCode: 'NO_CONSTRUCTORA_SELECTED',
});
}
// Validar acceso a constructora
const access = await this.userConstructoraRepo.findOne({
where: {
userId: user.id,
constructoraId: user.constructoraId,
},
relations: ['constructora'],
});
if (!access) {
throw new ForbiddenException({
statusCode: 403,
message: 'No tienes acceso a esta constructora',
errorCode: 'CONSTRUCTORA_ACCESS_DENIED',
constructoraId: user.constructoraId,
});
}
// Validar estado del usuario en constructora
if (access.status !== UserStatus.ACTIVE) {
throw new ForbiddenException({
statusCode: 403,
message: `Tu acceso a esta constructora está ${access.status}`,
errorCode: 'CONSTRUCTORA_ACCESS_INACTIVE',
status: access.status,
});
}
// Validar que constructora esté activa
if (!access.constructora.active) {
throw new ForbiddenException({
statusCode: 403,
message: 'Esta constructora está inactiva',
errorCode: 'CONSTRUCTORA_INACTIVE',
});
}
return true;
}
}
3. Backend - Decorators
@Roles Decorator
Ubicación: apps/backend/src/modules/auth/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { ConstructionRole } from '../enums/construction-role.enum';
/**
* Decorator para especificar roles permitidos en un endpoint
*
* Ejemplos:
* @Roles(ConstructionRole.DIRECTOR)
* @Roles(ConstructionRole.DIRECTOR, ConstructionRole.ENGINEER)
* @Roles(...BUDGET_ACCESS_ROLES)
*/
export const Roles = (...roles: ConstructionRole[]) => SetMetadata('roles', roles);
@CurrentUser Decorator
Ubicación: apps/backend/src/modules/auth/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
/**
* Decorator para obtener usuario actual del request
*
* Uso:
* @Get('profile')
* getProfile(@CurrentUser() user: UserJwtPayload) {
* return user;
* }
*/
export const CurrentUser = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
},
);
/**
* Obtener solo el ID del usuario
*
* Uso:
* @Get('my-data')
* getMyData(@UserId() userId: string) {
* return this.service.findByUserId(userId);
* }
*/
export const UserId = createParamDecorator(
(data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest();
return request.user?.id;
},
);
/**
* Obtener constructora actual
*/
export const CurrentConstructora = createParamDecorator(
(data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest();
return request.user?.constructoraId;
},
);
4. Backend - Interceptor para RLS Context
Ubicación: apps/backend/src/common/interceptors/set-rls-context.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable, from } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
/**
* Interceptor que configura el contexto de RLS en PostgreSQL
*
* Configura variables de sesión:
* - app.current_user_id
* - app.current_constructora_id
* - app.current_user_role
*
* Estas variables son usadas por RLS policies para filtrar datos
*/
@Injectable()
export class SetRlsContextInterceptor implements NestInterceptor {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const user = request.user;
// Si no hay usuario autenticado, continuar sin configurar RLS
if (!user) {
return next.handle();
}
// Configurar variables de sesión de PostgreSQL
return from(
this.dataSource.query(`
SELECT
set_config('app.current_user_id', $1, true),
set_config('app.current_constructora_id', $2, true),
set_config('app.current_user_role', $3, true)
`, [
user.id || '',
user.constructoraId || '',
user.role || '',
])
).pipe(
switchMap(() => next.handle())
);
}
}
Aplicación global:
// apps/backend/src/main.ts
import { SetRlsContextInterceptor } from './common/interceptors/set-rls-context.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Aplicar interceptor globalmente
app.useGlobalInterceptors(new SetRlsContextInterceptor(app.get(DataSource)));
await app.listen(3000);
}
5. Backend - Ejemplo de Controller con Guards
Ubicación: apps/backend/src/modules/budgets/budgets.controller.ts
import { Controller, Get, Post, Patch, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
import { RolesGuard } from '@modules/auth/guards/roles.guard';
import { ConstructoraGuard } from '@modules/auth/guards/constructora.guard';
import { Roles } from '@modules/auth/decorators/roles.decorator';
import { CurrentUser, CurrentConstructora } from '@modules/auth/decorators/current-user.decorator';
import { ConstructionRole, BUDGET_ACCESS_ROLES } from '@modules/auth/enums/construction-role.enum';
import { BudgetsService } from './budgets.service';
/**
* Controller de Presupuestos
*
* Guards aplicados:
* 1. JwtAuthGuard: Validar que usuario esté autenticado
* 2. ConstructoraGuard: Validar acceso a constructora
* 3. RolesGuard: Validar rol requerido
*/
@Controller('budgets')
@UseGuards(JwtAuthGuard, ConstructoraGuard, RolesGuard)
export class BudgetsController {
constructor(private readonly budgetsService: BudgetsService) {}
/**
* Listar presupuestos
* Acceso: director, engineer, finance
*/
@Roles(...BUDGET_ACCESS_ROLES)
@Get()
async findAll(@CurrentConstructora() constructoraId: string) {
return this.budgetsService.findAll(constructoraId);
}
/**
* Crear presupuesto
* Acceso: director, engineer
*/
@Roles(ConstructionRole.DIRECTOR, ConstructionRole.ENGINEER)
@Post()
async create(
@Body() createDto: CreateBudgetDto,
@CurrentUser() user: UserJwtPayload,
@CurrentConstructora() constructoraId: string,
) {
return this.budgetsService.create(createDto, user.id, constructoraId);
}
/**
* Aprobar presupuesto
* Acceso: SOLO director
*/
@Roles(ConstructionRole.DIRECTOR)
@Patch(':id/approve')
async approve(
@Param('id') id: string,
@CurrentUser() user: UserJwtPayload,
) {
return this.budgetsService.approve(id, user.id);
}
/**
* Eliminar presupuesto
* Acceso: SOLO director
*/
@Roles(ConstructionRole.DIRECTOR)
@Delete(':id')
async remove(@Param('id') id: string) {
return this.budgetsService.remove(id);
}
}
🔒 Row Level Security (RLS) Policies
Patrón Base para RLS con Multi-tenancy
Todas las policies DEBEN incluir filtrado por constructora_id:
-- Patrón base
CREATE POLICY "policy_name" ON [schema].[table]
FOR [SELECT|INSERT|UPDATE|DELETE]
TO authenticated
USING (
-- 1. Filtro por constructora (OBLIGATORIO)
constructora_id = auth_management.get_current_constructora_id()
-- 2. Filtro por rol (según necesidad)
AND auth_management.user_has_any_role(ARRAY['director', 'engineer'])
-- 3. Filtros adicionales (según lógica de negocio)
AND [condiciones específicas]
);
Ejemplo 1: Proyectos - Acceso Basado en Rol
-- apps/database/ddl/schemas/projects/tables/projects.sql
ALTER TABLE projects.projects ENABLE ROW LEVEL SECURITY;
-- Policy 1: Director y Engineer ven todos los proyectos de su constructora
CREATE POLICY "directors_engineers_view_all_projects"
ON projects.projects
FOR SELECT
TO authenticated
USING (
constructora_id = auth_management.get_current_constructora_id()
AND auth_management.user_has_any_role(ARRAY['director', 'engineer'])
);
-- Policy 2: Residente solo ve proyectos asignados
CREATE POLICY "residents_view_own_projects"
ON projects.projects
FOR SELECT
TO authenticated
USING (
constructora_id = auth_management.get_current_constructora_id()
AND auth_management.get_current_user_role() = 'resident'
AND id IN (
SELECT project_id
FROM projects.project_team_assignments
WHERE user_id = auth_management.get_current_user_id()
AND role = 'resident'
AND active = TRUE
)
);
-- Policy 3: Finance ve todos los proyectos (para reportes)
CREATE POLICY "finance_view_all_projects"
ON projects.projects
FOR SELECT
TO authenticated
USING (
constructora_id = auth_management.get_current_constructora_id()
AND auth_management.get_current_user_role() = 'finance'
);
-- Policy 4: Solo director y engineer pueden crear proyectos
CREATE POLICY "create_projects"
ON projects.projects
FOR INSERT
TO authenticated
WITH CHECK (
constructora_id = auth_management.get_current_constructora_id()
AND auth_management.user_has_any_role(ARRAY['director', 'engineer'])
);
-- Policy 5: Solo director y engineer pueden editar proyectos
CREATE POLICY "update_projects"
ON projects.projects
FOR UPDATE
TO authenticated
USING (
constructora_id = auth_management.get_current_constructora_id()
AND auth_management.user_has_any_role(ARRAY['director', 'engineer'])
);
-- Policy 6: Solo director puede archivar proyectos
CREATE POLICY "archive_projects"
ON projects.projects
FOR DELETE
TO authenticated
USING (
constructora_id = auth_management.get_current_constructora_id()
AND auth_management.get_current_user_role() = 'director'
);
Ejemplo 2: Presupuestos - Ocultar Márgenes según Rol
-- apps/database/ddl/schemas/budgets/tables/budgets.sql
ALTER TABLE budgets.budgets ENABLE ROW LEVEL SECURITY;
-- Policy 1: Director y Finance ven presupuestos completos (incluye márgenes)
CREATE POLICY "directors_finance_view_full_budgets"
ON budgets.budgets
FOR SELECT
TO authenticated
USING (
constructora_id = auth_management.get_current_constructora_id()
AND auth_management.user_has_any_role(ARRAY['director', 'finance'])
);
-- Policy 2: Engineer ve presupuestos (pero márgenes se ocultan en aplicación)
CREATE POLICY "engineers_view_budgets"
ON budgets.budgets
FOR SELECT
TO authenticated
USING (
constructora_id = auth_management.get_current_constructora_id()
AND auth_management.get_current_user_role() = 'engineer'
);
-- Policy 3: Solo director y engineer pueden crear presupuestos
CREATE POLICY "create_budgets"
ON budgets.budgets
FOR INSERT
TO authenticated
WITH CHECK (
constructora_id = auth_management.get_current_constructora_id()
AND auth_management.user_has_any_role(ARRAY['director', 'engineer'])
);
-- Policy 4: Solo director puede aprobar presupuestos
CREATE POLICY "approve_budgets"
ON budgets.budgets
FOR UPDATE
TO authenticated
USING (
constructora_id = auth_management.get_current_constructora_id()
AND auth_management.get_current_user_role() = 'director'
AND status = 'pending' -- Solo aprobar pendientes
)
WITH CHECK (
status = 'approved' -- Solo cambiar a aprobado
);
Nota: Para ocultar columnas específicas según rol (ej: margin_percentage), se usa en el service:
// apps/backend/src/modules/budgets/budgets.service.ts
async findAll(constructoraId: string, userRole: ConstructionRole) {
const query = this.budgetRepo
.createQueryBuilder('budget')
.where('budget.constructoraId = :constructoraId', { constructoraId });
// Ocultar márgenes si no es director o finance
if (![ConstructionRole.DIRECTOR, ConstructionRole.FINANCE].includes(userRole)) {
query.select([
'budget.id',
'budget.projectId',
'budget.totalCost',
'budget.status',
// NO incluir: budget.marginPercentage, budget.profitAmount
]);
}
return query.getMany();
}
Ejemplo 3: Empleados (RRHH) - Acceso Selectivo
-- apps/database/ddl/schemas/hr/tables/employees.sql
ALTER TABLE hr.employees ENABLE ROW LEVEL SECURITY;
-- Policy 1: Director y HR ven todos los empleados
CREATE POLICY "directors_hr_view_all_employees"
ON hr.employees
FOR SELECT
TO authenticated
USING (
constructora_id = auth_management.get_current_constructora_id()
AND auth_management.user_has_any_role(ARRAY['director', 'hr'])
);
-- Policy 2: Engineer y Resident ven empleados asignados a sus proyectos
CREATE POLICY "engineers_residents_view_assigned_employees"
ON hr.employees
FOR SELECT
TO authenticated
USING (
constructora_id = auth_management.get_current_constructora_id()
AND auth_management.user_has_any_role(ARRAY['engineer', 'resident'])
AND id IN (
SELECT employee_id
FROM hr.project_employee_assignments pea
INNER JOIN projects.project_team_assignments pta
ON pta.project_id = pea.project_id
WHERE pta.user_id = auth_management.get_current_user_id()
AND pea.active = TRUE
)
);
-- Policy 3: Solo HR puede crear empleados
CREATE POLICY "hr_create_employees"
ON hr.employees
FOR INSERT
TO authenticated
WITH CHECK (
constructora_id = auth_management.get_current_constructora_id()
AND auth_management.user_has_any_role(ARRAY['director', 'hr'])
);
📊 Performance y Escalabilidad
1. Índices Requeridos
-- Índices en columna role para filtrado rápido
CREATE INDEX idx_user_constructoras_role
ON auth_management.user_constructoras(user_id, constructora_id, role)
WHERE status = 'active';
-- Índice compuesto para RLS policies
CREATE INDEX idx_project_team_assignments_composite
ON projects.project_team_assignments(user_id, project_id, role, active);
-- Índice para asignaciones de empleados
CREATE INDEX idx_project_employee_assignments_composite
ON hr.project_employee_assignments(project_id, employee_id, active);
2. Caching de Funciones
-- Funciones STABLE se cachean durante la transacción
CREATE OR REPLACE FUNCTION auth_management.get_current_user_role()
RETURNS auth_management.construction_role
LANGUAGE sql STABLE SECURITY DEFINER
AS $$
SELECT role
FROM auth_management.user_constructoras
WHERE user_id = auth_management.get_current_user_id()
AND constructora_id = auth_management.get_current_constructora_id()
AND status = 'active'
LIMIT 1;
$$;
3. Monitoreo de Queries Lentos
-- Query para detectar policies que causan slow queries
SELECT
schemaname,
tablename,
policyname,
qual
FROM pg_policies
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY tablename;
-- Habilitar logging de queries lentos
-- postgresql.conf:
-- log_min_duration_statement = 1000 (1 segundo)
🧪 Testing
Unit Tests - RolesGuard
// apps/backend/src/modules/auth/guards/roles.guard.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { Reflector } from '@nestjs/core';
import { RolesGuard } from './roles.guard';
import { ConstructionRole } from '../enums/construction-role.enum';
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
describe('RolesGuard', () => {
let guard: RolesGuard;
let reflector: Reflector;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RolesGuard,
{
provide: Reflector,
useValue: {
getAllAndOverride: jest.fn(),
},
},
],
}).compile();
guard = module.get<RolesGuard>(RolesGuard);
reflector = module.get<Reflector>(Reflector);
});
const createMockContext = (user: any, requiredRoles: ConstructionRole[]): ExecutionContext => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(requiredRoles);
return {
switchToHttp: () => ({
getRequest: () => ({ user }),
}),
getHandler: jest.fn(),
getClass: jest.fn(),
} as any;
};
it('should allow access if user has required role', () => {
const user = { id: '123', role: ConstructionRole.DIRECTOR };
const requiredRoles = [ConstructionRole.DIRECTOR, ConstructionRole.ENGINEER];
const context = createMockContext(user, requiredRoles);
const result = guard.canActivate(context);
expect(result).toBe(true);
});
it('should deny access if user lacks required role', () => {
const user = { id: '123', role: ConstructionRole.RESIDENT };
const requiredRoles = [ConstructionRole.DIRECTOR];
const context = createMockContext(user, requiredRoles);
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
});
it('should allow access if no roles required', () => {
const user = { id: '123', role: ConstructionRole.RESIDENT };
const requiredRoles = [];
const context = createMockContext(user, requiredRoles);
const result = guard.canActivate(context);
expect(result).toBe(true);
});
it('should throw if user is not authenticated', () => {
const user = null;
const requiredRoles = [ConstructionRole.DIRECTOR];
const context = createMockContext(user, requiredRoles);
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
});
});
E2E Tests - RBAC Integration
// apps/backend/test/rbac/rbac.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, HttpStatus } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '@/app.module';
import { ConstructionRole } from '@modules/auth/enums/construction-role.enum';
describe('RBAC E2E Tests', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
describe('Budgets Endpoints', () => {
it('director should access budgets', async () => {
const director = await createUser({ role: ConstructionRole.DIRECTOR });
const token = await getAuthToken(director);
const response = await request(app.getHttpServer())
.get('/budgets')
.set('Authorization', `Bearer ${token}`)
.expect(HttpStatus.OK);
expect(response.body.data).toBeDefined();
});
it('engineer should access budgets', async () => {
const engineer = await createUser({ role: ConstructionRole.ENGINEER });
const token = await getAuthToken(engineer);
const response = await request(app.getHttpServer())
.get('/budgets')
.set('Authorization', `Bearer ${token}`)
.expect(HttpStatus.OK);
expect(response.body.data).toBeDefined();
});
it('resident should NOT access budgets', async () => {
const resident = await createUser({ role: ConstructionRole.RESIDENT });
const token = await getAuthToken(resident);
const response = await request(app.getHttpServer())
.get('/budgets')
.set('Authorization', `Bearer ${token}`)
.expect(HttpStatus.FORBIDDEN);
expect(response.body.errorCode).toBe('INSUFFICIENT_ROLE');
});
it('only director can approve budgets', async () => {
const engineer = await createUser({ role: ConstructionRole.ENGINEER });
const budget = await createBudget({ status: 'pending' });
const token = await getAuthToken(engineer);
const response = await request(app.getHttpServer())
.patch(`/budgets/${budget.id}/approve`)
.set('Authorization', `Bearer ${token}`)
.expect(HttpStatus.FORBIDDEN);
expect(response.body.errorCode).toBe('INSUFFICIENT_ROLE');
});
});
describe('RLS Data Isolation by Constructora', () => {
it('user should only see data from their constructora', async () => {
// Setup: 2 constructoras con 1 proyecto cada una
const constructoraA = await createConstructora({ nombre: 'Constructora A' });
const constructoraB = await createConstructora({ nombre: 'Constructora B' });
const projectA = await createProject({ constructoraId: constructoraA.id, nombre: 'Proyecto A' });
const projectB = await createProject({ constructoraId: constructoraB.id, nombre: 'Proyecto B' });
// Usuario con acceso SOLO a constructora A
const user = await createUser({ role: ConstructionRole.ENGINEER });
await assignToConstructora(user.id, constructoraA.id);
const token = await getAuthToken(user, constructoraA.id);
// Act: Solicitar todos los proyectos
const response = await request(app.getHttpServer())
.get('/projects')
.set('Authorization', `Bearer ${token}`)
.expect(HttpStatus.OK);
// Assert: Solo debe ver proyecto de constructora A
expect(response.body.data).toHaveLength(1);
expect(response.body.data[0].id).toBe(projectA.id);
expect(response.body.data[0].nombre).toBe('Proyecto A');
// Proyecto B NO debe aparecer (RLS lo bloqueó)
const projectBInResponse = response.body.data.find(p => p.id === projectB.id);
expect(projectBInResponse).toBeUndefined();
});
});
describe('Role-based Field Visibility', () => {
it('director should see budget margins', async () => {
const director = await createUser({ role: ConstructionRole.DIRECTOR });
const budget = await createBudget({ marginPercentage: 15.5 });
const token = await getAuthToken(director);
const response = await request(app.getHttpServer())
.get(`/budgets/${budget.id}`)
.set('Authorization', `Bearer ${token}`)
.expect(HttpStatus.OK);
expect(response.body.marginPercentage).toBe(15.5);
expect(response.body.profitAmount).toBeDefined();
});
it('engineer should NOT see budget margins', async () => {
const engineer = await createUser({ role: ConstructionRole.ENGINEER });
const budget = await createBudget({ marginPercentage: 15.5 });
const token = await getAuthToken(engineer);
const response = await request(app.getHttpServer())
.get(`/budgets/${budget.id}`)
.set('Authorization', `Bearer ${token}`)
.expect(HttpStatus.OK);
// Márgenes ocultos
expect(response.body.marginPercentage).toBeUndefined();
expect(response.body.profitAmount).toBeUndefined();
// Pero ve otros campos
expect(response.body.totalCost).toBeDefined();
expect(response.body.status).toBeDefined();
});
});
});
📚 Referencias Adicionales
Documentos Relacionados
- 📄 RF-AUTH-001: Sistema de Roles de Construcción
- 📄 RF-AUTH-002: Estados de Cuenta
- 📄 RF-AUTH-003: Multi-tenancy
- 📄 ET-AUTH-003: Multi-tenancy Implementation (Pendiente)
Estándares y Best Practices
- NIST RBAC - Estándar de RBAC
- PostgreSQL RLS Documentation
- OWASP Access Control
Recursos Técnicos
📅 Historial de Cambios
| Versión | Fecha | Autor | Cambios |
|---|---|---|---|
| 1.0 | 2025-11-17 | Tech Team | Creación inicial adaptada de GAMILIT con 7 roles de construcción y multi-tenancy |
Documento: MAI-001-fundamentos/especificaciones/ET-AUTH-001-rbac.md
Ruta absoluta: [RUTA-LEGACY-ELIMINADA]/docs/01-fase-alcance-inicial/MAI-001-fundamentos/especificaciones/ET-AUTH-001-rbac.md
Generado: 2025-11-17
Mantenedores: @tech-lead @backend-team @frontend-team @database-team