21 KiB
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
- Multi-tenancy seguro: Múltiples constructoras usan el mismo sistema sin ver datos ajenos
- Control de acceso por rol: Cada usuario solo accede a funcionalidades según su rol
- Gestión centralizada: Administradores gestionan usuarios desde un solo lugar
- Onboarding rápido: Nuevos usuarios operan en < 5 minutos desde invitación
- Trazabilidad: Auditoría completa de cambios en usuarios y permisos
Objetivos Técnicos
- Row Level Security (RLS): Filtrado automático de datos por
constructoraId - Invitación controlada: No registro público, solo por invitación de administrador
- Estados de cuenta: Manejo de usuarios activos, inactivos, suspendidos, bloqueados
- Validación en 3 capas: Frontend → Backend → Database
- 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
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:
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:
{
"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:
- Se muestra un selector con las constructoras activas
- Usuario selecciona constructora de trabajo
- Se guarda
constructoraIden el token JWT - Todos los queries se filtran por
constructoraId(RLS) - Usuario puede cambiar de constructora desde el menú sin re-login
👤 Gestión de Usuarios
Modelo de Datos Completo
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
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
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:
-
Admin crea invitación:
- Email del nuevo usuario
- Rol a asignar
- Constructora(s) de pertenencia
- Mensaje personalizado (opcional)
-
Sistema genera token único:
- Token de invitación válido por 7 días
- Link:
https://app.ejemplo.com/register?token=abc123xyz
-
Email enviado:
- Asunto: "Invitación a plataforma ERP - [Constructora ABC]"
- Contiene link de registro
-
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
-
Usuario activo:
- Estado:
active - Rol asignado: según invitación
mustChangePassword: false
- Estado:
CRUD de Usuarios
Crear Usuario (solo por invitación)
Endpoint: POST /api/admin/users/invite
Request:
{
"email": "juan.perez@empresa.com",
"firstName": "Juan",
"lastName": "Pérez",
"constructoraId": "uuid-A",
"role": "engineer",
"customMessage": "Bienvenido al equipo de Proyecto Los Pinos"
}
Response:
{
"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
directoro 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 rolsearch: Búsqueda por nombre o email (full-text search)page,limit: Paginación
Response:
{
"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:
{
"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:
{
"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:
{
"status": "suspended",
"reason": "Violación de políticas de uso",
"suspendedUntil": "2025-12-01T00:00:00Z" // Opcional
}
Estados permitidos:
active→suspendedactive→inactivesuspended→activeinactive→activelocked→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:
-- 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:
-- 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.
-- 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:
- Director va a "Administración" → "Usuarios" → "Invitar usuario"
- Completa formulario:
- Email:
ing.carlos@empresa.com - Nombre: Carlos Martínez
- Rol: Ingeniero
- Constructora: Constructora ABC
- Email:
- Click en "Enviar invitación"
- Sistema genera token único
- Email enviado a
ing.carlos@empresa.com - Carlos recibe email, click en link
- Carlos completa registro (nombre, teléfono, contraseña)
- Acepta términos
- Login automático → Dashboard de Ingeniero
Resultado:
- Carlos es usuario activo
- Rol:
engineeren 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:
- Juan hace login con
juan@email.com - Sistema detecta que pertenece a 2 empresas
- Muestra selector:
- 🏢 Constructora ABC (Director)
- 🏢 Constructora XYZ (Ingeniero)
- Juan selecciona "Constructora ABC"
- Token JWT contiene:
{ "userId": "uuid-123", "constructoraId": "uuid-A", "role": "director" } - Dashboard de Director (acceso completo)
- Juan quiere revisar obra en Empresa B
- Click en selector de empresa → "Constructora XYZ"
- Token se regenera:
{ "userId": "uuid-123", "constructoraId": "uuid-B", "role": "engineer" } - 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:
- Director va a "Usuarios" → Busca "Pedro González (Residente)"
- Click en "Acciones" → "Suspender usuario"
- Modal de confirmación:
- Razón: "Investigación por incidente en obra"
- Suspender hasta: 2025-12-01
- Click en "Confirmar"
- 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é)
- Cambia status de Pedro a
- Pedro intenta login → Error: "Cuenta suspendida temporalmente. Contacte al administrador."
- 2025-12-01 00:00:00 → Cron job reactiva automáticamente
- 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
constructoraIdyrole - ✅ 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
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
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)
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
- Historia de usuario: US-ADM-001, US-ADM-002
- Módulo base: README.md
- Análisis GAMILIT: ANALISIS-REUTILIZACION-GAMILIT.md
Generado: 2025-11-20 Versión: 1.0 Autor: Sistema de Documentación Técnica Estado: ✅ Completo