Saltar a contenido

📚 Unidad ISS-10 · Feature Users (identidad y contraseña) — 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 Feature Users (identidad y contraseña) 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-10 — Feature Users (identidad y contraseña) (7 diapositivas). Se visualiza aquí, dentro del sitio; no hace falta descargar nada para estudiarla.

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

7 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, el CRUD de usuarios del ISS-10: identificación, conflictos y cambio de contraseña. Las rutas ya importan authenticate y authorize, que todavía no existen.

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


ISS-10 — Cuaderno de aprendizaje visual

Tema

Feature Users (identidad y contraseña): construir el CRUD de identidades como un feature más (DTOs, repository, service, controller y rutas), con dos rasgos que lo distinguen de los de Fase I: la contraseña nunca entra ni sale en claro, y el service expone una consulta de permisos efectivos que recorre el grafo RBAC.

Fuente técnica autoritativa

Archivo fuente ../manual/12-ISS-10-auth-users.md
Nombre literal 12-ISS-10-auth-users.md
Estado Solo lectura — este cuaderno no modifica el ISS
Fase Fase II — Auth con RBAC
Alcance 1.116 líneas, 12 bloques de código, 8 criterios de aceptación (15.1 … 15.8)

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 ## La contraseña, 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: ¿cómo se construye un feature de identidades sin que la contraseña se filtre jamás?

Regla del ISS

Objetivo: construir el CRUD de identidades como un feature más (mismas 4 capas y mismo contrato DTO que los de Fase I), con dos diferencias clave: la contraseña nunca entra ni sale en claro, y el service expone una consulta de permisos efectivos que recorre el grafo RBAC. Bloqueado por: ISS-09 (modelo User y rbac.associations.ts).

La condición fundamental que el propio ISS exige para darse por terminado se puede resumir en dos invariantes:

  1. La contraseña es invisible: se hashea al escribir y nunca aparece en una respuesta (toUserResponse la excluye).
  2. El 409 es explícito: crear un usuario con username repetido responde 409, no un 500 por reventar la restricción única de la BD.

Y una advertencia de alcance del propio ISS: como las rutas se escriben en modalidad JWT + RBAC, pero la matriz y los middlewares aún no existen (llegan en ISS-12/ISS-13), en este punto GET /api/usuarios responde 401 (no hay token). La verificación funcional completa se cierra en ISS-13 y en el cierre de fase.

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, La contraseña, 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

Este feature es el primero de features/auth/ con las cuatro capas completas. Léelo comparándolo con un feature de Fase I (clients, por ejemplo): la estructura es idéntica; solo cambian las reglas de negocio y los dos endpoints extra.

Ruta de aprendizaje

Esta ruta es específica de este ISS: sigue el orden real de sus 8 apartados (15.1 … 15.8).

ISS-09 (modelo User + rbac.associations.ts)
    ↓
15.1  DTOs (create, update, patch, change-password, user-response, index)
    ↓
15.2  Repository (findAllActive, findById, findByIdWithPassword,
      findByIdentifierWithPassword, findConflicts, create, update, delete)
    ↓
15.3  Service (hash al crear/actualizar, unicidad 409, changePassword,
      getEffectivePermissions)
    ↓
15.4  Controller (this.run, this.paramId, nunca devuelve el hash)
    ↓
15.5  Rutas (JWT + RBAC en las 9 operaciones)
    ↓
15.6  Seeder (admin y seller, idempotente)
    ↓
15.7  Swagger (9 endpoints con bearerAuth)
    ↓
15.8  Pruebas HTTP (.http de lectura y escritura)
    ↓
Verificación: tsc + db:seed + dev

Fíjate en el orden: primero el contrato (DTOs), luego datos (repository), luego reglas (service), luego HTTP (controller, rutas, Swagger y pruebas). Es el mismo orden que usarías para cualquier feature nuevo.

Pregunta que responde: ¿en qué orden se construye el feature Users y por qué el contrato va antes que la lógica?

Índice

  1. Ficha del ISS
  2. Mapa mental del ISS
  3. Mapa del backend
  4. La contraseña, 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-10
