Tenere le permission effettive nella sessione è un'ottimizzazione ragionevole. Ricalcolare role, grant e partizioni a ogni request costa caro, e quasi sempre il risultato è lo stesso. Il problema è quel “quasi”. Una sessione materializzata è una cache, e una cache senza strategia di invalidazione è un privilegio appiccicoso che aspetta solo di manifestarsi.
Il payload di solito contiene qualcosa del genere:
user_id
roles
permissions
org_units
permission_version
authz_catalog_version
permissions_valid_untilGli ultimi tre campi esistono perché la sessione può diventare obsoleta per tre motivi diversi, e ognuno ha bisogno di un meccanismo proprio.
1. L'utente è cambiato
Ha ottenuto o perso un role, ha cambiato unità, ha ricevuto o perso un grant diretto. Basta un contatore per utente: ogni mutazione di autorizzazione incrementa permission_version nella stessa transazione della modifica. La sessione conserva la versione con cui è stata risolta, e la differenza tra le due smaschera la cache vecchia.
2. Il catalogo è cambiato
È il caso che di solito passa inosservato. Qualcuno aggiunge una permission a un role, o la rimuove, o cambia la semantica di un role jolly. Nessun utente ha subito una mutazione diretta, quindi nessun permission_version è cambiato, e tutte le sessioni aperte restano con l'insieme vecchio. Un authz_catalog_version globale, incrementato a ogni modifica del catalogo, copre esattamente questo buco.
3. L'orologio è andato avanti
Un grant temporaneo scade alle 22:00. Il passaggio dell'orologio dalle 21:59 alle 22:00 non modifica nessuna riga nel database. Se la sessione viene invalidata solo quando cambia qualche versione, il privilegio scaduto resta attivo finché qualcuno non tocca l'utente.
La tentazione è risolvere con un job che gira ogni cinque minuti e revoca ciò che è scaduto. Funziona finché il job non ritarda, fallisce o viene spento durante una manutenzione, e a quel punto la finestra di privilegio scaduto diventa l'intervallo tra un'esecuzione e l'altra, o infinita. La sessione deve portarsi dietro il prossimo istante in cui l'insieme materializzato smette di essere affidabile:
permissions_valid_until = min(expires_at dei grant diretti attivi)La scadenza deve valere anche se nessun job gira.
Il predicato di ri-risoluzione
Con i tre campi, la decisione di ricalcolare è un confronto economico, fatto a ogni request:
needs_reresolve(session, db) :=
session.permission_version != db.user_permission_version
OR session.authz_catalog_version != db.catalog_version
OR now() >= session.permissions_valid_untilQuando è vero, il server ricalcola role, permission, partizioni e la prossima scadenza, aggiorna la sessione e prosegue con la request. Nessuno deve essere disconnesso solo perché l'autorizzazione è cambiata. Le due versioni stanno in un lookup indicizzato, o in una cache invalidata da eventi, e l'orologio è gratis.
Invalidare l'autorizzazione non è uccidere la sessione
Sono problemi simili con soluzioni diverse. Disattivare un utente, resettare una credenziale o rispondere a un incidente richiede di abbattere le sessioni attive, non solo di ricalcolare cosa possono fare. Per questo un indice delle sessioni per utente, in Redis o equivalente, resta utile. Il versionamento risponde a “cosa può fare questa sessione adesso”; il session kill risponde a “questa sessione non dovrebbe esistere”.
Il bootstrap del bypass
Una permission che attraversa la RLS crea un problema dell'uovo e della gallina. Per sapere se la connessione può ricevere il bypass di tenant, bisogna leggere le permission dell'utente prima di aprire lo scope del tenant. Quindi il minimo necessario per risolvere quest'autorità viene letto in uno scope di sistema ristretto e controllato, e solo dopo l'applicazione decide se la connessione riceve un contesto normale o un bypass esplicito.
Contesto di connessione e pool
Quando la RLS legge il tenant da una variabile di sessione di Postgres, il ciclo di vita di quella variabile fa parte della sicurezza. Con il connection pooling, un SET normale sopravvive alla restituzione della connessione, e la request successiva, di un altro tenant, eredita il contesto precedente. Usa una configurazione locale alla transazione:
SET LOCAL app.tenant_id = '...';
SET LOCAL app.is_platform_bypass = 'false';
-- oppure, dall'interno di una funzione:
SELECT set_config('app.tenant_id', '...', true);E testalo nel modo in cui si rompe: un test di integrazione che riusa apposta la stessa connessione tra due tenant e dimostra che tenant e bypass sono stati resettati. È il tipo di bug che nessun test unitario becca e che compare in produzione solo sotto carico.