Authorization#
Who may do what in CommandCenter is decided by Open Policy Agent (OPA). The policies live in
this repository (policies/), are tested in CI, and ship baked into the commandcenter-opa image,
so a release runs exactly the policies it was tested with.
The backends make the real decision; the SPA asks the same policy only to hide or disable what the user may not do. The SPA is never the boundary.
The pieces#
flowchart LR
Browser["Browser (SPA)"]
IdP["Identity provider<br/>(Entra ID or Keycloak)"]
BFF["BFF<br/>CommandCenter.Bff.Frontdoor"]
WebApi["WebApi"]
OPA["OPA<br/>commandcenter-opa"]
Browser -- "session cookie, X-CSRF" --> BFF
BFF -- "sign-in (OIDC)" --> IdP
BFF -- "user token (ES256, 5 min)" --> WebApi
BFF -- "POST /bff/authz/decisions" --> OPA
WebApi -- "decision per request" --> OPA
subgraph authz ["internal network: authz"]
OPA
end
- The BFF signs the user in with the identity provider, normalises the user (id, name, roles —
whatever the provider) and passes WebApi a short-lived token it signs itself
(
backend/CLAUDE.md→ Internal user token). - Roles are internal:
cc.viewer,cc.operator,cc.config-admin,cc.security-admin. The provider's own roles are mapped onto them by the BFF (Authorization:Subject:RoleMap), so the policy never depends on what a role is called at Entra or Keycloak. - OPA answers
POST /v1/data/commandcenter/authz/decisionson the internalauthznetwork, which only the BFF and WebApi join. Its own API is locked: nothing can read or change the policy or its data through it.
What each role may do#
| Permission | viewer | operator | config-admin | security-admin |
|---|---|---|---|---|
environment.access — open an environment |
✓ | ✓ | ✓ | ✓ |
config.read — see configuration pages |
✓ | ✓ | ✓ | ✓ |
loglevel.write — change log levels (never audit) |
✓ | ✓ | ||
hosts.restart — restart a host |
✓ | ✓ | ||
config.write — change settings |
✓ | |||
agents.config.write, sqlservers.write, hosts.migrate |
✓ | |||
secrets.write, secrets.rotate — secrets and credentials |
✓ | |||
idp.write — the identity provider |
✓ | |||
access.write — who may do what |
✓ |
Two rules on top of the table: writing a secret setting also needs secrets.write, and
changing the identity provider also needs idp.write — so a configuration admin cannot, alone,
change how everyone signs in. Nobody may lower the log level of audit events. A user can hold
several roles; the permissions add up. The table is policies/src/commandcenter/access/data.json.
A decision, step by step#
sequenceDiagram
participant SPA
participant BFF
participant WebApi
participant OPA
SPA->>BFF: PUT /api/features/x (cookie, X-CSRF)
BFF->>BFF: normalise the user, sign a token
BFF->>WebApi: PUT /api/features/x (Bearer user token)
WebApi->>WebApi: validate the token (keys from the BFF)
WebApi->>OPA: may {sub, roles} do config.write on features?
OPA-->>WebApi: {"allow": true}
WebApi-->>SPA: 200
Note over WebApi,OPA: no answer within 750 ms, an error, or no result:<br/>denied, 503 authz-unavailable
The backend answers:
| Status | type |
|
|---|---|---|
| Nobody signed in | 401 | …/problems/not-signed-in |
| Not allowed | 403 | …/problems/forbidden, with reasons (e.g. missing_permission:config.write) |
| OPA could not be asked | 503 | …/problems/authz-unavailable |
It fails closed. A timeout, a transport error, a non-2xx answer, an answer without a result
(OPA's 200 {} when a rule is undefined) or a check without a proper decision is a denial — never
an accidental "yes". Only the endpoints that ask are affected by an OPA outage; everything else
keeps working.
For the operator#
- Who may change what is decided by the roles a user has at the identity provider and the
mapping in
Authorization:Subject:RoleMap. In Entra ID, define app roles on the BFF's app registration and assign them to users or groups (Entra drops the group list of users in too many groups, so groups are not read). In Keycloak, realm roles, or client roles of the client set asAuthorization:Subject:KeycloakClientId. - A signed-in user who gets 403 everywhere has no mapped role: check the mapping first.
- When the policy seems wrong, the decision log on OPA's console shows each decision with its input (the user's name masked) and the bundle revision it ran.
- When OPA is down,
/health/readyon the BFF and WebApi reports it and the protected actions answer 503. The container restarts by itself (restart: unless-stopped); nothing else is needed. - If a release shipped a policy that locks everyone out, deploy the previous
commandcenter-opaimage; the policy tests in CI exist to make that unnecessary.
In Development every signed-in user gets cc.config-admin and cc.security-admin
(Authorization:Subject:DevRoles in the BFF's development settings). The BFF refuses to start with
DevRoles in any other environment.
For the developer#
Protect an endpoint with .RequirePermission(action, area) (Shared.Authorization.Authz):
A handler that knows more than the area (which setting, whether it is a secret) authorises with an
AuthzResource as the resource instead of relying on the endpoint's.
Add an action in policies/src/commandcenter/authz/authz.rego (actions), grant it in
access/data.json, and test it in policies/tests/. Run the policy checks locally with:
Under compose.dev.yaml OPA reads policies/src from the working tree and reloads on every save.
Ask from the SPA with POST /bff/authz/decisions (X-CSRF required, 1–50 checks):
{ "checks": [{ "id": "save", "action": "config.write", "resource": { "type": "area", "area": "features" } }] }
It always answers 200 for a well-formed batch:
{ "available": true, "policyRevision": "…", "subject": { "authenticated": true, "name": "…", "roles": […] }, "decisions": { "save": { "allow": true, "reasons": [] } } }.
With OPA down, available is false and every check is denied.
Signing in to a change#
journey
title An operator switches a feature
section Signed out
Opens /config/features: 5: Operator
Switches, gets "Sign in to change this switch": 2: Operator
section Signed in without a mapped role
Switches, gets "You are not allowed": 2: Operator
Asks for the config-admin role: 3: Operator
section Signed in as config-admin
Switches, the change is saved with their name: 5: Operator