# 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
1. **Principio de menor privilegio:** Usuarios solo tienen permisos necesarios para su trabajo
2. **Control granular:** Permisos a nivel de módulo, acción (CRUD+Approve) y registro
3. **Flexibilidad:** Permisos adicionales (custom) más allá del rol base
4. **Trazabilidad:** Auditoría de quién accede a qué y cuándo
5. **Cumplimiento:** ISO 27001, SOC 2 compliance ready
### Objetivos Técnicos
1. **Performance:** Validación de permisos < 10ms
2. **Cache de permisos:** Permisos calculados y cacheados en JWT
3. **RLS automático:** Row Level Security en PostgreSQL
4. **Guards reutilizables:** Decoradores en NestJS para validación
5. **Testing:** 100% cobertura de reglas de permisos
---
## 🔑 Modelo de Permisos
### Permisos por Acción (CRUD+A)
```typescript
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
```typescript
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
```typescript
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
```typescript
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.
```typescript
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) |
```typescript
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)
```typescript
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.
```typescript
// React component
const ProjectActions = () => {
const { hasPermission } = useAuth();
return (
{hasPermission('projects', 'create') && (
)}
{hasPermission('projects', 'update') && (
)}
{hasPermission('projects', 'delete') && (
)}
);
};
```
**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**.
```typescript
// 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:**
```typescript
@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**.
```sql
-- 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:**
```typescript
// 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:
```typescript
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:**
```json
{
"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 `READ` en módulo `budgets`
- 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:**
1. Pedro va a módulo "Estimaciones"
2. Ve listado de estimaciones del proyecto (permiso READ ✅)
3. Click en "Estimación #5" → Ver detalle ✅
4. **Botón "Aprobar" está oculto** (Frontend valida que no tiene permiso APPROVE)
5. Pedro intenta acceder directo vía API:
```bash
PATCH /api/estimations/5/approve
Authorization: Bearer
```
6. **Backend rechaza:**
```json
{
"statusCode": 403,
"message": "Forbidden: You don't have permission to approve estimations",
"error": "Forbidden"
}
```
7. **Auditoría registra:**
```json
{
"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:**
1. Carlos login → Token contiene:
```json
{
"userId": "carlos-uuid",
"role": "engineer",
"constructoraId": "empresa-a",
"projectAssignments": ["proyecto-a-uuid"]
}
```
2. Carlos va a "Presupuestos" del Proyecto A
3. Ve presupuesto maestro (READ ✅)
4. Click en "Editar" → Formulario se abre ✅
5. Modifica partida "Cimentación" de $500K → $520K
6. Click en "Guardar"
7. **Backend valida:**
- ✅ Usuario tiene rol `engineer`
- ✅ Rol `engineer` tiene permiso `budgets:update`
- ✅ Usuario está asignado a Proyecto A
- ✅ Presupuesto pertenece a Proyecto A
8. **Database (RLS) valida:**
```sql
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'
);
```
9. **Auditoría registra:**
```json
{
"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:**
1. Director va a "Administración" → "Permisos Personalizados"
2. Click en "Otorgar Permiso Temporal"
3. Completa formulario:
- Usuario: `auditor@externo.com`
- Módulo: `budgets`
- Acción: `read`
- Alcance: Solo Proyecto "Fraccionamiento Los Pinos"
- Válido hasta: 2025-12-15
4. Click en "Otorgar"
5. Sistema crea `CustomPermission`:
```json
{
"userId": "auditor-uuid",
"module": "budgets",
"actions": ["read"],
"scope": {
"projectId": "proyecto-los-pinos-uuid",
"validUntil": "2025-12-15T23:59:59Z"
},
"grantedBy": "director-uuid"
}
```
6. Auditor login → Token incluye custom permissions
7. Auditor puede ver presupuestos **solo de ese proyecto**
8. Auditor intenta ver presupuesto de otro proyecto → 403 Forbidden
9. 2025-12-16 00:00:00 → Cron job elimina permiso expirado
10. 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/B` retorna 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
```typescript
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
```typescript
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
```typescript
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](../especificaciones/ET-ADM-001-rbac-multi-tenancy.md)
- **Historia de usuario:** [US-ADM-002](../historias-usuario/US-ADM-002-asignar-roles-permisos.md)
- **RF relacionado:** [RF-ADM-001](./RF-ADM-001-usuarios-roles.md)
- **Módulo:** [README.md](../README.md)
---
**Generado:** 2025-11-20
**Versión:** 1.0
**Autor:** Sistema de Documentación Técnica
**Estado:** ✅ Completo