Título Feature Users (identidad y contraseña)
Objetivo Construir el CRUD de identidades con las 4 capas + DTOs, contraseña hasheada, cambio de contraseña y consulta de permisos efectivos
Fase Fase II — Auth con RBAC
Tecnología principal Sequelize + bcrypt (12 rondas) sobre Express 5 / TypeScript
Depende de ISS-09 — Base de seguridad y modelos Auth
Habilita ISS-11 — Features Roles y Resources
Archivos creados src/features/auth/users/dto/*.ts (6), users.repository.ts, users.service.ts, users.controller.ts, users.routes.ts, users.seeder.ts, users.swagger.ts, http/users.get.http, http/users.create.http
Archivos parcheados src/database/seeders/index.ts, src/database/seeders/counts.ts, src/config/index.ts (registro de usersRoutes)
Componentes incorporados CRUD de identidades, cambio de contraseña, permisos efectivos, seeder canónico idempotente, 9 endpoints documentados
Verificación principal npx tsc --noEmit + npm run db:seed + npm run dev; 409 ante duplicado; el password no aparece
Resultado esperado Feature Users registrado y seeder operativo; rutas declaradas como JWT + RBAC (efectivas en ISS-13)
GATE DoD del ISS-10: criterios 15.1 … 15.8 + contraseña invisible + 409 + cambio de contraseña + tsc/dev OK

Qué implementamos AHORA

  • El feature users completo en src/features/auth/users/, con las mismas cuatro capas (repository, service, controller, rutas) que los features de Fase I.
  • Seis DTOs: create, update, patch, change-password, user-response e index (barrel).
  • El contrato de salida UserResponseDto + mapper toUserResponse, que elimina password.
  • Dos operaciones que no son CRUD: PATCH /api/usuarios/:id/password (cambio de contraseña) y GET /api/usuarios/:id/permisos (permisos efectivos).
  • El seeder de usuarios canónicos (admin, seller), idempotente por username.
  • El Swagger de los 9 endpoints y los archivos .http de prueba.

Qué todavía NO implementamos

No se implementa aquí Llega en
Feature roles (catálogo de roles) ISS-11
Feature resources y el catálogo de 58 recursos ISS-11
Pivotes role_users y resource_roles + ResourceRolesService.findEffectiveForUser ISS-12
Middlewares authenticate y authorize (los que importa users.routes.ts) ISS-13
Protección real de las rutas de negocio de Fase I ISS-13
Refresh tokens y rotación ISS-14
Login, perfil, logout, /api/permisos ISS-15

Trampa habitual nº 1: ver users.routes.ts con authenticate, authorize y creer que ya protege. Los middlewares se construyen en ISS-13; hasta entonces las rutas están declaradas en modalidad JWT + RBAC pero no hay verificación funcional.

Trampa habitual nº 2: getEffectivePermissions delega en ResourceRolesService (ISS-12). El feature users no sabe de roles ni de recursos: pide el resultado al feature que sí lo sabe. Esa delegación es intencional, no un cabo suelto.

Mapa mental del ISS

mindmap
  root((ISS-10<br/>Feature Users))
    Contrato DTO
      create
      update
      patch
      change password
      user response
    Capas
      repository
      service
      controller
      routes
    Seguridad
      hash bcrypt 12
      nunca devuelve password
      cambio exige password actual
    Permisos
      getEffectivePermissions
      delega en resource roles
    Datos
      seeder admin seller
      idempotente
    Documentacion
      swagger 9 endpoints
      archivos http
    Resultado
      rutas JWT mas RBAC
      efectivas en ISS-13

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

Mapa del backend

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

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

   HTTP
    ↓
   Routes (5 features business — todavía SIN AUTH)        ✅ Fase I
   Routes (users — declaradas JWT + RBAC)                  ✅ ISS-10
    ↓
   Controller → Service → Repository → Model → Sequelize → BD
        │
        └── feature users COMPLETO                         ✅ ISS-10
              dto/ · repository · service · controller
              + users.seeder (admin, seller)               ✅ ISS-10

   ── Base compartida (de ISS-09) ────────────────────────────────
   shared/auth/   password · jwt · resource-match · auth-user   ✅
   shared/http/   error-response · swagger-security             ✅
   features/auth/ 6 modelos + rbac.associations                 ✅


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

   HTTP
    ↓
   Routes
    ↓
   authenticate → authorize        🎯 ISS-13 (hacen efectivas las rutas de users)
    ↓
   Controller → Service → Repository → Model → Sequelize → BD
        ↑                    ↑
   feature roles / resources    pivotes role_users / resource_roles
        🎯 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?

Observa la asimetría clave: el feature users está completo (✅), pero los middlewares que lo protegen todavía no existen (🎯 ISS-13). Las rutas de negocio siguen SIN proteger hasta ISS-13; este ISS no las toca.

La contraseña, por dentro

Este es el concepto que define al feature users. Vamos a verlo con calma.

flowchart TD
    subgraph Escritura [Cuando se crea o cambia la contrasena]
        P1["Contrasena en claro"] --> HP["hashPassword plain"]
        HP --> SALT["bcrypt genera un salt aleatorio"]
        SALT --> COST["aplica el algoritmo 2 elevado a 12 veces"]
        COST --> H["Hash de 60 caracteres con salt y coste dentro"]
        H --> DB[("Se guarda SOLO el hash en users.password")]
    end
    subgraph Verificacion [Cuando alguien inicia sesion]
        P2["Contrasena candidata"] --> CMP["comparePassword plain, hashGuardado"]
        DB -.-> CMP
        CMP --> READ["bcrypt lee el salt y el coste del hash"]
        READ --> RECALC["recalcula y compara en memoria"]
        RECALC --> OK["true o false"]
    end

Pregunta que responde: ¿qué pasa exactamente con la contraseña desde que entra hasta que se verifica?

Cuatro ideas que tienes que poder explicar con tus palabras:

  1. Nunca se guarda en claro. Lo que vive en users.password es un hash de una sola dirección. De un hash no se puede recuperar la contraseña; lo único que se puede hacer es comprobar candidatas. Por eso el login compara, no descifra. El hash se calcula en los hooks del modelo (beforeCreate, beforeUpdate, beforeBulkCreate), de modo que ninguna ruta de escritura puede olvidarse.

  2. Qué es el salt. Es un valor aleatorio que bcrypt genera para cada contraseña y guarda dentro del propio hash. Sirve para dos cosas: que dos usuarios con la misma contraseña tengan hashes distintos, y que no valga una tabla precalculada (rainbow table). Como el salt viaja dentro del hash, no hay que guardarlo en una columna aparte.

  3. Por qué el coste 12. bcrypt es deliberadamente lento: el coste es el exponente de un bucle, así que cada incremento duplica el trabajo. Con 12 rondas, verificar una contraseña cuesta lo justo para no molestar a un usuario (milisegundos) y muchísimo para quien prueba millones de candidatas. Es el valor de referencia del diseño (docs/bd-storelab.md §14.1): un compromiso entre coste del servidor y coste del atacante.

  4. Por qué la comparación es en memoria. comparePassword(plain, hash) toma la contraseña candidata, el hash almacenado y de ahí saca el salt y el coste con los que recalcula, y compara. Ocurre en memoria del proceso, sin pasar por la BD y sin escribir nada. El hash leído de la BD viaja a esa función únicamente en las dos operaciones que lo necesitan (findByIdWithPassword, findByIdentifierWithPassword), nunca en las lecturas normales.

Como refuerzo, los DTOs de salida y el repository aplican el mismo principio: el repository excluye password de sus lecturas, y toUserResponse lo vuelve a eliminar por si el modelo se cargó con el hash. Son dos redes para el mismo error.

Árbol de archivos

Estructura antes

Estado al terminar ISS-09: la carpeta users/ solo contiene el modelo.

src/
└── features/
    └── auth/
        ├── users/
        │   └── user.model.ts             (creado en ISS-09)
        ├── 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

Archivos creados / modificados en este ISS

★ src/features/auth/users/dto/create-user.dto.ts          (15.1)
★ src/features/auth/users/dto/update-user.dto.ts          (15.1)
★ src/features/auth/users/dto/patch-user.dto.ts           (15.1)
★ src/features/auth/users/dto/change-password.dto.ts      (15.1)
★ src/features/auth/users/dto/user-response.dto.ts        (15.1)
★ src/features/auth/users/dto/index.ts                    (15.1)
★ src/features/auth/users/users.repository.ts             (15.2)
★ src/features/auth/users/users.service.ts                (15.3)
★ src/features/auth/users/users.controller.ts             (15.4)
★ src/features/auth/users/users.routes.ts                 (15.5)
★ src/features/auth/users/users.seeder.ts                 (15.6)
★ src/features/auth/users/users.swagger.ts                (15.7)
★ src/features/auth/users/http/users.get.http             (15.8)
★ src/features/auth/users/http/users.create.http          (15.8)
△ src/database/seeders/index.ts     (registra seedUsers)
△ src/database/seeders/counts.ts    (clave users)
△ src/config/index.ts               (registra usersRoutes)

Estructura después

src/
└── features/
    └── auth/
        ├── users/
        │   ├── user.model.ts            (ISS-09)
        │   ├── dto/
        │   │   ├── create-user.dto.ts           ★
        │   │   ├── update-user.dto.ts           ★
        │   │   ├── patch-user.dto.ts            ★
        │   │   ├── change-password.dto.ts       ★
        │   │   ├── user-response.dto.ts         ★
        │   │   └── index.ts                     ★
        │   ├── users.repository.ts              ★
        │   ├── users.service.ts                 ★
        │   ├── users.controller.ts              ★
        │   ├── users.routes.ts                  ★
        │   ├── users.seeder.ts                  ★
        │   ├── users.swagger.ts                 ★
        │   └── http/
        │       ├── users.get.http               ★
        │       └── users.create.http            ★
        ├── 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

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

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

Fíjate en el patrón: users/ pasa a tener exactamente la misma forma que un feature de Fase I (model, dto/, repository, service, controller, routes, seeder, swagger y http/), más los dos endpoints propios.

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/features/auth/users/dto/user-response.dto.ts

Propósito

Definir el contrato de salida de un usuario y garantizar que password nunca lo cruza.

Explicación

  • UserResponseDto = Omit<UserI, "password"> expresa en el tipo que la respuesta no tiene credencial.
  • toUserResponse(user) desestructura y descarta password antes de devolver el objeto plano. Se hace aunque el repository ya excluya el hash, porque hay rutas (cambio de contraseña) que cargan el modelo con hash: esta es la segunda red.
  • Recuerda la regla transversal heredada de Fase I: el service devuelve DTOs de respuesta, nunca instancias de Sequelize.

Se conecta con

  • Entrada: UsersService (todas las lecturas y escrituras) y UsersController.
  • Salida: el JSON de la API.

Archivo: src/features/auth/users/dto/change-password.dto.ts

Propósito

Definir el contrato del cambio de credencial, que no es un patch normal del recurso.

Explicación

  • Exige current_password y new_password. La contraseña actual no es un detalle burocrático: es defensa en profundidad. Aunque el RBAC autorice la operación, nadie puede cambiar la credencial de otro sin conocerla. Evita que un administrador comprometido rote contraseñas ajenas sin más.
  • Por eso password no viaja en UpdateUserDto ni en PatchUserDto: tiene su propia operación.

Se conecta con

  • Entrada: PATCH /api/usuarios/:id/password.
  • Salida: UsersService.changePassword.

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

Propósito

Ser la única capa que habla con Sequelize para el modelo User, controlando qué proyección de columnas sale en cada consulta.

Explicación

  • WITHOUT_PASSWORD es una constante de proyección ({ exclude: ["password"] }) que usan findAllActive y findById. Así, un findById cualquiera jamás puede devolver el hash por descuido.
  • Solo dos consultas incluyen el hash, y lo dicen en su nombre: findByIdWithPassword (cambio de contraseña) y findByIdentifierWithPassword (login; única operación que lee la credencial). El nombre explícito es una defensa de diseño, no una casualidad.
  • findByIdentifierWithPassword busca por username o email con Op.or, normalizando el identificador a minúsculas: es lo que permitirá el login con «usuario o correo» en ISS-15.
  • findConflicts(username, email) devuelve los usuarios que ya usan esos valores, para que el service decida el 409 antes de escribir.
  • create confía en el hook del modelo para hashear; el seeder usa User.create/findOrCreate y también pasa por el hook.

Se conecta con

  • Entrada: UsersService (y el seeder users.seeder.ts).
  • Salida: el modelo User y Sequelize.

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

Propósito

Concentrar las reglas de negocio del feature: unicidad, valores por defecto, borrado lógico, cambio de credencial y permisos efectivos.

Explicación

  • create primero llama a assertUnique y después copia campo a campo lo que declara el DTO (nunca { ...body }). Esa copia evita mass assignment: nadie puede inyectar un id o un status inesperado. El default de status es active.
  • assertUnique recibe un excludeId para poder excluir al propio usuario al actualizar; si hay conflicto, lanza AppError(409) con un mensaje útil («Username already in use» / «Email already in use») en lugar de dejar que la restricción de la BD reviente como 500.
  • changePassword exige ambos campos, busca al usuario con su hash (findByIdWithPassword), rechaza si no existe o está inactivo (404), y compara la contraseña actual en memoria (comparePassword). Si no coincide, 400. Solo entonces actualiza: el hook beforeUpdate vuelve a hashear.
  • getEffectivePermissions verifica que el usuario existe y delega en ResourceRolesService.findEffectiveForUser. El permiso es una concesión rol-recurso, no un atributo del usuario: por eso la lógica vive en el feature resource-roles (ISS-12).
  • findOrFail(id, onlyActive = true) aplica la política de borrado lógico: si el usuario no existe o está inactivo, 404.
  • deletePhysical borra de verdad; deleteLogical cambia status a inactive y no borra el hash.

Se conecta con

  • Entrada: UsersController; y ResourceRolesService (ISS-12).
  • Salida: UsersRepository, comparePassword (ISS-09) y AppError.

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

Propósito

Traducir HTTP ↔ service sin contener reglas de negocio: el «camino feliz».

Explicación

  • Todos los handlers se envuelven en this.run(res, …) (regla transversal nº 1 de Fase I): el try/catch vive una sola vez, en BaseController.
  • El :id se lee con this.paramId(req) (regla nº 2): si no es un entero positivo, responde 400 y el service nunca ve un id inválido.
  • Expone las dos operaciones no-CRUD: changePassword y getEffectivePermissions. Ninguna de ellas conoce el hash: el controller solo llama al service.

Se conecta con

  • Entrada: UsersRoutes.
  • Salida: UsersService y BaseController.

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

Propósito

Registrar los 9 endpoints del feature y declararlos en modalidad JWT + RBAC.

Explicación

  • Cada operación se registra con authenticate, authorize delante del handler. La administración de identidades está ella misma protegida por la matriz: no basta con estar autenticado, hay que tener la concesión concreta.
  • Los 9 recursos son: GET/POST /api/usuarios, GET/PUT/PATCH/DELETE /api/usuarios/:id, PATCH /api/usuarios/:id/deactivate, PATCH /api/usuarios/:id/password y GET /api/usuarios/:id/permisos.
  • authenticate y authorize se construyen en ISS-13. Hasta entonces, esta capa está declarada pero no es efectiva: el ISS lo dice de forma explícita.

Se conecta con

  • Entrada: config/index.ts, que llama a usersRoutes.routes(app).
  • Salida: UsersController y los middlewares de ../access (ISS-13).

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

Propósito

Sembrar dos usuarios canónicos que sostienen toda la demostración de RBAC, de forma idempotente.

Explicación

  • SEED_USERS declara admin (Admin123!) y seller (Seller123!). Sus roles se asignan en ISS-12; aquí solo se crean como identidades.
  • findOrCreate por username hace el seeder idempotente: reejecutarlo no duplica. Además reactiva a los canónicos si quedaron inactivos, igual que hacen los seeders de roles y recursos: npm run db:seed devuelve siempre el laboratorio a un estado operable.
  • Si count > 2, añade usuarios aleatorios con Faker sin rol: servirán para comprobar en ISS-13 que estar autenticado no basta (recibirán 403).
  • Las contraseñas se guardan como hash porque las hashea el hook beforeCreate; el seeder no contiene ni una línea de criptografía.

Se conecta con

  • Entrada: SeedersRunner (seeders/index.ts → seedUsers(counts.users)).
  • Salida: el modelo User y, por sus hooks, password.ts.

Archivo: src/features/auth/users/users.swagger.ts y http/*.http

Propósito

Documentar los 9 endpoints y ofrecer pruebas ejecutables.

Explicación

  • usersSwagger usa security: bearerSecurity en todas las operaciones y reutiliza unauthorizedResponse, forbiddenResponse, invalidIdResponse y notFoundResponse de ISS-09. Define además los esquemas User, UserCreate, UserUpdate, UserPatch y ChangePassword.
  • Los dos archivos .http hacen algo muy didáctico: el de lectura muestra el patrón 401 → 403 (sin token / token sin concesión) y el de escritura recorre create, conflicto 409, PUT, PATCH, cambio de contraseña, permisos, baja lógica y baja física.
  • Los .http usan un login contra /api/sesion/login (ISS-15) para obtener el token; se pueden ejecutar por completo cuando la fase cierre.

Se conecta con

  • Entrada: src/swagger/index.ts (el módulo se registra en el ensamblaje de OpenAPI).
  • Salida: /api/docs y el flujo manual de pruebas.

Comandos explicados

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

npx tsc --noEmit

COMANDO
   ↓
npx tsc --noEmit
   ↓
QUÉ HACE
   Comprueba los tipos de todo el proyecto sin emitir archivos.
   ↓
POR QUÉ SE NECESITA
   Detecta errores de tipos en los DTOs, el service y las rutas (por ejemplo,
   un `Partial<UpdateUserDto>` mal usado o un import roto).
   ↓
QUÉ CREA O MODIFICA
   Nada.
   ↓
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, que ahora incluye `seedUsers(counts.users)`.
   ↓
POR QUÉ SE NECESITA
   Crea los usuarios `admin` y `seller` (idempotente) y, con `sync`, asegura la
   tabla `users` con sus índices.
   ↓
QUÉ CREA O MODIFICA
   Filas en `users` (2 canónicos + aleatorios si el conteo es mayor).
   ↓
RESULTADO ESPERADO
   Log del seeder indicando cuántos usuarios insertó, sin errores.
   ↓
CÓMO VERIFICARLO
   Repetir el comando: los canónicos no se duplican (idempotencia).

npm run dev

COMANDO
   ↓
npm run dev
   ↓
QUÉ HACE
   Arranca el servidor Express en modo desarrollo.
   ↓
POR QUÉ SE NECESITA
   Comprueba que el feature se cablea sin romper el arranque (rutas registradas,
   modelos sincronizados).
   ↓
QUÉ CREA O MODIFICA
   Nada en disco; abre el puerto (por defecto 4000).
   ↓
RESULTADO ESPERADO
   Servidor escuchando y log de base de datos sincronizada.
   ↓
CÓMO VERIFICARLO
   Ver el log de arranque; probar `GET /api/usuarios` sin token y comprobar que
   responde 401 (comportamiento esperado en este punto).

Pruebas HTTP (archivos .http)

COMANDO
   ↓
(Abrir y ejecutar src/features/auth/users/http/users.get.http y users.create.http
 con el cliente REST de tu editor — REST Client de VS Code o equivalente.)
   ↓
QUÉ HACE
   Dispara peticiones reales contra el servidor: login, 401 sin token, 403 con
   rol sin concesión, create, 409, put, patch, cambio de contraseña y bajas.
   ↓
POR QUÉ SE NECESITA
   Es la forma de comprobar el contrato HTTP completo del feature.
   ↓
QUÉ CREA O MODIFICA
   Filas en `users` (create, update, delete lógico/físico).
   ↓
RESULTADO ESPERADO
   201 al crear, 409 con `username` repetido, 200 en las actualizaciones,
   `password` ausente en todas las respuestas.
   ↓
CÓMO VERIFICARLO
   Revisar cada respuesta; la verificación funcional completa requiere ISS-13.

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

Flujos

Flujo 1 — Crear un usuario (con hash y unicidad)

sequenceDiagram
    participant C as Cliente
    participant R as users routes
    participant K as users controller
    participant S as users service
    participant D as users repository
    participant M as User model hooks
    C->>R: POST /api/usuarios
    R->>K: authenticate authorize create
    K->>S: create body
    S->>D: findConflicts username email
    D-->>S: lista de conflictos
    S->>D: create con campos del DTO
    D->>M: beforeCreate hashea password
    M-->>D: fila persistida con hash
    D-->>S: instancia User
    S-->>K: toUserResponse sin password
    K-->>C: 201 con el usuario

Pregunta que responde: ¿qué pasos recorre una creación y en qué punto se hashea la contraseña?

Nota el detalle: si findConflicts devuelve un conflicto, el flujo se corta en el service con 409 y no llega al repository. Nunca se escribe para luego descubrir el duplicado.

Flujo 2 — Cambiar la contraseña

sequenceDiagram
    participant C as Cliente
    participant S as users service
    participant D as users repository
    participant P as password comparePassword
    participant M as User beforeUpdate
    C->>S: PATCH password con current y new
    S->>D: findByIdWithPassword id
    D-->>S: usuario con hash
    S->>P: comparePassword current, hash
    P-->>S: true o false
    alt current incorrecta
        S-->>C: 400 Current password is incorrect
    else current correcta
        S->>D: update password nueva
        D->>M: beforeUpdate hashea la nueva
        M-->>C: 200 Password updated
    end

Pregunta que responde: ¿por qué hace falta la contraseña actual para poder cambiarla?

Flujo 3 — Consultar los permisos efectivos

sequenceDiagram
    participant C as Cliente
    participant S as users service
    participant D as users repository
    participant RR as resource roles service
    C->>S: GET permisos del usuario
    S->>D: findById id
    D-->>S: usuario activo o null
    alt no existe o inactivo
        S-->>C: 404 User not found
    else existe
        S->>RR: findEffectiveForUser id
        RR-->>S: lista de method y path
        S-->>C: 200 permissions
    end

Pregunta que responde: ¿de dónde salen los permisos de un usuario y por qué no los calcula el feature users?

La cadena que recorre findEffectiveForUser (ISS-12) es la misma que usará authorize (ISS-13):

User → (role_users.status = 'active') → Role → (resource_roles.status = 'active') → Resource
                                            con Role.status = 'active' y Resource.status = 'active'

Es decir: un eslabón inactivo corta la cadena. Por eso deny by default no es solo «no hay fila»: también es «hay fila pero inactiva».

Flujo 4 — Ciclo de vida de un usuario

stateDiagram-v2
    [*] --> Creado
    Creado --> Activo : status active
    Activo --> Inactivo : deactivate borrado logico
    Inactivo --> Activo : no hay endpoint de reactivar aqui
    Activo --> Eliminado : DELETE fisico
    Inactivo --> Eliminado : DELETE fisico
    Eliminado --> [*]

Pregunta que responde: ¿qué estados puede tener un usuario y cómo se pasa de uno a otro?

El borrado lógico deactivate deja al usuario invisible para la API: findOrFail responderá 404 y, en ISS-13, el middleware authenticate dejará de reconocer sus tokens (efecto inmediato).

Flujo 5 — La doble red contra la fuga del hash

flowchart LR
    R["Lectura de users"] --> A["Repository excluye password"]
    A --> B{"La consulta necesitaba el hash"}
    B -->|No| C["Objeto sin password"]
    B -->|Si cambio de password o login| D["Objeto con password"]
    D --> E["toUserResponse elimina password"]
    C --> F["Respuesta JSON"] 
    E --> F
    F --> G["password NUNCA sale"]

Pregunta que responde: ¿por qué hay dos mecanismos distintos que eliminan el hash de la respuesta?

Recorrido del ISS, paso a paso

A partir de aquí viene el contenido técnico completo del ISS, verbatim: sus 8 apartados (15.1 … 15.8), 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-10 — Feature Users (identidad y contraseña)

Contexto mínimo (autosuficiente). Si abres este archivo aislado —o lo llevas a otra IA—, esto es lo que hay que saber antes de ejecutarlo. - Proyecto: app-storelab-express-ii — Express 5 + TypeScript + Sequelize, arquitectura por features. - Ya construido: Fase I (Business) y, en ISS-09, las primitivas de seguridad y los seis modelos de Auth con sus asociaciones. - Recorrido obligatorio de una petición: HTTP → Controller → Service → Repository → Model → Sequelize → BD. Ninguna capa se salta a la siguiente. - Diseño de datos (Fase II): ../bd-storelab.md §14.1 (users). - Capas, convenciones y reglas transversales: 00-contexto.md.

Este ISS
Título Feature Users (identidad y contraseña)
Feature / tabla features/auth/users/ · users
API /api/usuarios… (JWT + RBAC)
Depende de ISS-09 — Base de seguridad y modelos Auth
Habilita ISS-11 — Features Roles y Resources

Contenido de este ISS

  • 15.1 DTOs del feature (dto/)
  • 15.2 Repository
  • 15.3 Service (hash, unicidad y permisos efectivos)
  • 15.4 Controller
  • 15.5 Rutas (JWT + RBAC)
  • 15.6 Seeder (usuarios canónicos)
  • 15.7 Swagger
  • 15.8 Pruebas HTTP

Objetivo: construir el CRUD de identidades como un feature más (mismas 4 capas y mismo contrato DTO que los de Fase I), con dos diferencias clave: la contraseña nunca entra ni sale en claro, y el service expone una consulta de permisos efectivos que recorre el grafo RBAC.

Bloqueado por: ISS-09 (modelo User y rbac.associations.ts).

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

  • [ ] 15.1 carpeta dto/ con create-user.dto.ts, update-user.dto.ts, patch-user.dto.ts, change-password.dto.ts, user-response.dto.ts e index.ts
  • [ ] 15.2 users.repository.ts accede a Sequelize con UserEntity; incluye findByUsernameOrEmail
  • [ ] 15.3 users.service.ts: hashea al crear/actualizar, valida unicidad de username/email (409) y ofrece getEffectivePermissions
  • [ ] 15.4 users.controller.ts: usa this.run(res, …) y this.paramId(req); nunca devuelve el hash
  • [ ] 15.5 users.routes.ts protege todas las operaciones con authenticate, authorize
  • [ ] 15.6 users.seeder.ts crea admin y seller de forma idempotente
  • [ ] 15.7 users.swagger.ts documenta los 9 endpoints con security: bearerAuth
  • [ ] 15.8 archivos .http de lectura y escritura
  • [ ] npx tsc --noEmit OK

15.1 DTOs del feature

Contrato de la API (el repository no los conoce):

: > src/features/auth/users/dto/create-user.dto.ts
cat >> src/features/auth/users/dto/create-user.dto.ts << 'EOF'
/**
 * Datos de entrada de `POST /api/usuarios`.
 *
 * `status` es opcional y por defecto `active` (como en business). Después de
 * crear el usuario, el estado solo cambia con el borrado lógico.
 */
