erp-construccion/docs/02-definicion-modulos/MAI-013-administracion-seguridad/requerimientos/RF-ADM-001-usuarios-roles.md

758 lines
21 KiB
Markdown

# RF-ADM-001: Gestión de Usuarios y Roles por Empresa/Obra
**ID:** RF-ADM-001
**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 permitir la **gestión completa de usuarios** con soporte multi-tenancy (múltiples empresas constructoras) y asignación de **7 roles especializados** en el sector construcción.
Cada usuario puede:
- Pertenecer a **una o más empresas constructoras** (multi-tenancy)
- Tener **diferentes roles** en cada empresa
- Acceder únicamente a **los datos de la empresa activa** (aislamiento total)
- Cambiar de empresa sin cerrar sesión
**Diferenciador vs GAMILIT:** GAMILIT tiene 3 roles académicos (student, teacher, admin); este sistema extiende a 7 roles especializados en construcción con permisos granulares por módulo.
---
## 🎯 Objetivos
### Objetivos de Negocio
1. **Multi-tenancy seguro:** Múltiples constructoras usan el mismo sistema sin ver datos ajenos
2. **Control de acceso por rol:** Cada usuario solo accede a funcionalidades según su rol
3. **Gestión centralizada:** Administradores gestionan usuarios desde un solo lugar
4. **Onboarding rápido:** Nuevos usuarios operan en < 5 minutos desde invitación
5. **Trazabilidad:** Auditoría completa de cambios en usuarios y permisos
### Objetivos Técnicos
1. **Row Level Security (RLS):** Filtrado automático de datos por `constructoraId`
2. **Invitación controlada:** No registro público, solo por invitación de administrador
3. **Estados de cuenta:** Manejo de usuarios activos, inactivos, suspendidos, bloqueados
4. **Validación en 3 capas:** Frontend Backend Database
5. **Performance:** Consultas de usuarios < 100ms
---
## 👥 Roles del Sistema
### 7 Roles Especializados
| Rol | Código | Nivel | Descripción Completa | Permisos Globales |
|-----|--------|-------|----------------------|-------------------|
| **Director General** | `director` | 🔴 Alto | Máxima autoridad de la empresa. Visión estratégica, aprobaciones finales, acceso total. | CRUD+Approve en todos los módulos |
| **Ingeniero/Planeación** | `engineer` | 🟠 Alto | Gestión técnica, planeación de obra, presupuestos, control de costos y avances. | CRUD en Proyectos, Presupuestos, Control Obra |
| **Residente de Obra** | `resident` | 🟡 Medio | Ejecución diaria en campo, supervisión de cuadrillas, captura de avances, evidencias. | CRUD en Control Obra, Compras, Avances |
| **Compras/Almacén** | `purchases` | 🟡 Medio | Gestión de compras, cotizaciones, órdenes de compra, inventarios, almacenes. | CRUD+Approve en Compras, Inventarios |
| **Administración/Finanzas** | `finance` | 🟠 Alto | Control financiero, flujo de efectivo, estimaciones, pagos, cuentas por cobrar/pagar. | CRUD+Approve en Estimaciones, Finanzas |
| **RRHH/Nómina** | `hr` | 🟡 Medio | Recursos humanos, asistencias, nómina, IMSS, INFONAVIT, control de personal. | CRUD+Approve en RRHH, Asistencias |
| **Postventa** | `post_sales` | 🟢 Bajo | Atención a clientes, garantías, calidad, CRM de derechohabientes. | CRUD en Postventa, Garantías, CRM |
### Comparación con GAMILIT
| GAMILIT | Inmobiliario | Cambios |
|---------|--------------|---------|
| `student` | - | No aplica (sistema B2B, no hay usuarios finales públicos) |
| `admin_teacher` | `engineer`, `resident`, `purchases`, `finance`, `hr` | Expansión a 5 roles especializados |
| `super_admin` | `director` | Similar, pero con permisos específicos de construcción |
---
## 🏢 Multi-Tenancy: Empresas Constructoras
### Modelo de Datos
```typescript
interface Constructora {
id: string; // UUID
code: string; // CONST-001, CONST-002 (auto-generado)
name: string; // "Constructora ABC S.A. de C.V."
legalName: string; // Razón social completa
rfc: string; // RFC (México)
taxRegime: string; // Régimen fiscal
// Domicilio fiscal
address: string;
city: string;
state: string;
zipCode: string;
country: string;
// Contacto
phone: string;
email: string;
website?: string;
// Logo y branding
logoUrl?: string;
primaryColor?: string; // Hex color
// Configuración
status: 'active' | 'inactive' | 'suspended';
createdAt: Date;
updatedAt: Date;
}
```
### Relación Usuario - Constructora
Un usuario puede pertenecer a **múltiples constructoras** con **diferentes roles**:
```typescript
interface UserConstructora {
userId: string; // UUID
constructoraId: string; // UUID
role: ConstructionRole; // director | engineer | resident | etc.
status: 'active' | 'inactive';
joinedAt: Date;
// Permisos adicionales (opcional)
customPermissions?: {
module: string;
permissions: ('create' | 'read' | 'update' | 'delete' | 'approve')[];
}[];
}
```
**Ejemplo:**
```json
{
"userId": "uuid-123",
"userName": "Juan Pérez",
"constructoras": [
{
"constructoraId": "uuid-A",
"constructoraName": "Constructora ABC",
"role": "director",
"status": "active"
},
{
"constructoraId": "uuid-B",
"constructoraName": "Constructora XYZ",
"role": "engineer",
"status": "active"
}
]
}
```
Juan Pérez es **Director** en Constructora ABC y **Ingeniero** en Constructora XYZ.
### Selector de Constructora
Al hacer login, si el usuario pertenece a múltiples constructoras:
1. Se muestra un **selector** con las constructoras activas
2. Usuario selecciona constructora de trabajo
3. Se guarda `constructoraId` en el token JWT
4. Todos los queries se filtran por `constructoraId` (RLS)
5. Usuario puede **cambiar de constructora** desde el menú sin re-login
---
## 👤 Gestión de Usuarios
### Modelo de Datos Completo
```typescript
interface User {
// Identificación
id: string; // UUID
email: string; // Único a nivel sistema
password: string; // Hash bcrypt
// Datos personales
firstName: string;
lastName: string;
fullName: string; // Concatenación automática
phone?: string;
mobilePhone?: string;
// Avatar
avatarUrl?: string;
// Estado de cuenta
status: AccountStatus; // active | inactive | suspended | locked
emailVerified: boolean;
emailVerifiedAt?: Date;
// Seguridad
lastLoginAt?: Date;
lastLoginIp?: string;
failedLoginAttempts: number; // Bloqueo tras 5 intentos
lockedUntil?: Date; // Bloqueo temporal
passwordChangedAt?: Date;
mustChangePassword: boolean; // Primer login
// Metadata
createdAt: Date;
updatedAt: Date;
createdBy?: string; // Usuario que creó la invitación
deletedAt?: Date; // Soft delete
}
```
### Estados de Cuenta
```typescript
enum AccountStatus {
ACTIVE = 'active', // Usuario activo, puede usar el sistema
INACTIVE = 'inactive', // Usuario dado de baja (puede reactivarse)
SUSPENDED = 'suspended', // Usuario temporalmente suspendido (sanción)
LOCKED = 'locked' // Bloqueado por intentos fallidos de login
}
```
#### Flujo de Estados
```mermaid
stateDiagram-v2
[*] --> active: Invitación aceptada
active --> suspended: Suspender (temporal)
suspended --> active: Reactivar
active --> inactive: Dar de baja
inactive --> active: Reactivar
active --> locked: 5 intentos fallidos
locked --> active: Admin desbloquea o timeout (30 min)
```
### Proceso de Creación de Usuario (Invitación)
**No hay registro público.** Solo invitación por administrador:
1. **Admin crea invitación:**
- Email del nuevo usuario
- Rol a asignar
- Constructora(s) de pertenencia
- Mensaje personalizado (opcional)
2. **Sistema genera token único:**
- Token de invitación válido por 7 días
- Link: `https://app.ejemplo.com/register?token=abc123xyz`
3. **Email enviado:**
- Asunto: "Invitación a plataforma ERP - [Constructora ABC]"
- Contiene link de registro
4. **Usuario completa registro:**
- Click en link
- Valida token (no expirado)
- Completa perfil (nombre, teléfono, foto)
- Define contraseña (requisitos de seguridad)
- Acepta términos y condiciones
- **Primer login automático**
5. **Usuario activo:**
- Estado: `active`
- Rol asignado: según invitación
- `mustChangePassword: false`
### CRUD de Usuarios
#### Crear Usuario (solo por invitación)
**Endpoint:** `POST /api/admin/users/invite`
**Request:**
```json
{
"email": "juan.perez@empresa.com",
"firstName": "Juan",
"lastName": "Pérez",
"constructoraId": "uuid-A",
"role": "engineer",
"customMessage": "Bienvenido al equipo de Proyecto Los Pinos"
}
```
**Response:**
```json
{
"invitationId": "uuid-inv-123",
"email": "juan.perez@empresa.com",
"token": "abc123xyz",
"expiresAt": "2025-11-27T23:59:59Z",
"invitationLink": "https://app.ejemplo.com/register?token=abc123xyz",
"emailSent": true
}
```
**Validaciones:**
- Email no existe previamente en el sistema
- Email con formato válido
- Rol válido (uno de los 7 roles)
- Constructora existe y está activa
- Usuario invitador tiene rol `director` o es super admin
#### Leer Usuarios
**Endpoint:** `GET /api/admin/users?constructoraId={uuid}&status={active|inactive|all}&role={role}&search={text}`
**Filters:**
- `constructoraId`: UUID (obligatorio, RLS)
- `status`: active | inactive | suspended | locked | all (default: active)
- `role`: Filtrar por rol
- `search`: Búsqueda por nombre o email (full-text search)
- `page`, `limit`: Paginación
**Response:**
```json
{
"users": [
{
"id": "uuid-123",
"fullName": "Juan Pérez",
"email": "juan.perez@empresa.com",
"role": "engineer",
"status": "active",
"avatarUrl": "https://...",
"lastLoginAt": "2025-11-20T10:30:00Z",
"createdAt": "2025-11-01T08:00:00Z"
}
],
"pagination": {
"total": 45,
"page": 1,
"limit": 20,
"totalPages": 3
}
}
```
#### Actualizar Usuario
**Endpoint:** `PATCH /api/admin/users/{userId}`
**Request:**
```json
{
"firstName": "Juan Carlos",
"phone": "+52 55 1234 5678",
"avatarUrl": "https://..."
}
```
**Campos editables:**
- Datos personales (firstName, lastName, phone, mobilePhone, avatarUrl)
- Status (solo admins)
- Rol (solo admins, con auditoría)
**Campos NO editables:**
- Email (es unique identifier)
- Password (endpoint separado)
- createdAt, createdBy
#### Cambiar Rol de Usuario
**Endpoint:** `PATCH /api/admin/users/{userId}/role`
**Request:**
```json
{
"constructoraId": "uuid-A",
"newRole": "director"
}
```
**Validaciones:**
- Solo Director o super admin pueden cambiar roles
- Se registra en auditoría (acción crítica)
- Notificación al usuario del cambio
#### Cambiar Estado de Usuario
**Endpoint:** `PATCH /api/admin/users/{userId}/status`
**Request:**
```json
{
"status": "suspended",
"reason": "Violación de políticas de uso",
"suspendedUntil": "2025-12-01T00:00:00Z" // Opcional
}
```
**Estados permitidos:**
- `active` `suspended`
- `active` `inactive`
- `suspended` `active`
- `inactive` `active`
- `locked` `active` (solo admin)
**Auditoría:**
- Se registra quién, cuándo y por qué cambió el estado
- Notificación al usuario suspendido/bloqueado
#### Eliminar Usuario (Soft Delete)
**Endpoint:** `DELETE /api/admin/users/{userId}`
**Comportamiento:**
- No eliminación física
- Set `deletedAt = now()`
- Status `inactive`
- Se desactivan todas las asignaciones a constructoras
- Se cierran todas las sesiones activas
- Se mantiene en auditoría
**Reactivación:**
- `POST /api/admin/users/{userId}/restore`
---
## 🔐 Row Level Security (RLS)
### Políticas en PostgreSQL
Todas las tablas sensibles tienen políticas RLS que **automáticamente** filtran por `constructoraId`:
```sql
-- Ejemplo: Tabla de proyectos
CREATE POLICY projects_isolation_policy ON projects
USING (constructora_id = current_setting('app.current_constructora_id')::uuid);
-- Ejemplo: Tabla de usuarios (solo ven usuarios de su empresa)
CREATE POLICY users_isolation_policy ON user_constructoras
USING (constructora_id = current_setting('app.current_constructora_id')::uuid);
```
### Configuración por Sesión
Al hacer login o cambiar de constructora:
```sql
-- Backend ejecuta al inicio de cada request
SET app.current_constructora_id = 'uuid-A';
SET app.current_user_id = 'uuid-123';
SET app.current_user_role = 'engineer';
```
**Resultado:** Todas las queries se filtran automáticamente.
```sql
-- El developer escribe:
SELECT * FROM projects;
-- PostgreSQL ejecuta (automático):
SELECT * FROM projects
WHERE constructora_id = 'uuid-A';
```
**Ventaja:** Imposible acceder a datos de otra empresa por error de código.
---
## 📊 Casos de Uso
### Caso 1: Invitar Nuevo Ingeniero
**Actor:** Director General de Constructora ABC
**Flujo:**
1. Director va a "Administración" "Usuarios" "Invitar usuario"
2. Completa formulario:
- Email: `ing.carlos@empresa.com`
- Nombre: Carlos Martínez
- Rol: Ingeniero
- Constructora: Constructora ABC
3. Click en "Enviar invitación"
4. Sistema genera token único
5. Email enviado a `ing.carlos@empresa.com`
6. Carlos recibe email, click en link
7. Carlos completa registro (nombre, teléfono, contraseña)
8. Acepta términos
9. **Login automático** Dashboard de Ingeniero
**Resultado:**
- Carlos es usuario activo
- Rol: `engineer` en Constructora ABC
- Acceso a módulos: Proyectos, Presupuestos, Control de Obra (CRUD)
### Caso 2: Usuario Multi-Empresa
**Actor:** Juan Pérez (Director en Empresa A, Ingeniero en Empresa B)
**Flujo:**
1. Juan hace login con `juan@email.com`
2. Sistema detecta que pertenece a 2 empresas
3. Muestra selector:
- 🏢 Constructora ABC (Director)
- 🏢 Constructora XYZ (Ingeniero)
4. Juan selecciona "Constructora ABC"
5. **Token JWT contiene:**
```json
{
"userId": "uuid-123",
"constructoraId": "uuid-A",
"role": "director"
}
```
6. Dashboard de Director (acceso completo)
7. Juan quiere revisar obra en Empresa B
8. Click en selector de empresa "Constructora XYZ"
9. **Token se regenera:**
```json
{
"userId": "uuid-123",
"constructoraId": "uuid-B",
"role": "engineer"
}
```
10. Dashboard de Ingeniero (acceso limitado)
**Resultado:**
- Sin re-login
- Datos completamente aislados entre empresas
- Permisos cambian según rol en cada empresa
### Caso 3: Suspensión Temporal
**Actor:** Director suspende a Residente
**Flujo:**
1. Director va a "Usuarios" Busca "Pedro González (Residente)"
2. Click en "Acciones" "Suspender usuario"
3. Modal de confirmación:
- Razón: "Investigación por incidente en obra"
- Suspender hasta: 2025-12-01
4. Click en "Confirmar"
5. Sistema:
- Cambia status de Pedro a `suspended`
- Cierra todas las sesiones activas
- Envía email a Pedro notificando suspensión
- Registra en auditoría (quién, cuándo, por qué)
6. Pedro intenta login Error: "Cuenta suspendida temporalmente. Contacte al administrador."
7. 2025-12-01 00:00:00 Cron job reactiva automáticamente
8. Pedro recibe email: "Tu cuenta ha sido reactivada"
**Resultado:**
- Suspensión temporal efectiva
- Trazabilidad completa
- Reactivación automática
---
## ✅ Criterios de Aceptación
### AC1: Invitación de Usuarios
**DADO** un Director de Constructora ABC
**CUANDO** invita a un nuevo usuario con email `nuevo@empresa.com` y rol `engineer`
**ENTONCES**
- Se genera token de invitación único
- Token válido por 7 días
- Email enviado con link de registro
- Usuario completa registro en < 5 minutos
- Primer login automático exitoso
- Usuario activo con rol `engineer`
### AC2: Multi-Tenancy Estricto
**DADO** dos usuarios de diferentes empresas (A y B)
**CUANDO** ambos hacen login simultáneamente
**ENTONCES**
- Usuario A solo ve datos de Empresa A
- Usuario B solo ve datos de Empresa B
- No es posible acceder a datos de otra empresa (bloqueado por RLS)
- Tests de penetración validan aislamiento
### AC3: Cambio de Empresa sin Re-Login
**DADO** un usuario que pertenece a 2 empresas
**CUANDO** cambia de empresa desde el selector
**ENTONCES**
- Token JWT se regenera con nuevo `constructoraId` y `role`
- Dashboard se actualiza mostrando datos de nueva empresa
- Permisos cambian según rol en nueva empresa
- No se requiere re-login
- Operación < 500ms
### AC4: Gestión de Estados de Cuenta
**DADO** un administrador
**CUANDO** suspende a un usuario
**ENTONCES**
- Estado cambia a `suspended`
- Todas las sesiones activas se cierran
- Usuario no puede hacer login
- Email de notificación enviado
- Auditoría registra la acción con razón
- Reactivación manual o automática funciona
### AC5: Bloqueo por Intentos Fallidos
**DADO** un usuario con contraseña incorrecta
**CUANDO** falla 5 veces consecutivas
**ENTONCES**
- Cuenta bloqueada automáticamente (`locked`)
- `lockedUntil` = now() + 30 minutos
- Mensaje: "Cuenta bloqueada. Intente en 30 minutos o contacte al administrador."
- Email de notificación al usuario
- Auditoría registra evento de seguridad
- Tras 30 min, desbloqueo automático
---
## 🧪 Escenarios de Prueba
### Test 1: Invitación Exitosa
```typescript
describe('RF-ADM-001: Invitación de usuarios', () => {
it('debe crear invitación y enviar email', async () => {
const director = await loginAs('director');
const response = await api.post('/admin/users/invite', {
email: 'test@empresa.com',
firstName: 'Test',
lastName: 'User',
constructoraId: director.constructoraId,
role: 'engineer'
});
expect(response.status).toBe(201);
expect(response.data.invitationId).toBeDefined();
expect(response.data.token).toBeDefined();
expect(response.data.emailSent).toBe(true);
// Validar que email fue enviado
const emails = await getEmailsSent();
expect(emails).toContainEqual(
expect.objectContaining({
to: 'test@empresa.com',
subject: expect.stringContaining('Invitación')
})
);
});
it('debe permitir registro con token válido', async () => {
const { token } = await createInvitation('nuevo@test.com', 'engineer');
const response = await api.post('/auth/register-by-invitation', {
token,
firstName: 'Nuevo',
lastName: 'Usuario',
password: 'SecurePass123!',
passwordConfirm: 'SecurePass123!'
});
expect(response.status).toBe(201);
expect(response.data.user.status).toBe('active');
expect(response.data.user.role).toBe('engineer');
expect(response.data.accessToken).toBeDefined();
});
it('debe rechazar token expirado', async () => {
const expiredToken = await createExpiredInvitation();
const response = await api.post('/auth/register-by-invitation', {
token: expiredToken,
firstName: 'Test',
lastName: 'User',
password: 'SecurePass123!'
});
expect(response.status).toBe(400);
expect(response.data.error).toBe('Invitation expired');
});
});
```
### Test 2: Multi-Tenancy Aislamiento
```typescript
describe('RF-ADM-001: Multi-tenancy isolation', () => {
it('debe aislar datos entre empresas', async () => {
// Usuario A en Empresa A
const userA = await loginAs('engineer', 'empresa-a');
// Usuario B en Empresa B
const userB = await loginAs('engineer', 'empresa-b');
// Crear proyecto en Empresa A
const projectA = await api.post('/projects', {
name: 'Proyecto A',
constructoraId: userA.constructoraId
}, { headers: { Authorization: userA.token } });
// Usuario B NO debe ver proyecto de A
const projectsB = await api.get('/projects', {
headers: { Authorization: userB.token }
});
expect(projectsB.data.projects).not.toContainEqual(
expect.objectContaining({ id: projectA.data.id })
);
// Intentar acceso directo (debe fallar)
const directAccess = await api.get(`/projects/${projectA.data.id}`, {
headers: { Authorization: userB.token }
});
expect(directAccess.status).toBe(404); // RLS lo bloquea
});
});
```
### Test 3: Cambio de Rol (Auditoría)
```typescript
describe('RF-ADM-001: Cambio de rol', () => {
it('debe auditar cambio de rol', async () => {
const director = await loginAs('director');
const engineer = await createUser('engineer');
// Cambiar rol de engineer a director
const response = await api.patch(
`/admin/users/${engineer.id}/role`,
{ newRole: 'director' },
{ headers: { Authorization: director.token } }
);
expect(response.status).toBe(200);
// Validar auditoría
const auditLogs = await api.get('/admin/audit-logs', {
params: { entityId: engineer.id, action: 'role_change' },
headers: { Authorization: director.token }
});
expect(auditLogs.data.logs).toContainEqual(
expect.objectContaining({
userId: director.id,
action: 'role_change',
changes: expect.arrayContaining([
{ field: 'role', oldValue: 'engineer', newValue: 'director' }
])
})
);
});
});
```
---
## 🔗 Referencias
- **Especificación técnica:** [ET-ADM-001](../especificaciones/ET-ADM-001-rbac-multi-tenancy.md)
- **Historia de usuario:** [US-ADM-001](../historias-usuario/US-ADM-001-crear-usuarios.md), [US-ADM-002](../historias-usuario/US-ADM-002-asignar-roles-permisos.md)
- **Módulo base:** [README.md](../README.md)
- **Análisis GAMILIT:** [ANALISIS-REUTILIZACION-GAMILIT.md](../../ANALISIS-REUTILIZACION-GAMILIT.md)
---
**Generado:** 2025-11-20
**Versión:** 1.0
**Autor:** Sistema de Documentación Técnica
**Estado:** Completo