El Módulo 5 terminó con una confesión: en todo lo construido hasta ahora, cualquier proceso podía hablar con cualquier servicio y leer cualquier dato. pedidos llama a inventario.ReservarStock sin decir quién es; el consumidor de Flink lee pedidos.eventos sin identificarse; y el monolito original guardaba la sesión de Ana en la memoria del único proceso que existía, algo que dejó de funcionar en el momento en que hubo dos instancias detrás de un balanceador. Las dos preguntas que ningún sistema puede eludir son quién es quién (autenticación) y quién puede qué (autorización), y en un sistema distribuido tienen una dificultad añadida: la respuesta debe viajar con cada petición a través de servicios que no comparten memoria, ni proceso, ni a veces siquiera equipo de desarrollo.

En esta lección separaremos con precisión ambos conceptos, veremos cómo se almacenan contraseñas de forma que una fuga de la base de datos no sea una catástrofe, compararemos sesiones con estado (cookie más Redis) frente a tokens sin estado, desmontaremos un JSON Web Token pieza a pieza, y estudiaremos cómo esa identidad se propaga de la web a pedidos y de pedidos a inventario. Después construiremos la autorización: roles, atributos y relaciones, y el principio de mínimo privilegio. El código deja a Kilómetro Cero con un servicio identidad que emite tokens RS256, y con inventario rechazando cualquier llamada que no venga firmada y autorizada. Queda deliberadamente fuera cómo se delega el login en un proveedor de identidad (OAuth 2.0, OpenID Connect, SSO: lección 06-03), cómo se identifican los servicios entre sí con certificados (06-04) y cómo se centraliza la validación en el borde (06-05).

Contenido

  1. Autenticación y autorización: dos preguntas distintas
  2. Por qué una sesión en memoria no sobrevive a la distribución
  3. Factores de autenticación y almacenamiento de contraseñas
  4. Sesiones con estado frente a tokens sin estado
  5. JSON Web Tokens: anatomía, firma y claims
  6. Caducidad, refresh tokens y revocación
  7. Errores clásicos con JWT
  8. Propagación de identidad entre servicios
  9. Modelos de autorización: RBAC, ABAC, ReBAC
  10. Dónde vive la política: centralizada o en cada servicio
  11. Kilómetro Cero: identidad, interceptor gRPC y comprobación ABAC
  12. Errores Comunes y Consejos
  13. Ejercicios
  14. Conclusión

  1. Autenticación y autorización: dos preguntas distintas

Se confunden constantemente porque suelen ocurrir seguidas y porque ambas se abrevian "auth". Conviene fijarlas:

Autenticación (AuthN) Autorización (AuthZ)
Pregunta ¿Eres quien dices ser? ¿Puedes hacer lo que pides?
Entrada Credenciales: contraseña, código, certificado, token Identidad ya verificada + acción + recurso
Salida Una identidad (sujeto) y, a menudo, atributos (roles, ids) Una decisión: permitir o denegar
Cuándo Una vez por sesión o por token En cada operación
Fallo típico 401 Unauthorized (mal nombre: significa "no autenticado") 403 Forbidden
En Kilómetro Cero Ana demuestra ser [email protected] con su contraseña Ana puede ver P-2026-000123 porque es suyo; no puede editar queso-curado

Un error frecuente de diseño es resolver la primera y olvidar la segunda: la aplicación comprueba que hay un usuario válido y a partir de ahí todo se permite. En un marketplace eso significa que cualquier cliente registrado podría, cambiando un id en la URL, ver los pedidos de Marc o modificar los precios de la Bodega Roble Alto. La autenticación identifica; la autorización protege.

  1. Por qué una sesión en memoria no sobrevive a la distribución

El monolito de 01-01 hacía lo que hacen todos los frameworks web por defecto: al hacer login, guardaba {"usuario": "ana", "roles": ["cliente"]} en un diccionario en memoria, generaba un identificador aleatorio y lo devolvía en una cookie. En cada petición posterior, buscaba la cookie en el diccionario. Perfecto con un proceso. Y en 01-06 la plataforma pasó a tener:

  • Varias instancias del mismo servicio detrás de un balanceador: la petición 2 de Ana puede aterrizar en una instancia que nunca vio su login. La solución de urgencia, sticky sessions (el balanceador envía siempre a Ana a la misma instancia), rompe el reparto de carga y pierde las sesiones cuando esa instancia muere o se redespliega.
  • Varios servicios: pedidos recibe una petición que viene de la web, pero necesita saber que es de Ana para consultar sus pedidos y no otros, y luego llama a inventario, que también debería saber en nombre de quién actúa.
  • Procesos sin navegador: la app de repartidores, el DAG de Airflow, un consumidor de Kafka. No tienen cookies y no "hacen login" en el sentido humano.

Hay dos familias de soluciones, y las dos se usan:

flowchart LR
    subgraph E["Sesión con estado"]
        C1[Cookie: id opaco] --> R[(Redis compartido<br/>sesion:abc123 → ana, roles)]
        R --> S1[Instancia 1]
        R --> S2[Instancia 2]
    end
    subgraph T["Token sin estado"]
        C2[Token firmado:<br/>sub=ana, roles, exp] --> V1[Instancia 1<br/>verifica firma]
        C2 --> V2[Instancia 2<br/>verifica firma]
        C2 --> V3[inventario<br/>verifica firma]
    end

En la primera, la identidad vive en un almacén compartido y la cookie es solo una referencia. En la segunda, la identidad viaja dentro del propio token, protegida por una firma que cualquier servicio puede verificar sin preguntar a nadie. Antes de compararlas, hay que resolver el paso previo: cómo verificamos la contraseña de Ana la primera vez.

  1. Factores de autenticación y almacenamiento de contraseñas

Los factores de autenticación se clasifican en tres familias: algo que sabes (contraseña, PIN), algo que tienes (móvil con app TOTP, llave FIDO2, tarjeta), algo que eres (huella, cara). La autenticación multifactor (MFA) combina dos familias distintas; dos contraseñas no son dos factores. Su implantación en la plataforma, junto con la recuperación de cuentas, es tema de 06-03; aquí nos centramos en el factor que toda aplicación acaba gestionando: la contraseña.

