Saltar a contenido

📚 Unidad ISS-09 · Auth base (seguridad y modelos) — 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 Auth base (seguridad y modelos) 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-09 — Auth base (seguridad y modelos) (8 diapositivas). Se visualiza aquí, dentro del sitio; no hace falta descargar nada para estudiarla.

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

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


🎬 Video explicativo

Recorrido audiovisual de la unidad. Este video explica, bloque por bloque, las primitivas de autenticación del ISS-09: hash, comparación de contraseña, token opaco y el contrato que el criterio llama verifyPassword y el archivo exporta como comparePassword.

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


ISS-09 — Cuaderno de aprendizaje visual

Tema

Base de seguridad compartida y modelos Auth: construir las primitivas transversales (hash de contraseña con bcrypt, firma y verificación de JWT HS256, matcher de rutas, identidad en Request, sendError) y las seis tablas del modelo RBAC con sus asociaciones, antes de escribir el primer endpoint protegido.

Fuente técnica autoritativa

Archivo fuente ../manual/11-ISS-09-auth-base.md
Nombre literal 11-ISS-09-auth-base.md
Estado Solo lectura — este cuaderno no modifica el ISS
Fase Fase II — Auth con RBAC (primer ISS de la fase)
Alcance 1.377 líneas, 12 bloques de código, 10 criterios de aceptación (14.1 … 14.10)

Este cuaderno es una capa pedagógica sobre ese archivo: todo su contenido técnico aparece aquí íntegro y verbatim. El ISS manda; el cuaderno explica. Las secciones ## El access token, por dentro, ## Anatomía del código, ## Comandos explicados, ## Flujos, ## Diagnóstico y ## Glosario son aportación didáctica; nada de lo que afirman contradice al ISS.

Pregunta que responde: ¿qué piezas de seguridad debo construir antes de poder hablar de «usuarios» y «permisos»?

Regla del ISS

Objetivo: dejar listas las primitivas de seguridad (hash de contraseña, hashes de tokens opacos, firma y verificación de JWT, matcher de rutas) y las seis tablas del modelo RBAC con sus asociaciones, de modo que los ISS posteriores solo construyan capas sobre esta base. Bloqueado por: Fase I cerrada (el App, Routes, SeedersRunner y Swagger ya existen y se extienden, no se reescriben).

La condición fundamental que el propio ISS exige para darse por terminado combina dos ideas:

  1. Compila y arranca: npx tsc --noEmit sin errores y npm run dev levanta el servidor.
  2. Sin endpoints nuevos y sin protección todavía: la API de Fase I sigue funcionando SIN AUTH. Las rutas de negocio no se tocan aquí; la protección real llega en ISS-13.

Es decir: este ISS crea infraestructura y modelos vacíos. No hay ni un GET /api/usuarios funcional, y eso es correcto. Si al terminar intentas llamar a un endpoint de auth y recibes un 404, no has fallado: todavía no existe.

Cómo leer este cuaderno

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

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

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

El recorrido de lectura es siempre el mismo:

EXPLICACIÓN
    ↓
CÓDIGO
    ↓
VISUAL

Y 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, paso a paso ver el qué exacto, verbatim
Diagramas Mapa mental, Mapa del backend, El access token, por dentro, 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

En este ISS el código es casi todo el contenido: son 12 bloques de un solo uso (seed de archivos con cat >> … << 'EOF'). Léelos como «recetas de creación», no como código que se ejecuta en caliente.

Ruta de aprendizaje

Esta ruta es específica de este ISS: sigue el orden real de sus 10 apartados (14.1 … 14.10).

Fase I cerrada (5 features business, SIN AUTH)
    ↓
14.1  Instalar jsonwebtoken + nuevas variables .env
    ↓
14.2  password.ts      → bcrypt 12, sha256Hex, generateOpaqueToken
    ↓
14.3  jwt.ts           → firma y verificación HS256
    ↓
14.4  resource-match.ts → normalizePath, pathMatches, isOperationGranted
    ↓
14.5  auth-user.ts     → Request.auth y requireAuthUser
    ↓
14.6  error-response.ts + PARCHE de BaseController
    ↓
14.7  swagger-security.ts → bearerAuth, 401, 403
    ↓
14.8  Los 6 modelos Sequelize (users, roles, resources, role_users,
      resource_roles, refresh_tokens)
    ↓
14.9  rbac.associations.ts → el grafo completo
    ↓
14.10 Cableado en config/index.ts y seeders/index.ts
    ↓
Verificación: tsc + db:seed + dev

Fíjate en lo que no aparece: no hay «feature users», ni «services», ni «middlewares», ni «login». Todo eso son ISS-10 … ISS-15. Aquí solo se prepara el terreno sobre el que se apoyarán.

Pregunta que responde: ¿en qué orden se construye la base de seguridad y por qué ese orden?

Índice

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

Ficha del ISS

Campo Valor
ISS ISS-09
Título Base de seguridad compartida y modelos Auth
Objetivo Dejar listas las primitivas de seguridad (bcrypt 12, SHA-256 de tokens, JWT HS256, matcher de rutas) y las 6 tablas RBAC con sus asociaciones
Fase Fase II — Auth con RBAC (primer ISS)
Tecnología principal jsonwebtoken@^9.0.3 (HS256), bcryptjs (12 rondas), Sequelize
Depende de Cierre de Fase I (00…ISS-08); el App, Routes, SeedersRunner y Swagger ya existen
Habilita ISS-10 — Feature Users
Archivos creados src/shared/auth/{password,jwt,resource-match,auth-user}.ts, src/shared/http/{error-response,swagger-security}.ts, 6 modelos en src/features/auth/…, src/features/auth/rbac.associations.ts
Archivos parcheados src/shared/http/base-controller.ts, src/config/index.ts, src/database/seeders/index.ts, .env, package.json
Componentes incorporados Hash bcrypt, hash SHA-256, token opaco, firma/verificación JWT, matcher de rutas, sendError, requireAuthUser, 6 modelos + asociaciones
Verificación principal npx tsc --noEmit + npm run db:seed + npm run dev
Resultado esperado Las 6 tablas existen vacías; el servidor arranca; sin endpoints nuevos (la API de Fase I sigue SIN AUTH)
GATE DoD del ISS-09: criterios 14.1 … 14.10 + tsc OK + db:seed crea las 6 tablas + dev arranca

Qué implementamos AHORA

  • Primitivas de seguridad en src/shared/auth/:
  • password.ts → hashPassword, comparePassword (bcrypt 12), sha256Hex, generateOpaqueToken.
  • jwt.ts → signAccessToken / verifyAccessToken / extractBearerToken (HS256 con iss, aud, exp, jti).
  • resource-match.ts → normalizePath, pathMatches, isOperationGranted.
  • auth-user.ts → tipo AuthUser, Request.auth, requireAuthUser.
  • HTTP compartido en src/shared/http/:
  • error-response.ts → sendError (único mapeo error → HTTP).
  • Parche de base-controller.ts para que handleError delegue en sendError.
  • swagger-security.ts → bearerSecurityScheme, unauthorizedResponse, forbiddenResponse.
  • Los seis modelos en src/features/auth/: User, Role, Resource, RoleUser, ResourceRole, RefreshToken, con sus UK e índices.
  • El grafo de asociaciones en rbac.associations.ts.
  • El cableado de modelos y asociaciones en config/index.ts y seeders/index.ts (primero modelos, después asociaciones).

Qué todavía NO implementamos

No se implementa aquí Llega en
Feature users (DTOs, repository, service, controller, rutas) ISS-10
Features roles y resources (+ catálogo de 58 recursos) ISS-11
Pivotes role_users y resource_roles + consulta de permisos efectivos ISS-12
Middlewares authenticate y authorize; protección real de rutas ISS-13
refresh_tokens funcional (rotación, detección de reúso) ISS-14
Login, refresh, logout, perfil, /api/permisos ISS-15
Protección de las rutas de negocio de Fase I ISS-13

Trampa habitual: ver src/features/auth/refresh-tokens/refresh-token.model.ts y creer que el refresh token ya funciona. No: aquí solo nace la tabla (refresh_tokens). La lógica de rotación vive en ISS-14. Lo mismo vale para users: la tabla se crea en ISS-09, pero el CRUD llega en ISS-10.

Mapa mental del ISS

mindmap
  root((ISS-09<br/>Base de seguridad))
    Primitivas shared auth
      password
      jwt
      resource match
      auth user
    HTTP compartido
      error response
      parche BaseController
      swagger security
    Seis modelos
      User
      Role
      Resource
      RoleUser
      ResourceRole
      RefreshToken
    Asociaciones
      rbac associations
    Cableado
      config index
      seeders index
    Resultado
      tablas vacias
      sin endpoints nuevos

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

Mapa del backend

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

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

   HTTP
    ↓
   Routes (5 features business — todavía SIN AUTH)          ✅ Fase I
    ↓
   Controller → Service → Repository → Model → Sequelize → BD   ✅ Fase I

   ── Capa transversal compartida (NUEVA en ISS-09) ──────────────
   shared/auth/   password.ts          bcrypt 12               ✅
                  jwt.ts              firma/verificación HS256  ✅
                  resource-match.ts   normalizador + matcher   ✅
                  auth-user.ts        Request.auth            ✅
   shared/http/   error-response.ts   sendError              ✅
                  swagger-security.ts bearerAuth, 401, 403    ✅

   ── Modelos Auth (NUEVOS en ISS-09, tablas VACÍAS) ───────────────
   features/auth/ User  Role  Resource  RoleUser
                  ResourceRole  RefreshToken                  ✅
                  rbac.associations.ts                        ✅


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

   HTTP
    ↓
   Routes
    ↓
   authenticate → authorize        🎯 ISS-13
    ↓
   Controller → Service → Repository → Model → Sequelize → BD
        ↑                    ↑
   feature users        features roles / resources / pivotes
        🎯 ISS-10             🎯 ISS-11 / ISS-12

   login · refresh · logout · perfil · permisos   🎯 ISS-15
   protección de las 5 rutas de negocio            🎯 ISS-13

Pregunta que responde: ¿qué capas del backend existen ya y cuáles siguen siendo objetivo?

Lo importante de este mapa: la caja «capa de seguridad» ya está construida (✅), pero todavía no está enchufada a ninguna ruta. Es como tener las cerraduras montadas pero aún sin colocar en las puertas: las puertas de negocio siguen abiertas hasta ISS-13.

El access token, por dentro

Un access token es un JWT (JSON Web Token) firmado: un texto con tres partes separadas por puntos (header.payload.signature). El servidor lo firma con JWT_SECRET (HMAC SHA-256) y cualquiera puede leer su contenido, pero nadie puede modificarlo sin invalidar la firma. Esto es lo que permite autenticar sin tocar la base de datos.

flowchart TD
    U["Usuario autenticado"] --> S["signAccessToken user"]
    S --> H["jwt.sign con HS256"]
    H --> P["Payload del token"]
    P --> C1["sub  id del usuario"]
    P --> C2["username  nombre"]
    P --> C3["jti  identificador unico del token"]
    P --> C4["iss  app-storelab-express"]
    P --> C5["aud  app-storelab-api"]
    P --> C6["iat  fecha de emision"]
    P --> C7["exp  caducidad  15 min por defecto"]
    P --> NO["NO viajan roles ni permisos"]
    H --> F["Firma HMAC SHA-256 con JWT_SECRET"]
    F --> T["access_token entregado al cliente"]

Pregunta que responde: ¿qué información viaja dentro del access token y qué información NO viaja?

Traducción de cada claim (declaración):

Claim Qué significa Para qué sirve aquí
sub subject: a quién representa el token Identifica al usuario (su id); se valida como entero positivo
username Nombre del usuario Comodidad de lectura / trazabilidad
jti JWT ID: identificador único del token Trazabilidad y correlación (RFC 8725)
iss issuer: quién lo emitió Rechaza tokens de otro servicio
aud audience: para quién es Rechaza tokens destinados a otra API
iat issued at: cuándo se emitió Trazabilidad
exp expiration: cuándo caduca El token deja de valer solo

