Saltar a contenido

📚 Unidad ISS-12 · Features RoleUsers y ResourceRoles — capa 🧠 APRENDER

🛠 Construir este bloque → · ✅ Condiciones de cierre (GATE) · 📝 Evaluación

Capa Página Para qué
🧠 Aprender esta página comprender, explicar y relacionar
🛠 Construir Features RoleUsers y ResourceRoles ejecutar, programar y verificar
✅ GATE Cierre de la unidad condición para pasar al bloque siguiente

Mapa de correspondencias. Cada fila enlaza el mismo tema en las dos capas de la unidad; los enlaces apuntan a secciones reales del material (anclas de MkDocs, sin acentos).

Tema 🧠 Aprender (esta página) 🛠 Construir (ISS técnico)
Ruta y ficha de la unidad Ruta de aprendizaje · Ficha del ISS Contenido de la unidad
Mapas y estructura Mapa mental · Mapa del backend · Árbol de archivos Contenido de la unidad
Recorrido y comandos Comandos explicados · Recorrido paso a paso Contenido de la unidad
Diagnóstico Diagnóstico Criterios de aceptación
Evaluación y cierre Criterios · Evaluación · GATE Condiciones de cierre · Cierre

🖥 Presentación del ISS

Presentación de la unidad. Diapositivas de ISS-12 — Features RoleUsers y ResourceRoles (8 diapositivas). Se visualiza aquí, dentro del sitio; no hace falta descargar nada para estudiarla.

⛶ Ver presentación completa ⬇ Archivo editable (.pptx)

8 diapositivas · se visualiza dentro del sitio (archivo editable .pptx como opción secundaria).


🎬 Video explicativo

Recorrido audiovisual de la unidad. Este video explica, bloque por bloque, la matriz del ISS-12: asignar, conceder y reconcileRole. No hay borrado físico. El seed deja 65 concesiones: 58 de ADMIN y 7 de SELLER. Las rutas de negocio siguen abiertas.

6:02 · narración en español · subtítulos activables desde el reproductor.


ISS-12 — Cuaderno de aprendizaje visual

Tema

La matriz RBAC: las dos tablas pivote (role_users y resource_roles) que asignan roles a usuarios y conceden recursos a roles, convirtiendo los catálogos de roles y resources en autorización real.

Fuente técnica autoritativa

Archivo fuente ../manual/14-ISS-12-auth-role-users-resource-roles.md
Nombre del archivo 14-ISS-12-auth-role-users-resource-roles.md
Estado Solo lectura — este cuaderno no modifica el ISS
Alcance 1.701 líneas, 50 bloques de código, los criterios 17.1 … 17.8 y el DoD
Secciones del ISS 17.1 DTOs RoleUsers · 17.2 repository/service/controller/rutas · 17.3 seeder y swagger · 17.4 DTOs ResourceRoles · 17.5 repository/service/controller/rutas · 17.6 reconcileRole · 17.7 seeder y swagger · 17.8 pruebas HTTP

Este cuaderno es una capa pedagógica sobre ese archivo. Su contenido técnico completo (DTOs, repositorios, servicios, rutas, seeders, swagger y pruebas HTTP) aparece aquí íntegro y verbatim en la sección Recorrido del ISS, paso a paso. El ISS manda; el cuaderno explica el por qué.

Pregunta que responde: ¿de qué archivo nace este cuaderno y por qué no se puede modificar?

Regla del ISS

El ISS lo declara así, literalmente:

Objetivo: construir las dos tablas pivote que convierten el modelo en autorización real.

Bloqueado por: ISS-11.

La condición que el propio ISS exige para darse por terminado es doble: que el código compile y que la matriz sea determinista. En palabras del propio DoD:

  • [ ] Todos los criterios de aceptación (17.1 … 17.8) cumplidos
  • [ ] Reasignar un rol ya activo → 409; sobre uno inactivo → reactiva
  • [ ] Reejecutar npm run db:seed deja resource_roles en 65 filas activas exactas
  • [ ] npx tsc --noEmit sin errores y npm run dev arranca

Es decir: no basta con que existan las tablas. La matriz tiene que ser exacta y reproducible: sembrar dos veces debe dejar el mismo resultado (ADMIN 58 + SELLER 7 = 65), no duplicar filas.

Pregunta que responde: ¿cuál es la condición mínima para dar el ISS-12 por cerrado?

Cómo leer este cuaderno

Cada concepto se presenta tres veces, desde tres ángulos distintos:

                 CONCEPTO
                    │
        ┌───────────┼───────────┐
        ▼           ▼           ▼
   EXPLICACIÓN    CÓDIGO      VISUAL
        │           │           │
    ¿qué es?    ¿dónde está?  ¿cómo lo
    ¿por qué?   ¿qué hace?     visualizo?
    ¿para qué?  ¿cómo opera?  ¿con qué
                               se relaciona?

Pregunta que responde: ¿cómo está organizado este cuaderno y qué espero encontrar en cada parte?

El recorrido de lectura es siempre el mismo:

EXPLICACIÓN
    ↓
CÓDIGO
    ↓
VISUAL

Primero entiendes el problema (por qué hacen falta dos tablas pivote). Después ves el código exacto que lo resuelve (insertado verbatim desde el ISS). Y por último lo miras de forma visual: la cadena completa, el ciclo de vida de una concesión y el flujo de una petición.

Este cuaderno contiene los mismos seis componentes que el resto del curso:

Componente Dónde vive Para qué sirve
Texto todas las secciones entender el por qué
Código Recorrido del ISS ver el qué exacto
Diagramas Mapa mental, Mapa del backend, La cadena de autorización, Asignar, retirar y reactivar, Flujos ver el cómo se conecta
Preguntas Evaluación comprobar que entendiste
Evaluación Evaluación practicar y autoevaluarte
GATE GATE saber si puedes pasar al siguiente ISS

Pregunta que responde: ¿qué seis componentes forman este cuaderno y para qué sirve cada uno?

Ruta de aprendizaje

Esta ruta es específica de este ISS: sigue el orden real de las ocho secciones del manual (17.1 … 17.8), del dato al efecto.

Entender el problema (Role y Resource son catálogos inertes)
    ↓
role_users: asignar un rol a un usuario (17.1 → 17.3)
    ↓
resource_roles: conceder un recurso a un rol (17.4 → 17.5)
    ↓
reconcileRole: la matriz determinista (17.6)
    ↓
Seeder de la matriz: ADMIN 58 + SELLER 7 (17.7)
    ↓
Pruebas HTTP y efecto inmediato sin reinicio (17.8)
    ↓
Verificar (GATE): tsc + seed + conteos

Fíjate en lo que no aparece: no hay middlewares, no hay authenticate/authorize, no hay protección de rutas de negocio. La matriz se construye aquí, pero quien la consume en cada petición llega en el ISS-13.

Pregunta que responde: ¿en qué orden se construye la matriz y qué queda fuera de este ISS?

Índice

  1. Ficha del ISS
  2. Mapa mental del ISS
  3. Mapa del backend
  4. La cadena de autorización
  5. Asignar, retirar y reactivar
  6. Árbol de archivos
  7. Anatomía del código
  8. Comandos explicados
  9. Flujos
  10. Recorrido del ISS, paso a paso
  11. Diagnóstico
  12. Conexión con el resto del curso
  13. Criterios de aceptación
  14. Evaluación
  15. GATE
  16. Glosario

Ficha del ISS

Campo Valor
ISS ISS-12
Título Features RoleUsers y ResourceRoles (asignar roles y conceder permisos)
Objetivo Construir las dos tablas pivote que convierten el modelo en autorización real
Fase Fase II — Auth con RBAC
Tecnología principal Sequelize (relaciones N:M con tablas pivote), Express 5 + TypeScript
Depende de ISS-11 — Features Roles y Resources
Habilita de ISS-13 — Middlewares de acceso
Archivos creados features/auth/role-users/ (10 archivos) y features/auth/resource-roles/ (11 archivos)
Archivos parcheados src/routes/index.ts, src/config/index.ts, src/database/seeders/index.ts
Componentes incorporados Features completas RoleUsers y ResourceRoles (DTOs, repository, service, controller, routes, seeder, swagger, http) + reconcileRole + findEffectiveForUser
Verificación principal npx tsc --noEmit y npm run db:seed → role_users = 2, resource_roles = 65
Resultado esperado Matriz RBAC operativa: admin → ADMIN (58) y seller → SELLER (7)
GATE npx tsc --noEmit sin errores + seed determinista (2 asignaciones, 65 concesiones)

Qué implementamos AHORA

En términos de datos y de API, este ISS entrega:

  • La tabla pivote role_users operativa: POST /api/asignaciones-rol asigna un rol a un usuario; /deactivate y /reactivate cambian el estado sin borrar filas.
  • La tabla pivote resource_roles operativa: POST /api/concesiones-rol concede un recurso a un rol; /deactivate y /reactivate gestionan el permiso.
  • La consulta de autorización efectiva findEffectiveForUser(userId), que recorre la cadena RBAC completa y exige los cuatro eslabones activos.
  • reconcileRole(roleId, resourceIds): deja el catálogo de un rol exactamente en el conjunto pedido (concede, reactiva y retira en una transacción).
  • Seeders deterministas: role_users (admin→ADMIN, seller→SELLER) y resource_roles (ADMIN 58, SELLER 7).
  • Swagger y archivos .http de ambos features.

El efecto es inmediato y por datos: la próxima petición que consulte la matriz ya ve el cambio, sin reiniciar el servidor.

Qué todavía NO implementamos

Este es el punto que más confusión crea, así que queda explícito: aquí construimos la matriz, pero todavía no la hacemos cumplir.

No se implementa aquí Llega en
Middlewares authenticate y authorize (los consumidores de la matriz) ISS-13
Protección de las 5 rutas de negocio de Fase I (siguen sin auth) ISS-13
El "deny by default" aplicado a (method, path) en cada petición ISS-13
Feature RefreshTokens (rotación, hash SHA-256, detección de reúso) ISS-14
Sesión completa: login, refresh, logout y perfil (/api/sesion/...) ISS-15
Caché de permisos nunca (la autorización se resuelve por datos en cada petición)
Borrado físico de asignaciones o concesiones nunca (es lógico a propósito, para preservar auditoría)

Ojo con la trampa habitual: ver POST /api/concesiones-rol funcionando no significa que el sistema ya esté protegido. Significa que el dato del permiso existe. Quien lo lee para decir «403» es el middleware del ISS-13. Hasta entonces, las rutas de negocio siguen abiertas.

Mapa mental del ISS

mindmap
  root((ISS-12<br/>Matriz RBAC))
    La cadena
      user
      role_users
      roles
      resource_roles
      resources
      Cuatro eslabones activos
    RoleUsers
      DTOs
      assign
      deactivate
      reactivate
      seeder admin y seller
    ResourceRoles
      DTOs
      grant
      deactivate
      reactivate
      reconcileRole
      findEffectiveForUser
    Seeders
      ADMIN 58
      SELLER 7
      Deterministas
    Verificacion
      tsc sin errores
      seed idempotente
    GATE
      role_users 2
      resource_roles 65

Pregunta que responde: ¿de qué trata este ISS y qué piezas lo componen?

Mapa del backend

Esta representación responde siempre a la misma pregunta: ¿en qué parte del backend estamos?

IMPLEMENTADO HASTA ESTE ISS
───────────────────────────

   HTTP
    ↓
   Routes ──────────────── ✅  (los 5 features de negocio siguen SIN PROTEGER)
    ↓
   Controller ──────────── ✅  (RoleUsers y ResourceRoles nuevos)
    ↓
   Service ─────────────── ✅  (assign/deactivate/reactivate · grant/reconcileRole)
    ↓
   Repository ──────────── ✅  (findEffectiveForUser: la consulta RBAC)
    ↓
   Model ───────────────── ✅  (RoleUser, ResourceRole — pivotes)
    ↓
   Sequelize
    ↓
   Base de datos

   Seguridad (Fase II)
   ├── users            ✅  ISS-10
   ├── roles            ✅  ISS-11
   ├── resources        ✅  ISS-11 (catálogo de 58)
   ├── role_users       ✅  ISS-12 ← la asignación
   └── resource_roles   ✅  ISS-12 ← la concesión

   Middlewares authenticate / authorize   ⬜  llegan en ISS-13
   Rutas de negocio protegidas            ⬜  llegan en ISS-13


OBJETIVO DE ARQUITECTURA
────────────────────────

   HTTP
    ↓
   authenticate (JWT)            🎯  ISS-13
    ↓
   authorize (JWT + RBAC)        🎯  ISS-13  ← CONSUME la matriz de este ISS
    ↓
   Routes → Controller → Service → Repository → Model → Sequelize → BD

   refresh_tokens                🎯  ISS-14
   sesión (login/refresh/logout) 🎯  ISS-15

   ⬜  No existe entidad `Permission`: el permiso es la tupla
       `(role_id, resource_id)` en `resource_roles`.

