Skip to content

Authorization#

Who may do what in CommandCenter is decided by Open Policy Agent (OPA). The policy code lives in this repository (policies/), is tested in CI, and ships baked into the commandcenter-opa image, so a release runs exactly the rules it was tested with. Which role holds which permission is data, edited on /config/access without a release; until someone edits it, the policy decides with the release defaults it carries.

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 ✓
certificates.request — request, complete and cancel TLS certificates ✓ ✓
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 above is the release default (policies/src/commandcenter/defaults/access/data.json); what applies is shown, and changed, on /config/access (below). The audit rule and the secret and identity-provider rules are policy code, not data: nothing edited there can switch them off.

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 role map on /config/access (below). 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.

Rotating the signing key (/config/credentials)#

The gateway signs the user token every backend reads (who the user is, their roles) with a P-256 key, and publishes the public part of every key it has at /.well-known/cc-internal/jwks. The page /config/credentials rotates that key in three steps. The server allows each one only once it is safe, says why not otherwise, and the page follows along until it is (secrets.rotate; reading needs config.read).

stateDiagram-v2
    direction LR
    [*] --> Published: Add next key<br/>(key into Key Vault, reference at label platform/bff)
    Published --> Active: Activate<br/>every gateway instance loaded and resolved it,<br/>backends had 7 min to re-read the key set
    Published --> [*]: Remove (never signed)
    Active --> Previous: another key is activated
    Previous --> [*]: Retire<br/>every instance signs with the new key,<br/>its tokens have expired (lifetime + 30 s)
