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
- 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 |
✓ | |||
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
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 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.
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)
- 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 atAuthorization:Assertion:SigningKeys:{kid}(labelplatform/bff). The gateway publishes it but keeps signing withActiveKey, 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
combinedWebApi,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:Assertionchanges (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
- 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, labelplatform/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"]
- Two bundles. OPA runs the policy baked into its image (roots
commandcenter/authz,commandcenter/defaults,system) and a second bundle,access(rootcommandcenter/access), which it downloads from WebApi. WebApi builds it fromPlatform:Access:Permissions, one JSON value at labelplatform. 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 withaccess.writeis refused, and so is one that would takeaccess.writefrom 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 withAuthorization:Opa:BundleToken(compose passes the same value to both;scripts/dev-bootstrap.ps1generates 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/v2routes 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):
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:
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