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.
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)
- Zitadel app — create a Web application, auth method PKCE (no client
secret), redirect URI
https://fleet-stg.<base>/auth/callback, post-logout URIhttps://fleet-stg.<base>/. Copy its Client ID. 1b. Roles — the dashboard requires thefleet-adminproject role. Create that role on the project and grant it to each operator user. The login flow requests roles in-band via the OIDC scopeurn: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. - Seed config in OpenBao (namespace
fleet-staging) — the deploy derives every host frombase_domain, so you set only:FleetDeployConfig.operator_oidc_client_id= the Client IDFleetDeployConfig.operator_trusted_audiences=["<Client ID>"]FleetDeploySecrets.operator_cookie_key_b64=openssl rand -base64 64
- Deploy:
./fleet/scripts/dev-deploy-operator.sh - 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
issmismatch —zitadel_basemust equal the token issuer byte-for-byte, no trailing slash.audmismatch —trusted_audiencesmust contain the token'saud; 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 key —
cookie_key_b64must 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-adminrole (see step 1b). A logged-in user without it gets a 403 with a sign-out link.