Saltar a contenido

📚 Unidad ISS-11 · Features Roles y Resources — 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 Roles y Resources 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-11 — Features Roles y Resources (8 diapositivas). Se visualiza aquí, dentro del sitio; no hace falta descargar nada para estudiarla.

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

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


🎬 Video explicativo

Recorrido audiovisual de la unidad. Este video explica, bloque por bloque, roles y recursos del ISS-11. Crear un ADMIN no concede nada; seller: true marca siete recursos de operación. El catálogo sembrado suma 58.

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


ISS-11 — Cuaderno de aprendizaje visual

Tema

Features Roles y Resources (catálogo de autorización): construir los dos extremos del permiso —el rol como sujeto y el recurso como objeto, un par method + path— con su CRUD administrativo completo y el catálogo semilla de los 58 recursos del sistema.

Fuente técnica autoritativa

Archivo fuente ../manual/13-ISS-11-auth-roles-resources.md
Nombre exacto 13-ISS-11-auth-roles-resources.md
Estado Solo lectura — este cuaderno no modifica el ISS
Alcance 1.636 líneas, 54 bloques de código, criterios 16.1–16.7 + DoD

El ISS 13-ISS-11-auth-roles-resources.md es la fuente técnica autoritativa: manda sobre este cuaderno. Todo su contenido técnico (código, catálogo, criterios, comandos) aparece aquí íntegro y verbatim más abajo, en la sección Recorrido del ISS, paso a paso. Este cuaderno es solo la capa pedagógica: explica el por qué que el ISS da por supuesto.

Pregunta que responde: ¿qué archivo manda cuando este cuaderno y mi memoria se contradicen?

Regla del ISS

La condición que el propio ISS exige para darse por terminado es su Definition of Done, y es literal:

  • [ ] Todos los criterios de aceptación (16.1 … 16.7) cumplidos
  • [ ] resources tiene 58 filas y UQ(method, path) impide duplicados (409)
  • [ ] Los roles se crean sin permisos; concederlos es el ISS-12
  • [ ] npx tsc --noEmit sin errores y npm run db:seed idempotente

De las cuatro líneas, la que más se olvida es la tercera: en este ISS los roles nacen vacíos. Crear ADMIN no concede nada; solo crea la etiqueta. El acto de conceder es el ISS-12, y hasta entonces Role y Resource son dos catálogos que existen pero no se tocan.

Pregunta que responde: ¿cuándo puedo considerar que el ISS-11 está realmente terminado?

Cómo leer este cuaderno

Todo concepto importante 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: ¿por qué el mismo concepto aparece explicado, codificado y dibujado?

El recorrido de lectura de cada concepto es:

EXPLICACIÓN
    ↓
CÓDIGO
    ↓
VISUAL

La explicación te da el modelo mental; el código te muestra el qué exacto (y en este cuaderno aparece verbatim, sin resumir); lo visual te deja ver cómo las piezas se conectan. Si te saltas uno de los tres, el concepto queda a medias.

Cada cuaderno contiene los mismos seis componentes:

Componente Dónde vive Para qué sirve
Texto todas las secciones entender el por qué
Código Recorrido del ISS (verbatim) ver el qué exacto
Diagramas Mapa mental, Mapa del backend, Roles + Recursos, 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é espero encontrar en cada parte de este cuaderno?

Ruta de aprendizaje

Esta ruta es específica de ISS-11 y solo cubre lo que este ISS construye:

Ficha + mapa del ISS (ubicarte)
    ↓
Entender el modelo: rol (sujeto) + recurso (objeto)
    ↓
Feature roles: DTOs → repository → service → controller → routes
    ↓
Feature resources: DTOs + catálogo de 58 → repository → service → controller → routes
    ↓
Seeder de roles (ADMIN, SELLER) + seeder del catálogo
    ↓
Swagger de ambos features + registro en routes/config/swagger/seeders
    ↓
Verificación (tsc + db:seed: 58 recursos y 2 roles)
    ↓
GATE

Fíjate en lo que no aparece en la ruta: «asignar un rol a un usuario», «conceder un recurso a un rol», «proteger una ruta». Nada de eso se implementa aquí; todo eso empieza en ISS-12 y ISS-13. Este ISS construye los dos catálogos, no los puentes entre ellos.

Pregunta que responde: ¿en qué orden construyo las piezas de este ISS y qué dejo deliberadamente fuera?

Índice

  1. Ficha del ISS
  2. Mapa mental del ISS
  3. Mapa del backend
  4. Roles + Recursos = permiso
  5. Árbol de archivos
  6. Anatomía del código
  7. Comandos explicados
  8. Flujos
  9. Diagnóstico
  10. Criterios de aceptación
  11. Evaluación
  12. Conexión con el resto del curso
  13. Glosario
  14. Recorrido del ISS, paso a paso
  15. GATE

Ficha del ISS

Campo Valor
ISS ISS-11
Título Features Roles y Resources (catálogo de autorización)
Objetivo Construir los dos extremos del permiso: el rol (a quién se concede) y el recurso (qué se concede, par method + path), con el catálogo semilla de 58 recursos
Fase Fase II — Auth con RBAC
Tecnología principal Express 5 + TypeScript + Sequelize; RBAC con catálogo determinista
Depende de ISS-10 — Feature Users
Habilita ISS-12 — Asignaciones y concesiones
Archivos creados 12 en features/auth/roles/ (dto ×5, repository, service, controller, routes, seeder, swagger, http) y 13 en features/auth/resources/ (dto ×5, resource-catalog.ts, repository, service, controller, routes, seeder, swagger, http)
Archivos parcheados src/routes/index.ts, src/config/index.ts, src/swagger/index.ts, src/database/seeders/index.ts
Componentes incorporados Feature roles completo · feature resources completo · catálogo en código de los 58 recursos · seeders idempotentes · documentación OpenAPI de ambos
Verificación principal npx tsc --noEmit sin errores + npm run db:seed idempotente
Resultado esperado resources con 58 filas y roles con 2 (ADMIN, SELLER); UQ(method, path) responde 409 ante duplicados
GATE tsc OK, seed OK, 58 + 2 en la BD y roles sin permisos

Qué implementamos AHORA

Construimos dos features administrativos completos, con las mismas cuatro capas que los de Fase I:

  • features/auth/roles/ — CRUD de roles. Un rol es un agrupador de responsabilidades con nombre único (name) y borrado lógico (status). Se siembran ADMIN y SELLER.
  • features/auth/resources/ — CRUD de recursos. Un recurso es un punto de acceso protegible, el par (method, path). Se siembra el catálogo de 58 recursos definido en código en resource-catalog.ts.

Además dejamos cableado todo lo que permite que esos features existan sin romper el proyecto: se registran sus rutas en el agregador, se cargan sus modelos en el arranque, se documentan en Swagger y se añaden sus seeders al SeedersRunner. Y dejamos la verificación lista: tsc sin errores y db:seed que reconcilia el catálogo.

Lo que no implementamos, y es la trampa conceptual de este ISS: los roles y los recursos se crean, pero todavía no se unen. La autorización de verdad no ocurre aquí.

Qué todavía NO implementamos

No se implementa aquí Llega en
Tabla pivote role_users (asignar un rol a un usuario) ISS-12
Tabla pivote resource_roles (conceder un recurso a un rol) ISS-12
Endpoints /api/asignaciones-rol y /api/concesiones-rol ISS-12
reconcileRole — la matriz determinista (ADMIN 58, SELLER 7) ISS-12
Middlewares authenticate y authorize (features/auth/access/) ISS-13
Protección efectiva de las 5 rutas de negocio (JWT + RBAC) ISS-13
refresh_tokens (rotación y detección de reúso) ISS-14
POST /api/sesion/login y el resto de la sesión ISS-15
Una tabla permissions No llega nunca: por diseño no existe

Dos advertencias que evitan el error más común de este ISS:

  1. Un rol no autoriza. Crear AUDITOR en POST /api/roles no le concede absolutamente nada. La autorización son las filas de resource_roles que se crean en ISS-12.
  2. Los middlewares son meta, no presente. Los archivos de rutas de este ISS declaran la modalidad JWT + RBAC (importan authenticate, authorize), pero el archivo que los implementa (features/auth/access/) llega en ISS-13. Aquí se declara la intención; la protección efectiva aún no existe.

Pregunta que responde: ¿qué debo resistir la tentación de implementar dentro del ISS-11?

Mapa mental del ISS

mindmap
  root((ISS-11<br/>Roles y Resources))
    Objetivo
      Dos extremos del permiso
      Rol como sujeto
      Recurso como objeto
    Feature roles
      CRUD administrativo
      name unico
      Seeder ADMIN y SELLER
      Nacen sin permisos
    Feature resources
      CRUD administrativo
      Catalogo de 58 recursos
      path en patron
      UQ method y path
    Concepto central
      No existe tabla permissions
      Permiso igual a rol mas recurso
      Matriz en ISS-12
    Verificacion
      tsc sin errores
      seed con 58 recursos y 2 roles
    Pendiente
      Matriz RBAC ISS-12
      Middlewares ISS-13

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? Distingue lo que ya existe de lo que sigue siendo objetivo.

IMPLEMENTADO HASTA ESTE ISS (ISS-11)
────────────────────────────────────

   HTTP
     │
     ▼
   Routes  (registradas en src/routes/index.ts)
     ├── Fase I  · 5 features business          ✅ (ISS-03…08)
     ├── Fase II · users                         ✅ (ISS-10)
     ├── Fase II · roles        ★ nuevo          ✅ (ISS-11)
     └── Fase II · resources    ★ nuevo          ✅ (ISS-11)

   Controller → Service → Repository → Model → Sequelize → BD
     roles y resources: las 4 capas completas    ✅ (ISS-11)

   Capa base de seguridad
     ├── JWT HS256, bcrypt 12                    ✅ (ISS-09)
     ├── AppError, sendError, BaseController      ✅ (ISS-09)
     └── resource-match (patron ↔ concreto)       ✅ (ISS-09)

   Feature users (hash, permisos efectivos)       ✅ (ISS-10)

   BD
     ├── tabla roles        → 2 filas sembradas   ✅
     └── tabla resources    → 58 filas sembradas  ✅


OBJETIVO DE ARQUITECTURA (aún no)
─────────────────────────────────

   🎯 MATRIZ RBAC — las dos tablas pivote          ISS-12
        role_users      (usuario ↔ rol)
        resource_roles  (rol ↔ recurso)

   🎯 Middlewares authenticate / authorize          ISS-13
        y las 5 rutas de negocio en JWT + RBAC

   🎯 Refresh tokens (rotación, detección de reúso) ISS-14
   🎯 Sesión: login / refresh / logout / perfil     ISS-15

   ⬜ Fuera de alcance del laboratorio

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

Lo más importante de este mapa son los dos 🎯. El feature roles y el feature resources ya están ✅, pero el eslabón que los une (la matriz role_users + resource_roles) y el middleware que la consulta (authorize) están 🎯. Si en tu cabeza «roles y resources» equivale ya a «RBAC funcionando», este diagrama te corrige: tienes los dos extremos del permiso, no el permiso.

Roles + Recursos = permiso

Este es el concepto central del RBAC del curso, y también el que más se malinterpreta. Léelo despacio.

En este proyecto NO existe una tabla permissions. No es un olvido: es una decisión de diseño. Un permiso no es una entidad que se guarda; es la conjunción de dos cosas que sí se guardan por separado:

   permiso  ≡  ( un ROL )  ∧  ( un RECURSO = method + path )

   ┌──────────────────┐        ┌────────────────────────────┐
   │  Rol              │        │  Recurso                    │
   │  ADMIN / SELLER   │   ∧    │  GET  /api/productos/:id    │
   └──────────────────┘        └────────────────────────────┘
             │                              │
             └──────────── AND ─────────────┘
                            │
                            ▼
                  "el rol ADMIN puede hacer
                   GET sobre /api/productos/:id"

Ese «∧» se materializa en la fila de una tabla pivote: resource_roles guarda el par (role_id, resource_id) con su status. Cuando esa fila existe y está activa, el permiso existe; cuando se desactiva, el permiso deja de existir. No hay ningún registro llamado «permiso» en ninguna parte.