Pregunta que responde: ¿qué capas existen ya y cuáles son todavía objetivo de arquitectura?

En este punto el proyecto ya tiene la matriz completa como datos, pero el guardia que la lee en cada petición todavía no existe. Ese guardia es el ISS-13.

La cadena de autorización

Esta es la sección central del cuaderno. Todo lo demás la sirve.

El RBAC de este laboratorio no viaja en el token: el token solo lleva la identidad (sub, username, jti). La autorización se resuelve en cada petición recorriendo una cadena de cinco piezas:

user → role_users → roles → resource_roles → resources

Léela como una frase: «un usuario tiene asignaciones; esas asignaciones apuntan a roles; esos roles tienen concesiones; esas concesiones apuntan a recursos; los recursos son la operación (method, path).»

erDiagram
    USERS ||--o{ ROLE_USERS : "tiene asignaciones"
    ROLES ||--o{ ROLE_USERS : "es asignado en"
    ROLES ||--o{ RESOURCE_ROLES : "recibe concesiones"
    RESOURCES ||--o{ RESOURCE_ROLES : "es concedido en"

    USERS {
        int id PK
        string username
        string email
        enum status
    }
    ROLES {
        int id PK
        string name
        enum status
    }
    ROLE_USERS {
        int id PK
        int user_id FK
        int role_id FK
        enum status
    }
    RESOURCE_ROLES {
        int id PK
        int role_id FK
        int resource_id FK
        enum status
    }
    RESOURCES {
        int id PK
        string method
        string path
        enum status
    }

Pregunta que responde: ¿qué tablas forman la cadena de autorización y cómo se enlazan por clave foránea?

La regla que lo gobierna todo: la cadena se corta si cualquier eslabón está inactive. No hay «permisos a medias». La consulta de autorización efectiva (findEffectiveForUser) exige status = 'active' en los cuatro eslabones:

resource_roles.status = active      ← la concesión
   AND roles.status    = active      ← el rol
   AND role_users.status = active     ← la asignación (del usuario pedido)
   AND resources.status  = active     ← el recurso

Si falta una fila o cualquier eslabón está inactivo, el recurso simplemente no aparece en el resultado. Y un resultado vacío se traduce, en el middleware del ISS-13, en deny by default: sin concesión → 403.

Esta es la razón de que retirar un rol tenga efecto inmediato: como los permisos no viajan en el token, no hay que esperar a que caduque para que la revocación surta efecto. La próxima petición vuelve a recorrer la cadena y ya no encuentra el eslabón.

Pregunta que responde: ¿por qué la revocación de un permiso tiene efecto inmediato aunque el token siga vigente?

Por qué dos tablas pivote y no una

Podrías pensar que bastaría con guardar los permisos directamente en el usuario. No es así, y la razón es de diseño:

  • role_users conecta usuario ↔ rol. Responde «¿quién tiene qué rol?».
  • resource_roles conecta rol ↔ recurso. Responde «¿qué concede qué rol?».

Separarlas permite que un permiso se conceda una sola vez (al rol) y lo hereden todos los usuarios con ese rol. Si mañana cambias el catálogo de ADMIN, no tocas ni una fila de role_users: tocas resource_roles. Además, no existe una entidad Permission: el permiso es la tupla (role_id, resource_id) materializada en resource_roles.

Asignar, retirar y reactivar

ASIGNAR y CONCEDER suenan parecido, pero operan en eslabones distintos de la cadena. Esta tabla lo fija:

Operación Tabla Pregunta que responde Endpoint
Asignar un rol a un usuario role_users ¿quién tiene qué rol? POST /api/asignaciones-rol
Conceder un recurso a un rol resource_roles ¿qué permite qué rol? POST /api/concesiones-rol
Retirar (asignación o concesión) ambas ¿cómo quito sin perder historial? PATCH .../:id/deactivate
Reactivar (asignación o concesión) ambas ¿cómo devuelvo lo retirado? PATCH .../:id/reactivate

Ambas tablas usan borrado lógico. Retirar no es DELETE: es status = inactive. ¿Por qué?

  • Auditoría: se conserva quién tuvo qué rol y qué concedía cada rol, aunque hoy no lo tenga.
  • Reversibilidad: reactivar es volver a poner active, sin recrear la fila.
  • Unicidad: la restricción única (user_id, role_id) (y (role_id, resource_id)) impide duplicados. Como la fila nunca se borra, «volver a asignar» siempre es reactivar, nunca chocar.

De ahí que asignar sea un upsert lógico con tres desenlaces:

Situación de (user_id, role_id) Resultado
No existe se crea la fila active
Existe active 409 (ya está asignado)
Existe inactive se reactiva (no se duplica)

El ciclo de vida de una concesión (idéntico para una asignación) es:

stateDiagram-v2
    [*] --> inactive : "fila creada en estado por defecto"
    inactive --> active : "grant / assign (o reactivate)"
    active --> active : "grant repetido → 409"
    active --> inactive : "deactivate (borrado lógico)"
    inactive --> inactive : "deactivate repetido"
    active --> [*] : "nunca se borra la fila"

Pregunta que responde: ¿qué estados atraviesa una concesión y por qué nunca se borra la fila?

Fíjate en la última transición: active --> [*] está etiquetada «nunca se borra la fila». Es deliberado: no hay arista hacia un borrado físico. El estado terminal real es inactive, y desde ahí siempre se puede volver.

El mecanismo determinista: reconcileRole

Cuando administras decenas de permisos a mano, es fácil que un rol acumule concesiones por accidente. reconcileRole(roleId, resourceIds) resuelve eso: recibe la lista exacta de recursos que un rol debe tener y deja la tabla en ese estado.

Recurso en la lista Fila previa Acción
sí no existe concede
sí inactive reactiva
sí active no toca
no active retira (borrado lógico)

Es total y determinista: al terminar, el conjunto de concesiones activas del rol es exactamente la lista recibida. Y todo ocurre dentro de una withTransaction: o el rol queda con ese catálogo exacto, o no se toca nada.

Por eso el seeder puede ejecutarse mil veces sin duplicar:

reconcileRole(ADMIN,  RESOURCE_CATALOG)  → 58 activas
reconcileRole(SELLER, SELLER_RESOURCES)  →  7 activas

Pregunta que responde: ¿cómo garantiza reconcileRole que reejecutar el seeder no acumule permisos?

Árbol de archivos

Estructura antes

Antes del ISS-12, la Fase II ya tiene la base de seguridad (ISS-09), el feature users (ISS-10) y los features roles y resources (ISS-11). Las tablas pivote existen como modelos declarados en ISS-09, pero son catálogos inertes: nadie los puebla ni los consulta como matriz.

app-storelab-express-ii/
├── src/
│   ├── config/index.ts                     △ (cableado de modelos y rutas)
│   ├── routes/index.ts                     △ (registro de features)
│   ├── database/seeders/index.ts           △ (orquestador de seeders)
│   └── features/
│       ├── business/                        Fase I (5 features)
│       └── auth/
│           ├── rbac.associations.ts         grafo User↔Role↔Resource (ISS-09)
│           ├── users/                        (ISS-10)
│           ├── roles/                        (ISS-11)
│           ├── resources/                    (ISS-11 — catálogo de 58)
│           ├── role-users/
│           │   └── role-user.model.ts        modelo pivote, inerte
│           └── resource-roles/
│               └── resource-role.model.ts    modelo pivote, inerte

Archivos creados / modificados en este ISS

Leyenda: ★ = creado en este ISS · △ = archivo existente parcheado.

src/
├── config/index.ts                                                          △
├── routes/index.ts                                                          △
├── database/seeders/index.ts                                                △
└── features/auth/
    ├── role-users/
    │   ├── dto/
    │   │   ├── create-role-user.dto.ts                                      ★
    │   │   ├── role-user-response.dto.ts                                    ★
    │   │   └── index.ts                                                     ★
    │   ├── role-users.repository.ts                                         ★
    │   ├── role-users.service.ts                                            ★
    │   ├── role-users.controller.ts                                         ★
    │   ├── role-users.routes.ts                                             ★
    │   ├── role-users.seeder.ts                                             ★
    │   ├── role-users.swagger.ts                                            ★
    │   └── http/
    │       └── role-users.assign.http                                       ★
    └── resource-roles/
        ├── dto/
        │   ├── create-resource-role.dto.ts                                  ★
        │   ├── list-resource-roles.dto.ts                                   ★
        │   ├── resource-role-response.dto.ts                                ★
        │   └── index.ts                                                     ★
        ├── resource-roles.repository.ts                                     ★
        ├── resource-roles.service.ts                                        ★
        ├── resource-roles.controller.ts                                     ★
        ├── resource-roles.routes.ts                                         ★
        ├── resource-roles.seeder.ts                                         ★
        ├── resource-roles.swagger.ts                                        ★
        └── http/
            └── resource-roles.grant.http                                    ★

Los tres parches (△) son de cableado, no de lógica: registrar las rutas nuevas en routes/index.ts, montarlas y seguir importando los modelos en config/index.ts, y encadenar seedRoleUsers() y seedResourceRoles() en el orquestador de database/seeders/index.ts.

Estructura después

app-storelab-express-ii/
├── src/
│   ├── config/index.ts                     △ ahora monta los 7 features de auth
│   ├── routes/index.ts                     △ ahora registra RoleUsers y ResourceRoles
│   ├── database/seeders/index.ts           △ ahora ejecuta seedRoleUsers y seedResourceRoles
│   └── features/
│       ├── business/
│       └── auth/
│           ├── rbac.associations.ts
│           ├── users/ roles/ resources/
│           ├── role-users/                                            ★ NUEVO
│           │   ├── role-user.model.ts
│           │   ├── dto/{create-role-user,role-user-response,index}.ts
│           │   ├── role-users.{repository,service,controller,routes,seeder,swagger}.ts
│           │   └── http/role-users.assign.http
│           └── resource-roles/                                        ★ NUEVO
│               ├── resource-role.model.ts
│               ├── dto/{create-resource-role,list-resource-roles,resource-role-response,index}.ts
│               ├── resource-roles.{repository,service,controller,routes,seeder,swagger}.ts
│               └── http/resource-roles.grant.http

Pregunta que responde: ¿qué archivos nacen en este ISS y cuáles solo se tocan para registrarlos?

Anatomía del código

Esta sección despieza los archivos con más carga conceptual del ISS. No repite el código: lo explica. El código íntegro está en el Recorrido del ISS.

Archivo: src/features/auth/role-users/role-users.repository.ts

Propósito

Es la capa que habla con Sequelize para la tabla role_users. Su responsabilidad es doble: exponer las consultas CRUD de las asignaciones y ofrecer la búsqueda clave findByUserAndRole, que habilita la semántica de create-or-reactivate.

Explicación

  • El include reutilizable SUMMARIES trae un resumen del usuario (id, username, email) y del rol (id, name). La contraseña no se proyecta aquí: se excluye en el propio attributes, de modo que nunca sale de la base de datos.
  • findAllActive() filtra por status: "active": la lista que devuelve la API solo muestra asignaciones vigentes.
  • findByUserAndRole() es el método que evita duplicados: busca la pareja sin filtrar por estado, porque el service necesita saber si existe aunque esté inactiva para poder reactivarla.
  • create() y update() son los dos únicos caminos de escritura. No hay destroy(): el borrado físico no existe en este feature.

Se conecta con

  • Entrada: lo llama RoleUsersService.
  • Salida: llama a los modelos RoleUser, User y Role a través de Sequelize.

Archivo: src/features/auth/role-users/role-users.service.ts

Propósito

Concentra las reglas de negocio de la asignación usuario-rol. Aquí vive la diferencia entre crear, reactivar e informar un conflicto.

Explicación

  • assign() valida que lleguen user_id y role_id (400 si faltan) y después exige que ambos extremos estén activos con assertUserActive() y assertRoleActive() (404 en caso contrario). Un eslabón inactivo haría inútil la asignación, así que se rechaza de entrada.
  • Si la pareja ya existe y está active, lanza 409; si existe inactive, la reactiva y recarga el resultado con sus resúmenes.
  • deactivate() y reactivate() cambian el estado. findOrFail(id, onlyActive) centraliza la búsqueda: por defecto solo acepta asignaciones activas, y reactivate la llama con onlyActive = false para poder encontrar las inactivas.
  • El reload() posterior garantiza que la respuesta incluya los include de resumen, no solo la instancia recién modificada.

Se conecta con

  • Entrada: lo llama RoleUsersController.
  • Salida: usa RoleUsersRepository, UsersRepository y RolesRepository; lanza AppError.

Archivo: src/features/auth/role-users/role-users.routes.ts

Propósito

Declara los cinco endpoints administrativos de la asignación y su modalidad de acceso.

Explicación

  • Orden de operaciones del proyecto: getAll → getOne → assign → deactivate → reactivate.
  • Las cinco rutas van con authenticate, authorize: la gestión de la matriz es, por definición, un recurso privilegiado (GET/POST /api/asignaciones-rol, etc.).
  • No hay DELETE: /:id/deactivate y /:id/reactivate son PATCH, coherentes con el borrado lógico.

Se conecta con

  • Entrada: la registra el parche src/routes/index.ts y la monta src/config/index.ts.
  • Salida: llama a RoleUsersController; usa authenticate/authorize de ../access (cuya implementación llega en el ISS-13).

Archivo: src/features/auth/resource-roles/resource-roles.repository.ts

Propósito

Es el corazón del RBAC a nivel de datos: contiene la consulta que recorre la cadena completa de autorización y las consultas auxiliares de la concesión.

Explicación

  • findAllActiveFiltered() implementa los dos filtros que permiten auditar la matriz desde ambos lados: ?role_id= («¿qué concede este rol?») y ?resource_id= («¿qué roles conceden este recurso?»).
  • findAllByRole() devuelve activas e inactivas de un rol: es la entrada de reconcileRole, que necesita ver lo que sobra para retirarlo.
  • findByRoleAndResource() habilita la misma semántica create-or-reactivate que en role_users.
  • findEffectiveForUser(userId) es la joya: usa include con required: true (INNER JOIN) para exigir los cuatro eslabones active. El include del RoleUser se proyecta con attributes: [] porque solo interesa que exista y cumpla el where; no hace falta traer sus columnas.
  • Los contadores (countActiveByRole, countActive, countActiveByResources) existen para que el seeder y la verificación puedan confirmar los números de la matriz.

Se conecta con

  • Entrada: lo llama ResourceRolesService.
  • Salida: consulta ResourceRole, Role, Resource y RoleUser; produce EffectivePermissionDto.

Archivo: src/features/auth/resource-roles/resource-roles.service.ts

Propósito

Materializa la frase «Role + Resource = permiso». Es donde conceder, retirar y reconciliar toman forma de regla de negocio.

Explicación

  • grant() valida que rol y recurso existan y estén activos; si la concesión ya está activa lanza 409, y si estaba inactiva la reactiva.
  • deactivate() retira solo esa operación: el resto de permisos del rol siguen vigentes.
  • findEffectiveForUser() delega en el repository y devuelve la lista plana (method, path, role_name) que consumirá el authorize del ISS-13.
  • reconcileRole() es el método estrella: construye un Set con los recursos deseados, recorre los existentes y decide conceder / reactivar / retirar. Devuelve un ReconcileResult con activated, deactivated y total_active, todo dentro de withTransaction.

Se conecta con

  • Entrada: lo llama ResourceRolesController (y el seeder, directamente).
  • Salida: usa ResourceRolesRepository, RolesRepository y ResourcesRepository; usa withTransaction.

Archivo: src/features/auth/resource-roles/resource-roles.seeder.ts

Propósito

Construye la matriz de permisos. Es el archivo que convierte el catálogo de recursos (código) en filas de resource_roles (datos).

Explicación

  • Instancia el ResourceRolesService y usa reconcileRole, no inserciones a mano. Ese detalle es el que hace el seeder idempotente.
  • Lee los recursos activos de la base y construye un Map "METHOD /path" → id. La función idsFor() traduce el catálogo en código a los resource_id reales: el seeder nunca codifica ids numéricos a fuego.
  • Aplica reconcileRole a ADMIN con RESOURCE_CATALOG (58) y a SELLER con SELLER_RESOURCES (7), e imprime el resultado de cada reconciliación.
  • Si un rol no existe, lo omite con un mensaje, sin romper la ejecución.

Se conecta con

  • Entrada: lo llama seedResourceRoles(), encadenado por database/seeders/index.ts.
  • Salida: usa ResourceRolesService, los modelos Resource y Role, y los catálogos RESOURCE_CATALOG / SELLER_RESOURCES.

Comandos explicados

El ISS usa un único comando de desarrollo y un par de verificaciones. Cada uno sigue el esquema obligatorio.

npx tsc --noEmit

COMANDO
   ↓
npx tsc --noEmit
   ↓
QUÉ HACE
   Compila todo el proyecto con TypeScript en modo "sin emitir":
   comprueba tipos, imports y firmas, pero no genera archivos .js.
   ↓
POR QUÉ SE NECESITA
   Las features nuevas cruzan DTOs, modelos y repositorios. Un tipo mal
   inferido (por ejemplo, un status que no es "active" | "inactive") haría
   fallar el arranque. Detectar eso antes de levantar el servidor ahorra
   tiempo.
   ↓
QUÉ CREA O MODIFICA
   Nada en disco. Es una verificación de solo lectura.
   ↓
RESULTADO ESPERADO
   Sin salida (o solo mensajes informativos) y código de salida 0.
   ↓
CÓMO VERIFICARLO
   El propio comando es la verificación: si imprime errores "TS...", hay
   que corregirlos antes de seguir.

npm run db:seed

COMANDO
   ↓
npm run db:seed
   ↓
QUÉ HACE
   Ejecuta el SeedersRunner: sincroniza los modelos y puebla todas las
   tablas, empezando por la seguridad (roles → resources → users →
   role_users → resource_roles) y siguiendo por el negocio.
   ↓
POR QUÉ SE NECESITA
   Es el único camino para materializar la matriz. Los seeders de
   role_users y resource_roles son deterministas: reejecutar el comando
   reconcilia, no duplica.
   ↓
QUÉ CREA O MODIFICA
   Filas en las 11 tablas. En particular: 2 asignaciones en role_users y
   65 concesiones en resource_roles (58 ADMIN + 7 SELLER).
   ↓
RESULTADO ESPERADO
   El log del runner termina con "SeedersRunner finalizado" y líneas como
   "resource_roles: ADMIN -> 58 recursos" y "resource_roles: SELLER -> 7".
   ↓
CÓMO VERIFICARLO
   Consultando los conteos (ver el bloque SQL del GATE): role_users = 2 y
   resource_roles = 65. Volver a ejecutar el seed debe dejar los mismos
   números.

Consulta de conteos (SQL)

COMANDO
   ↓
SELECT COUNT(*) FROM role_users;
SELECT COUNT(*) FROM resource_roles;
   ↓
QUÉ HACE
   Cuenta las filas sembradas de cada tabla pivote.
   ↓
POR QUÉ SE NECESITA
   Es la prueba objetiva de que la matriz quedó como el ISS exige; sin ella,
   el seed "parece" correcto aunque falten o sobren filas.
   ↓
QUÉ CREA O MODIFICA
   Nada.
   ↓
RESULTADO ESPERADO
   role_users = 2 y resource_roles = 65.
   ↓
CÓMO VERIFICARLO
   Los dos números coinciden con los del DoD del ISS.

Flujos

Flujo 1 — Cómo se resuelve una autorización (efecto inmediato)

flowchart TD
    A["Petición del usuario"] --> B["authenticate extrae la identidad"]
    B --> C["authorize pide findEffectiveForUser"]
    C --> D{"¿Existe concesión activa para el par method path?"}
    D -- "sí" --> E["Continúa al controller"]
    D -- "no" --> F["403 deny by default"]
    C -. "consulta la matriz (FRESCO en cada petición)" .-> G["role_users and roles and resource_roles and resources"]

Pregunta que responde: ¿en qué momento se consulta la matriz y por qué el cambio se ve de inmediato?

Flujo 2 — Conceder un permiso cambia el resultado sin reiniciar

sequenceDiagram
    participant Admin
    participant API
    participant DB as "Base de datos"
    participant Seller
    Admin->>API: "POST /api/concesiones-rol {role_id 2, resource_id 3}"
    API->>DB: "grant: inserta fila active"
    DB-->>API: "grant creado"
    API-->>Admin: "201 concesión creada"
    Note over Seller: "La próxima petición ya consulta la matriz nueva"
    Seller->>API: "POST /api/clientes"
    API->>DB: "findEffectiveForUser"
    DB-->>API: "POST /api/clientes concedida"
    API-->>Seller: "201 creado"

Pregunta que responde: ¿por qué conceder un permiso no requiere reiniciar el servidor?

Flujo 3 — reconcileRole, paso a paso

flowchart LR
    A["reconcileRole(roleId, resourceIds)"] --> B["Lee concesiones del rol"]
    B --> C{"¿Recurso pedido?"}
    C -- "no existe" --> D["Concede"]
    C -- "inactive" --> E["Reactiva"]
    C -- "active" --> F["No toca"]
    B --> G{"¿Concedido y NO pedido?"}
    G -- "active" --> H["Retira inactive"]

Pregunta que responde: ¿qué decisión toma reconcileRole por cada fila previa?

Recorrido del ISS, paso a paso

A partir de aquí viene el contenido técnico completo del ISS, verbatim: sus DTOs, repositorios, servicios, controladores, rutas, seeders, swagger y pruebas HTTP. Se reproduce sin resumir y sin reformatear; el generador solo ha degradado los encabezados un nivel para que aniden bajo esta sección y ha reescrito los enlaces relativos a ../manual/ para que abran bien desde docs/aprendizaje/.

Fase II: Auth con RBAC — ISS-12 — Features RoleUsers y ResourceRoles (asignar roles y conceder permisos)

Contexto mínimo (autosuficiente). Si abres este archivo aislado —o lo llevas a otra IA—, esto es lo que hay que saber antes de ejecutarlo. - Proyecto: app-storelab-express-ii — Express 5 + TypeScript + Sequelize, arquitectura por features. - Ya construido: Fase I, infraestructura y modelos (ISS-09), ISS-10 Users e ISS-11 Roles/Resources. - Recorrido obligatorio de una petición: HTTP → Controller → Service → Repository → Model → Sequelize → BD. Ninguna capa se salta a la siguiente. - Diseño de datos (Fase II): ../bd-storelab.md §14.4 (role_users), §14.5 (resource_roles), §15 (por qué no hay permissions) y §16 (consulta de autorización efectiva). - Capas, convenciones y reglas transversales: 00-contexto.md.

Este ISS
Título Features RoleUsers y ResourceRoles
Feature / tablas features/auth/role-users/ · role_users — features/auth/resource-roles/ · resource_roles
API /api/asignaciones-rol… y /api/concesiones-rol… (JWT + RBAC)
Depende de ISS-11 — Features Roles y Resources
Habilita ISS-13 — Middlewares de acceso

Contenido de este ISS

  • 17.1 DTOs de RoleUsers
  • 17.2 Repository, service, controller y rutas de RoleUsers
  • 17.3 Seeder y swagger de RoleUsers
  • 17.4 DTOs de ResourceRoles
  • 17.5 Repository, service, controller y rutas de ResourceRoles
  • 17.6 reconcileRole — la matriz determinista
  • 17.7 Seeder de la matriz + swagger
  • 17.8 Pruebas HTTP

Objetivo: construir las dos tablas pivote que convierten el modelo en autorización real:

User   ──(RoleUser)────▶  Role   ──(ResourceRole)────▶  Resource
        «quién tiene        «qué agrupa»       «qué se concede»
         qué rol»

Son los métodos que añaden permisos a los recursos (el requisito explícito de la Fase II): sin estas dos features, Role y Resource serían catálogos inertes.

Bloqueado por: ISS-11.

Criterios de aceptación (ISS-12) — consolidados

  • [ ] 17.1 role-users/dto/ (create-role-user.dto.ts, role-user-response.dto.ts, index.ts)
  • [ ] 17.2 role-users.{repository,service,controller,routes}.ts; POST /api/asignaciones-rol idempotente (reactiva si existía inactiva)
  • [ ] 17.3 role-users.seeder.ts (admin→ADMIN, seller→SELLER) y role-users.swagger.ts
  • [ ] 17.4 resource-roles/dto/ (create-resource-role.dto.ts, list-resource-roles.dto.ts, resource-role-response.dto.ts, index.ts)
  • [ ] 17.5 resource-roles.{repository,service,controller,routes}.ts; POST /api/concesiones-rol idempotente y /deactivate · /reactivate
  • [ ] 17.6 ResourceRolesService.reconcileRole(roleId, resourceIds) determinista (concede lo que falta, reactiva, retira lo que sobra)
  • [ ] 17.7 resource-roles.seeder.ts construye la matriz (ADMIN 58, SELLER 7) y resource-roles.swagger.ts
  • [ ] 17.8 archivos .http de ambos features
  • [ ] npx tsc --noEmit OK

17.1 DTOs de RoleUsers

: > src/features/auth/role-users/dto/create-role-user.dto.ts
cat >> src/features/auth/role-users/dto/create-role-user.dto.ts << 'EOF'
/**
 * Datos de entrada de `POST /api/asignaciones-rol` — **asignar un rol a un usuario**.
 *
 * Es el primer eslabón de la autorización. Se envía la pareja de identificadores;
 * si la asignación ya existía inactiva, se **reactiva** en lugar de duplicarla
 * (la restricción única `(user_id, role_id)` lo garantiza).
 */
export interface CreateRoleUserDto {
  user_id: number;
  role_id: number;
}
EOF
: > src/features/auth/role-users/dto/role-user-response.dto.ts
cat >> src/features/auth/role-users/dto/role-user-response.dto.ts << 'EOF'
import { RoleUser, RoleUserI } from "../role-user.model";

/**
 * Respuesta HTTP de una asignación usuario-rol.
 *
 * Incluye, además de las claves foráneas, un resumen del usuario y del rol
 * (`user`, `role`) para que el consumidor no tenga que hacer dos peticiones
 * extra. La proyección del usuario **excluye la contraseña** por `attributes`
 * en el `include` del repository, no aquí: nunca sale de la base de datos.
 */
export interface RoleUserResponseDto extends RoleUserI {
  user?: { id: number; username: string; email: string } | null;
  role?: { id: number; name: string } | null;
}

/** Mapper modelo -> DTO de respuesta (objeto plano, con resúmenes si vienen). */
export function toRoleUserResponse(roleUser: RoleUser): RoleUserResponseDto {
  return roleUser.toJSON() as RoleUserResponseDto;
}
EOF
: > src/features/auth/role-users/dto/index.ts
cat >> src/features/auth/role-users/dto/index.ts << 'EOF'
export * from "./create-role-user.dto";
export * from "./role-user-response.dto";
EOF

17.2 RoleUsers — repository, service, controller y rutas

Asignar un rol es un upsert lógico, no un alta a ciegas:

Situación de (user_id, role_id) Resultado
No existe se crea la fila active
Existe active 409 (ya está asignado)
Existe inactive se reactiva (no se duplica)

No hay borrado físico: retirar un rol es PATCH /:id/deactivate y la auditoría de quién tuvo qué rol se conserva.

: > src/features/auth/role-users/role-users.repository.ts
cat >> src/features/auth/role-users/role-users.repository.ts << 'EOF'
import { CreationAttributes, Transaction } from "sequelize";
import { RoleUser } from "./role-user.model";
import { Role } from "../roles/role.model";
import { User } from "../users/user.model";

/** `include` reutilizable: resumen del usuario (sin contraseña) y del rol. */
const SUMMARIES = [
  { model: User, as: "user", attributes: ["id", "username", "email"] },
  { model: Role, as: "role", attributes: ["id", "name"] },
];

/**
 * Capa Repository del feature RoleUsers (tabla `role_users`).
 * Única que habla con Sequelize. La proyección del usuario excluye `password`.
 */
export class RoleUsersRepository {
  /** Asignaciones activas (con resumen de usuario y rol). */
  public async findAllActive(): Promise<RoleUser[]> {
    return RoleUser.findAll({ where: { status: "active" }, include: SUMMARIES });
  }

  /** Una asignación por PK (o `null`). */
  public async findById(id: number, transaction?: Transaction): Promise<RoleUser | null> {
    return RoleUser.findByPk(id, { include: SUMMARIES, transaction });
  }

  /**
   * La asignación de un usuario a un rol, sea cual sea su estado.
   *
   * Permite la semántica *create-or-reactivate*: si ya existe inactiva, se
   * reactiva en lugar de chocar con la restricción única `(user_id, role_id)`.
   */
  public async findByUserAndRole(userId: number, roleId: number): Promise<RoleUser | null> {
    return RoleUser.findOne({ where: { user_id: userId, role_id: roleId } });
  }

  /** Inserta una asignación. */
  public async create(data: CreationAttributes<RoleUser>): Promise<RoleUser> {
    return RoleUser.create(data);
  }

  /** Persiste cambios sobre una instancia existente. */
  public async update(roleUser: RoleUser, data: Partial<RoleUser>): Promise<RoleUser> {
    return roleUser.update(data);
  }
}
EOF
: > src/features/auth/role-users/role-users.service.ts
cat >> src/features/auth/role-users/role-users.service.ts << 'EOF'
import {
  CreateRoleUserDto,
  RoleUserResponseDto,
  toRoleUserResponse,
} from "./dto";
import { RoleUsersRepository } from "./role-users.repository";
import { RoleUser } from "./role-user.model";
import { UsersRepository } from "../users/users.repository";
import { RolesRepository } from "../roles/roles.repository";
import { AppError } from "../../../shared/errors/app-error";

/**
 * Capa Service del feature RoleUsers — **asignaciones usuario ↔ rol**.
 *
 * Aquí empieza la administración de la autorización. Reglas de negocio:
 *  - Solo se asigna un rol **activo** a un usuario **activo** (un eslabón
 *    inactivo rompería la cadena y el permiso no se concedería de todos modos).
 *  - Asignar es **idempotente**: si la pareja ya existía desactivada, se
 *    reactiva; si ya estaba activa, se informa 409 sin duplicar filas.
 *  - Retirar es un borrado lógico: preserva la auditoría y es reversible.
 *
 * El efecto es inmediato: la próxima petición del usuario vuelve a consultar la
 * cadena RBAC y ya ve (o deja de ver) el permiso. No hay caché que invalidar.
 */
export class RoleUsersService {
  public constructor(
    private readonly repository: RoleUsersRepository = new RoleUsersRepository(),
    private readonly usersRepository: UsersRepository = new UsersRepository(),
    private readonly rolesRepository: RolesRepository = new RolesRepository()
  ) {}

  // ================== READ ==================
  public async getAll(): Promise<RoleUserResponseDto[]> {
    const assignments = await this.repository.findAllActive();
    return assignments.map((assignment) => toRoleUserResponse(assignment));
  }

  public async getOne(id: number): Promise<RoleUserResponseDto> {
    return toRoleUserResponse(await this.findOrFail(id));
  }

  // ================== CREATE (asignar) ==================
  /** Asigna un rol a un usuario (o reactiva la asignación existente). */
  public async assign(body: CreateRoleUserDto): Promise<RoleUserResponseDto> {
    if (!body.user_id || !body.role_id) {
      throw new AppError(400, "user_id and role_id are required");
    }

    await this.assertUserActive(body.user_id);
    await this.assertRoleActive(body.role_id);

    const existing = await this.repository.findByUserAndRole(body.user_id, body.role_id);
    if (existing) {
      if (existing.status === "active") {
        throw new AppError(409, "Role is already assigned to this user");
      }
      const reactivated = await this.repository.update(existing, { status: "active" });
      return toRoleUserResponse(await this.reload(reactivated.id));
    }

    const created = await this.repository.create({
      user_id: body.user_id,
      role_id: body.role_id,
      status: "active",
    });
    return toRoleUserResponse(await this.reload(created.id));
  }

  // ================== STATE (retirar / reactivar) ==================
  /** Retirar el rol -> `status = inactive`. El usuario pierde los permisos del rol. */
  public async deactivate(id: number): Promise<RoleUserResponseDto> {
    const assignment = await this.findOrFail(id);
    await this.repository.update(assignment, { status: "inactive" });
    return toRoleUserResponse(await this.reload(assignment.id));
  }

  /** Reactivar la asignación. */
  public async reactivate(id: number): Promise<RoleUserResponseDto> {
    const assignment = await this.findOrFail(id, false);
    if (assignment.status === "active") {
      throw new AppError(409, "Assignment is already active");
    }
    await this.repository.update(assignment, { status: "active" });
    return toRoleUserResponse(await this.reload(assignment.id));
  }

  // ================== HELPERS ==================
  private async findOrFail(id: number, onlyActive = true): Promise<RoleUser> {
    const assignment = await this.repository.findById(id);
    if (!assignment || (onlyActive && assignment.status !== "active")) {
      throw new AppError(404, "Role assignment not found");
    }
    return assignment;
  }

  /** Recarga con los `include` de resumen (el `findById` ya los trae). */
  private async reload(id: number): Promise<RoleUser> {
    const assignment = await this.repository.findById(id);
    if (!assignment) {
      throw new AppError(404, "Role assignment not found");
    }
    return assignment;
  }

  private async assertUserActive(userId: number): Promise<void> {
    const user = await this.usersRepository.findById(userId);
    if (!user || user.status !== "active") {
      throw new AppError(404, "User not found or inactive");
    }
  }

  private async assertRoleActive(roleId: number): Promise<void> {
    const role = await this.rolesRepository.findById(roleId);
    if (!role || role.status !== "active") {
      throw new AppError(404, "Role not found or inactive");
    }
  }
}
EOF
: > src/features/auth/role-users/role-users.controller.ts
cat >> src/features/auth/role-users/role-users.controller.ts << 'EOF'
import { Request, Response } from "express";
import { BaseController } from "../../../shared/http/base-controller";
import { CreateRoleUserDto } from "./dto";
import { RoleUsersService } from "./role-users.service";

/**
 * Capa Controller del feature RoleUsers.
 *
 * Orden de operaciones (el mismo patrón del proyecto):
 * getAll → getOne → assign (create) → deactivate → reactivate.
 * No expone borrado físico: la revocación es lógica para preservar auditoría.
 */
export class RoleUsersController extends BaseController {
  public constructor(
    private readonly service: RoleUsersService = new RoleUsersService()
  ) {
    super();
  }

  // ================== READ ==================
  public async getAll(_req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const assignments = await this.service.getAll();
      res.status(200).json({ assignments });
    });
  }

  public async getOne(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const assignment = await this.service.getOne(this.paramId(req));
      res.status(200).json({ assignment });
    });
  }

  // ================== CREATE (asignar) ==================
  public async assign(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const assignment = await this.service.assign(req.body as CreateRoleUserDto);
      res.status(201).json({ assignment });
    });
  }

  // ================== STATE ==================
  /** Retirar el rol (borrado lógico). */
  public async deactivate(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const assignment = await this.service.deactivate(this.paramId(req));
      res.status(200).json({ message: "Role assignment deactivated", assignment });
    });
  }

  /** Reactivar la asignación. */
  public async reactivate(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const assignment = await this.service.reactivate(this.paramId(req));
      res.status(200).json({ message: "Role assignment reactivated", assignment });
    });
  }
}
EOF
: > src/features/auth/role-users/role-users.routes.ts
cat >> src/features/auth/role-users/role-users.routes.ts << 'EOF'
import { Application } from "express";
import { RoleUsersController } from "./role-users.controller";
import { authenticate, authorize } from "../access";

