feat(fleet-auth): unify Zitadel role extraction + request roles via scope (Ch1) #327

Merged
johnride merged 1 commits from feat/fleet-ch1-role-gate-followups into feat/fleet-device-exec-logs 2026-06-09 19:44:32 +00:00
2 changed files with 38 additions and 6 deletions

View File

@@ -11,9 +11,10 @@ public client). Distinct from the agent/callout machine auth
secret), redirect URI `https://fleet-stg.<base>/auth/callback`, post-logout URI secret), redirect URI `https://fleet-stg.<base>/auth/callback`, post-logout URI
`https://fleet-stg.<base>/`. Copy its **Client ID**. `https://fleet-stg.<base>/`. Copy its **Client ID**.
1b. **Roles** — the dashboard requires the **`fleet-admin`** project role. Create that 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 on the project and grant it to each operator user. The login flow requests
role lands in the ID token), and grant it to each operator user. Without it they roles **in-band** via the OIDC scope `urn:zitadel:iam:org:project:roles`, so the
authenticate but get **403 Access denied**. 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 2. **Seed config** in OpenBao (namespace `fleet-staging`) — the deploy derives every
host from `base_domain`, so you set only: host from `base_domain`, so you set only:
- `FleetDeployConfig.operator_oidc_client_id` = the Client ID - `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 The operator reads `ZitadelAuthConfig` + `OperatorCookieKey` via ConfigClient. The
deploy derives `zitadel_base` / `base_url` / `logout_redirect_uri` from `base_domain` deploy derives `zitadel_base` / `base_url` / `logout_redirect_uri` from `base_domain`
(`https://sso-stg.<base>`, `https://fleet-stg.<base>`, `…/`) and fixes (`https://sso-stg.<base>`, `https://fleet-stg.<base>`, `…/`) and fixes
`scope = openid profile email`; you supply `client_id`, `trusted_audiences`, `scope = openid profile email` (the login flow appends
`cookie_key_b64`. All endpoints derive from `zitadel_base`: `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`, `/.well-known/openid-configuration`, `/oauth/v2/authorize`, `/oauth/v2/token`,
`/oidc/v1/end_session`. `/oidc/v1/end_session`.

View File

@@ -134,6 +134,22 @@ pub fn build_logout_url(config: &ZitadelAuthConfig, id_token: &str) -> Result<Ur
Ok(url) Ok(url)
} }
/// Zitadel's reserved scope that asserts the caller's project roles in the
/// token — the in-band alternative to the project's "Assert Roles on
/// Authentication" checkbox, which is out-of-band and silently broke the gate.
const ZITADEL_PROJECT_ROLES_SCOPE: &str = "urn:zitadel:iam:org:project:roles";
fn ensure_roles_scope(scope: &str) -> 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<LoginAttempt> { pub fn build_login_attempt(config: &ZitadelAuthConfig) -> Result<LoginAttempt> {
let state = random_url_token(32); let state = random_url_token(32);
let pkce_code_verifier = random_url_token(32); let pkce_code_verifier = random_url_token(32);
@@ -145,7 +161,7 @@ pub fn build_login_attempt(config: &ZitadelAuthConfig) -> Result<LoginAttempt> {
.append_pair("client_id", &config.client_id) .append_pair("client_id", &config.client_id)
.append_pair("redirect_uri", &config.redirect_uri()) .append_pair("redirect_uri", &config.redirect_uri())
.append_pair("response_type", "code") .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", &code_challenge)
.append_pair("code_challenge_method", "S256") .append_pair("code_challenge_method", "S256")
.append_pair("state", &state) .append_pair("state", &state)
@@ -225,4 +241,18 @@ mod tests {
let challenge = pkce_s256_challenge(code_verifier); let challenge = pkce_s256_challenge(code_verifier);
assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); 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);
}
} }