flowchart TD
    U["Usuario"] --> RU["role_users<br/>asignacion usuario-rol<br/>(ISS-12)"]
    RU --> R["Rol<br/>ADMIN o SELLER"]
    R --> RR["resource_roles<br/>concesion rol-recurso<br/>(ISS-12)"]
    RR --> RES["Recurso<br/>par method + path<br/>(ISS-11)"]
    REQ["Peticion HTTP<br/>GET /api/productos/42"] --> AUTH["authorize<br/>(ISS-13)"]
    AUTH --> RR
    RES --> AUTH
    AUTH -->|"hay concesion activa"| OK["200 OK"]
    AUTH -->|"no hay concesion"| DENY["403 Forbidden"]

Pregunta que responde: ¿dónde vive «el permiso» si no existe una tabla permissions?

Fíjate en los colores del diagrama (expresados en los rótulos): lo que este ISS construye es el bloque Recurso; el Rol ya lo construye también aquí (roles), pero el Usuario es ISS-10, la concesión (resource_roles) es ISS-12 y el middleware que decide (authorize) es ISS-13. El ISS-11 es, literalmente, la pieza «qué se concede» de la fórmula.

Qué es el path PATRÓN de un recurso

Un recurso no guarda la URL concreta que un cliente pidió, sino la ruta en patrón, con sus parámetros:

   se guarda:   GET  /api/productos/:id        ← PATRON
   llega:       GET  /api/productos/42         ← valor concreto

Se concede por patrón y no por URL concreta por una razón de escala y de sentido común: si se guardara la URL concreta, conceder «leer un producto» exigiría una fila por cada producto del catálogo, y un producto nuevo nacería sin permiso. Con el patrón, una sola concesión cubre todos los identificadores.

La coincidencia la resuelve shared/auth/resource-match.ts, y sus reglas son deliberadamente estrictas:

  • el verbo HTTP debe coincidir exactamente;
  • un segmento :param del patrón casa con un segmento cualquiera;
  • el resto de segmentos deben ser iguales carácter a carácter;
  • el número de segmentos debe coincidir (no hay comodines *).

Por eso GET /api/productos/42 no casa con el patrón GET /api/productos: si casara, un permiso de «listar» autorizaría por error una «lectura concreta». Y /api/productos/42/lotes tampoco, porque tiene un segmento de más. Esta función ya existe (es de ISS-09), pero se usa de verdad cuando el middleware authorize (ISS-13) compara el (method, path) real de la petición contra los recursos concedidos. Por eso este ISS guarda los recursos con el patrón, no con valores concretos: es la condición que hará posible ISS-12 e ISS-13.

Pregunta que responde: ¿por qué /api/productos/:id y no /api/productos/42 en el catálogo?

Árbol de archivos

Leyenda: ★ = creado en este ISS · △ = ya existía y se parchea.

Estructura antes

Solo se muestran las piezas relevantes de features/auth/ (los modelos y los usuarios ya existen desde ISS-09/ISS-10).

app-storelab-express-ii/
├── src/
│   ├── config/index.ts                    (carga modelos y registra rutas)
│   ├── routes/index.ts                    (agregador de features)
│   ├── swagger/index.ts                   (registro OpenAPI)
│   ├── database/seeders/index.ts          (SeedersRunner)
│   └── features/
│       ├── auth/
│       │   ├── users/                     ✅ ISS-10
│       │   ├── role.model.ts              ✅ ISS-09
│       │   └── resource.model.ts          ✅ ISS-09
│       └── business/                      ✅ ISS-03…08

Pregunta que responde: ¿qué piezas de seguridad existían ya antes de este ISS?

Archivos creados / modificados en este ISS

src/
├── routes/index.ts                                  △ (registra roles + resources)
├── config/index.ts                                  △ (carga ambos modelos + arranca sus rutas)
├── swagger/index.ts                                 △ (fusiona ambos módulos OpenAPI)
├── database/seeders/index.ts                        △ (llama seedRoles + seedResources)
└── features/auth/
    ├── roles/
    │   ├── dto/
    │   │   ├── create-role.dto.ts                   ★
    │   │   ├── update-role.dto.ts                   ★
    │   │   ├── patch-role.dto.ts                    ★
    │   │   ├── role-response.dto.ts                 ★
    │   │   └── index.ts                             ★
    │   ├── roles.repository.ts                      ★
    │   ├── roles.service.ts                         ★
    │   ├── roles.controller.ts                      ★
    │   ├── roles.routes.ts                          ★
    │   ├── roles.seeder.ts                          ★
    │   ├── roles.swagger.ts                         ★
    │   └── http/roles.get.http                      ★
    └── resources/
        ├── dto/
        │   ├── create-resource.dto.ts               ★
        │   ├── update-resource.dto.ts               ★
        │   ├── patch-resource.dto.ts                ★
        │   ├── resource-response.dto.ts             ★
        │   └── index.ts                             ★
        ├── resource-catalog.ts                      ★ (los 58 recursos)
        ├── resources.repository.ts                  ★
        ├── resources.service.ts                     ★
        ├── resources.controller.ts                  ★
        ├── resources.routes.ts                      ★
        ├── resources.seeder.ts                      ★
        ├── resources.swagger.ts                     ★
        └── http/resources.get.http                  ★

Estructura después

app-storelab-express-ii/
├── src/
│   ├── config/index.ts                    △ (modelos roles/resources + rutas)
│   ├── routes/index.ts                    △ (RolesRoutes + ResourcesRoutes)
│   ├── swagger/index.ts                   △ (rolesSwagger + resourcesSwagger)
│   ├── database/seeders/index.ts          △ (seedRoles + seedResources)
│   └── features/
│       ├── auth/
│       │   ├── users/                     ✅ ISS-10
│       │   ├── roles/                     ★ feature completo
│       │   ├── resources/                 ★ feature completo + catálogo
│       │   ├── role.model.ts              ✅ ISS-09
│       │   └── resource.model.ts          ✅ ISS-09
│       └── business/                      ✅ ISS-03…08

Pregunta que responde: ¿qué archivos nacen y qué archivos se parchean en este ISS?

Observa el patrón de la estructura después: los dos features nuevos (roles/, resources/) tienen exactamente la misma forma que los de Fase I —dto/, repository, service, controller, routes— más dos piezas propias: el seeder (catálogo determinista) y el swagger. Además, el feature resources/ añade un archivo que no existe en ningún otro feature: resource-catalog.ts, la fuente única de los 58 recursos.

Anatomía del código

En esta sección se despieza cada archivo sin repetir su código (el código verbatim está en Recorrido del ISS, paso a paso). Aquí interesa el por qué de cada pieza.

Archivo: src/features/auth/roles/dto/

Propósito

Definir el contrato de entrada y salida del feature Roles, separado del modelo de Sequelize. Cinco archivos pequeños: create-role.dto.ts, update-role.dto.ts, patch-role.dto.ts, role-response.dto.ts e index.ts (barril).

Explicación

  • CreateRoleDto — lo que se acepta en POST /api/roles: name obligatorio, description opcional y status opcional (por defecto active).
  • UpdateRoleDto — lo que se acepta en PUT /api/roles/:id: reemplazo completo con name obligatorio. No incluye status, porque el estado solo cambia con el borrado lógico.
  • PatchRoleDto — Partial<UpdateRoleDto>: actualización parcial. Se deriva del anterior para no duplicar la forma.
  • RoleResponseDto — alias de RoleI; incluye toRoleResponse(), el mapper que convierte la instancia del modelo en un objeto plano con toJSON().
  • index.ts — reexporta los cuatro, de modo que el resto del feature importa siempre desde "./dto".

Se conecta con

  • Entrada: los importa el roles.controller.ts (tipar req.body) y el roles.service.ts (firmas de sus métodos).
  • Salida: RoleResponseDto es el contrato que devuelve la API; si el modelo cambia, el DTO puede mantenerse estable.

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

Propósito

Ser la única capa que habla con Sequelize para el modelo Role. El service no toca Role directamente.

Explicación

  • findAllActive() — devuelve solo los roles activos; alimenta la lista del CRUD.
  • findById(id, transaction?) — busca por clave primaria; el transaction opcional deja la puerta abierta a operaciones transaccionales.
  • findByName(name) — normaliza el nombre a MAYÚSCULAS antes de buscar; es la base del control de unicidad del service.
  • create, update, delete — inserción, persistencia de cambios y borrado físico de una instancia.

Se conecta con

  • Entrada: lo instancia el RolesService (por defecto new RolesRepository()).
  • Salida: usa el modelo Role (de ISS-09) y los tipos CreationAttributes/Transaction de Sequelize.

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

Propósito

Concentrar la regla de negocio: el nombre del rol es único, y el nombre no autoriza nada (solo agrupa).

Explicación

  • Organiza los métodos en bloques READ, CREATE, UPDATE y DELETE, igual que los features de Fase I.
  • create() valida que venga name (400), comprueba que no exista (409) y normaliza status a active si no se envía.
  • updatePut() y updatePatch() reutilizan assertNameAvailable() con un excludeId, para no chocar contra el propio rol que se está editando.
  • Ofrece dos borrados: deletePhysical() (destruye la fila) y deleteLogical() (cambia status a inactive). El segundo es el importante en RBAC: al desactivar un rol, todos sus usuarios pierden esos permisos (efecto que se notará en ISS-12/13).
  • Los helpers privados findOrFail() y assertNameAvailable() lanzan AppError (404 y 409), que el BaseController traduce a HTTP.

Se conecta con

  • Entrada: lo instancia el RolesController.
  • Salida: usa RolesRepository, el modelo Role, los DTOs y AppError.

Archivo: src/features/auth/roles/roles.controller.ts

Propósito

Traducir HTTP ↔ service. Solo lee req, llama al service y arma la respuesta; no contiene reglas de negocio.

Explicación

  • Extiende BaseController, así que hereda run() (que captura errores y delega en sendError) y paramId() (que valida el :id de la ruta).
  • Cada método envuelve su lógica en await this.run(res, async () => { … }), de modo que cualquier AppError se convierte en la respuesta JSON correcta.
  • getAll responde { roles }; getOne responde { role }; create usa 201; los update usan 200; deletePhysical responde un mensaje con el id; deleteLogical responde el rol ya desactivado.

Se conecta con

  • Entrada: lo instancia RolesRoutes y lo registra en las rutas.
  • Salida: llama a RolesService y hereda de BaseController (ISS-09).

Archivo: src/features/auth/roles/roles.routes.ts

Propósito

Declarar los endpoints del feature y su modalidad de acceso.

Explicación

  • Registra el CRUD completo sobre /api/roles y /api/roles/:id, más PATCH /api/roles/:id/deactivate para el borrado lógico.
  • Declara la modalidad JWT + RBAC en todas las operaciones: cada handler va precedido por authenticate, authorize. Es decir, la ruta nace ya «protegida» en su intención.
  • Usa singleton de controller como propiedad de la clase, y registra los handlers con .bind(...).

Se conecta con

  • Entrada: la instancia el agregador src/routes/index.ts (RolesRoutes).
  • Salida: importa authenticate, authorize desde ../access — implementación que llega en ISS-13 (aquí se declara la modalidad, no se implementa el middleware).

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

Propósito

Sembrar el catálogo mínimo de roles del sistema: ADMIN y SELLER, de forma determinista e idempotente.

Explicación

  • SEED_ROLES es una constante con los dos roles y su descripción: determinista, sin datos aleatorios (a diferencia de los seeders de Fase I con Faker).
  • seedRoles() recorre la lista y usa findOrCreate por nombre: si el rol no existe lo crea; si existe y estaba inactivo, lo reactiva. Reejecutarlo no duplica.
  • Devuelve cuántos roles nuevos se crearon y loguea el resultado de la reconciliación.
  • En su propio comentario deja claro el punto clave: los roles nacen sin permisos; las concesiones se crean en el seeder de resource_roles (ISS-12), donde ADMIN recibirá los 58 y SELLER los 7 de operación.

Se conecta con

  • Entrada: lo llama seedRoles() desde src/database/seeders/index.ts.
  • Salida: usa el modelo Role; prepara el terreno de ISS-12 (las concesiones se apoyan en estos dos nombres).

Archivo: src/features/auth/roles/roles.swagger.ts

Propósito

Documentar en OpenAPI el CRUD de roles, con su modalidad de seguridad y sus códigos de respuesta.