export interface CreateUserDto {
  username: string;
  email: string;
  password: string;
  avatar?: string | null;
  status?: "active" | "inactive";
}
EOF
: > src/features/auth/users/dto/update-user.dto.ts
cat >> src/features/auth/users/dto/update-user.dto.ts << 'EOF'
/**
 * Datos de entrada de `PUT /api/usuarios/:id` (reemplazo completo).
 *
 * Ni `password` ni `status` están aquí, a propósito:
 *  - la contraseña tiene su propia operación (`PATCH /api/usuarios/:id/password`),
 *    porque cambiar una credencial exige verificar la anterior;
 *  - el estado solo cambia con el borrado lógico (`/deactivate`).
 */
export interface UpdateUserDto {
  username: string;
  email: string;
  avatar?: string | null;
}
EOF
: > src/features/auth/users/dto/patch-user.dto.ts
cat >> src/features/auth/users/dto/patch-user.dto.ts << 'EOF'
import { UpdateUserDto } from "./update-user.dto";

/** Datos de entrada de `PATCH /api/usuarios/:id` (actualización parcial). */
export type PatchUserDto = Partial<UpdateUserDto>;
EOF
: > src/features/auth/users/dto/change-password.dto.ts
cat >> src/features/auth/users/dto/change-password.dto.ts << 'EOF'
/**
 * Datos de entrada de `PATCH /api/usuarios/:id/password`.
 *
 * Exige la contraseña **actual** además de la nueva. Es una defensa en
 * profundidad: aunque el RBAC autorice la operación, nadie puede cambiar la
 * credencial de otro usuario sin conocerla (evita que un administrador
 * comprometido rote contraseñas ajenas sin más).
 */
