Skip to content

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
Hold "Alt" / "Option" to enable pan & zoom
  • 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/decisions on the internal authz network, 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
Hold "Alt" / "Option" to enable pan & zoom

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 as Authorization: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/ready on 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-opa image; 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):

group.MapPut("/{name}", UpdateFeatureAsync)
    .RequirePermission("config.write", "features");

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:

docker run --rm -v "$PWD/policies:/p" openpolicyagent/opa:1.21.0-static test /p/src /p/tests -v

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
Hold "Alt" / "Option" to enable pan & zoom