Explicación

  • Exporta rolesSwagger, un módulo que se fusiona en el documento OpenAPI del proyecto.
  • Cada operación declara security: bearerSecurity y describe el recurso (method, path) que le corresponde (por ejemplo, GET /api/roles).
  • Reutiliza las respuestas comunes importadas de swagger-security: unauthorizedResponse (401), forbiddenResponse (403), invalidIdResponse (400) y notFoundResponse (404).
  • Define los esquemas Role, RoleCreate, RoleUpdate y RolePatch.
  • Repite en la descripción el recordatorio de diseño: el nombre del rol no autoriza nada; la autorización son las filas de resource_roles.

Se conecta con

  • Entrada: lo importa src/swagger/index.ts y lo añade al registro de módulos.
  • Salida: usa los helpers de shared/http/swagger-security.

Archivo: src/features/auth/resources/dto/ y resource-catalog.ts

Propósito

El dto/ repite el contrato del feature Roles, pero para el par (method, path). El archivo resource-catalog.ts es distinto a todo lo anterior: es la fuente única del catálogo de los 58 recursos.

Explicación

  • CreateResourceDto exige method (uno de GET/POST/PUT/PATCH/DELETE) y path; UpdateResourceDto y PatchResourceDto siguen el mismo patrón que en roles; ResourceResponseDto es alias de ResourceI con su mapper toResourceResponse().
  • resource-catalog.ts define la interfaz CatalogResource (method, path, description, y seller?) y exporta RESOURCE_CATALOG, un array constante con los 58 recursos agrupados por dominio: clientes (7), tipos de producto (7), productos (7), ventas (3), detalle de ventas (1), usuarios (9), roles (7), recursos (7), asignaciones usuario-rol (5) y concesiones rol-recurso (5).
  • La marca seller: true designa los 7 recursos de operación que recibirá el rol SELLER.
  • Al final, SELLER_RESOURCES deriva del catálogo (filter(resource => resource.seller === true)) en lugar de repetir la lista: una sola fuente de verdad.
  • El comentario del archivo deja explícito que las operaciones de sesión (/api/sesion/* y /api/sesiones/*) no son recursos RBAC: son OPEN o JWT, porque no dependen de la matriz de permisos sino de tener (o no) identidad válida.

Se conecta con

  • Entrada: el resources.service.ts usa los DTOs; el resources.seeder.ts recorre RESOURCE_CATALOG; el seeder de ISS-12 usará SELLER_RESOURCES.
  • Salida: usa el modelo Resource, el normalizePath de resource-match y la referencia de diseño docs/bd-storelab.md §21.

Archivo: src/features/auth/resources/resources.repository.ts

Propósito

Ser la única capa que habla con Sequelize para Resource.

Explicación

  • findAllActive() y findById() — igual que en roles.
  • findByOperation(method, path) — la clave del feature: busca por la tupla (method, path), normalizando el verbo a mayúsculas y la ruta con normalizePath(). Es lo que sostiene el 409.
  • create, update, delete — inserción, persistencia y borrado físico.

Se conecta con

  • Entrada: lo instancia ResourcesService.
  • Salida: usa el modelo Resource y normalizePath de shared/auth/resource-match (ISS-09).

Archivo: src/features/auth/resources/resources.service.ts

Propósito

Concentrar la regla de negocio: la tupla (method, path) es única.

Explicación

  • Mismo esqueleto que RolesService (READ, CREATE, UPDATE, DELETE).
  • create() exige method y path (400) y luego llama a assertOperationAvailable() (409) antes de escribir. La gracia es responder un 409 con un mensaje útil en lugar de dejar reventar la restricción única de la base de datos como un 500.
  • updatePatch() es más cuidadoso que en roles: como el cliente puede cambiar solo method o solo path, recompone la tupla con body.method ?? resource.method y valida la tupla resultante completa.
  • deleteLogical() pone status = inactive: deshabilita el punto de acceso (ningún rol podrá autorizarlo, efecto de ISS-12/13).

Se conecta con

  • Entrada: lo instancia ResourcesController.
  • Salida: usa ResourcesRepository, el modelo Resource, los DTOs y AppError.

Archivo: src/features/auth/resources/resources.controller.ts

Propósito

Traducir HTTP ↔ service para recursos; mismo estilo que el de roles.

Explicación

  • Extiende BaseController y usa run() / paramId().
  • getAll responde { resources } y getOne responde { resource }; create responde 201; los update responden 200; deletePhysical responde un mensaje con el id; deleteLogical responde el recurso desactivado.

Se conecta con

  • Entrada: lo instancia ResourcesRoutes.
  • Salida: llama a ResourcesService y hereda de BaseController.

Archivo: src/features/auth/resources/resources.routes.ts

Propósito

Declarar los endpoints de recursos y su modalidad de acceso.

Explicación

  • Registra el CRUD sobre /api/recursos y /api/recursos/:id, más PATCH /api/recursos/:id/deactivate.
  • Declara JWT + RBAC en todas las operaciones, con authenticate, authorize delante de cada handler.

Se conecta con

  • Entrada: la instancia src/routes/index.ts (ResourcesRoutes).
  • Salida: importa authenticate, authorize desde ../access, implementación de ISS-13.

Archivo: src/features/auth/resources/resources.seeder.ts

Propósito

Poblar la tabla resources con el catálogo determinista de 58 recursos.

Explicación

  • No usa datos aleatorios: recorre RESOURCE_CATALOG.
  • Es idempotente por partida doble: findOrCreate por (method, path) y reactivación de las filas que existían inactivas. Reejecutarlo reconcilia el catálogo sin duplicar ni perder concesiones.
  • Devuelve cuántos recursos nuevos se crearon y loguea el total de la reconciliación.
  • Es la pieza que transforma el catálogo en código en filas de base de datos.

Se conecta con

  • Entrada: lo llama seedResources() desde src/database/seeders/index.ts.
  • Salida: usa el modelo Resource y RESOURCE_CATALOG.

Archivo: src/features/auth/resources/resources.swagger.ts

Propósito

Documentar en OpenAPI el CRUD de recursos y el modelo (method, path).

Explicación

  • Mismo patrón que rolesSwagger: security: bearerSecurity, recursos (method, path) descritos, respuestas comunes reutilizadas.
  • Añade dos ideas didácticas: el path se documenta con patrón (/api/productos/:id) y se recuerda que GET y POST sobre la misma ruta son dos recursos distintos.
  • Define los esquemas Resource, ResourceCreate, ResourceUpdate y ResourcePatch (con method como enum de los cinco verbos).

Se conecta con

  • Entrada: lo importa src/swagger/index.ts.
  • Salida: usa los helpers de swagger-security.

Archivo: src/features/auth/roles/http/roles.get.http y resources/http/resources.get.http

Propósito

Dejar peticiones de prueba reproducibles del CRUD, incluidos los casos de error.

Explicación

  • Ambos archivos hacen login como admin y, en roles, también como seller, para demostrar el 403 cuando un rol no tiene concedido un recurso.
  • Cubren el ciclo completo: getAll, getOne, POST, PUT, PATCH, DELETE lógico y DELETE físico.
  • En resources, incluyen el caso 409 provocado por un (method, path) duplicado y el alta de un punto de acceso nuevo (/api/reportes/ventas/:id).
  • Los comentarios ### de los archivos explican el efecto de cada operación, por ejemplo que desactivar un rol hace que sus usuarios pierdan esos permisos.

Se conecta con

  • Entrada: se ejecutan con un cliente REST (VS Code REST Client) contra el servidor local.
  • Salida: dependen del endpoint de login (POST /api/sesion/login), que pertenece a la sesión (ISS-15); hasta entonces sirven como contrato a mano.

Comandos explicados

El ISS-11 se verifica con dos comandos y una consulta SQL. Ninguno de ellos arranca el servidor: son de compilación y de datos.

npx tsc --noEmit

COMANDO
   ↓
npx tsc --noEmit
   ↓
QUÉ HACE
   Ejecuta el compilador de TypeScript en modo "solo comprobar": analiza todos
   los tipos y reporta errores, pero no escribe ningún archivo .js.
   ↓
POR QUÉ SE NECESITA
   El ISS añade 25 archivos nuevos y toca imports. Un import mal escrito o un DTO
   desalineado aparecería solo al ejecutar; aquí aparece antes de arrancar nada.
   ↓
QUÉ CREA O MODIFICA
   Nada en el disco (de ahí --noEmit). Solo salida por consola.
   ↓
RESULTADO ESPERADO
   Sin líneas de error. Si hay errores, se imprimen con archivo y número de línea.
   ↓
CÓMO VERIFICARLO
   El propio comando: si no imprime errores y termina con código 0, pasa.

npm run db:seed

COMANDO
   ↓
npm run db:seed
   ↓
QUÉ HACE
   Ejecuta el SeedersRunner: sincroniza los modelos y llama a los seeders en
   orden (seguridad primero, negocio después).
   ↓
POR QUÉ SE NECESITA
   Es lo que materializa el catálogo: crea los 2 roles y los 58 recursos en la
   base de datos. Además demuestra que el seeder es idempotente al reejecutarlo.
   ↓
QUÉ CREA O MODIFICA
   Filas en roles (ADMIN, SELLER) y en resources (58). Sincroniza tablas si faltan.
   ↓
RESULTADO ESPERADO
   Logs de reconciliación: "roles: catálogo reconciliado (2 roles, …)" y
   "resources: catálogo reconciliado (58 recursos, …)".
   ↓
CÓMO VERIFICARLO
   Reejecuta el comando: los conteos no deben crecer (idempotencia) y el
   resultado debe seguir siendo 2 y 58.

Las consultas SQL de verificación

COMANDO
   ↓
SELECT COUNT(*) FROM resources;   -- 58
SELECT COUNT(*) FROM roles;       -- 2
SELECT name, status FROM roles;   -- ADMIN, SELLER
   ↓
QUÉ HACE
   Cuenta las filas de cada catálogo y lista los roles con su estado.
   ↓
POR QUÉ SE NECESITA
   Es la comprobación directa del DoD: 58 recursos y 2 roles. Si el conteo no
   cuadra, el seeder no cargó el catálogo completo (o el catálogo no tiene 58).
   ↓
QUÉ CREA O MODIFICA
   Nada. Solo lectura.
   ↓
RESULTADO ESPERADO
   58, 2 y las filas ADMIN/SELLER en estado active.
   ↓
CÓMO VERIFICARLO
   Comparar con los números del DoD. Un 0 indica que no se ejecutó el seeder;
   un número mayor de 58 indica filas duplicadas (debería impedirlo la UQ).

Pregunta que responde: ¿cómo demuestro, sin arrancar el servidor, que el ISS-11 está bien construido?

Flujos

sequenceDiagram
    participant CLI as "CLI"
    participant Runner as "SeedersRunner"
    participant Seeders as "seedRoles y seedResources"
    participant DB as "Base de datos"
    CLI->>Runner: "ejecuta npm run db:seed"
    Runner->>DB: "testConnection y sync"
    Runner->>Seeders: "seedRoles"
    Seeders->>DB: "findOrCreate de ADMIN y SELLER"
    Runner->>Seeders: "seedResources"
    loop "58 recursos del catalogo"
        Seeders->>DB: "findOrCreate de method y path"
    end
    DB-->>Runner: "2 roles y 58 recursos"

Pregunta que responde: ¿cómo se convierte el catálogo escrito en código en filas de la base de datos?

Flujo 2 — La autorización que llegará (ISS-13), para entender por qué importa el patrón

sequenceDiagram
    participant C as "Cliente"
    participant A as "authenticate"
    participant Z as "authorize"
    participant M as "Matriz RBAC"
    C->>A: "peticion con Bearer token"
    A->>A: "valida el JWT y carga el usuario"
    A->>Z: "identidad en req.auth"
    Z->>M: "consulta concesiones activas"
    M-->>Z: "recursos concedidos con su patron"
    Z->>Z: "isOperationGranted compara method y path"
    Z-->>C: "200 si hay concesion, 403 si no"

Pregunta que responde: ¿en qué momento se usa el patrón de un recurso para decidir un 200 o un 403?

Este segundo flujo no ocurre en ISS-11: authenticate y authorize son ISS-13 y la matriz es ISS-12. Se dibuja aquí porque explica por qué este ISS guarda los recursos con patrón: el paso isOperationGranted compara method y path compara el (method, path) real de la petición contra los patrones concedidos.

Flujo 3 — El modelo de datos: dos catálogos hoy, matriz mañana

erDiagram
    USERS ||--o{ ROLE_USERS : "tiene"
    ROLES ||--o{ ROLE_USERS : "se asigna"
    ROLES ||--o{ RESOURCE_ROLES : "recibe"
    RESOURCES ||--o{ RESOURCE_ROLES : "se concede"
    USERS {
        int id PK
        string username
        string status
    }
    ROLES {
        int id PK
        string name
        string status
    }
    RESOURCES {
        int id PK
        string method
        string path
        string status
    }
    ROLE_USERS {
        int id PK
        int user_id FK
        int role_id FK
        string status
    }
    RESOURCE_ROLES {
        int id PK
        int role_id FK
        int resource_id FK
        string status
    }

Pregunta que responde: ¿qué tablas forman el RBAC y cuáles de ellas construye este ISS?

En ISS-11 se construyen ROLES y RESOURCES (los dos extremos). Las pivotes ROLE_USERS y RESOURCE_ROLES —que en el diagrama están a la derecha de cada flecha— son ISS-12. Fíjate en que no hay ninguna entidad PERMISSIONS: el permiso es la fila de RESOURCE_ROLES.

Flujo 4 — La forma de las clases del feature

classDiagram
    class BaseController {
        <<abstract>>
        +run(res, fn)
        +paramId(req)
    }
    class RolesController {
        +getAll()
        +getOne()
        +create()
        +updatePut()
        +updatePatch()
        +deletePhysical()
        +deleteLogical()
    }
    class RolesService {
        +getAll()
        +create()
        +assertNameAvailable()
    }
    class RolesRepository {
        +findAllActive()
        +findByName()
        +create()
    }
    class ResourcesController {
        +getAll()
        +create()
        +deleteLogical()
    }
    class ResourcesService {
        +assertOperationAvailable()
    }
    class ResourcesRepository {
        +findByOperation()
    }
    RolesController --|> BaseController
    RolesController --> RolesService
    RolesService --> RolesRepository
    ResourcesController --|> BaseController
    ResourcesController --> ResourcesService
    ResourcesService --> ResourcesRepository

Pregunta que responde: ¿cómo se llama cada capa y quién puede llamar a quién dentro de un feature?

La flecha marca la dirección de la dependencia: Controller → Service → Repository. Nunca al revés: el repository no conoce al service, y el service no conoce a Express. Es exactamente la misma forma que los features de Fase I.

Diagnóstico

Síntoma Causa probable Solución
Cannot find module "../access" al compilar El import de authenticate/authorize apunta a un feature que llega en ISS-13 Comprobar que estás aplicando el ISS completo (los archivos de rutas son de la meta final) y que features/auth/access/ existirá; en el orden del curso, ese archivo es ISS-13
npx tsc --noEmit reporta un DTO desalineado Un campo renombrado en el modelo y no en el DTO (o al revés) Revisar create/update/patch del feature; el DTO es el contrato, el modelo es la persistencia
POST /api/recursos devuelve 500 en vez de 409 Se dejó reventar la restricción UQ(method, path) El service debe llamar a assertOperationAvailable() antes de escribir
POST /api/roles con un nombre repetido devuelve 500 Falta el control de unicidad previo assertNameAvailable() debe comprobar y lanzar AppError(409, …)
Un rol «no hace nada» tras el seed Es correcto: roles nace sin permisos Conceder es ISS-12 (matriz); aquí solo se crea el rol
SELECT COUNT(*) FROM resources devuelve 0 No se ejecutó el seeder o falló la conexión Ejecutar npm run db:seed y revisar los logs
El conteo de recursos supera 58 Filas duplicadas por un path con formato distinto El seeder normaliza y la UQ protege; revisar normalizePath y no insertar a mano
Dos recursos que «parecen» iguales no chocan Distinto method (p. ej. GET y POST sobre la misma ruta) Es correcto: la tupla (method, path) los distingue
GET /api/productos/42 no casa con GET /api/productos/:id Al comprobar se usó la ruta sin normalizar o sin el patrón Revisar normalizePath y pathMatches en shared/auth/resource-match.ts

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

Criterios de aceptación

Los del ISS, textuales, como checklist:

  • [ ] 16.1 features/auth/roles/dto/ completo (create, update, patch, role-response, index)
  • [ ] 16.2 roles.repository.ts, roles.service.ts, roles.controller.ts, roles.routes.ts (JWT + RBAC)
  • [ ] 16.3 roles.seeder.ts (ADMIN, SELLER, idempotente) y roles.swagger.ts
  • [ ] 16.4 features/auth/resources/dto/ + resource-catalog.ts con 58 recursos
  • [ ] 16.5 resources.repository.ts, resources.service.ts, resources.controller.ts, resources.routes.ts (JWT + RBAC)
  • [ ] 16.6 resources.seeder.ts (carga el catálogo) y resources.swagger.ts
  • [ ] 16.7 archivos .http de ambos features
  • [ ] npx tsc --noEmit OK

Evaluación

Preguntas de comprensión

  1. ¿Por qué en este proyecto no existe una tabla permissions? Porque un permiso no es una entidad con vida propia, sino la conjunción de un rol y un recurso. Se guarda por separado: los roles en roles, los recursos en resources y la concesión (el «∧») en la pivote resource_roles (ISS-12). Tener una tabla permissions duplicaría una verdad que ya está expresada por la existencia de una fila en la pivote.

  2. Crear un rol ADMIN con POST /api/roles, ¿le concede permisos? No. El nombre del rol no autoriza nada; solo agrupa. Un ADMIN recién creado está vacío: no habilita ninguna operación hasta que en ISS-12 se le concedan recursos en resource_roles. Es la trampa conceptual número uno de este ISS.

  3. ¿Qué es exactamente un recurso y por qué GET /api/productos y POST /api/productos son dos recursos distintos? Un recurso es un punto de acceso protegible: el par (method, path). GET y POST sobre la misma ruta son operaciones distintas y se conceden por separado (leer el catálogo no es lo mismo que crear un producto). La identidad del recurso es la tupla; por eso la restricción única es UQ(method, path).

  4. ¿Por qué el path se guarda como patrón (/api/productos/:id) y no con el identificador concreto? Porque conceder por URL concreta exigiría una fila por cada recurso del catálogo y cada producto nuevo nacería sin permiso. Con el patrón, una sola concesión cubre todos los identificadores. La coincidencia patrón ↔ petición la resuelve resource-match en ISS-13.

  5. ¿Por qué GET /api/productos/42 no casa con el patrón GET /api/productos? Porque las reglas de coincidencia son estrictas: el número de segmentos debe coincidir. Si casara, un permiso de «listar productos» autorizaría por error una «lectura concreta». Ser estricto es lo que evita escaladas de privilegio accidentales.

  6. ¿Qué garantiza UQ(method, path) y qué error produce un duplicado? Garantiza que no haya dos filas con el mismo verbo y la misma ruta. El service comprueba la tupla antes de escribir y responde 409 con un mensaje útil, en lugar de dejar que la restricción reviente como un 500.

  7. ¿Qué significa que los seeders de roles y resources sean idempotentes y reconciliadores? Que reejecutarlos no duplica datos: usan findOrCreate por clave (name en roles, (method, path) en resources) y reactivan las filas inactivas. Así el catálogo queda siempre exacto (2 roles y 58 recursos), sin perder concesiones.

  8. ¿Por qué las operaciones de sesión no son recursos RBAC? Porque no dependen de la matriz de permisos, sino de poseer (o no) una identidad válida. login, refresh y logout son OPEN; perfil y sesiones son JWT. Ninguna se concede por rol.

  9. ¿Qué diferencia hay entre que un rol sea «agrupador» y que sea «autorización»? Como agrupador, el rol solo reúne responsabilidades bajo un nombre. La autorización aparece cuando una fila de resource_roles lo une a un recurso activo. ISS-11 construye los agrupadores; ISS-12 construye los vínculos.

  10. ¿Por qué la matriz role_users y resource_roles es ISS-12 y no ISS-11? Porque el ISS-11 construye los dos extremos del permiso, que son catálogos independientes. La matriz es el puente entre ellos y necesita que existan primero: no se puede conceder un recurso a un rol que aún no existe. El orden es dependencia real, no capricho.

Ejercicios

Ejercicio 1 — Diseñar un recurso nuevo. Quieres proteger un endpoint GET /api/reportes/ventas/:id. Escribe cómo quedaría su fila en el catálogo y explica por qué es un solo recurso, aunque mañana existan mil reportes.

Respuesta razonada En el catálogo se declara con el **patrón**, no con un id concreto:
{ method: "GET", path: "/api/reportes/ventas/:id", description: "Consultar reporte de ventas" }
Es **un solo** recurso porque el `path` guarda el patrón `/api/reportes/ventas/:id`, donde `:id` casa con cualquier segmento. El middleware compara el `(method, path)` de la petición real (`GET /api/reportes/ventas/7`) contra ese patrón con `pathMatches` y obtiene una coincidencia. Si se guardara la URL concreta, habría que insertar una fila por reporte.

Ejercicio 2 — Auditar el catálogo. Suma los recursos por grupo del catálogo y comprueba que el total es 58. Explica qué representa la marca seller: true y cuántos recursos tiene.

Respuesta razonada Composición del catálogo: | Grupo | Recursos | |---|---:| | Clientes | 7 | | Tipos de producto | 7 | | Productos | 7 | | Ventas | 3 | | Detalle de ventas | 1 | | Usuarios | 9 | | Roles | 7 | | Recursos | 7 | | Asignaciones usuario-rol | 5 | | Concesiones rol-recurso | 5 | | **Total** | **58** | La marca `seller: true` designa los recursos de **operación** que recibirá el rol `SELLER`: son **7** (listar/consultar clientes, listar/consultar productos, listar/consultar/registrar ventas). `SELLER_RESOURCES` no repite esa lista: se **deriva** del catálogo con un `filter`, para mantener una sola fuente de verdad.

Ejercicio 3 — Provocar el 409. Explica qué ocurre si se intenta POST /api/recursos con { "method": "GET", "path": "/api/clientes" } y por qué el error es 409 y no 500.

Respuesta razonada Esa tupla ya existe (es «Listar clientes» del catálogo). El `ResourcesService` llama a `assertOperationAvailable("GET", "/api/clientes")`, el repository encuentra el recurso con `findByOperation`, y como el `id` existente no coincide con el `excludeId`, el service lanza `AppError(409, "Resource GET /api/clientes already exists")`. Es **409** (conflicto) y no **500** porque el conflicto se detecta **antes** de escribir, con una regla de negocio explícita. Si se dejara escribir, la restricción `UQ(method, path)` haría saltar un error de base de datos que, sin traducir, se vería como un 500 poco informativo.

Conexión con el resto del curso

ISS-09  capa base de seguridad + los seis modelos de Auth
   ↓    (JWT, bcrypt, AppError, resource-match, models)
ISS-10  feature users
   ↓
ISS-11  features roles y resources   ← ESTE ISS
   ↓
ISS-12  role_users + resource_roles (la matriz RBAC)
   ↓
ISS-13  authenticate + authorize (rutas de negocio protegidas)
   ↓
ISS-14  refresh_tokens   ·   ISS-15  sesión
  • Lo que reutiliza de ISS-09: los modelos Role y Resource (ya definidos, con su normalización en hooks), las primitivas AppError, BaseController, sendError y normalizePath.
  • Lo que reutiliza de ISS-10: el patrón de feature completo (DTO → repository → service → controller → routes → seeder → swagger) y el uso de findOrCreate idempotente en los seeders.
  • Lo que habilita en ISS-12: los dos catálogos sobre los que se construyen las pivotes. El seeder de la matriz usará SELLER_RESOURCES (derivado del catálogo de este ISS) y RESOURCE_CATALOG completo para ADMIN.
  • Lo que habilita en ISS-13: los recursos con patrón que authorize comparará contra cada petición.

Pregunta que responde: ¿de dónde vengo, a dónde voy y qué piezas reutilizo por el camino?

Glosario

Término Significado
Rol Agrupador lógico de responsabilidades (roles). Su nombre es único y no autoriza nada por sí mismo.
Recurso Punto de acceso protegible; el par (method, path) con el path en patrón.
Permiso Conjunción de un rol y un recurso. No es una tabla: se materializa en la pivote resource_roles (ISS-12).
Concesión Acto de unir un recurso a un rol (fila de resource_roles). ISS-12.
Asignación Acto de unir un usuario a un rol (fila de role_users). ISS-12.
Matriz RBAC El conjunto de asignaciones y concesiones que define quién puede hacer qué. ISS-12.
Path patrón Ruta con parámetros (/api/productos/:id) que se guarda en lugar de una URL concreta.
Deny by default Sin concesión explícita, se deniega (403). Postura del laboratorio, aplicada en ISS-13.
Modalidad de acceso OPEN (sin identidad), JWT (token válido) o JWT + RBAC (token + concesión).
Idempotente Reejecutarlo produce el mismo estado (los seeders de este ISS lo son).
Reconciliador Además de idempotente, deja el catálogo exacto: crea lo que falta y reactiva lo inactivo.
UQ(method, path) Restricción única compuesta que impide dos recursos con el mismo verbo y ruta (409).
Borrado lógico Marcar status = inactive en lugar de destruir la fila; deshabilita el rol o el punto de acceso.

Pregunta que responde: ¿qué vocabulario nuevo tengo que dominar antes de seguir al ISS-12?

Recorrido del ISS, paso a paso

A partir de aquí viene el contenido técnico completo del ISS, verbatim: sus objetivo, criterios, código y verificación. Se reproduce sin resumir, sin reformatear y sin omitir un solo bloque: es la fuente autoritativa. Solo se han degradado los encabezados un nivel, y se han reescrito los enlaces relativos a ../manual/.

Fase II: Auth con RBAC — ISS-11 — Features Roles y Resources (catálogo de autorización)

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 (Business), infraestructura de seguridad y modelos (ISS-09) y feature Users (ISS-10). - 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.2 (roles), §14.3 (resources) y §21 (catálogo semilla). - Capas, convenciones y reglas transversales: 00-contexto.md.

Este ISS
Título Features Roles y Resources (catálogo de autorización)
Feature / tablas features/auth/roles/ · roles — features/auth/resources/ · resources
API /api/roles… y /api/recursos… (JWT + RBAC)
Depende de ISS-10 — Feature Users
Habilita ISS-12 — Asignaciones y concesiones

Contenido de este ISS

  • 16.1 Feature Roles — DTOs
  • 16.2 Feature Roles — repository, service, controller y rutas
  • 16.3 Feature Roles — seeder y swagger
  • 16.4 Feature Resources — DTOs y catálogo semilla
  • 16.5 Feature Resources — repository, service, controller y rutas
  • 16.6 Feature Resources — seeder y swagger
  • 16.7 Pruebas HTTP

Objetivo: construir los dos extremos del permiso:

  • Role — el sujeto de la autorización (a quién se concede).
  • Resource — el objeto (qué se concede), un par (method, path).

Este ISS crea el CRUD administrativo de ambos y el catálogo semilla de los 58 recursos del sistema. Las concesiones (el acto de unirlos) son el ISS-12.

Bloqueado por: ISS-10.

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

  • [ ] 16.1 features/auth/roles/dto/ completo (create, update, patch, role-response, index)
  • [ ] 16.2 roles.repository.ts, roles.service.ts, roles.controller.ts, roles.routes.ts (JWT + RBAC)
  • [ ] 16.3 roles.seeder.ts (ADMIN, SELLER, idempotente) y roles.swagger.ts
  • [ ] 16.4 features/auth/resources/dto/ + resource-catalog.ts con 58 recursos
  • [ ] 16.5 resources.repository.ts, resources.service.ts, resources.controller.ts, resources.routes.ts (JWT + RBAC)
  • [ ] 16.6 resources.seeder.ts (carga el catálogo) y resources.swagger.ts
  • [ ] 16.7 archivos .http de ambos features
  • [ ] npx tsc --noEmit OK

16.1 Feature Roles — DTOs

: > src/features/auth/roles/dto/create-role.dto.ts
cat >> src/features/auth/roles/dto/create-role.dto.ts << 'EOF'
/**
 * Datos de entrada de `POST /api/roles`.
 * `name` se normaliza a MAYÚSCULAS en el modelo.
 */
export interface CreateRoleDto {
  name: string;
  description?: string | null;
  status?: "active" | "inactive";
}
EOF
: > src/features/auth/roles/dto/update-role.dto.ts
cat >> src/features/auth/roles/dto/update-role.dto.ts << 'EOF'
/**
 * Datos de entrada de `PUT /api/roles/:id` (reemplazo completo).
 * `status` no está aquí: el estado solo cambia con el borrado lógico.
 */
export interface UpdateRoleDto {
  name: string;
  description?: string | null;
}
EOF
: > src/features/auth/roles/dto/patch-role.dto.ts
cat >> src/features/auth/roles/dto/patch-role.dto.ts << 'EOF'
import { UpdateRoleDto } from "./update-role.dto";

/** Datos de entrada de `PATCH /api/roles/:id` (actualización parcial). */
export type PatchRoleDto = Partial<UpdateRoleDto>;
EOF
: > src/features/auth/roles/dto/role-response.dto.ts
cat >> src/features/auth/roles/dto/role-response.dto.ts << 'EOF'
import { Role, RoleI } from "../role.model";

/** Respuesta HTTP de un rol. Sin campos internos: el DTO coincide con el modelo. */
export type RoleResponseDto = RoleI;

/** Mapper modelo -> DTO de respuesta (objeto plano). */
export function toRoleResponse(role: Role): RoleResponseDto {
  return role.toJSON() as RoleI;
}
EOF
: > src/features/auth/roles/dto/index.ts
cat >> src/features/auth/roles/dto/index.ts << 'EOF'
export * from "./create-role.dto";
export * from "./update-role.dto";
export * from "./patch-role.dto";
export * from "./role-response.dto";
EOF

Un rol se identifica por name (UK). El nombre no autoriza nada: crear un rol AUDITOR no le concede ningún recurso; el rol nace sin permisos.

16.2 Feature Roles — repository, service, controller y rutas

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

/**
 * Capa Repository del feature Roles.
 * Única que habla con Sequelize (el modelo `Role`).
 */
export class RolesRepository {
  /** Todos los roles activos. */
  public async findAllActive(): Promise<Role[]> {
    return Role.findAll({ where: { status: "active" } });
  }

  /** Un rol por PK (o `null`). */
  public async findById(id: number, transaction?: Transaction): Promise<Role | null> {
    return Role.findByPk(id, { transaction });
  }

  /** Un rol por nombre normalizado a MAYÚSCULAS (o `null`). */
  public async findByName(name: string): Promise<Role | null> {
    return Role.findOne({ where: { name: name.trim().toUpperCase() } });
  }

  /** Inserta un rol. */
  public async create(data: CreationAttributes<Role>): Promise<Role> {
    return Role.create(data);
  }

  /** Persiste cambios sobre una instancia existente. */
  public async update(role: Role, data: Partial<Role>): Promise<Role> {
    return role.update(data);
  }

  /** Elimina físicamente una instancia. */
  public async delete(role: Role): Promise<void> {
    await role.destroy();
  }
}
EOF
: > src/features/auth/roles/roles.service.ts
cat >> src/features/auth/roles/roles.service.ts << 'EOF'
import {
  CreateRoleDto,
  PatchRoleDto,
  RoleResponseDto,
  UpdateRoleDto,
  toRoleResponse,
} from "./dto";
import { RolesRepository } from "./roles.repository";
import { Role } from "./role.model";
import { AppError } from "../../../shared/errors/app-error";

/**
 * Capa Service del feature Roles.
 *
 * Regla de negocio: el nombre del rol es único. La autorización **nunca** se
 * decide por el nombre, sino por las concesiones (`resource_roles`) asociadas;
 * el nombre solo sirve para agrupar.
 */
export class RolesService {
  public constructor(
    private readonly repository: RolesRepository = new RolesRepository()
  ) {}

  // ================== READ ==================
  public async getAll(): Promise<RoleResponseDto[]> {
    const roles = await this.repository.findAllActive();
    return roles.map((role) => toRoleResponse(role));
  }

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

  // ================== CREATE ==================
  public async create(body: CreateRoleDto): Promise<RoleResponseDto> {
    if (!body.name) {
      throw new AppError(400, "name is required");
    }
    await this.assertNameAvailable(body.name);

    const role = await this.repository.create({
      name: body.name,
      description: body.description ?? null,
      status: body.status ?? "active",
    });
    return toRoleResponse(role);
  }

  // ================== UPDATE ==================
  public async updatePut(id: number, body: UpdateRoleDto): Promise<RoleResponseDto> {
    const role = await this.findOrFail(id);
    await this.assertNameAvailable(body.name, id);

    await this.repository.update(role, {
      name: body.name,
      description: body.description ?? null,
    });
    return toRoleResponse(role);
  }

  public async updatePatch(id: number, body: PatchRoleDto): Promise<RoleResponseDto> {
    const role = await this.findOrFail(id);

    if (body.name) {
      await this.assertNameAvailable(body.name, id);
    }

    await this.repository.update(role, body);
    return toRoleResponse(role);
  }

  // ================== DELETE ==================
  /** Eliminación física. */
  public async deletePhysical(id: number): Promise<void> {
    const role = await this.findOrFail(id, false);
    await this.repository.delete(role);
  }

  /** Eliminación lógica -> `status = inactive`. Todos sus usuarios pierden ese rol. */
  public async deleteLogical(id: number): Promise<RoleResponseDto> {
    const role = await this.findOrFail(id);
    await this.repository.update(role, { status: "inactive" });
    return toRoleResponse(role);
  }

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

  private async assertNameAvailable(name: string, excludeId?: number): Promise<void> {
    const existing = await this.repository.findByName(name);
    if (existing && existing.id !== excludeId) {
      throw new AppError(409, "Role name already in use");
    }
  }
}
EOF
: > src/features/auth/roles/roles.controller.ts
cat >> src/features/auth/roles/roles.controller.ts << 'EOF'
import { Request, Response } from "express";
import { BaseController } from "../../../shared/http/base-controller";
import { CreateRoleDto, PatchRoleDto, UpdateRoleDto } from "./dto";
import { RolesService } from "./roles.service";

/**
 * Capa Controller del feature Roles.
 * Solo HTTP: lee `req`, llama al service y arma la respuesta.
 */
export class RolesController extends BaseController {
  public constructor(
    private readonly service: RolesService = new RolesService()
  ) {
    super();
  }

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

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

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

  // ================== UPDATE ==================
  public async updatePut(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const role = await this.service.updatePut(
        this.paramId(req),
        req.body as UpdateRoleDto
      );
      res.status(200).json({ role });
    });
  }

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

  // ================== DELETE ==================
  /** Eliminación física. */
  public async deletePhysical(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const id = this.paramId(req);
      await this.service.deletePhysical(id);
      res.status(200).json({ message: "Role permanently deleted", id });
    });
  }

  /** Eliminación lógica -> `status = inactive`. */
  public async deleteLogical(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const role = await this.service.deleteLogical(this.paramId(req));
      res.status(200).json({ message: "Role deactivated (logical delete)", role });
    });
  }
}
EOF
: > src/features/auth/roles/roles.routes.ts
cat >> src/features/auth/roles/roles.routes.ts << 'EOF'
import { Application } from "express";
import { RolesController } from "./roles.controller";
import { authenticate, authorize } from "../access";

