diff --git a/Cargo.lock b/Cargo.lock
index b27c5d2b..cda03257 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",
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..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::ZitadelAuthConfig;
+use harmony_zitadel_auth::{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,93 @@ 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(Role::FleetAdmin, req, next).await
+}
+
+/// Reusable role-gating middleware body.
+///
+/// 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
+ // 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.has(role) {
+ return next.run(req).await;
+ }
+
+ tracing::warn!(
+ subject = %session.subject,
+ required_role = %role,
+ granted_roles = ?session.roles.iter().collect::>(),
+ "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: Role) -> Response {
+ let role_name = required_role.name();
+ 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" { (role_name) }
+ " 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 +821,172 @@ 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 harmony_zitadel_auth::Roles;
+ 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))
+ }
+
+ /// 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.into_iter().collect::(),
+ }
+ }
+
+ 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. 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
+ .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(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([Role::FleetAdmin])));
+ 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 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([])));
+ 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(",
name: Option,
nonce: Option,
+ #[serde(flatten)]
+ roles: RoleClaims,
}
let claims = decode::(token, &decoding_key, &validation)
@@ -177,6 +186,7 @@ fn verify_with_jwk(
name: claims.name,
expires_at: claims.exp,
nonce: claims.nonce,
+ roles: claims.roles.into_roles(),
})
}
diff --git a/harmony_zitadel_auth/src/lib.rs b/harmony_zitadel_auth/src/lib.rs
index f5cd0e78..481de474 100644
--- a/harmony_zitadel_auth/src/lib.rs
+++ b/harmony_zitadel_auth/src/lib.rs
@@ -3,6 +3,7 @@ pub mod axum_login_flow;
pub mod config;
pub mod jwks;
pub mod login;
+pub mod roles;
pub mod session;
#[cfg(feature = "axum")]
@@ -20,4 +21,6 @@ pub use login::{
validate_callback_state, validate_id_token,
};
+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
new file mode 100644
index 00000000..091470a9
--- /dev/null
+++ b/harmony_zitadel_auth/src/roles.rs
@@ -0,0 +1,325 @@
+//! Typed role extraction for Zitadel / OIDC JWT bodies.
+//!
+//! 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).
+//!
+//! This module replaces all of that with **typed serde
+//! deserialization**:
+//!
+//! - [`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.
+
+use std::collections::{HashMap, HashSet};
+
+use serde::Deserialize;
+
+/// A role the platform recognizes.
+///
+/// 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,
+}
+
+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",
+ }
+ }
+}
+
+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 }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ /// 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_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" }
+ },
+ "roles": []
+ }));
+ assert!(roles.has(Role::FleetAdmin));
+ }
+
+ #[test]
+ 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 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": {
+ "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 8e5f73c3..545aa844 100644
--- a/harmony_zitadel_auth/src/session.rs
+++ b/harmony_zitadel_auth/src/session.rs
@@ -1,6 +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. 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,
@@ -9,6 +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,
+ /// 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