Hold "Alt" / "Option" to enable pan & zoom
  • Add generates the key in WebApi straight into its Key Vault (cc-user-token-signing-{kid}, single-line base64 PKCS#8) and adds a Key Vault reference at Authorization:Assertion:SigningKeys:{kid} (label platform/bff). The gateway publishes it but keeps signing with ActiveKey, which this step also writes when it was implicit (a single key). The first key, when only a generated development key (or none) is published, is active at once.
  • Activate waits until every gateway instance's host status shows the revision that added the key and no failure to read its secret, until this backend trusts it, and until the backends have had time to fetch it: 5 minutes plus a minute, plus a minute of margin. Every backend replica asks for the key set once a minute and re-reads it once it is 5 minutes old (JwtBearer's default is 12 hours), even without traffic, since IdentityModel refreshes in the background and the request that finds the set out of date would still be checked against the old one. That is what makes the wait sufficient. An instance's "could not read its secret" clears as soon as a later read succeeds.
  • Retire waits until every instance has loaded the activation and the old key's tokens have expired, then removes its reference and disables its secret. A key that never signed (a rotation cancelled) goes at once.
  • One rotation at a time: while a key in the store does not sign, no next key can be added.
  • What blocks it: an instance that takes Authorization:Assertion:* from its environment (the store cannot change that; the page names it; this blocks every step), no host status (Configuration:Status), no Key Vault on WebApi (Configuration:KeyVault:Uri), or, for the first key, a key set this backend cannot read (it would not know about a key set outside the store). A key set outside App Configuration is shown as such and must be removed from the deployment.
  • Roles: the identity of the WebApi serving the platform (the platform host, or a combined WebApi, docs/configuration.md → WebApi's roles) needs Key Vault Secrets Officer on its vault and App Configuration Data Owner; the gateway's identity needs Key Vault Secrets User on the same vault.
  • The gateway reloads its keys when Authorization:Assertion changes (EventId 15). A reload that would leave it without its active key, or with a malformed key, is refused and the previous keys stay (EventId 16). At start, a missing or ambiguous active key turns signing off with a critical log (EventId 17) instead of stopping the front door; a malformed key still stops it.
  • Audit: 3192 added, 3193 activated, 3194 retired: who and the key ids, never a key.

The role map (/config/access)#

sequenceDiagram
    participant Admin as Security admin
    participant SPA as /config/access
    participant API as WebApi
    participant OPA
    participant AC as App Configuration
    participant GW as Gateway
    Admin->>SPA: CommandCenter.Support → cc.operator, Save
    SPA->>API: PUT /api/access/role-map, the version read
    API->>API: names as the gateway reads them; a cc.security-admin mapping kept
    API->>OPA: access.write
    API->>AC: Authorization:Subject:RoleMap at label platform/bff (one JSON value), PlatformSentinel
    AC-->>GW: reload
    GW->>GW: the next request of a Support user carries cc.operator
Hold "Alt" / "Option" to enable pan & zoom
  • Provider role → internal roles. A provider role is what Entra (an app role's value) or Keycloak sends; it grants every internal role ticked for it. Provider roles not listed grant nothing; an internal name (cc.…) the provider sends directly is kept as it is. The page also says which internal roles you have now.
  • Stored as one JSON value at Authorization:Subject:RoleMap, label platform/bff — only the gateway reads it — with the version read, so two admins cannot overwrite each other. The gateway maps the next request with it, the admin's own included.
  • Checked as the gateway reads it: a provider role name has no : (it would split the key) and appears once without regard to case; only the four internal roles can be granted.
  • No lockout by accident: a save that leaves no provider role granting cc.security-admin, where one did, is refused — that role changes access, and nobody signed in through the map could undo it. access.write (security admin) to change; audit 3189 with the new map.
  • Role names are not secrets, and the map lives in App Configuration, not in the gateway's appsettings.json (a value there would sit under the store's and could not be removed from the page).

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.

Who may do what (/config/access)#

flowchart LR
    Admin([Security admin]) -- "grid: role x permission, Save" --> WebApi
    WebApi -- "access.write, and some role keeps it, and so do you" --> WebApi
    WebApi -- "Platform:Access:Permissions (label platform), PlatformSentinel" --> AC["App Configuration"]
    AC -. "refresh" .-> WebApi
    OPA -- "GET /internal/opa/bundles/access.tar.gz<br/>Bearer OPA_BUNDLE_TOKEN, every 20-40 s" --> WebApi
    OPA -- "persist" --> Vol[("volume opa-data")]
    OPA -- "decides with it; provenance reports its revision" --> BFF["Gateway and backends"]
Hold "Alt" / "Option" to enable pan & zoom
  • Two bundles. OPA runs the policy baked into its image (roots commandcenter/authz, commandcenter/defaults, system) and a second bundle, access (root commandcenter/access), which it downloads from WebApi. WebApi builds it from Platform:Access:Permissions, one JSON value at label platform. The policy decides with the downloaded data when it has any, and with the release defaults otherwise, so a fresh install, a WebApi that is down, or a missing token never means "nobody may do anything".
  • Saving needs access.write, the version read (409 when someone else saved meanwhile), and data OPA can use. A save that leaves no role with access.write is refused, and so is one that would take access.write from you (the roles in your token). Reset goes back to the release defaults. Audit 3190 (changed, with the new data) and 3191 (reset).
  • When it applies. WebApi serves the new bundle once its configuration refreshes (seconds after the sentinel moves), and OPA polls every 20 to 40 s. The page shows the revision OPA reports in its decisions, and waits for it: OPA still decides with revision … until the two match.
  • Persisted. OPA keeps the last bundle on the volume opa-data (/var/lib/opa), so a restart starts with it even while WebApi is down.
  • The token. OPA sends OPA_BUNDLE_TOKEN; WebApi compares it with Authorization:Opa:BundleToken (compose passes the same value to both; scripts/dev-bootstrap.ps1 generates it locally). Without a token on WebApi the bundle answers 503 and OPA keeps what it has; with a wrong one, 401. The bundle path is outside the /v2 routes the gateway forwards, so it cannot be reached through the gateway. The data holds role and permission names only.
  • Bad data never reaches OPA. Stored data that is not usable (no role keeps access.write, an unknown role or permission) is served as an empty bundle, so OPA decides with the release defaults; the page shows the problem.
  • Production roles. Optionally, only some roles may reach an environment marked production (production_roles); none ticked means no extra limit.

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) and in Shared.Authorization.AuthzActions.All, grant it in the release defaults (defaults/access/data.json and AccessData.Defaults), and test it in policies/tests/. Tests fail when the C# lists and the policy's differ (AccessPermissionsTests). Existing deployments with edited permissions do not get the new action on any role until someone ticks it on /config/access. 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, and takes policies/config.yaml from there too, so an image built before a config change still starts.

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