· 11 min Progettare

Autorizzazione Granulare con OPA e Keycloak: Separare Autenticazione e Autorizzazione

OPA Keycloak Authorization Rego OpenPolicyAgent

Bloccare un utente dal checkout quando il claim JWT e’ ancora valido fino alla scadenza del token. Aggiungere una regola di accesso e dover modificare Keycloak, il codice Express, e magari anche un mapper custom. Sono problemi comuni quando autenticazione e autorizzazione non sono separate.

In un sistema che usa Keycloak per entrambe le responsabilità, le regole di accesso finiscono sparse tra claim JWT, mapper custom e logica applicativa. Funziona, ma crea un accoppiamento: modificare chi può fare cosa richiede toccare Keycloak, il codice, o entrambi.

La separazione delle due responsabilità risolve entrambi i problemi: Keycloak autentica (chi sei), OPA autorizza (cosa puoi fare). L’integrazione avviene in MockMart, lo stesso e-commerce demo usato negli articoli precedenti, con tre pattern concreti:

  1. RBAC su prodotti: solo admin può creare, modificare ed eliminare
  2. Deny list su checkout: blocco immediato senza re-login
  3. Ownership su ordini: ogni utente vede solo i propri

Il Modello: OPA come Sidecar HTTP

Open Policy Agent (OPA) è un policy engine general-purpose. Riceve una richiesta di decisione via HTTP, la valuta contro regole scritte in Rego (il suo linguaggio dichiarativo), e risponde con un booleano.

Nel contesto MockMart, OPA gira come container separato nella rete Docker. Shop API lo chiama prima di eseguire operazioni protette.

Architettura Aggiornata

Gateway (nginx:80)
+-- /        -> shop-ui (React SPA, :3000)
+-- /api/    -> shop-api (Node.js/Express, :3001)
+-- /auth/   -> keycloak (:8080)

shop-api --> opa (:8181)              <-- nuovo
shop-api --> postgres, payment, inventory, notification

Flusso di una Richiesta Protetta

  1. Il browser chiama shop-api con un Bearer JWT
  2. auth.js valida il JWT via JWKS e popola req.user (id, ruoli, username)
  3. opa.js costruisce un oggetto input e chiama OPA
  4. OPA valuta la policy Rego e risponde { "result": true } o { "result": false }. Se nessuna regola matcha (package errato, policy non caricata), OPA restituisce {} senza chiave result
  5. Se allow = true, il route handler esegue. Se false, il middleware risponde 403 Forbidden

Separazione delle Responsabilità

ComponenteResponsabilità
KeycloakAutenticazione, emissione JWT, gestione utenti e ruoli
auth.js (Express)Validazione JWT, estrazione identità
OPADecisione di autorizzazione (allow/deny)
Policy RegoRegole: chi può fare cosa su quale risorsa

Keycloak non ha bisogno di sapere nulla delle regole di business. OPA non ha bisogno di sapere come funziona il login. Ogni componente ha un perimetro chiaro.


Policy Rego: Tre Pattern Concreti

Le policy sono file .rego montati come volume nel container OPA. Ogni file corrisponde a un dominio di autorizzazione.

services/opa/
+-- policies/
|   +-- products.rego      # RBAC per ruolo
|   +-- orders.rego        # Visibilità per ownership
|   +-- checkout.rego      # Deny list
+-- data.json              # Dati esterni (utenti bloccati)

Prodotti: RBAC per Ruolo

Regola: tutti possono leggere i prodotti, solo admin può creare, modificare ed eliminare.

package mockmart.products

default allow = false

# Tutti possono leggere
allow if {
    input.action == "read"
}

# Solo admin può creare/modificare/eliminare
allow if {
    input.action in {"create", "update", "delete"}
    "admin" in input.user.roles
}

default allow = false è il principio di deny-by-default: se nessuna regola matcha, l’accesso è negato. Ogni blocco allow if { ... } definisce una condizione sufficiente per concedere l’accesso. Le condizioni all’interno di un blocco sono in AND: devono essere tutte vere.

Checkout: Deny List Esterna

