feat(fleet-auth): unify Zitadel role extraction + request roles via scope (Ch1) #327
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user