From aad14cd04d2698749ea738b183265e346fb85afd Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sun, 24 May 2026 16:39:37 -0400 Subject: [PATCH 1/2] feat(fleet-operator): require fleet-admin role on dashboard routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the dashboard granted full access to any Zitadel user with a valid JWT — issuer, audience and signature were the only checks. Operators routinely give non-admin users an account in Zitadel for SSO into adjacent apps, so a valid JWT is not by itself evidence the user should manage a fleet. This change closes that gap. Behaviour: - Verified sessions now carry a `roles: Vec` extracted from a configurable JWT claim (env `FLEET_AUTH_ROLES_CLAIM`, default the Zitadel role URN). The extractor accepts both OIDC array shape and Zitadel's object-map shape; missing/malformed claim yields empty vec (closed door, never wildcard). - `require_fleet_admin` middleware layers above `require_auth` on the private routes. A logged-in user without the role gets a human- readable 403 page that names the missing role and offers a sign-out link. - `/logout` is in `public_routes` so a non-admin can switch accounts without being trapped by the gate that rendered the 403. Shared role extraction: - `harmony_zitadel_auth::roles::extract_roles` is the single source of truth for Zitadel role parsing. - The NATS auth callout's `ZitadelValidator::extract_roles` now delegates to it. Any future quirk in Zitadel's role-emission shape is fixed once. Tests: - 9 unit tests on the role extractor: both claim shapes, empty/missing fail closed, non-string array entries skipped, dotted paths, Zitadel URN paths. - 5 axum middleware tests via `tower::ServiceExt::oneshot`: no session → unauthenticated redirect, role-less session → 403 with expected role name + logout link, fleet-admin session → handler reached, HTML (not JSON) content-type on 403. Defers to follow-ups: a `fleet-viewer` read-only role; unifying the dashboard role policy with the NATS callout's per-device scoping. --- Cargo.lock | 3 + fleet/harmony-fleet-operator/Cargo.toml | 8 + .../src/frontend/server.rs | 274 +++++++++++++++++- harmony_zitadel_auth/src/config.rs | 12 + harmony_zitadel_auth/src/jwks.rs | 12 + harmony_zitadel_auth/src/lib.rs | 7 +- harmony_zitadel_auth/src/roles.rs | 190 ++++++++++++ harmony_zitadel_auth/src/session.rs | 10 + nats/callout/Cargo.toml | 1 + nats/callout/src/zitadel.rs | 18 +- 10 files changed, 518 insertions(+), 17 deletions(-) create mode 100644 harmony_zitadel_auth/src/roles.rs diff --git a/Cargo.lock b/Cargo.lock index b27c5d2b..70491a18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4155,6 +4155,7 @@ dependencies = [ "harmony-fleet-auth", "harmony-reconciler-contracts", "harmony_zitadel_auth", + "http-body-util", "k8s-openapi", "kube", "maud", @@ -4166,6 +4167,7 @@ dependencies = [ "tokio", "tokio-stream", "toml", + "tower", "tracing", "tracing-subscriber", "url", @@ -4198,6 +4200,7 @@ dependencies = [ "async-nats", "futures-util", "harmony-reconciler-contracts", + "harmony_zitadel_auth", "jsonwebtoken", "nats-jwt", "nkeys", diff --git a/fleet/harmony-fleet-operator/Cargo.toml b/fleet/harmony-fleet-operator/Cargo.toml index f272a067..00628a4b 100644 --- a/fleet/harmony-fleet-operator/Cargo.toml +++ b/fleet/harmony-fleet-operator/Cargo.toml @@ -43,3 +43,11 @@ axum-extra = { version = "0.10", features = ["cookie", "cookie-private"], option maud = { version = "0.27", features = ["axum"], optional = true } tokio-stream = { version = "0.1", optional = true } dotenvy = "0.15" + +[dev-dependencies] +# `oneshot` lets the middleware tests drive a `Router` end-to-end +# without binding a TCP port — the only way to exercise the +# require_auth/require_fleet_admin composition as the production +# stack actually layers them. +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" diff --git a/fleet/harmony-fleet-operator/src/frontend/server.rs b/fleet/harmony-fleet-operator/src/frontend/server.rs index 07ec872c..5f1461bc 100644 --- a/fleet/harmony-fleet-operator/src/frontend/server.rs +++ b/fleet/harmony-fleet-operator/src/frontend/server.rs @@ -28,7 +28,7 @@ use super::views::{ }; use crate::frontend::auth::{self, DASHBOARD_SESSION_COOKIE, DashboardSession, JwksCache}; use crate::service::FleetService; -use harmony_zitadel_auth::ZitadelAuthConfig; +use harmony_zitadel_auth::{DEFAULT_ADMIN_ROLE, ZitadelAuthConfig}; pub const DEFAULT_PORT: u16 = 18080; @@ -90,6 +90,14 @@ pub fn router(state: AppState) -> Router { let public_routes = Router::new() .route("/login", get(auth::login_handler)) .route("/auth/callback", get(auth::callback_handler)) + // `/logout` lives here, not in `private_routes`, so a logged-in + // user without `fleet-admin` can still switch accounts. The + // handler is idempotent — it clears the session cookie and + // redirects to Zitadel's end-session endpoint regardless of + // whether a session existed — so making it public costs us + // nothing and avoids a 403 trap where the forbidden page + // links to a route the role gate would itself reject. + .route("/logout", get(auth::logout_handler)) .route("/static/tailwind.css", get(tailwind_css)) .route("/static/htmx.min.js", get(htmx_js)) .route("/static/htmx-ext-sse.js", get(htmx_sse_js)) @@ -115,9 +123,12 @@ pub fn router(state: AppState) -> Router { // Settings .route("/settings", get(settings_handler)) .route("/settings/toggle/{key}", post(settings_toggle_handler)) - // Logout - .route("/logout", get(auth::logout_handler)) .route_layer(middleware::from_fn_with_state(state.clone(), csrf_protect)) + // The role gate (v0.3 Ch.1) runs **after** authentication: it + // reads the `DashboardSession` extension that `require_auth` + // inserts. `route_layer`'s execution order is bottom-up, so + // `require_auth` is listed below to ensure it runs first. + .route_layer(middleware::from_fn(require_fleet_admin)) .route_layer(middleware::from_fn_with_state(state.clone(), require_auth)); let mut r = public_routes.merge(private_routes); @@ -156,6 +167,91 @@ async fn require_auth( } } +/// Require the dashboard's `fleet-admin` role on the verified session. +/// +/// v0.3 Chapter 1: before this gate, any authenticated Zitadel user +/// reached every dashboard handler — the JWT signature/issuer/audience +/// check was the only authorization. Operators routinely give non-admin +/// users an account in Zitadel for SSO into adjacent apps, so the JWT +/// being valid is **not** sufficient evidence the user should manage a +/// fleet. This middleware closes that gap. +/// +/// Composition: layered **above** `require_auth` so the +/// `DashboardSession` extension is guaranteed present. A missing +/// extension is a programming error (route mounted without auth) and +/// fails closed with the same forbidden response — never wildcard-grant. +async fn require_fleet_admin(req: Request, next: Next) -> Response { + require_role(DEFAULT_ADMIN_ROLE, req, next).await +} + +/// Reusable role-gating middleware body. +/// +/// Kept as a plain async fn taking the role-name parameter so the same +/// implementation can back additional roles in the future (e.g. a +/// `fleet-viewer` read-only gate) without duplicating the closed-door +/// branches below. +async fn require_role(role: &'static str, req: Request, next: Next) -> Response { + let Some(session) = req.extensions().get::() else { + // No session = require_auth wasn't layered above us. Fail + // closed: serving the request would skip authentication + // entirely. The 401 path (login redirect) is correct because + // the user genuinely has no session. + tracing::error!( + "require_role invoked without an authenticated session — \ + middleware composition bug; failing closed" + ); + return unauthenticated_response(&req); + }; + + if session.roles.iter().any(|r| r == role) { + return next.run(req).await; + } + + tracing::warn!( + subject = %session.subject, + required_role = %role, + roles = ?session.roles, + "user lacks required role for dashboard", + ); + forbidden_response(role) +} + +/// Render a human-facing 403 page for a missing role. +/// +/// Not JSON: the dashboard is human-facing, so a logged-in user who +/// lands here gets a page they can read and act on (ask their admin +/// for the role) rather than a raw error code. +fn forbidden_response(required_role: &str) -> Response { + let body = maud::html! { + (maud::DOCTYPE) + html lang="en" { + head { + meta charset="utf-8"; + meta name="viewport" content="width=device-width, initial-scale=1"; + title { "Access denied — Harmony Fleet" } + link rel="stylesheet" href="/static/tailwind.css"; + } + body class="min-h-screen flex items-center justify-center" + style="background:var(--bg); color:#e2e8f0; font-family:'Inter',sans-serif" { + main class="max-w-md w-full px-6 py-10 rounded-lg border" + style="border-color:var(--border); background:rgba(148,163,184,0.04)" { + h1 class="text-xl font-semibold text-slate-100 mb-2" { "Access denied" } + p class="text-sm text-slate-300 mb-4" { + "The " + code class="font-mono text-slate-200" { (required_role) } + " role is required to use this dashboard. Ask your administrator to grant it on your Zitadel account." + } + p class="text-sm" { + a href="/logout" class="text-cyan-400 hover:text-cyan-300" { "Sign out" } + } + } + } + } + }; + + (StatusCode::FORBIDDEN, body).into_response() +} + async fn csrf_protect(State(state): State, req: Request, next: Next) -> Response { if !is_mutating_method(req.method()) { return next.run(req).await; @@ -723,3 +819,175 @@ impl IntoResponse for AppError { (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", self.0)).into_response() } } + +#[cfg(test)] +mod role_middleware_tests { + //! Behaviour tests for the v0.3 Ch.1 dashboard role gate. + //! + //! These drive an in-memory `Router` through `tower::ServiceExt::oneshot` + //! so we exercise the *composition* of `require_auth` and + //! `require_fleet_admin` the same way it's mounted in production — + //! mocking just the `DashboardSession` extension rather than + //! standing up Zitadel. + + use super::*; + use axum::Router; + use axum::body::Body; + use axum::http::Request; + use axum::middleware; + use axum::routing::get; + use http_body_util::BodyExt; + use tower::ServiceExt; + + async fn ok_handler() -> &'static str { + "OK" + } + + /// Inject a pre-built session — production code only ever has + /// `require_auth` insert this, so tests do the same by hand. + async fn inject_session( + session: DashboardSession, + mut req: Request, + next: Next, + ) -> Response { + req.extensions_mut().insert(session); + next.run(req).await + } + + fn router_with_session(session: Option) -> Router { + let injector_layer = move |req: Request, next: Next| { + let session = session.clone(); + async move { + if let Some(s) = session { + inject_session(s, req, next).await + } else { + next.run(req).await + } + } + }; + + Router::new() + .route("/", get(ok_handler)) + .route_layer(middleware::from_fn(require_fleet_admin)) + .route_layer(middleware::from_fn(injector_layer)) + } + + fn session_with_roles(roles: Vec) -> DashboardSession { + DashboardSession { + subject: "u-1".to_string(), + email: Some("u@example.com".to_string()), + name: Some("Tester".to_string()), + expires_at: 0, + nonce: None, + roles, + } + } + + async fn read_body(resp: Response) -> String { + let bytes = resp + .into_body() + .collect() + .await + .expect("collect body") + .to_bytes(); + String::from_utf8(bytes.to_vec()).expect("utf-8 body") + } + + #[tokio::test] + async fn request_without_session_is_unauthenticated() { + // No DashboardSession in extensions → fail closed via the + // same path require_auth uses when the cookie is missing. + let app = router_with_session(None); + let resp = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .expect("middleware ran"); + // unauthenticated_response → 303 redirect to /login for HTML clients. + assert!( + matches!( + resp.status(), + StatusCode::SEE_OTHER + | StatusCode::TEMPORARY_REDIRECT + | StatusCode::UNAUTHORIZED + | StatusCode::FOUND + ), + "expected redirect or 401, got {:?}", + resp.status() + ); + } + + #[tokio::test] + async fn session_without_fleet_admin_is_forbidden() { + // The exact gap v0.3 Ch.1 closes: a valid Zitadel JWT with no + // fleet-admin role must NOT reach dashboard handlers. + let app = router_with_session(Some(session_with_roles(vec!["device".to_string()]))); + let resp = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .expect("middleware ran"); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + + let body = read_body(resp).await; + // Body must name the missing role so the user can ask for it, + // and link to /logout so they can switch accounts. + assert!(body.contains(DEFAULT_ADMIN_ROLE)); + assert!(body.contains("/logout")); + } + + #[tokio::test] + async fn session_with_fleet_admin_reaches_handler() { + let app = router_with_session(Some(session_with_roles(vec![ + "device".to_string(), + DEFAULT_ADMIN_ROLE.to_string(), + ]))); + let resp = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .expect("middleware ran"); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(read_body(resp).await, "OK"); + } + + #[tokio::test] + async fn session_with_only_fleet_admin_role_is_allowed() { + // Negative-control sibling of the device-only test: confirm a + // user with the admin role and nothing else still passes. + let app = router_with_session(Some(session_with_roles(vec![ + DEFAULT_ADMIN_ROLE.to_string(), + ]))); + let resp = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .expect("middleware ran"); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn forbidden_response_is_html_not_json() { + // Dashboard is human-facing — a 403 must render as HTML the + // user can read, not a JSON `{error:"..."}` envelope. + let app = router_with_session(Some(session_with_roles(vec![]))); + let resp = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .expect("middleware ran"); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + + let content_type = resp + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_string(); + assert!( + content_type.contains("text/html"), + "expected HTML content-type, got {content_type:?}" + ); + + let body = read_body(resp).await; + assert!( + body.contains(", pub logout_redirect_uri: String, + /// JSON-claim path used to read the user's roles off the Zitadel JWT. + /// + /// Defaults to [`crate::roles::DEFAULT_ROLES_CLAIM`] when the env var + /// is unset. Configurable so operators using a custom Zitadel mapper + /// (or a different IdP) can point at their own claim without a + /// recompile. + pub roles_claim: String, } impl ZitadelAuthConfig { @@ -40,6 +47,9 @@ pub const SCOPE_ENV: &str = "FLEET_AUTH_SCOPE"; pub const TRUSTED_AUDIENCES_ENV: &str = "FLEET_AUTH_TRUSTED_AUDIENCES"; pub const LOGOUT_REDIRECT_URI_ENV: &str = "FLEET_AUTH_LOGOUT_REDIRECT_URI"; pub const COOKIE_KEY_ENV: &str = "FLEET_OPERATOR_COOKIE_KEY_B64"; +/// Env var that overrides the JSON-claim path used to read roles from the +/// Zitadel JWT. Unset → [`crate::roles::DEFAULT_ROLES_CLAIM`]. +pub const ROLES_CLAIM_ENV: &str = "FLEET_AUTH_ROLES_CLAIM"; pub fn config_from_env() -> ZitadelAuthConfig { ZitadelAuthConfig { @@ -52,6 +62,8 @@ pub fn config_from_env() -> ZitadelAuthConfig { .map(str::to_string) .collect(), logout_redirect_uri: required_env(LOGOUT_REDIRECT_URI_ENV), + roles_claim: std::env::var(ROLES_CLAIM_ENV) + .unwrap_or_else(|_| crate::roles::DEFAULT_ROLES_CLAIM.to_string()), } } diff --git a/harmony_zitadel_auth/src/jwks.rs b/harmony_zitadel_auth/src/jwks.rs index ed8947cf..7fe0e359 100644 --- a/harmony_zitadel_auth/src/jwks.rs +++ b/harmony_zitadel_auth/src/jwks.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -5,6 +6,7 @@ use anyhow::Result; use serde::Deserialize; use crate::config::ZitadelAuthConfig; +use crate::roles::extract_roles; use crate::session::VerifiedSession; struct JwksCacheInner { @@ -158,6 +160,10 @@ fn verify_with_jwk( validation.set_audience(&config.trusted_audiences); validation.set_issuer(&[&config.zitadel_base]); + // We capture extra claims as a `HashMap` so the configured + // `roles_claim` can be looked up by path without a second decode + // pass — the roles path is operator-configurable, so we cannot + // bake it into a typed `serde` field. #[derive(Deserialize)] struct Claims { sub: String, @@ -165,18 +171,24 @@ fn verify_with_jwk( email: Option, name: Option, nonce: Option, + #[serde(flatten)] + extra: HashMap, } let claims = decode::(token, &decoding_key, &validation) .map_err(|e| anyhow::anyhow!("JWT verification failed: {e}"))? .claims; + let claims_value = serde_json::Value::Object(claims.extra.into_iter().collect()); + let roles = extract_roles(&claims_value, &config.roles_claim); + Ok(VerifiedSession { subject: claims.sub, email: claims.email, name: claims.name, expires_at: claims.exp, nonce: claims.nonce, + roles, }) } diff --git a/harmony_zitadel_auth/src/lib.rs b/harmony_zitadel_auth/src/lib.rs index f5cd0e78..70c866da 100644 --- a/harmony_zitadel_auth/src/lib.rs +++ b/harmony_zitadel_auth/src/lib.rs @@ -3,13 +3,14 @@ pub mod axum_login_flow; pub mod config; pub mod jwks; pub mod login; +pub mod roles; pub mod session; #[cfg(feature = "axum")] pub use config::cookie_key_from_env; pub use config::{ - BASE_URL_ENV, CLIENT_ID_ENV, COOKIE_KEY_ENV, LOGOUT_REDIRECT_URI_ENV, SCOPE_ENV, - TRUSTED_AUDIENCES_ENV, ZITADEL_BASE_ENV, ZitadelAuthConfig, config_from_env, + BASE_URL_ENV, CLIENT_ID_ENV, COOKIE_KEY_ENV, LOGOUT_REDIRECT_URI_ENV, ROLES_CLAIM_ENV, + SCOPE_ENV, TRUSTED_AUDIENCES_ENV, ZITADEL_BASE_ENV, ZitadelAuthConfig, config_from_env, }; pub use jwks::JwksCache; @@ -20,4 +21,6 @@ pub use login::{ validate_callback_state, validate_id_token, }; +pub use roles::{DEFAULT_ADMIN_ROLE, DEFAULT_ROLES_CLAIM, extract_roles}; + pub use session::{LoginAttemptCookie, VerifiedSession}; diff --git a/harmony_zitadel_auth/src/roles.rs b/harmony_zitadel_auth/src/roles.rs new file mode 100644 index 00000000..d6371ab8 --- /dev/null +++ b/harmony_zitadel_auth/src/roles.rs @@ -0,0 +1,190 @@ +//! Shared role-extraction helpers for Zitadel JWTs. +//! +//! Why this module exists: two crates need *exactly* the same logic for +//! pulling role names off a Zitadel JWT — the NATS auth callout +//! (`harmony-nats-callout`) for per-device permission scoping, and the +//! fleet operator's web dashboard for the `fleet-admin` gate added in +//! v0.3 Chapter 1. Forking the logic would mean two places to keep in +//! sync every time Zitadel introduces a new role-mapping quirk. +//! +//! The dashboard side previously verified JWT validity only and granted +//! full access to any logged-in Zitadel user — a security gap. The +//! callout side already had the correct, dual-shape extraction logic +//! battle-tested in production. This module is the lift of that logic +//! into shared territory so the dashboard can reuse it verbatim. +//! +//! The helpers here only **extract** role names. Mapping role names to +//! authorization decisions (admin-wins privilege escalation, etc.) is a +//! callout-specific concern and stays in `nats/callout/src/roles.rs`. + +/// Default JSON-claim path that Zitadel uses for project roles. +/// +/// Zitadel emits this claim as an object map `{role-name: {org-id: +/// org-name}}`. The standard OIDC convention is a string array. This +/// module's [`extract_roles`] accepts both. +pub const DEFAULT_ROLES_CLAIM: &str = "urn:zitadel:iam:org:project:roles"; + +/// Default role name granting unrestricted dashboard / NATS access. +/// +/// Kept here (not in callout-only code) so the dashboard middleware can +/// reference the same constant and operators only have to remember one +/// role-name spelling across the platform. +pub const DEFAULT_ADMIN_ROLE: &str = "fleet-admin"; + +/// Extract role names from `claims` at the given JSON path. +/// +/// Accepts both shapes that OIDC providers emit: +/// - **Array of strings**: `["fleet-admin", "device"]` — the OIDC-standard +/// shape, used by custom mappers and most non-Zitadel issuers. +/// - **Zitadel object-map**: `{"fleet-admin": {"": ""}, ...}` +/// — Zitadel's default `urn:zitadel:iam:org:project:roles` shape, where +/// the role names are the *keys* and the values describe which +/// organization granted the role. +/// +/// Returns an empty vec if the claim is missing or has neither shape; +/// callers MUST treat an empty vec as "no roles" rather than defaulting +/// to admin. +/// +/// The path lookup follows the same convention as the callout: any path +/// containing `urn:` is treated as a flat key (Zitadel's roles claim has +/// dots inside it), otherwise dots are interpreted as nested-object +/// navigation (`foo.bar.baz`). +pub fn extract_roles(claims: &serde_json::Value, roles_claim: &str) -> Vec { + let Some(value) = lookup_claim(claims, roles_claim) else { + return Vec::new(); + }; + + match value { + serde_json::Value::Array(items) => items + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(), + serde_json::Value::Object(map) => map.keys().cloned().collect(), + _ => Vec::new(), + } +} + +/// Resolve a claim path against `root`. +/// +/// Paths containing `urn:` are treated as flat keys (Zitadel's role +/// claim is the canonical example: it contains dots that must NOT be +/// split). All other paths are dotted (`foo.bar.baz` → +/// `root["foo"]["bar"]["baz"]`). +fn lookup_claim<'a>(root: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> { + let parts: Vec<&str> = if path.contains('.') && !path.contains("urn:") { + path.split('.').collect() + } else { + vec![path] + }; + + let mut current = root; + for part in &parts { + current = current.get(part)?; + } + Some(current) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn fleet_admin_role_from_array_claim_resolves() { + // OIDC-standard shape (and what Zitadel emits if you configure + // a custom mapper that flattens roles). + let claims = json!({ + "sub": "u-1", + "roles": ["fleet-admin", "device"], + }); + let roles = extract_roles(&claims, "roles"); + assert!(roles.contains(&"fleet-admin".to_string())); + } + + #[test] + fn fleet_admin_role_from_zitadel_object_map_resolves() { + // Default Zitadel shape: role names are the *keys* of an object + // whose values describe the granting organization. + let claims = json!({ + "sub": "u-1", + "urn:zitadel:iam:org:project:roles": { + "fleet-admin": { "org-a": "Org A" } + }, + }); + let roles = extract_roles(&claims, "urn:zitadel:iam:org:project:roles"); + assert!(roles.contains(&"fleet-admin".to_string())); + } + + #[test] + fn missing_roles_claim_yields_empty_vec() { + // Without this guarantee callers might mistake a parse failure + // for "no roles" and lock everyone out — or worse, treat the + // None-ness as a wildcard and let everyone in. Empty vec is the + // explicit closed-door default. + let claims = json!({ "sub": "u-1" }); + let roles = extract_roles(&claims, "roles"); + assert!(roles.is_empty()); + } + + #[test] + fn only_device_role_present_does_not_carry_fleet_admin() { + // The fleet-admin middleware MUST NOT grant access to a user + // who only has the device role — this is the core authorization + // gap Chapter 1 closes. + let claims = json!({ + "sub": "u-1", + "urn:zitadel:iam:org:project:roles": { + "device": { "org-a": "Org A" } + }, + }); + let roles = extract_roles(&claims, "urn:zitadel:iam:org:project:roles"); + assert_eq!(roles, vec!["device"]); + assert!(!roles.contains(&"fleet-admin".to_string())); + } + + #[test] + fn array_with_non_string_entries_is_filtered() { + // A misbehaving issuer that puts `null` or numbers into the + // role array must not crash the extractor — just skip them. + let claims = json!({ + "roles": ["fleet-admin", 42, null, true, "device"], + }); + let roles = extract_roles(&claims, "roles"); + assert_eq!(roles, vec!["fleet-admin", "device"]); + } + + #[test] + fn primitive_at_claim_path_yields_empty_vec() { + // If the path resolves to a scalar (string, number, bool) we + // refuse to guess — empty vec, closed door. + let claims = json!({ "roles": "fleet-admin" }); + assert!(extract_roles(&claims, "roles").is_empty()); + } + + #[test] + fn empty_array_and_empty_map_both_yield_empty_vec() { + let array = json!({ "roles": [] }); + assert!(extract_roles(&array, "roles").is_empty()); + let object = json!({ "roles": {} }); + assert!(extract_roles(&object, "roles").is_empty()); + } + + #[test] + fn dotted_paths_navigate_nested_objects() { + let claims = json!({ + "app": { "fleet": { "roles": ["fleet-admin"] } } + }); + let roles = extract_roles(&claims, "app.fleet.roles"); + assert_eq!(roles, vec!["fleet-admin"]); + } + + #[test] + fn urn_paths_are_treated_as_flat_keys() { + // Without the urn: opt-out, `urn:zitadel:iam:org:project:roles` + // would be safe (no dots) but `urn:zitadel.iam` would be split + // on dot — Zitadel does emit dotted URNs in some configurations. + let claims = json!({ "urn:zitadel.iam": { "fleet-admin": {} } }); + let roles = extract_roles(&claims, "urn:zitadel.iam"); + assert_eq!(roles, vec!["fleet-admin"]); + } +} diff --git a/harmony_zitadel_auth/src/session.rs b/harmony_zitadel_auth/src/session.rs index 8e5f73c3..b0e07ea7 100644 --- a/harmony_zitadel_auth/src/session.rs +++ b/harmony_zitadel_auth/src/session.rs @@ -1,6 +1,12 @@ use serde::{Deserialize, Serialize}; /// Claims extracted from a verified session cookie JWT on each request. +/// +/// `roles` is captured here (rather than re-extracted on demand) so the +/// role-enforcement middleware introduced in v0.3 Chapter 1 doesn't have +/// to re-decode the JWT body. The vec is the post-extraction list — same +/// shape regardless of whether the underlying issuer emitted an array +/// or Zitadel's role-name-keyed object map. #[derive(Debug, Clone)] pub struct VerifiedSession { pub subject: String, @@ -9,6 +15,10 @@ pub struct VerifiedSession { pub expires_at: i64, /// OIDC nonce from the ID token, used to bind callback tokens to login attempts. pub nonce: Option, + /// Role names extracted from the configured `roles_claim`. Empty + /// when the claim is absent or has neither the array nor object-map + /// shape — callers MUST treat empty as "no roles", not as a wildcard. + pub roles: Vec, } /// PKCE state persisted in the encrypted login-attempt cookie during the diff --git a/nats/callout/Cargo.toml b/nats/callout/Cargo.toml index 83fb84ee..e8c2bf3e 100644 --- a/nats/callout/Cargo.toml +++ b/nats/callout/Cargo.toml @@ -17,6 +17,7 @@ path = "src/main.rs" [dependencies] nats-jwt = { path = "../jwt" } +harmony_zitadel_auth = { path = "../../harmony_zitadel_auth" } async-nats.workspace = true nkeys = "0.4" jsonwebtoken = "9" diff --git a/nats/callout/src/zitadel.rs b/nats/callout/src/zitadel.rs index 447de76b..600fb566 100644 --- a/nats/callout/src/zitadel.rs +++ b/nats/callout/src/zitadel.rs @@ -194,6 +194,11 @@ impl ZitadelValidator { /// Extract role names from `claims` at the given JSON path. /// + /// Delegates to [`harmony_zitadel_auth::extract_roles`] so the + /// dashboard's role-enforcement middleware and this callout share + /// **one** implementation. Any future quirk in how Zitadel emits + /// roles is fixed once in the shared crate. + /// /// Accepts both shapes that OIDC providers emit: /// - **Array of strings**: `["fleet-admin", "device"]` (common with custom mappers) /// - **Object map**: `{"fleet-admin": {"": ""}, ...}` (Zitadel's default @@ -202,18 +207,7 @@ impl ZitadelValidator { /// Returns an empty vec if the claim is missing or has neither shape. pub fn extract_roles(&self, claims: &ZitadelClaims, roles_claim: &str) -> Vec { let root = claims_to_value(claims); - let Some(value) = lookup_claim(&root, roles_claim) else { - return Vec::new(); - }; - - match value { - serde_json::Value::Array(items) => items - .iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect(), - serde_json::Value::Object(map) => map.keys().cloned().collect(), - _ => Vec::new(), - } + harmony_zitadel_auth::extract_roles(&root, roles_claim) } pub fn start_refresh_task(&self, interval: Duration) { -- 2.39.5 From e9522464af0bf3c4c6882592f5b3f2965b4ca0b2 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 25 May 2026 06:45:34 -0400 Subject: [PATCH 2/2] refactor(zitadel-auth): typed Role/Roles, drop string-path role lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the brittle pieces that landed in the original role-enforcement commit: `extract_roles(value, path)` walking `serde_json::Value` with a `path.contains("urn:")`-aware splitter, an env-driven `FLEET_AUTH_ROLES_CLAIM`, and `VerifiedSession.roles: Vec` compared with `iter().any(|r| r == "fleet-admin")` in middleware. This is a security-relevant code path; runtime string heuristics inside it hide injection-shaped bugs (a misconfigured env or a custom Zitadel mapper shifts the lookup to a path the attacker controls). Replaced with end-to-end typed serde decoding: * `Role` — closed enum (one variant today: `FleetAdmin`). Adding a variant is a deliberate code change to the security path. Unknown role names emitted by the IdP cannot be represented and therefore cannot grant access. * `RoleClaims` — wire-side struct, flattened into the JWT `Claims`. The two well-known role-claim locations are matched verbatim by `#[serde(rename = "urn:zitadel:iam:org:project:roles")]` and `#[serde(rename = "roles")]`. No dotted-path navigation. No env string. If a future issuer adds a third location it is an additive `#[serde(rename = ...)]` field inside the security boundary. * `Roles` — domain value on `VerifiedSession`. Construction is restricted to `RoleClaims::into_roles` plus a typed `FromIterator` for test fixtures. `Roles::has(Role::FleetAdmin)` is the only check the middleware needs; no string comparison exists anywhere downstream. * Malformed shapes (scalar at the URN path, mixed-type array) now ERROR at decode rather than degrading to an empty-vec "closed door". Fail-loud is the security-correct default when the IdP misbehaves — the user re-logs in, the operator notices. Callout side: reverted the shared-extract_roles delegation. The callout retains its own, unchanged role-extraction logic. We do NOT need cross-crate sharing here, and the shared extractor was the entry point we were trying to delete — the callout's own behaviour was the status quo and is preserved verbatim. Dropped exports: `DEFAULT_ADMIN_ROLE`, `DEFAULT_ROLES_CLAIM`, `extract_roles`, `ROLES_CLAIM_ENV`, `ZitadelAuthConfig.roles_claim`. None had external consumers in the workspace. Tests: * 12 tests on `RoleClaims` deserialization: both shapes resolve, Zitadel URN wins precedence, unknown roles dropped, malformed scalar/mixed-type arrays error at decode, missing claim → empty, empty object/array → empty, extra unrelated claims are ignored, display matches wire spelling. * 4 middleware tests on the typed `require_role(Role::FleetAdmin, …)` path. Dropped the redundant "admin-only vs admin+other" test — with a single-variant enum it duplicated the positive case. --- Cargo.lock | 1 - .../src/frontend/server.rs | 75 ++- harmony_zitadel_auth/src/config.rs | 12 - harmony_zitadel_auth/src/jwks.rs | 20 +- harmony_zitadel_auth/src/lib.rs | 6 +- harmony_zitadel_auth/src/roles.rs | 443 ++++++++++++------ harmony_zitadel_auth/src/session.rs | 21 +- nats/callout/Cargo.toml | 1 - nats/callout/src/zitadel.rs | 18 +- 9 files changed, 363 insertions(+), 234 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 70491a18..cda03257 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4200,7 +4200,6 @@ dependencies = [ "async-nats", "futures-util", "harmony-reconciler-contracts", - "harmony_zitadel_auth", "jsonwebtoken", "nats-jwt", "nkeys", diff --git a/fleet/harmony-fleet-operator/src/frontend/server.rs b/fleet/harmony-fleet-operator/src/frontend/server.rs index 5f1461bc..2b181e41 100644 --- a/fleet/harmony-fleet-operator/src/frontend/server.rs +++ b/fleet/harmony-fleet-operator/src/frontend/server.rs @@ -28,7 +28,7 @@ use super::views::{ }; use crate::frontend::auth::{self, DASHBOARD_SESSION_COOKIE, DashboardSession, JwksCache}; use crate::service::FleetService; -use harmony_zitadel_auth::{DEFAULT_ADMIN_ROLE, ZitadelAuthConfig}; +use harmony_zitadel_auth::{Role, ZitadelAuthConfig}; pub const DEFAULT_PORT: u16 = 18080; @@ -181,16 +181,17 @@ async fn require_auth( /// extension is a programming error (route mounted without auth) and /// fails closed with the same forbidden response — never wildcard-grant. async fn require_fleet_admin(req: Request, next: Next) -> Response { - require_role(DEFAULT_ADMIN_ROLE, req, next).await + require_role(Role::FleetAdmin, req, next).await } /// Reusable role-gating middleware body. /// -/// Kept as a plain async fn taking the role-name parameter so the same -/// implementation can back additional roles in the future (e.g. a -/// `fleet-viewer` read-only gate) without duplicating the closed-door -/// branches below. -async fn require_role(role: &'static str, req: Request, next: Next) -> Response { +/// Takes a typed [`Role`] — not a string — so adding a future role +/// gate (e.g. a `fleet-viewer` read-only zone) is a new variant + +/// new wrapper fn, and the compiler refuses every string-based +/// "role" comparison that previously hid bugs (case mismatch, typo, +/// missing claim treated as wildcard). +async fn require_role(role: Role, req: Request, next: Next) -> Response { let Some(session) = req.extensions().get::() else { // No session = require_auth wasn't layered above us. Fail // closed: serving the request would skip authentication @@ -203,14 +204,14 @@ async fn require_role(role: &'static str, req: Request, next: Next) -> Res return unauthenticated_response(&req); }; - if session.roles.iter().any(|r| r == role) { + if session.roles.has(role) { return next.run(req).await; } tracing::warn!( subject = %session.subject, required_role = %role, - roles = ?session.roles, + granted_roles = ?session.roles.iter().collect::>(), "user lacks required role for dashboard", ); forbidden_response(role) @@ -221,7 +222,8 @@ async fn require_role(role: &'static str, req: Request, next: Next) -> Res /// Not JSON: the dashboard is human-facing, so a logged-in user who /// lands here gets a page they can read and act on (ask their admin /// for the role) rather than a raw error code. -fn forbidden_response(required_role: &str) -> Response { +fn forbidden_response(required_role: Role) -> Response { + let role_name = required_role.name(); let body = maud::html! { (maud::DOCTYPE) html lang="en" { @@ -238,7 +240,7 @@ fn forbidden_response(required_role: &str) -> Response { h1 class="text-xl font-semibold text-slate-100 mb-2" { "Access denied" } p class="text-sm text-slate-300 mb-4" { "The " - code class="font-mono text-slate-200" { (required_role) } + code class="font-mono text-slate-200" { (role_name) } " role is required to use this dashboard. Ask your administrator to grant it on your Zitadel account." } p class="text-sm" { @@ -836,6 +838,7 @@ mod role_middleware_tests { use axum::http::Request; use axum::middleware; use axum::routing::get; + use harmony_zitadel_auth::Roles; use http_body_util::BodyExt; use tower::ServiceExt; @@ -872,14 +875,23 @@ mod role_middleware_tests { .route_layer(middleware::from_fn(injector_layer)) } - fn session_with_roles(roles: Vec) -> DashboardSession { + /// Build a fixture session. Takes typed [`Role`] values — there is + /// no string-keyed path here, by design. A test that wants to + /// exercise an "unknown" role just passes an empty slice; the + /// only way to grant `fleet-admin` in a test is to mention + /// `Role::FleetAdmin` explicitly, which is exactly the + /// security-correct property we want production code to enforce. + fn session_with_roles(roles: I) -> DashboardSession + where + I: IntoIterator, + { DashboardSession { subject: "u-1".to_string(), email: Some("u@example.com".to_string()), name: Some("Tester".to_string()), expires_at: 0, nonce: None, - roles, + roles: roles.into_iter().collect::(), } } @@ -918,9 +930,13 @@ mod role_middleware_tests { #[tokio::test] async fn session_without_fleet_admin_is_forbidden() { - // The exact gap v0.3 Ch.1 closes: a valid Zitadel JWT with no - // fleet-admin role must NOT reach dashboard handlers. - let app = router_with_session(Some(session_with_roles(vec!["device".to_string()]))); + // The exact gap v0.3 Ch.1 closes: a valid Zitadel JWT with + // no fleet-admin role must NOT reach dashboard handlers. We + // can't even *spell* "device" as a Role variant from the + // test — the typed API forces "anything not Role::FleetAdmin" + // to round-trip through the empty roles set, which is the + // exact production behaviour for an unrecognized role name. + let app = router_with_session(Some(session_with_roles([]))); let resp = app .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) .await @@ -928,18 +944,15 @@ mod role_middleware_tests { assert_eq!(resp.status(), StatusCode::FORBIDDEN); let body = read_body(resp).await; - // Body must name the missing role so the user can ask for it, - // and link to /logout so they can switch accounts. - assert!(body.contains(DEFAULT_ADMIN_ROLE)); + // Body must name the missing role so the user can ask for + // it, and link to /logout so they can switch accounts. + assert!(body.contains(Role::FleetAdmin.name())); assert!(body.contains("/logout")); } #[tokio::test] async fn session_with_fleet_admin_reaches_handler() { - let app = router_with_session(Some(session_with_roles(vec![ - "device".to_string(), - DEFAULT_ADMIN_ROLE.to_string(), - ]))); + let app = router_with_session(Some(session_with_roles([Role::FleetAdmin]))); let resp = app .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) .await @@ -948,25 +961,11 @@ mod role_middleware_tests { assert_eq!(read_body(resp).await, "OK"); } - #[tokio::test] - async fn session_with_only_fleet_admin_role_is_allowed() { - // Negative-control sibling of the device-only test: confirm a - // user with the admin role and nothing else still passes. - let app = router_with_session(Some(session_with_roles(vec![ - DEFAULT_ADMIN_ROLE.to_string(), - ]))); - let resp = app - .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) - .await - .expect("middleware ran"); - assert_eq!(resp.status(), StatusCode::OK); - } - #[tokio::test] async fn forbidden_response_is_html_not_json() { // Dashboard is human-facing — a 403 must render as HTML the // user can read, not a JSON `{error:"..."}` envelope. - let app = router_with_session(Some(session_with_roles(vec![]))); + let app = router_with_session(Some(session_with_roles([]))); let resp = app .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) .await diff --git a/harmony_zitadel_auth/src/config.rs b/harmony_zitadel_auth/src/config.rs index 9973da2f..52019426 100644 --- a/harmony_zitadel_auth/src/config.rs +++ b/harmony_zitadel_auth/src/config.rs @@ -6,13 +6,6 @@ pub struct ZitadelAuthConfig { pub scope: String, pub trusted_audiences: Vec, pub logout_redirect_uri: String, - /// JSON-claim path used to read the user's roles off the Zitadel JWT. - /// - /// Defaults to [`crate::roles::DEFAULT_ROLES_CLAIM`] when the env var - /// is unset. Configurable so operators using a custom Zitadel mapper - /// (or a different IdP) can point at their own claim without a - /// recompile. - pub roles_claim: String, } impl ZitadelAuthConfig { @@ -47,9 +40,6 @@ pub const SCOPE_ENV: &str = "FLEET_AUTH_SCOPE"; pub const TRUSTED_AUDIENCES_ENV: &str = "FLEET_AUTH_TRUSTED_AUDIENCES"; pub const LOGOUT_REDIRECT_URI_ENV: &str = "FLEET_AUTH_LOGOUT_REDIRECT_URI"; pub const COOKIE_KEY_ENV: &str = "FLEET_OPERATOR_COOKIE_KEY_B64"; -/// Env var that overrides the JSON-claim path used to read roles from the -/// Zitadel JWT. Unset → [`crate::roles::DEFAULT_ROLES_CLAIM`]. -pub const ROLES_CLAIM_ENV: &str = "FLEET_AUTH_ROLES_CLAIM"; pub fn config_from_env() -> ZitadelAuthConfig { ZitadelAuthConfig { @@ -62,8 +52,6 @@ pub fn config_from_env() -> ZitadelAuthConfig { .map(str::to_string) .collect(), logout_redirect_uri: required_env(LOGOUT_REDIRECT_URI_ENV), - roles_claim: std::env::var(ROLES_CLAIM_ENV) - .unwrap_or_else(|_| crate::roles::DEFAULT_ROLES_CLAIM.to_string()), } } diff --git a/harmony_zitadel_auth/src/jwks.rs b/harmony_zitadel_auth/src/jwks.rs index 7fe0e359..f186831f 100644 --- a/harmony_zitadel_auth/src/jwks.rs +++ b/harmony_zitadel_auth/src/jwks.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -6,7 +5,7 @@ use anyhow::Result; use serde::Deserialize; use crate::config::ZitadelAuthConfig; -use crate::roles::extract_roles; +use crate::roles::RoleClaims; use crate::session::VerifiedSession; struct JwksCacheInner { @@ -160,10 +159,12 @@ fn verify_with_jwk( validation.set_audience(&config.trusted_audiences); validation.set_issuer(&[&config.zitadel_base]); - // We capture extra claims as a `HashMap` so the configured - // `roles_claim` can be looked up by path without a second decode - // pass — the roles path is operator-configurable, so we cannot - // bake it into a typed `serde` field. + // Role claims are decoded **typed**, not via flatten-into-Value + // followed by a string-path lookup. `RoleClaims` resolves the two + // well-known role claim locations via exact `#[serde(rename)]` + // matches at decode time — there is no env-driven path string to + // poison, no dotted-path heuristic to fool. See + // [`crate::roles`] for the full rationale. #[derive(Deserialize)] struct Claims { sub: String, @@ -172,23 +173,20 @@ fn verify_with_jwk( name: Option, nonce: Option, #[serde(flatten)] - extra: HashMap, + roles: RoleClaims, } let claims = decode::(token, &decoding_key, &validation) .map_err(|e| anyhow::anyhow!("JWT verification failed: {e}"))? .claims; - let claims_value = serde_json::Value::Object(claims.extra.into_iter().collect()); - let roles = extract_roles(&claims_value, &config.roles_claim); - Ok(VerifiedSession { subject: claims.sub, email: claims.email, name: claims.name, expires_at: claims.exp, nonce: claims.nonce, - roles, + roles: claims.roles.into_roles(), }) } diff --git a/harmony_zitadel_auth/src/lib.rs b/harmony_zitadel_auth/src/lib.rs index 70c866da..481de474 100644 --- a/harmony_zitadel_auth/src/lib.rs +++ b/harmony_zitadel_auth/src/lib.rs @@ -9,8 +9,8 @@ pub mod session; #[cfg(feature = "axum")] pub use config::cookie_key_from_env; pub use config::{ - BASE_URL_ENV, CLIENT_ID_ENV, COOKIE_KEY_ENV, LOGOUT_REDIRECT_URI_ENV, ROLES_CLAIM_ENV, - SCOPE_ENV, TRUSTED_AUDIENCES_ENV, ZITADEL_BASE_ENV, ZitadelAuthConfig, config_from_env, + BASE_URL_ENV, CLIENT_ID_ENV, COOKIE_KEY_ENV, LOGOUT_REDIRECT_URI_ENV, SCOPE_ENV, + TRUSTED_AUDIENCES_ENV, ZITADEL_BASE_ENV, ZitadelAuthConfig, config_from_env, }; pub use jwks::JwksCache; @@ -21,6 +21,6 @@ pub use login::{ validate_callback_state, validate_id_token, }; -pub use roles::{DEFAULT_ADMIN_ROLE, DEFAULT_ROLES_CLAIM, extract_roles}; +pub use roles::{Role, RoleClaims, Roles}; pub use session::{LoginAttemptCookie, VerifiedSession}; diff --git a/harmony_zitadel_auth/src/roles.rs b/harmony_zitadel_auth/src/roles.rs index d6371ab8..091470a9 100644 --- a/harmony_zitadel_auth/src/roles.rs +++ b/harmony_zitadel_auth/src/roles.rs @@ -1,87 +1,171 @@ -//! Shared role-extraction helpers for Zitadel JWTs. +//! Typed role extraction for Zitadel / OIDC JWT bodies. //! -//! Why this module exists: two crates need *exactly* the same logic for -//! pulling role names off a Zitadel JWT — the NATS auth callout -//! (`harmony-nats-callout`) for per-device permission scoping, and the -//! fleet operator's web dashboard for the `fleet-admin` gate added in -//! v0.3 Chapter 1. Forking the logic would mean two places to keep in -//! sync every time Zitadel introduces a new role-mapping quirk. +//! Why this module is paranoid: the role claim is the *only* thing +//! that separates a non-admin Zitadel user from full dashboard +//! control. Earlier drafts walked `serde_json::Value` with a +//! configurable string path (`lookup_claim(value, "a.b.c")`) and a +//! `urn:`-aware splitter — that's a heuristic over untrusted input +//! inside a security boundary, the exact shape that hides +//! injection-style bugs (a misconfigured env var or a Zitadel mapper +//! quirk shifts the lookup to a path the attacker controls). //! -//! The dashboard side previously verified JWT validity only and granted -//! full access to any logged-in Zitadel user — a security gap. The -//! callout side already had the correct, dual-shape extraction logic -//! battle-tested in production. This module is the lift of that logic -//! into shared territory so the dashboard can reuse it verbatim. +//! This module replaces all of that with **typed serde +//! deserialization**: //! -//! The helpers here only **extract** role names. Mapping role names to -//! authorization decisions (admin-wins privilege escalation, etc.) is a -//! callout-specific concern and stays in `nats/callout/src/roles.rs`. +//! - [`Role`] is a closed enum of every role the platform knows about. +//! Adding `fleet-viewer` later is a code change here, not a config +//! flip. Unknown role names emitted by the IdP are silently dropped +//! — they cannot grant access. +//! - [`RoleClaims`] is the wire-side struct that the JWT verifier +//! `flatten`s into its top-level `Claims`. The two well-known claim +//! locations are matched verbatim by `#[serde(rename = "...")]`, +//! not by string-path walking — there is no env-driven path to +//! poison. +//! - [`Roles`] is the domain value carried on [`crate::VerifiedSession`]. +//! Construction is restricted to deserialization. Middleware +//! compares typed [`Role`] values, never strings. +//! +//! No `lookup_claim`. No dotted paths. No `path.contains("urn:")` +//! heuristic. If a future issuer puts roles somewhere new, add a new +//! `#[serde(rename = ...)]` field on [`RoleClaims`] — that's an +//! additive, reviewable, typed code change inside the security +//! boundary. -/// Default JSON-claim path that Zitadel uses for project roles. -/// -/// Zitadel emits this claim as an object map `{role-name: {org-id: -/// org-name}}`. The standard OIDC convention is a string array. This -/// module's [`extract_roles`] accepts both. -pub const DEFAULT_ROLES_CLAIM: &str = "urn:zitadel:iam:org:project:roles"; +use std::collections::{HashMap, HashSet}; -/// Default role name granting unrestricted dashboard / NATS access. -/// -/// Kept here (not in callout-only code) so the dashboard middleware can -/// reference the same constant and operators only have to remember one -/// role-name spelling across the platform. -pub const DEFAULT_ADMIN_ROLE: &str = "fleet-admin"; +use serde::Deserialize; -/// Extract role names from `claims` at the given JSON path. +/// A role the platform recognizes. /// -/// Accepts both shapes that OIDC providers emit: -/// - **Array of strings**: `["fleet-admin", "device"]` — the OIDC-standard -/// shape, used by custom mappers and most non-Zitadel issuers. -/// - **Zitadel object-map**: `{"fleet-admin": {"": ""}, ...}` -/// — Zitadel's default `urn:zitadel:iam:org:project:roles` shape, where -/// the role names are the *keys* and the values describe which -/// organization granted the role. -/// -/// Returns an empty vec if the claim is missing or has neither shape; -/// callers MUST treat an empty vec as "no roles" rather than defaulting -/// to admin. -/// -/// The path lookup follows the same convention as the callout: any path -/// containing `urn:` is treated as a flat key (Zitadel's roles claim has -/// dots inside it), otherwise dots are interpreted as nested-object -/// navigation (`foo.bar.baz`). -pub fn extract_roles(claims: &serde_json::Value, roles_claim: &str) -> Vec { - let Some(value) = lookup_claim(claims, roles_claim) else { - return Vec::new(); - }; +/// Closed enum: adding a variant is a deliberate code change to the +/// security path, not a runtime string. Unknown role names emitted by +/// the IdP deserialize to nothing — they cannot grant access through +/// this type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Role { + /// Full read/write access to the fleet dashboard and the + /// operator's management endpoints. + FleetAdmin, +} - match value { - serde_json::Value::Array(items) => items - .iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect(), - serde_json::Value::Object(map) => map.keys().cloned().collect(), - _ => Vec::new(), +impl Role { + /// Resolve a wire-format role name to its typed variant. + /// + /// Exhaustive match on purpose: a new [`Role`] variant forces this + /// function to grow a new arm at compile time. Returns `None` for + /// unknown names; those names never grant access. + fn parse(name: &str) -> Option { + match name { + "fleet-admin" => Some(Role::FleetAdmin), + _ => None, + } + } + + /// Wire-format name — what the IdP emits, what the 403 page + /// renders. The mapping is pinned by a test so a typo in the enum + /// can't drift from the wire spelling silently. + pub fn name(&self) -> &'static str { + match self { + Role::FleetAdmin => "fleet-admin", + } } } -/// Resolve a claim path against `root`. -/// -/// Paths containing `urn:` are treated as flat keys (Zitadel's role -/// claim is the canonical example: it contains dots that must NOT be -/// split). All other paths are dotted (`foo.bar.baz` → -/// `root["foo"]["bar"]["baz"]`). -fn lookup_claim<'a>(root: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> { - let parts: Vec<&str> = if path.contains('.') && !path.contains("urn:") { - path.split('.').collect() - } else { - vec![path] - }; - - let mut current = root; - for part in &parts { - current = current.get(part)?; +impl std::fmt::Display for Role { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.name()) + } +} + +/// The set of roles granted to a verified principal. +/// +/// **Construction is restricted to deserialization** via [`RoleClaims::into_roles`]. +/// There is no public constructor and no string-keyed mutator — +/// anything held here came from a verified JWT body decoded by serde. +/// The middleware checks a typed [`Role`], not a string. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Roles { + granted: HashSet, +} + +impl Roles { + /// Whether the principal holds `role`. + pub fn has(&self, role: Role) -> bool { + self.granted.contains(&role) + } + + /// Iterate granted roles for logging / diagnostics. Read-only — + /// there is no way to add roles outside of deserialization. + pub fn iter(&self) -> impl Iterator + '_ { + self.granted.iter().copied() + } + + pub fn is_empty(&self) -> bool { + self.granted.is_empty() + } + + pub fn len(&self) -> usize { + self.granted.len() + } +} + +/// Construct from a typed iterator of [`Role`] variants. +/// +/// Note: this accepts only [`Role`] values — there is no `From<&str>` +/// or `From>` path. Test fixtures and explicit +/// platform-internal constructions use this; user-supplied data still +/// goes through [`RoleClaims`] deserialization. +impl FromIterator for Roles { + fn from_iter>(iter: I) -> Self { + Roles { + granted: iter.into_iter().collect(), + } + } +} + +/// Role-bearing claims as serde decodes them out of a verified JWT +/// body via `#[serde(flatten)]`. +/// +/// Two well-known locations, both resolved by serde at decode time +/// from an **exact** key match — no path walking, no env-driven +/// string, no heuristic. +/// +/// 1. `urn:zitadel:iam:org:project:roles` — Zitadel's default role +/// claim. Emitted as an object whose **keys** are role names; the +/// values describe the granting organization (we ignore them with +/// [`serde::de::IgnoredAny`] so the decoder never allocates them). +/// 2. `roles` — OIDC-standard flat array used by custom mappers and +/// most non-Zitadel issuers. Used as a fallback when the Zitadel +/// URN is absent. +/// +/// If both are present, Zitadel's URN wins. If neither is present, +/// the resulting [`Roles`] is empty — closed door, never wildcard. +#[derive(Debug, Default, Deserialize)] +pub struct RoleClaims { + #[serde(default, rename = "urn:zitadel:iam:org:project:roles")] + zitadel_roles: Option>, + + #[serde(default, rename = "roles")] + oidc_roles: Option>, +} + +impl RoleClaims { + /// Promote wire claims into the typed domain value. + /// + /// Unknown role names — anything not matched by [`Role::parse`] — + /// are silently dropped. The expected production cardinality is + /// "a handful" of roles per token, so the cost of iterating both + /// shapes is irrelevant. + pub fn into_roles(self) -> Roles { + let granted: HashSet = if let Some(map) = self.zitadel_roles { + map.into_keys().filter_map(|k| Role::parse(&k)).collect() + } else if let Some(arr) = self.oidc_roles { + arr.into_iter().filter_map(|s| Role::parse(&s)).collect() + } else { + HashSet::new() + }; + Roles { granted } } - Some(current) } #[cfg(test)] @@ -89,102 +173,153 @@ mod tests { use super::*; use serde_json::json; - #[test] - fn fleet_admin_role_from_array_claim_resolves() { - // OIDC-standard shape (and what Zitadel emits if you configure - // a custom mapper that flattens roles). - let claims = json!({ - "sub": "u-1", - "roles": ["fleet-admin", "device"], - }); - let roles = extract_roles(&claims, "roles"); - assert!(roles.contains(&"fleet-admin".to_string())); + /// Round-trip a JSON body through serde — mirrors what the JWT + /// verifier does after signature checks succeed. Returns the + /// resulting typed [`Roles`] for assertions. + fn parse(body: serde_json::Value) -> Roles { + let claims: RoleClaims = + serde_json::from_value(body).expect("test inputs must be RoleClaims-shaped"); + claims.into_roles() } #[test] - fn fleet_admin_role_from_zitadel_object_map_resolves() { - // Default Zitadel shape: role names are the *keys* of an object - // whose values describe the granting organization. - let claims = json!({ - "sub": "u-1", + fn fleet_admin_from_zitadel_object_map_resolves() { + let roles = parse(json!({ + "urn:zitadel:iam:org:project:roles": { + "fleet-admin": { "org-a": "Org A" } + } + })); + assert!(roles.has(Role::FleetAdmin)); + assert_eq!(roles.len(), 1); + } + + #[test] + fn fleet_admin_from_oidc_array_resolves() { + let roles = parse(json!({ "roles": ["fleet-admin", "device"] })); + assert!(roles.has(Role::FleetAdmin)); + assert_eq!(roles.len(), 1); + } + + #[test] + fn missing_role_claim_yields_empty_roles() { + // Without this guarantee a parse failure could be confused + // with "no roles" and lock everyone out — or worse, be + // treated as a wildcard. Empty `Roles` is the closed-door + // default, asserted here. + let roles = parse(json!({ "sub": "u-1" })); + assert!(roles.is_empty()); + assert!(!roles.has(Role::FleetAdmin)); + } + + #[test] + fn unknown_role_names_are_silently_dropped() { + // An IdP that mints a `superuser` or `*` role cannot escalate + // — only names matched by `Role::parse` become `Role` variants + // in the typed set. + let roles = parse(json!({ + "roles": ["superuser", "*", "fleet-admin", "fleet-admin-typo"] + })); + assert!(roles.has(Role::FleetAdmin)); + assert_eq!(roles.len(), 1); + } + + #[test] + fn zitadel_urn_wins_over_oidc_array_when_both_present() { + // An unusual but legal token. Zitadel's URN is what the + // operator explicitly configured the IdP to emit; the array + // claim is a fallback for non-Zitadel issuers and should NOT + // override a present Zitadel claim. + let roles = parse(json!({ "urn:zitadel:iam:org:project:roles": { "fleet-admin": { "org-a": "Org A" } }, - }); - let roles = extract_roles(&claims, "urn:zitadel:iam:org:project:roles"); - assert!(roles.contains(&"fleet-admin".to_string())); + "roles": [] + })); + assert!(roles.has(Role::FleetAdmin)); } #[test] - fn missing_roles_claim_yields_empty_vec() { - // Without this guarantee callers might mistake a parse failure - // for "no roles" and lock everyone out — or worse, treat the - // None-ness as a wildcard and let everyone in. Empty vec is the - // explicit closed-door default. - let claims = json!({ "sub": "u-1" }); - let roles = extract_roles(&claims, "roles"); + fn device_only_session_does_not_carry_fleet_admin() { + // The core authorization gap Chapter 1 closes: a logged-in + // Zitadel user with only the `device` role must NOT pass the + // dashboard's `fleet-admin` gate. + let roles = parse(json!({ + "urn:zitadel:iam:org:project:roles": { + "device": { "org-a": "Org A" } + } + })); + assert!(!roles.has(Role::FleetAdmin)); + assert!(roles.is_empty(), "device is not a Role variant"); + } + + #[test] + fn malformed_zitadel_shape_errors_at_decode() { + // If Zitadel emits a scalar at the URN path (mapper + // misconfigured, breaking IdP upgrade) serde errors here. The + // upstream JWT verifier surfaces that as a verification + // failure, which logs the user out for re-auth — preferable + // to silently treating them as roleless and proceeding. + let res: Result = serde_json::from_value(json!({ + "urn:zitadel:iam:org:project:roles": "fleet-admin" + })); + assert!(res.is_err(), "scalar at URN path must not parse"); + } + + #[test] + fn malformed_oidc_array_with_mixed_types_errors_at_decode() { + // The OIDC `roles` claim is typed `Vec`. A mixed-type + // array (`["fleet-admin", 42]`) errors at decode rather than + // silently dropping the bad entries — fail-loud is the + // security-correct default when the IdP misbehaves. + let res: Result = serde_json::from_value(json!({ + "roles": ["fleet-admin", 42, null] + })); + assert!(res.is_err(), "mixed-type array must not parse"); + } + + #[test] + fn empty_object_at_zitadel_path_yields_empty_roles() { + let roles = parse(json!({ + "urn:zitadel:iam:org:project:roles": {} + })); assert!(roles.is_empty()); } #[test] - fn only_device_role_present_does_not_carry_fleet_admin() { - // The fleet-admin middleware MUST NOT grant access to a user - // who only has the device role — this is the core authorization - // gap Chapter 1 closes. - let claims = json!({ + fn empty_array_at_oidc_path_yields_empty_roles() { + let roles = parse(json!({ "roles": [] })); + assert!(roles.is_empty()); + } + + #[test] + fn role_display_matches_wire_name() { + // Pin the wire spelling. If anyone edits `Role::name()` to + // return a different string than what the IdP emits, the + // 403 page misleads the user about which role to ask for — + // and worse, the gate would silently never match. + assert_eq!(Role::FleetAdmin.to_string(), "fleet-admin"); + assert_eq!(Role::FleetAdmin.name(), "fleet-admin"); + } + + #[test] + fn extra_unrelated_claims_do_not_disturb_role_extraction() { + // Real JWT bodies have many fields (`iss`, `aud`, `exp`, + // `email`, ...). The decoder must ignore everything it + // doesn't rename to a `RoleClaims` field — serde's default + // behaviour, asserted here as a regression canary against a + // future `#[serde(deny_unknown_fields)]` slipping in and + // breaking JWT decoding on every real token. + let roles = parse(json!({ + "iss": "https://zitadel.example/", + "aud": ["dashboard"], + "exp": 9_999_999_999i64, "sub": "u-1", + "email": "admin@example.com", + "name": "Admin", "urn:zitadel:iam:org:project:roles": { - "device": { "org-a": "Org A" } - }, - }); - let roles = extract_roles(&claims, "urn:zitadel:iam:org:project:roles"); - assert_eq!(roles, vec!["device"]); - assert!(!roles.contains(&"fleet-admin".to_string())); - } - - #[test] - fn array_with_non_string_entries_is_filtered() { - // A misbehaving issuer that puts `null` or numbers into the - // role array must not crash the extractor — just skip them. - let claims = json!({ - "roles": ["fleet-admin", 42, null, true, "device"], - }); - let roles = extract_roles(&claims, "roles"); - assert_eq!(roles, vec!["fleet-admin", "device"]); - } - - #[test] - fn primitive_at_claim_path_yields_empty_vec() { - // If the path resolves to a scalar (string, number, bool) we - // refuse to guess — empty vec, closed door. - let claims = json!({ "roles": "fleet-admin" }); - assert!(extract_roles(&claims, "roles").is_empty()); - } - - #[test] - fn empty_array_and_empty_map_both_yield_empty_vec() { - let array = json!({ "roles": [] }); - assert!(extract_roles(&array, "roles").is_empty()); - let object = json!({ "roles": {} }); - assert!(extract_roles(&object, "roles").is_empty()); - } - - #[test] - fn dotted_paths_navigate_nested_objects() { - let claims = json!({ - "app": { "fleet": { "roles": ["fleet-admin"] } } - }); - let roles = extract_roles(&claims, "app.fleet.roles"); - assert_eq!(roles, vec!["fleet-admin"]); - } - - #[test] - fn urn_paths_are_treated_as_flat_keys() { - // Without the urn: opt-out, `urn:zitadel:iam:org:project:roles` - // would be safe (no dots) but `urn:zitadel.iam` would be split - // on dot — Zitadel does emit dotted URNs in some configurations. - let claims = json!({ "urn:zitadel.iam": { "fleet-admin": {} } }); - let roles = extract_roles(&claims, "urn:zitadel.iam"); - assert_eq!(roles, vec!["fleet-admin"]); + "fleet-admin": { "org-a": "Org A" } + } + })); + assert!(roles.has(Role::FleetAdmin)); } } diff --git a/harmony_zitadel_auth/src/session.rs b/harmony_zitadel_auth/src/session.rs index b0e07ea7..545aa844 100644 --- a/harmony_zitadel_auth/src/session.rs +++ b/harmony_zitadel_auth/src/session.rs @@ -1,12 +1,15 @@ use serde::{Deserialize, Serialize}; +use crate::roles::Roles; + /// Claims extracted from a verified session cookie JWT on each request. /// /// `roles` is captured here (rather than re-extracted on demand) so the -/// role-enforcement middleware introduced in v0.3 Chapter 1 doesn't have -/// to re-decode the JWT body. The vec is the post-extraction list — same -/// shape regardless of whether the underlying issuer emitted an array -/// or Zitadel's role-name-keyed object map. +/// role-enforcement middleware introduced in v0.3 Chapter 1 doesn't +/// have to re-decode the JWT body. It is a typed [`Roles`] value, not a +/// `Vec` — middleware checks named [`crate::Role`] variants +/// rather than comparing strings, eliminating the string-comparison +/// surface inside the security boundary. #[derive(Debug, Clone)] pub struct VerifiedSession { pub subject: String, @@ -15,10 +18,12 @@ pub struct VerifiedSession { pub expires_at: i64, /// OIDC nonce from the ID token, used to bind callback tokens to login attempts. pub nonce: Option, - /// Role names extracted from the configured `roles_claim`. Empty - /// when the claim is absent or has neither the array nor object-map - /// shape — callers MUST treat empty as "no roles", not as a wildcard. - pub roles: Vec, + /// Roles granted by the verified JWT. Constructed only via serde + /// deserialization of a [`crate::RoleClaims`] flatten on the JWT + /// body — no string-keyed mutation path exists. Empty when the IdP + /// emitted no recognized role; callers MUST treat empty as "no + /// roles", never as a wildcard. + pub roles: Roles, } /// PKCE state persisted in the encrypted login-attempt cookie during the diff --git a/nats/callout/Cargo.toml b/nats/callout/Cargo.toml index e8c2bf3e..83fb84ee 100644 --- a/nats/callout/Cargo.toml +++ b/nats/callout/Cargo.toml @@ -17,7 +17,6 @@ path = "src/main.rs" [dependencies] nats-jwt = { path = "../jwt" } -harmony_zitadel_auth = { path = "../../harmony_zitadel_auth" } async-nats.workspace = true nkeys = "0.4" jsonwebtoken = "9" diff --git a/nats/callout/src/zitadel.rs b/nats/callout/src/zitadel.rs index 600fb566..447de76b 100644 --- a/nats/callout/src/zitadel.rs +++ b/nats/callout/src/zitadel.rs @@ -194,11 +194,6 @@ impl ZitadelValidator { /// Extract role names from `claims` at the given JSON path. /// - /// Delegates to [`harmony_zitadel_auth::extract_roles`] so the - /// dashboard's role-enforcement middleware and this callout share - /// **one** implementation. Any future quirk in how Zitadel emits - /// roles is fixed once in the shared crate. - /// /// Accepts both shapes that OIDC providers emit: /// - **Array of strings**: `["fleet-admin", "device"]` (common with custom mappers) /// - **Object map**: `{"fleet-admin": {"": ""}, ...}` (Zitadel's default @@ -207,7 +202,18 @@ impl ZitadelValidator { /// Returns an empty vec if the claim is missing or has neither shape. pub fn extract_roles(&self, claims: &ZitadelClaims, roles_claim: &str) -> Vec { let root = claims_to_value(claims); - harmony_zitadel_auth::extract_roles(&root, roles_claim) + let Some(value) = lookup_claim(&root, roles_claim) else { + return Vec::new(); + }; + + match value { + serde_json::Value::Array(items) => items + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(), + serde_json::Value::Object(map) => map.keys().cloned().collect(), + _ => Vec::new(), + } } pub fn start_refresh_task(&self, interval: Duration) { -- 2.39.5