La regla: nunca cifrado reversible, siempre hash con sal

Una contraseña no debe poder recuperarse, ni siquiera por el administrador. Si se cifra con AES y una clave, quien tenga la clave (o la robe junto con la base de datos) obtiene todas las contraseñas en claro, que los usuarios reutilizan en su banco y su correo. Lo correcto es almacenar un hash: una función de un solo sentido. Pero no cualquier hash:

Método Problema
MD5 / SHA-1 / SHA-256 de la contraseña Rapidísimos: una GPU prueba miles de millones por segundo; las tablas precalculadas (rainbow tables) invierten hashes comunes al instante
SHA-256 con sal Anula las tablas precalculadas, pero sigue siendo rápido: un ataque de diccionario por usuario sigue siendo viable
bcrypt, scrypt, Argon2 (hashes de contraseña) Diseñados para ser lentos y configurables (coste en tiempo y, en Argon2/scrypt, en memoria), con sal aleatoria incorporada

La sal es un valor aleatorio, distinto por usuario, que se concatena a la contraseña antes de hacer el hash y se guarda junto al resultado. Dos usuarios con la contraseña verano2026 tendrán hashes diferentes, y el atacante no puede amortizar un cálculo entre usuarios. El factor de coste hace que verificar una contraseña tarde, deliberadamente, decenas de milisegundos: irrelevante para un login, devastador para quien intenta miles de millones.

Argon2id es la recomendación actual (ganador de la Password Hashing Competition, resistente a GPU por su uso de memoria); bcrypt sigue siendo perfectamente aceptable y es más común en sistemas existentes. En Kilómetro Cero usaremos argon2-cffi:

# km0/servicios/identidad/contrasenas.py
# pip install argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

# Parámetros: 3 iteraciones, 64 MiB de memoria, 4 hilos. Ajústalos para que
# 'hash' tarde ~100 ms en tu hardware de producción: es el coste que pagas
# una vez por login y que el atacante paga miles de millones de veces.
ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4)

def registrar(contrasena: str) -> str:
    """Devuelve la cadena que se guarda en la base de datos. Incluye
    algoritmo, parámetros, sal y hash: todo lo necesario para verificar."""
    return ph.hash(contrasena)

def verificar(hash_guardado: str, contrasena: str) -> bool:
    try:
        ph.verify(hash_guardado, contrasena)
    except VerifyMismatchError:
        return False
    return True

def necesita_rehash(hash_guardado: str) -> bool:
    """True si el hash se hizo con parámetros más débiles que los actuales:
    en el siguiente login correcto, se recalcula y se guarda."""
    return ph.check_needs_rehash(hash_guardado)

if __name__ == "__main__":
    h = registrar("verano2026")
    print(h)
    # $argon2id$v=19$m=65536,t=3,p=4$Q2xhdmVBbGVhdG9yaWE$...hash...
    print(verificar(h, "verano2026"), verificar(h, "Verano2026"))   # True False

Observa la cadena resultante: $argon2id$v=19$m=65536,t=3,p=4$<sal>$<hash>. Lleva el algoritmo y los parámetros dentro, de modo que dentro de tres años, cuando subas memory_cost, los hashes antiguos seguirán verificándose y necesita_rehash te dirá cuáles actualizar en el siguiente login. Con bcrypt el código es análogo: bcrypt.hashpw(contrasena.encode(), bcrypt.gensalt(rounds=12)) y bcrypt.checkpw(...), con el límite de 72 bytes de contraseña que conviene conocer.

Dos detalles de diseño que a menudo se olvidan: el mensaje de error del login debe ser idéntico si el usuario no existe y si la contraseña es incorrecta (de lo contrario se enumeran cuentas), y el tiempo de respuesta también, lo que se consigue verificando contra un hash ficticio cuando el usuario no existe.

  1. Sesiones con estado frente a tokens sin estado

Sesión con estado: cookie + Redis

La lección 04-05 dejó a Redis como caché de catálogo; es también el almacén de sesiones clásico. El flujo es:

  1. Ana envía usuario y contraseña a identidad; se verifica con Argon2.
  2. identidad genera un id aleatorio de 256 bits (secrets.token_urlsafe(32)), guarda sesion:<id> → {"sub": "u-ana", "roles": ["cliente"]} en Redis con TTL, y devuelve el id en una cookie HttpOnly; Secure; SameSite=Lax.
  3. Cada instancia de la web, en cada petición, lee la cookie, hace GET sesion:<id> en Redis y obtiene la identidad.
# Fragmento: almacén de sesiones compartido (la web tiene N instancias)
import json, secrets, redis
r = redis.Redis(host="redis", decode_responses=True)
TTL_S = 8 * 3600

def crear_sesion(sub: str, roles: list[str]) -> str:
    sid = secrets.token_urlsafe(32)
    r.set(f"sesion:{sid}", json.dumps({"sub": sub, "roles": roles}), ex=TTL_S)
    return sid

def cargar_sesion(sid: str) -> dict | None:
    v = r.get(f"sesion:{sid}")
    if v is None:
        return None
    r.expire(f"sesion:{sid}", TTL_S)      # sesión deslizante: cada uso renueva
    return json.loads(v)

def cerrar_sesion(sid: str) -> None:
    r.delete(f"sesion:{sid}")             # revocación inmediata y trivial

Ventajas: la revocación es un DEL; el cliente no ve nada de la identidad (el id es opaco); la sesión puede contener lo que se quiera sin preocuparse por el tamaño. Inconvenientes: cada petición cuesta un viaje a Redis, Redis es ahora un punto crítico (si cae, nadie está autenticado), y sobre todo no se propaga: cuando la web llama a pedidos e inventario, esos servicios no tienen la cookie ni deberían consultar el Redis de sesiones de la web.

Token sin estado

La alternativa es que identidad emita un documento firmado con la identidad y sus atributos: un token. Quien lo recibe verifica la firma y confía en el contenido sin consultar a nadie. Es el mismo principio que las URLs prefirmadas de MinIO (04-03), que llamamos capacidad: un permiso portátil, autocontenido, con caducidad, que se verifica criptográficamente.

