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

20 KiB
Raw Blame History

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)

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 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:
    PATCH /api/estimations/5/approve
    Authorization: Bearer <token-pedro>
    
  6. Backend rechaza:
    {
      "statusCode": 403,
      "message": "Forbidden: You don't have permission to approve estimations",
      "error": "Forbidden"
    }
    
  7. 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:

  1. Carlos login → Token contiene:
    {
      "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:
    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:
    {
      "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:
    {
      "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

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


Generado: 2025-11-20 Versión: 1.0 Autor: Sistema de Documentación Técnica Estado: Completo