- 2026-09-10
- 8 minuti
OpenFGA in produzione: 6 pattern per un'integrazione sicura e scalabile

Se hai un IdP RBAC in produzione e stai pensando di affiancarci OpenFGA per i permessi per-risorsa, sai già che la soluzione “facile” è farli convivere. È anche la più rischiosa.
La forma in cui te ne accorgi è quasi sempre la stessa: un controller con un check che mette in OR il ruolo dell’IdP e una chiamata a OpenFGA.
if (user.Role == PowerUser || await fga.Check(user, "can_edit", "course:42"))
Funziona. Ma quale dei due è il ground truth? Se l’utente ha il ruolo ma non la tupla, passa. Se ha la tupla ma non il ruolo, passa lo stesso. Il codice esegue, ma nessuno sa più esattamente perché. E questo è peggio di un bug: almeno un bug ti dice che qualcosa non va.
Qui entra in gioco una scelta architetturale che molti rimandano: chi è la fonte di verità per ogni decisione? In questo articolo vediamo i sei pattern che ti tengono fuori da quel controller ambiguo: come dare a OpenFGA un ruolo chiaro, come modellare i permessi senza replicare RBAC, come evitare la classe di bug silenziosi tipica dei sistemi di autorizzazione. Per DSL e Check API, l’articolo introduttivo è un buon punto di partenza per acquisire le prime nozioni.
1. Una sola fonte di verità per ogni decisione
Il problema dell’OR tra ruolo IdP e check FGA è prima di tutto concettuale: hai due sistemi che rispondono alla stessa domanda con logiche diverse, e il codice diventa l’arbitro implicito. Le conseguenze le vivi tutti i giorni. Devi capire endpoint per endpoint quale sistema si applica, senza nessun aiuto dal codice. Dare accesso a un nuovo utente significa toccare entrambi i sistemi, con due possibilità di disallineamento. Revocare un permesso? Devi ricordarti di togliere sia il ruolo sia la tupla. Frustrante, vero?
La regola è una: per ogni decisione di autorizzazione, un solo sistema autoritativo. Quando migri un gate a OpenFGA, il ruolo dell’IdP perde autorità su quella decisione. Niente fallback, niente OR “per sicurezza”. Se tieni l’OR, non sei mai uscito dalla coesistenza ambigua: hai solo aggiunto un layer.
Il ruolo dell’IdP non sparisce, cambia funzione. Diventa metadata per l’UI (che menu mostrare, quali sezioni rendere visibili), ma non alimenta più decisioni di accesso. Tutte le decisioni passano da OpenFGA.
💡 La domanda da farti a ogni endpoint: “se cambio policy, quanti posti devo toccare?” Se la risposta è più di uno, non hai una single source of truth.
2. Capability, non ruoli globali
La trappola in cui cadi quasi sempre è mappare i ruoli RBAC uno a uno: PowerUser diventa power_user in FGA, Editor diventa editor, e vai in produzione. Il modello FGA è cambiato, il problema no: hai spostato i ruoli da un sistema all’altro senza ridisegnarli.
Il modello che sfrutta davvero ReBAC è quello a capability: permessi atomici su singole operazioni, assegnati a un oggetto singleton che rappresenta la piattaforma.
type system
relations
define root: [user]
define creator: [user]
define user_manager: [user]
define can_insert: creator or root
define can_manage_users: user_manager or root
Un utente può avere user_manager senza essere admin globale. root resta come chiave universale per pochissimi subject (operatori di piattaforma, automazioni privilegiate). Aggiungere una nuova capability è una riga di DSL, non un meeting per decidere se serve un ruolo nuovo.
La differenza pratica? Con i ruoli “tutto o niente”, concedere il permesso di gestire utenti significa promuovere qualcuno ad admin con tutti gli effetti collaterali del caso. Con le capability, lo stesso utente ottiene esattamente quel permesso, e nient’altro.
⚠️
rootnon va mai assegnato a utenti applicativi: è un bypass implicito di tutte le capability granulari e rende inutile il modello.
Apri il Playground con questo DSL: root, creator e user_manager sono assegnabili direttamente, mentre can_insert e can_manage_users sono computate. La distinzione fa la differenza nel codice. Provi a scrivere la tupla (user:bob, can_manage_users, system:main) invece di (user:bob, user_manager, system:main)? OpenFGA rifiuta la Write API con un validation_error: le relazioni computate non hanno type restrictions e non sono assegnabili. È esattamente il comportamento che vuoi — l’errore arriva subito, non si scopre mesi dopo da un Check che ritorna Denied senza apparente motivo.
3. La gerarchia è una proprietà del modello, non del codice
Sintomo classico di RBAC mal scalato: per dare a un utente accesso a un corso, il tuo codice deve aggiornare separatamente i permessi su tutte le risorse derivate (lezioni, materiali, valutazioni). Ogni cambio di policy ti obbliga a ritrovare tutti i posti in cui quella logica è stata duplicata.
In OpenFGA la propagazione si esprime nel modello, non nel codice:
type lesson
relations
define parent: [course]
define editor: [user]
define can_edit: editor or editor from parent
Con due tuple, l’editor di un corso ottiene can_edit su tutte le sue lezioni senza nessuna tupla diretta sulle lezioni:
user:alice editor course:c1
course:c1 parent lesson:l1
Quando il backend chiede Check(user:alice, can_edit, lesson:l1), OpenFGA traversa il grafo, segue parent fino a course:c1, trova l’assegnazione diretta, risponde allowed: true. Il tuo codice applicativo fa una chiamata HTTP e ottiene un sì o un no. Basta.
Il vantaggio vero sta nel fatto che la regola “chi gestisce il corso gestisce le sue lezioni” vive in un solo posto, il modello, invece di essere disseminata negli endpoint. La gerarchia diventa struttura del grafo, non logica applicativa.
💡 Quando aggiungi un nuovo livello di gerarchia (es. moduli dentro i corsi), il codice non cambia: estendi la relazione
parentnel modello e basta.
4. Migrazione gate-by-gate, mai big bang
Migrare tutto in una PR è la scelta che sembra più efficiente, ed è quella che blocca il sistema. Il big bang richiede che modello FGA, codice applicativo e provisioning delle tuple siano tutti pronti e corretti allo stesso istante. Basta un endpoint dimenticato o un set di tuple incompleto per rompere qualcosa che prima funzionava. Ti sembra rischioso? Lo è.
L’approccio sostenibile è migrare un gate alla volta. Un buon candidato per partire soddisfa tre criteri: tocca un solo tipo di risorsa, è già gestito da un solo sistema (non dall’OR), ha basso volume di traffico. Le operazioni amministrative rare sono spesso il punto di ingresso giusto. Il cuore del dominio viene per ultimo, quando il modello è stato validato sui casi più semplici.
Il processo per ogni gate è sempre lo stesso: sostituisci il check esistente con la chiamata FGA, verifica in staging che il comportamento sia identico, poi rimuovi il riferimento al ruolo IdP da quel check. Non commentarlo “per sicurezza”: rimuovilo. Se resta nel codice, prima o poi tornerà a essere usato.
💡 Tieni una mappa degli endpoint con il loro stato (
legacy,migrating,fga). Durante la transizione è l’unico modo per rispondere alla domanda “quale sistema decide su questo endpoint?” senza rileggere il codice.
5. Validazione del modello al boot
La classe di bug più insidiosa in OpenFGA è quella silenziosa: il tuo codice chiama Check su una (tipo, relazione) non dichiarata nel modello, OpenFGA ritorna errore, il wrapper fail-closed lo trasforma in false, e l’endpoint smette di funzionare per tutti senza un log utile. Dal punto di vista degli utenti l’operazione “non è permessa”, anche se in realtà il problema è che il modello e il codice si sono disallineati.
Come ti difendi? Validazione al boot. All’avvio dell’applicazione, enumera ogni (tipo, relazione) interrogata dal codice e verifica che esista nel modello deployato. Se manca, l’applicazione non parte.
var requiredRelations = new[]
{
("system", "can_manage_users"),
("system", "can_insert"),
("course", "can_edit"),
("lesson", "can_edit"),
};
var response = await _fgaClient.ReadAuthorizationModel();
var declared = response.AuthorizationModel.TypeDefinitions
.ToDictionary(t => t.Type, t => t.Relations ?? new Dictionary<string, Userset>());
foreach (var (type, relation) in requiredRelations)
{
if (!declared.TryGetValue(type, out var relations) || !relations.ContainsKey(relation))
throw new InvalidOperationException(
$"OpenFGA model mismatch: {type}#{relation} not found. Deploy aborted.");
}
L’errore che blocca il deploy è fastidioso la prima volta. Vale la pena? Dalla seconda volta sì: sposta una categoria di bug dal runtime al boot, dove sono ovvi e diagnosticabili.
⚠️ La lista delle relazioni richieste va trattata come parte del contratto col modello. Ogni nuovo
Checknel codice aggiunge una riga: se salti questo passo, il pattern non protegge più.
6. Fail-closed e facade dedicata
Su errore, fail-closed. Sempre. Se la chiamata HTTP verso FGA fallisce per qualunque motivo (timeout, modello non raggiungibile, errore di rete), il check ritorna false. La domanda implicita di ogni autorizzazione è “questa azione è permessa?”. E la risposta sicura, quando non lo sai, è “no”. Una modalità disabled opt-in è accettabile in development. Mai in produzione.
L’altro pattern utile è separare l’API di autorizzazione in due metodi distinti per i due contesti d’uso:
// Branching UI: mostra o nasconde elementi
var showMenu = await _auth.CanManageUsers(user.Id);
// Guard prima dell'operazione: eccezione se non autorizzato
await _auth.EnsureCanManageUsers(user.Id);
await _userService.CreateUser(dto);
CanXxx ritorna un bool per le decisioni condizionali. EnsureXxx lancia un’eccezione se il check fallisce. Unificare i due dietro un solo metodo costringe ogni chiamante a gestire entrambi i casi e ricrea l’ambiguità che il fail-closed cerca di eliminare.
Errori da evitare
Assegnare un permesso computato come tupla diretta: scrivere
(user:alice, can_edit, course:42)viene rifiutato da OpenFGA convalidation_error(le relazioni computate non hanno type restrictions).can_editè computato daeditor: la tupla che devi scrivere è(user:alice, editor, course:42). L’errore al Write è il tuo amico — peggio sarebbe accorgersi del problema solo a Check fallito.Self-demote senza protezione: un utente che rimuove dal proprio subject l’ultima capability amministrativa si blocca fuori dalla gestione delle capability. Il controller deve rifiutare la richiesta se il caller sta rimuovendo il proprio ultimo permesso amministrativo.
Write non idempotenti: dalla v1.10.0, OpenFGA supporta
on_duplicate: "ignore"eon_missing: "ignore". Le sequenze multi-step non sono transazionali: vanno progettate come operazioni idempotenti riprovabili, non come transazioni distribuite.
Conclusione
I sei pattern raccontano una sola idea: la coesistenza non governata di due sistemi di autorizzazione è il problema, non i singoli strumenti. OpenFGA ti dà gli strumenti per esprimere policy che RBAC non sa modellare, ma il valore arriva solo quando consolidi le decisioni in un singolo sistema autoritativo, modelli per capability invece che per ruoli, e ti proteggi dalla classe di bug silenziosi con validazione al boot e fail-closed.
Resta aperto un problema: la sincronizzazione tra IdP e OpenFGA quando un utente viene creato, disabilitato o cambia gruppo. Due sistemi, due fonti di verità per dati che devono restare allineati. Ma questa è materia per un prossimo articolo.
Risorse utili
- OpenFGA — documentazione ufficiale: il punto di partenza per il DSL e la Check API
- OpenFGA Playground: testa il modello e le tuple in browser, senza setup
- Managing Tuples and Invoking API Best Practices: le practice ufficiali su tuple e chiamate API
- Zanzibar: Google’s Consistent, Global Authorization System: il paper originale da cui OpenFGA prende ispirazione