Criterio Sesión (cookie + Redis) Token (JWT)
Estado en el servidor Sí (Redis) No
Coste por petición Un GET a Redis Verificar una firma (microsegundos con HS256, ~decenas de µs con RS256)
Revocación Inmediata (DEL) Difícil: el token es válido hasta exp (apartado 6)
Propagación entre servicios No natural Natural: el token viaja en cada llamada
Qué ve el cliente Un id opaco El contenido (firmado, no cifrado)
Tamaño Cookie de ~50 bytes Cientos de bytes a 1-2 KB por petición
Uso típico Web tradicional monolítica APIs, microservicios, apps móviles, máquina-a-máquina

En la práctica muchas plataformas combinan ambas: la web mantiene una sesión con cookie para el navegador (más segura frente a XSS, como veremos) y, al llamar a los servicios internos, intercambia esa sesión por un token de corta vida que sí se propaga. Kilómetro Cero usará tokens desde la app de repartidores y entre servicios, y la decisión para la web se discute en el apartado 7.

  1. JSON Web Tokens: anatomía, firma y claims

Un JWT (RFC 7519) es una cadena con tres partes separadas por puntos, cada una en Base64URL:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImttMC0yMDI2LTA5In0
.
eyJpc3MiOiJodHRwczovL2lkZW50aWRhZC5rbTAiLCJzdWIiOiJ1LWFuYSIsImF1ZCI6WyJwZWRpZG9zIiwiaW52ZW50YXJpbyJdLCJleHAiOjE3NzM2NzQwMDAsImlhdCI6MTc3MzY3MzEwMCwianRpIjoiN2Y0Li4uIiwicm9sZXMiOlsiY2xpZW50ZSJdfQ
.
Xk9v...firma...
  1. Header: {"alg": "RS256", "typ": "JWT", "kid": "km0-2026-09"}. El algoritmo de firma y, opcionalmente, el identificador de la clave (kid) para saber con cuál verificar cuando hay varias en rotación.
  2. Payload: los claims, un objeto JSON con afirmaciones sobre el sujeto.
  3. Firma: se calcula sobre base64url(header) + "." + base64url(payload) con el algoritmo indicado. Si alguien cambia una letra del payload, la firma deja de cuadrar.

Importante: el payload está codificado, no cifrado. Cualquiera que tenga el token lo puede leer con base64 -d. Nunca metas en un JWT un dato que el portador no deba ver (una contraseña, el teléfono de otro usuario). Existe una variante cifrada (JWE), poco usada en APIs; el cifrado de datos es tema de 06-02.

Claims estándar

Claim Nombre Significado En Kilómetro Cero
iss issuer Quién emitió el token https://identidad.km0
sub subject Identificador del sujeto (estable, no un email cambiante) u-ana, prod-queseria-montblanc, svc-pedidos
aud audience Para quién es el token; el receptor debe comprobar que está en la lista ["pedidos", "inventario"]
exp expiration Instante Unix tras el cual es inválido iat + 15 min
iat issued at Cuándo se emitió —
nbf not before No válido antes de este instante Raro; útil con relojes desincronizados
jti JWT id Identificador único del token Permite revocar uno concreto (apartado 6) y correlacionar auditoría (06-05)

A ellos se añaden claims privados propios de la aplicación. Kilómetro Cero usará roles (lista) y, para productores, productor_id (slug), y para repartidores repartidor_id. La regla: pocos claims, estables, y nunca los que cambian cada minuto (el saldo, el carrito), porque el token es una foto en el momento de emisión.

HS256 frente a RS256/ES256: por qué los servicios verifican con clave pública

Algoritmo Tipo Clave para firmar Clave para verificar Consecuencia
HS256 HMAC con SHA-256 (simétrico) Secreto compartido El mismo secreto Todo servicio que verifica puede también emitir tokens
RS256 RSA + SHA-256 (asimétrico) Clave privada (solo identidad) Clave pública (cualquiera) Los servicios verifican sin poder falsificar; firma ~256 bytes
ES256 ECDSA P-256 (asimétrico) Clave privada Clave pública Como RS256, firmas más cortas (64 bytes) y verificación rápida; menos soporte en librerías antiguas
EdDSA Ed25519 Clave privada Clave pública El más moderno y rápido; soporte creciente

Con HS256, inventario, pedidos, reparto y analitica necesitarían el secreto para verificar, y un compromiso de cualquiera de ellos (o de cualquiera de sus desarrolladores con acceso a la configuración) permitiría fabricar un token de operador. Con RS256, solo identidad tiene la clave privada; los demás cargan la pública, que puede publicarse sin riesgo (en 06-03 veremos el estándar para ello, JWKS). En un sistema distribuido con más de un verificador, la firma asimétrica no es una opción: es la única sensata.

  1. Caducidad, refresh tokens y revocación

El precio de no tener estado es que no se puede retirar un token emitido: es válido hasta exp, aunque el usuario cierre sesión o se le retire un rol. Las estrategias, combinables:

  1. Caducidad corta: tokens de acceso de 5 a 15 minutos. Limita la ventana de daño de un token robado y la latencia con que se aplican los cambios de rol.
  2. Refresh token: un segundo token, de larga vida (días o semanas), con estado (guardado en identidad, en PostgreSQL o Redis) y de un solo uso, que sirve únicamente para pedir un nuevo token de acceso. Cerrar sesión o revocar a un usuario es borrar sus refresh tokens: en 15 minutos como máximo todos sus tokens de acceso habrán caducado. La rotación (cada uso del refresh emite uno nuevo e invalida el anterior) detecta robos: si aparece el antiguo, alguien lo copió, y se invalida toda la familia.
  3. Lista negra en Redis por jti: para los casos en que 15 minutos son demasiados (bloqueo urgente de una cuenta comprometida), el servicio consulta SISMEMBER jwt:revocados <jti> antes de aceptar el token, con TTL igual al tiempo que le quedaba de vida. Reintroduce un viaje a Redis por petición, pero solo se consulta una estructura pequeña, y es la única forma de revocación inmediata con tokens.
  4. Rotación de claves de firma: publicar la nueva clave pública con un kid nuevo, empezar a firmar con ella, y retirar la antigua cuando hayan caducado todos los tokens firmados con ella. Los verificadores deben aceptar varias claves simultáneamente, elegidas por kid.