/**
 * Rutas del feature RoleUsers — **modalidad 3 (JWT + RBAC)**.
 *
 * Es la vía administrativa para **asignar un rol a un usuario**:
 * `POST /api/asignaciones-rol` con `{ user_id, role_id }`.
 *
 * No hay borrado físico: retirar un rol es un borrado lógico (`/deactivate`) y
 * es reversible (`/reactivate`). La auditoría de quién tuvo qué rol se conserva.
 */
export class RoleUsersRoutes {
  public roleUsersController: RoleUsersController = new RoleUsersController();

  public routes(app: Application): void {
    // getAll
    app
      .route("/api/asignaciones-rol")
      .get(
        authenticate,
        authorize,
        this.roleUsersController.getAll.bind(this.roleUsersController)
      );

    // getOne
    app
      .route("/api/asignaciones-rol/:id")
      .get(
        authenticate,
        authorize,
        this.roleUsersController.getOne.bind(this.roleUsersController)
      );

    // asignar rol (create)
    app
      .route("/api/asignaciones-rol")
      .post(
        authenticate,
        authorize,
        this.roleUsersController.assign.bind(this.roleUsersController)
      );

    // retirar rol (delete lógico)
    app
      .route("/api/asignaciones-rol/:id/deactivate")
      .patch(
        authenticate,
        authorize,
        this.roleUsersController.deactivate.bind(this.roleUsersController)
      );

    // reactivar asignación
    app
      .route("/api/asignaciones-rol/:id/reactivate")
      .patch(
        authenticate,
        authorize,
        this.roleUsersController.reactivate.bind(this.roleUsersController)
      );
  }
}
EOF

