📚 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.
🎬 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
authenticateyauthorize, 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
Useryrbac.associations.ts).
La condición fundamental que el propio ISS exige para darse por terminado se puede resumir en dos invariantes:
- La contraseña es invisible: se hashea al escribir y nunca aparece en una respuesta (
toUserResponsela excluye). - El 409 es explícito: crear un usuario con
usernamerepetido 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:
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
- Ficha del ISS
- Mapa mental del ISS
- Mapa del backend
- La contraseña, por dentro
- Árbol de archivos
- Anatomía del código
- Comandos explicados
- Flujos
- Recorrido del ISS, paso a paso
- Diagnóstico
- Conexión con el resto del curso
- Glosario
- Criterios de aceptación
- Evaluación
- 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
userscompleto ensrc/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-responseeindex(barrel). - El contrato de salida
UserResponseDto+ mappertoUserResponse, que eliminapassword. - Dos operaciones que no son CRUD:
PATCH /api/usuarios/:id/password(cambio de contraseña) yGET /api/usuarios/:id/permisos(permisos efectivos). - El seeder de usuarios canónicos (
admin,seller), idempotente porusername. - El Swagger de los 9 endpoints y los archivos
.httpde 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.tsconauthenticate, authorizey 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:
getEffectivePermissionsdelega enResourceRolesService(ISS-12). El featureusersno 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:
-
Nunca se guarda en claro. Lo que vive en
users.passwordes 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. -
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.
-
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. -
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
passwordde sus lecturas, ytoUserResponselo 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 descartapasswordantes 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) yUsersController. - 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_passwordynew_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
passwordno viaja enUpdateUserDtoni enPatchUserDto: 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_PASSWORDes una constante de proyección ({ exclude: ["password"] }) que usanfindAllActiveyfindById. Así, unfindByIdcualquiera 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) yfindByIdentifierWithPassword(login; única operación que lee la credencial). El nombre explícito es una defensa de diseño, no una casualidad. findByIdentifierWithPasswordbusca porusernameoemailconOp.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.createconfía en el hook del modelo para hashear; el seeder usaUser.create/findOrCreatey también pasa por el hook.
Se conecta con
- Entrada:
UsersService(y el seederusers.seeder.ts). - Salida: el modelo
Usery 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
createprimero llama aassertUniquey después copia campo a campo lo que declara el DTO (nunca{ ...body }). Esa copia evita mass assignment: nadie puede inyectar unido unstatusinesperado. El default destatusesactive.assertUniquerecibe unexcludeIdpara poder excluir al propio usuario al actualizar; si hay conflicto, lanzaAppError(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.changePasswordexige 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 hookbeforeUpdatevuelve a hashear.getEffectivePermissionsverifica que el usuario existe y delega enResourceRolesService.findEffectiveForUser. El permiso es una concesión rol-recurso, no un atributo del usuario: por eso la lógica vive en el featureresource-roles(ISS-12).findOrFail(id, onlyActive = true)aplica la política de borrado lógico: si el usuario no existe o está inactivo, 404.deletePhysicalborra de verdad;deleteLogicalcambiastatusainactivey no borra el hash.
Se conecta con
- Entrada:
UsersController; yResourceRolesService(ISS-12). - Salida:
UsersRepository,comparePassword(ISS-09) yAppError.
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): eltry/catchvive una sola vez, enBaseController. - El
:idse lee conthis.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:
changePasswordygetEffectivePermissions. Ninguna de ellas conoce el hash: el controller solo llama al service.
Se conecta con
- Entrada:
UsersRoutes. - Salida:
UsersServiceyBaseController.
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, authorizedelante 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/passwordyGET /api/usuarios/:id/permisos. authenticateyauthorizese 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 ausersRoutes.routes(app). - Salida:
UsersControllery 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_USERSdeclaraadmin(Admin123!) yseller(Seller123!). Sus roles se asignan en ISS-12; aquí solo se crean como identidades.findOrCreateporusernamehace 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:seeddevuelve 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
Usery, 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
usersSwaggerusasecurity: bearerSecurityen todas las operaciones y reutilizaunauthorizedResponse,forbiddenResponse,invalidIdResponseynotFoundResponsede ISS-09. Define además los esquemasUser,UserCreate,UserUpdate,UserPatchyChangePassword.- Los dos archivos
.httphacen 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
.httpusan 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/docsy 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/·usersAPI /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/concreate-user.dto.ts,update-user.dto.ts,patch-user.dto.ts,change-password.dto.ts,user-response.dto.tseindex.ts - [ ] 15.2
users.repository.tsaccede a Sequelize conUserEntity; incluyefindByUsernameOrEmail - [ ] 15.3
users.service.ts: hashea al crear/actualizar, valida unicidad deusername/email(409) y ofrecegetEffectivePermissions - [ ] 15.4
users.controller.ts: usathis.run(res, …)ythis.paramId(req); nunca devuelve el hash - [ ] 15.5
users.routes.tsprotege todas las operaciones conauthenticate, authorize - [ ] 15.6
users.seeder.tscreaadminysellerde forma idempotente - [ ] 15.7
users.swagger.tsdocumenta los 9 endpoints consecurity: bearerAuth - [ ] 15.8 archivos
.httpde lectura y escritura - [ ]
npx tsc --noEmitOK
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:
statusno viaja enUpdateUserDtoni enPatchUserDto. Solo cambia al crear o con el borrado lógico/deactivate.
change-password.dto.tses específico del cambio de contraseña (no es unpatchdel recurso): exige la contraseña actual y la nueva, y permiterevoke_sessionspara 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). usernameyemailson únicos: si ya existen,AppError(409, …).- Al actualizar, si llega
passwordse vuelve a hashear; si no llega, se conserva. - El borrado lógico (
deactivate) no borra el hash: el registro queda inactivo e invisible. getEffectivePermissionsrecorre 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
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 (
toUserResponsela excluye) - [ ]
POST /api/usuariosconusernamerepetido → 409 - [ ]
PATCH /api/usuarios/:id/passwordcambia el hash y es verificable converifyPassword - [ ]
npx tsc --noEmitsin errores ynpm run devarranca
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:
BaseControllery sus reglas (run,paramId,findOrFail), el patrón de features de Fase I,hashPassword/comparePasswordyAppError. - 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:
findEffectivePermissionsdepende de ISS-12; las rutas dependen de los middlewares de ISS-13; el login (que usafindByIdentifierWithPassword) 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/concreate-user.dto.ts,update-user.dto.ts,patch-user.dto.ts,change-password.dto.ts,user-response.dto.tseindex.ts - [ ] 15.2
users.repository.tsaccede a Sequelize conUserEntity; incluyefindByUsernameOrEmail - [ ] 15.3
users.service.ts: hashea al crear/actualizar, valida unicidad deusername/email(409) y ofrecegetEffectivePermissions - [ ] 15.4
users.controller.ts: usathis.run(res, …)ythis.paramId(req); nunca devuelve el hash - [ ] 15.5
users.routes.tsprotege todas las operaciones conauthenticate, authorize - [ ] 15.6
users.seeder.tscreaadminysellerde forma idempotente - [ ] 15.7
users.swagger.tsdocumenta los 9 endpoints consecurity: bearerAuth - [ ] 15.8 archivos
.httpde lectura y escritura - [ ]
npx tsc --noEmitOK
Evaluación
Preguntas de comprensión
-
¿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. -
¿Por qué el repository excluye
passworden casi todas sus consultas y solo lo incluye en dos, con nombre explícito? Porque así unfindByIdcualquiera no puede devolver el hash por descuido: para tenerlo hay que llamar afindByIdWithPasswordofindByIdentifierWithPassword, nombres que obligan a pensarlo. Es defensa por diseño: el error no se corrige, se hace difícil de cometer. -
¿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:
comparePasswordlo extrae de ahí al verificar. -
¿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.
-
¿Por qué
comparePasswordtrabaja 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. -
¿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. -
¿Por qué
getEffectivePermissionsdelega enResourceRolesService? Porque el permiso es una concesión rol-recurso, no un atributo del usuario. El featureusersconoce identidades; el featureresource-rolesconoce la matriz. Delegar mantiene cada regla en un solo sitio y reutiliza exactamente la misma consulta que usaráauthorizeen ISS-13. -
¿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):
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 (
toUserResponsela excluye) - [ ]
POST /api/usuariosconusernamerepetido → 409 - [ ]
PATCH /api/usuarios/:id/passwordcambia el hash y es verificable converifyPassword - [ ]
npx tsc --noEmitsin errores ynpm run devarranca
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