export interface ChangePasswordDto {
  current_password: string;
  new_password: string;
}
EOF
: > src/features/auth/users/dto/user-response.dto.ts
cat >> src/features/auth/users/dto/user-response.dto.ts << 'EOF'
import { User, UserI } from "../user.model";

/**
 * Respuesta HTTP de un usuario.
 *
 * Regla del DTO: `password` **nunca** sale de la API. El repositorio ni siquiera
 * lo proyecta en las lecturas (`attributes: { exclude: ["password"] }`), pero el
 * mapper lo elimina igualmente por si el modelo se cargó con el hash (p. ej. al
 * cambiar la contraseña). Doble red: el tipo no lo permite y el mapper lo borra.
 */
export type UserResponseDto = Omit<UserI, "password">;

/** Mapper modelo -> DTO de respuesta (objeto plano; elimina `password`). */
export function toUserResponse(user: User): UserResponseDto {
  const { password, ...safe } = user.toJSON() as UserI & { password?: string };
  return safe;
}
EOF
: > src/features/auth/users/dto/index.ts
cat >> src/features/auth/users/dto/index.ts << 'EOF'
export * from "./create-user.dto";
export * from "./update-user.dto";
export * from "./patch-user.dto";
export * from "./change-password.dto";
export * from "./user-response.dto";
EOF

Regla transversal que se mantiene: status no viaja en UpdateUserDto ni en PatchUserDto. Solo cambia al crear o con el borrado lógico /deactivate.

change-password.dto.ts es específico del cambio de contraseña (no es un patch del recurso): exige la contraseña actual y la nueva, y permite revoke_sessions para cerrar las sesiones abiertas del usuario.

15.2 Repository

Única capa que usa Sequelize. Además del CRUD genérico, aporta findByUsernameOrEmail, que necesita el login (ISS-15): acepta usuario o correo en un solo campo.

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

/**
 * Capa Repository del feature Users.
 *
 * Única que habla con Sequelize (el modelo `User`). No contiene reglas de
 * negocio ni conoce `req`/`res`.
 *
 * Detalle de seguridad: las lecturas **normales** excluyen `password` en la
 * proyección SQL. Solo dos consultas lo incluyen, ambas con nombre explícito en
 * su firma (`...WithPassword`), de modo que un `findById` cualquiera jamás puede
 * devolver el hash por descuido.
 */
export class UsersRepository {
  /** Proyección sin credencial: la que usan todas las lecturas de API. */
  private static readonly WITHOUT_PASSWORD = { exclude: ["password"] };

  /** Todos los usuarios activos (sin `password`). */
  public async findAllActive(): Promise<User[]> {
    return User.findAll({
      where: { status: "active" },
      attributes: UsersRepository.WITHOUT_PASSWORD,
    });
  }

