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
- Autenticación y autorización: dos preguntas distintas
- Por qué una sesión en memoria no sobrevive a la distribución
- Factores de autenticación y almacenamiento de contraseñas
- Sesiones con estado frente a tokens sin estado
- JSON Web Tokens: anatomía, firma y claims
- Caducidad, refresh tokens y revocación
- Errores clásicos con JWT
- Propagación de identidad entre servicios
- Modelos de autorización: RBAC, ABAC, ReBAC
- Dónde vive la política: centralizada o en cada servicio
- Kilómetro Cero:
identidad, interceptor gRPC y comprobación ABAC - Errores Comunes y Consejos
- Ejercicios
- Conclusión
- 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.
- 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:
pedidosrecibe 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 ainventario, 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.
- 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 FalseObserva 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.
- 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:
- Ana envía usuario y contraseña a
identidad; se verifica con Argon2. identidadgenera un id aleatorio de 256 bits (secrets.token_urlsafe(32)), guardasesion:<id> → {"sub": "u-ana", "roles": ["cliente"]}en Redis con TTL, y devuelve el id en una cookieHttpOnly; Secure; SameSite=Lax.- 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 trivialVentajas: 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.
- 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...
- 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. - Payload: los claims, un objeto JSON con afirmaciones sobre el sujeto.
- 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.
- 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:
- 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.
- 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. - Lista negra en Redis por
jti: para los casos en que 15 minutos son demasiados (bloqueo urgente de una cuenta comprometida), el servicio consultaSISMEMBER 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. - Rotación de claves de firma: publicar la nueva clave pública con un
kidnuevo, 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 porkid.
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
- 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.
- 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
authorizationcon el mismo valorBearer <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.
- 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.
- 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.
- Kilómetro Cero:
identidad, interceptor gRPC y comprobación ABAC
identidad, interceptor gRPC y comprobación ABACVamos 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.pubLa .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 segmentsCon 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/
UNAUTHENTICATEDes "no sé quién eres" y debe llevar al cliente a autenticarse (o a refrescar). 403/PERMISSION_DENIEDes "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
expen desaparecer. Diseña con esa latencia (tokens cortos) o revoca porjti. audcomo cadena única en un token que cruza varios servicios: cada uno rechazará el token porque no es su audiencia.audadmite 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-Idque 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,audequivocado,exppasado). 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
- Conceptos Básicos de Sistemas Distribuidos
- Modelos de Sistemas Distribuidos
- Ventajas y Desafíos de los Sistemas Distribuidos
- Las Falacias de la Computación Distribuida
- Tiempo, Relojes y Ordenación de Eventos
- Del Monolito a la Plataforma Distribuida: el Caso Kilómetro Cero
Módulo 2: Comunicación en Sistemas Distribuidos
- Protocolos de Comunicación
- RPC y RMI
- gRPC y Serialización de Datos
- Mensajería y Colas de Mensajes
- Patrones de Comunicación Asíncrona
Módulo 3: Consistencia y Replicación
- Modelos de Consistencia
- El Teorema CAP y PACELC
- Algoritmos de Consenso
- Replicación de Datos
- Transacciones Distribuidas y Sagas
Módulo 4: Almacenamiento Distribuido
- Particionado de Datos y Hashing Consistente
- Sistemas de Archivos Distribuidos
- Almacenamiento de Objetos
- Bases de Datos Distribuidas
- Cachés Distribuidos
Módulo 5: Computación Distribuida
- Modelos de Computación Distribuida
- MapReduce y Hadoop
- Spark y Computación en Memoria
- Procesamiento de Flujos de Datos
- Planificación de Trabajos y Pipelines de Datos
Módulo 6: Seguridad en Sistemas Distribuidos
- Autenticación y Autorización
- Cifrado y Protección de Datos
- Gestión de Identidades
- Seguridad entre Servicios: mTLS y Gestión de Secretos
- Puertas de Enlace, Limitación de Tasa y Auditoría
Módulo 7: Monitoreo y Mantenimiento
- Monitoreo de Sistemas Distribuidos
- Logs Centralizados y Trazabilidad Distribuida
- Gestión de Fallos y Recuperación
- Patrones de Resiliencia: Timeouts, Reintentos y Circuit Breaker
- Automatización y Orquestación
- Pruebas en Sistemas Distribuidos e Ingeniería del Caos