17.3 RoleUsers — seeder y swagger

: > src/features/auth/role-users/role-users.seeder.ts
cat >> src/features/auth/role-users/role-users.seeder.ts << 'EOF'
import { RoleUser } from "./role-user.model";
import { Role } from "../roles/role.model";
import { User } from "../users/user.model";

/**
 * Seeder de las asignaciones usuario ↔ rol (`role_users`).
 *
 * Crea las dos asignaciones de referencia. Con esto el usuario `admin` hereda
 * los 58 recursos de `ADMIN` y el usuario `seller` los 7 de `SELLER`, sin
 * escribir ni una fila de autorización a mano.
 *
 * Idempotente: si la pareja ya existe (activa o no), se asegura de que quede
 * activa en lugar de duplicarla.
 */
export const SEED_ROLE_USERS = [
  { username: "admin", roleName: "ADMIN" },
  { username: "seller", roleName: "SELLER" },
] as const;

export async function seedRoleUsers(): Promise<number> {
  let created = 0;

  for (const item of SEED_ROLE_USERS) {
    const user = await User.findOne({ where: { username: item.username } });
    const role = await Role.findOne({ where: { name: item.roleName } });

    if (!user || !role) {
      console.log(
        `⏭️  role_users: falta ${item.username} o ${item.roleName}, se omite esa asignación`
      );
      continue;
    }

    const [assignment, wasCreated] = await RoleUser.findOrCreate({
      where: { user_id: user.id, role_id: role.id },
      defaults: { user_id: user.id, role_id: role.id, status: "active" },
    });

    if (wasCreated) {
      created++;
      continue;
    }
    if (assignment.status !== "active") {
      await assignment.update({ status: "active" });
    }
  }

  console.log(
    `✅ role_users: asignaciones reconciliadas (${SEED_ROLE_USERS.length}, ${created} nuevas)`
  );
  return created;
}
EOF
: > src/features/auth/role-users/role-users.swagger.ts
cat >> src/features/auth/role-users/role-users.swagger.ts << 'EOF'
import {
  bearerSecurity,
  forbiddenResponse,
  invalidIdResponse,
  notFoundResponse,
  unauthorizedResponse,
} from "../../../shared/http/swagger-security";

