feat/v0-3-dashboard-role-enforcement #293

Open
johnride wants to merge 2 commits from feat/v0-3-dashboard-role-enforcement into feat/smoke-test-contract
7 changed files with 633 additions and 3 deletions

2
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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<Body>, 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<Body>, next: Next) -> Response {
let Some(session) = req.extensions().get::<DashboardSession>() 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::<Vec<_>>(),
"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<AppState>, req: Request<Body>, 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<Body>,
next: Next,
) -> Response {
req.extensions_mut().insert(session);
next.run(req).await
}
fn router_with_session(session: Option<DashboardSession>) -> Router {
let injector_layer = move |req: Request<Body>, 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<I>(roles: I) -> DashboardSession
where
I: IntoIterator<Item = Role>,
{
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::<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. 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("<html"),
"body should be a full HTML document"
);
}
}

View File

@@ -5,6 +5,7 @@ use anyhow::Result;
use serde::Deserialize;
use crate::config::ZitadelAuthConfig;
use crate::roles::RoleClaims;
use crate::session::VerifiedSession;
struct JwksCacheInner {
@@ -158,6 +159,12 @@ fn verify_with_jwk(
validation.set_audience(&config.trusted_audiences);
validation.set_issuer(&[&config.zitadel_base]);
// 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,
@@ -165,6 +172,8 @@ fn verify_with_jwk(
email: Option<String>,
name: Option<String>,
nonce: Option<String>,
#[serde(flatten)]
roles: RoleClaims,
}
let claims = decode::<Claims>(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(),
})
}

View File

@@ -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};

View File

@@ -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<Self> {
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<Role>,
}
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<Item = Role> + '_ {
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<Vec<String>>` path. Test fixtures and explicit
/// platform-internal constructions use this; user-supplied data still
/// goes through [`RoleClaims`] deserialization.
impl FromIterator<Role> for Roles {
fn from_iter<I: IntoIterator<Item = Role>>(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<HashMap<String, serde::de::IgnoredAny>>,
#[serde(default, rename = "roles")]
oidc_roles: Option<Vec<String>>,
}
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<Role> = 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<RoleClaims, _> = 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<String>`. 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<RoleClaims, _> = 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));
}
}

View File

@@ -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<String>` — 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<String>,
/// 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