Files
harmony/docs/guides/operator-dashboard-sso.md
Jean-Gabriel Gill-Couture f316bd629b
All checks were successful
Run Check Script / check (pull_request) Successful in 2m31s
feat(fleet-auth): request Zitadel project roles in-band via OIDC scope (Ch1)
Role-gate follow-up from v0.3 plan Ch1:

- `build_login_attempt` appends the `urn:zitadel:iam:org:project:roles` scope,
  so the gate no longer depends on Zitadel's out-of-band "Assert Roles on
  Authentication" checkbox (which silently broke it once). Idempotent if the
  scope is already present.
- docs/guides/operator-dashboard-sso.md step 1b + config reference: drop the
  wrong checkbox instruction, document the in-band scope.

Role extraction stays local to each crate (dashboard object-map; callout
configurable claim path) — two small, genuinely-different parsers, not a
shared crate. Lifting `require_role` to a composable layer is skipped as
YAGNI — only `fleet-admin` exists; revisit at the second role.
2026-06-05 15:25:53 -04:00

3.2 KiB

Operator Dashboard SSO (Zitadel) — setup

Browser SSO for the fleet operator web UI (OIDC Authorization Code + PKCE, public client). Distinct from the agent/callout machine auth (fleet-zitadel-faq); the security rationale is in web-auth-security. Code: harmony_zitadel_auth/.

Quickstart (staging)

  1. Zitadel app — create a Web application, auth method PKCE (no client secret), redirect URI https://fleet-stg.<base>/auth/callback, post-logout URI https://fleet-stg.<base>/. Copy its Client ID. 1b. Roles — the dashboard requires the fleet-admin project role. Create that role on the project and grant it to each operator user. The login flow requests roles in-band via the OIDC scope urn:zitadel:iam:org:project:roles, so the project's Assert Roles on Authentication checkbox is not needed. Without the role grant, users authenticate but get 403 Access denied.
  2. Seed config in OpenBao (namespace fleet-staging) — the deploy derives every host from base_domain, so you set only:
    • FleetDeployConfig.operator_oidc_client_id = the Client ID
    • FleetDeployConfig.operator_trusted_audiences = ["<Client ID>"]
    • FleetDeploySecrets.operator_cookie_key_b64 = openssl rand -base64 64
  3. Deploy: ./fleet/scripts/dev-deploy-operator.sh
  4. Open https://fleet-stg.<base>/ → Zitadel login → back to the dashboard.

fleet_staging_install already generates the cookie key, so a fresh install needs only the Client ID + audiences.

Local dev (serve-web)

fleet/harmony-fleet-operator/dev.sh sets the same config as two HARMONY_CONFIG_<TypeName> env JSON blobs (ConfigClient's env source). Point base_url at http://localhost:18080, register that callback in the app, and turn on the app's Development Mode (Zitadel rejects non-HTTPS redirects otherwise).

When login fails — check these first

  • iss mismatchzitadel_base must equal the token issuer byte-for-byte, no trailing slash.
  • aud mismatchtrusted_audiences must contain the token's aud; Zitadel puts the app's Client ID there by default.
  • Client secret — the app must be PKCE-only; the code never sends a secret.
  • Redirect URI — must be exactly {base_url}/auth/callback.
  • Cookie keycookie_key_b64 must decode to ≥64 bytes, else the dashboard refuses to start (cookie_key_b64 must decode to at least 64 bytes; reconcile keeps running).

Config reference

The operator reads ZitadelAuthConfig + OperatorCookieKey via ConfigClient. The deploy derives zitadel_base / base_url / logout_redirect_uri from base_domain (https://sso-stg.<base>, https://fleet-stg.<base>, …/) and fixes scope = openid profile email (the login flow appends urn:zitadel:iam:org:project:roles so roles are always asserted); you supply client_id, trusted_audiences, cookie_key_b64. All endpoints derive from zitadel_base: /.well-known/openid-configuration, /oauth/v2/authorize, /oauth/v2/token, /oidc/v1/end_session.

The dashboard requires both authentication and the fleet-admin role (see step 1b). A logged-in user without it gets a 403 with a sign-out link.