Nello stack base MockMart, il blocco al checkout avviene tramite un claim JWT canCheckout. Se un utente viene bloccato in Keycloak, deve ri-loggarsi perché il claim nel token cambi. Con OPA, il blocco è immediato.

package mockmart.checkout

import data.mockmart.blocked_users

default allow = false

allow if {
    input.action == "checkout"
    not is_blocked
}

is_blocked if {
    input.user.username == blocked_users[_]
}

La lista degli utenti bloccati proviene da data.json, un file esterno montato nel container OPA:

{
  "mockmart": {
    "blocked_users": [
      "blocked"
    ]
  }
}

import data.mockmart.blocked_users rende disponibile questo array nella policy. Per bloccare un utente basta aggiungere il suo username al file e riavviare il container OPA (docker compose restart opa): nessuna modifica a Keycloak, nessun re-deploy dell’applicazione. Per ambienti che richiedono reload a caldo senza restart, OPA supporta la Bundle API con aggiornamenti periodici via HTTP.

Ordini: Ownership

Regola: admin vede tutti gli ordini, un utente normale vede solo i propri.

package mockmart.orders

default allow = false

# Admin può listare e leggere tutti gli ordini
allow if {
    input.action in {"list", "read"}
    "admin" in input.user.roles
}

# Utenti: solo i propri ordini
allow if {
    input.action in {"list", "read"}
    input.resource_owner == input.user.id
}

Questa policy introduce il concetto di resource owner: l’input include sia l’identità dell’utente (input.user.id) sia il proprietario della risorsa (input.resource_owner). Il middleware Express è responsabile di popolare questo campo prima di chiamare OPA.


Il Middleware Express

Il middleware opa.js ha due responsabilità: costruire l’oggetto input per OPA e gestire la risposta.

const OPA_URL = process.env.OPA_URL || 'http://opa:8181';
const AUTHORIZATION_MODE = process.env.AUTHORIZATION_MODE || 'claims';

async function checkPolicy(packageName, input) {
  const url = `${OPA_URL}/v1/data/mockmart/${packageName}/allow`;

  const res = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ input })
  });

  if (!res.ok) {
    throw new Error(`OPA returned ${res.status}: ${await res.text()}`);
  }

  const data = await res.json();
  return data.result === true;
}

checkPolicy chiama l’endpoint OPA Data API: POST /v1/data/mockmart/{package}/allow. Il body contiene { input: {...} }, la risposta è { result: true|false }. Il check data.result === true gestisce anche il caso in cui OPA restituisca {} (nessuna chiave result): in quel caso l’accesso viene negato, coerente con il principio deny-by-default.

function requirePolicy(packageName, action) {
  return async (req, res, next) => {
    if (AUTHORIZATION_MODE !== 'opa') {
      return next();
    }

    const input = {
      user: req.user ? {
        id: req.user.id,
        username: req.user.username,
        roles: req.user.roles,
        email: req.user.email
      } : null,
      action: typeof action === 'function' ? action(req) : action
    };

    if (req.opaContext) {
      Object.assign(input, req.opaContext);
    }

    try {
      const allowed = await checkPolicy(packageName, input);
      if (!allowed) {
        return res.status(403).json({ error: 'Forbidden by policy' });
      }
      next();
    } catch (error) {
      console.error('OPA policy check failed:', error.message);
      return res.status(503).json({ error: 'Authorization service unavailable' });
    }
  };
}

Tre aspetti rilevanti:

  • Switch AUTHORIZATION_MODE: se il valore non è opa, il middleware fa passthrough. Questo garantisce che make up (stack base) continui a funzionare con la logica claims, senza toccare nulla
  • req.opaContext: middleware precedenti possono aggiungere contesto extra (es. resource_owner per gli ordini). requirePolicy lo include automaticamente nell’input OPA
  • Fail closed: se OPA non è raggiungibile, il middleware risponde 503 Service Unavailable invece di lasciar passare la richiesta. In un sistema di autorizzazione, il default in caso di errore deve essere il deny

Integrazione nelle Route

Ogni route protetta aggiunge requirePolicy nella catena middleware, dopo requireAuth.

Prodotti

// Tutti leggono
app.get('/api/products', optionalAuth, requirePolicy('products', 'read'), handler);