  /** Un usuario por PK (o `null`), sin `password`. Acepta transacción. */
  public async findById(id: number, transaction?: Transaction): Promise<User | null> {
    return User.findByPk(id, {
      attributes: UsersRepository.WITHOUT_PASSWORD,
      transaction,
    });
  }

  /** Un usuario por PK **con** su hash. Uso exclusivo: cambio de contraseña. */
  public async findByIdWithPassword(id: number): Promise<User | null> {
    return User.findByPk(id);
  }

  /**
   * Un usuario por `username` **o** `email`, con su hash.
   *
   * Uso exclusivo: validación de credenciales en el login (única operación que
   * lee la credencial). Normaliza el identificador a minúsculas para casar con
   * el valor almacenado.
   */
  public async findByIdentifierWithPassword(identifier: string): Promise<User | null> {
    const value = identifier.trim().toLowerCase();
    return User.findOne({
      where: { [Op.or]: [{ username: value }, { email: value }] },
    });
  }

  /** Busca por `username` o `email` (sin `password`) para detectar duplicados. */
  public async findConflicts(username: string, email: string): Promise<User[]> {
    return User.findAll({
      where: {
        [Op.or]: [
          { username: username.trim().toLowerCase() },
          { email: email.trim().toLowerCase() },
        ],
      },
      attributes: ["id", "username", "email"],
    });
  }

  /** Inserta un usuario (el hook del modelo hashea `password`). */
  public async create(data: CreationAttributes<User>): Promise<User> {
    return User.create(data);
  }

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

  /** Elimina físicamente una instancia. */
  public async delete(user: User): Promise<void> {
    await user.destroy();
  }
}
EOF

15.3 Service

Aquí viven las reglas de User:

  • Nunca se guarda la contraseña en claro (hashPassword, bcrypt 12).
  • username y email son únicos: si ya existen, AppError(409, …).
  • Al actualizar, si llega password se vuelve a hashear; si no llega, se conserva.
  • El borrado lógico (deactivate) no borra el hash: el registro queda inactivo e invisible.
  • getEffectivePermissions recorre el grafo y devuelve la lista de (method, path) vigentes.
: > src/features/auth/users/users.service.ts
cat >> src/features/auth/users/users.service.ts << 'EOF'
import {
  ChangePasswordDto,
  CreateUserDto,
  PatchUserDto,
  UpdateUserDto,
  UserResponseDto,
  toUserResponse,
} from "./dto";
import { UsersRepository } from "./users.repository";
import { User } from "./user.model";
import { AppError } from "../../../shared/errors/app-error";
import { comparePassword } from "../../../shared/auth/password";
import { ResourceRolesService } from "../resource-roles/resource-roles.service";
import { EffectivePermissionDto } from "../resource-roles/dto";

/**
 * Capa Service del feature Users.
 *
 * Reglas de negocio: unicidad de `username`/`email`, default de `status`,
 * política de borrado lógico, cambio de credencial y consulta de permisos
 * efectivos (que delega en el feature `resource-roles`: el permiso es una
 * concesión rol-recurso, no un atributo del usuario).
 *
 * No conoce `req`/`res` ni escribe Sequelize directamente.
 */
export class UsersService {
  public constructor(
    private readonly repository: UsersRepository = new UsersRepository(),
    private readonly resourceRolesService: ResourceRolesService = new ResourceRolesService()
  ) {}

  // ================== READ ==================
  public async getAll(): Promise<UserResponseDto[]> {
    const users = await this.repository.findAllActive();
    return users.map((user) => toUserResponse(user));
  }

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

  /** Permisos efectivos del usuario (cadena RBAC completa). 404 si no existe. */
  public async getEffectivePermissions(id: number): Promise<EffectivePermissionDto[]> {
    await this.findOrFail(id);
    return this.resourceRolesService.findEffectiveForUser(id);
  }

  // ================== CREATE ==================
  public async create(body: CreateUserDto): Promise<UserResponseDto> {
    await this.assertUnique(body.username, body.email);

    // Copia campo a campo: solo lo que declara el DTO llega al modelo
    // (evita *mass assignment*, p. ej. inyectar un `id` o un `status` raro).
    const user = await this.repository.create({
      username: body.username,
      email: body.email,
      password: body.password,
      avatar: body.avatar ?? null,
      status: body.status ?? "active",
    });
    return toUserResponse(user);
  }

  // ================== UPDATE ==================
  public async updatePut(id: number, body: UpdateUserDto): Promise<UserResponseDto> {
    const user = await this.findOrFail(id);
    await this.assertUnique(body.username, body.email, id);

    await this.repository.update(user, {
      username: body.username,
      email: body.email,
      avatar: body.avatar ?? null,
    });
    return toUserResponse(user);
  }

  public async updatePatch(id: number, body: PatchUserDto): Promise<UserResponseDto> {
    const user = await this.findOrFail(id);

    const username = body.username ?? user.username;
    const email = body.email ?? user.email;
    await this.assertUnique(username, email, id);

    await this.repository.update(user, body);
    return toUserResponse(user);
  }

  /**
   * Cambia la contraseña de un usuario.
   *
   * Verifica la credencial actual antes de aceptar la nueva. El hash lo vuelve a
   * calcular el hook `beforeUpdate` del modelo al detectar el campo cambiado.
   */
  public async changePassword(id: number, body: ChangePasswordDto): Promise<void> {
    if (!body.current_password || !body.new_password) {
      throw new AppError(400, "current_password and new_password are required");
    }

    const user = await this.repository.findByIdWithPassword(id);
    if (!user || user.status !== "active") {
      throw new AppError(404, "User not found");
    }

    const matches = await comparePassword(body.current_password, user.password);
    if (!matches) {
      throw new AppError(400, "Current password is incorrect");
    }

    await this.repository.update(user, { password: body.new_password });
  }

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

  /** Eliminación lógica -> `status = inactive`. */
  public async deleteLogical(id: number): Promise<UserResponseDto> {
    const user = await this.findOrFail(id);
    await this.repository.update(user, { status: "inactive" });
    return toUserResponse(user);
  }

  // ================== HELPERS ==================
  /** Busca por PK y falla con 404. `onlyActive` aplica la política de borrado lógico. */
  private async findOrFail(id: number, onlyActive = true): Promise<User> {
    const user = await this.repository.findById(id);
    if (!user || (onlyActive && user.status !== "active")) {
      throw new AppError(404, "User not found");
    }
    return user;
  }

  /**
   * Comprueba que `username` y `email` no estén tomados por **otro** usuario.
   *
   * `excludeId` permite excluir al propio usuario en las actualizaciones. Se
   * hace antes de escribir para responder 409 con un mensaje útil en lugar de
   * dejar que la restricción única de la BD reviente como un 500.
   */
  private async assertUnique(
    username: string,
    email: string,
    excludeId?: number
  ): Promise<void> {
    const conflicts = await this.repository.findConflicts(username, email);
    const taken = conflicts.find((candidate) => candidate.id !== excludeId);

    if (!taken) return;
    if (taken.username === username.trim().toLowerCase()) {
      throw new AppError(409, "Username already in use");
    }
    throw new AppError(409, "Email already in use");
  }
}
EOF

La consulta de permisos efectivos es la misma lógica que usará el middleware authorize en ISS-13: partir del usuario y quedarse solo con los recursos alcanzables por filas activas en toda la cadena.

User → (role_users.status = 'active') → Role → (resource_roles.status = 'active') → Resource
                                              con Role.status = 'active' y Resource.status = 'active'

15.4 Controller

HTTP puro: this.run(res, …), this.paramId(req) y findOrFail en el service. Expone dos operaciones que no son CRUD: cambio de contraseña y permisos efectivos.

: > src/features/auth/users/users.controller.ts
cat >> src/features/auth/users/users.controller.ts << 'EOF'
import { Request, Response } from "express";
import { BaseController } from "../../../shared/http/base-controller";
import {
  ChangePasswordDto,
  CreateUserDto,
  PatchUserDto,
  UpdateUserDto,
} from "./dto";
import { UsersService } from "./users.service";

/**
 * Capa Controller del feature Users.
 * Solo HTTP: lee `req`, llama al service y arma la respuesta.
 * El manejo de errores se delega en `run()` (ver `BaseController`).
 */
export class UsersController extends BaseController {
  public constructor(
    private readonly service: UsersService = new UsersService()
  ) {
    super();
  }

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

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

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

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

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

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

  /** Eliminación lógica -> `status = inactive`. */
  public async deleteLogical(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const user = await this.service.deleteLogical(this.paramId(req));
      res.status(200).json({ message: "User deactivated (logical delete)", user });
    });
  }

  // ================== IDENTIDAD Y PERMISOS ==================
  /** Cambio de credencial (exige la contraseña actual). */
  public async changePassword(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const id = this.paramId(req);
      await this.service.changePassword(id, req.body as ChangePasswordDto);
      res.status(200).json({ message: "Password updated", id });
    });
  }

  /** Permisos efectivos del usuario: recursos concedidos por sus roles activos. */
  public async getEffectivePermissions(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const permissions = await this.service.getEffectivePermissions(this.paramId(req));
      res.status(200).json({ permissions });
    });
  }
}
EOF