sequenceDiagram
    participant A as App de Ana
    participant I as identidad
    participant P as pedidos
    A->>I: POST /login (email, contraseña)
    I->>I: Argon2 verify
    I-->>A: access (15 min, RS256) + refresh (30 días, opaco, en BD)
    A->>P: GET /pedidos  Authorization: Bearer <access>
    P->>P: verificar firma con clave pública, exp, aud
    P-->>A: 200 pedidos de u-ana
    Note over A: pasan 15 min: access caducado
    A->>P: GET /pedidos  Bearer <access>
    P-->>A: 401 (exp)
    A->>I: POST /token/refresh (refresh)
    I->>I: ¿existe y no usado? rotar
    I-->>A: nuevo access + nuevo refresh

  1. Errores clásicos con JWT

La especificación de JWT es flexible y esa flexibilidad ha producido vulnerabilidades famosas. Conócelas para no repetirlas:

Error Qué ocurre Defensa
Aceptar alg: none La spec permite tokens "sin firma"; librerías antiguas los aceptaban y un atacante podía escribir el payload que quisiera Pasar siempre la lista algorithms=["RS256"] a la verificación; nunca leer el algoritmo del propio token
Confusión de algoritmo (RS256 → HS256) El atacante firma con HS256 usando la clave pública (conocida) como secreto; una librería que acepte ambos algoritmos con la misma clave lo valida Igual: lista cerrada de algoritmos, y objetos de clave con tipo (PyJWT ≥ 2 lo impide)
No validar aud Un token emitido para la app de repartidores se reutiliza contra pagos Cada servicio verifica que su nombre está en aud
No validar exp / iss Tokens eternos, o tokens de otro emisor con la misma librería Verificación completa por defecto; iss esperado explícito
Secretos débiles en HS256 Con HS256 y secret123, la firma se rompe por fuerza bruta en minutos RS256/ES256; si HS256, secreto de ≥ 256 bits aleatorios
JWT en localStorage Cualquier script inyectado (XSS) lee localStorage y roba el token; con una cookie HttpOnly no puede En la web, cookie HttpOnly; Secure; SameSite (aunque contenga un JWT), o sesión con estado; el JWT en memoria de la SPA con refresh por cookie es el compromiso habitual
Datos sensibles en el payload Es legible por el portador y queda en logs y proxies Solo identificadores y roles
Tokens de horas o días sin refresh Ventana enorme de daño; cambios de rol que tardan un día 5-15 minutos + refresh con rotación
Verificar en cada servicio "a mano" Uno se olvida de aud, otro de exp Una librería común (km0/servicios/comun/auth.py) y el gateway como primera barrera (06-05)

En Kilómetro Cero: la web guardará el token de acceso solo en memoria de la SPA y el refresh en cookie HttpOnly; la app de repartidores en el almacenamiento seguro del sistema operativo; los servicios nunca lo persistirán.

  1. Propagación de identidad entre servicios

Cuando Ana confirma el pedido P-2026-000126, la petición atraviesa varios saltos: web → pedidos → inventario (reservar stock) → pagos. ¿Quién actúa en cada salto?

  • En HTTP, el token viaja en la cabecera Authorization: Bearer <jwt>.
  • En gRPC, en los metadatos de la llamada (02-03 los presentó, y adelantó que servirían para credenciales): la clave authorization con el mismo valor Bearer <jwt>. gRPC comprime las cabeceras con HPACK en HTTP/2, así que el coste de repetirlas en cada llamada es pequeño.

Hay dos modelos de propagación:

Modelo Descripción Ventajas Inconvenientes
Token del usuario reenviado (on-behalf-of) pedidos reenvía a inventario el mismo JWT de Ana inventario sabe que la reserva es de Ana; auditoría de extremo a extremo; el mínimo privilegio se conserva El aud debe incluir a todos los servicios de la cadena; un servicio intermedio comprometido tiene el token de Ana durante 15 min
Identidad del servicio pedidos llama a inventario con su propio token (sub: svc-pedidos), y pasa el id de usuario como dato Tokens de servicio simples; no expone el de Ana inventario tiene que confiar en que pedidos dice la verdad sobre el usuario: solo válido si pedidos está autenticado como servicio (06-04)

Kilómetro Cero usará el primero en las llamadas que ejecutan la acción de un usuario (pedidos → inventario.ReservarStock lleva el token de Ana), y el segundo para procesos autónomos (Airflow leyendo el lago, Flink consumiendo pedidos.eventos), que en 06-03 obtendrán tokens de máquina y en 06-04 se identificarán además por certificado. El mismo mecanismo de metadatos servirá en 07-02 para propagar el identificador de traza.

  1. Modelos de autorización: RBAC, ABAC, ReBAC

Con la identidad verificada llega la segunda pregunta. Los modelos principales, de menos a más expresivos:

RBAC: control basado en roles

A cada sujeto se le asignan roles, y a cada rol, permisos (acción sobre tipo de recurso). Kilómetro Cero define cuatro:

Rol Quién Permisos (extracto)
cliente Ana, Marc, Lucía pedidos:crear, pedidos:leer (los propios), catalogo:leer
productor Huerta La Vega, Quesería Montblanc, Bodega Roble Alto catalogo:leer, productos:editar (los propios), inventario:ajustar (el propio), pedidos:leer (los que contienen sus productos)
repartidor Los 140 de furgoneta-3 y el resto reparto:posicion, pedidos:leer (los asignados), pedidos:marcar_entregado
operador Empleados de Kilómetro Cero Todo lo anterior + pedidos:cancelar, productores:aprobar, campanas:crear

RBAC es sencillo, auditable ("¿quién puede aprobar productores? los operadores") y suficiente para permisos de tipo. Su límite está entre paréntesis en la tabla: "los propios", "los asignados". Un rol no sabe de qué queso es dueño quién.

ABAC: control basado en atributos