/**
 * Documentación OpenAPI del feature RoleUsers — **asignaciones usuario ↔ rol**.
 *
 * Modalidad: **JWT + RBAC** en todas las operaciones.
 *
 * Estas rutas son una de las dos vías administrativas de la autorización:
 * `POST /api/asignaciones-rol` **asigna un rol a un usuario**, primer eslabón de
 * la cadena. Sin asignación activa no hay permisos, por muchos roles que existan.
 */
export const roleUsersSwagger = {
  tags: [
    {
      name: "Asignaciones usuario-rol",
      description:
        "Asignar / retirar / reactivar el rol de un usuario (`role_users`) — **JWT + RBAC**",
    },
  ],
  paths: {
    "/api/asignaciones-rol": {
      get: {
        tags: ["Asignaciones usuario-rol"],
        summary: "Listar asignaciones activas",
        description:
          "JWT + RBAC — recurso `GET /api/asignaciones-rol`. Incluye un resumen del usuario (sin `password`) y del rol.",
        security: bearerSecurity,
        responses: {
          "200": { description: "Lista de asignaciones (`{ assignments: [...] }`)" },
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
        },
      },
      post: {
        tags: ["Asignaciones usuario-rol"],
        summary: "Asignar rol a usuario",
        description:
          "JWT + RBAC — recurso `POST /api/asignaciones-rol`. " +
          "Cuerpo: `{ user_id, role_id }`. Es idempotente: si la pareja existía desactivada, se reactiva. " +
          "El usuario y el rol deben estar activos.",
        security: bearerSecurity,
        requestBody: {
          required: true,
          content: {
            "application/json": { schema: { $ref: "#/components/schemas/RoleUserCreate" } },
          },
        },
        responses: {
          "201": { description: "Asignación creada (`{ assignment }`)" },
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": { description: "Usuario o rol inexistente o inactivo" },
          "409": { description: "El rol ya está asignado a ese usuario" },
        },
      },
    },
    "/api/asignaciones-rol/{id}": {
      get: {
        tags: ["Asignaciones usuario-rol"],
        summary: "Obtener asignación por id",
        description: "JWT + RBAC — recurso `GET /api/asignaciones-rol/:id`.",
        security: bearerSecurity,
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
        responses: {
          "200": { description: "Asignación (`{ assignment }`)" },
          "400": invalidIdResponse,
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": notFoundResponse,
        },
      },
    },
    "/api/asignaciones-rol/{id}/deactivate": {
      patch: {
        tags: ["Asignaciones usuario-rol"],
        summary: "Retirar rol a usuario (borrado lógico)",
        description:
          "JWT + RBAC — recurso `PATCH /api/asignaciones-rol/:id/deactivate`. " +
          "Rompe el eslabón `role_users` -> el usuario pierde los permisos de ese rol de inmediato (403).",
        security: bearerSecurity,
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
        responses: {
          "200": { description: "Asignación desactivada (`{ message, assignment }`)" },
          "400": invalidIdResponse,
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": notFoundResponse,
        },
      },
    },
    "/api/asignaciones-rol/{id}/reactivate": {
      patch: {
        tags: ["Asignaciones usuario-rol"],
        summary: "Reactivar asignación",
        description:
          "JWT + RBAC — recurso `PATCH /api/asignaciones-rol/:id/reactivate`. Reversible: vuelve a conceder los permisos del rol.",
        security: bearerSecurity,
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
        responses: {
          "200": { description: "Asignación reactivada (`{ message, assignment }`)" },
          "400": invalidIdResponse,
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": notFoundResponse,
          "409": { description: "La asignación ya estaba activa" },
        },
      },
    },
  },
  components: {
    schemas: {
      RoleUser: {
        type: "object",
        properties: {
          id: { type: "integer", example: 1 },
          user_id: { type: "integer", example: 1 },
          role_id: { type: "integer", example: 1 },
          status: { type: "string", enum: ["active", "inactive"], example: "active" },
          user: {
            type: "object",
            properties: {
              id: { type: "integer" },
              username: { type: "string" },
              email: { type: "string", format: "email" },
            },
          },
          role: {
            type: "object",
            properties: { id: { type: "integer" }, name: { type: "string" } },
          },
          createdAt: { type: "string", format: "date-time" },
          updatedAt: { type: "string", format: "date-time" },
        },
      },
      RoleUserCreate: {
        type: "object",
        required: ["user_id", "role_id"],
        properties: {
          user_id: { type: "integer", example: 2 },
          role_id: { type: "integer", example: 2 },
        },
      },
    },
  },
};
EOF

17.4 DTOs de ResourceRoles

: > src/features/auth/resource-roles/dto/create-resource-role.dto.ts
cat >> src/features/auth/resource-roles/dto/create-resource-role.dto.ts << 'EOF'
/**
 * Datos de entrada de `POST /api/concesiones-rol` — **conceder un recurso a un rol**.
 *
 * Esta operación **crea un permiso**: el permiso no es una entidad con nombre,
 * es la tupla `(role_id, resource_id)` materializada en `resource_roles`. Si la
 * concesión ya existía inactiva, se reactiva en lugar de duplicarla.
 *
 * Ejemplo: conceder `POST /api/ventas` al rol `SELLER` significa que los
 * usuarios con ese rol podrán registrar ventas, sin tocar el código.
 */
export interface CreateResourceRoleDto {
  role_id: number;
  resource_id: number;
}
EOF
: > src/features/auth/resource-roles/dto/list-resource-roles.dto.ts
cat >> src/features/auth/resource-roles/dto/list-resource-roles.dto.ts << 'EOF'
/**
 * Filtros de `GET /api/concesiones-rol`.
 * Permiten pedir "los permisos de este rol" o "los roles que conceden este recurso".
 */
export interface ListResourceRolesDto {
  role_id?: number;
  resource_id?: number;
}
EOF
: > src/features/auth/resource-roles/dto/resource-role-response.dto.ts
cat >> src/features/auth/resource-roles/dto/resource-role-response.dto.ts << 'EOF'
import { ResourceRole, ResourceRoleI } from "../resource-role.model";

/**
 * Respuesta HTTP de una concesión rol-recurso (un permiso).
 *
 * Incluye un resumen del rol y del recurso: `resource` lleva `(method, path)`,
 * que es exactamente el par que evalúa el middleware de autorización.
 */
export interface ResourceRoleResponseDto extends ResourceRoleI {
  role?: { id: number; name: string } | null;
  resource?: {
    id: number;
    method: string;
    path: string;
    description: string | null;
  } | null;
}

/** Mapper modelo -> DTO de respuesta (objeto plano). */
export function toResourceRoleResponse(resourceRole: ResourceRole): ResourceRoleResponseDto {
  return resourceRole.toJSON() as ResourceRoleResponseDto;
}

/**
 * Un permiso **efectivo**: el resultado de recorrer la cadena completa
 * `role_users → roles → resource_roles → resources` para un usuario concreto.
 *
 * Es plano a propósito: el middleware de autorización solo necesita
 * `(method, path)`; el resto es información útil para el endpoint de consulta.
 */
export interface EffectivePermissionDto {
  resource_id: number;
  method: string;
  path: string;
  description: string | null;
  role_id: number;
  role_name: string;
}
EOF
: > src/features/auth/resource-roles/dto/index.ts
cat >> src/features/auth/resource-roles/dto/index.ts << 'EOF'
export * from "./create-resource-role.dto";
export * from "./list-resource-roles.dto";
export * from "./resource-role-response.dto";
EOF

El DTO de listado admite dos filtros que se usan para auditar la matriz desde ambos lados:

  • ?role_id= — «¿qué concede este rol?»
  • ?resource_id= — «¿qué roles conceden este recurso?»

17.5 ResourceRoles — repository, service, controller y rutas

POST /api/concesiones-rol con { role_id, resource_id } materializa el permiso. Misma semántica idempotente que en RoleUsers.

: > src/features/auth/resource-roles/resource-roles.repository.ts
cat >> src/features/auth/resource-roles/resource-roles.repository.ts << 'EOF'
import { CreationAttributes, Op, Transaction } from "sequelize";
import { ResourceRole } from "./resource-role.model";
import { Role } from "../roles/role.model";
import { Resource } from "../resources/resource.model";
import { RoleUser } from "../role-users/role-user.model";
import { EffectivePermissionDto } from "./dto";

/** `include` reutilizable: resumen del rol y del recurso (con `method`/`path`). */
const SUMMARIES = [
  { model: Role, as: "role", attributes: ["id", "name"] },
  {
    model: Resource,
    as: "resource",
    attributes: ["id", "method", "path", "description"],
  },
];

/**
 * Capa Repository del feature ResourceRoles (tabla `resource_roles`).
 *
 * Aquí vive la **consulta de autorización efectiva**: la única que recorre la
 * cadena completa de seguridad. Es el corazón del RBAC.
 */
export class ResourceRolesRepository {
  /** Todas las concesiones activas (con resumen de rol y recurso). */
  public async findAllActive(): Promise<ResourceRole[]> {
    return ResourceRole.findAll({ where: { status: "active" }, include: SUMMARIES });
  }

  /** Concesiones activas filtradas por rol y/o recurso. */
  public async findAllActiveFiltered(filters: {
    role_id?: number;
    resource_id?: number;
  }): Promise<ResourceRole[]> {
    const where: Record<string, unknown> = { status: "active" };
    if (filters.role_id) where.role_id = filters.role_id;
    if (filters.resource_id) where.resource_id = filters.resource_id;

    return ResourceRole.findAll({ where, include: SUMMARIES, order: [["id", "ASC"]] });
  }

  /** Una concesión por PK (o `null`). */
  public async findById(id: number, transaction?: Transaction): Promise<ResourceRole | null> {
    return ResourceRole.findByPk(id, { include: SUMMARIES, transaction });
  }

  /** La concesión de un recurso a un rol, sea cual sea su estado. */
  public async findByRoleAndResource(
    roleId: number,
    resourceId: number
  ): Promise<ResourceRole | null> {
    return ResourceRole.findOne({
      where: { role_id: roleId, resource_id: resourceId },
    });
  }

  /** Todas las concesiones (activas e inactivas) de un rol. */
  public async findAllByRole(roleId: number, transaction?: Transaction): Promise<ResourceRole[]> {
    return ResourceRole.findAll({ where: { role_id: roleId }, transaction });
  }

  /** Inserta una concesión. */
  public async create(
    data: CreationAttributes<ResourceRole>,
    transaction?: Transaction
  ): Promise<ResourceRole> {
    return ResourceRole.create(data, { transaction });
  }

  /** Persiste cambios sobre una instancia existente. */
  public async update(
    resourceRole: ResourceRole,
    data: Partial<ResourceRole>,
    transaction?: Transaction
  ): Promise<ResourceRole> {
    return resourceRole.update(data, { transaction });
  }

  /**
   * **CONSULTA DE AUTORIZACIÓN EFECTIVA** (`docs/bd-storelab.md` §16).
   *
   * Devuelve los recursos que un usuario puede ejecutar, recorriendo la cadena
   * y exigiendo `status = 'active'` en **los cuatro eslabones**:
   *
   * ```sql
   * resource_roles (rr)  -> rr.status = active
   *   JOIN roles (ro)     -> ro.status = active
   *   JOIN role_users(ru) -> ru.status = active AND ru.user_id = :userId
   *   JOIN resources (r)  -> r.status  = active
   * ```
   *
   * Si cualquier eslabón está inactivo o ausente, la fila no aparece: el
   * resultado vacío se traduce en **deny by default** en el middleware.
   *
   * Los `include` con `required: true` producen INNER JOIN; no se usan
   * `attributes` del `RoleUser` porque solo interesa que exista y cumpla el WHERE.
   */
  public async findEffectiveForUser(userId: number): Promise<EffectivePermissionDto[]> {
    const rows = await ResourceRole.findAll({
      where: { status: "active" },
      attributes: ["id"],
      include: [
        {
          model: Role,
          as: "role",
          required: true,
          attributes: ["id", "name"],
          where: { status: "active" },
          include: [
            {
              model: RoleUser,
              as: "role_users",
              required: true,
              attributes: [],
              where: { status: "active", user_id: userId },
            },
          ],
        },
        {
          model: Resource,
          as: "resource",
          required: true,
          attributes: ["id", "method", "path", "description"],
          where: { status: "active" },
        },
      ],
      order: [["id", "ASC"]],
    });

    return rows.map((row) => {
      const plain = row.toJSON() as unknown as {
        role: { id: number; name: string };
        resource: { id: number; method: string; path: string; description: string | null };
      };
      return {
        resource_id: plain.resource.id,
        method: plain.resource.method,
        path: plain.resource.path,
        description: plain.resource.description,
        role_id: plain.role.id,
        role_name: plain.role.name,
      };
    });
  }