15.5 Rutas (modalidad JWT + RBAC)

Todos los endpoints de administración de identidades están ellos mismos protegidos por la matriz: no basta con estar autenticado, hay que tener la concesión concreta (GET /api/usuarios, POST /api/usuarios, …).

: > src/features/auth/users/users.routes.ts
cat >> src/features/auth/users/users.routes.ts << 'EOF'
import { Application } from "express";
import { UsersController } from "./users.controller";
import { authenticate, authorize } from "../access";

/**
 * Rutas del feature Users — **modalidad 3 (JWT + RBAC)** en todas las operaciones.
 *
 * La administración de identidades está ella misma protegida por la matriz de
 * permisos: no basta con estar autenticado, hay que tener la concesión concreta
 * (`GET /api/usuarios`, `POST /api/usuarios`, ...). El catálogo de recursos ya
 * incluye las 9 operaciones de este feature.
 */
export class UsersRoutes {
  public usersController: UsersController = new UsersController();

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

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

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

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

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

    // delete lógico
    app
      .route("/api/usuarios/:id/deactivate")
      .patch(
        authenticate,
        authorize,
        this.usersController.deleteLogical.bind(this.usersController)
      );

    // cambio de contraseña
    app
      .route("/api/usuarios/:id/password")
      .patch(
        authenticate,
        authorize,
        this.usersController.changePassword.bind(this.usersController)
      );

    // permisos efectivos del usuario
    app
      .route("/api/usuarios/:id/permisos")
      .get(
        authenticate,
        authorize,
        this.usersController.getEffectivePermissions.bind(this.usersController)
      );
  }
}
EOF

15.6 Seeder de usuarios canónicos

Dos usuarios de laboratorio, idempotentes (findOrCreate por username), con contraseña hasheada. Son la puerta de entrada para probar las tres modalidades.

: > src/features/auth/users/users.seeder.ts
cat >> src/features/auth/users/users.seeder.ts << 'EOF'
import { User } from "./user.model";
import { faker } from "@faker-js/faker";

/**
 * Seeder de usuarios (`users`).
 *
 * Crea **dos usuarios canónicos** que sostienen toda la demostración de RBAC:
 *
 * | username | password    | rol    | permisos |
 * |----------|-------------|--------|----------|
 * | `admin`  | `Admin123!` | ADMIN  | 58 recursos |
 * | `seller` | `Seller123!`| SELLER | 7 recursos |
 *
 * Si `count > 2`, se añaden usuarios aleatorios (sin rol asignado): sirven para
 * comprobar que **estar autenticado no basta**: recibirán 403 en todo.
 *
 * Las contraseñas se guardan como **hash**: las hashea el hook `beforeCreate` del
 * modelo. Idempotente por `username`.
 */
export const SEED_USERS = [
  { username: "admin", email: "admin@storelab.local", password: "Admin123!" },
  { username: "seller", email: "seller@storelab.local", password: "Seller123!" },
] as const;

export async function seedUsers(count: number): Promise<number> {
  if (count <= 0) {
    console.log("⏭️  users: count=0, se omite");
    return 0;
  }

  let created = 0;

  for (const item of SEED_USERS) {
    const [user, wasCreated] = await User.findOrCreate({
      where: { username: item.username },
      defaults: {
        username: item.username,
        email: item.email,
        password: item.password,
        avatar: null,
        status: "active",
      },
    });
    if (wasCreated) {
      created++;
      continue;
    }
    // Reconciliación: igual que los seeders de roles y recursos, el de usuarios
    // **reactiva** los canónicos si quedaron inactivos. Así `npm run db:seed`
    // devuelve siempre el laboratorio a un estado operable.
    if (user.status !== "active") {
      await user.update({ status: "active" });
    }
  }

  const extras = Math.max(0, count - SEED_USERS.length);
  for (let i = 0; i < extras; i++) {
    const username = `user.${i}.${faker.string.alphanumeric(6)}`.toLowerCase();
    await User.create({
      username,
      email: `${username}@example.com`,
      password: "Password123!",
      avatar: null,
      status: "active",
    });
    created++;
  }

  console.log(`✅ users: insertados ${created} usuario(s) (2 canónicos + ${extras} aleatorios)`);
  return created;
}
EOF
Usuario Contraseña Rol (asignado en ISS-12) Permisos
admin Admin123! ADMIN 58 recursos
seller Seller123! SELLER 7 recursos de operación

15.7 Swagger del feature

Los 9 endpoints (GET/POST /api/usuarios, GET/PUT/PATCH/DELETE /:id, /deactivate, /password, /:id/permisos) documentados con security: [{ bearerAuth: [] }] y las respuestas 401/403 reutilizables.

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

/**
 * Documentación OpenAPI del feature Users.
 *
 * Modalidad de **todas** las operaciones: **JWT + RBAC**. La administración de
 * identidades está protegida por la propia matriz de permisos: además de un
 * token válido, se exige la concesión del recurso `(method, path)`.
 */
export const usersSwagger = {
  tags: [
    {
      name: "Usuarios",
      description:
        "CRUD de identidades + cambio de contraseña + permisos efectivos — **JWT + RBAC**",
    },
  ],
  paths: {
    "/api/usuarios": {
      get: {
        tags: ["Usuarios"],
        summary: "Listar usuarios activos",
        description: "JWT + RBAC — recurso `GET /api/usuarios`. Nunca devuelve `password`.",
        security: bearerSecurity,
        responses: {
          "200": { description: "Lista de usuarios (`{ users: [...] }`)" },
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
        },
      },
      post: {
        tags: ["Usuarios"],
        summary: "Crear usuario",
        description:
          "JWT + RBAC — recurso `POST /api/usuarios`. El `password` se hashea (bcrypt, 12 rondas).",
        security: bearerSecurity,
        requestBody: {
          required: true,
          content: {
            "application/json": { schema: { $ref: "#/components/schemas/UserCreate" } },
          },
        },
        responses: {
          "201": { description: "Usuario creado (`{ user }`)" },
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "409": { description: "`username` o `email` ya en uso" },
        },
      },
    },
    "/api/usuarios/{id}": {
      get: {
        tags: ["Usuarios"],
        summary: "Obtener usuario por id",
        description: "JWT + RBAC — recurso `GET /api/usuarios/:id`. 404 si no existe o está inactivo.",
        security: bearerSecurity,
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
        responses: {
          "200": { description: "Usuario (`{ user }`)" },
          "400": invalidIdResponse,
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": notFoundResponse,
        },
      },
      put: {
        tags: ["Usuarios"],
        summary: "Reemplazar usuario (PUT)",
        description: "JWT + RBAC — recurso `PUT /api/usuarios/:id`. No cambia `password` ni `status`.",
        security: bearerSecurity,
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
        requestBody: {
          required: true,
          content: {
            "application/json": { schema: { $ref: "#/components/schemas/UserUpdate" } },
          },
        },
        responses: {
          "200": { description: "Usuario actualizado (`{ user }`)" },
          "400": invalidIdResponse,
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": notFoundResponse,
          "409": { description: "`username` o `email` ya en uso" },
        },
      },
      patch: {
        tags: ["Usuarios"],
        summary: "Modificar usuario (PATCH)",
        description: "JWT + RBAC — recurso `PATCH /api/usuarios/:id`. Actualización parcial.",
        security: bearerSecurity,
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
        requestBody: {
          content: {
            "application/json": { schema: { $ref: "#/components/schemas/UserPatch" } },
          },
        },
        responses: {
          "200": { description: "Usuario actualizado (`{ user }`)" },
          "400": invalidIdResponse,
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": notFoundResponse,
        },
      },
      delete: {
        tags: ["Usuarios"],
        summary: "Eliminar usuario (físico)",
        description: "JWT + RBAC — recurso `DELETE /api/usuarios/:id`.",
        security: bearerSecurity,
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
        responses: {
          "200": { description: "Eliminado (`{ message, id }`)" },
          "400": invalidIdResponse,
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": notFoundResponse,
        },
      },
    },
    "/api/usuarios/{id}/deactivate": {
      patch: {
        tags: ["Usuarios"],
        summary: "Desactivar usuario (borrado lógico)",
        description:
          "JWT + RBAC — recurso `PATCH /api/usuarios/:id/deactivate`. " +
          "Efecto inmediato: la revalidación del middleware `authenticate` deja de reconocer al usuario (401).",
        security: bearerSecurity,
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
        responses: {
          "200": { description: "Desactivado (`{ message, user }`)" },
          "400": invalidIdResponse,
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": notFoundResponse,
        },
      },
    },
    "/api/usuarios/{id}/password": {
      patch: {
        tags: ["Usuarios"],
        summary: "Cambiar contraseña",
        description:
          "JWT + RBAC — recurso `PATCH /api/usuarios/:id/password`. " +
          "Exige `current_password`: ni un administrador puede cambiar una credencial ajena sin conocerla (defensa en profundidad).",
        security: bearerSecurity,
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
        requestBody: {
          required: true,
          content: {
            "application/json": { schema: { $ref: "#/components/schemas/ChangePassword" } },
          },
        },
        responses: {
          "200": { description: "Contraseña actualizada (`{ message, id }`)" },
          "400": { description: "Faltan campos o `current_password` incorrecta" },
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": notFoundResponse,
        },
      },
    },
    "/api/usuarios/{id}/permisos": {
      get: {
        tags: ["Usuarios"],
        summary: "Permisos efectivos del usuario",
        description:
          "JWT + RBAC — recurso `GET /api/usuarios/:id/permisos`. Ejecuta la consulta de autorización " +
          "(`resource_roles → roles → role_users → resources`, todos los eslabones activos) y devuelve el par `(method, path)` de cada permiso.",
        security: bearerSecurity,
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
        responses: {
          "200": { description: "Permisos efectivos (`{ permissions: [...] }`)" },
          "400": invalidIdResponse,
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "404": notFoundResponse,
        },
      },
    },
  },
  components: {
    schemas: {
      User: {
        type: "object",
        properties: {
          id: { type: "integer", example: 1 },
          username: { type: "string", example: "admin" },
          email: { type: "string", format: "email", example: "admin@storelab.local" },
          avatar: { type: "string", nullable: true, example: null },
          status: { type: "string", enum: ["active", "inactive"], example: "active" },
          createdAt: { type: "string", format: "date-time" },
          updatedAt: { type: "string", format: "date-time" },
        },
      },
      UserCreate: {
        type: "object",
        required: ["username", "email", "password"],
        properties: {
          username: { type: "string", minLength: 3, maxLength: 80, example: "nuevo.usuario" },
          email: { type: "string", format: "email", example: "nuevo@storelab.local" },
          password: { type: "string", format: "password", minLength: 8, example: "Password123!" },
          avatar: { type: "string", nullable: true },
          status: { type: "string", enum: ["active", "inactive"], default: "active" },
        },
      },
      UserUpdate: {
        type: "object",
        required: ["username", "email"],
        properties: {
          username: { type: "string" },
          email: { type: "string", format: "email" },
          avatar: { type: "string", nullable: true },
        },
      },
      UserPatch: {
        type: "object",
        properties: {
          username: { type: "string" },
          email: { type: "string", format: "email" },
          avatar: { type: "string", nullable: true },
        },
      },
      ChangePassword: {
        type: "object",
        required: ["current_password", "new_password"],
        properties: {
          current_password: { type: "string", format: "password" },
          new_password: { type: "string", format: "password", minLength: 8 },
        },
      },
    },
  },
};
EOF

