26  17. Necesitamos que autenticado no signifique puede todo

Autenticación ≠ autorización

NotaEn una frase

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:

  1. 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 es if task.user_id != user.id: raise 404 despué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.
  2. 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) con viewer/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

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"]

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_id es gratis y estructural; los if posteriores 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 < admin con 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

  1. Escribe la query de deleteTask con ownership por filtro (no por if).
  2. Un viewer comparte proyecto contigo e intenta DELETE /projects/5/tasks/9. ¿Qué responde tu API y por qué 403 y no 404 aquí?
  3. Un extraño (no miembro) intenta el mismo DELETE. ¿Por qué ahora sí es 404?
  1. DELETE FROM tasks WHERE public_id = $1 AND user_id = $2;
    -- check rows affected: 0 → NotFoundError (not yours OR doesn't exist)
  2. 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”.
  3. 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).

26.12 Lo que deberías saber hacer ahora