📚 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.
🎬 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:seeddejaresource_rolesen 65 filas activas exactas- [ ]
npx tsc --noEmitsin errores ynpm run devarranca
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:
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
- Ficha del ISS
- Mapa mental del ISS
- Mapa del backend
- La cadena de autorización
- Asignar, retirar y reactivar
- Árbol de archivos
- Anatomía del código
- Comandos explicados
- Flujos
- Recorrido del ISS, paso a paso
- Diagnóstico
- Conexión con el resto del curso
- Criterios de aceptación
- Evaluación
- GATE
- 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_usersoperativa:POST /api/asignaciones-rolasigna un rol a un usuario;/deactivatey/reactivatecambian el estado sin borrar filas. - La tabla pivote
resource_rolesoperativa:POST /api/concesiones-rolconcede un recurso a un rol;/deactivatey/reactivategestionan 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) yresource_roles(ADMIN 58, SELLER 7). - Swagger y archivos
.httpde 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-rolfuncionando 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:
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_usersconecta usuario ↔ rol. Responde «¿quién tiene qué rol?».resource_rolesconecta 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
reconcileRoleque 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
includereutilizableSUMMARIEStrae un resumen del usuario (id,username,email) y del rol (id,name). La contraseña no se proyecta aquí: se excluye en el propioattributes, de modo que nunca sale de la base de datos. findAllActive()filtra porstatus: "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()yupdate()son los dos únicos caminos de escritura. No haydestroy(): el borrado físico no existe en este feature.
Se conecta con
- Entrada: lo llama
RoleUsersService. - Salida: llama a los modelos
RoleUser,UseryRolea 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 lleguenuser_idyrole_id(400 si faltan) y después exige que ambos extremos estén activos conassertUserActive()yassertRoleActive()(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 existeinactive, la reactiva y recarga el resultado con sus resúmenes. deactivate()yreactivate()cambian el estado.findOrFail(id, onlyActive)centraliza la búsqueda: por defecto solo acepta asignaciones activas, yreactivatela llama cononlyActive = falsepara poder encontrar las inactivas.- El
reload()posterior garantiza que la respuesta incluya losincludede resumen, no solo la instancia recién modificada.
Se conecta con
- Entrada: lo llama
RoleUsersController. - Salida: usa
RoleUsersRepository,UsersRepositoryyRolesRepository; lanzaAppError.
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/deactivatey/:id/reactivatesonPATCH, coherentes con el borrado lógico.
Se conecta con
- Entrada: la registra el parche
src/routes/index.tsy la montasrc/config/index.ts. - Salida: llama a
RoleUsersController; usaauthenticate/authorizede../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 dereconcileRole, que necesita ver lo que sobra para retirarlo.findByRoleAndResource()habilita la misma semántica create-or-reactivate que enrole_users.findEffectiveForUser(userId)es la joya: usaincludeconrequired: true(INNER JOIN) para exigir los cuatro eslabonesactive. ElincludedelRoleUserse proyecta conattributes: []porque solo interesa que exista y cumpla elwhere; 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,ResourceyRoleUser; produceEffectivePermissionDto.
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á elauthorizedel ISS-13.reconcileRole()es el método estrella: construye unSetcon los recursos deseados, recorre los existentes y decide conceder / reactivar / retirar. Devuelve unReconcileResultconactivated,deactivatedytotal_active, todo dentro dewithTransaction.
Se conecta con
- Entrada: lo llama
ResourceRolesController(y el seeder, directamente). - Salida: usa
ResourceRolesRepository,RolesRepositoryyResourcesRepository; usawithTransaction.
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
ResourceRolesServicey usareconcileRole, 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ónidsFor()traduce el catálogo en código a losresource_idreales: el seeder nunca codifica ids numéricos a fuego. - Aplica
reconcileRoleaADMINconRESOURCE_CATALOG(58) y aSELLERconSELLER_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 pordatabase/seeders/index.ts. - Salida: usa
ResourceRolesService, los modelosResourceyRole, y los catálogosRESOURCE_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
reconcileRolepor 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 haypermissions) 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_rolesAPI /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-rolidempotente (reactiva si existía inactiva) - [ ] 17.3
role-users.seeder.ts(admin→ADMIN, seller→SELLER) yrole-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-rolidempotente y/deactivate·/reactivate - [ ] 17.6
ResourceRolesService.reconcileRole(roleId, resourceIds)determinista (concede lo que falta, reactiva, retira lo que sobra) - [ ] 17.7
resource-roles.seeder.tsconstruye la matriz (ADMIN 58, SELLER 7) yresource-roles.swagger.ts - [ ] 17.8 archivos
.httpde ambos features - [ ]
npx tsc --noEmitOK
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
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:seeddejaresource_rolesen 65 filas activas exactas - [ ]
npx tsc --noEmitsin errores ynpm run devarranca
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
authorizeconsumiráfindEffectiveForUserpara 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 derbac.associations.tsy las utilidadesAppError,BaseControllerywithTransaction. - 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-rolidempotente (reactiva si existía inactiva) - [ ] 17.3
role-users.seeder.ts(admin→ADMIN, seller→SELLER) yrole-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-rolidempotente y/deactivate·/reactivate - [ ] 17.6
ResourceRolesService.reconcileRole(roleId, resourceIds)determinista (concede lo que falta, reactiva, retira lo que sobra) - [ ] 17.7
resource-roles.seeder.tsconstruye la matriz (ADMIN 58, SELLER 7) yresource-roles.swagger.ts - [ ] 17.8 archivos
.httpde ambos features - [ ]
npx tsc --noEmitOK
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:seeddejaresource_rolesen 65 filas activas exactas - [ ]
npx tsc --noEmitsin errores ynpm run devarranca
Evaluación
Preguntas de comprensión
-
¿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_usersresponde «¿quién tiene qué rol?» yresource_rolesresponde «¿qué concede qué rol?». Separarlas permite conceder un permiso una sola vez (al rol) y que lo hereden todos sus usuarios, sin tocarrole_users. -
¿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 creaactive; si ya estabaactive, se responde 409 sin tocar nada; si estabainactive, se reactiva. La restricción única de la tabla lo garantiza. -
¿Por qué la cadena se corta si cualquier eslabón está
inactive? PorquefindEffectiveForUserexigestatus = activeen 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». -
Conceder
POST /api/clientesal 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, elauthorizeque hace esa lectura llega en el ISS-13.) -
¿Qué diferencia hay entre
deactivatey unDELETE?deactivatees borrado lógico: cambiastatusainactivey conserva la fila.DELETEserí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. -
¿Qué garantiza
reconcileRoleque no garantizagrant?grantconcedo un permiso concreto; si lo repites, choca (409).reconcileRolerecibe 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. -
¿Por qué el seeder de
resource_roleses la pieza que «construye la matriz»? Porque traduce el catálogo de recursos (definido en código, enRESOURCE_CATALOGySELLER_RESOURCES) a filas reales deresource_roles, usandoreconcileRole. Sin él, los roles y los recursos serían dos catálogos sin conexión. -
Si borraras físicamente todas las filas de
role_usersde 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:
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