15.8 Pruebas HTTP

: > src/features/auth/users/http/users.get.http
cat >> src/features/auth/users/http/users.get.http << 'EOF'
### Feature Users — GET ALL / GET ONE (modalidad JWT + RBAC)
### JWT + RBAC: `authenticate` (401 si no hay identidad válida) +
### `authorize` (403 si la matriz no concede el par method+path).
@baseUrl = http://localhost:4000

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

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

@adminToken = {{loginAdmin.response.body.$.access_token}}
@id = 1

### getAll — recurso `GET /api/usuarios` (solo ADMIN). Nunca devuelve `password`.
GET {{baseUrl}}/api/usuarios
Authorization: Bearer {{adminToken}}

### getOne — recurso `GET /api/usuarios/:id`
GET {{baseUrl}}/api/usuarios/{{id}}
Authorization: Bearer {{adminToken}}

### 400 — id no es entero positivo (validado en BaseController.paramId)
GET {{baseUrl}}/api/usuarios/abc
Authorization: Bearer {{adminToken}}

### 401 — sin token
GET {{baseUrl}}/api/usuarios

### 403 — el rol SELLER no tiene concedido `GET /api/usuarios`
# @name loginSeller
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

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

###
GET {{baseUrl}}/api/usuarios
Authorization: Bearer {{loginSeller.response.body.$.access_token}}
EOF
: > src/features/auth/users/http/users.create.http
cat >> src/features/auth/users/http/users.create.http << 'EOF'
### Feature Users — CREATE / UPDATE / DELETE (modalidad JWT + RBAC)
@baseUrl = http://localhost:4000

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

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

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

### CREATE — recurso `POST /api/usuarios`. El `password` se hashea (bcrypt, 12 rondas).
POST {{baseUrl}}/api/usuarios
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "username": "nuevo.usuario",
  "email": "nuevo.usuario@storelab.local",
  "password": "Password123!",
  "avatar": null
}

### 409 — username/email ya en uso
POST {{baseUrl}}/api/usuarios
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "username": "admin",
  "email": "otro@storelab.local",
  "password": "Password123!"
}

### UPDATE PUT — reemplazo completo. No cambia `password` ni `status`.
PUT {{baseUrl}}/api/usuarios/{{id}}
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "username": "seller",
  "email": "seller@storelab.local",
  "avatar": "https://example.com/avatar.png"
}

### UPDATE PATCH — parcial
PATCH {{baseUrl}}/api/usuarios/{{id}}
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "avatar": null
}

### CAMBIO DE CONTRASEÑA — recurso `PATCH /api/usuarios/:id/password`.
### Exige la contraseña ACTUAL (defensa en profundidad, incluso para un admin).
PATCH {{baseUrl}}/api/usuarios/{{id}}/password
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "current_password": "Seller123!",
  "new_password": "Seller456!"
}

### PERMISOS EFECTIVOS del usuario — recurso `GET /api/usuarios/:id/permisos`.
### Ejecuta la cadena RBAC completa (seller -> 7 permisos).
GET {{baseUrl}}/api/usuarios/{{id}}/permisos
Authorization: Bearer {{token}}

### DELETE lógico — `status = inactive`. Efecto inmediato: sus tokens dejan de valer (401).
PATCH {{baseUrl}}/api/usuarios/{{id}}/deactivate
Authorization: Bearer {{token}}

### DELETE físico — recurso `DELETE /api/usuarios/:id`
DELETE {{baseUrl}}/api/usuarios/{{id}}
Authorization: Bearer {{token}}
EOF

Verificación

npx tsc --noEmit
npm run db:seed
npm run dev

Como las rutas son JWT + RBAC y la matriz aún no existe (llega en ISS-12/13), en este punto un GET /api/usuarios responde 401 (no hay token). La verificación funcional se completa en el ISS-13 y en el cierre.

DoD del ISS-10

  • [ ] Todos los criterios de aceptación (15.1 … 15.8) cumplidos
  • [ ] La contraseña nunca aparece en una respuesta (toUserResponse la excluye)
  • [ ] POST /api/usuarios con username repetido → 409
  • [ ] PATCH /api/usuarios/:id/password cambia el hash y es verificable con verifyPassword
  • [ ] npx tsc --noEmit sin errores y npm run dev arranca

Diagnóstico

Síntoma Causa probable Solución
password aparece en una respuesta Se devolvió la instancia de Sequelize en vez del DTO Devolver siempre toUserResponse(user) desde el service
Crear un usuario repetido devuelve 500 Se dejó reventar la UK de la BD Pasar por assertUnique antes de escribir → 409
PATCH /password responde 400 aunque la contraseña sea correcta El hash no se cargó (se usó findById, que excluye password) Usar findByIdWithPassword en el cambio de contraseña
La contraseña nueva no se hashea Se escribió con update saltándose el hook El hook beforeUpdate solo hashea si changed("password"); actualizar la instancia con password
GET /api/usuarios/:id/permisos falla en compilación ResourceRolesService es de ISS-12 Esperar a ISS-12; la delegación es intencional
GET /api/usuarios responde 401 Correcto: rutas JWT + RBAC sin token En este punto es el comportamiento esperado; funcional en ISS-13
El seeder duplica usuarios Se usó create en vez de findOrCreate Los canónicos usan findOrCreate por username
Un usuario inactivo sigue leyéndose Se usó findById en lugar de findOrFail findOrFail(id) con onlyActive = true
PATCH /deactivate no cambia el hash Es correcto: el borrado lógico no borra la credencial Conservar el hash; el registro queda inactivo e invisible

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

Conexión con el resto del curso

                    ISS-09 (base compartida + modelo User)
                              │  entrega: hash bcrypt, Request.auth,
                              │           sendError, tabla users, asociaciones
                              ▼
                       ┌─────────────┐
                       │   ISS-10    │  feature users completo + seeder
                       └─────────────┘
                              │
        ┌─────────────────────┼──────────────────────────┐
        ▼                     ▼                          ▼
   ISS-11 roles/resources   ISS-12 pivotes + permisos   ISS-13 access
        │                     │                          │
        └─────────────────────┴──────────────┬───────────┘
                                             ▼
                                 ISS-14 refresh · ISS-15 sesión
  • Piezas que este ISS reutiliza: BaseController y sus reglas (run, paramId, findOrFail), el patrón de features de Fase I, hashPassword/comparePassword y AppError.
  • Piezas que este ISS entrega: el CRUD de identidades, el cambio de contraseña, el seeder canónico y el punto de entrada de los permisos efectivos.
  • Lo que deja pendiente para que otros ISS lo completen: findEffectivePermissions depende de ISS-12; las rutas dependen de los middlewares de ISS-13; el login (que usa findByIdentifierWithPassword) llega en ISS-15.

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

