📚 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.
🎬 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: truemarca 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
- [ ]
resourcestiene 58 filas yUQ(method, path)impide duplicados (409)- [ ] Los roles se crean sin permisos; concederlos es el ISS-12
- [ ]
npx tsc --noEmitsin errores ynpm run db:seedidempotente
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:
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
- Ficha del ISS
- Mapa mental del ISS
- Mapa del backend
- Roles + Recursos = permiso
- Árbol de archivos
- Anatomía del código
- Comandos explicados
- Flujos
- Diagnóstico
- Criterios de aceptación
- Evaluación
- Conexión con el resto del curso
- Glosario
- Recorrido del ISS, paso a paso
- 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 siembranADMINySELLER.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 enresource-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:
- Un rol no autoriza. Crear
AUDITORenPOST /api/rolesno le concede absolutamente nada. La autorización son las filas deresource_rolesque se crean en ISS-12. - 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 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
:paramdel 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/:idy no/api/productos/42en 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 enPOST /api/roles:nameobligatorio,descriptionopcional ystatusopcional (por defectoactive).UpdateRoleDto— lo que se acepta enPUT /api/roles/:id: reemplazo completo connameobligatorio. No incluyestatus, 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 deRoleI; incluyetoRoleResponse(), el mapper que convierte la instancia del modelo en un objeto plano contoJSON().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(tiparreq.body) y elroles.service.ts(firmas de sus métodos). - Salida:
RoleResponseDtoes 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; eltransactionopcional 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 defectonew RolesRepository()). - Salida: usa el modelo
Role(de ISS-09) y los tiposCreationAttributes/Transactionde 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,UPDATEyDELETE, igual que los features de Fase I. create()valida que venganame(400), comprueba que no exista (409) y normalizastatusaactivesi no se envía.updatePut()yupdatePatch()reutilizanassertNameAvailable()con unexcludeId, para no chocar contra el propio rol que se está editando.- Ofrece dos borrados:
deletePhysical()(destruye la fila) ydeleteLogical()(cambiastatusainactive). 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()yassertNameAvailable()lanzanAppError(404 y 409), que elBaseControllertraduce a HTTP.
Se conecta con
- Entrada: lo instancia el
RolesController. - Salida: usa
RolesRepository, el modeloRole, los DTOs yAppError.
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 heredarun()(que captura errores y delega ensendError) yparamId()(que valida el:idde la ruta). - Cada método envuelve su lógica en
await this.run(res, async () => { … }), de modo que cualquierAppErrorse convierte en la respuesta JSON correcta. getAllresponde{ roles };getOneresponde{ role };createusa201; losupdateusan200;deletePhysicalresponde un mensaje con elid;deleteLogicalresponde el rol ya desactivado.
Se conecta con
- Entrada: lo instancia
RolesRoutesy lo registra en las rutas. - Salida: llama a
RolesServicey hereda deBaseController(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/rolesy/api/roles/:id, másPATCH /api/roles/:id/deactivatepara 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
singletonde 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, authorizedesde../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_ROLESes 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 usafindOrCreatepor 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), dondeADMINrecibirá los 58 ySELLERlos 7 de operación.
Se conecta con
- Entrada: lo llama
seedRoles()desdesrc/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: bearerSecurityy 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) ynotFoundResponse(404). - Define los esquemas
Role,RoleCreate,RoleUpdateyRolePatch. - 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.tsy 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
CreateResourceDtoexigemethod(uno deGET/POST/PUT/PATCH/DELETE) ypath;UpdateResourceDtoyPatchResourceDtosiguen el mismo patrón que en roles;ResourceResponseDtoes alias deResourceIcon su mappertoResourceResponse().resource-catalog.tsdefine la interfazCatalogResource(method,path,description, yseller?) y exportaRESOURCE_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: truedesigna los 7 recursos de operación que recibirá el rolSELLER. - Al final,
SELLER_RESOURCESderiva 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.tsusa los DTOs; elresources.seeder.tsrecorreRESOURCE_CATALOG; el seeder de ISS-12 usaráSELLER_RESOURCES. - Salida: usa el modelo
Resource, elnormalizePathderesource-matchy la referencia de diseñodocs/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()yfindById()— 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 connormalizePath(). 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
ResourceynormalizePathdeshared/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()exigemethodypath(400) y luego llama aassertOperationAvailable()(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 solomethodo solopath, recompone la tupla conbody.method ?? resource.methody valida la tupla resultante completa.deleteLogical()ponestatus = 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 modeloResource, los DTOs yAppError.
Archivo: src/features/auth/resources/resources.controller.ts
Propósito
Traducir HTTP ↔ service para recursos; mismo estilo que el de roles.
Explicación
- Extiende
BaseControllery usarun()/paramId(). getAllresponde{ resources }ygetOneresponde{ resource };createresponde201; losupdateresponden200;deletePhysicalresponde un mensaje con elid;deleteLogicalresponde el recurso desactivado.
Se conecta con
- Entrada: lo instancia
ResourcesRoutes. - Salida: llama a
ResourcesServicey hereda deBaseController.
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/recursosy/api/recursos/:id, másPATCH /api/recursos/:id/deactivate. - Declara JWT + RBAC en todas las operaciones, con
authenticate, authorizedelante de cada handler.
Se conecta con
- Entrada: la instancia
src/routes/index.ts(ResourcesRoutes). - Salida: importa
authenticate, authorizedesde../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:
findOrCreatepor(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()desdesrc/database/seeders/index.ts. - Salida: usa el modelo
ResourceyRESOURCE_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
pathse documenta con patrón (/api/productos/:id) y se recuerda queGETyPOSTsobre la misma ruta son dos recursos distintos. - Define los esquemas
Resource,ResourceCreate,ResourceUpdateyResourcePatch(conmethodcomo 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
adminy, en roles, también comoseller, para demostrar el 403 cuando un rol no tiene concedido un recurso. - Cubren el ciclo completo:
getAll,getOne,POST,PUT,PATCH,DELETElógico yDELETEfí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
Flujo 1 — El seeder que puebla el catálogo
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) yroles.swagger.ts - [ ] 16.4
features/auth/resources/dto/+resource-catalog.tscon 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) yresources.swagger.ts - [ ] 16.7 archivos
.httpde ambos features - [ ]
npx tsc --noEmitOK
Evaluación
Preguntas de comprensión
-
¿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 enroles, los recursos enresourcesy la concesión (el «∧») en la pivoteresource_roles(ISS-12). Tener una tablapermissionsduplicaría una verdad que ya está expresada por la existencia de una fila en la pivote. -
Crear un rol
ADMINconPOST /api/roles, ¿le concede permisos? No. El nombre del rol no autoriza nada; solo agrupa. UnADMINrecién creado está vacío: no habilita ninguna operación hasta que en ISS-12 se le concedan recursos enresource_roles. Es la trampa conceptual número uno de este ISS. -
¿Qué es exactamente un recurso y por qué
GET /api/productosyPOST /api/productosson dos recursos distintos? Un recurso es un punto de acceso protegible: el par(method, path).GETyPOSTsobre 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 esUQ(method, path). -
¿Por qué el
pathse 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 resuelveresource-matchen ISS-13. -
¿Por qué
GET /api/productos/42no casa con el patrónGET /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. -
¿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. -
¿Qué significa que los seeders de roles y resources sean idempotentes y reconciliadores? Que reejecutarlos no duplica datos: usan
findOrCreatepor clave (nameen roles,(method, path)en resources) y reactivan las filas inactivas. Así el catálogo queda siempre exacto (2roles y58recursos), sin perder concesiones. -
¿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,refreshylogoutson OPEN;perfilysesionesson JWT. Ninguna se concede por rol. -
¿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_roleslo une a un recurso activo. ISS-11 construye los agrupadores; ISS-12 construye los vínculos. -
¿Por qué la matriz
role_usersyresource_roleses 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: 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
RoleyResource(ya definidos, con su normalización en hooks), las primitivasAppError,BaseController,sendErrorynormalizePath. - Lo que reutiliza de ISS-10: el patrón de feature completo (DTO → repository → service → controller → routes → seeder → swagger) y el uso de
findOrCreateidempotente 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) yRESOURCE_CATALOGcompleto para ADMIN. - Lo que habilita en ISS-13: los recursos con patrón que
authorizecomparará 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/·resourcesAPI /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) yroles.swagger.ts - [ ] 16.4
features/auth/resources/dto/+resource-catalog.tscon 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) yresources.swagger.ts - [ ] 16.7 archivos
.httpde ambos features - [ ]
npx tsc --noEmitOK
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 rolAUDITORno 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
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
- [ ]
resourcestiene 58 filas yUQ(method, path)impide duplicados (409) - [ ] Los roles se crean sin permisos; concederlos es el ISS-12
- [ ]
npx tsc --noEmitsin errores ynpm run db:seedidempotente
GATE
Para cerrar el ISS-11, ejecuta:
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
- [ ]
resourcestiene 58 filas yUQ(method, path)impide duplicados (409) - [ ] Los roles se crean sin permisos; concederlos es el ISS-12
- [ ]
npx tsc --noEmitsin errores - [ ]
npm run db:seedidempotente (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