// Solo admin modifica
app.post('/api/products',       requireAuth, requirePolicy('products', 'create'), handler);
app.put('/api/products/:id',    requireAuth, requirePolicy('products', 'update'), handler);
app.delete('/api/products/:id', requireAuth, requirePolicy('products', 'delete'), handler);

Il secondo parametro di requirePolicy è l’azione: una stringa che corrisponde al valore che la policy Rego si aspetta in input.action.

Checkout

app.post('/api/checkout', requireAuth, requirePolicy('checkout', 'checkout'), async (req, res) => {
  const user = req.user;

  // In claims mode, controlla canCheckout dal JWT
  if (process.env.AUTHORIZATION_MODE !== 'opa' && !user.canCheckout) {
    return res.status(403).json({ error: 'You are not authorized to checkout.' });
  }
  // ...
});

La guardia AUTHORIZATION_MODE !== 'opa' mantiene il comportamento originale (claim JWT) quando OPA non è attivo.

Ordini: Popolare il Resource Owner

Per gli ordini, il middleware deve sapere chi è il proprietario della risorsa prima di chiamare OPA.

Lista ordini: il proprietario è l’utente corrente (ogni utente lista i propri).

app.get('/api/orders', requireAuth, (req, res, next) => {
  req.opaContext = { resource_owner: req.user.id };
  next();
}, requirePolicy('orders', 'list'), handler);

Dettaglio ordine: serve una query al database per sapere chi ha creato l’ordine.

app.get('/api/orders/:id', requireAuth, async (req, res, next) => {
  if (process.env.AUTHORIZATION_MODE === 'opa') {
    const result = await pool.query('SELECT user_id FROM orders WHERE id = $1', [req.params.id]);
    if (result.rows.length === 0) {
      return res.status(404).json({ error: 'Order not found' });
    }
    req.opaContext = { resource_owner: result.rows[0].user_id };
  }
  next();
}, requirePolicy('orders', 'read'), handler);

Questo è il caso in cui il costo della separazione diventa visibile: per decisioni basate su ownership serve una query aggiuntiva. Il trade-off è accettabile se le regole di accesso cambiano frequentemente o sono complesse.


Test e Verifica

Test Offline delle Policy

Le policy Rego si possono testare senza avviare alcun container. I file di test usano la convenzione test_ nel nome della regola:

package mockmart.products

test_allow_read_without_auth if {
    allow with input as {"action": "read", "user": null}
}

test_deny_create_as_user if {
    not allow with input as {"action": "create", "user": {"roles": ["user"]}}
}

test_allow_create_as_admin if {
    allow with input as {"action": "create", "user": {"roles": ["admin"]}}
}
$ make opa-test

data.mockmart.products.test_allow_read_without_auth: PASS
data.mockmart.products.test_deny_create_as_user: PASS
data.mockmart.products.test_allow_create_as_admin: PASS
data.mockmart.orders.test_allow_admin_list_all: PASS
data.mockmart.orders.test_deny_user_list_other: PASS
data.mockmart.checkout.test_deny_blocked_user: PASS
...
PASS: 17/17

Testabilità offline è uno dei vantaggi principali di OPA: l’input è JSON, l’output è un booleano. Non servono token, non serve Keycloak.

Verifica End-to-End

Con lo stack attivo (make up-opa), si possono verificare i flussi con curl.

Nota: gli esempi usano grant_type=password (Resource Owner Password Credentials) per ottenere token da terminale in modo rapido. Questo flusso è deprecato (RFC 9700) e abilitato in MockMart solo per testing locale. In produzione, usare Authorization Code + PKCE per utenti e Client Credentials per servizi.

Utente normale tenta di creare un prodotto:

$ TOKEN=$(curl -s -X POST "http://localhost:8080/auth/realms/techstore/protocol/openid-connect/token" \
  -d "grant_type=password" -d "client_id=shop-ui" \
  -d "username=mario" -d "password=mario123" | jq -r '.access_token')

$ curl -s -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Test","price":10,"category":"test"}' \
  http://localhost:3001/api/products

{"error":"Forbidden by policy"}    # HTTP 403

Admin crea un prodotto:

$ TOKEN=$(curl -s -X POST "http://localhost:8080/auth/realms/techstore/protocol/openid-connect/token" \
  -d "grant_type=password" -d "client_id=shop-ui" \
  -d "username=admin" -d "password=admin123" | jq -r '.access_token')

$ curl -s -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"New Product","price":29.99,"category":"electronics"}' \
  http://localhost:3001/api/products

{"id":13,"name":"New Product","price":29.99,...}    # HTTP 201

Utente bloccato tenta il checkout:

$ TOKEN=$(curl -s -X POST "http://localhost:8080/auth/realms/techstore/protocol/openid-connect/token" \
  -d "grant_type=password" -d "client_id=shop-ui" \
  -d "username=blocked" -d "password=blocked123" | jq -r '.access_token')

$ curl -s -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"shippingAddress":{...},"paymentMethod":"credit-card"}' \
  http://localhost:3001/api/checkout

{"error":"Forbidden by policy"}    # HTTP 403

Confronto: Claims JWT vs OPA

AspettoClaims JWT (canCheckout)OPA (Deny List + Policy)
Bloccare un utenteModificare attributo in Keycloak, utente deve ri-loggarsiEditare data.json, effetto immediato
Aggiungere una regolaNuovo claim + mapper KC + codice ExpressNuova regola Rego, nessun deploy applicativo
Dove vivono le regoleSparse tra KC config, mapper e codiceCentralizzate in services/opa/policies/
TestabilitàRichiede token reali e Keycloak attivoInput JSON, output booleano, testabile offline
Latenza aggiuntivaNessuna (claim già nel token)Chiamata HTTP locale aggiuntiva
Complessità infrastrutturaNessunaContainer aggiuntivo da gestire

Nessuno dei due approcci è universalmente migliore. Claims JWT funzionano bene quando le regole sono poche e statiche. OPA diventa vantaggioso quando le regole cambiano frequentemente, coinvolgono dati esterni (deny list, ownership), o devono essere testate in isolamento.


Stack Docker

OPA si aggiunge allo stack tramite un file Docker Compose di override, senza modificare la configurazione base:

# docker-compose.opa.yml
services:
  opa:
    image: openpolicyagent/opa:latest-debug
    command: run --server --log-level info --addr :8181 /policies /data.json
    ports:
      - "8181:8181"
    volumes:
      - ./services/opa/policies:/policies:ro
      - ./services/opa/data.json:/data.json:ro
    networks:
      - demo

  shop-api:
    environment:
      - OPA_URL=http://opa:8181
      - AUTHORIZATION_MODE=opa

Nota: l’esempio usa latest-debug per semplicità (include una shell utile per il debug). In produzione, fissa una versione specifica (es. openpolicyagent/opa:1.4.2) per garantire riproducibilità e sicurezza.

Due comandi per gestire lo stack:

# Avvio stack con OPA
make up-opa

# Stack base (senza OPA, logica claims)
make up

make up continua a funzionare esattamente come prima. L’aggiunta di OPA è puramente additiva.


Conclusioni

La separazione tra autenticazione (Keycloak) e autorizzazione (OPA) produce un sistema in cui ogni componente ha un perimetro preciso. Keycloak risponde a “chi sei”, OPA risponde a “cosa puoi fare”.

I tre pattern implementati coprono scenari comuni:

  • RBAC: azioni limitate per ruolo (prodotti)
  • Deny list: blocco basato su dati esterni (checkout)
  • Ownership: accesso condizionato al proprietario della risorsa (ordini)

Il trade-off principale è la complessità infrastrutturale: un container in più, una chiamata HTTP per ogni decisione, e la necessità di popolare il contesto (come resource_owner) nel middleware. In cambio, le regole diventano centralizzate, testabili offline, e modificabili senza toccare il codice applicativo.

Bloccare un utente al volo o aggiungere una regola di accesso senza deploy non richiede intervento sul codice applicativo: basta modificare un file .rego.

Risorse

Repository demo:

Documentazione:


Per domande o feedback: LinkedIn | GitHub

Hai bisogno di una revisione architetturale?

Scopri il servizio →

Ti è piaciuto questo articolo?

Articoli correlati

Modifica su GitHub