  /** Cuenta las concesiones activas de un rol. */
  public async countActiveByRole(roleId: number): Promise<number> {
    return ResourceRole.count({ where: { role_id: roleId, status: "active" } });
  }

  /** Cuenta las concesiones activas totales. */
  public async countActive(): Promise<number> {
    return ResourceRole.count({ where: { status: "active" } });
  }

  /** Cuenta las concesiones activas cuyo recurso está en una lista de ids. */
  public async countActiveByResources(resourceIds: number[]): Promise<number> {
    if (resourceIds.length === 0) return 0;
    return ResourceRole.count({
      where: { resource_id: { [Op.in]: resourceIds }, status: "active" },
    });
  }
}
EOF
: > src/features/auth/resource-roles/resource-roles.service.ts
cat >> src/features/auth/resource-roles/resource-roles.service.ts << 'EOF'
import {
  CreateResourceRoleDto,
  EffectivePermissionDto,
  ListResourceRolesDto,
  ResourceRoleResponseDto,
  toResourceRoleResponse,
} from "./dto";
import { ResourceRolesRepository } from "./resource-roles.repository";
import { ResourceRole } from "./resource-role.model";
import { RolesRepository } from "../roles/roles.repository";
import { ResourcesRepository } from "../resources/resources.repository";
import { AppError } from "../../../shared/errors/app-error";
import { withTransaction } from "../../../shared/database/with-transaction";

/** Resumen de una reconciliación de concesiones de un rol. */
export interface ReconcileResult {
  role_id: number;
  activated: number;
  deactivated: number;
  total_active: number;
}

/**
 * Capa Service del feature ResourceRoles — **la gestión de permisos**.
 *
 * Aquí es donde el modelo "Role + Resource = permiso" se vuelve operativo:
 *  - `grant`     -> concede un recurso a un rol (crea o reactiva la concesión).
 *  - `deactivate`-> retira el permiso (borrado lógico, reversible).
 *  - `findEffectiveForUser` -> materializa los permisos de un usuario concreto.
 *  - `reconcileRole` -> deja el catálogo de un rol exactamente en un conjunto
 *    dado de recursos (idempotente); lo usa el seeder para el rol `SELLER`.
 *
 * Nada de esto requiere desplegar código: son filas.
 */
export class ResourceRolesService {
  public constructor(
    private readonly repository: ResourceRolesRepository = new ResourceRolesRepository(),
    private readonly rolesRepository: RolesRepository = new RolesRepository(),
    private readonly resourcesRepository: ResourcesRepository = new ResourcesRepository()
  ) {}

  // ================== READ ==================
  public async getAll(filters: ListResourceRolesDto = {}): Promise<ResourceRoleResponseDto[]> {
    const grants = await this.repository.findAllActiveFiltered({
      role_id: filters.role_id,
      resource_id: filters.resource_id,
    });
    return grants.map((grant) => toResourceRoleResponse(grant));
  }

  public async getOne(id: number): Promise<ResourceRoleResponseDto> {
    return toResourceRoleResponse(await this.findOrFail(id));
  }

  /** Permisos efectivos de un usuario (cadena RBAC completa, todos los eslabones activos). */
  public async findEffectiveForUser(userId: number): Promise<EffectivePermissionDto[]> {
    return this.repository.findEffectiveForUser(userId);
  }

  // ================== CREATE (conceder) ==================
  /** Concede un recurso a un rol (crea el permiso o reactiva la concesión). */
  public async grant(body: CreateResourceRoleDto): Promise<ResourceRoleResponseDto> {
    if (!body.role_id || !body.resource_id) {
      throw new AppError(400, "role_id and resource_id are required");
    }

    const role = await this.rolesRepository.findById(body.role_id);
    if (!role || role.status !== "active") {
      throw new AppError(404, "Role not found or inactive");
    }
    const resource = await this.resourcesRepository.findById(body.resource_id);
    if (!resource || resource.status !== "active") {
      throw new AppError(404, "Resource not found or inactive");
    }

    const existing = await this.repository.findByRoleAndResource(body.role_id, body.resource_id);
    if (existing) {
      if (existing.status === "active") {
        throw new AppError(409, "Role already has this resource granted");
      }
      const reactivated = await this.repository.update(existing, { status: "active" });
      return toResourceRoleResponse(await this.reload(reactivated.id));
    }

    const created = await this.repository.create({
      role_id: body.role_id,
      resource_id: body.resource_id,
      status: "active",
    });
    return toResourceRoleResponse(await this.reload(created.id));
  }

  // ================== STATE (retirar / reactivar) ==================
  /** Retirar el permiso -> `status = inactive`. Solo se pierde esa operación. */
  public async deactivate(id: number): Promise<ResourceRoleResponseDto> {
    const grant = await this.findOrFail(id);
    await this.repository.update(grant, { status: "inactive" });
    return toResourceRoleResponse(await this.reload(grant.id));
  }

  /** Reactivar la concesión. */
  public async reactivate(id: number): Promise<ResourceRoleResponseDto> {
    const grant = await this.findOrFail(id, false);
    if (grant.status === "active") {
      throw new AppError(409, "Grant is already active");
    }
    await this.repository.update(grant, { status: "active" });
    return toResourceRoleResponse(await this.reload(grant.id));
  }

  // ================== RECONCILIACIÓN ==================
  /**
   * Deja las concesiones de un rol **exactamente** en `resourceIds`.
   *
   * - Recursos de la lista sin concesión -> se conceden.
   * - Recursos de la lista con concesión inactiva -> se reactivan.
   * - Recursos concedidos que no están en la lista -> se retiran (inactive).
   *
   * Todo dentro de una transacción: o el rol queda con ese catálogo exacto, o no
   * se toca nada. Lo usa el seeder para el rol `SELLER` (7 recursos) y `ADMIN`
   * (58), de modo que volver a ejecutar el seeder reconcilia en vez de duplicar.
   */
  public async reconcileRole(roleId: number, resourceIds: number[]): Promise<ReconcileResult> {
    const role = await this.rolesRepository.findById(roleId);
    if (!role) {
      throw new AppError(404, "Role not found");
    }

    const wanted = new Set(resourceIds);

    return withTransaction(async (t) => {
      const existing = await this.repository.findAllByRole(roleId, t);
      const byResource = new Map(existing.map((row) => [row.resource_id, row]));

      let activated = 0;
      let deactivated = 0;

      for (const resourceId of wanted) {
        const row = byResource.get(resourceId);
        if (!row) {
          await this.repository.create(
            { role_id: roleId, resource_id: resourceId, status: "active" },
            t
          );
          activated++;
          continue;
        }
        if (row.status !== "active") {
          await this.repository.update(row, { status: "active" }, t);
          activated++;
        }
      }

      for (const row of existing) {
        if (wanted.has(row.resource_id)) continue;
        if (row.status === "active") {
          await this.repository.update(row, { status: "inactive" }, t);
          deactivated++;
        }
      }

      return {
        role_id: roleId,
        activated,
        deactivated,
        total_active: wanted.size,
      };
    });
  }

  // ================== HELPERS ==================
  private async findOrFail(id: number, onlyActive = true): Promise<ResourceRole> {
    const grant = await this.repository.findById(id);
    if (!grant || (onlyActive && grant.status !== "active")) {
      throw new AppError(404, "Grant not found");
    }
    return grant;
  }

  private async reload(id: number): Promise<ResourceRole> {
    const grant = await this.repository.findById(id);
    if (!grant) {
      throw new AppError(404, "Grant not found");
    }
    return grant;
  }
}
EOF
: > src/features/auth/resource-roles/resource-roles.controller.ts
cat >> src/features/auth/resource-roles/resource-roles.controller.ts << 'EOF'
import { Request, Response } from "express";
import { BaseController } from "../../../shared/http/base-controller";
import { CreateResourceRoleDto } from "./dto";
import { ResourceRolesService } from "./resource-roles.service";

/**
 * Capa Controller del feature ResourceRoles.
 *
 * Orden de operaciones: getAll → getOne → grant (create) → deactivate → reactivate.
 * `GET /api/concesiones-rol` acepta filtros `?role_id=` y `?resource_id=`.
 */
export class ResourceRolesController extends BaseController {
  public constructor(
    private readonly service: ResourceRolesService = new ResourceRolesService()
  ) {
    super();
  }

  // ================== READ ==================
  public async getAll(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const grants = await this.service.getAll({
        role_id: toOptionalNumber(req.query.role_id),
        resource_id: toOptionalNumber(req.query.resource_id),
      });
      res.status(200).json({ grants });
    });
  }

  public async getOne(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const grant = await this.service.getOne(this.paramId(req));
      res.status(200).json({ grant });
    });
  }

  // ================== CREATE (conceder permiso) ==================
  public async grant(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const grant = await this.service.grant(req.body as CreateResourceRoleDto);
      res.status(201).json({ message: "Resource granted to role", grant });
    });
  }

  // ================== STATE ==================
  /** Retirar el permiso (borrado lógico). */
  public async deactivate(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const grant = await this.service.deactivate(this.paramId(req));
      res.status(200).json({ message: "Grant deactivated (permission revoked)", grant });
    });
  }

  /** Reactivar la concesión. */
  public async reactivate(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const grant = await this.service.reactivate(this.paramId(req));
      res.status(200).json({ message: "Grant reactivated", grant });
    });
  }
}

/** Convierte un `query param` en número o `undefined` (sin lanzar por basura). */
function toOptionalNumber(value: unknown): number | undefined {
  const raw = Array.isArray(value) ? value[0] : value;
  if (typeof raw !== "string" || !/^\d+$/.test(raw)) return undefined;
  return Number(raw);
}
EOF
: > src/features/auth/resource-roles/resource-roles.routes.ts
cat >> src/features/auth/resource-roles/resource-roles.routes.ts << 'EOF'
import { Application } from "express";
import { ResourceRolesController } from "./resource-roles.controller";
import { authenticate, authorize } from "../access";

/**
 * Rutas del feature ResourceRoles — **modalidad 3 (JWT + RBAC)**.
 *
 * Es la vía administrativa para **conceder un recurso a un rol** (crear un
 * permiso):
 * `POST /api/concesiones-rol` con `{ role_id, resource_id }`.
 *
 * El efecto es inmediato y por datos: la siguiente petición del usuario afectado
 * ya consulta la nueva matriz. No se reinicia el servidor ni se despliega nada.
 */
export class ResourceRolesRoutes {
  public resourceRolesController: ResourceRolesController = new ResourceRolesController();

  public routes(app: Application): void {
    // getAll (filtros ?role_id= y ?resource_id=)
    app
      .route("/api/concesiones-rol")
      .get(
        authenticate,
        authorize,
        this.resourceRolesController.getAll.bind(this.resourceRolesController)
      );

    // getOne
    app
      .route("/api/concesiones-rol/:id")
      .get(
        authenticate,
        authorize,
        this.resourceRolesController.getOne.bind(this.resourceRolesController)
      );

    // conceder recurso a rol (create)
    app
      .route("/api/concesiones-rol")
      .post(
        authenticate,
        authorize,
        this.resourceRolesController.grant.bind(this.resourceRolesController)
      );

    // retirar permiso (delete lógico)
    app
      .route("/api/concesiones-rol/:id/deactivate")
      .patch(
        authenticate,
        authorize,
        this.resourceRolesController.deactivate.bind(this.resourceRolesController)
      );

    // reactivar permiso
    app
      .route("/api/concesiones-rol/:id/reactivate")
      .patch(
        authenticate,
        authorize,
        this.resourceRolesController.reactivate.bind(this.resourceRolesController)
      );
  }
}
EOF

17.6 reconcileRole — la matriz determinista

reconcileRole(roleId, resourceIds) es el método que hace que un seeder (o un proceso de despliegue) pueda declarar los permisos de un rol en lugar de mutarlos a mano:

Recurso en la lista Fila previa Acción
sí no existe concede
sí inactive reactiva
sí active no toca
no active retira (borrado lógico)

Es total y determinista: al terminar, el conjunto de concesiones activas del rol es exactamente la lista recibida. Por eso el rol SELLER nunca acumula permisos por accidente al reejecutar el seeder.

reconcileRole(ADMIN,  RESOURCE_CATALOG)  → 58 activas
reconcileRole(SELLER, SELLER_RESOURCES)  →  7 activas

17.7 Seeder de la matriz y swagger

