20 KiB
RF-ADM-002: Sistema de Permisos Granulares (RBAC + ABAC)
ID: RF-ADM-002 Módulo: MAI-013 - Administración & Seguridad Tipo: Requerimiento Funcional Prioridad: P0 (Crítica) Fecha de creación: 2025-11-20 Versión: 1.0
📋 Descripción
El sistema debe implementar un sistema de permisos granulares que combine:
- RBAC (Role-Based Access Control): Permisos basados en roles predefinidos
- ABAC (Attribute-Based Access Control): Permisos adicionales basados en atributos/contexto
- Validación en 3 capas: Frontend (UX) + Backend (lógica) + Database (RLS)
Este sistema garantiza que cada usuario solo accede a las funcionalidades permitidas según su rol, proyecto asignado y reglas de negocio.
🎯 Objetivos
Objetivos de Negocio
- Principio de menor privilegio: Usuarios solo tienen permisos necesarios para su trabajo
- Control granular: Permisos a nivel de módulo, acción (CRUD+Approve) y registro
- Flexibilidad: Permisos adicionales (custom) más allá del rol base
- Trazabilidad: Auditoría de quién accede a qué y cuándo
- Cumplimiento: ISO 27001, SOC 2 compliance ready
Objetivos Técnicos
- Performance: Validación de permisos < 10ms
- Cache de permisos: Permisos calculados y cacheados en JWT
- RLS automático: Row Level Security en PostgreSQL
- Guards reutilizables: Decoradores en NestJS para validación
- Testing: 100% cobertura de reglas de permisos
🔑 Modelo de Permisos
Permisos por Acción (CRUD+A)
enum PermissionAction {
CREATE = 'create', // Crear nuevos registros
READ = 'read', // Ver/leer registros
UPDATE = 'update', // Modificar registros existentes
DELETE = 'delete', // Eliminar registros (soft delete)
APPROVE = 'approve' // Aprobar operaciones críticas
}
Permisos por Módulo
Cada uno de los 13 módulos del sistema tiene su propio conjunto de permisos:
| Módulo | Código | Acciones Disponibles |
|---|---|---|
| Fundamentos | auth |
CREATE, READ, UPDATE, DELETE |
| Proyectos | projects |
CREATE, READ, UPDATE, DELETE, APPROVE |
| Presupuestos | budgets |
CREATE, READ, UPDATE, DELETE, APPROVE |
| Compras | purchases |
CREATE, READ, UPDATE, DELETE, APPROVE |
| Inventarios | inventory |
CREATE, READ, UPDATE, DELETE |
| Contratos | contracts |
CREATE, READ, UPDATE, DELETE, APPROVE |
| Control de Obra | construction |
CREATE, READ, UPDATE, DELETE |
| Estimaciones | estimations |
CREATE, READ, UPDATE, DELETE, APPROVE |
| RRHH | hr |
CREATE, READ, UPDATE, DELETE, APPROVE |
| Calidad/Postventa | quality |
CREATE, READ, UPDATE, DELETE |
| CRM | crm |
CREATE, READ, UPDATE, DELETE, APPROVE |
| INFONAVIT | infonavit |
CREATE, READ, UPDATE, DELETE |
| Reportes | reports |
CREATE, READ, UPDATE, DELETE |
| Administración | admin |
CREATE, READ, UPDATE, DELETE, APPROVE |
📊 Matriz de Permisos por Rol
Matriz Completa (7 Roles × 14 Módulos)
| Módulo | Director | Engineer | Resident | Purchases | Finance | HR | Post Sales |
|---|---|---|---|---|---|---|---|
| auth | CRUD | R | R | R | R | R | R |
| projects | CRUD+A | CRUD | R | R | R | R | R |
| budgets | CRUD+A | CRUD | R | R | R | - | - |
| purchases | CRUD+A | R | CRUD | CRUD+A | R | - | - |
| inventory | CRUD+A | R | CRUD | CRUD | R | - | - |
| contracts | CRUD+A | CRUD | R | R | R | - | - |
| construction | CRUD+A | CRUD | CRUD | R | R | - | R |
| estimations | CRUD+A | CRUD | R | - | CRUD+A | - | - |
| hr | CRUD+A | R | R | - | R | CRUD+A | - |
| quality | CRUD+A | CRUD | CRUD | - | - | - | CRUD |
| crm | CRUD+A | R | - | - | R | - | CRUD+A |
| infonavit | CRUD+A | R | - | - | CRUD | CRUD | R |
| reports | CRUD+A | R | R | R | CRUD | R | R |
| admin | CRUD+A | - | - | - | R | R | - |
Leyenda:
- C: Create
- R: Read
- U: Update
- D: Delete
- A: Approve
- -: Sin acceso
Ejemplos de Permisos por Rol
Director General
const directorPermissions = {
projects: ['create', 'read', 'update', 'delete', 'approve'],
budgets: ['create', 'read', 'update', 'delete', 'approve'],
purchases: ['create', 'read', 'update', 'delete', 'approve'],
// ... todos los módulos con acceso completo
};
Residente de Obra
const residentPermissions = {
projects: ['read'],
construction: ['create', 'read', 'update', 'delete'],
inventory: ['create', 'read', 'update', 'delete'],
purchases: ['create', 'read', 'update', 'delete'],
reports: ['read'],
// No acceso a: budgets, admin, crm, infonavit
};
Compras/Almacén
const purchasesPermissions = {
purchases: ['create', 'read', 'update', 'delete', 'approve'],
inventory: ['create', 'read', 'update', 'delete'],
projects: ['read'],
construction: ['read'],
reports: ['read'],
// No acceso a: budgets, hr, quality, admin
};
🔒 Reglas de Negocio (ABAC)
Además del rol, se aplican reglas basadas en atributos y contexto:
Regla 1: Acceso por Proyecto Asignado
Un usuario solo puede ver/editar proyectos a los que está asignado como miembro del equipo.
interface ProjectAccessRule {
userId: string;
projectId: string;
role: 'director' | 'resident' | 'engineer' | 'supervisor';
canApprove: boolean;
}
Ejemplo:
- Juan es Ingeniero en Proyecto A → Puede ver/editar Proyecto A
- Juan NO está en Proyecto B → No puede ver Proyecto B (aunque sea Ingeniero)
Excepción: Director General puede ver todos los proyectos de su constructora.
Regla 2: Monto de Aprobación
Aprobaciones financieras requieren doble autorización según monto:
| Monto | Aprobador Nivel 1 | Aprobador Nivel 2 |
|---|---|---|
| < $20,000 | Compras o Finance | - |
| $20,000 - $100,000 | Finance | Director |
| > $100,000 | Finance + Director | (ambos requeridos) |
interface ApprovalRule {
entityType: 'purchase_order' | 'estimation' | 'budget_change';
amount: number;
requiredApprovers: string[]; // Roles
approvalCount: number; // 1 o 2
}
Regla 3: Edición Temporal
Algunos registros no pueden editarse pasado cierto tiempo:
| Entidad | Tiempo límite | Excepción |
|---|---|---|
| Estimación autorizada | 0 días (bloqueado) | Director puede revertir |
| Orden de compra entregada | 7 días | Director puede modificar |
| Avance de obra aprobado | 3 días | Ingeniero puede corregir |
Regla 4: Segregación de Funciones
Ciertas acciones no pueden ser hechas por la misma persona:
- ❌ Quien crea una orden de compra no puede aprobarla
- ❌ Quien captura avances no puede aprobarlos
- ✅ Director puede crear y aprobar (excepción)
interface SegregationRule {
action: 'approve';
entity: 'purchase_order' | 'estimation' | 'construction_progress';
cannotBeSameAs: 'creator' | 'last_modifier';
exceptions: string[]; // Roles exceptuados ['director']
}
🛡️ Validación en 3 Capas
Capa 1: Frontend (UX)
Oculta elementos de UI según permisos del usuario.
// React component
const ProjectActions = () => {
const { hasPermission } = useAuth();
return (
<div>
{hasPermission('projects', 'create') && (
<Button onClick={createProject}>Crear Proyecto</Button>
)}
{hasPermission('projects', 'update') && (
<Button onClick={editProject}>Editar</Button>
)}
{hasPermission('projects', 'delete') && (
<Button onClick={deleteProject}>Eliminar</Button>
)}
</div>
);
};
Propósito: Mejorar UX (no mostrar botones inútiles).
Seguridad: ⚠️ NO es seguridad real (puede bypassearse en DevTools).
Capa 2: Backend (Lógica de Negocio)
Valida permisos en cada endpoint con Guards.
// NestJS controller
@Controller('projects')
export class ProjectsController {
@Post()
@RequirePermissions('projects', 'create')
async create(@Body() dto: CreateProjectDto, @CurrentUser() user: User) {
// Solo ejecuta si usuario tiene permiso 'projects:create'
return this.projectsService.create(dto, user);
}
@Patch(':id/approve')
@RequirePermissions('projects', 'approve')
@RequireRole('director', 'engineer') // Y además rol específico
async approve(@Param('id') id: string, @CurrentUser() user: User) {
return this.projectsService.approve(id, user);
}
}
Guard implementation:
@Injectable()
export class PermissionsGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user; // Del JWT
const requiredModule = this.reflector.get('module', context.getHandler());
const requiredAction = this.reflector.get('action', context.getHandler());
return this.permissionsService.hasPermission(
user.role,
requiredModule,
requiredAction
);
}
}
Propósito: Seguridad real. Backend rechaza requests no autorizadas.
Capa 3: Database (Row Level Security)
PostgreSQL filtra automáticamente a nivel de base de datos.
-- RLS Policy: Solo ver proyectos de tu constructora
CREATE POLICY projects_read_policy ON projects
FOR SELECT
USING (
constructora_id = current_setting('app.current_constructora_id')::uuid
);
-- RLS Policy: Solo editar si eres miembro del equipo
CREATE POLICY projects_update_policy ON projects
FOR UPDATE
USING (
constructora_id = current_setting('app.current_constructora_id')::uuid
AND (
current_setting('app.current_user_role') = 'director'
OR EXISTS (
SELECT 1 FROM project_team_assignments
WHERE project_id = projects.id
AND user_id = current_setting('app.current_user_id')::uuid
)
)
);
Propósito: Defensa en profundidad. Incluso si backend tiene bug, DB protege.
Ejemplo de protección:
// Developer escribe (por error):
const allProjects = await db.query('SELECT * FROM projects');
// PostgreSQL ejecuta (automático):
SELECT * FROM projects
WHERE constructora_id = 'uuid-current-company'
AND (user_role = 'director' OR user_id IN (SELECT user_id FROM team WHERE project_id = projects.id));
🔐 Permisos Personalizados (Custom Permissions)
Además de los permisos por rol, se pueden asignar permisos adicionales a usuarios específicos:
interface CustomPermission {
userId: string;
constructoraId: string;
module: string; // 'budgets'
actions: PermissionAction[]; // ['read', 'update']
scope?: {
projectId?: string; // Solo para este proyecto
validUntil?: Date; // Permiso temporal
};
grantedBy: string; // Usuario que otorgó el permiso
grantedAt: Date;
}
Ejemplo:
{
"userId": "uuid-123",
"module": "budgets",
"actions": ["read"],
"scope": {
"projectId": "uuid-project-A",
"validUntil": "2025-12-31T23:59:59Z"
},
"grantedBy": "uuid-director",
"grantedAt": "2025-11-20T10:00:00Z"
}
Interpretación:
- Usuario 123 (normalmente sin acceso a Presupuestos)
- Tiene permiso
READen módulobudgets - Solo para Proyecto A
- Válido hasta 31/12/2025
- Otorgado por Director
Caso de uso: Auditor externo necesita ver presupuestos de un proyecto específico por 30 días.
📊 Casos de Uso
Caso 1: Residente Intenta Aprobar Estimación
Actor: Pedro (Residente de Obra)
Flujo:
- Pedro va a módulo "Estimaciones"
- Ve listado de estimaciones del proyecto (permiso READ ✅)
- Click en "Estimación #5" → Ver detalle ✅
- Botón "Aprobar" está oculto (Frontend valida que no tiene permiso APPROVE)
- Pedro intenta acceder directo vía API:
PATCH /api/estimations/5/approve Authorization: Bearer <token-pedro> - Backend rechaza:
{ "statusCode": 403, "message": "Forbidden: You don't have permission to approve estimations", "error": "Forbidden" } - Auditoría registra:
{ "userId": "pedro-uuid", "action": "approve_attempt", "module": "estimations", "entityId": "5", "success": false, "errorMessage": "Insufficient permissions" }
Resultado: Ataque bloqueado en 3 capas (Frontend, Backend, Auditoría).
Caso 2: Ingeniero Asignado Edita Presupuesto
Actor: Carlos (Ingeniero asignado a Proyecto A)
Flujo:
- Carlos login → Token contiene:
{ "userId": "carlos-uuid", "role": "engineer", "constructoraId": "empresa-a", "projectAssignments": ["proyecto-a-uuid"] } - Carlos va a "Presupuestos" del Proyecto A
- Ve presupuesto maestro (READ ✅)
- Click en "Editar" → Formulario se abre ✅
- Modifica partida "Cimentación" de $500K → $520K
- Click en "Guardar"
- Backend valida:
- ✅ Usuario tiene rol
engineer - ✅ Rol
engineertiene permisobudgets:update - ✅ Usuario está asignado a Proyecto A
- ✅ Presupuesto pertenece a Proyecto A
- ✅ Usuario tiene rol
- Database (RLS) valida:
UPDATE budgets SET amount = 520000 WHERE id = 'budget-uuid' AND constructora_id = 'empresa-a' -- RLS automático AND project_id IN ( SELECT project_id FROM project_team WHERE user_id = 'carlos-uuid' ); - Auditoría registra:
{ "userId": "carlos-uuid", "action": "update", "module": "budgets", "entityId": "budget-uuid", "changes": [ { "field": "amount", "oldValue": 500000, "newValue": 520000 } ], "success": true }
Resultado: Operación exitosa con trazabilidad completa.
Caso 3: Permiso Personalizado Temporal
Actor: Director otorga permiso temporal a Auditor Externo
Flujo:
- Director va a "Administración" → "Permisos Personalizados"
- Click en "Otorgar Permiso Temporal"
- Completa formulario:
- Usuario:
auditor@externo.com - Módulo:
budgets - Acción:
read - Alcance: Solo Proyecto "Fraccionamiento Los Pinos"
- Válido hasta: 2025-12-15
- Usuario:
- Click en "Otorgar"
- Sistema crea
CustomPermission:{ "userId": "auditor-uuid", "module": "budgets", "actions": ["read"], "scope": { "projectId": "proyecto-los-pinos-uuid", "validUntil": "2025-12-15T23:59:59Z" }, "grantedBy": "director-uuid" } - Auditor login → Token incluye custom permissions
- Auditor puede ver presupuestos solo de ese proyecto
- Auditor intenta ver presupuesto de otro proyecto → 403 Forbidden
- 2025-12-16 00:00:00 → Cron job elimina permiso expirado
- Auditor ya no puede acceder
Resultado: Acceso temporal y granular sin modificar rol base.
✅ Criterios de Aceptación
AC1: Validación Multi-Capa
DADO un usuario sin permiso budgets:approve
CUANDO intenta aprobar un presupuesto
ENTONCES
- ✅ Frontend oculta botón "Aprobar" (UX)
- ✅ Backend rechaza con 403 Forbidden (seguridad)
- ✅ Auditoría registra intento fallido
- ✅ RLS en DB bloquea query (defensa en profundidad)
AC2: Permisos por Proyecto
DADO un Ingeniero asignado solo a Proyecto A CUANDO intenta acceder a Proyecto B ENTONCES
- ✅ Proyecto B no aparece en su lista (filtrado automático)
- ✅ Acceso directo a
/projects/Bretorna 404 (RLS lo bloquea) - ✅ Auditoría registra intento de acceso no autorizado
AC3: Aprobación por Monto
DADO una orden de compra de $50,000 CUANDO Comprador intenta aprobar ENTONCES
- ✅ Sistema rechaza (requiere aprobación de Finance)
- ✅ Mensaje: "Monto requiere aprobación de Finanzas o Director"
Y CUANDO Finance aprueba ENTONCES
- ✅ Orden cambia a estado
approved - ✅ Auditoría registra aprobador
AC4: Segregación de Funciones
DADO un Ingeniero que creó una estimación CUANDO intenta aprobarla él mismo ENTONCES
- ✅ Sistema rechaza con mensaje: "No puede aprobar una estimación creada por usted"
- ✅ Solo Director u otro Ingeniero pueden aprobar
AC5: Permisos Personalizados
DADO un permiso personalizado válido hasta 2025-12-01 CUANDO es 2025-11-20 ENTONCES
- ✅ Usuario puede acceder según permiso
Y CUANDO es 2025-12-02 ENTONCES
- ✅ Permiso ha expirado
- ✅ Usuario ya no puede acceder
- ✅ Cron job eliminó el permiso
🧪 Escenarios de Prueba
Test 1: Matriz de Permisos por Rol
describe('RF-ADM-002: Permission Matrix', () => {
const roles = ['director', 'engineer', 'resident', 'purchases', 'finance', 'hr', 'post_sales'];
const modules = ['projects', 'budgets', 'purchases', 'construction', 'estimations', 'hr'];
roles.forEach(role => {
it(`should enforce correct permissions for ${role}`, async () => {
const user = await loginAs(role);
// Test cada módulo
for (const module of modules) {
const expectedPermissions = PERMISSION_MATRIX[role][module];
if (expectedPermissions === '-') {
// No debe tener acceso
const response = await api.get(`/${module}`, {
headers: { Authorization: user.token }
});
expect(response.status).toBe(403);
} else {
// Debe tener acceso según permisos
if (expectedPermissions.includes('R')) {
const response = await api.get(`/${module}`, {
headers: { Authorization: user.token }
});
expect(response.status).toBe(200);
}
if (expectedPermissions.includes('C')) {
const response = await api.post(`/${module}`, testData, {
headers: { Authorization: user.token }
});
expect(response.status).toBe(201);
}
}
}
});
});
});
Test 2: RLS en Database
describe('RF-ADM-002: Row Level Security', () => {
it('should isolate data by constructora_id', async () => {
// Crear proyectos en 2 empresas
const projectA = await createProject({ constructoraId: 'empresa-a' });
const projectB = await createProject({ constructoraId: 'empresa-b' });
// Usuario de Empresa A
const userA = await loginAs('engineer', 'empresa-a');
// Query directo a DB (simulando bug en backend)
const result = await db.query('SELECT * FROM projects');
// RLS debe haber filtrado automáticamente
expect(result.rows).toHaveLength(1);
expect(result.rows[0].id).toBe(projectA.id);
expect(result.rows).not.toContainEqual(
expect.objectContaining({ id: projectB.id })
);
});
});
Test 3: Permisos Personalizados
describe('RF-ADM-002: Custom Permissions', () => {
it('should grant temporary custom permissions', async () => {
const auditor = await createUser('engineer'); // No tiene acceso a budgets por defecto
const project = await createProject();
// Sin permiso custom
let response = await api.get(`/budgets?projectId=${project.id}`, {
headers: { Authorization: auditor.token }
});
expect(response.status).toBe(403);
// Director otorga permiso temporal
await grantCustomPermission({
userId: auditor.id,
module: 'budgets',
actions: ['read'],
scope: { projectId: project.id },
validUntil: addDays(new Date(), 7)
});
// Ahora sí tiene acceso
response = await api.get(`/budgets?projectId=${project.id}`, {
headers: { Authorization: auditor.token }
});
expect(response.status).toBe(200);
});
it('should revoke expired custom permissions', async () => {
const permission = await grantCustomPermission({
validUntil: subDays(new Date(), 1) // Expirado
});
// Ejecutar cron job
await cleanupExpiredPermissions();
// Permiso debe estar eliminado
const exists = await db.query(
'SELECT * FROM custom_permissions WHERE id = $1',
[permission.id]
);
expect(exists.rows).toHaveLength(0);
});
});
🔗 Referencias
- Especificación técnica: ET-ADM-001
- Historia de usuario: US-ADM-002
- RF relacionado: RF-ADM-001
- Módulo: README.md
Generado: 2025-11-20 Versión: 1.0 Autor: Sistema de Documentación Técnica Estado: ✅ Completo