El Mòdul 5 va acabar amb una confessió: en tot el que hem construït fins ara, qualsevol procés podia parlar amb qualsevol servei i llegir qualsevol dada. comandes crida inventari.ReservarEstoc sense dir qui és; el consumidor de Flink llegeix comandes.esdeveniments sense identificar-se; i el monòlit original desava la sessió de l'Anna a la memòria de l'únic procés que existia, cosa que va deixar de funcionar en el moment en què hi va haver dues instàncies darrere d'un balancejador. Les dues preguntes que cap sistema no pot eludir són qui és qui (autenticació) i qui pot fer què (autorització), i en un sistema distribuït tenen una dificultat afegida: la resposta ha de viatjar amb cada petició a través de serveis que no comparteixen memòria, ni procés, ni de vegades ni tan sols equip de desenvolupament.

En aquesta lliçó separarem amb precisió tots dos conceptes, veurem com es desen contrasenyes de manera que una fuita de la base de dades no sigui una catàstrofe, compararem sessions amb estat (galeta més Redis) amb tokens sense estat, desmuntarem un JSON Web Token peça a peça, i estudiarem com aquesta identitat es propaga de la web a comandes i de comandes a inventari. Després construirem l'autorització: rols, atributs i relacions, i el principi de mínim privilegi. El codi deixa Quilòmetre Zero amb un servei identitat que emet tokens RS256, i amb inventari rebutjant qualsevol crida que no arribi signada i autoritzada. Queda deliberadament fora com es delega el login en un proveïdor d'identitat (OAuth 2.0, OpenID Connect, SSO: lliçó 06-03), com s'identifiquen els serveis entre ells amb certificats (06-04) i com es centralitza la validació a la vora (06-05).

Contingut

  1. Autenticació i autorització: dues preguntes diferents
  2. Per què una sessió en memòria no sobreviu a la distribució
  3. Factors d'autenticació i emmagatzematge de contrasenyes
  4. Sessions amb estat contra tokens sense estat
  5. JSON Web Tokens: anatomia, signatura i claims
  6. Caducitat, refresh tokens i revocació
  7. Errors clàssics amb JWT
  8. Propagació d'identitat entre serveis
  9. Models d'autorització: RBAC, ABAC, ReBAC
  10. On viu la política: centralitzada o a cada servei
  11. Quilòmetre Zero: identitat, interceptor gRPC i comprovació ABAC
  12. Errors Comuns i Consells
  13. Exercicis
  14. Conclusió

  1. Autenticació i autorització: dues preguntes diferents

Es confonen constantment perquè solen passar una rere l'altra i perquè totes dues s'abreugen "auth". Val la pena fixar-les:

Autenticació (AuthN) Autorització (AuthZ)
Pregunta Ets qui dius que ets? Pots fer el que demanes?
Entrada Credencials: contrasenya, codi, certificat, token Identitat ja verificada + acció + recurs
Sortida Una identitat (subjecte) i, sovint, atributs (rols, ids) Una decisió: permetre o denegar
Quan Un cop per sessió o per token A cada operació
Fallada típica 401 Unauthorized (mal nom: vol dir "no autenticat") 403 Forbidden
A Quilòmetre Zero L'Anna demostra ser [email protected] amb la seva contrasenya L'Anna pot veure P-2026-000123 perquè és seva; no pot editar formatge-curat

Un error de disseny freqüent és resoldre la primera i oblidar la segona: l'aplicació comprova que hi ha un usuari vàlid i a partir d'aquí tot es permet. En un marketplace això vol dir que qualsevol client registrat podria, canviant un id a l'URL, veure les comandes del Marc o modificar els preus del Celler Roure Alt. L'autenticació identifica; l'autorització protegeix.

  1. Per què una sessió en memòria no sobreviu a la distribució

El monòlit de 01-01 feia el que fan tots els frameworks web per defecte: en fer login, desava {"usuari": "anna", "rols": ["client"]} en un diccionari en memòria, generava un identificador aleatori i el retornava en una galeta. A cada petició posterior, cercava la galeta al diccionari. Perfecte amb un sol procés. I a 01-06 la plataforma va passar a tenir:

  • Diverses instàncies del mateix servei darrere d'un balancejador: la petició 2 de l'Anna pot aterrar en una instància que mai no va veure el seu login. La solució d'urgència, les sticky sessions (el balancejador envia sempre l'Anna a la mateixa instància), trenca el repartiment de càrrega i perd les sessions quan aquella instància mor o es redesplega.
  • Diversos serveis: comandes rep una petició que ve de la web, però necessita saber que és de l'Anna per consultar les seves comandes i no unes altres, i després crida inventari, que també hauria de saber en nom de qui actua.
  • Processos sense navegador: l'app de repartidors, el DAG d'Airflow, un consumidor de Kafka. No tenen galetes i no "fan login" en el sentit humà.

Hi ha dues famílies de solucions, i totes dues es fan servir:

flowchart LR
    subgraph E["Sessió amb estat"]
        C1[Galeta: id opac] --> R[(Redis compartit<br/>sessio:abc123 → anna, rols)]
        R --> S1[Instància 1]
        R --> S2[Instància 2]
    end
    subgraph T["Token sense estat"]
        C2[Token signat:<br/>sub=anna, rols, exp] --> V1[Instància 1<br/>verifica la signatura]
        C2 --> V2[Instància 2<br/>verifica la signatura]
        C2 --> V3[inventari<br/>verifica la signatura]
    end

En la primera, la identitat viu en un magatzem compartit i la galeta és només una referència. En la segona, la identitat viatja dins del mateix token, protegida per una signatura que qualsevol servei pot verificar sense preguntar a ningú. Abans de comparar-les, cal resoldre el pas previ: com verifiquem la contrasenya de l'Anna el primer cop.

  1. Factors d'autenticació i emmagatzematge de contrasenyes