: > src/features/auth/resource-roles/resource-roles.seeder.ts
cat >> src/features/auth/resource-roles/resource-roles.seeder.ts << 'EOF'
import { Resource } from "../resources/resource.model";
import { Role } from "../roles/role.model";
import { RESOURCE_CATALOG, SELLER_RESOURCES } from "../resources/resource-catalog";
import { ResourceRolesService } from "./resource-roles.service";

/**
 * Seeder de las concesiones rol ↔ recurso (`resource_roles`). **Es el que
 * construye la matriz de permisos.**
 *
 * Reparto de referencia (`docs/bd-storelab.md` §21):
 *  - `ADMIN`  -> los **58** recursos (administración total).
 *  - `SELLER` -> los **7** recursos de operación (consultar clientes y
 *    productos, consultar y registrar ventas).
 *
 * Como `reconcileRole` es determinista, reejecutar el seeder **reconcilia** el
 * catálogo: concede lo que falte, reactiva lo inactivo y retira lo que sobre.
 * Así el rol `SELLER` nunca acumula permisos por accidente.
 */
export async function seedResourceRoles(): Promise<number> {
  const service = new ResourceRolesService();

  const resources = await Resource.findAll({ where: { status: "active" } });
  const idByOperation = new Map(
    resources.map((resource) => [`${resource.method} ${resource.path}`, resource.id])
  );

  /** Traduce el catálogo en código a los `resource_id` reales de la base. */
  const idsFor = (catalog: ReadonlyArray<{ method: string; path: string }>): number[] =>
    catalog
      .map((item) => idByOperation.get(`${item.method} ${item.path}`))
      .filter((id): id is number => typeof id === "number");

  let total = 0;

  const admin = await Role.findOne({ where: { name: "ADMIN" } });
  if (admin) {
    const result = await service.reconcileRole(admin.id, idsFor(RESOURCE_CATALOG));
    console.log(
      `✅ resource_roles: ADMIN -> ${result.total_active} recursos ` +
        `(${result.activated} altas, ${result.deactivated} bajas)`
    );
    total += result.total_active;
  }

  const seller = await Role.findOne({ where: { name: "SELLER" } });
  if (seller) {
    const result = await service.reconcileRole(seller.id, idsFor(SELLER_RESOURCES));
    console.log(
      `✅ resource_roles: SELLER -> ${result.total_active} recursos ` +
        `(${result.activated} altas, ${result.deactivated} bajas)`
    );
    total += result.total_active;
  }

  return total;
}
EOF
: > src/features/auth/resource-roles/resource-roles.swagger.ts
cat >> src/features/auth/resource-roles/resource-roles.swagger.ts << 'EOF'
import {
  bearerSecurity,
  forbiddenResponse,
  invalidIdResponse,
  notFoundResponse,
  unauthorizedResponse,
} from "../../../shared/http/swagger-security";

/**
 * Documentación OpenAPI del feature ResourceRoles — **la gestión de permisos**.
 *
 * Modalidad: **JWT + RBAC** en todas las operaciones.
 *
 * Aquí se materializa el principio de diseño: **no existe una entidad
 * `Permission`**. Conceder un permiso es crear (o reactivar) una fila en
 * `resource_roles`; el permiso es la tupla `(rol, recurso)`.
 */
export const resourceRolesSwagger = {
  tags: [
    {
      name: "Concesiones rol-recurso",
      description:
        "Conceder / retirar / reactivar recursos a un rol: **el permiso** — **JWT + RBAC**",
    },
  ],
  paths: {
    "/api/concesiones-rol": {
      get: {
        tags: ["Concesiones rol-recurso"],
        summary: "Listar concesiones activas",
        description:
          "JWT + RBAC — recurso `GET /api/concesiones-rol`. " +
          "Filtros opcionales: `?role_id=` (permisos de un rol) y `?resource_id=` (roles que conceden un recurso).",
        security: bearerSecurity,
        parameters: [
          { name: "role_id", in: "query", required: false, schema: { type: "integer" } },
          { name: "resource_id", in: "query", required: false, schema: { type: "integer" } },
        ],
        responses: {
          "200": { description: "Lista de concesiones (`{ grants: [...] }`)" },
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
        },
      },
      post: {
        tags: ["Concesiones rol-recurso"],
        summary: "Conceder recurso a rol (crear permiso)",
        description:
          "JWT + RBAC — recurso `POST /api/concesiones-rol`. " +
          "Cuerpo: `{ role_id, resource_id }`. Idempotente: si la concesión existía retirada, se reactiva. " +
          "Efecto inmediato y sin despliegue: la siguiente petición del usuario ya consulta la nueva matriz.",
        security: bearerSecurity,
        requestBody: {
          required: true,
          content: {
            "application/json": { schema: { $ref: "#/components/schemas/ResourceRoleCreate" } },
          },
        },
        responses: {
          "201": { description: "Permiso concedido (`{ message, grant }`)" },
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": { description: "Rol o recurso inexistente o inactivo" },
          "409": { description: "El rol ya tiene concedido ese recurso" },
        },
      },
    },
    "/api/concesiones-rol/{id}": {
      get: {
        tags: ["Concesiones rol-recurso"],
        summary: "Obtener concesión por id",
        description: "JWT + RBAC — recurso `GET /api/concesiones-rol/:id`.",
        security: bearerSecurity,
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
        responses: {
          "200": { description: "Concesión (`{ grant }`)" },
          "400": invalidIdResponse,
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": notFoundResponse,
        },
      },
    },
    "/api/concesiones-rol/{id}/deactivate": {
      patch: {
        tags: ["Concesiones rol-recurso"],
        summary: "Retirar permiso (borrado lógico)",
        description:
          "JWT + RBAC — recurso `PATCH /api/concesiones-rol/:id/deactivate`. " +
          "Solo se pierde esa operación; el resto de permisos del rol siguen vigentes.",
        security: bearerSecurity,
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
        responses: {
          "200": { description: "Permiso retirado (`{ message, grant }`)" },
          "400": invalidIdResponse,
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": notFoundResponse,
        },
      },
    },
    "/api/concesiones-rol/{id}/reactivate": {
      patch: {
        tags: ["Concesiones rol-recurso"],
        summary: "Reactivar permiso",
        description: "JWT + RBAC — recurso `PATCH /api/concesiones-rol/:id/reactivate`.",
        security: bearerSecurity,
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
        responses: {
          "200": { description: "Permiso reactivado (`{ message, grant }`)" },
          "400": invalidIdResponse,
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": notFoundResponse,
          "409": { description: "La concesión ya estaba activa" },
        },
      },
    },
  },
  components: {
    schemas: {
      ResourceRole: {
        type: "object",
        properties: {
          id: { type: "integer", example: 1 },
          role_id: { type: "integer", example: 2 },
          resource_id: { type: "integer", example: 25 },
          status: { type: "string", enum: ["active", "inactive"], example: "active" },
          role: {
            type: "object",
            properties: { id: { type: "integer" }, name: { type: "string", example: "SELLER" } },
          },
          resource: {
            type: "object",
            properties: {
              id: { type: "integer" },
              method: { type: "string", example: "POST" },
              path: { type: "string", example: "/api/ventas" },
              description: { type: "string", nullable: true },
            },
          },
          createdAt: { type: "string", format: "date-time" },
          updatedAt: { type: "string", format: "date-time" },
        },
      },
      ResourceRoleCreate: {
        type: "object",
        required: ["role_id", "resource_id"],
        properties: {
          role_id: { type: "integer", example: 2 },
          resource_id: { type: "integer", example: 25 },
        },
      },
    },
  },
};
EOF

17.8 Pruebas HTTP

: > src/features/auth/role-users/http/role-users.assign.http
cat >> src/features/auth/role-users/http/role-users.assign.http << 'EOF'
### Feature RoleUsers — ASIGNAR ROL A USUARIO (modalidad JWT + RBAC)
### Primer eslabón de la cadena: sin asignación activa NO hay permisos.
@baseUrl = http://localhost:4000

# @name loginAdmin
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

{
  "identifier": "admin",
  "password": "Admin123!"
}

@token = {{loginAdmin.response.body.$.access_token}}

### getAll — asignaciones activas (con resumen de usuario y rol)
GET {{baseUrl}}/api/asignaciones-rol
Authorization: Bearer {{token}}

### getOne
GET {{baseUrl}}/api/asignaciones-rol/1
Authorization: Bearer {{token}}

### ASIGNAR — `POST /api/asignaciones-rol` con { user_id, role_id }
### (seller = user_id 2 recibe ADMIN = role_id 1; no lo tiene todavía -> 201)
# @name assignCreate
POST {{baseUrl}}/api/asignaciones-rol
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "user_id": 2,
  "role_id": 1
}

@assignmentId = {{assignCreate.response.body.$.assignment.id}}

### 409 — ese rol ya está asignado a ese usuario (la tupla es única)
POST {{baseUrl}}/api/asignaciones-rol
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "user_id": 2,
  "role_id": 1
}

### 404 — usuario o rol inexistente/inactivo
POST {{baseUrl}}/api/asignaciones-rol
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "user_id": 9999,
  "role_id": 2
}

### RETIRAR ROL (borrado lógico) — el usuario pierde los permisos de ese rol de inmediato
PATCH {{baseUrl}}/api/asignaciones-rol/{{assignmentId}}/deactivate
Authorization: Bearer {{token}}

### Comprobación del efecto: los permisos efectivos cambian sin reiniciar nada
GET {{baseUrl}}/api/usuarios/2/permisos
Authorization: Bearer {{token}}

### REACTIVAR asignación (reversible, la auditoría se conserva)
PATCH {{baseUrl}}/api/asignaciones-rol/{{assignmentId}}/reactivate
Authorization: Bearer {{token}}
EOF
: > src/features/auth/resource-roles/http/resource-roles.grant.http
cat >> src/features/auth/resource-roles/http/resource-roles.grant.http << 'EOF'
### Feature ResourceRoles — CONCEDER / RETIRAR PERMISOS (modalidad JWT + RBAC)
### Aquí se ve el principio de diseño: NO existe entidad `Permission`.
### El permiso es la tupla (rol, recurso) materializada en `resource_roles`.
### resource_id 3 = `POST /api/clientes` (3.ª entrada del catálogo semilla).
@baseUrl = http://localhost:4000

# @name loginAdmin
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

{
  "identifier": "admin",
  "password": "Admin123!"
}

# @name loginSeller
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

{
  "identifier": "seller",
  "password": "Seller123!"
}

@token = {{loginAdmin.response.body.$.access_token}}
@sellerToken = {{loginSeller.response.body.$.access_token}}
@sellerRoleId = 2

### getAll — concesiones activas (ADMIN 58 + SELLER 7 = 65)
GET {{baseUrl}}/api/concesiones-rol
Authorization: Bearer {{token}}

### Filtro: permisos de un rol (?role_id=)
GET {{baseUrl}}/api/concesiones-rol?role_id={{sellerRoleId}}
Authorization: Bearer {{token}}

### Filtro inverso: qué roles conceden un recurso (?resource_id=)
GET {{baseUrl}}/api/concesiones-rol?resource_id=1
Authorization: Bearer {{token}}

### getOne
GET {{baseUrl}}/api/concesiones-rol/1
Authorization: Bearer {{token}}

### ESTADO INICIAL — SELLER no puede crear clientes -> 403 (deny by default)
POST {{baseUrl}}/api/clientes
Authorization: Bearer {{sellerToken}}
Content-Type: application/json

{
  "name": "Prueba RBAC",
  "phone": "3000000000",
  "email": "rbac.demo@example.com",
  "password": "x"
}

### CONCEDER — `POST /api/concesiones-rol` con { role_id: 2 (SELLER), resource_id: 3 (POST /api/clientes) }
# @name grantCreate
POST {{baseUrl}}/api/concesiones-rol
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "role_id": 2,
  "resource_id": 3
}

@grantId = {{grantCreate.response.body.$.grant.id}}

### EFECTO INMEDIATO — el mismo seller ahora sí puede (201), sin reiniciar el servidor
POST {{baseUrl}}/api/clientes
Authorization: Bearer {{sellerToken}}
Content-Type: application/json

{
  "name": "Prueba RBAC 2",
  "phone": "3000000001",
  "email": "rbac.demo2@example.com",
  "password": "x"
}

### RETIRAR EL PERMISO (borrado lógico) — solo se pierde esa operación
PATCH {{baseUrl}}/api/concesiones-rol/{{grantId}}/deactivate
Authorization: Bearer {{token}}

### El seller vuelve a 403
POST {{baseUrl}}/api/clientes
Authorization: Bearer {{sellerToken}}
Content-Type: application/json

{
  "name": "Prueba RBAC 3",
  "phone": "3000000002",
  "email": "rbac.demo3@example.com",
  "password": "x"
}

### REACTIVAR EL PERMISO (reversible, la auditoría se conserva)
PATCH {{baseUrl}}/api/concesiones-rol/{{grantId}}/reactivate
Authorization: Bearer {{token}}

### 403 — el propio SELLER no puede administrar la matriz de permisos
GET {{baseUrl}}/api/concesiones-rol
Authorization: Bearer {{sellerToken}}
EOF