/** Rutas del feature Roles — **modalidad 3 (JWT + RBAC)** en todas las operaciones. */
export class RolesRoutes {
  public rolesController: RolesController = new RolesController();

  public routes(app: Application): void {
    // getAll
    app
      .route("/api/roles")
      .get(authenticate, authorize, this.rolesController.getAll.bind(this.rolesController));

    // getOne
    app
      .route("/api/roles/:id")
      .get(authenticate, authorize, this.rolesController.getOne.bind(this.rolesController));

    // create
    app
      .route("/api/roles")
      .post(authenticate, authorize, this.rolesController.create.bind(this.rolesController));

    // update (PUT / PATCH)
    app
      .route("/api/roles/:id")
      .put(authenticate, authorize, this.rolesController.updatePut.bind(this.rolesController))
      .patch(authenticate, authorize, this.rolesController.updatePatch.bind(this.rolesController));

    // delete físico
    app
      .route("/api/roles/:id")
      .delete(
        authenticate,
        authorize,
        this.rolesController.deletePhysical.bind(this.rolesController)
      );

    // delete lógico
    app
      .route("/api/roles/:id/deactivate")
      .patch(authenticate, authorize, this.rolesController.deleteLogical.bind(this.rolesController));
  }
}
EOF

16.3 Feature Roles — seeder y swagger

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