La decisión se toma evaluando atributos del sujeto (rol, productor_id), del recurso (propietario, estado), de la acción y del contexto (hora, IP, mercado). La regla que RBAC no podía expresar: "un sujeto con rol productor puede productos:editar un producto si producto.productor_id == sujeto.productor_id". Con ABAC, la Quesería Montblanc edita queso-curado y queso-fresco y no puede tocar vino-crianza. Es más expresivo y más difícil de auditar: para responder "¿quién puede editar queso-curado?" hay que evaluar reglas, no leer una tabla.

ReBAC: control basado en relaciones

Generaliza ABAC modelando las relaciones como un grafo (usuario:ana es propietario de pedido:P-2026-000123; usuario:marc es miembro de productor:bodega-roble-alto, que es propietario de producto:vino-crianza) y respondiendo a "¿existe un camino que permita esta acción?". Es el modelo de Google Zanzibar y de sistemas como OpenFGA o SpiceDB, y brilla cuando hay jerarquías y permisos compartidos (un productor con varios empleados, una carpeta con subcarpetas). Para Kilómetro Cero, RBAC más unas pocas reglas ABAC bastan; ReBAC quedaría para cuando los productores tengan equipos con permisos delegados.

Mínimo privilegio

Transversal a los tres: cada sujeto (persona o servicio) tiene exactamente los permisos que necesita para su tarea y ninguno más. analitica lee el lago pero no puede escribir en km0_pedidos; reparto actualiza posiciones pero no puede cancelar pedidos; un operador de atención al cliente ve pedidos pero no puede crear campañas. Y, por defecto, denegar: lo que no está explícitamente permitido está prohibido. Cada permiso extra es superficie de ataque y, cuando un servicio o una cuenta se compromete, el radio de daño es exactamente su lista de permisos.

  1. Dónde vive la política: centralizada o en cada servicio

Una vez decididas las reglas, ¿dónde se ejecutan?

Opción Cómo Ventajas Inconvenientes
En cada servicio (código) Decoradores y comprobaciones en inventario, pedidos... Sin dependencias; latencia cero; contexto completo del dominio Reglas duplicadas e inconsistentes; cambiar "quién puede cancelar" es un despliegue en varios servicios
Política centralizada (motor de políticas) Cada servicio pregunta a un motor con la tupla (sujeto, acción, recurso, contexto); el motor evalúa reglas escritas en un lenguaje declarativo Una sola fuente de verdad; cambios sin redesplegar; auditoría de las reglas Un componente más; latencia de una llamada (se mitiga con el motor como sidecar local); las reglas necesitan los atributos del recurso

El representante más conocido del segundo enfoque es Open Policy Agent (OPA) con su lenguaje Rego: los servicios envían un JSON de entrada y OPA responde allow: true/false. Una regla para el caso de la Quesería Montblanc se escribiría, aproximadamente, así:

package km0.productos

default allow := false

allow if {
    "operador" in input.sujeto.roles
}

allow if {
    input.accion == "editar"
    "productor" in input.sujeto.roles
    input.recurso.productor_id == input.sujeto.productor_id
}

La decisión pragmática habitual, y la de Kilómetro Cero en esta lección: RBAC grueso en el borde y en cada servicio (¿tiene el rol necesario para este método?) mediante una librería común, y ABAC fino dentro del servicio que posee los datos (¿es suyo este producto?), donde están los atributos. Si las reglas crecen o hay que cambiarlas sin redesplegar, se externalizan a OPA sin cambiar la estructura: el decorador pasa a preguntar al motor.

  1. Kilómetro Cero: identidad, interceptor gRPC y comprobación ABAC

Vamos a construir tres piezas: el emisor de tokens, la verificación en inventario como interceptor gRPC, y la regla ABAC de productos.

Claves de firma

# Una vez, en desarrollo. En producción: generadas y custodiadas por el gestor de secretos (06-04).
mkdir -p km0/servicios/identidad/claves
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
    -out km0/servicios/identidad/claves/km0-2026-09.pem
openssl pkey -in km0/servicios/identidad/claves/km0-2026-09.pem -pubout \
    -out km0/servicios/identidad/claves/km0-2026-09.pub

La .pem (privada) solo la monta el contenedor de identidad; la .pub se distribuye a todos los servicios (en 06-03 se sustituirá por un endpoint JWKS).

servicios/identidad/tokens.py

# km0/servicios/identidad/tokens.py
# pip install "PyJWT[crypto]"
import time, uuid
import jwt                         # PyJWT

ISS = "https://identidad.km0"
KID = "km0-2026-09"
ACCESO_TTL_S = 15 * 60

with open("servicios/identidad/claves/km0-2026-09.pem", "rb") as f:
    CLAVE_PRIVADA = f.read()       # solo 'identidad' tiene este fichero

def emitir_acceso(sub: str, roles: list[str], aud: list[str], **extra) -> str:
    """Emite un JWT RS256 de 15 minutos. 'extra' permite claims privados
    como productor_id o repartidor_id."""
    ahora = int(time.time())
    claims = {
        "iss": ISS, "sub": sub, "aud": aud,
        "iat": ahora, "exp": ahora + ACCESO_TTL_S,
        "jti": str(uuid.uuid4()),
        "roles": roles, **extra,
    }
    return jwt.encode(claims, CLAVE_PRIVADA, algorithm="RS256",
                      headers={"kid": KID})

if __name__ == "__main__":
    print(emitir_acceso("u-ana", ["cliente"], ["pedidos", "inventario"]))
    print(emitir_acceso("prod-queseria-montblanc", ["productor"],
                        ["catalogo", "inventario"],
                        productor_id="queseria-montblanc"))

Y la verificación, que no vive en identidad sino en una librería común que importan todos los servicios:

# km0/servicios/comun/auth.py
import jwt
from jwt import PyJWKClient  # se usará en 06-03; aquí cargamos la pública de fichero

ISS = "https://identidad.km0"
CLAVES_PUBLICAS = {}
with open("servicios/comun/claves/km0-2026-09.pub", "rb") as f:
    CLAVES_PUBLICAS["km0-2026-09"] = f.read()

class TokenInvalido(Exception):
    pass