¿Por qué el token NO lleva roles ni permisos? Porque un token es inmutable durante su vida: lo que viaja dentro no se puede cambiar hasta que caduque. Si los permisos fueran dentro del token, revocar un permiso no tendría efecto hasta que el token expirara: el usuario seguiría pudiendo hacer lo prohibido durante, como mínimo, los 15 minutos de vida del token. La decisión de este diseño es la contraria: el token solo dice quién eres; qué puedes hacer se consulta contra la matriz en cada petición (ISS-13). Así la revocación es inmediata.

Este concepto es crítico y reaparece en ISS-13 (authorize reconstruye los permisos efectivos por petición) y en ISS-14/15 (los tokens de sesión se renuevan y se revocan). Si te llevas una sola idea de este ISS, que sea esta.

Árbol de archivos

Estructura antes

Estado al cerrar Fase I (referencia: 10-cierre-business.md). Solo se muestran las ramas relevantes.

app-storelab-express-ii/
├── src/
│   ├── config/
│   │   └── index.ts                     △ (se parchea en 14.10)
│   ├── database/
│   │   ├── db.ts
│   │   └── seeders/
│   │       ├── counts.ts
│   │       └── index.ts                 △ (se parchea en 14.10)
│   ├── features/
│   │   └── business/                    (5 features de Fase I)
│   ├── routes/
│   │   └── index.ts
│   ├── shared/
│   │   ├── database/with-transaction.ts
│   │   ├── errors/app-error.ts
│   │   └── http/
│   │       └── base-controller.ts       △ (se parchea en 14.6)
│   ├── swagger/index.ts
│   └── server.ts
└── .env                                 △ (se parchea en 14.1)

Archivos creados / modificados en este ISS

★ src/shared/auth/password.ts               (14.2)
★ src/shared/auth/jwt.ts                     (14.3)
★ src/shared/auth/resource-match.ts          (14.4)
★ src/shared/auth/auth-user.ts               (14.5)
★ src/shared/http/error-response.ts          (14.6)
△ src/shared/http/base-controller.ts         (14.6, parche: delega en sendError)
★ src/shared/http/swagger-security.ts        (14.7)
★ src/features/auth/users/user.model.ts              (14.8)
★ src/features/auth/roles/role.model.ts              (14.8)
★ src/features/auth/resources/resource.model.ts      (14.8)
★ src/features/auth/role-users/role-user.model.ts    (14.8)
★ src/features/auth/resource-roles/resource-role.model.ts  (14.8)
★ src/features/auth/refresh-tokens/refresh-token.model.ts  (14.8)
★ src/features/auth/rbac.associations.ts     (14.9)
△ src/config/index.ts                        (14.10, parche: imports + rutas)
△ src/database/seeders/index.ts              (14.10, parche: imports)
△ .env                                       (14.1, parche: 3 variables)
△ package.json                               (14.1, jsonwebtoken + @types)

Estructura después

app-storelab-express-ii/
├── src/
│   ├── config/
│   │   └── index.ts                     △ (cablea modelos + asociaciones)
│   ├── database/
│   │   ├── db.ts
│   │   └── seeders/
│   │       ├── counts.ts
│   │       └── index.ts                 △ (importa modelos + asociaciones)
│   ├── features/
│   │   ├── business/                    (sin cambios de Fase I)
│   │   └── auth/
│   │       ├── users/user.model.ts                    ★
│   │       ├── roles/role.model.ts                    ★
│   │       ├── resources/resource.model.ts            ★
│   │       ├── role-users/role-user.model.ts          ★
│   │       ├── resource-roles/resource-role.model.ts  ★
│   │       ├── refresh-tokens/refresh-token.model.ts  ★
│   │       └── rbac.associations.ts                   ★
│   ├── shared/
│   │   ├── auth/                        ★ (nueva carpeta)
│   │   │   ├── password.ts
│   │   │   ├── jwt.ts
│   │   │   ├── resource-match.ts
│   │   │   └── auth-user.ts
│   │   ├── database/with-transaction.ts
│   │   ├── errors/app-error.ts
│   │   └── http/
│   │       ├── base-controller.ts       △
│   │       ├── error-response.ts        ★
│   │       └── swagger-security.ts      ★
│   ├── routes/index.ts
│   ├── swagger/index.ts
│   └── server.ts
└── .env                                 △

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

Pregunta que responde: ¿este ISS modifica la estructura del proyecto y en qué partes?

Anatomía del código

Por cada archivo importante, qué responsabilidad tiene y con quién se conecta. El código completo está en ## Recorrido del ISS, paso a paso (verbatim); aquí se explica el porqué y el cómo encaja.

Archivo: src/shared/auth/password.ts

Propósito

Concentrar en un único punto toda la criptografía de contraseñas y de tokens de sesión, para que los tres sitios que los usan no puedan divergir.

Explicación

  • hashPassword / comparePassword envuelven bcryptjs con una constante SALT_ROUNDS = 12. Al vivir aquí, si mañana se sube o baja el coste se cambia en un solo lugar.
  • sha256Hex es para los refresh tokens, no para contraseñas: un token opaco de alta entropía no es adivinable, así que no necesita un algoritmo lento; solo hay que evitar guardarlo en claro y poder buscarlo por índice.
  • generateOpaqueToken produce un valor aleatorio URL-safe (randomBytes(64) en base64url), es decir, un token que no es un JWT y que por tanto no se puede autovalidar: hay que consultarlo en la BD.

Se conecta con

  • Entrada: el hook beforeCreate/beforeUpdate de User, el service de usuarios (ISS-10) y el login (ISS-15).
  • Salida: bcryptjs y node:crypto. La BD recibe siempre el hash, nunca el valor en claro.

Archivo: src/shared/auth/jwt.ts

Propósito

Emitir y verificar el access token (JWT firmado con HS256), fijando en el código el algoritmo, el emisor y la audiencia.

Explicación

  • signAccessToken firma con algorithm: "HS256", subject, issuer, audience, expiresIn y jwtid aleatorio (randomUUID). Devuelve el token y su TTL, porque el service lo necesita para responder cuándo caduca.
  • getSecret exige un JWT_SECRET de mínimo 32 caracteres; si falta, lanza AppError(500). Es un fallo de configuración, no de usuario.
  • verifyAccessToken pasa opciones explícitas (algorithms, issuer, audience, clockTolerance) y después comprueba a mano sub (entero positivo) y jti. Este detalle es importante: jsonwebtoken no tiene la opción require (esa es de jose); confiar en ella sería confiar en que valida algo cuando no lo hace.
  • Cualquier fallo de verificación se traduce a AppError(401): «no autenticado», nunca un 500.
  • extractBearerToken implementa la lectura de Authorization: Bearer <token> (RFC 6750).

Se conecta con

  • Entrada: el futuro middleware authenticate (ISS-13) y el service de sesión (ISS-15).
  • Salida: jsonwebtoken, node:crypto y AppError.

Archivo: src/shared/auth/resource-match.ts

Propósito

Traducir la petición real (GET /api/productos/42) a la concesión almacenada (GET /api/productos/:id). Es el corazón lógico del RBAC.

Explicación

  • normalizePath quita query string, hash, barra final y barras duplicadas. Normalizar antes de comparar evita que /api/productos/ y /api/productos se traten como cosas distintas.
  • pathMatches divide ambos caminos por / y exige el mismo número de segmentos; cada segmento :param del patrón casa con un segmento cualquiera, y el resto debe ser igual. Es deliberadamente estricto: no hay comodines *.
  • Por esa estrictez, GET /api/productos/42 no casa con GET /api/productos. Así, un permiso de «listar» no autoriza por accidente «leer uno concreto».
  • isOperationGranted compara el verbo (en mayúsculas) y el camino contra todas las concesiones; devuelve false si ninguna casa. Es la regla deny by default hecha código.

Se conecta con

  • Entrada: el futuro middleware authorize y el modelo Resource (que normaliza su path con esta misma función).
  • Salida: true/false puro, sin efectos secundarios. No conoce req/res ni Sequelize.

Archivo: src/shared/auth/auth-user.ts

Propósito

Definir qué es la identidad de una petición y cómo se lee de forma segura.

Explicación

  • La interfaz AuthUser describe lo que los middlewares de acceso dejan en la petición: id, username, email? y tokenId? (el jti, útil para cerrar la sesión actual).
  • requireAuthUser(req) devuelve req.auth o lanza AppError(401). Cierra el caso de las rutas JWT (sin authorize): el middleware ya garantizó la identidad, pero el tipo es opcional, así que esta función evita los ! (non-null assertions) dispersos.
  • El bloque declare global amplía Express.Request con auth?: AuthUser. El undefined significa «ruta OPEN»: es la forma de que el tipo refleje las tres modalidades de acceso.

Se conecta con

  • Entrada: el middleware authenticate (ISS-13) escribe req.auth.
  • Salida: los controllers que necesitan saber quién llama (/api/sesion/perfil, ISS-15).

Archivo: src/shared/http/error-response.ts

Propósito

Ser el único punto del proyecto donde se decide cómo un error se convierte en una respuesta HTTP.

Explicación

  • sendError distingue dos casos: si el error es un AppError, responde con su statusCode y su message; cualquier otra cosa se convierte en 500 con un detail de texto.
  • Se extrae del BaseController porque los middlewares authenticate/authorize también fallan antes de llegar a un controller y necesitan exactamente el mismo mapeo. Un solo punto = comportamiento consistente.
  • El stack nunca se envía en el cuerpo: eso sería una fuga de información.

Se conecta con

  • Entrada: BaseController.handleError (parcheado) y los middlewares de acceso (ISS-13).
  • Salida: AppError (de shared/errors/app-error.ts).

Archivo: src/shared/http/base-controller.ts (parche)

Propósito

Mantener la regla transversal de Fase I («todo handler se envuelve en this.run(res, …)»), ahora delegando el mapeo de errores en sendError.

Explicación

  • La única línea que cambia en handleError es la que ahora llama a sendError(res, error). El resto del archivo (run, paramId) sigue igual.
  • El parche es quirúrgico: no se reescribe el controller base, se reutiliza. Justo lo que anunciaba la cabecera del ISS: los archivos de Fase I «se extienden, no se reescriben».

Se conecta con

  • Entrada: los 7 métodos de cada controller de todo el proyecto.
  • Salida: error-response.ts.

Archivo: src/shared/http/swagger-security.ts

Propósito

Evitar que el esquema bearerAuth y las respuestas 401/403 se repitan en cada módulo de Swagger.

Explicación

  • bearerSecurityScheme define el esquema bearerAuth (Authorization: Bearer <token>), que Swagger UI usará para el botón Authorize.
  • openSecurity, bearerSecurity, unauthorizedResponse, forbiddenResponse, invalidIdResponse y notFoundResponse son piezas reutilizables por $ref.
  • La correspondencia con las tres modalidades queda documentada en el propio archivo: OPEN → openSecurity; JWT → bearerSecurity; RBAC → bearerSecurity + 401 y 403.

Se conecta con

  • Entrada: los módulos *.swagger.ts de los 7 features de auth y los 5 de business (ISS-10 en adelante).
  • Salida: el ensamblaje de OpenAPI en src/swagger/index.ts.

Archivo: src/features/auth/users/user.model.ts (y los otros cinco modelos)

Propósito

Definir la tabla users y, sobre todo, impedir por construcción que una contraseña se guarde en claro.

Explicación

  • El hash se calcula en hooks (beforeCreate, beforeUpdate, beforeBulkCreate). Cualquier ruta de escritura (create, update, bulkCreate del seeder) pasa por ellos: no hay forma de olvidarse. El algoritmo y el coste viven en password.ts.
  • beforeValidate normaliza username y email a minúsculas y sin espacios antes de validar, para que len/isEmail juzguen el valor definitivo y el login (que compara por igualdad) sea predecible.
  • password se declara STRING(255): el hash bcrypt ocupa 60, y 255 deja margen a algoritmos futuros.
  • La columna status es ENUM('active','inactive') con default inactive (fail-safe), igual que en Fase I.
  • Los otros cinco modelos (Role, Resource, RoleUser, ResourceRole, RefreshToken) siguen el mismo patrón. Dos detalles a retener:
  • Resource normaliza method y path en beforeValidate y tiene UK compuesta (method, path).
  • RefreshToken es la única tabla cuyo status por defecto es active (un token recién emitido nace vigente).