/**
 * Seeder del catálogo de roles (`roles`).
 *
 * Crea los dos roles de referencia del sistema. Es determinista (no usa datos
 * aleatorios) e idempotente: `findOrCreate` por nombre y reactivación si ya
 * existía inactivo.
 *
 * Los roles nacen **sin permisos**: las concesiones las crea el seeder de
 * `resource_roles` (ADMIN recibe los 58 recursos, SELLER los 7 de operación).
 */
export const SEED_ROLES = [
  { name: "ADMIN", description: "Administración del sistema: gestiona usuarios, roles y permisos" },
  { name: "SELLER", description: "Operación de ventas: consulta catálogo y registra ventas" },
] as const;

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

  for (const item of SEED_ROLES) {
    const [role, wasCreated] = await Role.findOrCreate({
      where: { name: item.name },
      defaults: { name: item.name, description: item.description, status: "active" },
    });

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

  console.log(`✅ roles: catálogo reconciliado (${SEED_ROLES.length} roles, ${created} nuevos)`);
  return created;
}
EOF
: > src/features/auth/roles/roles.swagger.ts
cat >> src/features/auth/roles/roles.swagger.ts << 'EOF'
import {
  bearerSecurity,
  forbiddenResponse,
  invalidIdResponse,
  notFoundResponse,
  unauthorizedResponse,
} from "../../../shared/http/swagger-security";