def verificar(token: str, audiencia: str) -> dict:
    """Devuelve los claims si el token es válido para este servicio.
    Cada comprobación que sigue corresponde a un error del apartado 7."""
    try:
        kid = jwt.get_unverified_header(token).get("kid")
        clave = CLAVES_PUBLICAS[kid]                      # rotación por kid
        return jwt.decode(
            token, clave,
            algorithms=["RS256"],                         # lista cerrada: nunca 'none', nunca HS256
            audience=audiencia,                           # aud debe contener a este servicio
            issuer=ISS,                                   # solo nuestro emisor
            options={"require": ["exp", "iat", "sub", "jti"]},
            leeway=30,                                    # 30 s de tolerancia de reloj (01-05)
        )
    except (KeyError, jwt.PyJWTError) as e:
        raise TokenInvalido(str(e))

leeway=30 merece un comentario: los relojes de identidad y de inventario no están perfectamente sincronizados (01-05), y un token emitido hace 200 ms puede parecer "del futuro" (iat posterior al reloj local) o haber "caducado" medio segundo antes. Treinta segundos de tolerancia evitan errores fantasma sin abrir una ventana significativa.

Interceptor gRPC en inventario

gRPC permite registrar interceptores en el servidor: código que se ejecuta antes de cada método, con acceso a los metadatos. Es el lugar natural para autenticar, de modo que ReservarStock y los demás métodos de 02-03 no tengan que repetirlo:

# km0/servicios/inventario/auth_interceptor.py
import grpc
from servicios.comun.auth import verificar, TokenInvalido

# RBAC grueso por método: qué roles pueden invocar cada RPC del contrato inventario.proto
ROLES_POR_METODO = {
    "/km0.inventario.v1.Inventario/ConsultarStock":  {"cliente", "productor", "repartidor", "operador"},
    "/km0.inventario.v1.Inventario/ReservarStock":   {"cliente", "operador"},
    "/km0.inventario.v1.Inventario/AjustarStock":    {"productor", "operador"},
    "/km0.inventario.v1.Inventario/ObservarCambios": {"operador"},
}

def _abortar(codigo, detalle):
    def handler(request, context):
        context.abort(codigo, detalle)
    return grpc.unary_unary_rpc_method_handler(handler)

class AuthInterceptor(grpc.ServerInterceptor):
    def intercept_service(self, continuation, handler_call_details):
        metodo = handler_call_details.method
        meta = dict(handler_call_details.invocation_metadata)
        auth = meta.get("authorization", "")
        if not auth.startswith("Bearer "):
            return _abortar(grpc.StatusCode.UNAUTHENTICATED, "falta el token")
        try:
            claims = verificar(auth[len("Bearer "):], audiencia="inventario")
        except TokenInvalido as e:
            return _abortar(grpc.StatusCode.UNAUTHENTICATED, f"token inválido: {e}")

        permitidos = ROLES_POR_METODO.get(metodo, set())
        if not permitidos & set(claims.get("roles", [])):
            return _abortar(grpc.StatusCode.PERMISSION_DENIED,
                            f"{claims['sub']} no puede invocar {metodo}")

        # Dejamos los claims accesibles al método a través de un metadato interno.
        # (grpc-python no ofrece un 'contexto de petición' compartido; el convenio
        # de km0 es que el servicer los relee con 'claims_de(context)'.)
        _CLAIMS_ACTUALES.set(claims)
        return continuation(handler_call_details)

import contextvars
_CLAIMS_ACTUALES = contextvars.ContextVar("claims_km0")

def claims_de(context) -> dict:
    return _CLAIMS_ACTUALES.get()

Fíjate en la distinción de códigos: UNAUTHENTICATED (no sé quién eres: equivalente a 401) frente a PERMISSION_DENIED (sé quién eres y no puedes: 403). Los clientes reaccionan de forma distinta a cada uno: el primero dispara un refresh del token; el segundo, no.

En el servidor de 02-03 solo cambia el arranque:

# km0/servicios/inventario/servidor.py (fragmento)
servidor = grpc.server(futures.ThreadPoolExecutor(max_workers=10),
                       interceptors=[AuthInterceptor()])

Decorador requiere_rol para el resto de servicios

Para los servicios HTTP (la API de pedidos, catalogo con FastAPI o Flask), el equivalente al interceptor es un decorador:

# km0/servicios/comun/auth.py (continuación)
from functools import wraps
from flask import request, g, abort

def requiere_rol(*roles_permitidos, audiencia):
    def decorador(fn):
        @wraps(fn)
        def envoltura(*args, **kwargs):
            auth = request.headers.get("Authorization", "")
            if not auth.startswith("Bearer "):
                abort(401)
            try:
                g.claims = verificar(auth[7:], audiencia)
            except TokenInvalido:
                abort(401)
            if not set(roles_permitidos) & set(g.claims.get("roles", [])):
                abort(403)
            return fn(*args, **kwargs)
        return envoltura
    return decorador

# Uso en servicios/pedidos/api.py:
# @app.get("/pedidos/<pedido_id>")
# @requiere_rol("cliente", "operador", audiencia="pedidos")
# def ver_pedido(pedido_id):
#     pedido = repositorio.cargar(pedido_id)
#     if "operador" not in g.claims["roles"] and pedido.cliente_id != g.claims["sub"]:
#         abort(403)          # ABAC: solo el dueño del pedido (o un operador) lo ve
#     return pedido.a_json()

Ese abort(403) de las últimas líneas es la comprobación que faltaba en el apartado 1: sin ella, Marc leería P-2026-000123 de Ana cambiando la URL.

La regla ABAC: "Quesería Montblanc solo modifica sus productos"

En inventario, el método AjustarStock ya ha pasado el filtro RBAC (rol productor u operador). La regla fina se aplica dentro, donde está el atributo del recurso:

# km0/servicios/inventario/servidor.py (fragmento del servicer)
from servicios.inventario.auth_interceptor import claims_de