Glosario

Término Significado
DTO Data Transfer Object: contrato de entrada/salida de la API. Un archivo por operación.
Mapper Función que convierte un modelo en un DTO de respuesta (toUserResponse).
Mass assignment Asignar al modelo todos los campos del cuerpo sin filtrar; se evita copiando campo a campo.
Hash Resultado de una función de una sola dirección; no se puede revertir.
Salt Valor aleatorio por contraseña, incluido en el hash, que hace únicos hashes iguales y bloquea rainbow tables.
Coste / rondas Número de iteraciones de bcrypt; 12 en el proyecto.
Idempotente Que puede repetirse sin cambiar el resultado (el seeder no duplica usuarios).
Borrado lógico Marcar status = inactive en vez de borrar la fila.
Borrado físico DELETE real de la fila.
Permisos efectivos Lista de (method, path) que un usuario puede ejercer, calculada recorriendo la cadena RBAC activa.
Defensa en profundidad Capas independientes de protección; aquí: RBAC + contraseña actual + doble red del hash.
409 Conflict Código HTTP para «el recurso ya existe» (username/email duplicado).
404 Aquí significa «no existe o está inactivo» (política de findOrFail).
Usuario canónico Usuario fijo de laboratorio (admin, seller) que sostiene la demostración.

Criterios de aceptación

Los del ISS, textuales:

  • [ ] 15.1 carpeta dto/ con create-user.dto.ts, update-user.dto.ts, patch-user.dto.ts, change-password.dto.ts, user-response.dto.ts e index.ts
  • [ ] 15.2 users.repository.ts accede a Sequelize con UserEntity; incluye findByUsernameOrEmail
  • [ ] 15.3 users.service.ts: hashea al crear/actualizar, valida unicidad de username/email (409) y ofrece getEffectivePermissions
  • [ ] 15.4 users.controller.ts: usa this.run(res, …) y this.paramId(req); nunca devuelve el hash
  • [ ] 15.5 users.routes.ts protege todas las operaciones con authenticate, authorize
  • [ ] 15.6 users.seeder.ts crea admin y seller de forma idempotente
  • [ ] 15.7 users.swagger.ts documenta los 9 endpoints con security: bearerAuth
  • [ ] 15.8 archivos .http de lectura y escritura
  • [ ] npx tsc --noEmit OK

Evaluación

Preguntas de comprensión

  1. ¿Por qué la contraseña no está en UpdateUserDto? Porque la contraseña tiene su propia operación (PATCH /api/usuarios/:id/password), que exige la contraseña actual. Cambiar una credencial no es editar un campo cualquiera: exige verificar la anterior. Separarlo hace imposible «colar» un cambio de contraseña por un update normal.

  2. ¿Por qué el repository excluye password en casi todas sus consultas y solo lo incluye en dos, con nombre explícito? Porque así un findById cualquiera no puede devolver el hash por descuido: para tenerlo hay que llamar a findByIdWithPassword o findByIdentifierWithPassword, nombres que obligan a pensarlo. Es defensa por diseño: el error no se corrige, se hace difícil de cometer.

  3. ¿Qué es el salt y por qué no hace falta guardarlo en una columna aparte? Es un valor aleatorio que bcrypt genera por contraseña y guarda dentro del propio hash de 60 caracteres. Hace que dos usuarios con la misma contraseña tengan hashes distintos y anula las tablas precalculadas. Como viaja dentro del hash, no necesita columna propia: comparePassword lo extrae de ahí al verificar.

  4. ¿Por qué el coste de bcrypt es 12 y no 1? El coste es exponencial: cada incremento duplica el trabajo. Un coste bajo (1) haría las contraseñas fáciles de romper por fuerza bruta. El 12 es el compromiso de referencia del proyecto: costoso para el atacante, imperceptible para el usuario legítimo.

  5. ¿Por qué comparePassword trabaja en memoria y no consulta la BD? Porque no existe «descifrar»: la BD solo tiene el hash. La verificación recalcula y compara. La BD interviene solo para traer el hash (y solo en las dos operaciones que lo necesitan); la comparación con la candidata ocurre en el proceso.

  6. ¿Por qué crear un usuario repetido debe responder 409 y no 500? Porque un duplicado es un conflicto de negocio predecible, no un fallo del servidor. El service lo comprueba antes de escribir (assertUnique) para dar un mensaje útil. Si se dejara reventar la restricción única de la BD, el cliente recibiría un 500 y el servidor un error en consola que ensucia el diagnóstico.

  7. ¿Por qué getEffectivePermissions delega en ResourceRolesService? Porque el permiso es una concesión rol-recurso, no un atributo del usuario. El feature users conoce identidades; el feature resource-roles conoce la matriz. Delegar mantiene cada regla en un solo sitio y reutiliza exactamente la misma consulta que usará authorize en ISS-13.

  8. ¿Por qué el borrado lógico no borra el hash? Porque «desactivar» y «destruir la credencial» son cosas distintas. El registro queda inactivo (invisible para la API, y en ISS-13 sus tokens dejan de valer) pero conserva su hash por si hay que reactivarlo. El borrado físico sí elimina la fila.

Ejercicios

Ejercicio 1 — Diseña el contrato. Debes añadir un endpoint para cambiar solo el email de un usuario. ¿Qué DTO crearías y qué validaciones pondrías en el service?

Respuesta razonada Podría reutilizarse `PatchUserDto` (que ya incluye `email`), porque el cambio de email **no** exige verificar crendenciales. El service necesitaría: - Verificar que el nuevo `email` no está tomado por **otro** usuario (`assertUnique` con `excludeId`). - Normalizar a minúsculas (lo hace el hook `beforeValidate`, pero conviene saberlo). - Devolver 409 si está en uso. La excepción es la contraseña: por eso **no** está en el patch. Un cambio de email es un cambio de dato de contacto; un cambio de contraseña es un cambio de credencial.

Ejercicio 2 — Predice el código de estado. Para cada petición, indica qué responde el ISS-10 y por qué: (a) POST /api/usuarios con username: "admin" · (b) GET /api/usuarios/abc · (c) GET /api/usuarios/999 · (d) PATCH /api/usuarios/2/password con current_password incorrecta.

Respuesta razonada - (a) **409**: `assertUnique` detecta el `username` en uso y lanza `AppError(409, "Username already in use")`. - (b) **400**: `BaseController.paramId` rechaza `abc` porque no es un entero positivo. - (c) **404**: `findOrFail` no encuentra el usuario (o está inactivo). - (d) **400**: `comparePassword` devuelve `false` y el service lanza `AppError(400, "Current password is incorrect")`. Fíjate en que ninguno de los cuatro es un 500: todos son errores previsibles con su código correcto.

Ejercicio 3 — Sigue el hash. Explica qué se guarda en users.password tras POST /api/usuarios con password: "Password123!", y demuestra que volver a crear el mismo usuario con la misma contraseña no produce el mismo hash.

Respuesta razonada Se guarda un **hash bcrypt** de 60 caracteres que incluye el salt y el coste. La contraseña en claro no toca la BD. Si se creara el mismo usuario de nuevo (con otro `id`), el hook volvería a llamar a `hashPassword`, que genera un **salt nuevo**. Como el salt es distinto, el hash resultante también lo es, aunque la contraseña sea idéntica. Esa es precisamente la función del salt: hashes diferentes para contraseñas iguales, de modo que un atacante que vea un hash no pueda deducir que otro usuario comparte contraseña.

GATE

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

npx tsc --noEmit
npm run db:seed
npm run dev

Resultado esperado: tsc sin errores; el seeder crea admin y seller de forma idempotente; el servidor arranca. Como las rutas son JWT + RBAC y la matriz aún no existe (llega en ISS-12/13), en este punto un GET /api/usuarios responde 401 (no hay token). La verificación funcional se completa en el ISS-13 y en el cierre de fase.

Checklist de cierre (DoD del ISS-10):

  • [ ] Todos los criterios de aceptación (15.1 … 15.8) cumplidos
  • [ ] La contraseña nunca aparece en una respuesta (toUserResponse la excluye)
  • [ ] POST /api/usuarios con username repetido → 409
  • [ ] PATCH /api/usuarios/:id/password cambia el hash y es verificable con verifyPassword
  • [ ] npx tsc --noEmit sin errores y npm run dev arranca

Con el GATE en verde puedes pasar al ISS-11 — Features Roles y Resources, donde se construye el catálogo de roles y el de los 58 recursos sobre los que se apoya la autorización.


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