Some checks failed
CI Pipeline / Lint & Type Check (push) Has been cancelled
CI Pipeline / Validate SSOT Constants (push) Has been cancelled
CI Pipeline / Backend Tests (push) Has been cancelled
CI Pipeline / Frontend Tests (push) Has been cancelled
CI Pipeline / Build (push) Has been cancelled
CI Pipeline / Docker Build (push) Has been cancelled
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1296 lines
44 KiB
Markdown
1296 lines
44 KiB
Markdown
# 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](../requerimientos/RF-AUTH-001-roles-construccion.md)
|
|
|
|
### 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:**
|
|
```sql
|
|
-- 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 constructora
|
|
- `projects.project_team_assignments.role` - Rol del usuario en cada proyecto específico
|
|
|
|
🗄️ **Funciones de Contexto:**
|
|
```sql
|
|
-- 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
|
|
📊 [TRACEABILITY.yml](../implementacion/TRACEABILITY.yml#L79-L115)
|
|
|
|
---
|
|
|
|
## 🏗️ 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:**
|
|
1. Frontend envía request con JWT que incluye `{ role: 'engineer', constructoraId: 'abc-123' }`
|
|
2. JwtAuthGuard extrae y valida JWT, inyecta `user` en request
|
|
3. ConstructoraGuard valida que usuario tenga acceso activo a esa constructora
|
|
4. SetRlsContextInterceptor configura variables de sesión de PostgreSQL
|
|
5. RolesGuard valida que usuario tenga rol requerido en endpoint
|
|
6. Controller ejecuta lógica de negocio
|
|
7. TypeORM/Prisma ejecuta queries
|
|
8. PostgreSQL aplica RLS automáticamente usando contexto configurado
|
|
9. 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`
|
|
|
|
```typescript
|
|
/**
|
|
* 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`
|
|
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
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:**
|
|
```typescript
|
|
// 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`
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```sql
|
|
-- 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
|
|
|
|
```sql
|
|
-- 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
|
|
|
|
```sql
|
|
-- 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:
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```sql
|
|
-- 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
|
|
|
|
```sql
|
|
-- Í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
|
|
|
|
```sql
|
|
-- 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
|
|
|
|
```sql
|
|
-- 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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](../requerimientos/RF-AUTH-001-roles-construccion.md)
|
|
- 📄 [RF-AUTH-002: Estados de Cuenta](../requerimientos/RF-AUTH-002-estados-cuenta.md)
|
|
- 📄 [RF-AUTH-003: Multi-tenancy](../requerimientos/RF-AUTH-003-multi-tenancy.md)
|
|
- 📄 [ET-AUTH-003: Multi-tenancy Implementation](./ET-AUTH-003-multi-tenancy.md) *(Pendiente)*
|
|
|
|
### Estándares y Best Practices
|
|
- [NIST RBAC](https://csrc.nist.gov/projects/role-based-access-control) - Estándar de RBAC
|
|
- [PostgreSQL RLS Documentation](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
|
|
- [OWASP Access Control](https://owasp.org/www-project-top-ten/2017/A5_2017-Broken_Access_Control)
|
|
|
|
### Recursos Técnicos
|
|
- [NestJS Guards](https://docs.nestjs.com/guards)
|
|
- [NestJS Custom Decorators](https://docs.nestjs.com/custom-decorators)
|
|
- [TypeORM Indexes](https://typeorm.io/indices)
|
|
|
|
---
|
|
|
|
## 📅 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
|