From f316bd629b205a821dfe259f68596697eb51ef3e Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Wed, 3 Jun 2026 23:02:15 -0400 Subject: [PATCH] feat(fleet-auth): request Zitadel project roles in-band via OIDC scope (Ch1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/guides/operator-dashboard-sso.md | 12 +++++----- harmony_zitadel_auth/src/login.rs | 32 ++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/docs/guides/operator-dashboard-sso.md b/docs/guides/operator-dashboard-sso.md index cf2c9f2d..0a111234 100644 --- a/docs/guides/operator-dashboard-sso.md +++ b/docs/guides/operator-dashboard-sso.md @@ -11,9 +11,10 @@ public client). Distinct from the agent/callout machine auth secret), redirect URI `https://fleet-stg./auth/callback`, post-logout URI `https://fleet-stg./`. Copy its **Client ID**. 1b. **Roles** — the dashboard requires the **`fleet-admin`** project role. Create that - role on the project, enable the project's **Assert Roles on Authentication** (so the - role lands in the ID token), and grant it to each operator user. Without it they - authenticate but get **403 Access denied**. + 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 @@ -49,8 +50,9 @@ on the app's **Development Mode** (Zitadel rejects non-HTTPS redirects otherwise The operator reads `ZitadelAuthConfig` + `OperatorCookieKey` via ConfigClient. The deploy derives `zitadel_base` / `base_url` / `logout_redirect_uri` from `base_domain` (`https://sso-stg.`, `https://fleet-stg.`, `…/`) and fixes -`scope = openid profile email`; you supply `client_id`, `trusted_audiences`, -`cookie_key_b64`. All endpoints derive from `zitadel_base`: +`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`. diff --git a/harmony_zitadel_auth/src/login.rs b/harmony_zitadel_auth/src/login.rs index c92aae1b..8be81bef 100644 --- a/harmony_zitadel_auth/src/login.rs +++ b/harmony_zitadel_auth/src/login.rs @@ -134,6 +134,22 @@ pub fn build_logout_url(config: &ZitadelAuthConfig, id_token: &str) -> Result String { + if scope + .split_whitespace() + .any(|s| s == ZITADEL_PROJECT_ROLES_SCOPE) + { + scope.to_string() + } else { + format!("{scope} {ZITADEL_PROJECT_ROLES_SCOPE}") + } +} + pub fn build_login_attempt(config: &ZitadelAuthConfig) -> Result { let state = random_url_token(32); let pkce_code_verifier = random_url_token(32); @@ -145,7 +161,7 @@ pub fn build_login_attempt(config: &ZitadelAuthConfig) -> Result { .append_pair("client_id", &config.client_id) .append_pair("redirect_uri", &config.redirect_uri()) .append_pair("response_type", "code") - .append_pair("scope", &config.scope) + .append_pair("scope", &ensure_roles_scope(&config.scope)) .append_pair("code_challenge", &code_challenge) .append_pair("code_challenge_method", "S256") .append_pair("state", &state) @@ -225,4 +241,18 @@ mod tests { let challenge = pkce_s256_challenge(code_verifier); assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); } + + #[test] + fn ensure_roles_scope_appends_when_absent() { + assert_eq!( + ensure_roles_scope("openid profile email"), + "openid profile email urn:zitadel:iam:org:project:roles" + ); + } + + #[test] + fn ensure_roles_scope_is_idempotent() { + let s = "openid urn:zitadel:iam:org:project:roles email"; + assert_eq!(ensure_roles_scope(s), s); + } } -- 2.39.5