/**
 * Documentación OpenAPI del feature Roles.
 *
 * Modalidad: **JWT + RBAC** en todas las operaciones.
 *
 * Recordatorio de diseño: el **nombre** del rol no autoriza nada. Un rol
 * `ADMIN` sin concesiones activas no habilita ninguna operación; la autorización
 * se decide por las filas de `resource_roles`.
 */
export const rolesSwagger = {
  tags: [
    { name: "Roles", description: "CRUD de roles (agrupadores de permisos) — **JWT + RBAC**" },
  ],
  paths: {
    "/api/roles": {
      get: {
        tags: ["Roles"],
        summary: "Listar roles activos",
        description: "JWT + RBAC — recurso `GET /api/roles`.",
        security: bearerSecurity,
        responses: {
          "200": { description: "Lista de roles (`{ roles: [...] }`)" },
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
        },
      },
      post: {
        tags: ["Roles"],
        summary: "Crear rol",
        description:
          "JWT + RBAC — recurso `POST /api/roles`. El rol nace **sin permisos**: se conceden con `POST /api/concesiones-rol`.",
        security: bearerSecurity,
        requestBody: {
          required: true,
          content: {
            "application/json": { schema: { $ref: "#/components/schemas/RoleCreate" } },
          },
        },
        responses: {
          "201": { description: "Rol creado (`{ role }`)" },
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "409": { description: "Nombre de rol ya en uso" },
        },
      },
    },
    "/api/roles/{id}": {
      get: {
        tags: ["Roles"],
        summary: "Obtener rol por id",
        description: "JWT + RBAC — recurso `GET /api/roles/:id`.",
        security: bearerSecurity,
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
        responses: {
          "200": { description: "Rol (`{ role }`)" },
          "400": invalidIdResponse,
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": notFoundResponse,
        },
      },
      put: {
        tags: ["Roles"],
        summary: "Reemplazar rol (PUT)",
        description: "JWT + RBAC — recurso `PUT /api/roles/:id`.",
        security: bearerSecurity,
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
        requestBody: {
          required: true,
          content: {
            "application/json": { schema: { $ref: "#/components/schemas/RoleUpdate" } },
          },
        },
        responses: {
          "200": { description: "Rol actualizado (`{ role }`)" },
          "400": invalidIdResponse,
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": notFoundResponse,
        },
      },
      patch: {
        tags: ["Roles"],
        summary: "Modificar rol (PATCH)",
        description: "JWT + RBAC — recurso `PATCH /api/roles/:id`.",
        security: bearerSecurity,
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
        requestBody: {
          content: {
            "application/json": { schema: { $ref: "#/components/schemas/RolePatch" } },
          },
        },
        responses: {
          "200": { description: "Rol actualizado (`{ role }`)" },
          "400": invalidIdResponse,
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": notFoundResponse,
        },
      },
      delete: {
        tags: ["Roles"],
        summary: "Eliminar rol (físico)",
        description: "JWT + RBAC — recurso `DELETE /api/roles/:id`.",
        security: bearerSecurity,
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
        responses: {
          "200": { description: "Eliminado (`{ message, id }`)" },
          "400": invalidIdResponse,
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": notFoundResponse,
        },
      },
    },
    "/api/roles/{id}/deactivate": {
      patch: {
        tags: ["Roles"],
        summary: "Desactivar rol (borrado lógico)",
        description:
          "JWT + RBAC — recurso `PATCH /api/roles/:id/deactivate`. " +
          "Efecto inmediato: todos los usuarios de ese rol pierden sus permisos (eslabón `roles` inactivo -> DENY).",
        security: bearerSecurity,
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
        responses: {
          "200": { description: "Desactivado (`{ message, role }`)" },
          "400": invalidIdResponse,
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": notFoundResponse,
        },
      },
    },
  },
  components: {
    schemas: {
      Role: {
        type: "object",
        properties: {
          id: { type: "integer", example: 1 },
          name: { type: "string", example: "SELLER" },
          description: { type: "string", nullable: true },
          status: { type: "string", enum: ["active", "inactive"], example: "active" },
          createdAt: { type: "string", format: "date-time" },
          updatedAt: { type: "string", format: "date-time" },
        },
      },
      RoleCreate: {
        type: "object",
        required: ["name"],
        properties: {
          name: { type: "string", example: "BUYER" },
          description: { type: "string", nullable: true },
          status: { type: "string", enum: ["active", "inactive"], default: "active" },
        },
      },
      RoleUpdate: {
        type: "object",
        required: ["name"],
        properties: {
          name: { type: "string" },
          description: { type: "string", nullable: true },
        },
      },
      RolePatch: {
        type: "object",
        properties: {
          name: { type: "string" },
          description: { type: "string", nullable: true },
        },
      },
    },
  },
};
EOF

16.4 Feature Resources — DTOs y catálogo semilla

: > src/features/auth/resources/dto/create-resource.dto.ts
cat >> src/features/auth/resources/dto/create-resource.dto.ts << 'EOF'
/**
 * Datos de entrada de `POST /api/recursos`.
 *
 * `path` se guarda con el patrón (`/api/productos/:id`), no con un valor concreto.
 */
export interface CreateResourceDto {
  method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
  path: string;
  description?: string | null;
  status?: "active" | "inactive";
}
EOF
: > src/features/auth/resources/dto/update-resource.dto.ts
cat >> src/features/auth/resources/dto/update-resource.dto.ts << 'EOF'
/**
 * Datos de entrada de `PUT /api/recursos/:id` (reemplazo completo).
 * `status` no está aquí: el estado solo cambia con el borrado lógico.
 */
export interface UpdateResourceDto {
  method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
  path: string;
  description?: string | null;
}
EOF
: > src/features/auth/resources/dto/patch-resource.dto.ts
cat >> src/features/auth/resources/dto/patch-resource.dto.ts << 'EOF'
import { UpdateResourceDto } from "./update-resource.dto";

/** Datos de entrada de `PATCH /api/recursos/:id` (actualización parcial). */
export type PatchResourceDto = Partial<UpdateResourceDto>;
EOF
: > src/features/auth/resources/dto/resource-response.dto.ts
cat >> src/features/auth/resources/dto/resource-response.dto.ts << 'EOF'
import { Resource, ResourceI } from "../resource.model";

/**
 * Respuesta HTTP de un recurso. `resources` no tiene campos internos, así que el
 * DTO coincide con el modelo; se declara igualmente para que la API quede
 * desacoplada del modelo (cambiar el modelo no cambia el contrato por accidente).
 */
export type ResourceResponseDto = ResourceI;

/** Mapper modelo -> DTO de respuesta (objeto plano). */
export function toResourceResponse(resource: Resource): ResourceResponseDto {
  return resource.toJSON() as ResourceI;
}
EOF
: > src/features/auth/resources/dto/index.ts
cat >> src/features/auth/resources/dto/index.ts << 'EOF'
export * from "./create-resource.dto";
export * from "./update-resource.dto";
export * from "./patch-resource.dto";
export * from "./resource-response.dto";
EOF

El catálogo es la fuente única: define los 58 recursos, de los que se derivan tanto las filas de resources como las concesiones de los roles. La marca seller: true designa los 7 recursos de operación.

: > src/features/auth/resources/resource-catalog.ts
cat >> src/features/auth/resources/resource-catalog.ts << 'EOF'
/**
 * Catálogo de los **58 recursos** del sistema (fuente única).
 *
 * Un recurso es un par `(method, path)`; un permiso es la concesión de un
 * recurso a un rol. Este archivo es la definición en código del catálogo que
 * puebla el seeder de `resources` y del que se derivan las concesiones de los
 * roles (`ADMIN` recibe los 58; `SELLER`, los 7 marcados con `seller: true`).
 *
 * Composición (referencia: `docs/bd-storelab.md` §21):
 *
 * | Grupo                                        | Recursos |
 * |----------------------------------------------|---------:|
 * | Clientes                                     | 7 |
 * | Tipos de producto                            | 7 |
 * | Productos                                    | 7 |
 * | Ventas                                       | 3 |
 * | Detalle de ventas                            | 1 |
 * | Usuarios (+ cambio de contraseña + permisos) | 9 |
 * | Roles                                        | 7 |
 * | Recursos                                     | 7 |
 * | Asignaciones usuario-rol                     | 5 |
 * | Concesiones rol-recurso                      | 5 |
 * | **Total**                                    | **58** |
 *
 * Nota: las operaciones de sesión (`/api/sesion/*` y `/api/sesiones/*`) **no**
 * son recursos RBAC. Son las modalidades OPEN y JWT: no dependen de la matriz
 * de permisos, sino de poseer (o no) una identidad válida.
 */
export interface CatalogResource {
  method: string;
  path: string;
  description: string;
  /** `true` si el rol `SELLER` recibe esta concesión (7 en total). */
  seller?: boolean;
}