def AjustarStock(self, request, context):
    claims = claims_de(context)
    producto = self._repo.cargar(request.producto)            # p. ej. 'queso-curado'
    if producto is None:
        context.abort(grpc.StatusCode.NOT_FOUND, "producto desconocido")

    es_operador = "operador" in claims["roles"]
    es_su_producto = claims.get("productor_id") == producto.productor_id
    if not (es_operador or es_su_producto):
        context.abort(grpc.StatusCode.PERMISSION_DENIED,
                      f"{claims['sub']} no es propietario de {request.producto}")

    nuevo = self._repo.ajustar(request.producto, request.mercado, request.delta)
    return inventario_pb2.AjustarStockResponse(stock=nuevo)

Prueba desde el cliente:

# Cliente de prueba (host): dos tokens, dos resultados
from servicios.identidad.tokens import emitir_acceso
import grpc, inventario_pb2, inventario_pb2_grpc

canal = grpc.insecure_channel("localhost:50051")     # TLS llega en 06-02, mTLS en 06-04
stub = inventario_pb2_grpc.InventarioStub(canal)

t_queseria = emitir_acceso("prod-queseria-montblanc", ["productor"], ["inventario"],
                           productor_id="queseria-montblanc")
t_bodega = emitir_acceso("prod-bodega-roble-alto", ["productor"], ["inventario"],
                         productor_id="bodega-roble-alto")

def ajustar(token, producto, delta):
    try:
        r = stub.AjustarStock(inventario_pb2.AjustarStockRequest(
                producto=producto, mercado="girona", delta=delta),
            metadata=(("authorization", f"Bearer {token}"),), timeout=2)
        print("OK  ", producto, "->", r.stock)
    except grpc.RpcError as e:
        print("FAIL", producto, e.code().name, e.details())

ajustar(t_queseria, "queso-curado", +20)   # OK   queso-curado -> 45
ajustar(t_bodega,   "queso-curado", -45)   # FAIL queso-curado PERMISSION_DENIED prod-bodega-roble-alto no es propietario...
ajustar("basura",   "queso-curado", +1)    # FAIL queso-curado UNAUTHENTICATED token inválido: Not enough segments

Con esto, inventario ha dejado de ser un servicio al que cualquiera podía vaciar el stock (la falacia 4 de 01-04). Todavía no sabe si quien le habla es de verdad pedidos (eso es identidad de servicio, 06-04) y todavía habla en claro por la red (06-02), pero ya no ejecuta nada sin saber en nombre de quién.

Errores Comunes y Consejos

  • Confundir 401 y 403. 401/UNAUTHENTICATED es "no sé quién eres" y debe llevar al cliente a autenticarse (o a refrescar). 403/PERMISSION_DENIED es "sé quién eres y no". Mezclarlos rompe la lógica de reintento de los clientes y confunde a soporte.
  • Guardar contraseñas con SHA-256 "porque es seguro". SHA-256 es seguro como hash criptográfico y pésimo como hash de contraseñas: es rápido. Argon2id o bcrypt, siempre.
  • Contraseñas cifradas "por si el usuario las olvida". Si puedes enviarle su contraseña por correo, la tienes en claro o casi. El flujo correcto es restablecer, nunca recuperar.
  • Roles en el token y roles en la base de datos que se contradicen. El token es una foto: si retiras un rol, tarda hasta exp en desaparecer. Diseña con esa latencia (tokens cortos) o revoca por jti.
  • aud como cadena única en un token que cruza varios servicios: cada uno rechazará el token porque no es su audiencia. aud admite una lista, y el token on-behalf-of debe nombrar a todos.
  • Emitir tokens desde varios sitios. Una única autoridad (identidad) firma; todo lo demás verifica. Si un servicio necesita "actuar como" alguien, pide un token, no lo fabrica.
  • Confiar en un X-User-Id que llega en una cabecera. Sin firma, cualquiera lo escribe. Solo la identidad que viene en un token verificado (o en un certificado, 06-04) es fiable.
  • Autorizar solo en el borde. El gateway de 06-05 será una primera barrera cómoda, pero cada servicio debe seguir verificando: la llamada que llega de otro servicio interno no pasó por el borde.
  • Consejo: centraliza la verificación en una librería (servicios/comun/auth.py) con tests que incluyan los ataques del apartado 7 (alg: none, HS256 con la clave pública, aud equivocado, exp pasado). Un test por vulnerabilidad conocida vale más que cualquier revisión manual.
  • Consejo: registra cada decisión de denegación con sub, jti, método y motivo; en 06-05 esos registros serán la base de la auditoría y de la detección de anomalías.
  • Advertencia de cumplimiento. El tratamiento de credenciales y de identificadores de clientes está sujeto al RGPD y, si hay pagos, a PCI DSS. Lo aquí descrito es diseño técnico; la implantación real debe revisarla un profesional de seguridad y de cumplimiento normativo.

Ejercicios

Ejercicio 1: Un token robado

Un atacante consigue, mediante un script inyectado en la web, el token de acceso de Ana a las 10:00:00 (exp = 10:15:00). Enumera qué puede hacer con él contra pedidos e inventario según el diseño de esta lección, hasta cuándo, y qué tres medidas del apartado 6 y 7 reducen o eliminan el daño. Después, indica qué mecanismo permitiría a un operador cortarlo a las 10:04 en cuanto Ana avise, y qué coste tiene ese mecanismo en cada petición.

Ejercicio 2: RBAC y ABAC para reparto

Los 140 repartidores de furgoneta-3 usan la app, que llama al servicio reparto con métodos PublicarPosicion(repartidor_id, lat, lon), ListarEntregas(repartidor_id) y MarcarEntregado(pedido_id). Un operador puede ver cualquier posición y cualquier entrega. Define (a) la tabla ROLES_POR_METODO del interceptor de reparto, (b) qué claim privado necesita el token de un repartidor, (c) la comprobación ABAC de cada método en pseudocódigo, y (d) qué ocurre si un repartidor llama a MarcarEntregado("P-2026-000124") y ese pedido está asignado a otro repartidor.

Ejercicio 3: HS256 en un solo servicio

Un compañero propone: "Para catalogo, que solo lo llama la web, usemos HS256 con un secreto en su configuración; es más simple y rápido que RS256". Argumenta en qué casos tendría razón y por qué en Kilómetro Cero, aun así, se descarta. Incluye en la respuesta qué ocurriría el día que analitica quisiera llamar a catalogo.

Soluciones

Ejercicio 1.

