erp-construccion/docs/02-definicion-modulos/MAI-013-administracion-seguridad/requerimientos/RF-ADM-002-permisos-granulares.md

703 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (
<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**.
```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 <token-pedro>
```
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