Se conecta con

  • Entrada: sequelize (de database/db.ts) y password.ts (el modelo User).
  • Salida: el repository de cada feature (ISS-10 en adelante) y rbac.associations.ts.

Archivo: src/features/auth/rbac.associations.ts

Propósito

Declarar en un solo archivo el grafo completo de relaciones, para que la cadena de autorización se pueda leer entera.

Explicación

  • Se declara después de los modelos porque los referencia (nunca al revés).
  • El grafo: User N:M Role mediante RoleUser; Role N:M Resource mediante ResourceRole; User 1:N RefreshToken.
  • Los alias (as: "role", as: "resource", as: "resource_roles", …) son los que usarán los include de los repositories RBAC (ISS-12/ISS-13). Cambiar un alias obliga a revisar esas consultas: por eso están todos juntos.

Se conecta con

  • Entrada: los seis modelos.
  • Salida: config/index.ts y seeders/index.ts, que lo importan después de los modelos.

Archivo: src/config/index.ts y src/database/seeders/index.ts (parches)

Propósito

Hacer que Sequelize conozca los seis modelos y sus asociaciones al arrancar la app y al ejecutar los seeders.

Explicación

  • Ambos archivos reciben el mismo bloque de import: primero los seis *.model.ts, después rbac.associations.ts. El orden importa: Sequelize solo conoce las asociaciones que se han ejecutado.
  • config/index.ts también registra las rutas de Fase II (sessionRoutes, usersRoutes, …). En ISS-09 esas rutas aún no existen; el ISS muestra el bloque completo porque documenta el destino final y 18-cierre-auth.md lo detalla.
  • seeders/index.ts recibe el mismo bloque para que npm run db:seed pueda hacer sync de las seis tablas.

Se conecta con

  • Entrada: sequelize.sync(...) en dbConnection (config) y en runAllSeeders (seeders).
  • Salida: la BD, con las seis tablas creadas.

Comandos explicados

Ningún comando va sin explicación. Usamos el esquema obligatorio del reglamento.

npm install jsonwebtoken@^9.0.3

COMANDO
   ↓
npm install jsonwebtoken@^9.0.3
   ↓
QUÉ HACE
   Instala la librería de firma/verificación de JWT (versión 9.0.3 o superior
   compatible dentro de la rama 9).
   ↓
POR QUÉ SE NECESITA
   Es la dependencia que usan jwt.ts (firma/verificación HS256) y, más adelante,
   el service de sesión. Sin ella el proyecto no compila.
   ↓
QUÉ CREA O MODIFICA
   package.json (dependencies) y package-lock.json; node_modules/jsonwebtoken.
   ↓
RESULTADO ESPERADO
   Árbol de dependencias resuelto sin errores de peer deps.
   ↓
CÓMO VERIFICARLO
   Revisar package.json: debe aparecer jsonwebtoken. O `node -e
   "require('jsonwebtoken')"` sin error.

npm install -D @types/jsonwebtoken@^9.0.10

COMANDO
   ↓
npm install -D @types/jsonwebtoken@^9.0.10
   ↓
QUÉ HACE
   Instala los tipos de TypeScript de jsonwebtoken como dependencia de desarrollo.
   ↓
POR QUÉ SE NECESITA
   El proyecto es TypeScript; sin los tipos, `import jwt from "jsonwebtoken"`
   carece de firmas y `npx tsc --noEmit` fallaría.
   ↓
QUÉ CREA O MODIFICA
   package.json (devDependencies) y package-lock.json.
   ↓
RESULTADO ESPERADO
   Tipos disponibles; los imports de jwt.ts resuelven.
   ↓
CÓMO VERIFICARLO
   `npx tsc --noEmit` no se queja de jsonwebtoken.

Ampliar .env con las tres variables nuevas

COMANDO
   ↓
cat >> .env << 'EOF'  (bloque con JWT_SECRET, JWT_ACCESS_TTL, JWT_REFRESH_TTL_DAYS)
   ↓
QUÉ HACE
   AÑADE al final del .env existente (no lo reemplaza) las tres variables de
   seguridad de Fase II.
   ↓
POR QUÉ SE NECESITA
   jwt.ts exige JWT_SECRET (mínimo 32 caracteres). JWT_ACCESS_TTL (segundos)
   y JWT_REFRESH_TTL_DAYS (días) fijan las vidas útiles de los tokens.
   ↓
QUÉ CREA O MODIFICA
   .env (parche). No toca las variables de Fase I.
   ↓
RESULTADO ESPERADO
   Tres líneas nuevas al final del .env con sus comentarios.
   ↓
CÓMO VERIFICARLO
   `grep JWT .env` muestra JWT_SECRET, JWT_ACCESS_TTL y JWT_REFRESH_TTL_DAYS.

Las dos unidades no son un descuido: JWT_ACCESS_TTL va en segundos (900 = 15 min) y JWT_REFRESH_TTL_DAYS en días. El service las convierte a milisegundos al persistir expires_at (ISS-14/ISS-15).

npx tsc --noEmit

COMANDO
   ↓
npx tsc --noEmit
   ↓
QUÉ HACE
   Comprueba los tipos de todo el proyecto sin generar archivos de salida.
   ↓
POR QUÉ SE NECESITA
   Es la verificación principal del ISS: detecta imports rotos, tipos mal
   declarados y el uso incorrecto de Request.auth.
   ↓
QUÉ CREA O MODIFICA
   Nada (no emite).
   ↓
RESULTADO ESPERADO
   Salida vacía y código de salida 0.
   ↓
CÓMO VERIFICARLO
   El propio comando: si imprime errores, el ISS no está cerrado.

npm run db:seed

COMANDO
   ↓
npm run db:seed
   ↓
QUÉ HACE
   Ejecuta el SeedersRunner: conecta, hace `sync({ alter: true })` y siembra.
   ↓
POR QUÉ SE NECESITA
   Es lo que crea físicamente las seis tablas (el `sync` las deriva de los
   modelos). En ISS-09 no siembra datos de auth todavía; solo prepara el esquema.
   ↓
QUÉ CREA O MODIFICA
   Las tablas users, roles, resources, role_users, resource_roles y
   refresh_tokens (con sus UK e índices).
   ↓
RESULTADO ESPERADO
   Mensaje de sincronización y ejecución de seeders sin error.
   ↓
CÓMO VERIFICARLO
   `SHOW TABLES LIKE '%role%'` y `SHOW TABLES LIKE 'users';` en MySQL.

npm run dev

COMANDO
   ↓
npm run dev
   ↓
QUÉ HACE
   Arranca el servidor Express en modo desarrollo (con recarga).
   ↓
POR QUÉ SE NECESITA
   Comprueba que el cableado de modelos no rompe el arranque: primero conecta y
   hace sync, después escucha en el puerto.
   ↓
QUÉ CREA O MODIFICA
   Nada en disco; abre el puerto (por defecto 4000).
   ↓
RESULTADO ESPERADO
   Mensajes de conexión y de base de datos sincronizada, y "Servidor ejecutándose".
   ↓
CÓMO VERIFICARLO
   Que el log muestre el arranque sin `process.exit(1)` por fallo de BD.

Consulta opcional de tablas

COMANDO
   ↓
mysql -u admin -p tecnogua -e "SHOW TABLES LIKE '%role%'; SHOW TABLES LIKE 'users';"
   ↓
QUÉ HACE
   Lista las tablas cuyo nombre contiene "role" y la tabla users.
   ↓
POR QUÉ SE NECESITA
   Verificación visual de que el `sync` creó las seis tablas.
   ↓
QUÉ CREA O MODIFICA
   Nada (consulta de solo lectura).
   ↓
RESULTADO ESPERADO
   roles, role_users, resource_roles (y users).
   ↓
CÓMO VERIFICARLO
   Las tres tablas con "role" y users aparecen en la salida.

Pregunta que responde: ¿qué hace cada comando del ISS y cómo sé que hizo lo que debía?

Flujos

Flujo 1 — Arranque de la app con los modelos cableados

sequenceDiagram
    participant E as server.ts
    participant A as App config
    participant D as dbConnection
    participant S as Sequelize sync
    participant P as Puerto 4000
    E->>A: new App().listen()
    A->>D: conectar y sincronizar
    D->>S: sync alter true
    S-->>D: tablas listas
    D-->>A: conexion ok
    A->>P: listen
    P-->>E: servidor ejecutandose

Pregunta que responde: ¿en qué orden arranca el servidor y por qué la BD va primero?

La BD va primero por una razón concreta: si el puerto se abre antes de terminar sync({ alter: true }), las sentencias DDL (ALTER TABLE, DROP/ADD FOREIGN KEY) compiten con las peticiones que ya están entrando y provocan deadlocks y errores de FK intermitentes.

Flujo 2 — Qué hace el access token, de punta a punta

sequenceDiagram
    participant C as Cliente
    participant S as Service de sesion
    participant J as jwt.ts
    participant M as authenticate
    C->>S: login credenciales
    S->>J: signAccessToken user
    J-->>S: token y expiresIn
    S-->>C: access_token
    C->>M: Authorization Bearer token
    M->>J: verifyAccessToken token
    J->>J: firma HS256 mas iss mas aud mas exp
    J->>J: sub entero positivo y jti presente
    J-->>M: payload
    M-->>C: identidad en req.auth

Pregunta que responde: ¿cómo pasa un token de ser «emitido» a ser «aceptado» en una petición posterior?

Fíjate en que la verificación no toca la BD: eso es lo que hace el access token stateless. La BD solo interviene (en ISS-13) para revalidar que el usuario sigue activo.

Flujo 3 — Cómo decide el matcher RBAC

sequenceDiagram
    participant R as Peticion real
    participant N as normalizePath
    participant P as pathMatches
    participant G as isOperationGranted
    R->>N: GET /api/productos/42
    N-->>P: /api/productos/42 normalizado
    P->>P: comparar contra el patron del recurso
    P-->>G: mismo numero de segmentos
    G-->>R: true si hay concesion, false si no

Pregunta que responde: ¿cómo se traduce una URL concreta a una concesión almacenada con parámetros?

Flujo 4 — El grafo RBAC (tablas y cardinalidades)