Con el token de Ana (sub: u-ana, roles: [cliente], aud: [pedidos, inventario]) el atacante puede hacer todo lo que Ana puede: listar y leer sus pedidos, crear pedidos a su nombre (hasta el paso de pago, que en un diseño correcto exige la pasarela), consultar stock y reservar stock a su nombre. No puede editar productos ni ver pedidos de Marc: el RBAC y el ABAC siguen aplicándose al token, que solo lleva el rol cliente y el sub de Ana. Puede hacerlo hasta las 10:15:00 (más los 30 s de leeway), y no puede renovarlo si el refresh token está en una cookie HttpOnly que el script no puede leer. Medidas: (1) caducidad corta, que es lo que limita el daño a 15 minutos; (2) no tener el token en localStorage (mantenerlo en memoria de la SPA y el refresh en cookie HttpOnly) reduce mucho la probabilidad del robo y elimina la renovación; (3) rotación de refresh tokens, para que si de algún modo obtuviera el refresh, su uso por el atacante y por Ana genere una detección e invalide la familia. Para cortar a las 10:04: lista negra por jti en Redis (SADD jwt:revocados <jti> con TTL hasta exp) consultada por pedidos e inventario antes de aceptar un token; el coste es un SISMEMBER (un viaje a Redis, submilisegundo) por petición, y una dependencia operativa de Redis para la autenticación, que es justo lo que los tokens sin estado querían evitar; por eso se activa solo para los servicios sensibles o se limita a una caché local del conjunto de revocados refrescada cada pocos segundos.

Ejercicio 2.

(a) PublicarPosicion: {repartidor}; ListarEntregas: {repartidor, operador}; MarcarEntregado: {repartidor, operador}. Un operador no publica posiciones (no conduce). (b) repartidor_id (por ejemplo furgoneta-3-017), emitido por identidad al hacer login la app. (c) PublicarPosicion: request.repartidor_id == claims.repartidor_id, si no PERMISSION_DENIED (mejor aún: ignorar el argumento y usar siempre el del token, eliminando la posibilidad de suplantar). ListarEntregas: si operador en roles, cualquier id; si no, request.repartidor_id == claims.repartidor_id. MarcarEntregado: cargar el pedido; si operador, permitir; si no, pedido.repartidor_asignado == claims.repartidor_id. (d) El interceptor deja pasar la llamada (el rol repartidor puede invocar el método); el servicer carga P-2026-000124, ve que está asignado a otro repartidor y aborta con PERMISSION_DENIED y un mensaje que no revele a quién está asignado; la denegación se registra con sub, jti y pedido para la auditoría.

Ejercicio 3.

Tendría razón si catalogo fuera el único verificador y el único emisor: una aplicación aislada que emite y verifica sus propios tokens con un secreto de 256 bits aleatorios es correcta con HS256, y la verificación HMAC es más rápida que RSA. Se descarta porque en Kilómetro Cero el emisor es identidad, no catalogo: para verificar con HS256, catalogo tendría que conocer el secreto de identidad, y con él podría emitir tokens de operador válidos para pedidos, inventario y pagos; el radio de daño de comprometer catalogo (el servicio más expuesto, el que sirve fotos y listados a Internet) pasaría a ser toda la plataforma. Tampoco hay ganancia de simplicidad real: la librería común ya verifica RS256 y la clave pública se distribuye sin cuidados. El día que analitica quisiera llamar a catalogo con HS256, habría que darle también el secreto (un servicio más con capacidad de emitir), o mantener dos secretos y dos rutas de verificación; con RS256, basta con que el token de analitica incluya catalogo en aud, y catalogo no cambia nada.

Conclusión

Autenticar es establecer quién es el sujeto; autorizar es decidir, en cada operación, si puede hacer lo que pide. En un sistema distribuido la identidad tiene que viajar: una sesión en memoria muere con la segunda instancia, una sesión en Redis resuelve la web pero no se propaga a los servicios, y un token firmado (JWT) lleva la identidad consigo y la hace verificable por cualquiera que tenga la clave pública. Por eso la firma debe ser asimétrica (RS256/ES256): una única autoridad emite, todos verifican, y ningún verificador puede fabricar. Un token es una foto con caducidad: tokens de acceso de minutos, refresh tokens con estado y rotación, lista negra por jti para las urgencias, y verificación estricta de alg, aud, iss y exp para no repetir los errores conocidos. Las contraseñas, cuando existen, se almacenan con Argon2id o bcrypt, con sal y coste, nunca de forma reversible. Sobre la identidad verificada se construye la autorización: RBAC para permisos por tipo (cliente, productor, repartidor, operador), ABAC para la propiedad ("sus productos", "sus pedidos"), ReBAC cuando las relaciones se complican, con mínimo privilegio y denegación por defecto, aplicada de forma gruesa en el borde y en cada servicio y de forma fina donde viven los datos, con OPA como camino de externalización si las reglas crecen. Kilómetro Cero tiene ahora servicios/identidad/tokens.py, una librería servicios/comun/auth.py, un AuthInterceptor en inventario y la primera regla ABAC: la Quesería Montblanc solo toca sus quesos.

Pero el token de Ana viaja todavía en claro entre pedidos e inventario, igual que su teléfono, su dirección y los eventos de pedidos.eventos que cualquiera con acceso a la red interna podría leer o alterar. Saber quién es quién no sirve de mucho si la conversación se puede escuchar. La siguiente lección trata el cifrado y la protección de datos: en tránsito, con TLS en gRPC y en cada almacén; en reposo, con cifrado de campos y de volúmenes; y en su dimensión personal, con seudonimización y el derecho al olvido.

Curso de Arquitecturas Distribuidas

Módulo 1: Introducción a los Sistemas Distribuidos

Módulo 2: Comunicación en Sistemas Distribuidos

Módulo 3: Consistencia y Replicación

Módulo 4: Almacenamiento Distribuido

Módulo 5: Computación Distribuida

Módulo 6: Seguridad en Sistemas Distribuidos

Módulo 7: Monitoreo y Mantenimiento

Módulo 8: Casos de Estudio y Aplicaciones

© Copyright 2026. Todos los derechos reservados