export const RESOURCE_CATALOG: readonly CatalogResource[] = [
  // ── Clientes (7) ──────────────────────────────────────────────
  { method: "GET", path: "/api/clientes", description: "Listar clientes", seller: true },
  { method: "GET", path: "/api/clientes/:id", description: "Consultar cliente", seller: true },
  { method: "POST", path: "/api/clientes", description: "Crear cliente" },
  { method: "PUT", path: "/api/clientes/:id", description: "Reemplazar cliente" },
  { method: "PATCH", path: "/api/clientes/:id", description: "Modificar cliente" },
  { method: "DELETE", path: "/api/clientes/:id", description: "Eliminar cliente" },
  { method: "PATCH", path: "/api/clientes/:id/deactivate", description: "Desactivar cliente" },

  // ── Tipos de producto (7) ─────────────────────────────────────
  { method: "GET", path: "/api/tipos-producto", description: "Listar tipos de producto" },
  { method: "GET", path: "/api/tipos-producto/:id", description: "Consultar tipo de producto" },
  { method: "POST", path: "/api/tipos-producto", description: "Crear tipo de producto" },
  { method: "PUT", path: "/api/tipos-producto/:id", description: "Reemplazar tipo de producto" },
  { method: "PATCH", path: "/api/tipos-producto/:id", description: "Modificar tipo de producto" },
  { method: "DELETE", path: "/api/tipos-producto/:id", description: "Eliminar tipo de producto" },
  {
    method: "PATCH",
    path: "/api/tipos-producto/:id/deactivate",
    description: "Desactivar tipo de producto",
  },

  // ── Productos (7) ─────────────────────────────────────────────
  { method: "GET", path: "/api/productos", description: "Listar productos", seller: true },
  { method: "GET", path: "/api/productos/:id", description: "Consultar producto", seller: true },
  { method: "POST", path: "/api/productos", description: "Crear producto" },
  { method: "PUT", path: "/api/productos/:id", description: "Reemplazar producto" },
  { method: "PATCH", path: "/api/productos/:id", description: "Modificar producto" },
  { method: "DELETE", path: "/api/productos/:id", description: "Eliminar producto" },
  { method: "PATCH", path: "/api/productos/:id/deactivate", description: "Desactivar producto" },

  // ── Ventas (3) ────────────────────────────────────────────────
  { method: "GET", path: "/api/ventas", description: "Listar ventas", seller: true },
  { method: "GET", path: "/api/ventas/:id", description: "Consultar venta", seller: true },
  { method: "POST", path: "/api/ventas", description: "Registrar venta", seller: true },

  // ── Detalle de ventas (1) ─────────────────────────────────────
  { method: "GET", path: "/api/detalle-ventas", description: "Listar detalle de ventas" },

  // ── Usuarios (9) ──────────────────────────────────────────────
  { method: "GET", path: "/api/usuarios", description: "Listar usuarios" },
  { method: "GET", path: "/api/usuarios/:id", description: "Consultar usuario" },
  { method: "POST", path: "/api/usuarios", description: "Crear usuario" },
  { method: "PUT", path: "/api/usuarios/:id", description: "Reemplazar usuario" },
  { method: "PATCH", path: "/api/usuarios/:id", description: "Modificar usuario" },
  { method: "DELETE", path: "/api/usuarios/:id", description: "Eliminar usuario" },
  { method: "PATCH", path: "/api/usuarios/:id/deactivate", description: "Desactivar usuario" },
  {
    method: "PATCH",
    path: "/api/usuarios/:id/password",
    description: "Cambiar contraseña de usuario",
  },
  {
    method: "GET",
    path: "/api/usuarios/:id/permisos",
    description: "Consultar permisos efectivos del usuario",
  },

  // ── Roles (7) ─────────────────────────────────────────────────
  { method: "GET", path: "/api/roles", description: "Listar roles" },
  { method: "GET", path: "/api/roles/:id", description: "Consultar rol" },
  { method: "POST", path: "/api/roles", description: "Crear rol" },
  { method: "PUT", path: "/api/roles/:id", description: "Reemplazar rol" },
  { method: "PATCH", path: "/api/roles/:id", description: "Modificar rol" },
  { method: "DELETE", path: "/api/roles/:id", description: "Eliminar rol" },
  { method: "PATCH", path: "/api/roles/:id/deactivate", description: "Desactivar rol" },

  // ── Recursos (7) ──────────────────────────────────────────────
  { method: "GET", path: "/api/recursos", description: "Listar recursos" },
  { method: "GET", path: "/api/recursos/:id", description: "Consultar recurso" },
  { method: "POST", path: "/api/recursos", description: "Crear recurso" },
  { method: "PUT", path: "/api/recursos/:id", description: "Reemplazar recurso" },
  { method: "PATCH", path: "/api/recursos/:id", description: "Modificar recurso" },
  { method: "DELETE", path: "/api/recursos/:id", description: "Eliminar recurso" },
  { method: "PATCH", path: "/api/recursos/:id/deactivate", description: "Desactivar recurso" },

  // ── Asignaciones usuario ↔ rol (5) ────────────────────────────
  { method: "GET", path: "/api/asignaciones-rol", description: "Listar asignaciones usuario-rol" },
  {
    method: "GET",
    path: "/api/asignaciones-rol/:id",
    description: "Consultar asignación usuario-rol",
  },
  {
    method: "POST",
    path: "/api/asignaciones-rol",
    description: "Asignar rol a usuario",
  },
  {
    method: "PATCH",
    path: "/api/asignaciones-rol/:id/deactivate",
    description: "Retirar rol a usuario",
  },
  {
    method: "PATCH",
    path: "/api/asignaciones-rol/:id/reactivate",
    description: "Reactivar rol a usuario",
  },

  // ── Concesiones rol ↔ recurso (5) ─────────────────────────────
  { method: "GET", path: "/api/concesiones-rol", description: "Listar concesiones rol-recurso" },
  {
    method: "GET",
    path: "/api/concesiones-rol/:id",
    description: "Consultar concesión rol-recurso",
  },
  {
    method: "POST",
    path: "/api/concesiones-rol",
    description: "Conceder recurso a rol",
  },
  {
    method: "PATCH",
    path: "/api/concesiones-rol/:id/deactivate",
    description: "Retirar recurso a rol",
  },
  {
    method: "PATCH",
    path: "/api/concesiones-rol/:id/reactivate",
    description: "Reactivar recurso a rol",
  },
];

/** Recursos que recibe el rol `SELLER` (7). Derivado del catálogo, no duplicado. */
export const SELLER_RESOURCES: readonly CatalogResource[] = RESOURCE_CATALOG.filter(
  (resource) => resource.seller === true
);
EOF

Composición del catálogo:

Grupo Recursos
Clientes 7
Tipos de producto 7
Productos 7
Ventas 3
Detalle de ventas 1
Usuarios (+ cambio de contraseña + permisos) 9
Roles 7
Recursos 7
Asignaciones usuario-rol 5
Concesiones rol-recurso 5
Total 58

Las operaciones de sesión no son recursos. /api/sesion/* y /api/sesiones/* no dependen de la matriz de permisos, sino de poseer (o no) una identidad válida. Por eso son OPEN o JWT, nunca JWT + RBAC.

16.5 Feature Resources — repository, service, controller y rutas

: > src/features/auth/resources/resources.repository.ts
cat >> src/features/auth/resources/resources.repository.ts << 'EOF'
import { CreationAttributes, Transaction } from "sequelize";
import { Resource } from "./resource.model";
import { normalizePath } from "../../../shared/auth/resource-match";

/**
 * Capa Repository del feature Resources.
 * Única que habla con Sequelize (el modelo `Resource`).
 */
export class ResourcesRepository {
  /** Todos los recursos activos. */
  public async findAllActive(): Promise<Resource[]> {
    return Resource.findAll({ where: { status: "active" } });
  }

  /** Un recurso por PK (o `null`). */
  public async findById(id: number, transaction?: Transaction): Promise<Resource | null> {
    return Resource.findByPk(id, { transaction });
  }

  /** Un recurso por su par `(method, path)` (o `null`). */
  public async findByOperation(method: string, path: string): Promise<Resource | null> {
    return Resource.findOne({
      where: { method: method.trim().toUpperCase(), path: normalizePath(path.trim()) },
    });
  }

  /** Inserta un recurso. */
  public async create(data: CreationAttributes<Resource>): Promise<Resource> {
    return Resource.create(data);
  }

  /** Persiste cambios sobre una instancia existente. */
  public async update(resource: Resource, data: Partial<Resource>): Promise<Resource> {
    return resource.update(data);
  }

  /** Elimina físicamente una instancia. */
  public async delete(resource: Resource): Promise<void> {
    await resource.destroy();
  }
}
EOF
: > src/features/auth/resources/resources.service.ts
cat >> src/features/auth/resources/resources.service.ts << 'EOF'
import {
  CreateResourceDto,
  PatchResourceDto,
  ResourceResponseDto,
  UpdateResourceDto,
  toResourceResponse,
} from "./dto";
import { ResourcesRepository } from "./resources.repository";
import { Resource } from "./resource.model";
import { AppError } from "../../../shared/errors/app-error";

/**
 * Capa Service del feature Resources.
 *
 * Reglas de negocio: la tupla `(method, path)` es única. Se comprueba antes de
 * escribir para responder 409 con un mensaje útil en lugar de dejar reventar la
 * restricción única de la base de datos como 500.
 */
export class ResourcesService {
  public constructor(
    private readonly repository: ResourcesRepository = new ResourcesRepository()
  ) {}

  // ================== READ ==================
  public async getAll(): Promise<ResourceResponseDto[]> {
    const resources = await this.repository.findAllActive();
    return resources.map((resource) => toResourceResponse(resource));
  }

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

  // ================== CREATE ==================
  public async create(body: CreateResourceDto): Promise<ResourceResponseDto> {
    if (!body.method || !body.path) {
      throw new AppError(400, "method and path are required");
    }
    await this.assertOperationAvailable(body.method, body.path);

    const resource = await this.repository.create({
      method: body.method,
      path: body.path,
      description: body.description ?? null,
      status: body.status ?? "active",
    });
    return toResourceResponse(resource);
  }

  // ================== UPDATE ==================
  public async updatePut(id: number, body: UpdateResourceDto): Promise<ResourceResponseDto> {
    const resource = await this.findOrFail(id);
    await this.assertOperationAvailable(body.method, body.path, id);

    await this.repository.update(resource, {
      method: body.method,
      path: body.path,
      description: body.description ?? null,
    });
    return toResourceResponse(resource);
  }

  public async updatePatch(id: number, body: PatchResourceDto): Promise<ResourceResponseDto> {
    const resource = await this.findOrFail(id);

    const method = body.method ?? resource.method;
    const path = body.path ?? resource.path;
    await this.assertOperationAvailable(method, path, id);

    await this.repository.update(resource, body);
    return toResourceResponse(resource);
  }

  // ================== DELETE ==================
  /** Eliminación física. */
  public async deletePhysical(id: number): Promise<void> {
    const resource = await this.findOrFail(id, false);
    await this.repository.delete(resource);
  }

  /** Eliminación lógica -> `status = inactive`. Deshabilita el punto de acceso. */
  public async deleteLogical(id: number): Promise<ResourceResponseDto> {
    const resource = await this.findOrFail(id);
    await this.repository.update(resource, { status: "inactive" });
    return toResourceResponse(resource);
  }

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

  /** 409 si otro recurso ya declara el mismo `(method, path)`. */
  private async assertOperationAvailable(
    method: string,
    path: string,
    excludeId?: number
  ): Promise<void> {
    const existing = await this.repository.findByOperation(method, path);
    if (existing && existing.id !== excludeId) {
      throw new AppError(409, `Resource ${method.toUpperCase()} ${path} already exists`);
    }
  }
}
EOF
: > src/features/auth/resources/resources.controller.ts
cat >> src/features/auth/resources/resources.controller.ts << 'EOF'
import { Request, Response } from "express";
import { BaseController } from "../../../shared/http/base-controller";
import {
  CreateResourceDto,
  PatchResourceDto,
  UpdateResourceDto,
} from "./dto";
import { ResourcesService } from "./resources.service";

/**
 * Capa Controller del feature Resources.
 * Solo HTTP: lee `req`, llama al service y arma la respuesta.
 */
export class ResourcesController extends BaseController {
  public constructor(
    private readonly service: ResourcesService = new ResourcesService()
  ) {
    super();
  }

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

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

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

  // ================== UPDATE ==================
  public async updatePut(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const resource = await this.service.updatePut(
        this.paramId(req),
        req.body as UpdateResourceDto
      );
      res.status(200).json({ resource });
    });
  }

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

  // ================== DELETE ==================
  /** Eliminación física. */
  public async deletePhysical(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const id = this.paramId(req);
      await this.service.deletePhysical(id);
      res.status(200).json({ message: "Resource permanently deleted", id });
    });
  }

  /** Eliminación lógica -> `status = inactive`. */
  public async deleteLogical(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const resource = await this.service.deleteLogical(this.paramId(req));
      res.status(200).json({ message: "Resource deactivated (logical delete)", resource });
    });
  }
}
EOF
: > src/features/auth/resources/resources.routes.ts
cat >> src/features/auth/resources/resources.routes.ts << 'EOF'
import { Application } from "express";
import { ResourcesController } from "./resources.controller";
import { authenticate, authorize } from "../access";

/** Rutas del feature Resources — **modalidad 3 (JWT + RBAC)** en todas las operaciones. */
export class ResourcesRoutes {
  public resourcesController: ResourcesController = new ResourcesController();

  public routes(app: Application): void {
    // getAll
    app
      .route("/api/recursos")
      .get(authenticate, authorize, this.resourcesController.getAll.bind(this.resourcesController));

    // getOne
    app
      .route("/api/recursos/:id")
      .get(authenticate, authorize, this.resourcesController.getOne.bind(this.resourcesController));

    // create
    app
      .route("/api/recursos")
      .post(authenticate, authorize, this.resourcesController.create.bind(this.resourcesController));

    // update (PUT / PATCH)
    app
      .route("/api/recursos/:id")
      .put(
        authenticate,
        authorize,
        this.resourcesController.updatePut.bind(this.resourcesController)
      )
      .patch(
        authenticate,
        authorize,
        this.resourcesController.updatePatch.bind(this.resourcesController)
      );

    // delete físico
    app
      .route("/api/recursos/:id")
      .delete(
        authenticate,
        authorize,
        this.resourcesController.deletePhysical.bind(this.resourcesController)
      );

    // delete lógico
    app
      .route("/api/recursos/:id/deactivate")
      .patch(
        authenticate,
        authorize,
        this.resourcesController.deleteLogical.bind(this.resourcesController)
      );
  }
}
EOF

