Quasi ogni sistema di autorizzazione nasce con una sola domanda: qual è il ruolo dell'utente? E funziona, finché anche il dominio è semplice. Il problema compare quando la stessa decisione comincia a dipendere da cose di natura diversa e qualcuno prova a risolvere tutto allungando la stessa gerarchia di ruoli.
Questo testo è un estratto di un'architettura che ho progettato per l'autorizzazione delle persone in sistemi multi-tenant. Non è Zanzibar per intero, non è un motore di policy generico e non butta tutta la regola dentro il database. È una scelta deliberata: prendere da ogni tradizione di controllo degli accessi l'astrazione che risolve bene un problema specifico, e solo quella.
Quando un role non basta più
Di solito si comincia così:
viewer < editor < admin < superadmin
if user.role.rank >= required_rank:
allow()Sembra economico, ma mescola concetti. Un amministratore può aver bisogno di registrare persone senza leggere contenuti sensibili. Un analista può eseguire un'operazione, ma solo sui dati della propria unità. Qualcuno di un'altra area può ricevere un report specifico senza diventare parte di quell'area. E un operatore di piattaforma può aver bisogno di attraversare i tenant in un'emergenza senza che il nome del suo role diventi una password master in ogni handler.
Non mancano livelli nella gerarchia. È la gerarchia a non essere l'astrazione giusta per tutte queste domande. Quando si insiste, compaiono i soliti sintomi: rank magici, eccezioni ad hoc, permission per reparto, if admin sparsi per il codice, condivisioni che danno più potere del dovuto e un isolamento tra clienti che dipende dalla disciplina di ogni endpoint.
Le quattro domande
Una decisione di autorizzazione matura si scompone in quattro dimensioni indipendenti:
| Domanda | Meccanismo | Ruolo |
|---|---|---|
| Che cosa può fare il soggetto? | RBAC con permission | Capacità |
| Di quali unità fa parte? | Membership organizzativa | Partizione |
| Questo oggetto è stato condiviso con lui? | Relazione tipizzata, alla ReBAC | Eccezione puntuale |
| Questa riga appartiene al tenant? | Row Level Security | Isolamento |
Tutte e quattro possono dare la stessa risposta finale, consentire o negare, ma non hanno la stessa semantica. Schiacciarle tutte in una colonna role o in una tabella generica di grant è la strada più breve verso una policy che nessuno riesce a spiegare né a testare.
Una domanda di autorizzazione, un meccanismo principale.
C'è un modo semplice per capire che gli assi devono restare separati: guardare come cambia ciascuno. La capacità cambia quando cambia la mansione della persona. La partizione cambia quando cambia l'organigramma. La condivisione cambia per un'azione puntuale di collaborazione. Il tenant non cambia quasi mai durante la vita di un'identità. Sono eventi con frequenza, responsabile, traccia e revoca diversi, e mettere tutto nello stesso record costringe il sistema a trattare come uguali cose che non lo sono.
Capacità: RBAC senza rank magici
Il nucleo classico dell'RBAC resta: gli utenti ricevono role, i role aggregano permission e l'autorizzazione consulta l'insieme effettivo. Quello che rifiuto è il role come numero. Un role è un insieme dichiarato. Se uno contiene l'altro, è perché è stato dichiarato così, non perché uno ha preso il 3 e l'altro il 2.
VIEWER = { profile.manage, content.view }
ANALYST = VIEWER ∪ {
analysis.run, data.query,
content.manage, content.share
}
TENANT_ADMIN = ANALYST ∪ {
users.read, users.write,
authz.roles.read, authz.roles.assign_tenant
}Così il potere diventa ispezionabile. Si può cambiare la composizione di un role senza ricostruire la semantica dell'intera gerarchia, e l'insieme effettivo diventa una semplice unione:
effective(u) =
permissions(base_role)
∪ permissions(roles di u)
∪ grant diretti validi(u)La policy è monotona: esiste solo la concessione, non esiste un deny esplicito. È una scelta, non una dimenticanza. Il deny cambia la precedenza e l'analisi di tutto, e merita di essere trattato come un'evoluzione dell'architettura, non come una colonna in più.
Una permission è una capacità atomica, nel formato dominio.risorsa.azione. Prima di entrare nel catalogo, passa per tre domande:
- Oggi esiste un punto reale di enforcement per questa permission?
- Esiste un role che ce l'ha e un altro che non ce l'ha?
- Backend e frontend useranno esattamente lo stesso codice?
Se la prima risposta è no, la permission è documentazione senza effetto. Se la seconda è no, la granularità è teatrale. Se la terza è no, interfaccia e API divergeranno. Il sintomo più comune di un catalogo disonesto è una permission per ogni passaggio di schermata, workflow.upload, workflow.preview, workflow.map, che nessun role distingue. Questo non esprime potere, copia solo la struttura della UI.
E non tutte le permission possono essere concesse dentro un tenant. Cose come platform.tenant.bypass e authz.roles.assign_platform vivono dall'altra parte di un confine, e la CI verifica che nessun role di tenant ne contenga una. Questa proprietà conta più del nome di qualsiasi role.
Partizione: l'organigramma non è un catalogo
Se un'azienda ha decine di unità, codificarle nelle permission porta a content.view.finance, content.view.hr, content.view.branch_001. L'organigramma diventa codice, e ogni riorganizzazione diventa un deploy. La permission smette di rappresentare una capacità e comincia a rappresentare un'istanza di dato.
La partizione è membership, e la membership è un dato:
org_unit(id, tenant_id, name)
user_org_unit(user_id, org_unit_id)
resource.org_unit_idUna decisione che sembra piccola e non lo è: la risorsa appartiene all'unità, non alla persona che l'ha creata. Se l'autore cambia area, può perdere l'accesso a ciò che ha prodotto. Suona controintuitivo, ma preserva il principio che l'asset è dell'organizzazione.
E attenzione al legacy. Un sistema in migrazione ha sempre dati vecchi senza unità. Trattare org_unit_id IS NULL come “legacy” per sempre è pericoloso, perché ogni oggetto nuovo che nasce senza unità eredita la stessa eccezione storica. Il legacy va marcato come legacy, in una colonna apposita, e un oggetto nuovo non può nascere con quel marchio.
Relazione: condividere è un'eccezione, non membership
Ricevere un report di un'altra area non rende nessuno membro di quell'area. È il caso perfetto per una relazione tipizzata, ed è la parte di Zanzibar che uso davvero. Non mi servono namespace globali, zookie né grafi distribuiti. L'idea utile è più piccola:
Un'eccezione di accesso su un oggetto concreto deve essere una relazione esplicita, revocabile e auditabile.
resource_share(
resource_id,
grantee_user_id,
granted_by_user_id,
granted_at
)Tre regole accompagnano la relazione. Lo share è intra-tenant per default: se condividere tra clienti è un requisito, merita un modello separato, e non può emergere come effetto collaterale di una tabella senza constraint. Chi riceve non acquisisce il potere di ricondividere, perché ricevere viewer non è autorità di concessione. E condividere richiede capacità e autorità sull'oggetto, espresse in permission e predicato, mai in if user.role == "admin".
can_share(u, r, grantee) :=
has(u, content.share)
AND same_tenant(u, r, grantee)
AND can_view_resource(u, r)
AND ( created_by(r) = u
OR has(u, content.view_all_partitions) )Aggregato non è microdato
È la distinzione più facile da perdere. Un report e il dataset da cui è nato sono oggetti di autorizzazione diversi, e ognuno ha il suo predicato:
can_view_resource(u, r) :=
same_tenant(u, r)
AND ( in_same_partition(u, r)
OR shared_with(u, r)
OR has_all_partitions_view(u)
OR is_legacy_unpartitioned(r) )
can_access_data_context(u, c) :=
same_tenant(u, c)
AND ( in_same_partition(u, c)
OR is_legacy_unpartitioned(c) )L'assenza di shared_with nel secondo predicato è voluta. Condividere la presentazione non può diventare accesso al dato grezzo. Per lo stesso motivo, content.view_all_partitions significa vedere gli aggregati di tutte le unità, non interrogare i microdati di tutte. Se il business ha bisogno di entrambe le cose, sono due capacità. Il nome di una permission deve riflettere il suo raggio reale.
E partizionare solo il report è una barriera cosmetica. Se la sorgente non ha un'autorizzazione propria, basta che qualcuno scopra l'identificatore del contesto e chiami direttamente l'endpoint.
Isolamento: il database garantisce la base
La Row Level Security qui ha una responsabilità ristretta: impedire l'accesso cross-tenant. La policy è abbastanza semplice da stare in due righe e da poter essere verificata:
tenant_id = current_tenant()
OR current_platform_bypass() = trueIl database non sa chi è viewer, analyst o titolare di uno share. Capacità, partizione e relazione restano nell'applicazione. Duplicare tutto nella RLS e nel codice crea una doppia manutenzione, e una correzione fatta da una parte non arriva all'altra. Se un giorno la partizione andrà nel database, che sia generata a partire dalla stessa specifica.
Il database garantisce la base. L'applicazione garantisce la policy.
Solo che la RLS di tenant impedisce al tenant A di vedere il tenant B; non impedisce al reparto A di vedere il reparto B. Una volta che esistono partizioni, un repository che fa get_by_id(id) e si fida della RLS smette di essere sicuro. La risposta qui è esigere un AccessContext in ogni lettura di contenuto, rendere i repository sensibili privati al modulo di autorizzazione e far fallire la CI quando un router ne importa uno direttamente. La review delle PR aiuta, ma non può essere l'unica barriera.
Chi può dare potere a chi
La regola “concede solo chi possiede” sembra sicura e non lo è. Se un admin ha tutte le permission di admin, il subset check gli permette di creare un altro admin. Allo stesso tempo, obbliga chi ha solo bisogno di concedere una capacità a possederla. L'autorità di concessione va modellata direttamente, in una matrice esplicita:
role_grantable(grantor_role, target_role)
tenant_admin -> viewer, analyst
platform_admin -> tenant_admin, viewer, analystUsare una capacità e assegnarla a un'altra persona sono operazioni diverse. Lo sono anche registrare un'identità e concedere autorità, quindi sono endpoint diversi. Ne ho scritto di più in Chi può dare potere a chi.
Sessione con data di scadenza
Quasi ogni sistema tiene l'insieme effettivo nella sessione per non ricalcolare tutto a ogni request. Va bene, purché la cache sappia quando è invecchiata. E invecchia in tre modi: quando cambia l'utente, quando cambia il catalogo e quando l'orologio supera la scadenza di un grant temporaneo. Ognuno ha bisogno di un meccanismo proprio, e l'ultimo non può dipendere da un cron. I dettagli sono in I permessi in cache invecchiano in tre modi.
Il frontend rispecchia, il backend decide
Il browser è fuori dal perimetro di fiducia. Nascondere un pulsante non è autorizzazione, e un claim di role che arriva dal client non è una fonte di verità. La permission ha lo stesso codice ai due capi, require_permission("analysis.run") sul server e hasPermission("analysis.run") nell'interfaccia, e decide solo il server.
| Frontend | Backend | Risultato |
|---|---|---|
| nasconde | blocca | corretto |
| nasconde | osserva soltanto | rollout, non enforcement |
| mostra | blocca | UX scadente |
| mostra | consente senza gate | vulnerabilità |
In una migrazione conviene far girare l'autorizzazione in shadow mode: il predicato viene valutato e la decisione registrata, ma il diniego non blocca ancora. Così si misura l'impatto reale prima del cutover. Solo che lo shadow mode non è sicurezza, e restarci all'infinito è un debito misurabile. Ogni rotta autenticata dichiara una permission o viene classificata esplicitamente come senza gate, e la CI confronta l'inventario delle rotte con il catalogo.
Cosa ho lasciato fuori di proposito
- Tutto in ReBAC. Si possono modellare membership ed ereditarietà come relazioni ma, per partizioni stabili, il costo di grafo, consistenza e tooling non si ripaga. Il rifiuto è pragmatico, non teorico.
- Un motore ABAC generico. Uso attributi come
tenant_id,org_unit_idedexpires_at, ma in predicati chiusi e testabili. Un linguaggio di policy arbitrario è difficile da revisionare e da spiegare. - Tutto nella RLS. L'enforcement vicino al dato è ottimo per la base; per le regole di prodotto diventa versionamento difficile e duplicazione.
- Share come nuovo role. Trasforma un'eccezione su un oggetto in un potere globale.
Per chiudere
Un'autorizzazione matura non è una sequenza di if role == ..., ma non deve nemmeno essere un grafo globale distribuito. Nella maggior parte dei sistemi applicativi, il problema sta in quattro domande: che cosa può fare la persona, a quale unità appartiene il dato, se esiste un'eccezione su quell'oggetto e se la riga è del tenant giusto. RBAC, membership, relazione tipizzata e RLS, ognuno al suo posto.
L'effetto collaterale migliore è la disciplina nell'evoluzione. Una nuova unità non richiede una nuova permission. Un nuovo share non cambia il role di nessuno. Una nuova capacità non tocca il tenant. Ogni asse evolve al suo ritmo.
La capacità non è partizione, la partizione non è condivisione, la condivisione non è tenant, l'aggregato non è microdato e l'interfaccia non è enforcement.