Els factors d'autenticació es classifiquen en tres famílies: una cosa que saps (contrasenya, PIN), una cosa que tens (mòbil amb app TOTP, clau FIDO2, targeta), una cosa que ets (empremta, cara). L'autenticació multifactor (MFA) combina dues famílies diferents; dues contrasenyes no són dos factors. La seva implantació a la plataforma, juntament amb la recuperació de comptes, és tema de 06-03; aquí ens centrem en el factor que tota aplicació acaba gestionant: la contrasenya.

La regla: mai xifratge reversible, sempre hash amb sal

Una contrasenya no s'ha de poder recuperar, ni tan sols per part de l'administrador. Si es xifra amb AES i una clau, qui tingui la clau (o la robi juntament amb la base de dades) obté totes les contrasenyes en clar, que els usuaris reutilitzen al banc i al correu. El que és correcte és desar-ne un hash: una funció d'un sol sentit. Però no un hash qualsevol:

Mètode Problema
MD5 / SHA-1 / SHA-256 de la contrasenya Rapidíssims: una GPU en prova milers de milions per segon; les taules precalculades (rainbow tables) inverteixen hashes comuns a l'instant
SHA-256 amb sal Anul·la les taules precalculades, però continua sent ràpid: un atac de diccionari per usuari continua sent viable
bcrypt, scrypt, Argon2 (hashes de contrasenya) Dissenyats per ser lents i configurables (cost en temps i, en Argon2/scrypt, en memòria), amb sal aleatòria incorporada

La sal és un valor aleatori, diferent per a cada usuari, que es concatena a la contrasenya abans de fer-ne el hash i es desa al costat del resultat. Dos usuaris amb la contrasenya estiu2026 tindran hashes diferents, i l'atacant no pot amortitzar un càlcul entre usuaris. El factor de cost fa que verificar una contrasenya trigui, deliberadament, desenes de mil·lisegons: irrellevant per a un login, devastador per a qui n'intenta milers de milions.

Argon2id és la recomanació actual (guanyador de la Password Hashing Competition, resistent a GPU pel seu ús de memòria); bcrypt continua sent perfectament acceptable i és més comú en sistemes existents. A Quilòmetre Zero farem servir argon2-cffi:

# km0/serveis/identitat/contrasenyes.py
# pip install argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

# Paràmetres: 3 iteracions, 64 MiB de memòria, 4 fils. Ajusta'ls perquè
# 'hash' trigui ~100 ms al teu maquinari de producció: és el cost que pagues
# un cop per login i que l'atacant paga milers de milions de vegades.
ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4)

def registrar(contrasenya: str) -> str:
    """Retorna la cadena que es desa a la base de dades. Inclou
    algorisme, paràmetres, sal i hash: tot el que cal per verificar."""
    return ph.hash(contrasenya)

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

def necessita_rehash(hash_desat: str) -> bool:
    """True si el hash es va fer amb paràmetres més febles que els actuals:
    al següent login correcte, es recalcula i es desa."""
    return ph.check_needs_rehash(hash_desat)

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

Fixa't en la cadena resultant: $argon2id$v=19$m=65536,t=3,p=4$<sal>$<hash>. Porta l'algorisme i els paràmetres a dins, de manera que d'aquí a tres anys, quan apugis memory_cost, els hashes antics continuaran verificant-se i necessita_rehash et dirà quins cal actualitzar al següent login. Amb bcrypt el codi és anàleg: bcrypt.hashpw(contrasenya.encode(), bcrypt.gensalt(rounds=12)) i bcrypt.checkpw(...), amb el límit de 72 bytes de contrasenya que convé conèixer.

Dos detalls de disseny que sovint s'obliden: el missatge d'error del login ha de ser idèntic tant si l'usuari no existeix com si la contrasenya és incorrecta (altrament s'enumeren comptes), i el temps de resposta també, cosa que s'aconsegueix verificant contra un hash fictici quan l'usuari no existeix.

  1. Sessions amb estat contra tokens sense estat

Sessió amb estat: galeta + Redis

La lliçó 04-05 va deixar Redis com a memòria cau del catàleg; és també el magatzem de sessions clàssic. El flux és:

  1. L'Anna envia usuari i contrasenya a identitat; es verifica amb Argon2.
  2. identitat genera un id aleatori de 256 bits (secrets.token_urlsafe(32)), desa sessio:<id> → {"sub": "u-anna", "roles": ["client"]} a Redis amb TTL, i retorna l'id en una galeta HttpOnly; Secure; SameSite=Lax.
  3. Cada instància de la web, a cada petició, llegeix la galeta, fa GET sessio:<id> a Redis i obté la identitat.
# Fragment: magatzem de sessions compartit (la web té N instàncies)
import json, secrets, redis
r = redis.Redis(host="redis", decode_responses=True)
TTL_S = 8 * 3600

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

def carregar_sessio(sid: str) -> dict | None:
    v = r.get(f"sessio:{sid}")
    if v is None:
        return None
    r.expire(f"sessio:{sid}", TTL_S)      # sessió lliscant: cada ús la renova
    return json.loads(v)

def tancar_sessio(sid: str) -> None:
    r.delete(f"sessio:{sid}")             # revocació immediata i trivial