erDiagram
    USERS ||--o{ ROLE_USERS : "tiene asignaciones"
    ROLES ||--o{ ROLE_USERS : "agrupa"
    ROLES ||--o{ RESOURCE_ROLES : "concede"
    RESOURCES ||--o{ RESOURCE_ROLES : "es concedido"
    USERS ||--o{ REFRESH_TOKENS : "abre sesiones"

Pregunta que responde: ¿cómo se relacionan las seis tablas entre sí?

Flujo 5 — Vida de un access token

stateDiagram-v2
    [*] --> Emitido
    Emitido --> Valido
    Valido --> Expirado : pasa el exp
    Valido --> Rechazado : firma o iss o aud mal
    Emitido --> Rechazado : sub o jti ausentes
    Expirado --> [*]
    Rechazado --> [*]

Pregunta que responde: ¿qué estados atraviesa un token y en qué casos se rechaza?

Recorrido del ISS, paso a paso

A partir de aquí viene el contenido técnico completo del ISS, verbatim: sus 10 apartados (14.1 … 14.10), sus bloques de código, sus criterios de aceptación y su DoD. Se reproduce sin resumir y sin reformatear; solo se degradan los encabezados un nivel para que aniden bajo esta sección y se reescriben los enlaces relativos a ../manual/ para que abran bien desde docs/aprendizaje/. Si vas a copiar un bloque, cópialo de aquí: es la fuente autoritativa.

Fase II: Auth con RBAC — ISS-09 — Base de seguridad compartida y modelos Auth

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. - Fase I (Business) ya construida: 5 features, 5 tablas y Swagger, sin autenticación. Se cierra en 10-cierre-business.md. - Esta Fase II añade Auth con RBAC y convive con lo anterior: las rutas de negocio pasan de SIN AUTH a JWT + RBAC. - 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 §12–§22 — tablas, índices, CHECK, consulta de autorización efectiva, ciclo de vida del refresh token y las tres formas de acceder a la BD. - Capas, convenciones y reglas transversales: 00-contexto.md.

Este ISS
Título Base de seguridad compartida y modelos Auth
Feature / tablas src/shared/auth/, src/shared/http/ y los 6 modelos de features/auth/
API — (infraestructura: aún no expone endpoints)
Depende de Cierre de Fase I — Estructura final y DoD
Habilita ISS-10 — Feature Users

Contenido de este ISS

  • 14.1 Dependencias y variables de entorno
  • 14.2 password.ts (bcrypt 12 rondas + SHA-256 + token opaco)
  • 14.3 jwt.ts (firma/verificación HS256 con iss/aud/exp/jti)
  • 14.4 resource-match.ts (normalizador y matcher de rutas parametrizadas)
  • 14.5 auth-user.ts (extensión de Request + requireAuthUser)
  • 14.6 error-response.ts (mapper único error → HTTP) y PARCHE de BaseController
  • 14.7 swagger-security.ts (esquema bearerAuth + respuestas 401/403)
  • 14.8 Los seis modelos Sequelize
  • 14.9 rbac.associations.ts (grafo de relaciones)
  • 14.10 Cableado de modelos en config y seeders

Objetivo: dejar listas las primitivas de seguridad (hash de contraseña, hashes de tokens opacos, firma y verificación de JWT, matcher de rutas) y las seis tablas del modelo RBAC con sus asociaciones, de modo que los ISS posteriores solo construyan capas sobre esta base.

Bloqueado por: Fase I cerrada (el App, Routes, SeedersRunner y Swagger ya existen y se extienden, no se reescriben).

Principios que fija este ISS

1. No existe la entidad Permission. La autorización se modela como un grafo:

User
 │  N:M  (RoleUser)
 ↓
Role
 │  N:M  (ResourceRole)
 ↓
Resource

Por tanto:

Role + Resource = permiso concedido

Ejemplo: el rol SELLER concede el recurso POST /api/ventas; eso es el permiso «SELLER puede crear ventas». No hay una fila «permiso» intermedia que haya que mantener sincronizada.

2. Las tres modalidades de acceso (se decidirán por ruta, no globalmente):

Modalidad Qué exige Middleware Ejemplos
OPEN nada — POST /api/sesion/login, /refresh, /logout, /api/docs
JWT access token válido authenticate GET /api/sesion/perfil, /api/permisos, /api/sesiones/*
JWT + RBAC token válido y concesión activa de (method, path) authenticate + authorize todo el CRUD de negocio y de administración

3. status es 'active' | 'inactive' en las seis tablas, igual que en Fase I. Un registro inactive es invisible para la API y, en RBAC, no autoriza.

4. deny by default: sin concesión explícita, la respuesta es 403. No existe lista de excepciones ni «rol comodín».

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

  • [ ] 14.1 .env define JWT_SECRET, JWT_ACCESS_TTL y JWT_REFRESH_TTL_DAYS; jsonwebtoken y @types/jsonwebtoken instalados
  • [ ] 14.2 src/shared/auth/password.ts con hashPassword, verifyPassword, sha256Hex, generateOpaqueToken
  • [ ] 14.3 src/shared/auth/jwt.ts firma y verifica con HS256, issuer, audience, expiresIn y jti
  • [ ] 14.4 src/shared/auth/resource-match.ts casa /api/productos/42 con el patrón /api/productos/:id
  • [ ] 14.5 src/shared/auth/auth-user.ts declara Request.auth y exporta requireAuthUser
  • [ ] 14.6 sendError centraliza error → HTTP; BaseController lo reutiliza
  • [ ] 14.7 bearerSecurityScheme, unauthorizedResponse y forbiddenResponse exportados
  • [ ] 14.8 los 6 modelos (User, Role, Resource, RoleUser, ResourceRole, RefreshToken) con sus UK e índices
  • [ ] 14.9 rbac.associations.ts declara el grafo completo (User↔Role, Role↔Resource, User→RefreshToken)
  • [ ] 14.10 config/index.ts y seeders/index.ts importan los modelos y las asociaciones, en ese orden
  • [ ] npx tsc --noEmit OK

14.1 Dependencias y variables de entorno

# Paquetes (una vez). bcryptjs ya venía de Fase I.
npm install jsonwebtoken@^9.0.3
npm install -D @types/jsonwebtoken@^9.0.10

Variables nuevas del .env (PARCHE: se añaden al final, no reemplazan las de Fase I):

cat >> .env << 'EOF'
# ─────────────────────────────────────────────────────────────
# Fase II — Seguridad (JWT + RBAC)
# ─────────────────────────────────────────────────────────────
# Secreto de firma del access token (HMAC SHA-256). Mínimo 32 caracteres.
# En producción: generar con `openssl rand -base64 48` y NO versionarlo.
JWT_SECRET=storelab-lab-secret-change-me-0123456789abcdef
# Vida útil del access token en segundos (900 = 15 min).
JWT_ACCESS_TTL=900
# Vida útil del refresh token en días.
JWT_REFRESH_TTL_DAYS=7
EOF

JWT_ACCESS_TTL se expresa en segundos y JWT_REFRESH_TTL_DAYS en días: son unidades distintas a propósito (el access token es de minutos; el refresh, de días). El service las convierte a milisegundos al persistir expires_at.

14.2 password.ts — hash de contraseña y hashes de tokens

Tres responsabilidades, todas de la capa shared (no son propias de un feature):

  • hashPassword / verifyPassword: bcrypt con 12 rondas (coste alto, deliberado).
  • sha256Hex: para los refresh tokens, que no se guardan en claro sino como hash.
  • generateOpaqueToken: crypto.randomBytes(32).toString("base64url") → token opaco (no JWT).
: > src/shared/auth/password.ts
cat >> src/shared/auth/password.ts << 'EOF'
import { hash, compare } from "bcryptjs";

/**
 * Derivación y verificación de contraseñas (bcrypt).
 *
 * Se centraliza aquí porque lo usan tres sitios distintos y **debe** usar los
 * mismos parámetros en los tres:
 *  - el hook `beforeCreate/beforeUpdate` del modelo `User` (hash al persistir);
 *  - el service de usuarios al cambiar la contraseña;
 *  - el login, que compara la credencial en memoria (nunca la devuelve).
 *
 * Coste 12 rondas: el valor de referencia del diseño de la base de datos
 * (`docs/bd-storelab.md` §14.1). Es un compromiso entre coste de CPU del servidor
 * y coste de fuerza bruta para un atacante que obtuviera el hash.
 */
const SALT_ROUNDS = 12;

/** Devuelve el hash bcrypt de una contraseña en claro. */
export async function hashPassword(plain: string): Promise<string> {
  return hash(plain, SALT_ROUNDS);
}

/** `true` si la contraseña en claro corresponde al hash almacenado. */
export async function comparePassword(plain: string, passwordHash: string): Promise<boolean> {
  return compare(plain, passwordHash);
}

/**
 * Hash determinista (SHA-256, hex) para credenciales de **alta entropía**.
 *
 * Se usa con los refresh tokens, no con contraseñas: un token aleatorio de 64
 * bytes no es adivinable, así que no necesita un algoritmo lento; basta con
 * impedir que el valor en claro quede en la base de datos. Esto permite, además,
 * buscar por índice único (`token_hash`) en O(1).
 */
import { createHash, randomBytes } from "node:crypto";

export function sha256Hex(value: string): string {
  return createHash("sha256").update(value).digest("hex");
}

/** Genera un token opaco no adivinable (URL-safe, 64 bytes ≈ 86 caracteres). */
export function generateOpaqueToken(): string {
  return randomBytes(64).toString("base64url");
}
EOF

14.3 jwt.ts — firma y verificación del access token

¿Por qué JWT aquí y token opaco para el refresh? El access token viaja en cada petición y debe validarse sin tocar la BD (Stateless): JWT firmado. El refresh token, en cambio, debe poder revocarse; por eso es opaco y se persiste (hasheado) en refresh_tokens.

: > src/shared/auth/jwt.ts
cat >> src/shared/auth/jwt.ts << 'EOF'
import jwt, { JwtPayload } from "jsonwebtoken";
import { randomUUID } from "node:crypto";
import { AppError } from "../errors/app-error";

/**
 * Emisión y verificación del **access token** (JWT firmado, HS256).
 *
 * Referencias (fuentes oficiales):
 *  - RFC 7519 — JSON Web Token (`sub`, `iss`, `aud`, `exp`, `iat`, `jti`).
 *  - RFC 8725 §3.1 — *Perform Algorithm Verification*: el algoritmo se fija en el
 *    código (lista permitida), nunca se toma del encabezado `alg` del token.
 *  - RFC 8725 §3.8/§3.9 — validar `iss` (emisor) y `aud` (audiencia).
 *  - RFC 6750 — el token viaja en `Authorization: Bearer <token>`.
 *
 * El access token es **autocontenido y no se persiste**: se valida con la firma.
 * La base de datos solo interviene para revalidar que el usuario sigue activo
 * (ver `authenticate`), y para los refresh tokens.
 */

const ALGORITHM = "HS256";

/** Emisor/audiencia del sistema. Sirven para rechazar tokens de otro servicio. */
export const TOKEN_ISSUER = "app-storelab-express";
export const TOKEN_AUDIENCE = "app-storelab-api";

/** Vida útil del access token. Corta por diseño (Owasp/OAuth2: token de vida corta). */
export const ACCESS_TOKEN_TTL_SECONDS = Number(process.env.JWT_ACCESS_TTL ?? 900); // 15 min

export interface AccessTokenPayload extends JwtPayload {
  sub: string;
  username: string;
  jti: string;
}

function getSecret(): string {
  const secret = process.env.JWT_SECRET;
  if (!secret || secret.length < 32) {
    throw new AppError(
      500,
      "JWT_SECRET no configurado (mínimo 32 caracteres). Ver .env"
    );
  }
  return secret;
}

/** Firma un access token para un usuario. */
export function signAccessToken(user: { id: number; username: string }): {
  token: string;
  expiresIn: number;
} {
  const token = jwt.sign(
    { username: user.username },
    getSecret(),
    {
      algorithm: ALGORITHM,
      subject: String(user.id),
      issuer: TOKEN_ISSUER,
      audience: TOKEN_AUDIENCE,
      expiresIn: ACCESS_TOKEN_TTL_SECONDS,
      jwtid: randomUUID(),
    }
  );
  return { token, expiresIn: ACCESS_TOKEN_TTL_SECONDS };
}

/**
 * Verifica firma y *claims* y devuelve el payload.
 *
 * Se pasan las opciones explícitas (no se confía en el token): `algorithms`,
 * `issuer` y `audience`; y después se comprueban a mano `sub` y `jti`.
 *
 * Ojo: `jsonwebtoken` **no** tiene opción `require` (es de `jose`); pasarla no
 * valida nada. Por eso los claims obligatorios se verifican explícitamente.
 * Cualquier fallo se traduce a `AppError(401)` para que el middleware responda
 * **no autenticado**.
 */
export function verifyAccessToken(token: string): AccessTokenPayload {
  let payload: JwtPayload;
  try {
    payload = jwt.verify(token, getSecret(), {
      algorithms: [ALGORITHM],
      issuer: TOKEN_ISSUER,
      audience: TOKEN_AUDIENCE,
      // Tolerancia de reloj: evita 401 espurios entre máquinas desincronizadas.
      clockTolerance: 5,
    }) as JwtPayload;
  } catch {
    throw new AppError(401, "Invalid or expired access token");
  }

  // Los claims obligatorios se comprueban AQUÍ, no en `jwt.verify`.
  //
  // `jsonwebtoken` **no** admite la opción `require` (esa opción es de `jose`):
  // pasarla no valida nada. `iss`, `aud` y `exp` sí los exige `jwt.verify` con
  // las opciones de arriba; `sub` y `jti` hay que verificarle explícitamente.
  //
  //  - sin `sub` no hay identidad -> no se puede autenticar;
  //  - `sub` debe ser un entero positivo: un valor no numérico llegaría al
  //    repositorio como `NaN` y provocaría un 500 en vez de un 401;
  //  - sin `jti` se pierde la trazabilidad del token (RFC 8725).
  if (
    typeof payload.sub !== "string" ||
    !/^[1-9]\d*$/.test(payload.sub) ||
    typeof payload.jti !== "string" ||
    payload.jti.length === 0
  ) {
    throw new AppError(401, "Invalid or expired access token");
  }

  return payload as AccessTokenPayload;
}

/** Extrae el token de `Authorization: Bearer <token>` (RFC 6750). */
export function extractBearerToken(header: string | undefined): string | null {
  if (!header) return null;
  const [scheme, value] = header.split(" ");
  if (!scheme || !value || scheme.toLowerCase() !== "bearer") return null;
  return value;
}
EOF

Puntos de seguridad (alineados con RFC 8725, JSON Web Token Best Current Practices):

Práctica Cómo se aplica
Fijar el algoritmo algorithm: "HS256" explícito al firmar y al verificar (algorithms: ["HS256"]) → evita alg: none y confusión de algoritmos
Emisor y audiencia issuer: "storelab" y audience: "storelab-api"; se exigen al verificar
Caducidad expiresIn: JWT_ACCESS_TTL
Identificador único claim jti (permite trazabilidad/correlación)
Carga útil mínima solo sub (id), username y jti; nunca roles ni permisos

Los roles NO viajan en el token. Si viajaran, revocar un permiso no tendría efecto hasta que caducara el token. La autorización se resuelve en cada petición contra la matriz (ISS-13), lo que hace la revocación inmediata (RFC 6749 / OWASP).

14.4 resource-match.ts — casar la petición con el recurso

Un recurso es un par (method, path) con el path en patrón: /api/productos/:id. El middleware authorize recibe la petición real (GET /api/productos/42) y debe encontrar el recurso. Este módulo hace esa traducción:

  • normalizePath: quita barra final, query string y colapsa barras repetidas.
  • buildPathMatcher: convierte /api/productos/:id en una expresión regular anclada.
  • pathsMatch: comprueba un patrón contra un path real.
: > src/shared/auth/resource-match.ts
cat >> src/shared/auth/resource-match.ts << 'EOF'
/**
 * Coincidencia entre la ruta de una petición y un **recurso** almacenado.
 *
 * Un recurso se guarda como patrón (`method` + `path` con parámetros):
 *
 * ```text
 * GET  /api/productos/:id
 * ```
 *
 * Y la petición llega con el valor concreto:
 *
 * ```text
 * GET  /api/productos/42
 * ```
 *
 * Reglas de la comparación (deliberadamente estrictas):
 *  - El verbo HTTP debe coincidir exactamente.
 *  - Un segmento `:param` del patrón casa con **un** segmento cualquiera.
 *  - El resto de segmentos deben ser iguales carácter a carácter.
 *  - El número de segmentos debe coincidir (no hay comodines tipo `*`).
 *
 * Así, `/api/productos/42` **no** casa con `/api/productos` (evita que un permiso
 * de listado autorice una lectura concreta por error) y `/api/productos/42/lotes`
 * tampoco.
 */

/** Normaliza una ruta: sin cadena de consulta, sin barra final, sin duplicar `/`. */
export function normalizePath(path: string): string {
  const withoutQuery = path.split("?")[0].split("#")[0];
  const single = withoutQuery.replace(/\/{2,}/g, "/");
  const trimmed = single.replace(/\/+$/, "");
  return trimmed === "" ? "/" : trimmed;
}

/** `true` si `path` (concreto) casa con `pattern` (con `:param`). */
export function pathMatches(pattern: string, path: string): boolean {
  const patternParts = normalizePath(pattern).split("/");
  const pathParts = normalizePath(path).split("/");

  if (patternParts.length !== pathParts.length) return false;

  for (let i = 0; i < patternParts.length; i++) {
    const p = patternParts[i];
    if (p.startsWith(":")) continue; // parámetro: casa con cualquier segmento
    if (p !== pathParts[i]) return false;
  }
  return true;
}

/**
 * `true` si el conjunto de recursos concedidos cubre la operación solicitada.
 *
 * Es la decisión final del RBAC: se compara el par `(method, path)` de la
 * petición contra las concesiones del usuario. **Deny by default**: si ninguna
 * coincide, se devuelve `false`.
 *
 * (Referencia: `docs/bd-storelab.md` §16 — la base de datos es la única fuente
 * de verdad de la matriz de permisos; la coincidencia por patrón se hace aquí.)
 */
export function isOperationGranted(
  granted: ReadonlyArray<{ method: string; path: string }>,
  method: string,
  path: string
): boolean {
  const upper = method.toUpperCase();
  return granted.some(
    (resource) => resource.method.toUpperCase() === upper && pathMatches(resource.path, path)
  );
}
EOF

14.5 auth-user.ts — la identidad en Request

Extiende el tipo Request de Express con auth y expone requireAuthUser, que los controllers JWT usan para leer al usuario sin adivinar si el middleware corrió.

: > src/shared/auth/auth-user.ts
cat >> src/shared/auth/auth-user.ts << 'EOF'
import { Request } from "express";
import { AppError } from "../errors/app-error";

/**
 * Identidad resuelta que los middlewares de acceso dejan en la petición.
 *
 * Se guarda en `req.auth` (ver la ampliación de tipos más abajo) y la consumen:
 *  - los controllers que necesitan saber quién llama (`GET /api/sesion/perfil`);
 *  - `authorize`, para consultar los permisos efectivos del usuario.
 */
export interface AuthUser {
  id: number;
  username: string;
  email?: string;
  /** Token con el que se autenticó (útil para cerrar la sesión actual). */
  tokenId?: string;
}

/**
 * Devuelve la identidad de la petición o falla con 401.
 *
 * Lo usan los controllers de rutas con modalidad JWT (sin `authorize`): allí el
 * middleware ya garantizó que `req.auth` existe, pero el tipo es opcional, así
 * que esta función cierra el caso sin recurrir a `!`.
 */
export function requireAuthUser(req: Request): AuthUser {
  if (!req.auth) {
    throw new AppError(401, "Authentication required");
  }
  return req.auth;
}

declare global {
  // eslint-disable-next-line @typescript-eslint/no-namespace
  namespace Express {
    interface Request {
      /** Identidad resuelta por el middleware `authenticate`. `undefined` = OPEN. */
      auth?: AuthUser;
    }
  }
}

export {};
EOF

14.6 error-response.ts y PARCHE de BaseController

El mapeo error → HTTP deja de vivir solo en el controller: lo necesitan también los middlewares authenticate/authorize. Se extrae a un único punto (sendError) y BaseController lo reutiliza.

: > src/shared/http/error-response.ts
cat >> src/shared/http/error-response.ts << 'EOF'
import { Response } from "express";
import { AppError } from "../errors/app-error";

/**
 * Traduce cualquier error a una respuesta HTTP. **Único punto** del proyecto
 * donde se decide el mapeo error -> status.
 *
 * Lo usan los dos sitios que pueden fallar antes de llegar a un controller:
 *  - `BaseController.handleError` (handlers de los controllers);
 *  - los middlewares de acceso (`authenticate` / `authorize`), que responden
 *    401/403 sin pasar por un controller.
 *
 * Regla: `AppError` -> su `statusCode`; cualquier otra cosa -> **500** (y el
 * detalle solo en el cuerpo, nunca el stack).
 */
export function sendError(res: Response, error: unknown): void {
  if (error instanceof AppError) {
    res.status(error.statusCode).json({ error: error.message });
    return;
  }
  res.status(500).json({ error: "Internal server error", detail: String(error) });
}
EOF

PARCHE en src/shared/http/base-controller.ts: handleError delega en sendError.

: > src/shared/http/base-controller.ts
cat >> src/shared/http/base-controller.ts << 'EOF'
import { Request, Response } from "express";
import { AppError } from "../errors/app-error";
import { sendError } from "./error-response";

/**
 * Base de los controllers HTTP.
 *
 * Aísla las tres responsabilidades puramente HTTP que, si no, se repetirían en
 * los 7 métodos de cada controller:
 *
 *  - `run`:            ejecuta el cuerpo del handler y traduce el error a HTTP.
 *  - `paramId`:        lee y valida el `:id` de la URL.
 *  - `handleError`:    mapea `AppError` a su status y lo demás a 500.
 *
 * La capa de negocio (service) no conoce `req`/`res`.
 */
export abstract class BaseController {
  /**
   * Ejecuta el cuerpo de un handler y centraliza el manejo de errores.
   *
   * Sin este helper, cada uno de los 35 métodos de los controllers tendría su
   * propio `try/catch`. Aquí el `catch` vive una sola vez.
   */
  protected async run(res: Response, work: () => Promise<void>): Promise<void> {
    try {
      await work();
    } catch (error) {
      this.handleError(res, error);
    }
  }

  /**
   * Lee el `:id` de la URL y lo valida como entero positivo.
   *
   * Sin la validación, `GET /api/clientes/abc` llegaría al repository como
   * `Number("abc") === NaN` y devolvería un 404 engañoso en vez de un 400.
   */
  protected paramId(req: Request): number {
    const raw = req.params.id;
    const value = Array.isArray(raw) ? raw[0] : raw;

    if (!value || !/^\d+$/.test(value) || Number(value) < 1) {
      throw new AppError(400, "Invalid id: must be a positive integer");
    }
    return Number(value);
  }

  /**
   * Mapea errores: `AppError` -> su status; cualquier otro -> 500.
   *
   * La traducción vive en `sendError` porque los middlewares de acceso también
   * la necesitan: un único punto decide el mapeo error -> HTTP.
   */
  protected handleError(res: Response, error: unknown): void {
    sendError(res, error);
  }
}
EOF

14.7 swagger-security.ts — seguridad reutilizable para OpenAPI

Export Para qué
bearerSecurityScheme Esquema bearerAuth (Authorization: Bearer <token>, RFC 6750)
unauthorizedResponse Respuesta 401 reutilizable ($ref)
forbiddenResponse Respuesta 403 reutilizable ($ref)
: > src/shared/http/swagger-security.ts
cat >> src/shared/http/swagger-security.ts << 'EOF'
/**
 * Piezas reutilizables de OpenAPI para las **tres modalidades de acceso**.
 *
 * Centralizar aquí el esquema `bearerAuth` y las respuestas 401/403 evita repetir
 * la misma definición en los 7 módulos de Swagger (auth) y en los 5 de business.
 * Al cambiar una descripción, cambia en toda la documentación.
 *
 * Convención de uso en cada operación:
 *
 * | Modalidad | `security` |
 * |---|---|
 * | OPEN  | `openSecurity`  (arreglo vacío: no exige credencial) |
 * | JWT   | `bearerSecurity` |
 * | RBAC  | `bearerSecurity` + respuestas 401 **y** 403 |
 */

/** Esquema de seguridad (RFC 6750: `Authorization: Bearer <token>`). */
export const bearerSecurityScheme = {
  bearerAuth: {
    type: "http",
    scheme: "bearer",
    bearerFormat: "JWT",
    description:
      "Access token JWT obtenido en `POST /api/sesion/login`. Enviar como " +
      "`Authorization: Bearer <access_token>`. Vida útil corta (por defecto 15 min); " +
      "se renueva con `POST /api/sesion/refresh`.",
  },
};

/** `security` de un endpoint OPEN (no exige credencial). */
export const openSecurity: unknown[] = [];

/** `security` de un endpoint JWT o RBAC (exige access token válido). */
export const bearerSecurity = [{ bearerAuth: [] }];

/** Respuesta 401: no hay identidad válida (token ausente, inválido o usuario inactivo). */
export const unauthorizedResponse = {
  description:
    "401 No autenticado — falta el Bearer token, el token es inválido/expiró o el usuario está inactivo",
};

/** Respuesta 403: hay identidad, pero la matriz RBAC no concede `(method, path)`. */
export const forbiddenResponse = {
  description:
    "403 Prohibido — autenticado, pero sin concesión activa para esta operación (deny by default)",
};

/** Respuesta 400 ante un `:id` que no es entero positivo. */
export const invalidIdResponse = {
  description: "400 id inválido (debe ser un entero positivo)",
};

/** Respuesta 404 estándar. */
export const notFoundResponse = {
  description: "404 No encontrado",
};
EOF

14.8 Los seis modelos Sequelize

Entidad Tabla Responsabilidad
User users identidad del usuario (contraseña hasheada)
Role roles agrupación de responsabilidades
RoleUser role_users asignación User ↔ Role (N:M)
Resource resources endpoint/acción protegible, (method, path)
ResourceRole resource_roles el permiso: concesión Role ↔ Resource (N:M)
RefreshToken refresh_tokens sesión renovable y revocable

User:

: > src/features/auth/users/user.model.ts
cat >> src/features/auth/users/user.model.ts << 'EOF'
import { DataTypes, Model } from "sequelize";
import { sequelize } from "../../../database/db";
import { hashPassword } from "../../../shared/auth/password";

/**
 * Modelo `User` (tabla `users`) — la identidad del sistema.
 *
 * Se diferencia de los modelos de business en un punto clave: **`password` nunca
 * se guarda en claro**. El hash se calcula en los hooks, de modo que ningún
 * service, repository o seeder puede olvidarse de hacerlo.
 *
 * El algoritmo y el coste viven en `shared/auth/password.ts` (única fuente), no
 * aquí: si mañana se sube el coste, se cambia en un solo sitio.
 */
export interface UserI {
  id?: number;
  username: string;
  email: string;
  password: string;
  avatar?: string | null;
  status: "active" | "inactive";
  createdAt?: Date;
  updatedAt?: Date;
}

export class User extends Model {
  public id!: number;
  public username!: string;
  public email!: string;
  public password!: string;
  public avatar!: string | null;
  public status!: "active" | "inactive";
  public readonly createdAt!: Date;
  public readonly updatedAt!: Date;
}

User.init(
  {
    username: {
      type: DataTypes.STRING(80),
      allowNull: false,
      // `unique` con nombre explícito -> la BD nombra la restricción `uq_users_username`
      // (misma nomenclatura que el DDL de referencia en docs/bd-storelab.md §14).
      unique: "uq_users_username",
      validate: {
        notEmpty: { msg: "Username cannot be empty" },
        len: { args: [3, 80], msg: "Username must be between 3 and 80 characters" },
      },
    },
    email: {
      type: DataTypes.STRING(150),
      allowNull: false,
      unique: "uq_users_email",
      validate: {
        isEmail: { msg: "Email must be a valid email address" },
      },
    },
    password: {
      // 255: el hash bcrypt ocupa 60 y sobra margen para algoritmos futuros.
      type: DataTypes.STRING(255),
      allowNull: false,
      validate: {
        notEmpty: { msg: "Password cannot be empty" },
      },
    },
    avatar: {
      type: DataTypes.STRING(500),
      allowNull: true,
    },
    status: {
      type: DataTypes.ENUM("active", "inactive"),
      defaultValue: "inactive",
      allowNull: false,
    },
  },
  {
    sequelize,
    modelName: "User",
    tableName: "users",
    timestamps: true,
    hooks: {
      // Los tres hooks que crean/actualizan el hash. Cualquier ruta de escritura
      // (create, update, bulkCreate del seeder) pasa por aquí: no hay forma de
      // persistir una contraseña en claro.
      beforeCreate: async (user: User) => {
        if (user.password) {
          user.password = await hashPassword(user.password);
        }
      },
      beforeUpdate: async (user: User) => {
        if (user.changed("password") && user.password) {
          user.password = await hashPassword(user.password);
        }
      },
      beforeBulkCreate: async (users: User[]) => {
        for (const user of users) {
          if (user.password) {
            user.password = await hashPassword(user.password);
          }
        }
      },
      // Normalización: `username` y `email` siempre en minúsculas y sin espacios.
      // Se hace antes de validar para que el `isEmail`/`len` juzgue el valor final
      // y para que el login (que compara por igualdad) sea predecible.
      beforeValidate: (user: User) => {
        if (user.username) user.username = user.username.trim().toLowerCase();
        if (user.email) user.email = user.email.trim().toLowerCase();
      },
    },
  }
);
EOF

Role:

: > src/features/auth/roles/role.model.ts
cat >> src/features/auth/roles/role.model.ts << 'EOF'
import { DataTypes, Model } from "sequelize";
import { sequelize } from "../../../database/db";

/**
 * Modelo `Role` (tabla `roles`) — agrupador lógico de responsabilidades.
 *
 * Nota de diseño: **el nombre del rol no autoriza nada**. La autorización se
 * decide por las concesiones (`resource_roles`) asociadas al rol. Un rol
 * `ADMIN` sin concesiones activas no habilita ninguna operación.
 */
export interface RoleI {
  id?: number;
  name: string;
  description?: string | null;
  status: "active" | "inactive";
  createdAt?: Date;
  updatedAt?: Date;
}

export class Role extends Model {
  public id!: number;
  public name!: string;
  public description!: string | null;
  public status!: "active" | "inactive";
  public readonly createdAt!: Date;
  public readonly updatedAt!: Date;
}

Role.init(
  {
    name: {
      type: DataTypes.STRING(80),
      allowNull: false,
      unique: "uq_roles_name",
      validate: {
        notEmpty: { msg: "Role name cannot be empty" },
      },
    },
    description: {
      type: DataTypes.STRING(255),
      allowNull: true,
    },
    status: {
      type: DataTypes.ENUM("active", "inactive"),
      defaultValue: "inactive",
      allowNull: false,
    },
  },
  {
    sequelize,
    modelName: "Role",
    tableName: "roles",
    timestamps: true,
    hooks: {
      // El nombre del rol se normaliza a MAYÚSCULAS (ADMIN, SELLER, BUYER):
      // es un identificador funcional, no una etiqueta libre.
      beforeValidate: (role: Role) => {
        if (role.name) role.name = role.name.trim().toUpperCase();
      },
    },
  }
);
EOF

Resource:

: > src/features/auth/resources/resource.model.ts
cat >> src/features/auth/resources/resource.model.ts << 'EOF'
import { DataTypes, Model } from "sequelize";
import { sequelize } from "../../../database/db";
import { normalizePath } from "../../../shared/auth/resource-match";

/**
 * Modelo `Resource` (tabla `resources`) — un punto de acceso protegible.
 *
 * Un recurso **no** es una entidad de negocio: es el par `(method, path)`.
 * `GET /api/productos` y `POST /api/productos` son **dos recursos distintos**.
 *
 * Las rutas se guardan con el patrón, no con el valor concreto:
 * `/api/productos/:id`. Así no se crea una fila por cada identificador y la
 * coincidencia se resuelve por patrón (`shared/auth/resource-match.ts`).
 */
export interface ResourceI {
  id?: number;
  method: string;
  path: string;
  description?: string | null;
  status: "active" | "inactive";
  createdAt?: Date;
  updatedAt?: Date;
}

export class Resource extends Model {
  public id!: number;
  public method!: string;
  public path!: string;
  public description!: string | null;
  public status!: "active" | "inactive";
  public readonly createdAt!: Date;
  public readonly updatedAt!: Date;
}

Resource.init(
  {
    method: {
      type: DataTypes.STRING(10),
      allowNull: false,
      validate: {
        isIn: {
          args: [["GET", "POST", "PUT", "PATCH", "DELETE"]],
          msg: "Method must be one of GET, POST, PUT, PATCH, DELETE",
        },
      },
    },
    path: {
      type: DataTypes.STRING(255),
      allowNull: false,
      validate: {
        notEmpty: { msg: "Path cannot be empty" },
      },
    },
    description: {
      type: DataTypes.STRING(255),
      allowNull: true,
    },
    status: {
      type: DataTypes.ENUM("active", "inactive"),
      defaultValue: "inactive",
      allowNull: false,
    },
  },
  {
    sequelize,
    modelName: "Resource",
    tableName: "resources",
    timestamps: true,
    // Clave única compuesta: el mismo verbo con distinta ruta (o al revés) son
    // recursos distintos, pero la tupla exacta no se repite.
    indexes: [
      {
        name: "uq_resources_method_path",
        unique: true,
        fields: ["method", "path"],
      },
    ],
    hooks: {
      // Normalización: verbo en mayúsculas y ruta sin barra final ni duplicados,
      // para que la comparación por patrón sea determinista.
      beforeValidate: (resource: Resource) => {
        if (resource.method) resource.method = resource.method.trim().toUpperCase();
        if (resource.path) resource.path = normalizePath(resource.path.trim());
      },
    },
  }
);
EOF

RoleUser:

: > src/features/auth/role-users/role-user.model.ts
cat >> src/features/auth/role-users/role-user.model.ts << 'EOF'
import { DataTypes, Model } from "sequelize";
import { sequelize } from "../../../database/db";

/**
 * Modelo `RoleUser` (tabla `role_users`) — asignación N:M `User` ↔ `Role`.
 *
 * Es el **primer eslabón** de la cadena de autorización. Un usuario sin filas
 * activas aquí no tiene ningún permiso granular, aunque tenga roles asignados
 * con estado `inactive`.
 *
 * La restricción única `(user_id, role_id)` impide duplicar la asignación:
 * revocar y volver a conceder se hace cambiando `status`, no insertando filas.
 */
export interface RoleUserI {
  id?: number;
  user_id: number;
  role_id: number;
  status: "active" | "inactive";
  createdAt?: Date;
  updatedAt?: Date;
}

export class RoleUser extends Model {
  public id!: number;
  public user_id!: number;
  public role_id!: number;
  public status!: "active" | "inactive";
  public readonly createdAt!: Date;
  public readonly updatedAt!: Date;
}

RoleUser.init(
  {
    user_id: {
      type: DataTypes.INTEGER,
      allowNull: false,
    },
    role_id: {
      type: DataTypes.INTEGER,
      allowNull: false,
    },
    status: {
      type: DataTypes.ENUM("active", "inactive"),
      defaultValue: "inactive",
      allowNull: false,
    },
  },
  {
    sequelize,
    modelName: "RoleUser",
    tableName: "role_users",
    timestamps: true,
    indexes: [
      { name: "uq_role_users_user_role", unique: true, fields: ["user_id", "role_id"] },
      { name: "ix_role_users_user_id", fields: ["user_id"] },
      { name: "ix_role_users_role_id", fields: ["role_id"] },
    ],
  }
);
EOF

ResourceRole:

: > src/features/auth/resource-roles/resource-role.model.ts
cat >> src/features/auth/resource-roles/resource-role.model.ts << 'EOF'
import { DataTypes, Model } from "sequelize";
import { sequelize } from "../../../database/db";

/**
 * Modelo `ResourceRole` (tabla `resource_roles`) — la **concesión** `Role` ↔ `Resource`.
 *
 * Esta tabla **es el permiso**. No existe una entidad `Permission`: el permiso
 * es la tupla `(rol, recurso)` materializada aquí.
 *
 * - Conceder acceso   -> insertar o reactivar una fila.
 * - Retirar acceso    -> `status = inactive`.
 * - Cambiar la matriz -> no requiere código ni despliegue.
 */
export interface ResourceRoleI {
  id?: number;
  role_id: number;
  resource_id: number;
  status: "active" | "inactive";
  createdAt?: Date;
  updatedAt?: Date;
}

export class ResourceRole extends Model {
  public id!: number;
  public role_id!: number;
  public resource_id!: number;
  public status!: "active" | "inactive";
  public readonly createdAt!: Date;
  public readonly updatedAt!: Date;
}

ResourceRole.init(
  {
    role_id: {
      type: DataTypes.INTEGER,
      allowNull: false,
    },
    resource_id: {
      type: DataTypes.INTEGER,
      allowNull: false,
    },
    status: {
      type: DataTypes.ENUM("active", "inactive"),
      defaultValue: "inactive",
      allowNull: false,
    },
  },
  {
    sequelize,
    modelName: "ResourceRole",
    tableName: "resource_roles",
    timestamps: true,
    indexes: [
      {
        name: "uq_resource_roles_role_resource",
        unique: true,
        fields: ["role_id", "resource_id"],
      },
      { name: "ix_resource_roles_role_id", fields: ["role_id"] },
      { name: "ix_resource_roles_resource_id", fields: ["resource_id"] },
    ],
  }
);
EOF

RefreshToken:

: > src/features/auth/refresh-tokens/refresh-token.model.ts
cat >> src/features/auth/refresh-tokens/refresh-token.model.ts << 'EOF'
import { DataTypes, Model } from "sequelize";
import { sequelize } from "../../../database/db";

/**
 * Modelo `RefreshToken` (tabla `refresh_tokens`) — sesión renovable y revocable.
 *
 * Es el **único** artefacto de sesión que se persiste. El access token (JWT) es
 * autocontenido y no se guarda.
 *
 * Campos de seguridad:
 *  - `token_hash`: solo se almacena el SHA-256 del token opaco. Aunque se
 *    filtrara la tabla, no se puede reconstruir un token utilizable. Permite
 *    buscar por índice único en O(1).
 *  - `family_id`: agrupa todos los tokens derivados de un mismo login por
 *    rotación. Si un token ya rotado se reutiliza, se revoca **toda la familia**
 *    (detección de reutilización, Owasp/OAuth2).
 *  - `expires_at`: vigencia; un token vencido se trata como inválido.
 *  - `device_info`: soporte de auditoría y de listado de sesiones por dispositivo.
 *
 * Desviación deliberada: `status` predetermina **`active`**. Un token recién
 * emitido nace vigente por definición, a diferencia del resto de tablas.
 */
export interface RefreshTokenI {
  id?: number;
  user_id: number;
  token_hash: string;
  family_id: string;
  device_info?: string | null;
  expires_at: Date;
  status: "active" | "inactive";
  createdAt?: Date;
  updatedAt?: Date;
}

export class RefreshToken extends Model {
  public id!: number;
  public user_id!: number;
  public token_hash!: string;
  public family_id!: string;
  public device_info!: string | null;
  public expires_at!: Date;
  public status!: "active" | "inactive";
  public readonly createdAt!: Date;
  public readonly updatedAt!: Date;
}

RefreshToken.init(
  {
    user_id: {
      type: DataTypes.INTEGER,
      allowNull: false,
    },
    token_hash: {
      type: DataTypes.STRING(255),
      allowNull: false,
      unique: "uq_refresh_tokens_token_hash",
    },
    family_id: {
      type: DataTypes.STRING(100),
      allowNull: false,
    },
    device_info: {
      type: DataTypes.STRING(500),
      allowNull: true,
    },
    expires_at: {
      type: DataTypes.DATE,
      allowNull: false,
    },
    status: {
      type: DataTypes.ENUM("active", "inactive"),
      // Única tabla cuyo estado por defecto es `active` (ver doc del modelo).
      defaultValue: "active",
      allowNull: false,
    },
  },
  {
    sequelize,
    modelName: "RefreshToken",
    tableName: "refresh_tokens",
    timestamps: true,
    indexes: [
      { name: "ix_refresh_tokens_family_id", fields: ["family_id"] },
      { name: "ix_refresh_tokens_user_id", fields: ["user_id"] },
    ],
  }
);
EOF

14.9 rbac.associations.ts — el grafo en un solo lugar

Las asociaciones se declaran después de los modelos (referencian a los modelos, no al revés) y en un único archivo para que el grafo se lea entero:

User N:M Role   mediante RoleUser
User 1:N RefreshToken
Role N:M Resource mediante ResourceRole
: > src/features/auth/rbac.associations.ts
cat >> src/features/auth/rbac.associations.ts << 'EOF'
import { User } from "./users/user.model";
import { Role } from "./roles/role.model";
import { Resource } from "./resources/resource.model";
import { RoleUser } from "./role-users/role-user.model";
import { ResourceRole } from "./resource-roles/resource-role.model";
import { RefreshToken } from "./refresh-tokens/refresh-token.model";

/**
 * Asociaciones de las seis entidades de seguridad.
 *
 * Se declaran en un solo archivo (y no dispersas por feature) porque la
 * autorización es una **cadena** que atraviesa cinco tablas; verla junta hace
 * evidente el camino que recorre la consulta de permisos:
 *
 * ```text
 * ResourceRole ──► Role ──► RoleUser ──► (filtro por user_id)
 *        │
 *        └────────► Resource  ──► (method, path)
 * ```
 *
 * Los alias (`as`) son los que usan los `include` de los repositories, así que
 * cambiar un alias aquí obliga a revisar las consultas RBAC.
 */

// --- La concesión conoce su rol y su recurso (los dos extremos del permiso) ---
ResourceRole.belongsTo(Role, { foreignKey: "role_id", as: "role" });
ResourceRole.belongsTo(Resource, { foreignKey: "resource_id", as: "resource" });
Role.hasMany(ResourceRole, { foreignKey: "role_id", as: "resource_roles" });
Resource.hasMany(ResourceRole, { foreignKey: "resource_id", as: "resource_roles" });

// --- La asignación conoce su usuario y su rol (primer eslabón de la cadena) ---
RoleUser.belongsTo(User, { foreignKey: "user_id", as: "user" });
RoleUser.belongsTo(Role, { foreignKey: "role_id", as: "role" });
User.hasMany(RoleUser, { foreignKey: "user_id", as: "role_users" });
Role.hasMany(RoleUser, { foreignKey: "role_id", as: "role_users" });

// --- Las sesiones pertenecen a un usuario ---
RefreshToken.belongsTo(User, { foreignKey: "user_id", as: "user" });
User.hasMany(RefreshToken, { foreignKey: "user_id", as: "refresh_tokens" });
EOF

14.10 Cableado de modelos en config y seeders

PARCHE en src/config/index.ts — los seis modelos, y después las asociaciones:

cat >> src/config/index.ts << 'EOF'
import dotenv from "dotenv";
import express, { Application, ErrorRequestHandler } from "express";
import morgan from "morgan";
var cors = require("cors");
import { sequelize, getDatabaseInfo, testConnection } from "../database/db";
import "../features/business/clients/client.model";
import "../features/business/product-types/product-type.model";
import "../features/business/products/product.model";
import "../features/business/sales/sale.model";
import "../features/business/product-sales/product-sale.model";
import "../features/business/products/products.associations";
import "../features/business/sales/sales.associations";
import "../features/business/product-sales/product-sales.associations";
// Fase II — Auth con RBAC: primero los seis modelos, después las asociaciones
// (las asociaciones referencian los modelos, no al revés).
import "../features/auth/users/user.model";
import "../features/auth/roles/role.model";
import "../features/auth/resources/resource.model";
import "../features/auth/role-users/role-user.model";
import "../features/auth/resource-roles/resource-role.model";
import "../features/auth/refresh-tokens/refresh-token.model";
import "../features/auth/rbac.associations";
import { Routes } from "../routes/index";
import { setupSwagger } from "../swagger/index";

dotenv.config();

export class App {
  public app: Application;
  public routePrv: Routes = new Routes();

  constructor(private port?: number | string) {
    this.app = express();
    this.settings();
    this.middlewares();
    this.routes();
    this.docs();
    this.errorHandling();
  }

  private settings(): void {
    this.app.set('port', this.port || process.env.PORT || 4000);
  }

  private middlewares(): void {
    this.app.use(morgan('dev'));
    this.app.use(cors());
    this.app.use(express.json());
    this.app.use(express.urlencoded({ extended: false }));
  }

  private routes(): void {
    // Fase I — Business (cada operación, modalidad JWT + RBAC)
    this.routePrv.clientsRoutes.routes(this.app);
    this.routePrv.productTypesRoutes.routes(this.app);
    this.routePrv.productsRoutes.routes(this.app);
    this.routePrv.salesRoutes.routes(this.app);
    this.routePrv.productSalesRoutes.routes(this.app);

    // Fase II — Auth con RBAC
    // `sessionRoutes` registra los endpoints OPEN/JWT (login, refresh, logout,
    // perfil, permisos); el resto son modalidad JWT + RBAC.
    this.routePrv.sessionRoutes.routes(this.app);
    this.routePrv.refreshTokensRoutes.routes(this.app);
    this.routePrv.usersRoutes.routes(this.app);
    this.routePrv.rolesRoutes.routes(this.app);
    this.routePrv.resourcesRoutes.routes(this.app);
    this.routePrv.roleUsersRoutes.routes(this.app);
    this.routePrv.resourceRolesRoutes.routes(this.app);
  }

  private docs(): void {
    setupSwagger(this.app);
  }

  /**
   * Errores que ocurren **antes** de llegar a un controller o middleware.
   *
   * El caso típico es un cuerpo JSON malformado: `express.json()` lanza un
   * `SyntaxError` que, sin manejador, cae en el de Express por defecto y responde
   * 400 con un HTML que incluye el **stack trace y rutas absolutas del servidor**
   * (fuga de información). Aquí se traduce a un 400 JSON limpio.
   *
   * Debe registrarse **después** de las rutas: Express reconoce un middleware de
   * error por su aridad de 4 argumentos.
   */
  private errorHandling(): void {
    const bodyErrorHandler: ErrorRequestHandler = (err, _req, res, next) => {
      if (err instanceof SyntaxError && "body" in err) {
        res.status(400).json({ error: "Malformed JSON body" });
        return;
      }
      next(err);
    };
    this.app.use(bodyErrorHandler);
  }

  private async dbConnection(): Promise<void> {
    try {
      const dbInfo = getDatabaseInfo();
      console.log(`🔗 Intentando conectar a: ${dbInfo.engine.toUpperCase()}`);

      const isConnected = await testConnection();
      if (!isConnected) {
        throw new Error(`No se pudo conectar a la base de datos ${dbInfo.engine.toUpperCase()}`);
      }

      // Lab: sync crea/altera tablas desde los modelos (BD limpia → snake_case desde cero).
      const force = process.env.DB_SYNC_FORCE === "true";
      const isMysql =
        sequelize.getDialect() === "mysql" || sequelize.getDialect() === "mariadb";

      if (isMysql) {
        await sequelize.query("SET FOREIGN_KEY_CHECKS = 0");
      }
      try {
        await sequelize.sync({ force, alter: !force });
      } finally {
        if (isMysql) {
          await sequelize.query("SET FOREIGN_KEY_CHECKS = 1");
        }
      }

      console.log(
        force
          ? "📦 Base de datos recreada (DB_SYNC_FORCE=true)"
          : "📦 Base de datos sincronizada exitosamente"
      );
    } catch (error) {
      console.error("❌ Error al conectar con la base de datos:", error);
      process.exit(1);
    }
  }

  async listen() {
    // Orden de arranque: primero la BD (conexión + `sync`), después abrir el puerto.
    // Si se abre el puerto antes de terminar `sync({ alter: true })`, las sentencias
    // DDL (ALTER TABLE, DROP/ADD FOREIGN KEY) compiten con las peticiones que ya
    // están entrando y provocan deadlocks y errores de FK intermitentes.
    await this.dbConnection();
    await this.app.listen(this.app.get('port'));
    console.log(`🚀 Servidor ejecutándose en puerto ${this.app.get('port')}`);
  }
}
EOF

PARCHE en src/database/seeders/index.ts — mismo bloque de imports (los seeders de auth se añaden en sus ISS). El orden importa: Sequelize solo conoce las asociaciones que se han declarado.

El detalle de estos dos archivos completos, ya con las rutas y los seeders de todos los ISS, está en 18-cierre-auth.md.

Verificación

npx tsc --noEmit
npm run db:seed     # sync({ alter: true }) crea las 6 tablas vacías
npm run dev         # el servidor debe arrancar
# Las 6 tablas existen
mysql -u admin -p tecnogua -e "SHOW TABLES LIKE '%role%'; SHOW TABLES LIKE 'users';"

DoD del ISS-09

  • [ ] Todos los criterios de aceptación (14.1 … 14.10) cumplidos
  • [ ] npx tsc --noEmit sin errores
  • [ ] npm run db:seed crea users, roles, resources, role_users, resource_roles, refresh_tokens con sus UK
  • [ ] npm run dev arranca el servidor
  • [ ] Sin endpoints nuevos todavía: la API de Fase I sigue funcionando SIN AUTH (la protección llega en ISS-13)

Diagnóstico

Síntoma Causa probable Solución
tsc se queja de jsonwebtoken sin tipos Faltó instalar @types/jsonwebtoken npm install -D @types/jsonwebtoken@^9.0.10
AppError: JWT_SECRET no configurado Falta la variable o tiene menos de 32 caracteres Añadir JWT_SECRET al .env (mín. 32) y reiniciar
db:seed no crea las tablas de auth Los modelos no se importaron en seeders/index.ts o se importaron mal de orden Importar primero los 6 modelos y después rbac.associations
Error de asociación «X is not associated to Y» Se importó rbac.associations antes que algún modelo Reordenar los imports; las asociaciones referencian modelos
/api/usuarios responde 404 Correcto en ISS-09: el feature aún no existe Esperar a ISS-10; no es un fallo
sync falla al crear FKs Orden de tablas / FOREIGN_KEY_CHECKS El runner ya las desactiva en MySQL; revisar que el dialecto se detecte
Un GET de negocio pasa sin token Correcto: la protección llega en ISS-13 No «arreglarlo» aquí; sería adelantar ISS-13
Token válido pero rechazado por iss/aud Se firmó con otro emisor/audiencia Usar las constantes TOKEN_ISSUER/TOKEN_AUDIENCE

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

Conexión con el resto del curso

               ISS-00 … ISS-08 (Fase I · Business)
                          │
                          │  entrega: App, Routes, SeedersRunner, Swagger,
                          │           shared/{errors,http,database}
                          ▼
                    ┌─────────────┐
                    │   ISS-09    │  primitivas + 6 modelos + asociaciones
                    └─────────────┘
                          │
        ┌─────────────────┼─────────────────────────────┐
        ▼                 ▼                             ▼
   ISS-10 users      ISS-11 roles/resources      ISS-12 pivotes + permisos
        │                 │                             │
        └─────────────────┴──────────────┬──────────────┘
                                         ▼
                                    ISS-13 access
                            (authenticate + authorize)
                                         │
                                         ▼
                              ISS-14 refresh · ISS-15 sesión
  • Piezas que este ISS reutiliza de Fase I: el patrón de features, sequelize (database/db.ts), AppError, BaseController y el SeedersRunner.
  • Piezas que este ISS entrega: todo lo que los ISS de auth posteriores dan por hecho: el hash, el JWT, el matcher, sendError, req.auth y las seis tablas.
  • Qué NO toca: ninguna ruta de negocio. Las cinco features de Fase I quedan exactamente como estaban.

Pregunta que responde: ¿de dónde vengo y hacia dónde me lleva este ISS dentro del curso?

Glosario

Término Significado
JWT JSON Web Token: cadena header.payload.signature autocontenida y firmada.
HS256 Algoritmo de firma HMAC con SHA-256; usa un secreto compartido.
Claim Cada afirmación del payload de un JWT (sub, iss, aud, exp, iat, jti).
sub subject: a quién representa el token (el id del usuario).
jti JWT ID: identificador único del token.
iss / aud Emisor / audiencia; rechazan tokens de otro servicio o para otra API.
exp / iat Caducidad / emisión.
bcrypt Algoritmo de hash lento y con salt incorporado, pensado para contraseñas.
Salt Valor aleatorio por hash que hace que dos contraseñas iguales den hashes distintos; bcrypt lo genera y lo guarda dentro del propio hash.
Rondas (coste) Cuántas veces se aplica el algoritmo; 12 es el valor de referencia del proyecto.
SHA-256 Hash rápido y determinista, apto para valores de alta entropía (tokens), no para contraseñas.
Token opaco Token sin estructura interna que hay que validar contra la BD (el refresh token).
RBAC Role-Based Access Control: el acceso se decide por roles.
Recurso Par (method, path) protegible; GET /api/productos y POST /api/productos son dos recursos.
Concesión Fila de resource_roles: «este rol puede este recurso». Es el permiso.
Deny by default Sin concesión explícita, la respuesta es 403.
N:M Relación muchos-a-muchos, materializada por una tabla pivote (role_users, resource_roles).

Criterios de aceptación

Los del ISS, textuales:

  • [ ] 14.1 .env define JWT_SECRET, JWT_ACCESS_TTL y JWT_REFRESH_TTL_DAYS; jsonwebtoken y @types/jsonwebtoken instalados
  • [ ] 14.2 src/shared/auth/password.ts con hashPassword, verifyPassword, sha256Hex, generateOpaqueToken
  • [ ] 14.3 src/shared/auth/jwt.ts firma y verifica con HS256, issuer, audience, expiresIn y jti
  • [ ] 14.4 src/shared/auth/resource-match.ts casa /api/productos/42 con el patrón /api/productos/:id
  • [ ] 14.5 src/shared/auth/auth-user.ts declara Request.auth y exporta requireAuthUser
  • [ ] 14.6 sendError centraliza error → HTTP; BaseController lo reutiliza
  • [ ] 14.7 bearerSecurityScheme, unauthorizedResponse y forbiddenResponse exportados
  • [ ] 14.8 los 6 modelos (User, Role, Resource, RoleUser, ResourceRole, RefreshToken) con sus UK e índices
  • [ ] 14.9 rbac.associations.ts declara el grafo completo (User↔Role, Role↔Resource, User→RefreshToken)
  • [ ] 14.10 config/index.ts y seeders/index.ts importan los modelos y las asociaciones, en ese orden
  • [ ] npx tsc --noEmit OK

Evaluación

Preguntas de comprensión

  1. ¿Por qué este ISS no expone ningún endpoint? Porque su papel es construir la base sobre la que se apoyarán los demás ISS. Primero se definen las primitivas (hash, JWT, matcher) y las tablas; los endpoints llegan en ISS-10 (users) y siguientes. Construir de abajo arriba evita que un endpoint dependa de algo que aún no existe.

  2. ¿Por qué el access token es un JWT y el refresh token es opaco? Porque tienen requisitos opuestos. El access token viaja en cada petición y debe validarse sin tocar la BD → JWT firmado (stateless). El refresh token debe poder revocarse → es opaco y se persiste (hasheado) en refresh_tokens, de modo que se puede invalidar una fila. Un JWT no se puede revocar antes de su exp sin una lista negra.

  3. ¿Por qué el token no puede llevar los roles? Porque un token es inmutable durante su vida. Si los roles/permisos viajaran dentro, revocar un permiso no surtiría efecto hasta que el token caducara. La autorización se resuelve en cada petición contra la matriz (ISS-13), lo que hace la revocación inmediata.

  4. ¿Por qué password.ts usa bcrypt para contraseñas y SHA-256 para tokens? bcrypt es lento y con salt: encarece la fuerza bruta sobre credenciales de baja entropía (las que elige una persona). Un refresh token es aleatorio de 64 bytes: no es adivinable, así que un hash rápido y determinista (SHA-256) basta y además permite buscar por índice único en O(1).

  5. ¿Qué compra el proyecto al ser estricto el matcher de rutas? Que /api/productos/42 no case con /api/productos. Si un permiso de «listar» casara con «leer uno concreto», conceder la lista autorizaría de rebote la lectura individual. La estrictez convierte la matriz en mínima y predecible.

  6. ¿Por qué se declaran las asociaciones en un solo archivo y después de los modelos? Porque la autorización es una cadena que atraviesa cinco tablas: ResourceRole → Role → RoleUser → User. Verla junta hace evidente el camino de la consulta de permisos. Y se declaran después porque referencian a los modelos, no al revés.

  7. ¿Por qué jsonwebtoken no valida sub/jti aunque se pasen en las opciones? Porque la opción require no existe en jsonwebtoken (es de jose): pasarla no valida nada. Por eso verifyAccessToken comprueba sub (entero positivo) y jti explícitamente, y traduce cualquier fallo a AppError(401).

Ejercicios

Ejercicio 1 — Traza el matcher. Dada la concesión {"method": "GET", "path": "/api/productos/:id"} y estas tres peticiones, decide cuáles autoriza isOperationGranted y por qué: (a) GET /api/productos/42 · (b) GET /api/productos · (c) GET /api/productos/42/lotes.

Respuesta razonada - (a) **Sí**: tiene el mismo número de segmentos (`/api`, `/productos`, `/42`) y `:id` casa con `42`. - (b) **No**: la petición tiene un segmento menos; el patrón exige tres. Evita que un permiso de lectura individual autorice el listado. - (c) **No**: la petición tiene un segmento de más. No hay comodines `*`, así que no casa. La lección: la concesión es granular al nivel de `(method, path)` **exacto**; cada operación necesita su propia fila.

Ejercicio 2 — Inspecciona un JWT. Genera mentalmente (o con una herramienta local) un token con signAccessToken({ id: 7, username: "seller" }). Indica qué claims viajan y demuestra por qué modificar el payload a mano invalida el token.

Respuesta razonada El payload incluye `sub: "7"`, `username: "seller"`, un `jti` aleatorio, `iss: "app-storelab-express"`, `aud: "app-storelab-api"`, `iat` y `exp` (ahora + 900 s). **No** incluye roles ni permisos. Si editas el payload (por ejemplo, cambias `sub` por `"1"`), la firma deja de corresponder con el contenido: `verifyAccessToken` recalcula el HMAC con `JWT_SECRET` y no coincide → `AppError(401)`. Por eso el token es «legible pero no modificable»: confidencialidad no garantizada, integridad sí.

Ejercicio 3 — Justifica el orden de los imports. Explica por qué config/index.ts y seeders/index.ts importan primero los seis *.model.ts y después rbac.associations.ts, y qué fallaría si se invirtiera.

Respuesta razonada `rbac.associations.ts` ejecuta llamadas como `User.hasMany(RoleUser, …)`: necesita que las clases `User`, `Role`, etc. ya existan y hayan sido inicializadas con `Model.init`. Si se importara antes, esas clases estarían aún sin definir y las asociaciones no se registrarían (o darían error). Sequelize solo conoce las asociaciones que se han declarado; un `include` sobre una asociación no registrada falla en tiempo de ejecución.

GATE

Para cerrar el ISS-09, ejecuta (del propio ISS):

npx tsc --noEmit
npm run db:seed     # sync({ alter: true }) crea las 6 tablas vacías
npm run dev         # el servidor debe arrancar
# Las 6 tablas existen
mysql -u admin -p tecnogua -e "SHOW TABLES LIKE '%role%'; SHOW TABLES LIKE 'users';"

Resultado esperado: tsc sin errores; el seeder crea users, roles, resources, role_users, resource_roles y refresh_tokens con sus UK; el servidor arranca. Sin endpoints nuevos todavía: la API de Fase I sigue funcionando SIN AUTH (la protección llega en ISS-13).

Checklist de cierre (DoD del ISS-09):

  • [ ] Todos los criterios de aceptación (14.1 … 14.10) cumplidos
  • [ ] npx tsc --noEmit sin errores
  • [ ] npm run db:seed crea las 6 tablas con sus UK
  • [ ] npm run dev arranca el servidor
  • [ ] Sin endpoints nuevos: la API de Fase I sigue SIN AUTH

Con el GATE en verde puedes pasar al ISS-10 — Feature Users, donde sobre esta base nace el CRUD de identidades, el cambio de contraseña y la consulta de permisos efectivos.


Navegación de la ruta: ← CIERRE-BUSINESS · 🛠 Construir · ↑ Ruta Express · → ISS-09 · 🛠 Construir