flowchart TD
R["Request GET /tasks/42"] --> A{"¿token válido?<br/><i>autenticación</i>"}
A -->|no| E1["401"]
A -->|sí| O{"¿la tarea es tuya?<br/><i>autorización</i>"}
O -->|"no existe O no es tuya"| E4["404<br/><i>indistinguibles a propósito</i>"]
O -->|sí| R2{"¿rol suficiente?<br/><i>viewer no muta</i>"}
R2 -->|viewer intenta DELETE| E3["403<br/><i>sí la ves, no la tocas</i>"]
R2 -->|editor/admin| H["handler"]
26 17. Necesitamos que autenticado no signifique puede todo
Autenticación ≠ autorización
El cap. 16 respondió quién eres; falta qué puedes tocar. Ahora mismo cualquier usuario logueado puede DELETE /tasks/{id} de otro — autenticado sí, autorizado no. Este capítulo implementa ownership (“¿esta tarea es tuya?”) y roles (viewer/editor/admin), y la decisión fina: cuándo responder 403 y cuándo responder 404 a propósito.
26.1 El problema
Autenticación y autorización se confunden porque suenan parecido — son dos preguntas distintas:
La autorización responde “¿qué puedes hacer?”: eres usuario válido (autenticado), pero ¿puedes borrar esta tarea? Se decide por rol o por ownership — y se verifica en cada request, no se recuerda.
- Autenticación (cap. 15–16): ¿quién eres? → 401 si no sabemos.
- Autorización (este cap.): ya sé quién eres — ¿esto es tuyo? → 403 si sabemos y no puedes; 404 si ni siquiera debes saber que existe.
El bug clásico se llama IDOR (Insecure Direct Object Reference): la URL dice /tasks/41, cambias a 42, y ves la tarea de otro. La API verificó quién eres pero nunca de quién es eso. En Taskflow: Ana abre DevTools, cambia el id, borra las tareas de Bruno. La autenticación funcionó perfecto — eso es lo que la hace peligrosa.
Una API (Application Programming Interface) es el menú de un programa: la lista de operaciones que otros programas pueden pedirle. Tu frontend no habla con la base de datos — le pide cosas a la API, y la API decide.
La autenticación responde “¿quién eres?” — login, contraseña, token. Se confunde con autorización (“¿qué puedes hacer?”), que es la pregunta siguiente. Primero te identificas, luego te dejan o no pasar.
26.2 Cómo lo resuelve un equipo
Dos niveles, en orden de lo que Taskflow necesita:
- Ownership: la regla
task.user_id == current_user.id. El 90% de la autorización real es esto — “solo tocas lo tuyo”. Y la forma más robusta no esif task.user_id != user.id: raise 404después de leerla: es que la query misma filtre por dueño —WHERE public_id = $1 AND user_id = $2. Si no es tuya, la query devuelve cero filas: para ti, esa tarea no existe → 404. - Roles: cuando recursos se comparten (un proyecto con colaboradores), ownership no basta — se modela en la relación:
project_members(user_id, project_id, role)conviewer/editor/admin. El rol no vive en el usuario (sería global: “eres admin de TODO”), vive en la membresía: eres editor de este proyecto.
return hace dos cosas a la vez: devuelve el resultado Y termina la función — lo que esté debajo nunca corre. return task = “aquí está el plato, salgo de la cocina”.
Una query es la pregunta que le haces a la base de datos en SQL: SELECT * FROM tasks WHERE done = false. La BD traduce la pregunta a un plan de búsqueda — por eso los índices importan.
Los roles agrupan permisos: viewer lee, editor escribe, admin gestiona. RBAC = el permiso depende del rol que tienes en ese recurso — puedes ser admin de un equipo y viewer de otro.
Un recurso es la “cosa” que tu API maneja: tasks, users, projects. Las URLs los nombran (/tasks/5), los verbos actúan sobre ellos. Diseñar REST = nombrar bien tus recursos.
Y la regla de seguridad que cuesta entender: recursos ajenos responden 404, no 403. Si devuelves 403 confirmas “existe pero no es tuyo” — el atacante acaba de aprender que task/42 existe y puede enumerar cuántas hay. Con 404, la respuesta es idéntica a “no existe”: no hay nada que enumerar.
26.3 Conceptos nuevos
- U Ownership: “¿esta tarea es tuya?” — filtrado en la query, no en el if
- U Roles en la relación:
project_members.role— editor de esto, no admin de todo - U 401 vs 403 vs 404: quién eres / lo sé y no puedes / no debes saber que existe
- U Enumeración: qué le regala un 403 al atacante
- TS Py Go Factories:
requireRole("editor")— funciones que devuelven middleware
26.4 La explicación visual
Fíjate: 404 y 403 no son intercambiables — comunican cosas distintas a propósito.
26.5 Implementación
Taskflow agrega proyectos compartidos: project_members define tu rol en ese proyecto. La protección es un factory: requireRole("editor") devuelve el middleware/dependency que comprueba ese rol — así la misma lógica sirve para viewer, editor y admin.
Un middleware es un filtro en la cadena del request: pasa por él antes de llegar a tu handler. Auth es middleware — “verifica el token” vive una vez y protege todas las rutas, no se copia en cada una.
// ownership — the QUERY enforces it, not an if
export async function getTask(userId: number, publicId: string) {
const { rows } = await pool.query(
`SELECT public_id, title, done FROM tasks
WHERE public_id = $1 AND user_id = $2`, // not yours → zero rows
[publicId, userId],
);
if (!rows[0]) throw new NotFoundError("task not found"); // 404, even if it exists
return rows[0];
}
// roles — a factory that returns middleware
export function requireRole(minRole: "viewer" | "editor" | "admin") {
const rank = { viewer: 1, editor: 2, admin: 3 };
return async (req: Request, res: Response, next: NextFunction) => {
const { rows } = await pool.query(
`SELECT role FROM project_members
WHERE project_id = $1 AND user_id = $2`,
[req.params.projectId, res.locals.user.id],
);
if (!rows[0]) throw new NotFoundError("project not found"); // not a member → 404
if (rank[rows[0].role] < rank[minRole])
throw new ForbiddenError("insufficient role"); // member but weak → 403
next();
};
}
app.delete("/projects/:projectId/tasks/:id",
auth, requireRole("editor"), deleteTask);
// viewer sees (GET needs only "viewer"), only editor+ mutates# ownership — the query enforces it
def get_task(user_id: int, public_id: str) -> Task:
row = conn.execute(
"""SELECT public_id, title, done FROM tasks
WHERE public_id = %s AND user_id = %s""",
(public_id, user_id),
).fetchone()
if not row:
raise NotFoundError("task not found") # 404, even if it exists
return row
# roles — a dependency factory
RANK = {"viewer": 1, "editor": 2, "admin": 3}
def require_role(min_role: str):
def dep(project_id: str, user: User = Depends(get_current_user)) -> User:
row = conn.execute(
"""SELECT role FROM project_members
WHERE project_id = %s AND user_id = %s""",
(project_id, user.id),
).fetchone()
if not row:
raise NotFoundError("project not found") # not a member → 404
if RANK[row["role"]] < RANK[min_role]:
raise ForbiddenError("insufficient role") # member but weak → 403
return user
return dep
@app.delete("/projects/{project_id}/tasks/{task_id}")
def delete_task(project_id: str, task_id: str,
user: User = Depends(require_role("editor"))):
...// ownership — the query enforces it
func getTask(ctx context.Context, userID int64, publicID string) (Task, error) {
var t Task
err := pool.QueryRow(ctx,
`SELECT public_id, title, done FROM tasks
WHERE public_id = $1 AND user_id = $2`, publicID, userID,
).Scan(&t.PublicID, &t.Title, &t.Done)
if errors.Is(err, pgx.ErrNoRows) {
return Task{}, NotFoundError{"task not found"} // 404, even if it exists
}
return t, err
}
// roles — a function returning middleware
var rank = map[string]int{"viewer": 1, "editor": 2, "admin": 3}
func requireRole(minRole string) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(userKey).(*User)
var role string
err := pool.QueryRow(r.Context(),
`SELECT role FROM project_members
WHERE project_id = $1 AND user_id = $2`,
r.PathValue("projectId"), user.ID).Scan(&role)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, 404, "project not found") // not a member → 404
return
}
if rank[role] < rank[minRole] {
writeError(w, 403, "insufficient role") // member but weak → 403
return
}
next(w, r)
}
}
}
mux.HandleFunc("DELETE /projects/{projectId}/tasks/{id}",
auth(requireRole("editor")(deleteTask)))La pieza que merece quedarse: requireRole("editor") es una función que devuelve una función. En los tres dialectos la misma idea — parametrizas qué rol sin duplicar cómo se comprueba. Es el factory pattern aplicado al middleware, y la primera vez que funciones-que- devuelven-funciones dejan de ser un truco y se vuelven herramienta.
Una función es una receta reutilizable: recibe ingredientes (parámetros), hace pasos y devuelve un plato (return). La escribes una vez y la llamas mil veces: add(2, 3) → 5.
Implementa autorización en mi API [Express / FastAPI / Go] sobre la auth
del cap anterior. Requisitos: (1) ownership por query — todas las queries
de tasks filtran WHERE user_id, lo ajeno devuelve 404 indistinguible de
inexistente (nunca 403 en recursos ajenos); (2) roles por proyecto en
tabla project_members(user_id, project_id, role viewer|editor|admin) con
un factory requireRole(minRole) reutilizable; (3) viewer puede leer,
solo editor+ muta, no-miembro ni siquiera ve el 404; (4) el 403 reservado
para miembros con rol insuficiente. Dame el código y la tabla de qué
status devuelve cada caso.
26.6 ¿Por qué la autorización vive en dos sitios?
Lo único que necesitas llevarte: hay dos fronteras — la query (que filtra por dueño, tu primera línea y la más barata) y el middleware de rol (que decide capacidades en recursos compartidos). Juntas forman la regla: nunca confíes en que el cliente no mandará el id de otro — la autorización es del servidor, siempre, en cada request.
| Modelo | Qué es | Qué te cuesta | Cuándo elegirla |
|---|---|---|---|
| Ownership (user_id) | “Solo lo tuyo” | Nada — una columna y un WHERE | El 90% de los casos; Taskflow hoy |
| Roles por recurso | members.role |
Una tabla + checks | Compartir proyectos (este cap.) |
| Roles globales | users.role = 'admin' |
Granularidad nula — es todo o nada | Un panel admin, pocos roles |
| RBAC/permisos | roles → conjuntos de permisos | Tablas de permisos, matriz | Muchos roles, enterprise |
| ABAC / policies | “puede editar SI es suyo Y está publicado” | Motor de políticas (OPA, Casbin) | Reglas complejas y combinables |
| OAuth scopes | Permisos en el token de terceros | Delegas el modelo | APIs públicas (cap. 19) |
La escalera honesta: ownership → roles por recurso → (si la empresa crece) RBAC/ABAC. No construyas el último escalón primero.
26.7 Errores comunes
| Error | Por qué pasa | Fix |
|---|---|---|
| IDOR: el id de la URL no se verifica | “Está autenticado, qué más” | WHERE user_id = $2 en TODA query de recursos |
| 403 en recursos ajenos | “No es tuyo, te aviso” → confirmas que existe | 404 — indistinguible de inexistente |
| Rol global para permisos por recurso | user.role = 'editor' edita TODOS los proyectos |
El rol vive en la membresía |
| Chequeo solo en el frontend | El botón “borrar” oculto para viewers — la API sigue abierta | La UI oculta; el servidor enforcea |
if task.user_id != user.id después de leer |
Funciona, pero leíste lo ajeno y es un if olvidable |
La query filtra — el error es estructural, no de memoria |
26.8 Buenas prácticas
- Autorización en la query primero —
WHERE user_ides gratis y estructural; losifposteriores son plan B. - 404 para lo ajeno, 403 para lo insuficiente — aprende la diferencia; es de las pocas decisiones de API que también es seguridad.
- Roles pequeños y legibles (
viewer < editor < admincon ranking) antes que strings sueltos comparados a mano.
Un string es texto entre comillas: "hola". El nombre viene de “cadena de caracteres” — una secuencia de letras. Todo lo que llega de un formulario o una URL llega como string, aunque parezca número.
- Audita endpoints al crecer: cada ruta nueva pasa por el checklist “¿quién puede hacer esto?” — el mini reto formaliza el hábito.
26.9 Ejercicio
- Escribe la query de
deleteTaskcon ownership por filtro (no por if). - Un
viewercomparte proyecto contigo e intentaDELETE /projects/5/tasks/9. ¿Qué responde tu API y por qué 403 y no 404 aquí? - Un extraño (no miembro) intenta el mismo DELETE. ¿Por qué ahora sí es 404?
DELETE FROM tasks WHERE public_id = $1 AND user_id = $2; -- check rows affected: 0 → NotFoundError (not yours OR doesn't exist)- 403: es miembro del proyecto — ya sabes que el proyecto existe y que él pertenece; ocultar la existencia no aplica. Lo que falla es el nivel: viewer no basta para mutar. El 403 dice “te conozco, sé que ves esto, pero tu rol no alcanza”.
- 404: no siendo miembro, ni siquiera debe confirmar que el proyecto 5 existe — para él la respuesta es idéntica a “proyecto inexistente”. Si devolvieras 403, acabas de confirmarle que el proyecto existe: enumeración gratuita.
26.10 Mini reto
Auditar tus endpoints: ¿cuáles dejan adivinar que un recurso existe?
La auditoría pregunta por cada endpoint con {id}: ¿qué responde cuando el recurso existe pero no es tuyo? Endpoints sospechosos típicos:
GET /tasks/42→ 403 “forbidden” en vez de 404 → acabas de confirmar que la tarea 42 existe.POST /projects/5/join→ 403 vs 404 distinguibles → sabes qué proyectos existen aunque no puedas entrar.- Mensajes de error distintos: “task not found” vs “not your task” — diferencia de texto = diferencia de información.
Regla de la auditoría: para un recurso privado, “no existe” y “no es tuyo” deben producir respuestas byte a byte idénticas — mismo status, mismo body. Si puedes distinguirlas, un atacante también.
26.11 Vocabulario técnico del capítulo
Una variable es una caja con nombre donde guardas un valor: let total = 42 guarda el 42 bajo el nombre total. Puedes leerla y cambiarla después (total = 50). const = caja que no se puede reemplazar.
Un bucle repite una acción por cada elemento o hasta cumplir una condición: for task in tasks hace algo con cada tarea. map y filter son bucles disfrazados de funciones — transforman/filtran colecciones sin for explícito.
Una clase es el molde de un objeto: define qué datos y qué métodos tiene. class Task es el molde; new Task() es una instancia concreta. Go no tiene clases — usa struct + métodos sueltos.
Asíncrono = empezar algo sin esperar sentado a que termine: pides la pizza (async) y sigues trabajando; cuando llega, te avisan. Lo opuesto a síncrono (esperar parado). Vital cuando la espera es larga: red, disco, bases de datos.
Un diccionario (dict en Python, map en Go, objeto en JS) guarda parejas clave→valor: {"ana": 30}. Es la estructura que más aparece en JSON — un objeto JS es un diccionario.
Una traza sigue un request a través de todo el sistema: entró por el gateway → llamó auth → consultó la BD → tardó 340ms en la query. Cuando algo anda lento, la traza dice exactamente dónde.
La memoria RAM es el escritorio de trabajo del programa: rápida, pero se borra al apagar. Los datos que deben sobrevivir van a disco o a la base de datos. Por eso let tasks = [] pierde todo al reiniciar.
Un pool es el staff de conexiones a la BD: abrir una conexión por request es caro, así que el pool mantiene ~10–20 abiertas y las presta. El request la usa, la devuelve, y la siguiente la reutiliza.
Un token es una credencial portable: una cadena que dice quién eres y hasta cuándo. El servidor la emite tras el login; el cliente la presenta en cada request en vez de la contraseña.
Vertical: máquina más gorda (más CPU/RAM) — fácil, tiene techo. Horizontal: más máquinas — el camino de internet, pero requiere que la app no guarde estado local (por eso todo va a BD/Redis).