🛠 Unidad ISS-13 · Middlewares de acceso y las 3 modalidades — capa 🛠 CONSTRUIR
🧠 Comprender este bloque → · 📝 Evaluación · ✅ GATE de la unidad
Capa Página Para qué 🧠 Aprender Middlewares de acceso y las 3 modalidades 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-13 — Middlewares de acceso y las tres modalidades en rutas
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, ISS-09 base, ISS-10 Users, ISS-11 Roles/Resources y ISS-12 pivotes. - Recorrido obligatorio de una petición:HTTP → Controller → Service → Repository → Model → Sequelize → BD. Además, en este ISS se insertan middlewares entreHTTPyController. - Diseño de datos (Fase II):../bd-storelab.md§16 (autorización efectiva), §18 (tres formas de acceder a la BD) y §20 (deny by default). - Capas, convenciones y reglas transversales:00-contexto.md.
Este ISS Título Middlewares de acceso y las tres modalidades en rutas Feature / tablas features/auth/access/+ PARCHE de las 5 rutas de negocioAPI todas las de Fase I pasan a JWT + RBAC Depende de ISS-12 — RoleUsers y ResourceRoles Habilita ISS-14 — Feature RefreshTokens
Contenido de este ISS
- 18.1
authenticate.middleware.ts(modalidad JWT) - 18.2
authorize.middleware.ts(modalidad JWT + RBAC) - 18.3
access/index.ts(barrel) - 18.4 PARCHE: las 5 rutas de negocio pasan a JWT + RBAC
- 18.5 Las tres modalidades en una tabla
- 18.6 Verificación de 401 y 403
Objetivo: materializar las tres modalidades mediante dos middlewares componibles y aplicarlos a las rutas existentes sin tocar controllers, services ni repositories.
OPEN app.route(...).get(controller)
JWT app.route(...).get(authenticate, controller)
JWT + RBAC app.route(...).get(authenticate, authorize, controller)
Bloqueado por: ISS-12 (la matriz debe existir para que authorize tenga algo que consultar).
Criterios de aceptación (ISS-13) — consolidados
- [ ] 18.1
authenticatevalida el Bearer token, verifica algoritmo/issuer/audience/exp y carga el usuario activo enreq.auth - [ ] 18.2
authorizeresuelve(method, path)y busca concesión activa; deny by default → 403 - [ ] 18.3
access/index.tsreexporta ambos middlewares - [ ] 18.4 las 5 features de negocio (
clients,product-types,products,sales,product-sales) aplicanauthenticate, authorize - [ ] 18.5 documentadas las tres modalidades y qué códigos produce cada una
- [ ] 18.6 sin token → 401; con token pero sin concesión → 403; con concesión → 200/201
- [ ]
npx tsc --noEmitOK
18.1 authenticate — modalidad JWT
Hace exactamente cuatro cosas, en este orden:
- Lee el encabezado
Authorization: Bearer <token>(RFC 6750). Si falta o está mal formado → 401. - Verifica el JWT con algoritmo, emisor y audiencia fijos (RFC 8725). Si falla → 401.
- Carga el usuario en BD y exige
status = 'active'. Si no existe o está inactivo → 401. - Deja la identidad en
req.auth(tipado porauth-user.ts) y llama anext().
No consulta roles ni permisos. La autorización es responsabilidad del siguiente middleware: mezclar ambas impediría tener endpoints solo-JWT.
: > src/features/auth/access/authenticate.middleware.ts
cat >> src/features/auth/access/authenticate.middleware.ts << 'EOF'
import { NextFunction, Request, Response } from "express";
import { AppError } from "../../../shared/errors/app-error";
import { sendError } from "../../../shared/http/error-response";
import { extractBearerToken, verifyAccessToken } from "../../../shared/auth/jwt";
import { UsersRepository } from "../users/users.repository";
/**
* **MODALIDAD 2 — JWT (identidad).** Middleware de autenticación.
*
* Responde únicamente a la pregunta **¿quién eres?**:
*
* 1. Lee el token de `Authorization: Bearer <token>` (RFC 6750).
* 2. Verifica firma, algoritmo, `iss`, `aud`, `exp` (RFC 8725).
* 3. **Revalida contra la base de datos** que el usuario sigue existiendo y con
* `status = active`. Un token firmado sigue siendo válido después de
* desactivar la cuenta; esta revalidación hace que la desactivación tenga
* efecto inmediato.
*
* NO consulta la matriz de permisos: eso es responsabilidad de `authorize`.
* Si todo va bien, deja la identidad en `req.auth` y cede el paso.
*
* Cualquier fallo se responde con **401 (no autenticado)**.
*/
const usersRepository = new UsersRepository();
export async function authenticate(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const token = extractBearerToken(req.headers.authorization);
if (!token) {
throw new AppError(401, "Missing Bearer token");
}
const payload = verifyAccessToken(token);
// Defensa en profundidad: `verifyAccessToken` ya garantiza que `sub` es un
// entero positivo. Se vuelve a comprobar para que ningún cambio futuro en la
// verificación pueda enviar un `NaN` al repositorio (500 en vez de 401).
const userId = Number(payload.sub);
if (!Number.isInteger(userId) || userId < 1) {
throw new AppError(401, "Invalid or expired access token");
}
const user = await usersRepository.findById(userId);
if (!user || user.status !== "active") {
throw new AppError(401, "User is not active");
}
req.auth = {
id: user.id,
username: user.username,
email: user.email,
tokenId: payload.jti,
};
next();
} catch (error) {
sendError(res, error);
}
}
EOF
18.2 authorize — modalidad JWT + RBAC
Recibe la petición, normaliza el (method, path) real y comprueba si el usuario autenticado alcanza ese recurso por el grafo:
req.auth.user
→ role_users (active)
→ roles (active)
→ resource_roles (active)
→ resources (active, method = req.method, path ≈ req.path)
Si no hay concesión → 403 (deny by default). Como la consulta se hace en cada petición, revocar un permiso tiene efecto inmediato (no hay que esperar a que caduque el token, porque los permisos no viajan en él).
: > src/features/auth/access/authorize.middleware.ts
cat >> src/features/auth/access/authorize.middleware.ts << 'EOF'
import { NextFunction, Request, Response } from "express";
import { AppError } from "../../../shared/errors/app-error";
import { sendError } from "../../../shared/http/error-response";
import { isOperationGranted, normalizePath } from "../../../shared/auth/resource-match";
import { ResourceRolesRepository } from "../resource-roles/resource-roles.repository";
/**
* **MODALIDAD 3 — RBAC (identidad + autorización granular).** Middleware de
* autorización.
*
* Debe montarse **después** de `authenticate`. Responde a la segunda pregunta:
* *¿puede esta identidad ejecutar `method + path`?*
*
* Cómo resuelve la decisión:
* 1. Toma la identidad ya resuelta en `req.auth`.
* 2. Consulta la **cadena completa** de autorización en la base de datos
* (`resource_roles → roles → role_users → resources`, todos los eslabones
* activos) para ese `user_id`.
* 3. Compara el par `(method, path)` de la petición con las concesiones,
* por patrón (`/api/productos/:id` casa con `/api/productos/42`).
*
* Reglas:
* - **Deny by default**: sin concesión activa que cubra la operación -> 403.
* - **401** si no hay identidad (falta `authenticate` o el token no valió).
* - **403** si hay identidad válida pero no hay permiso.
*
* No recibe parámetros: el recurso y la acción se derivan de la propia petición.
* Añadir un permiso es insertar filas en la base de datos, nunca tocar el código.
*/
const resourceRolesRepository = new ResourceRolesRepository();
export async function authorize(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
if (!req.auth) {
throw new AppError(401, "Authentication required");
}
const method = req.method.toUpperCase();
const path = normalizePath(req.originalUrl);
const granted = await resourceRolesRepository.findEffectiveForUser(req.auth.id);
if (!isOperationGranted(granted, method, path)) {
throw new AppError(403, `Forbidden: no grant for ${method} ${path}`);
}
next();
} catch (error) {
sendError(res, error);
}
}
EOF
18.3 Barrel de acceso
: > src/features/auth/access/index.ts
cat >> src/features/auth/access/index.ts << 'EOF'
export * from "./authenticate.middleware";
export * from "./authorize.middleware";
EOF
18.4 PARCHE: las 5 rutas de negocio pasan a JWT + RBAC
El cambio es quirúrgico: se importa authenticate, authorize desde el barrel de auth y se insertan entre la ruta y el controller. Ni el contrato de la API ni las capas de negocio cambian.
import { authenticate, authorize } from "../../auth/access";
// ...
app.route("/api/clientes/:id").get(
authenticate,
authorize,
this.clientsController.getOne.bind(this.clientsController)
);
PARCHE en clients.routes.ts:
: > src/features/business/clients/clients.routes.ts
cat >> src/features/business/clients/clients.routes.ts << 'EOF'
import { Application } from "express";
import { ClientsController } from "./clients.controller";
import { authenticate, authorize } from "../../auth/access";
/**
* Rutas del feature Clients.
*
* **Modalidad 3 — JWT + RBAC**: `authenticate` resuelve la identidad (401 si no
* hay token válido o el usuario está inactivo) y `authorize` decide sobre el par
* `(method, path)` (403 si no hay concesión activa). El rol `SELLER` recibe solo
* las lecturas; `ADMIN`, las 7 operaciones.
*/
export class ClientsRoutes {
public clientsController: ClientsController = new ClientsController();
public routes(app: Application): void {
// getAll
app
.route("/api/clientes")
.get(authenticate, authorize, this.clientsController.getAll.bind(this.clientsController));
// getOne
app
.route("/api/clientes/:id")
.get(authenticate, authorize, this.clientsController.getOne.bind(this.clientsController));
// create
app
.route("/api/clientes")
.post(authenticate, authorize, this.clientsController.create.bind(this.clientsController));
// update (PUT / PATCH)
app
.route("/api/clientes/:id")
.put(authenticate, authorize, this.clientsController.updatePut.bind(this.clientsController))
.patch(
authenticate,
authorize,
this.clientsController.updatePatch.bind(this.clientsController)
);
// delete físico
app
.route("/api/clientes/:id")
.delete(
authenticate,
authorize,
this.clientsController.deletePhysical.bind(this.clientsController)
);
// delete lógico
app
.route("/api/clientes/:id/deactivate")
.patch(
authenticate,
authorize,
this.clientsController.deleteLogical.bind(this.clientsController)
);
}
}
EOF
PARCHE en product-types.routes.ts:
: > src/features/business/product-types/product-types.routes.ts
cat >> src/features/business/product-types/product-types.routes.ts << 'EOF'
import { Application } from "express";
import { ProductTypesController } from "./product-types.controller";
import { authenticate, authorize } from "../../auth/access";
/**
* Rutas del feature ProductTypes.
*
* **Modalidad 3 — JWT + RBAC.** Cada operación exige, en este orden:
* 1. `authenticate` -> valida el access token y revalida que el usuario siga activo (401 si no);
* 2. `authorize` -> comprueba la concesión del par `(method, path)` en la matriz RBAC (403 si no);
* 3. el handler del controller.
*
* El catálogo de recursos incluye estas 7 operaciones
* (`/api/tipos-producto`, `/api/tipos-producto/:id`, ...): concederlas a un rol
* no requiere tocar este archivo.
*/
export class ProductTypesRoutes {
public productTypesController: ProductTypesController = new ProductTypesController();
public routes(app: Application): void {
// getAll
app
.route("/api/tipos-producto")
.get(
authenticate,
authorize,
this.productTypesController.getAll.bind(this.productTypesController)
);
// getOne
app
.route("/api/tipos-producto/:id")
.get(
authenticate,
authorize,
this.productTypesController.getOne.bind(this.productTypesController)
);
// create
app
.route("/api/tipos-producto")
.post(
authenticate,
authorize,
this.productTypesController.create.bind(this.productTypesController)
);
// update (PUT / PATCH)
app
.route("/api/tipos-producto/:id")
.put(
authenticate,
authorize,
this.productTypesController.updatePut.bind(this.productTypesController)
)
.patch(
authenticate,
authorize,
this.productTypesController.updatePatch.bind(this.productTypesController)
);
// delete físico
app
.route("/api/tipos-producto/:id")
.delete(
authenticate,
authorize,
this.productTypesController.deletePhysical.bind(this.productTypesController)
);
// delete lógico
app
.route("/api/tipos-producto/:id/deactivate")
.patch(
authenticate,
authorize,
this.productTypesController.deleteLogical.bind(this.productTypesController)
);
}
}
EOF
PARCHE en products.routes.ts:
: > src/features/business/products/products.routes.ts
cat >> src/features/business/products/products.routes.ts << 'EOF'
import { Application } from "express";
import { ProductsController } from "./products.controller";
import { authenticate, authorize } from "../../auth/access";
/**
* Rutas del feature Products.
*
* **Modalidad 3 — JWT + RBAC**: `authenticate` (401 si no hay identidad válida)
* seguido de `authorize` (403 si la matriz no concede el par `(method, path)`).
*/
export class ProductsRoutes {
public productsController: ProductsController = new ProductsController();
public routes(app: Application): void {
// getAll
app
.route("/api/productos")
.get(authenticate, authorize, this.productsController.getAll.bind(this.productsController));
// getOne
app
.route("/api/productos/:id")
.get(authenticate, authorize, this.productsController.getOne.bind(this.productsController));
// create
app
.route("/api/productos")
.post(authenticate, authorize, this.productsController.create.bind(this.productsController));
// update (PUT / PATCH)
app
.route("/api/productos/:id")
.put(authenticate, authorize, this.productsController.updatePut.bind(this.productsController))
.patch(
authenticate,
authorize,
this.productsController.updatePatch.bind(this.productsController)
);
// delete físico
app
.route("/api/productos/:id")
.delete(
authenticate,
authorize,
this.productsController.deletePhysical.bind(this.productsController)
);
// delete lógico
app
.route("/api/productos/:id/deactivate")
.patch(
authenticate,
authorize,
this.productsController.deleteLogical.bind(this.productsController)
);
}
}
EOF
PARCHE en sales.routes.ts:
: > src/features/business/sales/sales.routes.ts
cat >> src/features/business/sales/sales.routes.ts << 'EOF'
import { Application } from "express";
import { SalesController } from "./sales.controller";
import { authenticate, authorize } from "../../auth/access";
/**
* Rutas del feature Sales.
*
* **Modalidad 3 — JWT + RBAC**. El rol `SELLER` recibe las 3 concesiones de este
* feature (`GET /api/ventas`, `GET /api/ventas/:id`, `POST /api/ventas`); el
* resto de verbos quedan solo para `ADMIN`.
*/
export class SalesRoutes {
public salesController: SalesController = new SalesController();
public routes(app: Application): void {
// getAll
app
.route("/api/ventas")
.get(authenticate, authorize, this.salesController.getAll.bind(this.salesController));
// getOne
app
.route("/api/ventas/:id")
.get(authenticate, authorize, this.salesController.getOne.bind(this.salesController));
// create (registrar venta)
app
.route("/api/ventas")
.post(authenticate, authorize, this.salesController.create.bind(this.salesController));
// update (PUT / PATCH)
app
.route("/api/ventas/:id")
.put(authenticate, authorize, this.salesController.updatePut.bind(this.salesController))
.patch(authenticate, authorize, this.salesController.updatePatch.bind(this.salesController));
// delete físico
app
.route("/api/ventas/:id")
.delete(
authenticate,
authorize,
this.salesController.deletePhysical.bind(this.salesController)
);
// delete lógico
app
.route("/api/ventas/:id/deactivate")
.patch(authenticate, authorize, this.salesController.deleteLogical.bind(this.salesController));
}
}
EOF
PARCHE en product-sales.routes.ts:
: > src/features/business/product-sales/product-sales.routes.ts
cat >> src/features/business/product-sales/product-sales.routes.ts << 'EOF'
import { Application } from "express";
import { ProductSalesController } from "./product-sales.controller";
import { authenticate, authorize } from "../../auth/access";
/**
* Rutas del feature ProductSales (tabla `product_sales`).
*
* **Modalidad 3 — JWT + RBAC**. Solo `ADMIN` recibe la concesión de
* `GET /api/detalle-ventas`; el detalle de una venta concreta se consulta a
* través de `GET /api/ventas/:id` (que sí concede `SELLER`).
*/
export class ProductSalesRoutes {
public productSalesController: ProductSalesController = new ProductSalesController();
public routes(app: Application): void {
// getAll
app
.route("/api/detalle-ventas")
.get(
authenticate,
authorize,
this.productSalesController.getAll.bind(this.productSalesController)
);
// getOne
app
.route("/api/detalle-ventas/:id")
.get(
authenticate,
authorize,
this.productSalesController.getOne.bind(this.productSalesController)
);
// create
app
.route("/api/detalle-ventas")
.post(
authenticate,
authorize,
this.productSalesController.create.bind(this.productSalesController)
);
// update (PUT / PATCH)
app
.route("/api/detalle-ventas/:id")
.put(
authenticate,
authorize,
this.productSalesController.updatePut.bind(this.productSalesController)
)
.patch(
authenticate,
authorize,
this.productSalesController.updatePatch.bind(this.productSalesController)
);
// delete físico
app
.route("/api/detalle-ventas/:id")
.delete(
authenticate,
authorize,
this.productSalesController.deletePhysical.bind(this.productSalesController)
);
// delete lógico
app
.route("/api/detalle-ventas/:id/deactivate")
.patch(
authenticate,
authorize,
this.productSalesController.deleteLogical.bind(this.productSalesController)
);
}
}
EOF
18.5 Las tres modalidades en una tabla
| Modalidad | Middleware en la ruta | Qué exige | Sin cumplir |
|---|---|---|---|
| OPEN | — | nada | — |
| JWT | authenticate |
access token válido y usuario activo | 401 |
| JWT + RBAC | authenticate, authorize |
token válido y concesión activa de (method, path) |
401 (sin token) / 403 (sin permiso) |
| Petición | Resultado |
|---|---|
GET /api/clientes sin Authorization |
401 |
GET /api/clientes con token de seller |
200 (SELLER tiene esa lectura) |
POST /api/clientes con token de seller |
403 (SELLER no tiene esa concesión) |
POST /api/clientes con token de admin |
201 (ADMIN tiene los 58) |
GET /api/clientes/abc con token válido |
400 (paramId) |
| Cualquier ruta con token caducado o manipulado | 401 |
18.6 Verificación de 401 y 403
# 401 — sin token
curl -i http://localhost:4000/api/clientes
# 401 — token manipulado
curl -i -H "Authorization: Bearer no.es.un.jwt" http://localhost:4000/api/clientes
# 200 — seller lee
TOKEN=$(curl -s -X POST http://localhost:4000/api/sesion/login \
-H "Content-Type: application/json" \
-d '{"identifier":"seller","password":"Seller123!"}' | node -pe "JSON.parse(require('fs').readFileSync(0)).access_token")
curl -i -H "Authorization: Bearer $TOKEN" http://localhost:4000/api/clientes
# 403 — seller intenta crear (no tiene la concesión)
curl -i -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"x","phone":"1","email":"x@x.com","password":"x"}' \
http://localhost:4000/api/clientes
DoD del ISS-13
- [ ] Todos los criterios de aceptación (18.1 … 18.6) cumplidos
- [ ]
GET /api/clientespasa de SIN AUTH a exigir token (401 sin él) - [ ]
POST /api/clientesconseller→ 403; conadmin→ 201 - [ ] Los controllers, services y repositories de Fase I no fueron modificados
- [ ]
npx tsc --noEmitsin errores ynpm run devarranca
✅ GATE de la unidad ISS-13 — 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-14 · Feature RefreshTokens (sesiones).
Navegación de la ruta: ← ISS-13 · 🧠 Aprender · ↑ Ruta Express · → ISS-14 · 🧠 Aprender