ADR 0008 — Authorization with OPA, and an internal user token#
| Status | Accepted |
| Date | 2026-09-28 |
| Authors | Ruben Knuijver |
| Supersedes | — |
| Superseded by | — |
| Related | #322 (configuration platform), #325 (internal user token), #326 (OPA foundation) |
Context#
The /config pages are to manage CommandCenter's external services, secrets and sign-in from the
UI. Until now nothing behind the BFF was protected: no route had an authorization policy, WebApi
received no user at all, and every container on the shared proxy network could call WebApi
directly. The identity provider is still open — Keycloak is more likely than Entra ID — and
authorization was chosen to be Open Policy Agent, on the backend and in the SPA.
Decision#
- The BFF passes the user to the backends in a token it signs itself (ES256, 5 minutes,
iss=commandcenter-bff,aud=commandcenter-internal), not the identity provider's token. It normalises the user — id, name, and roles mapped onto internalcc.*roles — from Entra or Keycloak claims alike. On a cluster markedUserAssertionit always removes the browser'sAuthorizationheader. The backends validate against the BFF's published keys. - OPA decides. The policies (
policies/, Rego v1) are code: reviewed in merge requests, tested in CI with a coverage gate, and baked into thecommandcenter-opaimage. OPA runs next to the BFF and WebApi on an internal network and its own API is locked. - One batch query,
data.commandcenter.authz.decisions, answers{id, action, resource}checks for one subject. The input never carries a setting's or a secret's value. - Fail closed: timeout (750 ms), error, empty result or malformed decision is a denial; "could not ask" (503) is kept apart from "not allowed" (403).
- The backend is the boundary. The SPA asks
POST /bff/authz/decisionsonly to hide or disable controls.
Consequences#
- Switching between Entra ID and Keycloak only changes the BFF (claim paths and the role map).
- Who may do what is data in the policy bundle (
access/data.json); a later step serves that data as its own bundle so the Access page can change it without a release. - OPA is a dependency of the protected actions only; an outage answers 503 there and nothing else.
- Every BFF instance needs the same signing key; without one outside Development the backends see no user, as before.
- There is no in-app break-glass. OPA restarts itself; a bad policy is rolled back by deploying the previous image.
Alternatives considered#
- Forward the identity provider's access token (on-behalf-of). Entra-specific (Keycloak's token exchange differs), puts role normalisation in every backend, and needs a token per downstream API.
- A plain user header from the BFF. Any container on the shared network could forge it.
- OPA only at the BFF (route level). The BFF cannot see which setting a request changes or whether it is a secret; the decision needs the backend's knowledge.
- A bundle server for the policy code. Needs storage and credentials for OPA to start, and the policy a release runs would no longer be the one it was tested with.