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
`https://fleet-stg.<base>/`. 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.<base>`, `https://fleet-stg.<base>`, `…/`) 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`.

View File

@@ -134,6 +134,22 @@ pub fn build_logout_url(config: &ZitadelAuthConfig, id_token: &str) -> Result<Ur
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> {
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<LoginAttempt> {
.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);
}
}