El archivo de concesiones demuestra el efecto inmediato: un POST concede POST /api/clientes al rol SELLER, y la siguiente petición del mismo usuario deja de recibir 403 sin reiniciar el servidor. La autorización se resuelve por datos en cada petición.

Verificación

npx tsc --noEmit
npm run db:seed
SELECT COUNT(*) FROM role_users;      -- 2 (admin→ADMIN, seller→SELLER)
SELECT COUNT(*) FROM resource_roles;  -- 65 (ADMIN 58 + SELLER 7)

DoD del ISS-12

  • [ ] Todos los criterios de aceptación (17.1 … 17.8) cumplidos
  • [ ] Reasignar un rol ya activo → 409; sobre uno inactivo → reactiva
  • [ ] Reejecutar npm run db:seed deja resource_roles en 65 filas activas exactas
  • [ ] npx tsc --noEmit sin errores y npm run dev arranca

Diagnóstico

Síntoma Causa probable Solución
POST /api/asignaciones-rol responde 409 cuando esperabas 201 La pareja (user_id, role_id) ya existía active Es correcto: el rol ya estaba asignado. Consulta el id con GET /api/asignaciones-rol
Asignar un rol devuelve 404 El usuario o el rol no existen o están inactive El service exige ambos extremos activos; reactiva primero el usuario o el rol
Crear la misma asignación duplica filas No se está usando findByUserAndRole (o falta la restricción única) Revisa la restricción uq_role_users_user_role y la lógica create-or-reactivate
Reejecutar npm run db:seed deja resource_roles con más de 65 filas El seeder inserta en lugar de reconciliar Usa reconcileRole, que retira lo que sobra y reactiva lo inactivo
ADMIN no tiene 58 recursos tras el seed El Map "METHOD /path" → id no casa con el catálogo, o algún recurso no está active Comprueba que resources está sembrado y activo antes de seedResourceRoles
El seller sigue recibiendo 403 tras conceder POST /api/clientes El middleware authorize aún no existe (llega en ISS-13) o el recurso concedido no coincide con (method, path) Recuerda: la concesión es dato; el consumidor es de ISS-13. Verifica que el recurso sea POST /api/clientes
npx tsc --noEmit falla en los DTOs Falta exportar desde el barrel dto/index.ts Añade el export * correspondiente
Un permiso retirado sigue concediendo acceso Estás mirando un token/caché antiguo, no la BD La matriz se consulta en cada petición; confirma el status de la fila en resource_roles

Pregunta que responde: si algo falla aquí, ¿por dónde empiezo a mirar?

Conexión con el resto del curso

  • Depende de ISS-11 — Features Roles y Resources: sin los catálogos de roles y de los 58 recursos, no hay nada que asignar ni que conceder.
  • Habilita ISS-13 — Middlewares de acceso: el middleware authorize consumirá findEffectiveForUser para resolver cada petición. La matriz que construyes aquí es exactamente el dato que ese middleware lee.
  • Reutiliza de ISS-09 — Auth base: los modelos pivote RoleUser/ResourceRole, las asociaciones de rbac.associations.ts y las utilidades AppError, BaseController y withTransaction.
  • Reutiliza de ISS-10 — Users: la noción de «permisos efectivos» que el service de usuarios ya exponía; aquí se materializa la consulta que la alimenta.
  • Prepara ISS-14 — RefreshTokens y ISS-15 — Sesión: las sesiones y la sesión completa se apoyarán en la misma identidad que recorre esta cadena.

El patrón que aprendes aquí —N:M mediante tabla pivote con status y borrado lógico— reaparece siempre que necesites relacionar dos catálogos sin perder historial.

Criterios de aceptación

Los del ISS, textuales, como checklist:

  • [ ] 17.1 role-users/dto/ (create-role-user.dto.ts, role-user-response.dto.ts, index.ts)
  • [ ] 17.2 role-users.{repository,service,controller,routes}.ts; POST /api/asignaciones-rol idempotente (reactiva si existía inactiva)
  • [ ] 17.3 role-users.seeder.ts (admin→ADMIN, seller→SELLER) y role-users.swagger.ts
  • [ ] 17.4 resource-roles/dto/ (create-resource-role.dto.ts, list-resource-roles.dto.ts, resource-role-response.dto.ts, index.ts)
  • [ ] 17.5 resource-roles.{repository,service,controller,routes}.ts; POST /api/concesiones-rol idempotente y /deactivate · /reactivate
  • [ ] 17.6 ResourceRolesService.reconcileRole(roleId, resourceIds) determinista (concede lo que falta, reactiva, retira lo que sobra)
  • [ ] 17.7 resource-roles.seeder.ts construye la matriz (ADMIN 58, SELLER 7) y resource-roles.swagger.ts
  • [ ] 17.8 archivos .http de ambos features
  • [ ] npx tsc --noEmit OK

Y el DoD del ISS:

  • [ ] Todos los criterios de aceptación (17.1 … 17.8) cumplidos
  • [ ] Reasignar un rol ya activo → 409; sobre uno inactivo → reactiva
  • [ ] Reejecutar npm run db:seed deja resource_roles en 65 filas activas exactas
  • [ ] npx tsc --noEmit sin errores y npm run dev arranca

Evaluación

Preguntas de comprensión

  1. ¿Por qué el ISS-12 necesita DOS tablas pivote y no una? Porque la autorización tiene dos saltos: usuario → rol y rol → recurso. role_users responde «¿quién tiene qué rol?» y resource_roles responde «¿qué concede qué rol?». Separarlas permite conceder un permiso una sola vez (al rol) y que lo hereden todos sus usuarios, sin tocar role_users.

  2. ¿Qué significa exactamente que asignar un rol sea «idempotente»? Que repetir la operación no crea filas duplicadas. Si la pareja (user_id, role_id) no existe, se crea active; si ya estaba active, se responde 409 sin tocar nada; si estaba inactive, se reactiva. La restricción única de la tabla lo garantiza.

  3. ¿Por qué la cadena se corta si cualquier eslabón está inactive? Porque findEffectiveForUser exige status = active en los cuatro eslabones (resource_roles, roles, role_users, resources). Si uno falla, la fila no aparece en el resultado. Un eslabón inactivo es, por diseño, «no hay permiso».

  4. Conceder POST /api/clientes al rol SELLER, ¿por qué surte efecto sin reiniciar el servidor? Porque los permisos no viajan en el token. La autorización se resuelve contra la base de datos en cada petición. La siguiente petición del seller vuelve a recorrer la cadena y ya encuentra la concesión nueva. (En la práctica, el authorize que hace esa lectura llega en el ISS-13.)

  5. ¿Qué diferencia hay entre deactivate y un DELETE? deactivate es borrado lógico: cambia status a inactive y conserva la fila. DELETE sería borrado físico y perdería la auditoría. El borrado lógico es reversible (/reactivate) y evita chocar con la restricción única.

  6. ¿Qué garantiza reconcileRole que no garantiza grant? grant concedo un permiso concreto; si lo repites, choca (409). reconcileRole recibe la lista completa de recursos y deja el rol exactamente con ese conjunto: concede lo que falta, reactiva lo inactivo y retira lo que sobra. Es total y determinista.

  7. ¿Por qué el seeder de resource_roles es la pieza que «construye la matriz»? Porque traduce el catálogo de recursos (definido en código, en RESOURCE_CATALOG y SELLER_RESOURCES) a filas reales de resource_roles, usando reconcileRole. Sin él, los roles y los recursos serían dos catálogos sin conexión.

  8. Si borraras físicamente todas las filas de role_users de un usuario, ¿qué pasaría con sus permisos? Que el usuario quedaría sin roles asignados y, por tanto, sin ningún permiso alcanzable: la cadena se corta en el segundo eslabón. Es justo lo que se evita con el borrado lógico, que permite restaurar la asignación.

Ejercicios

Ejercicio 1 — Traza la cadena. Dado un usuario seller con rol SELLER, y el rol SELLER con concesión activa sobre el recurso POST /api/ventas, enumera las cuatro filas/condiciones que deben estar activas para que una petición POST /api/ventas sea autorizada.

Respuesta razonada Las cuatro condiciones, una por eslabón: 1. `resources`: existe `(method='POST', path='/api/ventas')` y está `active`. 2. `resource_roles`: existe `(role_id=SELLER, resource_id=ventas)` y está `active`. 3. `roles`: el rol `SELLER` está `active`. 4. `role_users`: existe `(user_id=seller, role_id=SELLER)` y está `active`. Si cualquiera falla, `findEffectiveForUser` no devuelve esa fila y el middleware respondería **403** (deny by default). Recuerda que, al cierre del ISS-12, ese middleware todavía no existe: aquí lo que se comprueba es que el **dato** esté bien.

Ejercicio 2 — Simula el seeder dos veces. Explica por qué npm run db:seed ejecutado dos veces seguidas deja exactamente 65 filas activas en resource_roles y no 130.

Respuesta razonada Porque el seeder no inserta a ciegas: llama a `reconcileRole(roleId, ids)` por cada rol. En la primera pasada crea las concesiones; en la segunda encuentra las filas ya `active` y **no las toca**. La lista de recursos deseados es la misma, así que no hay nada que conceder, reactivar ni retirar. El resultado es determinista: 58 de ADMIN + 7 de SELLER = 65 activas, independientemente de cuántas veces lo ejecutes. La clave está en que `reconcileRole` también **retira** lo que sobra, por lo que ejecuciones manuales intermedias no acumulan permisos.

Ejercicio 3 — Decide el borrado. Un administrador quiere «eliminar de verdad» la asignación de un rol que ya no se usa. Explica qué le responderías desde el diseño de este ISS y qué haría en su lugar.

Respuesta razonada Le explicaría que el diseño es **borrado lógico a propósito**: la fila se conserva con `status = inactive`. Razones: (1) auditoría —queda registro de quién tuvo qué rol y cuándo; (2) reversibilidad —`/reactivate` devuelve el permiso sin recrear la fila; (3) integridad —la restricción única `(user_id, role_id)` garantiza que «volver a asignar» sea reactivar, nunca duplicar. Lo que haría es `PATCH /api/asignaciones-rol/:id/deactivate`. El borrado físico no está expuesto por ninguna ruta.

GATE

Para cerrar el ISS-12, ejecuta exactamente:

npx tsc --noEmit
npm run db:seed

Y comprueba los conteos de la matriz:

SELECT COUNT(*) FROM role_users;      -- 2 (admin→ADMIN, seller→SELLER)
SELECT COUNT(*) FROM resource_roles;  -- 65 (ADMIN 58 + SELLER 7)

Resultado esperado: npx tsc --noEmit termina sin errores, el SeedersRunner finaliza y los dos COUNT(*) devuelven 2 y 65. Reejecutar el seed debe dejar los mismos números.

Checklist de cierre:

  • [ ] npx tsc --noEmit → sin errores
  • [ ] npm run db:seed → SeedersRunner finalizado
  • [ ] SELECT COUNT(*) FROM role_users → 2
  • [ ] SELECT COUNT(*) FROM resource_roles → 65
  • [ ] Reasignar un rol activo → 409; sobre uno inactivo → reactiva
  • [ ] Los criterios 17.1 … 17.8 en verde

Con el GATE en verde, la matriz RBAC existe y es determinista. El siguiente ISS, ISS-13 — Middlewares de acceso, construye los middlewares authenticate y authorize que consumen esta matriz y protegen, por fin, las rutas de negocio.

Glosario

Término Significado en este ISS
RBAC Role-Based Access Control: los permisos se conceden a roles, no a usuarios individuales; el usuario hereda los de sus roles.
Matriz de permisos El conjunto de filas activas de resource_roles: para cada rol, qué recursos puede ejecutar.
Tabla pivote Tabla intermedia de una relación N:M. Aquí: role_users (User↔Role) y resource_roles (Role↔Resource).
Asignación Fila de role_users: un usuario tiene un rol.
Concesión Fila de resource_roles: un rol puede ejecutar un recurso.
Permiso La tupla (role_id, resource_id) materializada. No existe la entidad Permission.
Permiso efectivo Recurso alcanzable por un usuario tras recorrer la cadena con los cuatro eslabones activos.
Borrado lógico Marcar status = inactive en lugar de borrar la fila; preserva auditoría y es reversible.
Upsert lógico Create-or-reactivate: crear si no existe, reactivar si existía inactiva, 409 si ya estaba activa.
Idempotente Repetir la operación no cambia el resultado (el seeder deja siempre 65).
reconcileRole Deja el catálogo de un rol exactamente en un conjunto de recursos (concede, reactiva, retira).
Deny by default Sin concesión activa → 403. Es la política del middleware del ISS-13.
Efecto inmediato El cambio en la matriz se ve en la siguiente petición, porque los permisos no viajan en el token.
JWT + RBAC Modalidad de acceso que combina authenticate (token válido) y authorize (concesión activa).

Navegación de la ruta: ← ISS-11 · 🛠 Construir · ↑ Ruta Express · → ISS-12 · 🛠 Construir