🛠 Unidad ISS-09 · Auth base (seguridad y modelos) — capa 🛠 CONSTRUIR
🧠 Comprender este bloque → · 📝 Evaluación · ✅ GATE de la unidad
Capa Página Para qué 🧠 Aprender Auth base (seguridad y modelos) comprender, explicar y relacionar 🛠 Construir esta página ejecutar, programar y verificar ✅ GATE Condiciones de cierre condición para pasar al bloque siguiente Esta es la guía ejecutable. Sigue los pasos en orden. El cuerpo de abajo es el ISS técnico verbatim: comandos, rutas, versiones, PARCHEs, verificaciones y criterios, sin simplificar ni modernizar.
Fase II: Auth con RBAC — ISS-09 — Base de seguridad compartida y modelos Auth
Contexto mínimo (autosuficiente). Si abres este archivo aislado —o lo llevas a otra IA—, esto es lo que hay que saber antes de ejecutarlo. - Proyecto:
app-storelab-express-ii— Express 5 + TypeScript + Sequelize, arquitectura por features. - Fase I (Business) ya construida: 5 features, 5 tablas y Swagger, sin autenticación. Se cierra en10-cierre-business.md. - Esta Fase II añade Auth con RBAC y convive con lo anterior: las rutas de negocio pasan de SIN AUTH a JWT + RBAC. - Recorrido obligatorio de una petición:HTTP → Controller → Service → Repository → Model → Sequelize → BD. Ninguna capa se salta a la siguiente. - Diseño de datos (Fase II):../bd-storelab.md§12–§22 — tablas, índices,CHECK, consulta de autorización efectiva, ciclo de vida del refresh token y las tres formas de acceder a la BD. - Capas, convenciones y reglas transversales:00-contexto.md.
Este ISS Título Base de seguridad compartida y modelos Auth Feature / tablas src/shared/auth/,src/shared/http/y los 6 modelos defeatures/auth/API — (infraestructura: aún no expone endpoints) Depende de Cierre de Fase I — Estructura final y DoD Habilita ISS-10 — Feature Users
Contenido de este ISS
- 14.1 Dependencias y variables de entorno
- 14.2
password.ts(bcrypt 12 rondas + SHA-256 + token opaco) - 14.3
jwt.ts(firma/verificación HS256 coniss/aud/exp/jti) - 14.4
resource-match.ts(normalizador y matcher de rutas parametrizadas) - 14.5
auth-user.ts(extensión deRequest+requireAuthUser) - 14.6
error-response.ts(mapper único error → HTTP) y PARCHE deBaseController - 14.7
swagger-security.ts(esquemabearerAuth+ respuestas 401/403) - 14.8 Los seis modelos Sequelize
- 14.9
rbac.associations.ts(grafo de relaciones) - 14.10 Cableado de modelos en
configyseeders
Objetivo: dejar listas las primitivas de seguridad (hash de contraseña, hashes de tokens opacos, firma y verificación de JWT, matcher de rutas) y las seis tablas del modelo RBAC con sus asociaciones, de modo que los ISS posteriores solo construyan capas sobre esta base.
Bloqueado por: Fase I cerrada (el App, Routes, SeedersRunner y Swagger ya existen y se extienden, no se reescriben).
Principios que fija este ISS
1. No existe la entidad Permission. La autorización se modela como un grafo:
Por tanto:
Ejemplo: el rol SELLER concede el recurso POST /api/ventas; eso es el permiso «SELLER puede crear ventas». No hay una fila «permiso» intermedia que haya que mantener sincronizada.
2. Las tres modalidades de acceso (se decidirán por ruta, no globalmente):
| Modalidad | Qué exige | Middleware | Ejemplos |
|---|---|---|---|
| OPEN | nada | — | POST /api/sesion/login, /refresh, /logout, /api/docs |
| JWT | access token válido | authenticate |
GET /api/sesion/perfil, /api/permisos, /api/sesiones/* |
| JWT + RBAC | token válido y concesión activa de (method, path) |
authenticate + authorize |
todo el CRUD de negocio y de administración |
3. status es 'active' | 'inactive' en las seis tablas, igual que en Fase I. Un registro inactive es invisible para la API y, en RBAC, no autoriza.
4. deny by default: sin concesión explícita, la respuesta es 403. No existe lista de excepciones ni «rol comodín».
Criterios de aceptación (ISS-09) — consolidados
- [ ] 14.1
.envdefineJWT_SECRET,JWT_ACCESS_TTLyJWT_REFRESH_TTL_DAYS;jsonwebtokeny@types/jsonwebtokeninstalados - [ ] 14.2
src/shared/auth/password.tsconhashPassword,verifyPassword,sha256Hex,generateOpaqueToken - [ ] 14.3
src/shared/auth/jwt.tsfirma y verifica con HS256,issuer,audience,expiresInyjti - [ ] 14.4
src/shared/auth/resource-match.tscasa/api/productos/42con el patrón/api/productos/:id - [ ] 14.5
src/shared/auth/auth-user.tsdeclaraRequest.authy exportarequireAuthUser - [ ] 14.6
sendErrorcentralizaerror → HTTP;BaseControllerlo reutiliza - [ ] 14.7
bearerSecurityScheme,unauthorizedResponseyforbiddenResponseexportados - [ ] 14.8 los 6 modelos (
User,Role,Resource,RoleUser,ResourceRole,RefreshToken) con sus UK e índices - [ ] 14.9
rbac.associations.tsdeclara el grafo completo (User↔Role, Role↔Resource, User→RefreshToken) - [ ] 14.10
config/index.tsyseeders/index.tsimportan los modelos y las asociaciones, en ese orden - [ ]
npx tsc --noEmitOK
14.1 Dependencias y variables de entorno
# Paquetes (una vez). bcryptjs ya venía de Fase I.
npm install jsonwebtoken@^9.0.3
npm install -D @types/jsonwebtoken@^9.0.10
Variables nuevas del .env (PARCHE: se añaden al final, no reemplazan las de Fase I):
cat >> .env << 'EOF'
# ─────────────────────────────────────────────────────────────
# Fase II — Seguridad (JWT + RBAC)
# ─────────────────────────────────────────────────────────────
# Secreto de firma del access token (HMAC SHA-256). Mínimo 32 caracteres.
# En producción: generar con `openssl rand -base64 48` y NO versionarlo.
JWT_SECRET=storelab-lab-secret-change-me-0123456789abcdef
# Vida útil del access token en segundos (900 = 15 min).
JWT_ACCESS_TTL=900
# Vida útil del refresh token en días.
JWT_REFRESH_TTL_DAYS=7
EOF
JWT_ACCESS_TTLse expresa en segundos yJWT_REFRESH_TTL_DAYSen días: son unidades distintas a propósito (el access token es de minutos; el refresh, de días). El service las convierte a milisegundos al persistirexpires_at.
14.2 password.ts — hash de contraseña y hashes de tokens
Tres responsabilidades, todas de la capa shared (no son propias de un feature):
hashPassword/verifyPassword: bcrypt con 12 rondas (coste alto, deliberado).sha256Hex: para los refresh tokens, que no se guardan en claro sino como hash.generateOpaqueToken:crypto.randomBytes(32).toString("base64url")→ token opaco (no JWT).
: > src/shared/auth/password.ts
cat >> src/shared/auth/password.ts << 'EOF'
import { hash, compare } from "bcryptjs";
/**
* Derivación y verificación de contraseñas (bcrypt).
*
* Se centraliza aquí porque lo usan tres sitios distintos y **debe** usar los
* mismos parámetros en los tres:
* - el hook `beforeCreate/beforeUpdate` del modelo `User` (hash al persistir);
* - el service de usuarios al cambiar la contraseña;
* - el login, que compara la credencial en memoria (nunca la devuelve).
*
* Coste 12 rondas: el valor de referencia del diseño de la base de datos
* (`docs/bd-storelab.md` §14.1). Es un compromiso entre coste de CPU del servidor
* y coste de fuerza bruta para un atacante que obtuviera el hash.
*/
const SALT_ROUNDS = 12;
/** Devuelve el hash bcrypt de una contraseña en claro. */
export async function hashPassword(plain: string): Promise<string> {
return hash(plain, SALT_ROUNDS);
}
/** `true` si la contraseña en claro corresponde al hash almacenado. */
export async function comparePassword(plain: string, passwordHash: string): Promise<boolean> {
return compare(plain, passwordHash);
}
/**
* Hash determinista (SHA-256, hex) para credenciales de **alta entropía**.
*
* Se usa con los refresh tokens, no con contraseñas: un token aleatorio de 64
* bytes no es adivinable, así que no necesita un algoritmo lento; basta con
* impedir que el valor en claro quede en la base de datos. Esto permite, además,
* buscar por índice único (`token_hash`) en O(1).
*/
import { createHash, randomBytes } from "node:crypto";
export function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
/** Genera un token opaco no adivinable (URL-safe, 64 bytes ≈ 86 caracteres). */
export function generateOpaqueToken(): string {
return randomBytes(64).toString("base64url");
}
EOF
14.3 jwt.ts — firma y verificación del access token
¿Por qué JWT aquí y token opaco para el refresh? El access token viaja en cada petición y debe validarse sin tocar la BD (Stateless): JWT firmado. El refresh token, en cambio, debe poder revocarse; por eso es opaco y se persiste (hasheado) en refresh_tokens.
: > src/shared/auth/jwt.ts
cat >> src/shared/auth/jwt.ts << 'EOF'
import jwt, { JwtPayload } from "jsonwebtoken";
import { randomUUID } from "node:crypto";
import { AppError } from "../errors/app-error";
/**
* Emisión y verificación del **access token** (JWT firmado, HS256).
*
* Referencias (fuentes oficiales):
* - RFC 7519 — JSON Web Token (`sub`, `iss`, `aud`, `exp`, `iat`, `jti`).
* - RFC 8725 §3.1 — *Perform Algorithm Verification*: el algoritmo se fija en el
* código (lista permitida), nunca se toma del encabezado `alg` del token.
* - RFC 8725 §3.8/§3.9 — validar `iss` (emisor) y `aud` (audiencia).
* - RFC 6750 — el token viaja en `Authorization: Bearer <token>`.
*
* El access token es **autocontenido y no se persiste**: se valida con la firma.
* La base de datos solo interviene para revalidar que el usuario sigue activo
* (ver `authenticate`), y para los refresh tokens.
*/
const ALGORITHM = "HS256";
/** Emisor/audiencia del sistema. Sirven para rechazar tokens de otro servicio. */
export const TOKEN_ISSUER = "app-storelab-express";
export const TOKEN_AUDIENCE = "app-storelab-api";
/** Vida útil del access token. Corta por diseño (Owasp/OAuth2: token de vida corta). */
export const ACCESS_TOKEN_TTL_SECONDS = Number(process.env.JWT_ACCESS_TTL ?? 900); // 15 min
export interface AccessTokenPayload extends JwtPayload {
sub: string;
username: string;
jti: string;
}
function getSecret(): string {
const secret = process.env.JWT_SECRET;
if (!secret || secret.length < 32) {
throw new AppError(
500,
"JWT_SECRET no configurado (mínimo 32 caracteres). Ver .env"
);
}
return secret;
}
/** Firma un access token para un usuario. */
export function signAccessToken(user: { id: number; username: string }): {
token: string;
expiresIn: number;
} {
const token = jwt.sign(
{ username: user.username },
getSecret(),
{
algorithm: ALGORITHM,
subject: String(user.id),
issuer: TOKEN_ISSUER,
audience: TOKEN_AUDIENCE,
expiresIn: ACCESS_TOKEN_TTL_SECONDS,
jwtid: randomUUID(),
}
);
return { token, expiresIn: ACCESS_TOKEN_TTL_SECONDS };
}
/**
* Verifica firma y *claims* y devuelve el payload.
*
* Se pasan las opciones explícitas (no se confía en el token): `algorithms`,
* `issuer` y `audience`; y después se comprueban a mano `sub` y `jti`.
*
* Ojo: `jsonwebtoken` **no** tiene opción `require` (es de `jose`); pasarla no
* valida nada. Por eso los claims obligatorios se verifican explícitamente.
* Cualquier fallo se traduce a `AppError(401)` para que el middleware responda
* **no autenticado**.
*/
export function verifyAccessToken(token: string): AccessTokenPayload {
let payload: JwtPayload;
try {
payload = jwt.verify(token, getSecret(), {
algorithms: [ALGORITHM],
issuer: TOKEN_ISSUER,
audience: TOKEN_AUDIENCE,
// Tolerancia de reloj: evita 401 espurios entre máquinas desincronizadas.
clockTolerance: 5,
}) as JwtPayload;
} catch {
throw new AppError(401, "Invalid or expired access token");
}
// Los claims obligatorios se comprueban AQUÍ, no en `jwt.verify`.
//
// `jsonwebtoken` **no** admite la opción `require` (esa opción es de `jose`):
// pasarla no valida nada. `iss`, `aud` y `exp` sí los exige `jwt.verify` con
// las opciones de arriba; `sub` y `jti` hay que verificarle explícitamente.
//
// - sin `sub` no hay identidad -> no se puede autenticar;
// - `sub` debe ser un entero positivo: un valor no numérico llegaría al
// repositorio como `NaN` y provocaría un 500 en vez de un 401;
// - sin `jti` se pierde la trazabilidad del token (RFC 8725).
if (
typeof payload.sub !== "string" ||
!/^[1-9]\d*$/.test(payload.sub) ||
typeof payload.jti !== "string" ||
payload.jti.length === 0
) {
throw new AppError(401, "Invalid or expired access token");
}
return payload as AccessTokenPayload;
}
/** Extrae el token de `Authorization: Bearer <token>` (RFC 6750). */
export function extractBearerToken(header: string | undefined): string | null {
if (!header) return null;
const [scheme, value] = header.split(" ");
if (!scheme || !value || scheme.toLowerCase() !== "bearer") return null;
return value;
}
EOF
Puntos de seguridad (alineados con RFC 8725, JSON Web Token Best Current Practices):
| Práctica | Cómo se aplica |
|---|---|
| Fijar el algoritmo | algorithm: "HS256" explícito al firmar y al verificar (algorithms: ["HS256"]) → evita alg: none y confusión de algoritmos |
| Emisor y audiencia | issuer: "storelab" y audience: "storelab-api"; se exigen al verificar |
| Caducidad | expiresIn: JWT_ACCESS_TTL |
| Identificador único | claim jti (permite trazabilidad/correlación) |
| Carga útil mínima | solo sub (id), username y jti; nunca roles ni permisos |
Los roles NO viajan en el token. Si viajaran, revocar un permiso no tendría efecto hasta que caducara el token. La autorización se resuelve en cada petición contra la matriz (ISS-13), lo que hace la revocación inmediata (RFC 6749 / OWASP).
14.4 resource-match.ts — casar la petición con el recurso
Un recurso es un par (method, path) con el path en patrón: /api/productos/:id. El middleware authorize recibe la petición real (GET /api/productos/42) y debe encontrar el recurso. Este módulo hace esa traducción:
normalizePath: quita barra final, query string y colapsa barras repetidas.buildPathMatcher: convierte/api/productos/:iden una expresión regular anclada.pathsMatch: comprueba un patrón contra un path real.
: > src/shared/auth/resource-match.ts
cat >> src/shared/auth/resource-match.ts << 'EOF'
/**
* Coincidencia entre la ruta de una petición y un **recurso** almacenado.
*
* Un recurso se guarda como patrón (`method` + `path` con parámetros):
*
* ```text
* GET /api/productos/:id
* ```
*
* Y la petición llega con el valor concreto:
*
* ```text
* GET /api/productos/42
* ```
*
* Reglas de la comparación (deliberadamente estrictas):
* - El verbo HTTP debe coincidir exactamente.
* - Un segmento `:param` del patrón casa con **un** segmento cualquiera.
* - El resto de segmentos deben ser iguales carácter a carácter.
* - El número de segmentos debe coincidir (no hay comodines tipo `*`).
*
* Así, `/api/productos/42` **no** casa con `/api/productos` (evita que un permiso
* de listado autorice una lectura concreta por error) y `/api/productos/42/lotes`
* tampoco.
*/
/** Normaliza una ruta: sin cadena de consulta, sin barra final, sin duplicar `/`. */
export function normalizePath(path: string): string {
const withoutQuery = path.split("?")[0].split("#")[0];
const single = withoutQuery.replace(/\/{2,}/g, "/");
const trimmed = single.replace(/\/+$/, "");
return trimmed === "" ? "/" : trimmed;
}
/** `true` si `path` (concreto) casa con `pattern` (con `:param`). */
export function pathMatches(pattern: string, path: string): boolean {
const patternParts = normalizePath(pattern).split("/");
const pathParts = normalizePath(path).split("/");
if (patternParts.length !== pathParts.length) return false;
for (let i = 0; i < patternParts.length; i++) {
const p = patternParts[i];
if (p.startsWith(":")) continue; // parámetro: casa con cualquier segmento
if (p !== pathParts[i]) return false;
}
return true;
}
/**
* `true` si el conjunto de recursos concedidos cubre la operación solicitada.
*
* Es la decisión final del RBAC: se compara el par `(method, path)` de la
* petición contra las concesiones del usuario. **Deny by default**: si ninguna
* coincide, se devuelve `false`.
*
* (Referencia: `docs/bd-storelab.md` §16 — la base de datos es la única fuente
* de verdad de la matriz de permisos; la coincidencia por patrón se hace aquí.)
*/
export function isOperationGranted(
granted: ReadonlyArray<{ method: string; path: string }>,
method: string,
path: string
): boolean {
const upper = method.toUpperCase();
return granted.some(
(resource) => resource.method.toUpperCase() === upper && pathMatches(resource.path, path)
);
}
EOF
14.5 auth-user.ts — la identidad en Request
Extiende el tipo Request de Express con auth y expone requireAuthUser, que los controllers JWT usan para leer al usuario sin adivinar si el middleware corrió.
: > src/shared/auth/auth-user.ts
cat >> src/shared/auth/auth-user.ts << 'EOF'
import { Request } from "express";
import { AppError } from "../errors/app-error";
/**
* Identidad resuelta que los middlewares de acceso dejan en la petición.
*
* Se guarda en `req.auth` (ver la ampliación de tipos más abajo) y la consumen:
* - los controllers que necesitan saber quién llama (`GET /api/sesion/perfil`);
* - `authorize`, para consultar los permisos efectivos del usuario.
*/
export interface AuthUser {
id: number;
username: string;
email?: string;
/** Token con el que se autenticó (útil para cerrar la sesión actual). */
tokenId?: string;
}
/**
* Devuelve la identidad de la petición o falla con 401.
*
* Lo usan los controllers de rutas con modalidad JWT (sin `authorize`): allí el
* middleware ya garantizó que `req.auth` existe, pero el tipo es opcional, así
* que esta función cierra el caso sin recurrir a `!`.
*/
export function requireAuthUser(req: Request): AuthUser {
if (!req.auth) {
throw new AppError(401, "Authentication required");
}
return req.auth;
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
interface Request {
/** Identidad resuelta por el middleware `authenticate`. `undefined` = OPEN. */
auth?: AuthUser;
}
}
}
export {};
EOF
14.6 error-response.ts y PARCHE de BaseController
El mapeo error → HTTP deja de vivir solo en el controller: lo necesitan también los middlewares authenticate/authorize. Se extrae a un único punto (sendError) y BaseController lo reutiliza.
: > src/shared/http/error-response.ts
cat >> src/shared/http/error-response.ts << 'EOF'
import { Response } from "express";
import { AppError } from "../errors/app-error";
/**
* Traduce cualquier error a una respuesta HTTP. **Único punto** del proyecto
* donde se decide el mapeo error -> status.
*
* Lo usan los dos sitios que pueden fallar antes de llegar a un controller:
* - `BaseController.handleError` (handlers de los controllers);
* - los middlewares de acceso (`authenticate` / `authorize`), que responden
* 401/403 sin pasar por un controller.
*
* Regla: `AppError` -> su `statusCode`; cualquier otra cosa -> **500** (y el
* detalle solo en el cuerpo, nunca el stack).
*/
export function sendError(res: Response, error: unknown): void {
if (error instanceof AppError) {
res.status(error.statusCode).json({ error: error.message });
return;
}
res.status(500).json({ error: "Internal server error", detail: String(error) });
}
EOF
PARCHE en src/shared/http/base-controller.ts: handleError delega en sendError.
: > src/shared/http/base-controller.ts
cat >> src/shared/http/base-controller.ts << 'EOF'
import { Request, Response } from "express";
import { AppError } from "../errors/app-error";
import { sendError } from "./error-response";
/**
* Base de los controllers HTTP.
*
* Aísla las tres responsabilidades puramente HTTP que, si no, se repetirían en
* los 7 métodos de cada controller:
*
* - `run`: ejecuta el cuerpo del handler y traduce el error a HTTP.
* - `paramId`: lee y valida el `:id` de la URL.
* - `handleError`: mapea `AppError` a su status y lo demás a 500.
*
* La capa de negocio (service) no conoce `req`/`res`.
*/
export abstract class BaseController {
/**
* Ejecuta el cuerpo de un handler y centraliza el manejo de errores.
*
* Sin este helper, cada uno de los 35 métodos de los controllers tendría su
* propio `try/catch`. Aquí el `catch` vive una sola vez.
*/
protected async run(res: Response, work: () => Promise<void>): Promise<void> {
try {
await work();
} catch (error) {
this.handleError(res, error);
}
}
/**
* Lee el `:id` de la URL y lo valida como entero positivo.
*
* Sin la validación, `GET /api/clientes/abc` llegaría al repository como
* `Number("abc") === NaN` y devolvería un 404 engañoso en vez de un 400.
*/
protected paramId(req: Request): number {
const raw = req.params.id;
const value = Array.isArray(raw) ? raw[0] : raw;
if (!value || !/^\d+$/.test(value) || Number(value) < 1) {
throw new AppError(400, "Invalid id: must be a positive integer");
}
return Number(value);
}
/**
* Mapea errores: `AppError` -> su status; cualquier otro -> 500.
*
* La traducción vive en `sendError` porque los middlewares de acceso también
* la necesitan: un único punto decide el mapeo error -> HTTP.
*/
protected handleError(res: Response, error: unknown): void {
sendError(res, error);
}
}
EOF
14.7 swagger-security.ts — seguridad reutilizable para OpenAPI
| Export | Para qué |
|---|---|
bearerSecurityScheme |
Esquema bearerAuth (Authorization: Bearer <token>, RFC 6750) |
unauthorizedResponse |
Respuesta 401 reutilizable ($ref) |
forbiddenResponse |
Respuesta 403 reutilizable ($ref) |
: > src/shared/http/swagger-security.ts
cat >> src/shared/http/swagger-security.ts << 'EOF'
/**
* Piezas reutilizables de OpenAPI para las **tres modalidades de acceso**.
*
* Centralizar aquí el esquema `bearerAuth` y las respuestas 401/403 evita repetir
* la misma definición en los 7 módulos de Swagger (auth) y en los 5 de business.
* Al cambiar una descripción, cambia en toda la documentación.
*
* Convención de uso en cada operación:
*
* | Modalidad | `security` |
* |---|---|
* | OPEN | `openSecurity` (arreglo vacío: no exige credencial) |
* | JWT | `bearerSecurity` |
* | RBAC | `bearerSecurity` + respuestas 401 **y** 403 |
*/
/** Esquema de seguridad (RFC 6750: `Authorization: Bearer <token>`). */
export const bearerSecurityScheme = {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
description:
"Access token JWT obtenido en `POST /api/sesion/login`. Enviar como " +
"`Authorization: Bearer <access_token>`. Vida útil corta (por defecto 15 min); " +
"se renueva con `POST /api/sesion/refresh`.",
},
};
/** `security` de un endpoint OPEN (no exige credencial). */
export const openSecurity: unknown[] = [];
/** `security` de un endpoint JWT o RBAC (exige access token válido). */
export const bearerSecurity = [{ bearerAuth: [] }];
/** Respuesta 401: no hay identidad válida (token ausente, inválido o usuario inactivo). */
export const unauthorizedResponse = {
description:
"401 No autenticado — falta el Bearer token, el token es inválido/expiró o el usuario está inactivo",
};
/** Respuesta 403: hay identidad, pero la matriz RBAC no concede `(method, path)`. */
export const forbiddenResponse = {
description:
"403 Prohibido — autenticado, pero sin concesión activa para esta operación (deny by default)",
};
/** Respuesta 400 ante un `:id` que no es entero positivo. */
export const invalidIdResponse = {
description: "400 id inválido (debe ser un entero positivo)",
};
/** Respuesta 404 estándar. */
export const notFoundResponse = {
description: "404 No encontrado",
};
EOF
14.8 Los seis modelos Sequelize
| Entidad | Tabla | Responsabilidad |
|---|---|---|
User |
users |
identidad del usuario (contraseña hasheada) |
Role |
roles |
agrupación de responsabilidades |
RoleUser |
role_users |
asignación User ↔ Role (N:M) |
Resource |
resources |
endpoint/acción protegible, (method, path) |
ResourceRole |
resource_roles |
el permiso: concesión Role ↔ Resource (N:M) |
RefreshToken |
refresh_tokens |
sesión renovable y revocable |
User:
: > src/features/auth/users/user.model.ts
cat >> src/features/auth/users/user.model.ts << 'EOF'
import { DataTypes, Model } from "sequelize";
import { sequelize } from "../../../database/db";
import { hashPassword } from "../../../shared/auth/password";
/**
* Modelo `User` (tabla `users`) — la identidad del sistema.
*
* Se diferencia de los modelos de business en un punto clave: **`password` nunca
* se guarda en claro**. El hash se calcula en los hooks, de modo que ningún
* service, repository o seeder puede olvidarse de hacerlo.
*
* El algoritmo y el coste viven en `shared/auth/password.ts` (única fuente), no
* aquí: si mañana se sube el coste, se cambia en un solo sitio.
*/
export interface UserI {
id?: number;
username: string;
email: string;
password: string;
avatar?: string | null;
status: "active" | "inactive";
createdAt?: Date;
updatedAt?: Date;
}
export class User extends Model {
public id!: number;
public username!: string;
public email!: string;
public password!: string;
public avatar!: string | null;
public status!: "active" | "inactive";
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
}
User.init(
{
username: {
type: DataTypes.STRING(80),
allowNull: false,
// `unique` con nombre explícito -> la BD nombra la restricción `uq_users_username`
// (misma nomenclatura que el DDL de referencia en docs/bd-storelab.md §14).
unique: "uq_users_username",
validate: {
notEmpty: { msg: "Username cannot be empty" },
len: { args: [3, 80], msg: "Username must be between 3 and 80 characters" },
},
},
email: {
type: DataTypes.STRING(150),
allowNull: false,
unique: "uq_users_email",
validate: {
isEmail: { msg: "Email must be a valid email address" },
},
},
password: {
// 255: el hash bcrypt ocupa 60 y sobra margen para algoritmos futuros.
type: DataTypes.STRING(255),
allowNull: false,
validate: {
notEmpty: { msg: "Password cannot be empty" },
},
},
avatar: {
type: DataTypes.STRING(500),
allowNull: true,
},
status: {
type: DataTypes.ENUM("active", "inactive"),
defaultValue: "inactive",
allowNull: false,
},
},
{
sequelize,
modelName: "User",
tableName: "users",
timestamps: true,
hooks: {
// Los tres hooks que crean/actualizan el hash. Cualquier ruta de escritura
// (create, update, bulkCreate del seeder) pasa por aquí: no hay forma de
// persistir una contraseña en claro.
beforeCreate: async (user: User) => {
if (user.password) {
user.password = await hashPassword(user.password);
}
},
beforeUpdate: async (user: User) => {
if (user.changed("password") && user.password) {
user.password = await hashPassword(user.password);
}
},
beforeBulkCreate: async (users: User[]) => {
for (const user of users) {
if (user.password) {
user.password = await hashPassword(user.password);
}
}
},
// Normalización: `username` y `email` siempre en minúsculas y sin espacios.
// Se hace antes de validar para que el `isEmail`/`len` juzgue el valor final
// y para que el login (que compara por igualdad) sea predecible.
beforeValidate: (user: User) => {
if (user.username) user.username = user.username.trim().toLowerCase();
if (user.email) user.email = user.email.trim().toLowerCase();
},
},
}
);
EOF
Role:
: > src/features/auth/roles/role.model.ts
cat >> src/features/auth/roles/role.model.ts << 'EOF'
import { DataTypes, Model } from "sequelize";
import { sequelize } from "../../../database/db";
/**
* Modelo `Role` (tabla `roles`) — agrupador lógico de responsabilidades.
*
* Nota de diseño: **el nombre del rol no autoriza nada**. La autorización se
* decide por las concesiones (`resource_roles`) asociadas al rol. Un rol
* `ADMIN` sin concesiones activas no habilita ninguna operación.
*/
export interface RoleI {
id?: number;
name: string;
description?: string | null;
status: "active" | "inactive";
createdAt?: Date;
updatedAt?: Date;
}
export class Role extends Model {
public id!: number;
public name!: string;
public description!: string | null;
public status!: "active" | "inactive";
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
}
Role.init(
{
name: {
type: DataTypes.STRING(80),
allowNull: false,
unique: "uq_roles_name",
validate: {
notEmpty: { msg: "Role name cannot be empty" },
},
},
description: {
type: DataTypes.STRING(255),
allowNull: true,
},
status: {
type: DataTypes.ENUM("active", "inactive"),
defaultValue: "inactive",
allowNull: false,
},
},
{
sequelize,
modelName: "Role",
tableName: "roles",
timestamps: true,
hooks: {
// El nombre del rol se normaliza a MAYÚSCULAS (ADMIN, SELLER, BUYER):
// es un identificador funcional, no una etiqueta libre.
beforeValidate: (role: Role) => {
if (role.name) role.name = role.name.trim().toUpperCase();
},
},
}
);
EOF
Resource:
: > src/features/auth/resources/resource.model.ts
cat >> src/features/auth/resources/resource.model.ts << 'EOF'
import { DataTypes, Model } from "sequelize";
import { sequelize } from "../../../database/db";
import { normalizePath } from "../../../shared/auth/resource-match";
/**
* Modelo `Resource` (tabla `resources`) — un punto de acceso protegible.
*
* Un recurso **no** es una entidad de negocio: es el par `(method, path)`.
* `GET /api/productos` y `POST /api/productos` son **dos recursos distintos**.
*
* Las rutas se guardan con el patrón, no con el valor concreto:
* `/api/productos/:id`. Así no se crea una fila por cada identificador y la
* coincidencia se resuelve por patrón (`shared/auth/resource-match.ts`).
*/
export interface ResourceI {
id?: number;
method: string;
path: string;
description?: string | null;
status: "active" | "inactive";
createdAt?: Date;
updatedAt?: Date;
}
export class Resource extends Model {
public id!: number;
public method!: string;
public path!: string;
public description!: string | null;
public status!: "active" | "inactive";
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
}
Resource.init(
{
method: {
type: DataTypes.STRING(10),
allowNull: false,
validate: {
isIn: {
args: [["GET", "POST", "PUT", "PATCH", "DELETE"]],
msg: "Method must be one of GET, POST, PUT, PATCH, DELETE",
},
},
},
path: {
type: DataTypes.STRING(255),
allowNull: false,
validate: {
notEmpty: { msg: "Path cannot be empty" },
},
},
description: {
type: DataTypes.STRING(255),
allowNull: true,
},
status: {
type: DataTypes.ENUM("active", "inactive"),
defaultValue: "inactive",
allowNull: false,
},
},
{
sequelize,
modelName: "Resource",
tableName: "resources",
timestamps: true,
// Clave única compuesta: el mismo verbo con distinta ruta (o al revés) son
// recursos distintos, pero la tupla exacta no se repite.
indexes: [
{
name: "uq_resources_method_path",
unique: true,
fields: ["method", "path"],
},
],
hooks: {
// Normalización: verbo en mayúsculas y ruta sin barra final ni duplicados,
// para que la comparación por patrón sea determinista.
beforeValidate: (resource: Resource) => {
if (resource.method) resource.method = resource.method.trim().toUpperCase();
if (resource.path) resource.path = normalizePath(resource.path.trim());
},
},
}
);
EOF
RoleUser:
: > src/features/auth/role-users/role-user.model.ts
cat >> src/features/auth/role-users/role-user.model.ts << 'EOF'
import { DataTypes, Model } from "sequelize";
import { sequelize } from "../../../database/db";
/**
* Modelo `RoleUser` (tabla `role_users`) — asignación N:M `User` ↔ `Role`.
*
* Es el **primer eslabón** de la cadena de autorización. Un usuario sin filas
* activas aquí no tiene ningún permiso granular, aunque tenga roles asignados
* con estado `inactive`.
*
* La restricción única `(user_id, role_id)` impide duplicar la asignación:
* revocar y volver a conceder se hace cambiando `status`, no insertando filas.
*/
export interface RoleUserI {
id?: number;
user_id: number;
role_id: number;
status: "active" | "inactive";
createdAt?: Date;
updatedAt?: Date;
}
export class RoleUser extends Model {
public id!: number;
public user_id!: number;
public role_id!: number;
public status!: "active" | "inactive";
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
}
RoleUser.init(
{
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
},
role_id: {
type: DataTypes.INTEGER,
allowNull: false,
},
status: {
type: DataTypes.ENUM("active", "inactive"),
defaultValue: "inactive",
allowNull: false,
},
},
{
sequelize,
modelName: "RoleUser",
tableName: "role_users",
timestamps: true,
indexes: [
{ name: "uq_role_users_user_role", unique: true, fields: ["user_id", "role_id"] },
{ name: "ix_role_users_user_id", fields: ["user_id"] },
{ name: "ix_role_users_role_id", fields: ["role_id"] },
],
}
);
EOF
ResourceRole:
: > src/features/auth/resource-roles/resource-role.model.ts
cat >> src/features/auth/resource-roles/resource-role.model.ts << 'EOF'
import { DataTypes, Model } from "sequelize";
import { sequelize } from "../../../database/db";
/**
* Modelo `ResourceRole` (tabla `resource_roles`) — la **concesión** `Role` ↔ `Resource`.
*
* Esta tabla **es el permiso**. No existe una entidad `Permission`: el permiso
* es la tupla `(rol, recurso)` materializada aquí.
*
* - Conceder acceso -> insertar o reactivar una fila.
* - Retirar acceso -> `status = inactive`.
* - Cambiar la matriz -> no requiere código ni despliegue.
*/
export interface ResourceRoleI {
id?: number;
role_id: number;
resource_id: number;
status: "active" | "inactive";
createdAt?: Date;
updatedAt?: Date;
}
export class ResourceRole extends Model {
public id!: number;
public role_id!: number;
public resource_id!: number;
public status!: "active" | "inactive";
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
}
ResourceRole.init(
{
role_id: {
type: DataTypes.INTEGER,
allowNull: false,
},
resource_id: {
type: DataTypes.INTEGER,
allowNull: false,
},
status: {
type: DataTypes.ENUM("active", "inactive"),
defaultValue: "inactive",
allowNull: false,
},
},
{
sequelize,
modelName: "ResourceRole",
tableName: "resource_roles",
timestamps: true,
indexes: [
{
name: "uq_resource_roles_role_resource",
unique: true,
fields: ["role_id", "resource_id"],
},
{ name: "ix_resource_roles_role_id", fields: ["role_id"] },
{ name: "ix_resource_roles_resource_id", fields: ["resource_id"] },
],
}
);
EOF
RefreshToken:
: > src/features/auth/refresh-tokens/refresh-token.model.ts
cat >> src/features/auth/refresh-tokens/refresh-token.model.ts << 'EOF'
import { DataTypes, Model } from "sequelize";
import { sequelize } from "../../../database/db";
/**
* Modelo `RefreshToken` (tabla `refresh_tokens`) — sesión renovable y revocable.
*
* Es el **único** artefacto de sesión que se persiste. El access token (JWT) es
* autocontenido y no se guarda.
*
* Campos de seguridad:
* - `token_hash`: solo se almacena el SHA-256 del token opaco. Aunque se
* filtrara la tabla, no se puede reconstruir un token utilizable. Permite
* buscar por índice único en O(1).
* - `family_id`: agrupa todos los tokens derivados de un mismo login por
* rotación. Si un token ya rotado se reutiliza, se revoca **toda la familia**
* (detección de reutilización, Owasp/OAuth2).
* - `expires_at`: vigencia; un token vencido se trata como inválido.
* - `device_info`: soporte de auditoría y de listado de sesiones por dispositivo.
*
* Desviación deliberada: `status` predetermina **`active`**. Un token recién
* emitido nace vigente por definición, a diferencia del resto de tablas.
*/
export interface RefreshTokenI {
id?: number;
user_id: number;
token_hash: string;
family_id: string;
device_info?: string | null;
expires_at: Date;
status: "active" | "inactive";
createdAt?: Date;
updatedAt?: Date;
}
export class RefreshToken extends Model {
public id!: number;
public user_id!: number;
public token_hash!: string;
public family_id!: string;
public device_info!: string | null;
public expires_at!: Date;
public status!: "active" | "inactive";
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
}
RefreshToken.init(
{
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
},
token_hash: {
type: DataTypes.STRING(255),
allowNull: false,
unique: "uq_refresh_tokens_token_hash",
},
family_id: {
type: DataTypes.STRING(100),
allowNull: false,
},
device_info: {
type: DataTypes.STRING(500),
allowNull: true,
},
expires_at: {
type: DataTypes.DATE,
allowNull: false,
},
status: {
type: DataTypes.ENUM("active", "inactive"),
// Única tabla cuyo estado por defecto es `active` (ver doc del modelo).
defaultValue: "active",
allowNull: false,
},
},
{
sequelize,
modelName: "RefreshToken",
tableName: "refresh_tokens",
timestamps: true,
indexes: [
{ name: "ix_refresh_tokens_family_id", fields: ["family_id"] },
{ name: "ix_refresh_tokens_user_id", fields: ["user_id"] },
],
}
);
EOF
14.9 rbac.associations.ts — el grafo en un solo lugar
Las asociaciones se declaran después de los modelos (referencian a los modelos, no al revés) y en un único archivo para que el grafo se lea entero:
: > src/features/auth/rbac.associations.ts
cat >> src/features/auth/rbac.associations.ts << 'EOF'
import { User } from "./users/user.model";
import { Role } from "./roles/role.model";
import { Resource } from "./resources/resource.model";
import { RoleUser } from "./role-users/role-user.model";
import { ResourceRole } from "./resource-roles/resource-role.model";
import { RefreshToken } from "./refresh-tokens/refresh-token.model";
/**
* Asociaciones de las seis entidades de seguridad.
*
* Se declaran en un solo archivo (y no dispersas por feature) porque la
* autorización es una **cadena** que atraviesa cinco tablas; verla junta hace
* evidente el camino que recorre la consulta de permisos:
*
* ```text
* ResourceRole ──► Role ──► RoleUser ──► (filtro por user_id)
* │
* └────────► Resource ──► (method, path)
* ```
*
* Los alias (`as`) son los que usan los `include` de los repositories, así que
* cambiar un alias aquí obliga a revisar las consultas RBAC.
*/
// --- La concesión conoce su rol y su recurso (los dos extremos del permiso) ---
ResourceRole.belongsTo(Role, { foreignKey: "role_id", as: "role" });
ResourceRole.belongsTo(Resource, { foreignKey: "resource_id", as: "resource" });
Role.hasMany(ResourceRole, { foreignKey: "role_id", as: "resource_roles" });
Resource.hasMany(ResourceRole, { foreignKey: "resource_id", as: "resource_roles" });
// --- La asignación conoce su usuario y su rol (primer eslabón de la cadena) ---
RoleUser.belongsTo(User, { foreignKey: "user_id", as: "user" });
RoleUser.belongsTo(Role, { foreignKey: "role_id", as: "role" });
User.hasMany(RoleUser, { foreignKey: "user_id", as: "role_users" });
Role.hasMany(RoleUser, { foreignKey: "role_id", as: "role_users" });
// --- Las sesiones pertenecen a un usuario ---
RefreshToken.belongsTo(User, { foreignKey: "user_id", as: "user" });
User.hasMany(RefreshToken, { foreignKey: "user_id", as: "refresh_tokens" });
EOF
14.10 Cableado de modelos en config y seeders
PARCHE en src/config/index.ts — los seis modelos, y después las asociaciones:
cat >> src/config/index.ts << 'EOF'
import dotenv from "dotenv";
import express, { Application, ErrorRequestHandler } from "express";
import morgan from "morgan";
var cors = require("cors");
import { sequelize, getDatabaseInfo, testConnection } from "../database/db";
import "../features/business/clients/client.model";
import "../features/business/product-types/product-type.model";
import "../features/business/products/product.model";
import "../features/business/sales/sale.model";
import "../features/business/product-sales/product-sale.model";
import "../features/business/products/products.associations";
import "../features/business/sales/sales.associations";
import "../features/business/product-sales/product-sales.associations";
// Fase II — Auth con RBAC: primero los seis modelos, después las asociaciones
// (las asociaciones referencian los modelos, no al revés).
import "../features/auth/users/user.model";
import "../features/auth/roles/role.model";
import "../features/auth/resources/resource.model";
import "../features/auth/role-users/role-user.model";
import "../features/auth/resource-roles/resource-role.model";
import "../features/auth/refresh-tokens/refresh-token.model";
import "../features/auth/rbac.associations";
import { Routes } from "../routes/index";
import { setupSwagger } from "../swagger/index";
dotenv.config();
export class App {
public app: Application;
public routePrv: Routes = new Routes();
constructor(private port?: number | string) {
this.app = express();
this.settings();
this.middlewares();
this.routes();
this.docs();
this.errorHandling();
}
private settings(): void {
this.app.set('port', this.port || process.env.PORT || 4000);
}
private middlewares(): void {
this.app.use(morgan('dev'));
this.app.use(cors());
this.app.use(express.json());
this.app.use(express.urlencoded({ extended: false }));
}
private routes(): void {
// Fase I — Business (cada operación, modalidad JWT + RBAC)
this.routePrv.clientsRoutes.routes(this.app);
this.routePrv.productTypesRoutes.routes(this.app);
this.routePrv.productsRoutes.routes(this.app);
this.routePrv.salesRoutes.routes(this.app);
this.routePrv.productSalesRoutes.routes(this.app);
// Fase II — Auth con RBAC
// `sessionRoutes` registra los endpoints OPEN/JWT (login, refresh, logout,
// perfil, permisos); el resto son modalidad JWT + RBAC.
this.routePrv.sessionRoutes.routes(this.app);
this.routePrv.refreshTokensRoutes.routes(this.app);
this.routePrv.usersRoutes.routes(this.app);
this.routePrv.rolesRoutes.routes(this.app);
this.routePrv.resourcesRoutes.routes(this.app);
this.routePrv.roleUsersRoutes.routes(this.app);
this.routePrv.resourceRolesRoutes.routes(this.app);
}
private docs(): void {
setupSwagger(this.app);
}
/**
* Errores que ocurren **antes** de llegar a un controller o middleware.
*
* El caso típico es un cuerpo JSON malformado: `express.json()` lanza un
* `SyntaxError` que, sin manejador, cae en el de Express por defecto y responde
* 400 con un HTML que incluye el **stack trace y rutas absolutas del servidor**
* (fuga de información). Aquí se traduce a un 400 JSON limpio.
*
* Debe registrarse **después** de las rutas: Express reconoce un middleware de
* error por su aridad de 4 argumentos.
*/
private errorHandling(): void {
const bodyErrorHandler: ErrorRequestHandler = (err, _req, res, next) => {
if (err instanceof SyntaxError && "body" in err) {
res.status(400).json({ error: "Malformed JSON body" });
return;
}
next(err);
};
this.app.use(bodyErrorHandler);
}
private async dbConnection(): Promise<void> {
try {
const dbInfo = getDatabaseInfo();
console.log(`🔗 Intentando conectar a: ${dbInfo.engine.toUpperCase()}`);
const isConnected = await testConnection();
if (!isConnected) {
throw new Error(`No se pudo conectar a la base de datos ${dbInfo.engine.toUpperCase()}`);
}
// Lab: sync crea/altera tablas desde los modelos (BD limpia → snake_case desde cero).
const force = process.env.DB_SYNC_FORCE === "true";
const isMysql =
sequelize.getDialect() === "mysql" || sequelize.getDialect() === "mariadb";
if (isMysql) {
await sequelize.query("SET FOREIGN_KEY_CHECKS = 0");
}
try {
await sequelize.sync({ force, alter: !force });
} finally {
if (isMysql) {
await sequelize.query("SET FOREIGN_KEY_CHECKS = 1");
}
}
console.log(
force
? "📦 Base de datos recreada (DB_SYNC_FORCE=true)"
: "📦 Base de datos sincronizada exitosamente"
);
} catch (error) {
console.error("❌ Error al conectar con la base de datos:", error);
process.exit(1);
}
}
async listen() {
// Orden de arranque: primero la BD (conexión + `sync`), después abrir el puerto.
// Si se abre el puerto antes de terminar `sync({ alter: true })`, las sentencias
// DDL (ALTER TABLE, DROP/ADD FOREIGN KEY) compiten con las peticiones que ya
// están entrando y provocan deadlocks y errores de FK intermitentes.
await this.dbConnection();
await this.app.listen(this.app.get('port'));
console.log(`🚀 Servidor ejecutándose en puerto ${this.app.get('port')}`);
}
}
EOF
PARCHE en src/database/seeders/index.ts — mismo bloque de imports (los seeders de auth se añaden en sus ISS). El orden importa: Sequelize solo conoce las asociaciones que se han declarado.
El detalle de estos dos archivos completos, ya con las rutas y los seeders de todos los ISS, está en
18-cierre-auth.md.
Verificación
npx tsc --noEmit
npm run db:seed # sync({ alter: true }) crea las 6 tablas vacías
npm run dev # el servidor debe arrancar
# Las 6 tablas existen
mysql -u admin -p tecnogua -e "SHOW TABLES LIKE '%role%'; SHOW TABLES LIKE 'users';"
DoD del ISS-09
- [ ] Todos los criterios de aceptación (14.1 … 14.10) cumplidos
- [ ]
npx tsc --noEmitsin errores - [ ]
npm run db:seedcreausers,roles,resources,role_users,resource_roles,refresh_tokenscon sus UK - [ ]
npm run devarranca el servidor - [ ] Sin endpoints nuevos todavía: la API de Fase I sigue funcionando SIN AUTH (la protección llega en ISS-13)
✅ GATE de la unidad ISS-09 — este bloque no añade ningún criterio nuevo.
Las condiciones de cierre son exactamente las que define el ISS técnico de esta misma página:
- 📋 Criterios de aceptación → Criterios de aceptación
- 🧩 Cierre de la unidad → Cierre de la unidad
- 🧠 Autoevaluación → Evaluación del cuaderno
Con el GATE en verde queda habilitado ISS-10 · Feature Users (identidad y contraseña).
Navegación de la ruta: ← ISS-09 · 🧠 Aprender · ↑ Ruta Express · → ISS-10 · 🧠 Aprender