Avantatges: la revocació és un DEL; el client no veu res de la identitat (l'id és opac); la sessió pot contenir el que es vulgui sense patir per la mida. Inconvenients: cada petició costa un viatge a Redis, Redis és ara un punt crític (si cau, ningú no està autenticat), i sobretot no es propaga: quan la web crida comandes i inventari, aquests serveis no tenen la galeta ni haurien de consultar el Redis de sessions de la web.

Token sense estat

L'alternativa és que identitat emeti un document signat amb la identitat i els seus atributs: un token. Qui el rep en verifica la signatura i confia en el contingut sense consultar ningú. És el mateix principi que els URL presignats de MinIO (04-03), que vam anomenar capacitat: un permís portable, autocontingut, amb caducitat, que es verifica criptogràficament.

Criteri Sessió (galeta + Redis) Token (JWT)
Estat al servidor Sí (Redis) No
Cost per petició Un GET a Redis Verificar una signatura (microsegons amb HS256, ~desenes de µs amb RS256)
Revocació Immediata (DEL) Difícil: el token és vàlid fins a exp (apartat 6)
Propagació entre serveis No natural Natural: el token viatja a cada crida
Què veu el client Un id opac El contingut (signat, no xifrat)
Mida Galeta de ~50 bytes De centenars de bytes a 1-2 KB per petició
Ús típic Web tradicional monolítica APIs, microserveis, apps mòbils, màquina a màquina

A la pràctica moltes plataformes combinen totes dues: la web manté una sessió amb galeta per al navegador (més segura davant de XSS, com veurem) i, en cridar els serveis interns, bescanvia aquesta sessió per un token de vida curta que sí que es propaga. Quilòmetre Zero farà servir tokens des de l'app de repartidors i entre serveis, i la decisió per a la web es discuteix a l'apartat 7.

  1. JSON Web Tokens: anatomia, signatura i claims

Un JWT (RFC 7519) és una cadena amb tres parts separades per punts, cadascuna en Base64URL:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImttMC0yMDI2LTA5In0
.
eyJpc3MiOiJodHRwczovL2lkZW50aXRhdC5rbTAiLCJzdWIiOiJ1LWFubmEiLCJhdWQiOlsiY29tYW5kZXMiLCJpbnZlbnRhcmkiXSwiZXhwIjoxNzczNjc0MDAwLCJpYXQiOjE3NzM2NzMxMDAsImp0aSI6IjdmNC4uLiIsInJvbGVzIjpbImNsaWVudCJdfQ
.
Xk9v...signatura...
  1. Header: {"alg": "RS256", "typ": "JWT", "kid": "km0-2026-09"}. L'algorisme de signatura i, opcionalment, l'identificador de la clau (kid) per saber amb quina verificar quan n'hi ha diverses en rotació.
  2. Payload: els claims, un objecte JSON amb afirmacions sobre el subjecte.
  3. Signatura: es calcula sobre base64url(header) + "." + base64url(payload) amb l'algorisme indicat. Si algú canvia una lletra del payload, la signatura deixa de quadrar.

Important: el payload està codificat, no xifrat. Qualsevol que tingui el token el pot llegir amb base64 -d. No posis mai en un JWT una dada que el portador no hagi de veure (una contrasenya, el telèfon d'un altre usuari). Existeix una variant xifrada (JWE), poc usada en APIs; el xifratge de dades és tema de 06-02.

Claims estàndard

Claim Nom Significat A Quilòmetre Zero
iss issuer Qui va emetre el token https://identitat.km0
sub subject Identificador del subjecte (estable, no un correu canviant) u-anna, prod-formatgeria-montblanc, svc-comandes
aud audience Per a qui és el token; el receptor ha de comprovar que hi és a la llista ["comandes", "inventari"]
exp expiration Instant Unix a partir del qual és invàlid iat + 15 min
iat issued at Quan es va emetre —
nbf not before No vàlid abans d'aquest instant Rar; útil amb rellotges desincronitzats
jti JWT id Identificador únic del token Permet revocar-ne un de concret (apartat 6) i correlacionar l'auditoria (06-05)

A aquests s'hi afegeixen claims privats propis de l'aplicació. Quilòmetre Zero farà servir roles (llista) i, per als productors, productor_id (slug), i per als repartidors repartidor_id. La regla: pocs claims, estables, i mai els que canvien cada minut (el saldo, el cistell), perquè el token és una foto en el moment de l'emissió.

HS256 contra RS256/ES256: per què els serveis verifiquen amb clau pública

Algorisme Tipus Clau per signar Clau per verificar Conseqüència
HS256 HMAC amb SHA-256 (simètric) Secret compartit El mateix secret Tot servei que verifica pot també emetre tokens
RS256 RSA + SHA-256 (asimètric) Clau privada (només identitat) Clau pública (qualsevol) Els serveis verifiquen sense poder falsificar; signatura de ~256 bytes
ES256 ECDSA P-256 (asimètric) Clau privada Clau pública Com RS256, signatures més curtes (64 bytes) i verificació ràpida; menys suport en llibreries antigues
EdDSA Ed25519 Clau privada Clau pública El més modern i ràpid; suport creixent

Amb HS256, inventari, comandes, repartiment i analitica necessitarien el secret per verificar, i un compromís de qualsevol d'ells (o de qualsevol dels seus desenvolupadors amb accés a la configuració) permetria fabricar un token d'operador. Amb RS256, només identitat té la clau privada; els altres carreguen la pública, que es pot publicar sense risc (a 06-03 veurem l'estàndard per fer-ho, JWKS). En un sistema distribuït amb més d'un verificador, la signatura asimètrica no és una opció: és l'única sensata.

  1. Caducitat, refresh tokens i revocació

El preu de no tenir estat és que no es pot retirar un token emès: és vàlid fins a exp, encara que l'usuari tanqui la sessió o se li retiri un rol. Les estratègies, combinables:

  1. Caducitat curta: tokens d'accés de 5 a 15 minuts. Limita la finestra de dany d'un token robat i la latència amb què s'apliquen els canvis de rol.
  2. Refresh token: un segon token, de vida llarga (dies o setmanes), amb estat (desat a identitat, a PostgreSQL o Redis) i d'un sol ús, que serveix únicament per demanar un nou token d'accés. Tancar la sessió o revocar un usuari és esborrar els seus refresh tokens: en 15 minuts com a màxim tots els seus tokens d'accés hauran caducat. La rotació (cada ús del refresh n'emet un de nou i invalida l'anterior) detecta robatoris: si apareix l'antic, algú el va copiar, i s'invalida tota la família.
  3. Llista negra a Redis per jti: per als casos en què 15 minuts són massa (bloqueig urgent d'un compte compromès), el servei consulta SISMEMBER jwt:revocats <jti> abans d'acceptar el token, amb TTL igual al temps de vida que li quedava. Reintrodueix un viatge a Redis per petició, però només es consulta una estructura petita, i és l'única manera de tenir revocació immediata amb tokens.
  4. Rotació de claus de signatura: publicar la nova clau pública amb un kid nou, començar a signar amb ella, i retirar l'antiga quan hagin caducat tots els tokens signats amb ella. Els verificadors han d'acceptar diverses claus simultàniament, triades per kid.
sequenceDiagram
    participant A as App de l'Anna
    participant I as identitat
    participant P as comandes
    A->>I: POST /login (correu, contrasenya)
    I->>I: Argon2 verify
    I-->>A: access (15 min, RS256) + refresh (30 dies, opac, a la BD)
    A->>P: GET /comandes  Authorization: Bearer <access>
    P->>P: verificar signatura amb clau pública, exp, aud
    P-->>A: 200 comandes de u-anna
    Note over A: passen 15 min: access caducat
    A->>P: GET /comandes  Bearer <access>
    P-->>A: 401 (exp)
    A->>I: POST /token/refresh (refresh)
    I->>I: existeix i no s'ha usat? rotar
    I-->>A: nou access + nou refresh

  1. Errors clàssics amb JWT

L'especificació de JWT és flexible i aquesta flexibilitat ha produït vulnerabilitats famoses. Coneix-les per no repetir-les:

Error Què passa Defensa
Acceptar alg: none L'especificació permet tokens "sense signatura"; les llibreries antigues els acceptaven i un atacant podia escriure el payload que volgués Passar sempre la llista algorithms=["RS256"] a la verificació; no llegir mai l'algorisme del mateix token
Confusió d'algorisme (RS256 → HS256) L'atacant signa amb HS256 fent servir la clau pública (coneguda) com a secret; una llibreria que accepti tots dos algorismes amb la mateixa clau el valida Igual: llista tancada d'algorismes, i objectes de clau amb tipus (PyJWT ≥ 2 ho impedeix)
No validar aud Un token emès per a l'app de repartidors es reutilitza contra pagaments Cada servei verifica que el seu nom és a aud
No validar exp / iss Tokens eterns, o tokens d'un altre emissor amb la mateixa llibreria Verificació completa per defecte; iss esperat explícit
Secrets febles a HS256 Amb HS256 i secret123, la signatura es trenca per força bruta en minuts RS256/ES256; si HS256, secret de ≥ 256 bits aleatoris
JWT a localStorage Qualsevol script injectat (XSS) llegeix localStorage i roba el token; amb una galeta HttpOnly no pot A la web, galeta HttpOnly; Secure; SameSite (encara que contingui un JWT), o sessió amb estat; el JWT en memòria de la SPA amb refresh per galeta és el compromís habitual
Dades sensibles al payload El portador les pot llegir i queden en logs i proxies Només identificadors i rols
Tokens d'hores o dies sense refresh Finestra de dany enorme; canvis de rol que triguen un dia 5-15 minuts + refresh amb rotació
Verificar a cada servei "a mà" Un s'oblida de aud, un altre de exp Una llibreria comuna (km0/serveis/comu/auth.py) i el gateway com a primera barrera (06-05)

A Quilòmetre Zero: la web desarà el token d'accés només a la memòria de la SPA i el refresh en una galeta HttpOnly; l'app de repartidors, a l'emmagatzematge segur del sistema operatiu; els serveis no el persistiran mai.

  1. Propagació d'identitat entre serveis

Quan l'Anna confirma la comanda P-2026-000126, la petició travessa diversos salts: web → comandes → inventari (reservar estoc) → pagaments. Qui actua a cada salt?

  • En HTTP, el token viatja a la capçalera Authorization: Bearer <jwt>.
  • En gRPC, a les metadades de la crida (02-03 les va presentar, i va avançar que servirien per a credencials): la clau authorization amb el mateix valor Bearer <jwt>. gRPC comprimeix les capçaleres amb HPACK a HTTP/2, així que el cost de repetir-les a cada crida és petit.

Hi ha dos models de propagació:

Model Descripció Avantatges Inconvenients
Token de l'usuari reenviat (on-behalf-of) comandes reenvia a inventari el mateix JWT de l'Anna inventari sap que la reserva és de l'Anna; auditoria d'extrem a extrem; el mínim privilegi es conserva L'aud ha d'incloure tots els serveis de la cadena; un servei intermedi compromès té el token de l'Anna durant 15 min
Identitat del servei comandes crida inventari amb el seu propi token (sub: svc-comandes), i passa l'id d'usuari com a dada Tokens de servei simples; no exposa el de l'Anna inventari ha de confiar que comandes diu la veritat sobre l'usuari: només vàlid si comandes està autenticat com a servei (06-04)

Quilòmetre Zero farà servir el primer a les crides que executen l'acció d'un usuari (comandes → inventari.ReservarEstoc porta el token de l'Anna), i el segon per a processos autònoms (Airflow llegint el llac, Flink consumint comandes.esdeveniments), que a 06-03 obtindran tokens de màquina i a 06-04 s'identificaran a més per certificat. El mateix mecanisme de metadades servirà a 07-02 per propagar l'identificador de traça.

  1. Models d'autorització: RBAC, ABAC, ReBAC

Amb la identitat verificada arriba la segona pregunta. Els models principals, de menys a més expressius:

RBAC: control basat en rols

A cada subjecte se li assignen rols, i a cada rol, permisos (acció sobre tipus de recurs). Quilòmetre Zero en defineix quatre:

Rol Qui Permisos (extracte)
client Anna, Marc, Llúcia comandes:crear, comandes:llegir (les pròpies), cataleg:llegir
productor Horta La Vega, Formatgeria Montblanc, Celler Roure Alt cataleg:llegir, productes:editar (els propis), inventari:ajustar (el propi), comandes:llegir (les que contenen els seus productes)
repartidor Els 140 de furgoneta-3 i la resta repartiment:posicio, comandes:llegir (les assignades), comandes:marcar_lliurada
operador Empleats de Quilòmetre Zero Tot l'anterior + comandes:cancellar, productors:aprovar, campanyes:crear

RBAC és senzill, auditable ("qui pot aprovar productors? els operadors") i suficient per a permisos de tipus. El seu límit és entre parèntesis a la taula: "les pròpies", "les assignades". Un rol no sap de quin formatge és propietari qui.

ABAC: control basat en atributs

La decisió es pren avaluant atributs del subjecte (rol, productor_id), del recurs (propietari, estat), de l'acció i del context (hora, IP, mercat). La regla que RBAC no podia expressar: "un subjecte amb rol productor pot productes:editar un producte si producte.productor_id == subjecte.productor_id". Amb ABAC, la Formatgeria Montblanc edita formatge-curat i formatge-fresc i no pot tocar vi-crianca. És més expressiu i més difícil d'auditar: per respondre "qui pot editar formatge-curat?" cal avaluar regles, no llegir una taula.

ReBAC: control basat en relacions

Generalitza ABAC modelant les relacions com un graf (usuari:anna és propietari de comanda:P-2026-000123; usuari:marc és membre de productor:celler-roure-alt, que és propietari de producte:vi-crianca) i responent a "existeix un camí que permeti aquesta acció?". És el model de Google Zanzibar i de sistemes com OpenFGA o SpiceDB, i brilla quan hi ha jerarquies i permisos compartits (un productor amb diversos empleats, una carpeta amb subcarpetes). Per a Quilòmetre Zero, RBAC més unes quantes regles ABAC són suficients; ReBAC quedaria per a quan els productors tinguin equips amb permisos delegats.

Mínim privilegi

Transversal als tres: cada subjecte (persona o servei) té exactament els permisos que necessita per a la seva tasca i cap més. analitica llegeix el llac però no pot escriure a km0_comandes; repartiment actualitza posicions però no pot cancel·lar comandes; un operador d'atenció al client veu comandes però no pot crear campanyes. I, per defecte, denegar: el que no està explícitament permès està prohibit. Cada permís extra és superfície d'atac i, quan un servei o un compte es compromet, el radi de dany és exactament la seva llista de permisos.

  1. On viu la política: centralitzada o a cada servei

Un cop decidides les regles, on s'executen?

Opció Com Avantatges Inconvenients
A cada servei (codi) Decoradors i comprovacions a inventari, comandes... Sense dependències; latència zero; context complet del domini Regles duplicades i inconsistents; canviar "qui pot cancel·lar" és un desplegament a diversos serveis
Política centralitzada (motor de polítiques) Cada servei pregunta a un motor amb la tupla (subjecte, acció, recurs, context); el motor avalua regles escrites en un llenguatge declaratiu Una sola font de veritat; canvis sense redesplegar; auditoria de les regles Un component més; latència d'una crida (es mitiga amb el motor com a sidecar local); les regles necessiten els atributs del recurs

El representant més conegut del segon enfocament és Open Policy Agent (OPA) amb el seu llenguatge Rego: els serveis envien un JSON d'entrada i OPA respon allow: true/false. Una regla per al cas de la Formatgeria Montblanc s'escriuria, aproximadament, així:

package km0.productes

default allow := false

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

allow if {
    input.accio == "editar"
    "productor" in input.subjecte.roles
    input.recurs.productor_id == input.subjecte.productor_id
}

La decisió pragmàtica habitual, i la de Quilòmetre Zero en aquesta lliçó: RBAC gruixut a la vora i a cada servei (té el rol necessari per a aquest mètode?) mitjançant una llibreria comuna, i ABAC fi dins del servei que posseeix les dades (és seu aquest producte?), on hi ha els atributs. Si les regles creixen o cal canviar-les sense redesplegar, s'externalitzen a OPA sense canviar l'estructura: el decorador passa a preguntar al motor.

  1. Quilòmetre Zero: identitat, interceptor gRPC i comprovació ABAC

Construirem tres peces: l'emissor de tokens, la verificació a inventari com a interceptor gRPC, i la regla ABAC de productes.

Claus de signatura

# Un sol cop, en desenvolupament. En producció: generades i custodiades pel gestor de secrets (06-04).
mkdir -p km0/serveis/identitat/claus
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
    -out km0/serveis/identitat/claus/km0-2026-09.pem
openssl pkey -in km0/serveis/identitat/claus/km0-2026-09.pem -pubout \
    -out km0/serveis/identitat/claus/km0-2026-09.pub

La .pem (privada) només la munta el contenidor d'identitat; la .pub es distribueix a tots els serveis (a 06-03 se substituirà per un endpoint JWKS).

serveis/identitat/tokens.py

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

ISS = "https://identitat.km0"
KID = "km0-2026-09"
ACCES_TTL_S = 15 * 60

with open("serveis/identitat/claus/km0-2026-09.pem", "rb") as f:
    CLAU_PRIVADA = f.read()        # només 'identitat' té aquest fitxer

def emetre_acces(sub: str, roles: list[str], aud: list[str], **extra) -> str:
    """Emet un JWT RS256 de 15 minuts. 'extra' permet claims privats
    com productor_id o repartidor_id."""
    ara = int(time.time())
    claims = {
        "iss": ISS, "sub": sub, "aud": aud,
        "iat": ara, "exp": ara + ACCES_TTL_S,
        "jti": str(uuid.uuid4()),
        "roles": roles, **extra,
    }
    return jwt.encode(claims, CLAU_PRIVADA, algorithm="RS256",
                      headers={"kid": KID})

if __name__ == "__main__":
    print(emetre_acces("u-anna", ["client"], ["comandes", "inventari"]))
    print(emetre_acces("prod-formatgeria-montblanc", ["productor"],
                       ["cataleg", "inventari"],
                       productor_id="formatgeria-montblanc"))

I la verificació, que no viu a identitat sinó en una llibreria comuna que importen tots els serveis:

# km0/serveis/comu/auth.py
import jwt
from jwt import PyJWKClient  # es farà servir a 06-03; aquí carreguem la pública d'un fitxer

ISS = "https://identitat.km0"
CLAUS_PUBLIQUES = {}
with open("serveis/comu/claus/km0-2026-09.pub", "rb") as f:
    CLAUS_PUBLIQUES["km0-2026-09"] = f.read()

class TokenInvalid(Exception):
    pass

def verificar(token: str, audiencia: str) -> dict:
    """Retorna els claims si el token és vàlid per a aquest servei.
    Cada comprovació que segueix correspon a un error de l'apartat 7."""
    try:
        kid = jwt.get_unverified_header(token).get("kid")
        clau = CLAUS_PUBLIQUES[kid]                       # rotació per kid
        return jwt.decode(
            token, clau,
            algorithms=["RS256"],                         # llista tancada: mai 'none', mai HS256
            audience=audiencia,                           # aud ha de contenir aquest servei
            issuer=ISS,                                   # només el nostre emissor
            options={"require": ["exp", "iat", "sub", "jti"]},
            leeway=30,                                    # 30 s de tolerància de rellotge (01-05)
        )
    except (KeyError, jwt.PyJWTError) as e:
        raise TokenInvalid(str(e))

leeway=30 mereix un comentari: els rellotges d'identitat i d'inventari no estan perfectament sincronitzats (01-05), i un token emès fa 200 ms pot semblar "del futur" (iat posterior al rellotge local) o haver "caducat" mig segon abans. Trenta segons de tolerància eviten errors fantasma sense obrir una finestra significativa.

Interceptor gRPC a inventari

gRPC permet registrar interceptors al servidor: codi que s'executa abans de cada mètode, amb accés a les metadades. És el lloc natural per autenticar, de manera que ReservarEstoc i la resta de mètodes de 02-03 no ho hagin de repetir:

# km0/serveis/inventari/auth_interceptor.py
import grpc
from serveis.comu.auth import verificar, TokenInvalid

# RBAC gruixut per mètode: quins rols poden invocar cada RPC del contracte inventari.proto
ROLS_PER_METODE = {
    "/km0.inventari.v1.Inventari/ConsultarEstoc":  {"client", "productor", "repartidor", "operador"},
    "/km0.inventari.v1.Inventari/ReservarEstoc":   {"client", "operador"},
    "/km0.inventari.v1.Inventari/AjustarEstoc":    {"productor", "operador"},
    "/km0.inventari.v1.Inventari/ObservarCanvis":  {"operador"},
}

def _avortar(codi, detall):
    def handler(request, context):
        context.abort(codi, detall)
    return grpc.unary_unary_rpc_method_handler(handler)

class AuthInterceptor(grpc.ServerInterceptor):
    def intercept_service(self, continuation, handler_call_details):
        metode = handler_call_details.method
        meta = dict(handler_call_details.invocation_metadata)
        auth = meta.get("authorization", "")
        if not auth.startswith("Bearer "):
            return _avortar(grpc.StatusCode.UNAUTHENTICATED, "falta el token")
        try:
            claims = verificar(auth[len("Bearer "):], audiencia="inventari")
        except TokenInvalid as e:
            return _avortar(grpc.StatusCode.UNAUTHENTICATED, f"token invalid: {e}")

        permesos = ROLS_PER_METODE.get(metode, set())
        if not permesos & set(claims.get("roles", [])):
            return _avortar(grpc.StatusCode.PERMISSION_DENIED,
                            f"{claims['sub']} no pot invocar {metode}")

        # Deixem els claims accessibles al mètode a través d'una metadada interna.
        # (grpc-python no ofereix un 'context de petició' compartit; el conveni
        # de km0 és que el servicer els rellegeix amb 'claims_de(context)'.)
        _CLAIMS_ACTUALS.set(claims)
        return continuation(handler_call_details)

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

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

Fixa't en la distinció de codis: UNAUTHENTICATED (no sé qui ets: equivalent a 401) davant de PERMISSION_DENIED (sé qui ets i no pots: 403). Els clients reaccionen de manera diferent a cadascun: el primer dispara un refresh del token; el segon, no.

Al servidor de 02-03 només canvia l'arrencada:

# km0/serveis/inventari/servidor.py (fragment)
servidor = grpc.server(futures.ThreadPoolExecutor(max_workers=10),
                       interceptors=[AuthInterceptor()])

Decorador requereix_rol per a la resta de serveis

Per als serveis HTTP (l'API de comandes, cataleg amb FastAPI o Flask), l'equivalent de l'interceptor és un decorador:

# km0/serveis/comu/auth.py (continuació)
from functools import wraps
from flask import request, g, abort

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

# Ús a serveis/comandes/api.py:
# @app.get("/comandes/<comanda_id>")
# @requereix_rol("client", "operador", audiencia="comandes")
# def veure_comanda(comanda_id):
#     comanda = repositori.carregar(comanda_id)
#     if "operador" not in g.claims["roles"] and comanda.client_id != g.claims["sub"]:
#         abort(403)          # ABAC: només el propietari de la comanda (o un operador) la veu
#     return comanda.a_json()

Aquell abort(403) de les últimes línies és la comprovació que faltava a l'apartat 1: sense ella, el Marc llegiria la P-2026-000123 de l'Anna canviant l'URL.

La regla ABAC: "la Formatgeria Montblanc només modifica els seus productes"

A inventari, el mètode AjustarEstoc ja ha passat el filtre RBAC (rol productor o operador). La regla fina s'aplica a dins, on hi ha l'atribut del recurs:

# km0/serveis/inventari/servidor.py (fragment del servicer)
from serveis.inventari.auth_interceptor import claims_de

def AjustarEstoc(self, request, context):
    claims = claims_de(context)
    producte = self._repo.carregar(request.producte)          # p. ex. 'formatge-curat'
    if producte is None:
        context.abort(grpc.StatusCode.NOT_FOUND, "producte desconegut")

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

    nou = self._repo.ajustar(request.producte, request.mercat, request.delta)
    return inventari_pb2.AjustarEstocResponse(estoc=nou)

Prova des del client:

# Client de prova (host): dos tokens, dos resultats
from serveis.identitat.tokens import emetre_acces
import grpc, inventari_pb2, inventari_pb2_grpc

canal = grpc.insecure_channel("localhost:50051")     # TLS arriba a 06-02, mTLS a 06-04
stub = inventari_pb2_grpc.InventariStub(canal)

t_formatgeria = emetre_acces("prod-formatgeria-montblanc", ["productor"], ["inventari"],
                             productor_id="formatgeria-montblanc")
t_celler = emetre_acces("prod-celler-roure-alt", ["productor"], ["inventari"],
                        productor_id="celler-roure-alt")

def ajustar(token, producte, delta):
    try:
        r = stub.AjustarEstoc(inventari_pb2.AjustarEstocRequest(
                producte=producte, mercat="girona", delta=delta),
            metadata=(("authorization", f"Bearer {token}"),), timeout=2)
        print("OK  ", producte, "->", r.estoc)
    except grpc.RpcError as e:
        print("FAIL", producte, e.code().name, e.details())

ajustar(t_formatgeria, "formatge-curat", +20)   # OK   formatge-curat -> 45
ajustar(t_celler,      "formatge-curat", -45)   # FAIL formatge-curat PERMISSION_DENIED prod-celler-roure-alt no es propietari...
ajustar("brossa",      "formatge-curat", +1)    # FAIL formatge-curat UNAUTHENTICATED token invalid: Not enough segments

Amb això, inventari ha deixat de ser un servei al qual qualsevol podia buidar l'estoc (la fal·làcia 4 de 01-04). Encara no sap si qui li parla és de debò comandes (això és identitat de servei, 06-04) i encara parla en clar per la xarxa (06-02), però ja no executa res sense saber en nom de qui.

Errors Comuns i Consells

  • Confondre 401 i 403. 401/UNAUTHENTICATED és "no sé qui ets" i ha de portar el client a autenticar-se (o a refrescar). 403/PERMISSION_DENIED és "sé qui ets i no pots". Barrejar-los trenca la lògica de reintent dels clients i confon el suport.
  • Desar contrasenyes amb SHA-256 "perquè és segur". SHA-256 és segur com a hash criptogràfic i pèssim com a hash de contrasenyes: és ràpid. Argon2id o bcrypt, sempre.
  • Contrasenyes xifrades "per si l'usuari les oblida". Si li pots enviar la contrasenya per correu, la tens en clar o gairebé. El flux correcte és restablir, mai recuperar.
  • Rols al token i rols a la base de dades que es contradiuen. El token és una foto: si retires un rol, triga fins a exp a desaparèixer. Dissenya amb aquesta latència (tokens curts) o revoca per jti.
  • aud com a cadena única en un token que travessa diversos serveis: cadascun rebutjarà el token perquè no és la seva audiència. aud admet una llista, i el token on-behalf-of els ha d'anomenar tots.
  • Emetre tokens des de diversos llocs. Una única autoritat (identitat) signa; tota la resta verifica. Si un servei necessita "actuar com" algú, demana un token, no el fabrica.
  • Confiar en un X-User-Id que arriba en una capçalera. Sense signatura, qualsevol l'escriu. Només la identitat que ve en un token verificat (o en un certificat, 06-04) és fiable.
  • Autoritzar només a la vora. El gateway de 06-05 serà una primera barrera còmoda, però cada servei ha de continuar verificant: la crida que arriba d'un altre servei intern no ha passat per la vora.
  • Consell: centralitza la verificació en una llibreria (serveis/comu/auth.py) amb tests que incloguin els atacs de l'apartat 7 (alg: none, HS256 amb la clau pública, aud equivocat, exp passat). Un test per vulnerabilitat coneguda val més que qualsevol revisió manual.
  • Consell: registra cada decisió de denegació amb sub, jti, mètode i motiu; a 06-05 aquests registres seran la base de l'auditoria i de la detecció d'anomalies.
  • Advertència de compliment normatiu. El tractament de credencials i d'identificadors de clients està subjecte al RGPD i, si hi ha pagaments, a PCI DSS. El que es descriu aquí és disseny tècnic; la implantació real l'ha de revisar un professional de seguretat i de compliment normatiu.

Exercicis

Exercici 1: Un token robat

Un atacant aconsegueix, mitjançant un script injectat a la web, el token d'accés de l'Anna a les 10:00:00 (exp = 10:15:00). Enumera què pot fer-hi contra comandes i inventari segons el disseny d'aquesta lliçó, fins quan, i quines tres mesures dels apartats 6 i 7 redueixen o eliminen el dany. Després, indica quin mecanisme permetria a un operador tallar-lo a les 10:04 tan bon punt l'Anna avisi, i quin cost té aquest mecanisme a cada petició.

Exercici 2: RBAC i ABAC per a repartiment

Els 140 repartidors de furgoneta-3 fan servir l'app, que crida el servei repartiment amb els mètodes PublicarPosicio(repartidor_id, lat, lon), LlistarLliuraments(repartidor_id) i MarcarLliurada(comanda_id). Un operador pot veure qualsevol posició i qualsevol lliurament. Defineix (a) la taula ROLS_PER_METODE de l'interceptor de repartiment, (b) quin claim privat necessita el token d'un repartidor, (c) la comprovació ABAC de cada mètode en pseudocodi, i (d) què passa si un repartidor crida MarcarLliurada("P-2026-000124") i aquesta comanda està assignada a un altre repartidor.

Exercici 3: HS256 en un sol servei

Un company proposa: "Per a cataleg, que només el crida la web, fem servir HS256 amb un secret a la seva configuració; és més simple i ràpid que RS256". Argumenta en quins casos tindria raó i per què a Quilòmetre Zero, tot i així, es descarta. Inclou a la resposta què passaria el dia que analitica volgués cridar cataleg.

Solucions

Exercici 1.

Amb el token de l'Anna (sub: u-anna, roles: [client], aud: [comandes, inventari]) l'atacant pot fer tot el que pot fer l'Anna: llistar i llegir les seves comandes, crear comandes en nom seu (fins al pas de pagament, que en un disseny correcte exigeix la passarel·la), consultar estoc i reservar estoc en nom seu. No pot editar productes ni veure comandes del Marc: el RBAC i l'ABAC continuen aplicant-se al token, que només porta el rol client i el sub de l'Anna. Ho pot fer fins a les 10:15:00 (més els 30 s de leeway), i no el pot renovar si el refresh token és en una galeta HttpOnly que l'script no pot llegir. Mesures: (1) caducitat curta, que és el que limita el dany a 15 minuts; (2) no tenir el token a localStorage (mantenir-lo a la memòria de la SPA i el refresh en una galeta HttpOnly) redueix molt la probabilitat del robatori i elimina la renovació; (3) rotació de refresh tokens, perquè si d'alguna manera obtingués el refresh, l'ús que en fessin l'atacant i l'Anna generi una detecció i invalidi la família. Per tallar a les 10:04: llista negra per jti a Redis (SADD jwt:revocats <jti> amb TTL fins a exp) consultada per comandes i inventari abans d'acceptar un token; el cost és un SISMEMBER (un viatge a Redis, submil·lisegon) per petició, i una dependència operativa de Redis per a l'autenticació, que és justament el que els tokens sense estat volien evitar; per això s'activa només per als serveis sensibles o es limita a una memòria cau local del conjunt de revocats refrescada cada pocs segons.

Exercici 2.

(a) PublicarPosicio: {repartidor}; LlistarLliuraments: {repartidor, operador}; MarcarLliurada: {repartidor, operador}. Un operador no publica posicions (no condueix). (b) repartidor_id (per exemple furgoneta-3-017), emès per identitat quan l'app fa login. (c) PublicarPosicio: request.repartidor_id == claims.repartidor_id, si no PERMISSION_DENIED (encara millor: ignorar l'argument i fer servir sempre el del token, eliminant la possibilitat de suplantar). LlistarLliuraments: si operador és als rols, qualsevol id; si no, request.repartidor_id == claims.repartidor_id. MarcarLliurada: carregar la comanda; si operador, permetre; si no, comanda.repartidor_assignat == claims.repartidor_id. (d) L'interceptor deixa passar la crida (el rol repartidor pot invocar el mètode); el servicer carrega P-2026-000124, veu que està assignada a un altre repartidor i avorta amb PERMISSION_DENIED i un missatge que no reveli a qui està assignada; la denegació es registra amb sub, jti i comanda per a l'auditoria.

Exercici 3.

Tindria raó si cataleg fos l'únic verificador i l'únic emissor: una aplicació aïllada que emet i verifica els seus propis tokens amb un secret de 256 bits aleatoris és correcta amb HS256, i la verificació HMAC és més ràpida que RSA. Es descarta perquè a Quilòmetre Zero l'emissor és identitat, no cataleg: per verificar amb HS256, cataleg hauria de conèixer el secret d'identitat, i amb ell podria emetre tokens d'operador vàlids per a comandes, inventari i pagaments; el radi de dany de comprometre cataleg (el servei més exposat, el que serveix fotos i llistats a Internet) passaria a ser tota la plataforma. Tampoc no hi ha cap guany real de simplicitat: la llibreria comuna ja verifica RS256 i la clau pública es distribueix sense precaucions. El dia que analitica volgués cridar cataleg amb HS256, caldria donar-li també el secret (un servei més amb capacitat d'emetre), o mantenir dos secrets i dues rutes de verificació; amb RS256, n'hi ha prou que el token d'analitica inclogui cataleg a aud, i cataleg no canvia res.

Conclusió

Autenticar és establir qui és el subjecte; autoritzar és decidir, a cada operació, si pot fer el que demana. En un sistema distribuït la identitat ha de viatjar: una sessió en memòria mor amb la segona instància, una sessió a Redis resol la web però no es propaga als serveis, i un token signat (JWT) porta la identitat a sobre i la fa verificable per qualsevol que tingui la clau pública. Per això la signatura ha de ser asimètrica (RS256/ES256): una única autoritat emet, tothom verifica, i cap verificador no pot fabricar. Un token és una foto amb caducitat: tokens d'accés de minuts, refresh tokens amb estat i rotació, llista negra per jti per a les urgències, i verificació estricta d'alg, aud, iss i exp per no repetir els errors coneguts. Les contrasenyes, quan n'hi ha, es desen amb Argon2id o bcrypt, amb sal i cost, mai de manera reversible. Sobre la identitat verificada es construeix l'autorització: RBAC per a permisos per tipus (client, productor, repartidor, operador), ABAC per a la propietat ("els seus productes", "les seves comandes"), ReBAC quan les relacions es compliquen, amb mínim privilegi i denegació per defecte, aplicada de manera gruixuda a la vora i a cada servei i de manera fina on viuen les dades, amb OPA com a camí d'externalització si les regles creixen. Quilòmetre Zero té ara serveis/identitat/tokens.py, una llibreria serveis/comu/auth.py, un AuthInterceptor a inventari i la primera regla ABAC: la Formatgeria Montblanc només toca els seus formatges.

Però el token de l'Anna encara viatja en clar entre comandes i inventari, igual que el seu telèfon, la seva adreça i els esdeveniments de comandes.esdeveniments que qualsevol amb accés a la xarxa interna podria llegir o alterar. Saber qui és qui no serveix de gaire si la conversa es pot escoltar. La següent lliçó tracta el xifratge i la protecció de dades: en trànsit, amb TLS a gRPC i a cada magatzem; en repòs, amb xifratge de camps i de volums; i en la seva dimensió personal, amb pseudonimització i el dret a l'oblit.

Curs d'Arquitectures Distribuïdes

Mòdul 1: Introducció als Sistemes Distribuïts

Mòdul 2: Comunicació en Sistemes Distribuïts

Mòdul 3: Consistència i Replicació

Mòdul 4: Emmagatzematge Distribuït

Mòdul 5: Computació Distribuïda

Mòdul 6: Seguretat en Sistemes Distribuïts

Mòdul 7: Monitoratge i Manteniment

Mòdul 8: Casos d'Estudi i Aplicacions

© Copyright 2026. Tots els drets reservats