POST /api/recursos con un (method, path) ya existente → 409: lo impone la restricción UQ(method, path).

16.6 Feature Resources — seeder y swagger

El seeder es idempotente y reconciliador: findOrCreate por (method, path) y reactivación si la fila estaba inactiva. Reejecutarlo deja el catálogo exacto, sin duplicados.

: > src/features/auth/resources/resources.seeder.ts
cat >> src/features/auth/resources/resources.seeder.ts << 'EOF'
import { Resource } from "./resource.model";
import { RESOURCE_CATALOG } from "./resource-catalog";

/**
 * Seeder del catálogo de recursos (`resources`).
 *
 * A diferencia de los seeders de business, este **no usa datos aleatorios**: los
 * 58 recursos son un catálogo determinista definido en `resource-catalog.ts`.
 * Es idempotente por partida doble: `findOrCreate` por `(method, path)` y
 * reactivación de las filas que ya existían inactivas, de modo que volver a
 * ejecutarlo reconcilia el catálogo sin duplicar ni perder concesiones.
 */
export async function seedResources(): Promise<number> {
  let created = 0;

  for (const item of RESOURCE_CATALOG) {
    const [resource, wasCreated] = await Resource.findOrCreate({
      where: { method: item.method, path: item.path },
      defaults: {
        method: item.method,
        path: item.path,
        description: item.description,
        status: "active",
      },
    });

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

  console.log(
    `✅ resources: catálogo reconciliado (${RESOURCE_CATALOG.length} recursos, ${created} nuevos)`
  );
  return created;
}
EOF
: > src/features/auth/resources/resources.swagger.ts
cat >> src/features/auth/resources/resources.swagger.ts << 'EOF'
import {
  bearerSecurity,
  forbiddenResponse,
  invalidIdResponse,
  notFoundResponse,
  unauthorizedResponse,
} from "../../../shared/http/swagger-security";

/**
 * Documentación OpenAPI del feature Resources.
 *
 * Modalidad: **JWT + RBAC** en todas las operaciones.
 *
 * Un recurso es un par `(method, path)` con la ruta **en patrón**
 * (`/api/productos/:id`). `GET` y `POST` sobre la misma ruta son dos recursos
 * distintos y se conceden por separado.
 */
export const resourcesSwagger = {
  tags: [
    {
      name: "Recursos",
      description:
        "Catálogo de puntos de acceso protegibles: par `(method, path)` — **JWT + RBAC**",
    },
  ],
  paths: {
    "/api/recursos": {
      get: {
        tags: ["Recursos"],
        summary: "Listar recursos activos",
        description: "JWT + RBAC — recurso `GET /api/recursos`.",
        security: bearerSecurity,
        responses: {
          "200": { description: "Lista de recursos (`{ resources: [...] }`)" },
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
        },
      },
      post: {
        tags: ["Recursos"],
        summary: "Crear recurso",
        description:
          "JWT + RBAC — recurso `POST /api/recursos`. Alta de un nuevo punto de acceso; concederlo a un rol no requiere desplegar código.",
        security: bearerSecurity,
        requestBody: {
          required: true,
          content: {
            "application/json": { schema: { $ref: "#/components/schemas/ResourceCreate" } },
          },
        },
        responses: {
          "201": { description: "Recurso creado (`{ resource }`)" },
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "409": { description: "La tupla `(method, path)` ya existe" },
        },
      },
    },
    "/api/recursos/{id}": {
      get: {
        tags: ["Recursos"],
        summary: "Obtener recurso por id",
        description: "JWT + RBAC — recurso `GET /api/recursos/:id`.",
        security: bearerSecurity,
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
        responses: {
          "200": { description: "Recurso (`{ resource }`)" },
          "400": invalidIdResponse,
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": notFoundResponse,
        },
      },
      put: {
        tags: ["Recursos"],
        summary: "Reemplazar recurso (PUT)",
        description: "JWT + RBAC — recurso `PUT /api/recursos/:id`.",
        security: bearerSecurity,
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
        requestBody: {
          required: true,
          content: {
            "application/json": { schema: { $ref: "#/components/schemas/ResourceUpdate" } },
          },
        },
        responses: {
          "200": { description: "Recurso actualizado (`{ resource }`)" },
          "400": invalidIdResponse,
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": notFoundResponse,
        },
      },
      patch: {
        tags: ["Recursos"],
        summary: "Modificar recurso (PATCH)",
        description: "JWT + RBAC — recurso `PATCH /api/recursos/:id`.",
        security: bearerSecurity,
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
        requestBody: {
          content: {
            "application/json": { schema: { $ref: "#/components/schemas/ResourcePatch" } },
          },
        },
        responses: {
          "200": { description: "Recurso actualizado (`{ resource }`)" },
          "400": invalidIdResponse,
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": notFoundResponse,
        },
      },
      delete: {
        tags: ["Recursos"],
        summary: "Eliminar recurso (físico)",
        description: "JWT + RBAC — recurso `DELETE /api/recursos/:id`.",
        security: bearerSecurity,
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
        responses: {
          "200": { description: "Eliminado (`{ message, id }`)" },
          "400": invalidIdResponse,
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": notFoundResponse,
        },
      },
    },
    "/api/recursos/{id}/deactivate": {
      patch: {
        tags: ["Recursos"],
        summary: "Desactivar recurso (borrado lógico)",
        description:
          "JWT + RBAC — recurso `PATCH /api/recursos/:id/deactivate`. " +
          "Efecto inmediato: ningún rol puede autorizar ese punto de acceso (eslabón `resources` inactivo -> DENY).",
        security: bearerSecurity,
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
        responses: {
          "200": { description: "Desactivado (`{ message, resource }`)" },
          "400": invalidIdResponse,
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": notFoundResponse,
        },
      },
    },
  },
  components: {
    schemas: {
      Resource: {
        type: "object",
        properties: {
          id: { type: "integer", example: 1 },
          method: { type: "string", enum: ["GET", "POST", "PUT", "PATCH", "DELETE"], example: "GET" },
          path: { type: "string", example: "/api/productos/:id" },
          description: { type: "string", nullable: true },
          status: { type: "string", enum: ["active", "inactive"], example: "active" },
          createdAt: { type: "string", format: "date-time" },
          updatedAt: { type: "string", format: "date-time" },
        },
      },
      ResourceCreate: {
        type: "object",
        required: ["method", "path"],
        properties: {
          method: { type: "string", enum: ["GET", "POST", "PUT", "PATCH", "DELETE"] },
          path: { type: "string", example: "/api/reportes/:id" },
          description: { type: "string", nullable: true },
          status: { type: "string", enum: ["active", "inactive"], default: "active" },
        },
      },
      ResourceUpdate: {
        type: "object",
        required: ["method", "path"],
        properties: {
          method: { type: "string", enum: ["GET", "POST", "PUT", "PATCH", "DELETE"] },
          path: { type: "string" },
          description: { type: "string", nullable: true },
        },
      },
      ResourcePatch: {
        type: "object",
        properties: {
          method: { type: "string", enum: ["GET", "POST", "PUT", "PATCH", "DELETE"] },
          path: { type: "string" },
          description: { type: "string", nullable: true },
        },
      },
    },
  },
};
EOF

16.7 Pruebas HTTP

: > src/features/auth/roles/http/roles.get.http
cat >> src/features/auth/roles/http/roles.get.http << 'EOF'
### Feature Roles — CRUD (modalidad JWT + RBAC)
### El NOMBRE del rol no autoriza nada: la autorización son las filas de `resource_roles`.
@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}}

### getAll — recurso `GET /api/roles` (ADMIN y SELLER)
GET {{baseUrl}}/api/roles
Authorization: Bearer {{token}}

### getOne
GET {{baseUrl}}/api/roles/1
Authorization: Bearer {{token}}

### CREATE — nace SIN permisos
POST {{baseUrl}}/api/roles
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "name": "AUDITOR",
  "description": "Solo lectura de catálogo"
}

### UPDATE PUT
PUT {{baseUrl}}/api/roles/3
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "name": "AUDITOR",
  "description": "Solo lectura de catálogo y ventas"
}

### UPDATE PATCH
PATCH {{baseUrl}}/api/roles/3
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "description": "Auditoría operativa"
}

### DELETE lógico — efecto inmediato: todos sus usuarios pierden esos permisos (403)
PATCH {{baseUrl}}/api/roles/3/deactivate
Authorization: Bearer {{token}}

### DELETE físico
DELETE {{baseUrl}}/api/roles/3
Authorization: Bearer {{token}}

### 403 — SELLER no tiene concedido `GET /api/roles`
GET {{baseUrl}}/api/roles
Authorization: Bearer {{loginSeller.response.body.$.access_token}}
EOF
: > src/features/auth/resources/http/resources.get.http
cat >> src/features/auth/resources/http/resources.get.http << 'EOF'
### Feature Resources — catálogo de puntos de acceso (modalidad JWT + RBAC)
### Un recurso es el par (method, path) con la ruta EN PATRÓN: /api/productos/:id
### GET y POST sobre la misma ruta son DOS recursos distintos.
@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 — el catálogo completo (58 recursos sembrados)
GET {{baseUrl}}/api/recursos
Authorization: Bearer {{token}}

### getOne
GET {{baseUrl}}/api/recursos/25
Authorization: Bearer {{token}}

### CREATE — alta de un punto de acceso nuevo
### Concederlo después a un rol no requiere desplegar código.
POST {{baseUrl}}/api/recursos
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "method": "GET",
  "path": "/api/reportes/ventas/:id",
  "description": "Consultar reporte de ventas"
}

### 409 — la tupla (method, path) ya existe
POST {{baseUrl}}/api/recursos
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "method": "GET",
  "path": "/api/clientes",
  "description": "Duplicado"
}

### UPDATE PUT
PUT {{baseUrl}}/api/recursos/59
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "method": "GET",
  "path": "/api/reportes/ventas/:id",
  "description": "Reporte de ventas por id"
}

### DELETE lógico — ningún rol puede ya autorizar ese endpoint (403 para todos)
PATCH {{baseUrl}}/api/recursos/59/deactivate
Authorization: Bearer {{token}}

### DELETE físico
DELETE {{baseUrl}}/api/recursos/59
Authorization: Bearer {{token}}
EOF

Verificación

npx tsc --noEmit
npm run db:seed
SELECT COUNT(*) FROM resources;   -- 58
SELECT COUNT(*) FROM roles;       -- 2
SELECT name, status FROM roles;   -- ADMIN, SELLER

DoD del ISS-11

  • [ ] Todos los criterios de aceptación (16.1 … 16.7) cumplidos
  • [ ] resources tiene 58 filas y UQ(method, path) impide duplicados (409)
  • [ ] Los roles se crean sin permisos; concederlos es el ISS-12
  • [ ] npx tsc --noEmit sin errores y npm run db:seed idempotente

GATE

Para cerrar el ISS-11, ejecuta:

npx tsc --noEmit
npm run db:seed

Resultado esperado: el compilador no imprime ningún error, y el seeder reconcilia el catálogo con los logs de roles: catálogo reconciliado (2 roles, …) y resources: catálogo reconciliado (58 recursos, …).

Y confirma el estado de la base de datos con las consultas del ISS:

SELECT COUNT(*) FROM resources;   -- 58
SELECT COUNT(*) FROM roles;       -- 2
SELECT name, status FROM roles;   -- ADMIN, SELLER

Resultado esperado: 58 recursos, 2 roles (ADMIN y SELLER) en estado active. Reejecuta npm run db:seed una segunda vez: los conteos no deben crecer (idempotencia).

Checklist de cierre:

  • [ ] Los criterios 16.1 … 16.7 cumplidos
  • [ ] resources tiene 58 filas y UQ(method, path) impide duplicados (409)
  • [ ] Los roles se crean sin permisos; concederlos es el ISS-12
  • [ ] npx tsc --noEmit sin errores
  • [ ] npm run db:seed idempotente (2 roles, 58 recursos)

Recuerda lo que no has construido todavía: la matriz que une roles y recursos (ISS-12) y los middlewares que la consultan (ISS-13). Con el GATE en verde, tienes los dos extremos del permiso listos para que el ISS-12 — Asignaciones y concesiones los una.


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