From d9356ebf14d6586ca63c9e558c046936ed6de0e5 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Wed, 22 Jul 2026 19:12:09 -0700 Subject: [PATCH 1/2] feat(dashboard): enforce OIDC verifier on the dashboard + /api router, fail-closed (JEF-487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the JEF-485 OIDC verifier (ADR-0030) into the live dashboard router as a content-negotiating, fail-closed access gate — closing the port-forward hole where any in-cluster caller reads the whole security graph unauthenticated. - New `auth/enforce.rs`: the `Enforcer` + `enforce` middleware. Reuses the verifier via a shared `authenticate` seam (no re-implemented verification). Route-class negotiation: `/api/*.json` (path prefix, authoritative) or a JSON-preferring Accept → 401 tiny JSON, NEVER 302; a document `GET /` → 302 to login (from OidcConfig / PROTECTOR_DASHBOARD_OIDC_LOGIN_URL); below-tier identity → 403; JWKS-unreachable → 503. Every denial is no-store, and the gate mounts UNDER the CSP layer so the strict CSP rides every rejection. - Config threading: run_loop builds the config + enforcer; issuer absent → serve edge-trust-only with a loud startup WARN (the single §6 bypass); misconfig → dashboard NOT served (fail-closed, never unauthenticated). - Server-derived `auth-mode` (oidc / edge-only) on StatusStripProps for the JEF-489 pill — mirrors exactly what is enforced, client derives nothing. - Helm: engine.dashboard.oidc.{issuer,audience,tierClaim}. - Integration tests over the real router (in-memory JWKS, no egress): no/ tampered/expired token → 401; valid → 200; GET / → 302; /api never 302'd; 403 below-tier; 503 JWKS-down; CSP+no-store on rejections; unconfigured serves without rejecting. DECISION: namespaced the dashboard-auth env vars as PROTECTOR_DASHBOARD_OIDC_* rather than the ticket's literal PROTECTOR_OIDC_* — the latter already names the cosign signature-verification (cert-identity) issuer, set unconditionally by the chart, so sharing it would flip every existing deploy into fail-closed auth against the wrong issuer and lock operators out on upgrade (the exact regression ADR-0030 §6 forbids). Closes JEF-487 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP --- charts/protector/templates/deployment.yaml | 14 + charts/protector/values.yaml | 24 ++ engine/examples/dashboard_preview.rs | 4 + engine/src/engine/dashboard/api_json_tests.rs | 7 +- engine/src/engine/dashboard/auth/enforce.rs | 342 ++++++++++++++++++ .../engine/dashboard/auth/enforce_tests.rs | 309 ++++++++++++++++ engine/src/engine/dashboard/auth/mod.rs | 76 ++-- .../src/engine/dashboard/auth/test_support.rs | 170 +++++++++ engine/src/engine/dashboard/auth/tests.rs | 177 +-------- engine/src/engine/dashboard/mod.rs | 45 ++- .../dashboard/view_model/action/tests.rs | 1 + .../dashboard/view_model/admission/tests.rs | 1 + .../engine/dashboard/view_model/props/mod.rs | 2 +- .../view_model/props/serialize_tests.rs | 19 + .../dashboard/view_model/props/status.rs | 31 +- .../src/engine/dashboard/view_model/strip.rs | 6 +- engine/src/engine/run_loop.rs | 81 ++++- 17 files changed, 1079 insertions(+), 230 deletions(-) create mode 100644 engine/src/engine/dashboard/auth/enforce.rs create mode 100644 engine/src/engine/dashboard/auth/enforce_tests.rs create mode 100644 engine/src/engine/dashboard/auth/test_support.rs diff --git a/charts/protector/templates/deployment.yaml b/charts/protector/templates/deployment.yaml index d400a88..08e898b 100644 --- a/charts/protector/templates/deployment.yaml +++ b/charts/protector/templates/deployment.yaml @@ -301,6 +301,20 @@ spec: value: "0.0.0.0:{{ .Values.engine.dashboard.port }}" - name: PROTECTOR_CLUSTER_LABEL value: {{ .Values.engine.dashboard.clusterLabel | quote }} + {{- with .Values.engine.dashboard.oidc }} + {{- if .issuer }} + # App-level OIDC verification (ADR-0030 / JEF-487) — fail-closed when the issuer is set. + # Namespaced PROTECTOR_DASHBOARD_OIDC_* so it never collides with the cosign + # signature-verification PROTECTOR_OIDC_ISSUER (a different, cert-identity concern). + # Absent issuer = edge-trust-only (the engine logs a loud startup warning, §6). + - name: PROTECTOR_DASHBOARD_OIDC_ISSUER + value: {{ .issuer | quote }} + - name: PROTECTOR_DASHBOARD_OIDC_AUDIENCE + value: {{ .audience | quote }} + - name: PROTECTOR_DASHBOARD_OIDC_TIER_CLAIM + value: {{ .tierClaim | quote }} + {{- end }} + {{- end }} {{- end }} {{- if $feeds }} # KEV catalogue file the feed-fetcher sidecar writes into the shared emptyDir diff --git a/charts/protector/values.yaml b/charts/protector/values.yaml index 96c88d4..c92f091 100644 --- a/charts/protector/values.yaml +++ b/charts/protector/values.yaml @@ -303,6 +303,30 @@ engine: port: 8080 # The cluster label shown in the dashboard's status strip. clusterLabel: cluster + # App-level OIDC verification (ADR-0030 / JEF-487) — closes the port-forward hole. Protector + # is a RESOURCE SERVER: it verifies the JWT the edge (or any conformant OIDC IdP) already + # issues; it runs no login flow of its own. This is DISTINCT from `signature.oidcIssuer` (the + # cosign keyless CERT-IDENTITY issuer) — a different concern, its own env namespace + # (PROTECTOR_DASHBOARD_OIDC_*) so the two never collide. + # + # FAIL-CLOSED when `issuer` is set: every request is verified and every failure DENIES (401 for + # /api, 302→login for a document, 503 if the JWKS is unreachable). Leave `issuer` EMPTY to run + # edge-trust-only (as today) — the engine then logs a LOUD startup warning that app-level auth + # is disabled (§6). The cluster-side IdP app + the engine→IdP NetworkPolicy live in the separate + # cluster repo, not here. + oidc: + # The expected token issuer (`iss`) and OIDC discovery base. EMPTY = unconfigured (edge-trust + # only, loud warning). Set to your IdP, e.g. the Cloudflare Access team issuer + # `https://.cloudflareaccess.com` (the reference §7) or Okta/Keycloak/Auth0/Dex. + issuer: "" + # The expected audience (`aud`). REQUIRED once `issuer` is set (a misconfiguration otherwise — + # the dashboard is then NOT served rather than served unauthenticated). e.g. `protector` or the + # Cloudflare Access application AUD tag. + audience: "" + # The claim path the operator's authorization tier is read from (governs how much of the + # already-read-only view an identity sees; JEF-489). Default `tier`; supports a flat namespaced + # key or a dotted path (e.g. `authz.tier`). + tierClaim: "tier" # Two exploitation-intel feeds drive the engine's exploit intel + model reasoning # (ADR-0015): the CISA KEV catalogue (actively-exploited CVEs, known NOW) and the FIRST.org # EPSS scores (the PREDICTIVE per-CVE exploitation probability — JEF-243). The engine NEVER diff --git a/engine/examples/dashboard_preview.rs b/engine/examples/dashboard_preview.rs index 2f7d51f..c285c05 100644 --- a/engine/examples/dashboard_preview.rs +++ b/engine/examples/dashboard_preview.rs @@ -578,6 +578,7 @@ fn build_clear() -> DashboardState { // The webhook floor: a populated admission log (admits + an audited + an enforced deny). policy_log: sample_policy_log(), cluster: "prod-us-east-1 (PREVIEW — clear)".into(), + auth_mode: protector::engine::dashboard::AuthMode::EdgeOnly, } } @@ -654,6 +655,7 @@ fn build_watching() -> DashboardState { decision_journal: sample_journal(), policy_log: sample_policy_log(), cluster: "prod-us-east-1 (PREVIEW — watching)".into(), + auth_mode: protector::engine::dashboard::AuthMode::EdgeOnly, } } @@ -813,6 +815,7 @@ fn build_breach() -> DashboardState { decision_journal: sample_journal(), policy_log: sample_policy_log(), cluster: "prod-us-east-1 (PREVIEW — breach)".into(), + auth_mode: protector::engine::dashboard::AuthMode::EdgeOnly, } } @@ -867,6 +870,7 @@ fn build_blind() -> DashboardState { // "no admission decisions recorded yet" empty state (the empty case the brief asks for). policy_log: Arc::new(PolicyDecisionLog::new()), cluster: "prod-us-east-1 (PREVIEW — blind)".into(), + auth_mode: protector::engine::dashboard::AuthMode::EdgeOnly, } } diff --git a/engine/src/engine/dashboard/api_json_tests.rs b/engine/src/engine/dashboard/api_json_tests.rs index dd67f50..6c32ce0 100644 --- a/engine/src/engine/dashboard/api_json_tests.rs +++ b/engine/src/engine/dashboard/api_json_tests.rs @@ -27,12 +27,13 @@ fn empty_state() -> DashboardState { decision_journal: Arc::new(DecisionJournal::disabled()), policy_log: Arc::new(PolicyDecisionLog::new()), cluster: "prod-test".into(), + auth_mode: super::AuthMode::EdgeOnly, } } /// GET a route and return `(status, no_store, body_bytes)`. async fn get(path: &str) -> (StatusCode, bool, Vec) { - let router = super::router(empty_state()); + let router = super::router(empty_state(), None); let response = router .oneshot( Request::builder() @@ -89,7 +90,7 @@ async fn every_endpoint_is_get_only_json_and_no_store() { #[tokio::test] async fn a_write_verb_is_rejected_the_view_is_never_a_gate() { // POST to a JSON endpoint must not be routed (405) — there is no write route (ADR-0016). - let router = super::router(empty_state()); + let router = super::router(empty_state(), None); let response = router .oneshot( Request::builder() @@ -170,7 +171,7 @@ async fn an_empty_engine_never_serves_a_false_green() { /// and forbid `'unsafe-inline'` / `'unsafe-eval'` (Finding 1, JEF-395 / JEF-396). #[tokio::test] async fn the_json_api_carries_the_strict_csp() { - let router = super::router(empty_state()); + let router = super::router(empty_state(), None); let response = router .oneshot( Request::builder() diff --git a/engine/src/engine/dashboard/auth/enforce.rs b/engine/src/engine/dashboard/auth/enforce.rs new file mode 100644 index 0000000..8758694 --- /dev/null +++ b/engine/src/engine/dashboard/auth/enforce.rs @@ -0,0 +1,342 @@ +//! Content-negotiating enforcement (JEF-487, ADR-0030 §6): mounts the JEF-485 [`Verifier`] as the +//! LIVE dashboard access gate and shapes every fail-closed denial by **route class**, so the client +//! contract holds: +//! +//! - a **document** request (`GET /`, a browser navigation) with no/expired/invalid token → **302** +//! to the IdP/login (derived from the OIDC config), so the browser can re-authenticate; +//! - an **`/api/*.json`** request (a programmatic `fetch`, `accept: application/json`) with +//! no/expired/invalid token → **401** with a tiny `{"error":"unauthenticated"}` body. An `/api` +//! route is **NEVER** 302'd — a redirected `fetch` dies on the strict CSP `connect-src 'self'` and +//! the client mislabels auth as "stale" (the load-bearing reason the path-prefix rule is a hard +//! guarantee, not a heuristic); +//! - a **valid** token whose identity is below the configured minimum tier → **403** (both classes; +//! re-login cannot change the identity, so a document is not redirected here); +//! - a **JWKS-unreachable** condition → **503** (both classes) — we could not verify, so we do not +//! serve; this matches the [`AuthError::status`](super::AuthError::status) mapping the verifier +//! already defines. Never a bypass. +//! +//! Every denial carries `Cache-Control: no-store` (JEF-283: a cached `302`→login is exactly the +//! Cloudflare-edge bug), and — because this layer is mounted UNDER the CSP layer — the strict CSP +//! rides every rejection too. The gate is the ONLY thing that can turn a request into a `next.run`; +//! there is no path that serves the graph on a verification error (the fail-*open* trap ADR-0030 §6 +//! names as the single highest-risk line). + +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::State; +use axum::http::{HeaderMap, HeaderValue, Request, StatusCode, Uri, header}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; + +use super::claims::Tier; +use super::{AuthError, Identity, OidcConfig, Verifier, authenticate, non_empty_env}; + +/// The tiny JSON body for a `401` — no graph data, no stack, no which-check-failed detail (ADR-0030 +/// §6: which check failed is not the caller's business). +const BODY_UNAUTHENTICATED: &str = r#"{"error":"unauthenticated"}"#; +/// The tiny JSON body for a `403`. +const BODY_FORBIDDEN: &str = r#"{"error":"forbidden"}"#; +/// The tiny JSON body for a `503` (JWKS unreachable — we could not verify). +const BODY_UNAVAILABLE: &str = r#"{"error":"unavailable"}"#; + +/// `PROTECTOR_DASHBOARD_OIDC_LOGIN_URL` — where a browser DOCUMENT request with no/invalid token is +/// 302'd. Optional; defaults to the configured issuer (ADR-0030 §7: protector is a resource server, +/// not an OAuth client — it does not build an `authorization_code` redirect, it points the browser +/// at the login surface, which for the Cloudflare-Access reference issues the assertion itself). +const ENV_LOGIN_URL: &str = "PROTECTOR_DASHBOARD_OIDC_LOGIN_URL"; +/// `PROTECTOR_DASHBOARD_OIDC_MIN_TIER` — the minimum authorization tier a verified identity must +/// hold to VIEW. Optional escape hatch; defaults to the most-restricted [`Tier::Redacted`] (every +/// verified identity passes), so configuring an issuer never, by itself, forbids a valid token. +const ENV_MIN_TIER: &str = "PROTECTOR_DASHBOARD_OIDC_MIN_TIER"; + +/// The live dashboard access gate: the JEF-485 [`Verifier`] plus the content-negotiation policy +/// (where to send an unauthenticated browser, and the minimum authorization tier). Built ONLY when +/// an issuer is configured — its absence is the loud edge-only bypass (ADR-0030 §6), which the +/// caller handles by simply not mounting this layer. +pub struct Enforcer { + verifier: Verifier, + /// Where a browser DOCUMENT request with no/expired/invalid token is 302'd. + login_redirect: String, + /// The minimum authorization tier a verified identity must hold to view (default `Redacted`). + min_tier: Tier, +} + +impl Enforcer { + /// Build the production gate from a configured [`OidcConfig`]: a verifier that fetches the + /// issuer's keys over HTTPS, the login redirect (configured URL, else the issuer), and the + /// minimum tier (env escape hatch, else the most-restricted default). + pub fn new(config: OidcConfig) -> Self { + let login_redirect = login_redirect(&config); + Self { + verifier: Verifier::from_config(config), + login_redirect, + min_tier: configured_min_tier(), + } + } + + /// Assemble a gate from explicit parts — the seam tests use to inject a [`Verifier`] built over + /// an in-memory JWKS (no egress) and pin the login redirect / minimum tier deterministically. + #[cfg(test)] + pub(crate) fn from_parts(verifier: Verifier, login_redirect: String, min_tier: Tier) -> Self { + Self { + verifier, + login_redirect, + min_tier, + } + } + + /// Whether a verified identity meets the minimum tier. `Tier` is ordered + /// `Redacted < Forensic < Raw`, and the default floor is `Redacted`, so with no configured + /// minimum every verified identity is permitted (authentication alone gates viewing). + fn identity_permitted(&self, identity: &Identity) -> bool { + identity.tier >= self.min_tier + } +} + +/// The axum middleware: verify the request (via the shared [`authenticate`] seam) and, on success +/// meeting the tier gate, insert the [`Identity`] and pass through; on ANY failure, DENY with a +/// route-class-negotiated response. Mounted with +/// `from_fn_with_state(Arc, enforce)` UNDER the CSP layer. +pub async fn enforce( + State(enforcer): State>, + mut request: Request, + next: Next, +) -> Response { + let class = route_class(request.uri(), request.headers()); + match authenticate(&enforcer.verifier, request.headers()).await { + Ok(identity) if enforcer.identity_permitted(&identity) => { + request.extensions_mut().insert(identity); + next.run(request).await + } + Ok(identity) => { + tracing::warn!( + subject = %identity.subject, + "dashboard: verified identity is below the minimum tier (forbidden)" + ); + deny(Denial::Forbidden, class, &enforcer.login_redirect) + } + Err(error) => { + tracing::warn!(%error, ?class, "dashboard OIDC verification denied (fail-closed)"); + deny(Denial::Auth(error), class, &enforcer.login_redirect) + } + } +} + +/// Which class of request this is — the axis the failure is negotiated on. An `/api/` path is the +/// JSON class unconditionally (the hard "never 302 an `/api` route" guarantee); otherwise a client +/// that explicitly prefers JSON is treated as the JSON class too. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RouteClass { + /// A browser navigation (`GET /`, `/assets/*`) — an auth failure is a `302` to login. + Document, + /// A programmatic `/api/*.json` (or JSON-preferring) request — an auth failure is a `401`, never + /// a redirect. + Api, +} + +/// Distinguish the JSON API class from a browser document. The `/api/` path prefix is authoritative +/// (a redirected `fetch` breaks under CSP), and assets fall to `Document` so an unauthenticated +/// direct asset hit rides the document 302 — the browser reaches assets only after the document has +/// authenticated, so this is the honest call. +fn route_class(uri: &Uri, headers: &HeaderMap) -> RouteClass { + if uri.path().starts_with("/api/") || prefers_json(headers) { + RouteClass::Api + } else { + RouteClass::Document + } +} + +/// Whether the client explicitly asks for JSON and not HTML — a programmatic `fetch`, not a browser +/// navigation. A browser's `Accept: text/html,...` is NOT treated as JSON even if it also lists +/// `application/json`. +fn prefers_json(headers: &HeaderMap) -> bool { + let Some(accept) = headers.get(header::ACCEPT).and_then(|v| v.to_str().ok()) else { + return false; + }; + accept.contains("application/json") && !accept.contains("text/html") +} + +/// The two kinds of denial: a verification failure (its [`AuthError`] carries the JWKS-down vs +/// auth-failure distinction) or a tier-authorization failure on an otherwise-valid token. +enum Denial { + Auth(AuthError), + Forbidden, +} + +/// Map a denial to the route-class-negotiated response, then stamp `no-store`. JWKS-unreachable is a +/// `503` on BOTH classes (availability, not an auth challenge — never a 302); a `Forbidden` is a +/// `403` on both (re-login cannot change the identity); every other auth failure is a `401` for the +/// JSON class and a `302`-to-login for a document. +fn deny(denial: Denial, class: RouteClass, login_redirect: &str) -> Response { + let response = match (denial, class) { + (Denial::Auth(AuthError::JwksUnreachable), RouteClass::Api) => { + json(StatusCode::SERVICE_UNAVAILABLE, BODY_UNAVAILABLE) + } + (Denial::Auth(AuthError::JwksUnreachable), RouteClass::Document) => { + StatusCode::SERVICE_UNAVAILABLE.into_response() + } + (Denial::Forbidden, RouteClass::Api) => json(StatusCode::FORBIDDEN, BODY_FORBIDDEN), + (Denial::Forbidden, RouteClass::Document) => StatusCode::FORBIDDEN.into_response(), + (Denial::Auth(_), RouteClass::Api) => json(StatusCode::UNAUTHORIZED, BODY_UNAUTHENTICATED), + (Denial::Auth(_), RouteClass::Document) => redirect(login_redirect), + }; + no_store(response) +} + +/// A tiny JSON error response with the given status — no graph data, no error detail. +fn json(status: StatusCode, body: &'static str) -> Response { + ( + status, + [( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + )], + body, + ) + .into_response() +} + +/// A `302 Found` to the login/IdP surface. A `Location` that cannot be a header value must not +/// panic — it denies with a bare `401` (fail closed), never serves. +fn redirect(location: &str) -> Response { + match HeaderValue::from_str(location) { + Ok(location) => (StatusCode::FOUND, [(header::LOCATION, location)]).into_response(), + Err(_) => StatusCode::UNAUTHORIZED.into_response(), + } +} + +/// Stamp `Cache-Control: no-store` on a denial so a shared edge (Cloudflare) never caches a +/// `302`→login (or any rejection) against the URL and serves it to the next caller (JEF-283). +fn no_store(mut response: Response) -> Response { + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + +/// The browser login redirect target: the configured `PROTECTOR_DASHBOARD_OIDC_LOGIN_URL`, else the +/// issuer (ADR-0030 §7). +fn login_redirect(config: &OidcConfig) -> String { + non_empty_env(ENV_LOGIN_URL).unwrap_or_else(|| config.issuer.clone()) +} + +/// The configured minimum viewing tier, or the most-restricted default (which permits every +/// verified identity). An unknown label maps to the floor (fail-safe, never permissive). +fn configured_min_tier() -> Tier { + non_empty_env(ENV_MIN_TIER) + .map(|value| Tier::from_claim_str(&value)) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::header::{ACCEPT, CACHE_CONTROL, CONTENT_TYPE, LOCATION}; + + fn uri(path: &str) -> Uri { + path.parse().unwrap() + } + + fn accept(value: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(ACCEPT, HeaderValue::from_str(value).unwrap()); + headers + } + + #[test] + fn api_path_is_always_the_json_class_even_with_an_html_accept() { + // The path prefix is authoritative: an `/api/` route is the JSON class no matter what the + // browser's Accept header says — the hard "never 302 an /api route" guarantee. + assert_eq!( + route_class(&uri("/api/findings.json"), &accept("text/html")), + RouteClass::Api + ); + } + + #[test] + fn root_is_the_document_class_but_a_json_fetch_of_root_is_the_api_class() { + // A plain browser navigation of `/` is a document (→ 302 on failure); a JSON-preferring + // fetch of `/` is treated as the API class (→ 401, never a redirect). + assert_eq!( + route_class(&uri("/"), &HeaderMap::new()), + RouteClass::Document + ); + assert_eq!( + route_class(&uri("/"), &accept("application/json")), + RouteClass::Api + ); + } + + #[test] + fn a_browser_accept_listing_both_html_and_json_is_a_document() { + // A real browser navigation sends `text/html,application/xhtml+xml,...,application/json;...` + // — that must NOT be mistaken for a programmatic JSON client. + assert!(!prefers_json(&accept( + "text/html,application/xhtml+xml,application/json;q=0.9" + ))); + assert!(prefers_json(&accept("application/json"))); + } + + #[test] + fn assets_are_the_document_class() { + assert_eq!( + route_class(&uri("/assets/dashboard.css"), &HeaderMap::new()), + RouteClass::Document + ); + } + + #[test] + fn api_auth_failure_is_a_401_json_never_a_redirect() { + let response = deny(Denial::Auth(AuthError::MissingToken), RouteClass::Api, "x"); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!( + response.headers().get(LOCATION).is_none(), + "/api never 302s" + ); + assert_eq!( + response.headers().get(CONTENT_TYPE).unwrap(), + "application/json" + ); + assert_eq!(response.headers().get(CACHE_CONTROL).unwrap(), "no-store"); + } + + #[test] + fn document_auth_failure_is_a_302_to_login() { + let response = deny( + Denial::Auth(AuthError::Expired), + RouteClass::Document, + "https://login.example", + ); + assert_eq!(response.status(), StatusCode::FOUND); + assert_eq!( + response.headers().get(LOCATION).unwrap(), + "https://login.example" + ); + assert_eq!(response.headers().get(CACHE_CONTROL).unwrap(), "no-store"); + } + + #[test] + fn jwks_unreachable_is_a_503_on_both_classes_never_a_302() { + for class in [RouteClass::Api, RouteClass::Document] { + let response = deny(Denial::Auth(AuthError::JwksUnreachable), class, "x"); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!( + response.headers().get(LOCATION).is_none(), + "a 503 is never a redirect" + ); + } + } + + #[test] + fn forbidden_is_a_403_on_both_classes_never_a_302() { + for class in [RouteClass::Api, RouteClass::Document] { + let response = deny(Denial::Forbidden, class, "x"); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!( + response.headers().get(LOCATION).is_none(), + "a forbidden identity is not re-challenged with a redirect" + ); + } + } +} diff --git a/engine/src/engine/dashboard/auth/enforce_tests.rs b/engine/src/engine/dashboard/auth/enforce_tests.rs new file mode 100644 index 0000000..aca114a --- /dev/null +++ b/engine/src/engine/dashboard/auth/enforce_tests.rs @@ -0,0 +1,309 @@ +//! Integration tests for the LIVE-router OIDC enforcement (JEF-487 / ADR-0030 §6). They drive the +//! REAL `dashboard::router` with a configured [`Enforcer`] built over an in-memory JWKS (no egress, +//! via the JEF-485 `test_support` seam), and assert the content-negotiated fail-closed contract: +//! `/api/*.json` denials are `401` JSON and are NEVER `302`'d; a document `GET /` denial is a `302` +//! to login; a below-tier identity is `403`; JWKS-down is `503`; every rejection still carries the +//! strict CSP + `no-store`; and the unconfigured router (no enforcer) serves without rejecting. + +use std::sync::Arc; + +use axum::body::{Body, to_bytes}; +use axum::http::{Request, StatusCode, header}; +use tower::ServiceExt; + +use super::Verifier; +use super::claims::Tier; +use super::enforce::Enforcer; +use super::test_support::{ + KEY_A_N, KEY_A_PEM, KID_A, TestFetcher, base_claims, jwk_set, now, sign, test_config, +}; +use crate::engine::dashboard::{AuthMode, DashboardState, router}; +use crate::engine::journal::DecisionJournal; +use crate::engine::policy_log::PolicyDecisionLog; +use crate::engine::state::{Findings, JudgementLog, ReversionLog}; + +const LOGIN_URL: &str = "https://login.example/authorize"; + +/// A minimal, empty-but-honest dashboard state — enough to exercise the routes end-to-end. +fn empty_state(auth_mode: AuthMode) -> DashboardState { + DashboardState { + findings: Arc::new(Findings::new()), + judgements: Arc::new(JudgementLog::new()), + reversions: Arc::new(ReversionLog::new()), + decision_journal: Arc::new(DecisionJournal::disabled()), + policy_log: Arc::new(PolicyDecisionLog::new()), + cluster: "prod-test".into(), + auth_mode, + } +} + +/// An enforcer over an in-memory JWKS serving key-A, with the given minimum tier and a fixed login +/// redirect. Zero egress — the verifier fetches from the injected [`TestFetcher`]. +fn enforcer(min_tier: Tier) -> Arc { + let fetcher = Arc::new(TestFetcher::new(jwk_set(&[(KID_A, KEY_A_N)]))); + let verifier = Verifier::with_fetcher(test_config(), fetcher); + Arc::new(Enforcer::from_parts(verifier, LOGIN_URL.into(), min_tier)) +} + +/// An enforcer whose JWKS fetch always fails — the unreachable-IdP condition. +fn enforcer_jwks_down() -> Arc { + let verifier = Verifier::with_fetcher(test_config(), Arc::new(TestFetcher::failing())); + Arc::new(Enforcer::from_parts( + verifier, + LOGIN_URL.into(), + Tier::Redacted, + )) +} + +/// Send a request through the configured router and return the response. +async fn send(auth: Option>, request: Request) -> axum::response::Response { + router(empty_state(AuthMode::Oidc), auth) + .oneshot(request) + .await + .unwrap() +} + +/// A `GET` request with an optional bearer token and Accept header. +fn get(uri: &str, token: Option<&str>, accept: Option<&str>) -> Request { + let mut builder = Request::builder().uri(uri); + if let Some(token) = token { + builder = builder.header(header::AUTHORIZATION, format!("Bearer {token}")); + } + if let Some(accept) = accept { + builder = builder.header(header::ACCEPT, accept); + } + builder.body(Body::empty()).unwrap() +} + +fn valid_token() -> String { + sign(KEY_A_PEM, KID_A, &base_claims()) +} + +async fn body_string(response: axum::response::Response) -> String { + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + String::from_utf8(bytes.to_vec()).unwrap() +} + +// ------------------------------------------------------------------------------------------------- +// /api/*.json — 401 JSON, never a redirect. No token / tampered / expired. +// ------------------------------------------------------------------------------------------------- + +#[tokio::test] +async fn api_findings_with_no_token_is_401_tiny_json_never_302() { + let response = send( + Some(enforcer(Tier::Redacted)), + get("/api/findings.json", None, Some("application/json")), + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!( + response.headers().get(header::LOCATION).is_none(), + "an /api route is NEVER 302'd (a redirected fetch dies on connect-src 'self')" + ); + // No graph data — a tiny body only (no strip, no findings, no stack). + let body = body_string(response).await; + assert_eq!(body, r#"{"error":"unauthenticated"}"#); + assert!( + !body.contains("strip"), + "the 401 body carries no view-model" + ); +} + +#[tokio::test] +async fn api_findings_with_tampered_token_is_401() { + // Flip the first char of the signature segment — a deterministic signature mismatch. + let token = valid_token(); + let mut segments: Vec = token.split('.').map(String::from).collect(); + let sig = &segments[2]; + let first = sig.chars().next().unwrap(); + let replacement = if first == 'A' { 'B' } else { 'A' }; + segments[2] = format!("{replacement}{}", &sig[1..]); + let tampered = segments.join("."); + + let response = send( + Some(enforcer(Tier::Redacted)), + get( + "/api/findings.json", + Some(&tampered), + Some("application/json"), + ), + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!(response.headers().get(header::LOCATION).is_none()); +} + +#[tokio::test] +async fn api_findings_with_expired_token_is_401() { + let mut claims = base_claims(); + claims["nbf"] = serde_json::json!(now() - 7200); + claims["exp"] = serde_json::json!(now() - 3600); + let token = sign(KEY_A_PEM, KID_A, &claims); + + let response = send( + Some(enforcer(Tier::Redacted)), + get("/api/findings.json", Some(&token), Some("application/json")), + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn api_route_is_never_302_even_without_a_json_accept_header() { + // The path prefix is authoritative: even with NO Accept header the `/api/` route is the JSON + // class and a missing token is a 401, never a redirect. + let response = send( + Some(enforcer(Tier::Redacted)), + get("/api/findings.json", None, None), + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!(response.headers().get(header::LOCATION).is_none()); +} + +// ------------------------------------------------------------------------------------------------- +// Valid token — 200 with the view-model, and the honest server-derived auth-mode pill. +// ------------------------------------------------------------------------------------------------- + +#[tokio::test] +async fn api_findings_with_valid_token_is_200_with_the_view_model_and_oidc_auth_mode() { + let response = send( + Some(enforcer(Tier::Redacted)), + get( + "/api/findings.json", + Some(&valid_token()), + Some("application/json"), + ), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let value: serde_json::Value = serde_json::from_str(&body_string(response).await).unwrap(); + assert!(value.get("strip").is_some(), "a 200 serves the view-model"); + assert_eq!( + value["strip"]["auth-mode"], + serde_json::json!("oidc"), + "the enforcing dashboard reports the honest oidc pill" + ); +} + +// ------------------------------------------------------------------------------------------------- +// Document GET / — 302 to login, never for /api. +// ------------------------------------------------------------------------------------------------- + +#[tokio::test] +async fn document_root_with_no_token_is_302_to_login() { + let response = send( + Some(enforcer(Tier::Redacted)), + get("/", None, Some("text/html,application/xhtml+xml")), + ) + .await; + assert_eq!(response.status(), StatusCode::FOUND); + assert_eq!( + response.headers().get(header::LOCATION).unwrap(), + LOGIN_URL, + "an unauthenticated browser navigation is redirected to login" + ); +} + +// ------------------------------------------------------------------------------------------------- +// 403 below the minimum tier; 503 JWKS-down. +// ------------------------------------------------------------------------------------------------- + +#[tokio::test] +async fn valid_token_below_the_minimum_tier_is_403() { + // The base token is tier `forensic`; require `raw` → the identity is verified but forbidden. + let response = send( + Some(enforcer(Tier::Raw)), + get( + "/api/findings.json", + Some(&valid_token()), + Some("application/json"), + ), + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(body_string(response).await, r#"{"error":"forbidden"}"#); +} + +#[tokio::test] +async fn jwks_unreachable_is_503_never_a_bypass() { + let response = send( + Some(enforcer_jwks_down()), + get( + "/api/findings.json", + Some(&valid_token()), + Some("application/json"), + ), + ) + .await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!( + response.headers().get(header::LOCATION).is_none(), + "a 503 is never a redirect and never serves the graph" + ); +} + +// ------------------------------------------------------------------------------------------------- +// The honesty guards compose WITH auth: CSP + no-store ride every rejection. +// ------------------------------------------------------------------------------------------------- + +#[tokio::test] +async fn a_401_still_carries_the_strict_csp_and_no_store() { + let response = send( + Some(enforcer(Tier::Redacted)), + get("/api/findings.json", None, Some("application/json")), + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let csp = response + .headers() + .get(header::CONTENT_SECURITY_POLICY) + .and_then(|v| v.to_str().ok()) + .expect("the CSP layer wraps the auth rejection too"); + assert!( + csp.contains("connect-src 'self'"), + "CSP rides the 401: {csp}" + ); + assert_eq!( + response.headers().get(header::CACHE_CONTROL).unwrap(), + "no-store", + "a cached 401/302 is the JEF-283 edge bug — every rejection is no-store" + ); +} + +#[tokio::test] +async fn a_302_login_redirect_is_no_store() { + let response = send( + Some(enforcer(Tier::Redacted)), + get("/", None, Some("text/html")), + ) + .await; + assert_eq!(response.status(), StatusCode::FOUND); + assert_eq!( + response.headers().get(header::CACHE_CONTROL).unwrap(), + "no-store", + "a cached 302->login is exactly the Cloudflare edge bug (JEF-283)" + ); +} + +// ------------------------------------------------------------------------------------------------- +// Unconfigured (no enforcer) — serves without rejecting; the loud-WARN passthrough is in run_loop. +// ------------------------------------------------------------------------------------------------- + +#[tokio::test] +async fn unconfigured_router_serves_without_rejecting() { + // The config-absent path (None) must NOT reject — an unauthenticated request is served as today + // (edge-trust only). This is the escape hatch that keeps an existing deploy from being locked + // out on upgrade (ADR-0030 §6). + let response = router(empty_state(AuthMode::EdgeOnly), None) + .oneshot(get("/api/findings.json", None, Some("application/json"))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let value: serde_json::Value = serde_json::from_str(&body_string(response).await).unwrap(); + assert_eq!( + value["strip"]["auth-mode"], + serde_json::json!("edge-only"), + "an unconfigured dashboard honestly reports edge-only" + ); +} diff --git a/engine/src/engine/dashboard/auth/mod.rs b/engine/src/engine/dashboard/auth/mod.rs index d876142..bd6baf9 100644 --- a/engine/src/engine/dashboard/auth/mod.rs +++ b/engine/src/engine/dashboard/auth/mod.rs @@ -11,15 +11,26 @@ //! sees a JWT, not how it arrived (ADR-0030 §3/§6/§7). //! //! **Scope (JEF-485):** this module is the verifier primitive + a mountable middleware layer. The -//! layer is the sibling shape to [`super::security_headers::set_csp`] and CAN be mounted, but it -//! is deliberately **NOT** wired into [`super::router`] here — enforcement wiring and content -//! negotiation (login redirect vs JSON `401`, the loud unconfigured-mode passthrough) are a later -//! ticket (JEF-487). [`OidcConfig::from_env`] merely models the UNCONFIGURED state (issuer absent) -//! so that later ticket can decide the passthrough behavior. +//! content-negotiating enforcement wiring that mounts it on the live [`super::router`] (login +//! redirect vs JSON `401`, the loud unconfigured-mode passthrough) lives in [`enforce`] (JEF-487). +//! [`OidcConfig::from_env`] models the UNCONFIGURED state (issuer absent) so that wiring can choose +//! the passthrough behavior. +//! +//! **Env namespace (JEF-487).** The dashboard-auth env vars are `PROTECTOR_DASHBOARD_OIDC_*` — a +//! namespace DISTINCT from the sigstore/cosign signature-verification `PROTECTOR_OIDC_ISSUER` +//! (the Fulcio keyless *cert-identity* issuer, unrelated to who may VIEW the dashboard). The two +//! would otherwise collide: the chart sets the signature issuer unconditionally, which — under a +//! shared name — would silently flip every existing deploy into fail-closed dashboard auth against +//! the wrong issuer and lock operators out on upgrade (the exact regression ADR-0030 §6 forbids). pub mod claims; +pub mod enforce; pub mod jwks; +#[cfg(test)] +mod enforce_tests; +#[cfg(test)] +pub(crate) mod test_support; #[cfg(test)] mod tests; @@ -35,17 +46,19 @@ use jsonwebtoken::{Algorithm, Validation, decode, decode_header}; use claims::{Claims, Tier}; use jwks::{HttpJwksFetcher, JwksFetcher, JwksStore}; -/// `PROTECTOR_OIDC_ISSUER` — the configured issuer. Its ABSENCE is what makes the verifier -/// UNCONFIGURED (edge-trust only), the single, loud bypass ADR-0030 §6 names. -const ENV_ISSUER: &str = "PROTECTOR_OIDC_ISSUER"; -/// `PROTECTOR_OIDC_AUDIENCE` — required once an issuer is configured. -const ENV_AUDIENCE: &str = "PROTECTOR_OIDC_AUDIENCE"; -/// `PROTECTOR_OIDC_TIER_CLAIM` — the configurable claim path the tier is read from. -const ENV_TIER_CLAIM: &str = "PROTECTOR_OIDC_TIER_CLAIM"; -/// `PROTECTOR_OIDC_ALGORITHM` — the pinned asymmetric algorithm (`RS256` | `ES256`). -const ENV_ALGORITHM: &str = "PROTECTOR_OIDC_ALGORITHM"; - -/// The default tier claim path when `PROTECTOR_OIDC_TIER_CLAIM` is unset. +/// `PROTECTOR_DASHBOARD_OIDC_ISSUER` — the configured issuer. Its ABSENCE is what makes the +/// verifier UNCONFIGURED (edge-trust only), the single, loud bypass ADR-0030 §6 names. Namespaced +/// under `PROTECTOR_DASHBOARD_OIDC_*` so it never collides with the sigstore signature-verification +/// `PROTECTOR_OIDC_ISSUER` (the cosign cert-identity issuer — a different concern, see the mod doc). +const ENV_ISSUER: &str = "PROTECTOR_DASHBOARD_OIDC_ISSUER"; +/// `PROTECTOR_DASHBOARD_OIDC_AUDIENCE` — required once an issuer is configured. +const ENV_AUDIENCE: &str = "PROTECTOR_DASHBOARD_OIDC_AUDIENCE"; +/// `PROTECTOR_DASHBOARD_OIDC_TIER_CLAIM` — the configurable claim path the tier is read from. +const ENV_TIER_CLAIM: &str = "PROTECTOR_DASHBOARD_OIDC_TIER_CLAIM"; +/// `PROTECTOR_DASHBOARD_OIDC_ALGORITHM` — the pinned asymmetric algorithm (`RS256` | `ES256`). +const ENV_ALGORITHM: &str = "PROTECTOR_DASHBOARD_OIDC_ALGORITHM"; + +/// The default tier claim path when `PROTECTOR_DASHBOARD_OIDC_TIER_CLAIM` is unset. const DEFAULT_TIER_CLAIM: &str = "tier"; /// The Cloudflare Access assertion header — the browser lane (ADR-0030 §7). @@ -104,16 +117,16 @@ pub struct OidcConfig { /// is a MISconfiguration (fail loud), distinct from the UNCONFIGURED case (issuer absent → `None`). #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum ConfigError { - /// An issuer is set but `PROTECTOR_OIDC_AUDIENCE` is missing — verification needs an audience. + /// An issuer is set but `PROTECTOR_DASHBOARD_OIDC_AUDIENCE` is missing — verification needs one. #[error("{ENV_AUDIENCE} must be set when {ENV_ISSUER} is configured")] MissingAudience, - /// `PROTECTOR_OIDC_ALGORITHM` is set to something other than a supported asymmetric algorithm. + /// `PROTECTOR_DASHBOARD_OIDC_ALGORITHM` is something other than a supported asymmetric algorithm. #[error("{ENV_ALGORITHM} `{0}` is not a supported asymmetric algorithm (RS256, ES256)")] UnsupportedAlgorithm(String), } impl OidcConfig { - /// Build from the environment (ADR-0030). `PROTECTOR_OIDC_ISSUER` **absent/empty** ⇒ `Ok(None)` + /// Build from the environment (ADR-0030). `PROTECTOR_DASHBOARD_OIDC_ISSUER` **absent/empty** ⇒ `Ok(None)` /// (UNCONFIGURED — representable so the enforcement ticket can decide loud-log-and-passthrough). /// An issuer WITHOUT an audience, or an unsupported algorithm, ⇒ `Err(ConfigError)` (a loud /// misconfiguration). Otherwise ⇒ `Ok(Some(config))`. @@ -139,7 +152,7 @@ impl OidcConfig { } /// A trimmed, non-empty environment value, or `None` if unset/blank. -fn non_empty_env(key: &str) -> Option { +pub(super) fn non_empty_env(key: &str) -> Option { std::env::var(key) .ok() .map(|value| value.trim().to_string()) @@ -331,19 +344,17 @@ fn build_validation(config: &OidcConfig) -> Validation { /// failure it DENIES (fail closed, ADR-0030 §6): a JWKS-unreachable condition is a `503`, every /// other failure a `401`. /// -/// This is the sibling shape to [`super::security_headers::set_csp`] — a mountable layer. It is -/// deliberately **NOT** wired into [`super::router`] in this ticket (JEF-485); enforcement wiring -/// and content negotiation are JEF-487. Mount it with -/// `axum::middleware::from_fn_with_state(Arc, require_oidc)`. +/// This is the sibling shape to [`super::security_headers::set_csp`] — a mountable layer that emits +/// a BARE status on failure (no content negotiation). The live [`super::router`] mounts the +/// content-negotiating [`enforce`] layer instead (JEF-487, ADR-0030 §6), which reuses the shared +/// [`authenticate`] seam this layer also uses — so the verification logic lives in exactly one +/// place. Mount this bare form with `axum::middleware::from_fn_with_state(Arc, require_oidc)`. pub async fn require_oidc( State(verifier): State>, mut request: Request, next: Next, ) -> Response { - let Some(token) = extract_token(request.headers()) else { - return AuthError::MissingToken.into_response(); - }; - match verifier.verify(&token).await { + match authenticate(&verifier, request.headers()).await { Ok(identity) => { request.extensions_mut().insert(identity); next.run(request).await @@ -355,6 +366,15 @@ pub async fn require_oidc( } } +/// Authenticate a request: extract the JWT from ANY arrival lane and verify it, yielding the +/// normalized [`Identity`] or the distinct [`AuthError`]. This is the ONE shared seam both the bare +/// [`require_oidc`] layer and the content-negotiating [`enforce`] layer route through — the +/// verification logic (extract → verify → fail-closed on every error) lives here, never duplicated. +pub async fn authenticate(verifier: &Verifier, headers: &HeaderMap) -> Result { + let token = extract_token(headers).ok_or(AuthError::MissingToken)?; + verifier.verify(&token).await +} + /// Extract the JWT from a request from EITHER lane, so one verification path serves both machine /// `Authorization: Bearer` tokens (incl. ID-JAG, `aud=protector`) and browser Cloudflare Access /// assertions. Priority: `Authorization: Bearer`, then the CF assertion header, then the diff --git a/engine/src/engine/dashboard/auth/test_support.rs b/engine/src/engine/dashboard/auth/test_support.rs new file mode 100644 index 0000000..128c9a4 --- /dev/null +++ b/engine/src/engine/dashboard/auth/test_support.rs @@ -0,0 +1,170 @@ +//! Shared, zero-egress test scaffolding for the OIDC auth suites (JEF-485 verifier unit tests + +//! JEF-487 enforcement integration tests). Fixed test RSA keypairs are embedded, and the JWKS is +//! served in-memory by [`TestFetcher`] — so the whole suite runs with NO network: the verifier's +//! [`JwksFetcher`] seam is what lets a test hand it a key set without a fetch. Extracted here so the +//! two suites mint valid/invalid/rotated tokens from ONE source (no fixture duplication). + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use jsonwebtoken::jwk::JwkSet; +use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; +use serde_json::{Value, json}; + +use super::jwks::JwksFetcher; +use super::{AuthError, OidcConfig, SigningAlgorithm, Verifier}; + +pub(crate) const ISSUER: &str = "https://issuer.example"; +pub(crate) const AUDIENCE: &str = "protector"; +pub(crate) const KID_A: &str = "key-a"; +pub(crate) const KID_B: &str = "key-b"; +/// RSA public exponent 65537 for every fixture key (base64url of `0x010001`). +pub(crate) const E: &str = "AQAB"; + +// Two fixed test RSA-2048 keypairs (PKCS#8). Test-only; never used outside this suite. +pub(crate) const KEY_A_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCEU9JVOiw5ixgW\nU8pg41O2YjdX3ycGLXzptlAcnnrZ4B0E5lML7EVpWWzDQ4LkcdMRAYBG+NfqjFUU\n4iGdTxxehE4lkL7na4SkC5AgWknPS7kMwlg5TiPbOGmiO1cHIrKKewba4CKZidbc\n0A/h+4D4UdoHJTfPrFyvLzMDxKCWkevz47D40k+BJK6XuKbJ8nX5+qwLakbRm80y\ne3Ys+c0McRHXagnAKSn4BMCA4qxu1a3/kp0obBhFNR2o5RiLu0d59uaS2uW2qS+q\neCA36ttYtDY/zrSd1LeZynHPUZWLzibiuLJ36KYUtyaRWpmtmUZbtpIw/m9Gq+ER\nJL5FlWdLAgMBAAECggEAQIgAEsADwSwr2veRwh4aPN84zgltJn4YZIEcIFjI4GeC\nv1jzNuVKFE4f1DmgI3e+zpRE0leYNDGrbDu62NQzqYQr9/XWo1Szoqxg5OYjCIyM\n+cPs8kVBBy9DlHILxtcM6quEdEjJlsa5mYV9uV7FTlPcV4+23/fWWzhRUI0bI1Hi\nk1iM4Qj7QT/DM6VzVWHyDkL4Kh9SxnyYZ2MWpH7feCrnr2buuHC3oO2Hc2SaTxwB\niILtLzApFJfmaKMkmfuK3goE886fe3Triw4Db5tod3gTm1S/+T1x782GN+kaba1I\nH5CClZvJXN0a8BeSt6QbkPQBoogh28xO1WekAWcKAQKBgQC4TNbChgVE35w9JCRT\neYnJlTg13Pieq2TZmhfH/Muh1UnU+mEV5JQCfiKz1NOly+P3svpi2XEprtFIciU2\nyF1v55ZjOqOTB0nQdSxrP2Bjs0PBUjkwrJ94751J10naXHHZx5jz18LsQClnkoqD\nwetBACndqqntb7kUrXz1kge7KwKBgQC3ztG4OVaWTsgJ/7ekSeT+SUsHaQRs1CIS\nAF0IKPWN6A81eck1OXboaHnaK2rfAVkBC2OzVsFSYtyZjQ2Rr6D1jHFHMNegA1im\nyS4dLyV5/oGbB0b9pXxpjm8cQ0QDB1AUWzLzkiB6xS/OZaQrEsctwGpF/441S+jH\n49Psg4h0YQKBgBaHNf1DOqOnncaPg208vw4IEn3rC+0BUGuU/XExwoZ+tu60yGdP\nsJP5bS6ERnbOzIf7tcWdhMquluB/K3Nd3KYQLf7lLReM3YYAvLRDY/nr8M1RyrHb\neAbla1maWmm5wST41AaCik4sraL+c7YVXzdr2LJC6VCfxoTzjAHMnutPAoGAaorz\nfXme+xlHUqRrakt69Pq/BtiUvBBqf0y+oFA9pbfxuOmS+8sHZcfJefDYzdMWKEjV\nzcpn3L15aXgdeWj4P9zcfIuPMS0/Yc4TcM83RfOEZLxfJf+akgUB2rwS3D6M6H/E\nlPMK6J8MCvNXqbAEzDxQXaq4X6RUlik1Wk8T9YECgYA/9HsG1gWhym7sOrRjKZuo\ndaCPOThXL4diIz3gjLB4CUeXnuPKB8qMhHBHA3DJTOppa2jznIpwkkTjLBS0IxGE\nTPxpfAAVlyJTwpAzQd5gF024sAjjMXcYqwPyHIdOFeRfqw5b1aPJCNFCmJzKuXDb\nvTzQghpuo/rDAj9SCJ+K1A==\n-----END PRIVATE KEY-----\n"; +pub(crate) const KEY_A_N: &str = "hFPSVTosOYsYFlPKYONTtmI3V98nBi186bZQHJ562eAdBOZTC-xFaVlsw0OC5HHTEQGARvjX6oxVFOIhnU8cXoROJZC-52uEpAuQIFpJz0u5DMJYOU4j2zhpojtXByKyinsG2uAimYnW3NAP4fuA-FHaByU3z6xcry8zA8SglpHr8-Ow-NJPgSSul7imyfJ1-fqsC2pG0ZvNMnt2LPnNDHER12oJwCkp-ATAgOKsbtWt_5KdKGwYRTUdqOUYi7tHefbmktrltqkvqnggN-rbWLQ2P860ndS3mcpxz1GVi84m4riyd-imFLcmkVqZrZlGW7aSMP5vRqvhESS-RZVnSw"; + +pub(crate) const KEY_B_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDAswonwmj3aTMZ\nVHjyTeDOt+x+aFggUyIBgjW1+0NEtN9XSk9kqkmghjgHZN/wEscvzp3Ppp1bjS9c\ntCsLpTEajTIFkR0naTYtnxtLCVtOp0jxhL+lvtUu5y2e5mmrvj3IG8TEg+PMu5Fn\nGdgCugzB++3Qa2zQWm+LW5ZtFnKmHeT/KmJjsq7hFYQMaoaJBrGf43vm37k7zfSX\nYw9VAr17YhXeX40g6n2DPdjADl9MBX/dDLXCfkGIPizsV5pGLEwO85mJ+cyS9vpw\nbuhy0bi6l/+yQlQ31difcBBzCcagBDkbGaCnlOHpcC5uOIfyAubN/iKQnjtSQp3K\nSdvG8MmxAgMBAAECggEABvo0ou3qKRM5E3C4lGV3f2SvfoA+uKTp9U4Grdk0PVej\nQqDhMQ7tbY+Olc30QdgcOEHt+ufYiMka7utjJ5/KoGB+cC8p9BReLta1AUmMcdOi\n04PwAIthYrpiL3++UcaorAc9X7Q62l3sTORlquubrKZ3nPVW0lCD+3LMhpSqgBNC\n8lvVSjpto+PDi3WNH7l3dk0ukU52N/neY73mZ9v2TAmlcpvNb97j5SlkirJlxe92\nVCEAckn3ZlPFzr8ZFEaUzZ/iCkzJFF2zrBVkmIJhT09bCNPG/HymbSjmBsDYqNS2\n1xwC2xO8sl1bI+pniQXSN7HiRfKX2ka1fIhui+h/RQKBgQDhXT7TiljtptLFjz1S\n5coalluMXVNIWLMcJopJsZm4Uy8G9AJ6G0x3Tnp0UpaGg/3TDEwXTrQYpHqYXvxY\njCxJzT7FqyrrxCIhJKNVOa0+4VnP0ydRJYGE3pMnAWpPuxKfnmJQA3P3iy+Q4kkw\n6zzI5+/zsFpOINwcgw3UsbTZewKBgQDa5Qui6Hv3kKEXrpNNLO+mtHOrFqcjfV1C\noxZ2siZw7fM/pl86HCEEJpmRFJTQ3sRv6DwUcSfw+dxg0k1b5rL5R8gRBM0A1QET\nW9zDPZF0YVDrx4ACEnn8m0kYhGhmo6LGRsq8ukm3+iI/Hz2cx2jh3I0NxmNy+olg\nPaRW4LETwwKBgQCUU4jMNhw9njTPLm2QKAmS4i8y/SGZVjfcaUlPI4MnHCixjNws\nfdcgFxjlgo3rzue6hjd2h6hlJ6xAqROxO+DSWjHca8H+FsLXyYNuzl1GK4+vByyz\nbdoHF28GlxnfjCK/x8CxJPSokoUl+KlvdwQ0vuLhIsrs7Rex9FegC64aDQKBgGOk\nijSBUhUy8DIAlSs3fmxLjq/eIv1jzvVLmik0FY2os+dQi96++USTcap6TPf7wD4U\n4GyJyh3HD8u/T9m63dPeGjOtFMkBLXkrgwYZW8I3noeGDD5lPMSBx7dyZrf6W1mY\n1ictQeuO4NINHZXlrFfMdyVDHvgzFiAKT2oA5HrTAoGBAN9JyGIqcAHMHxp86VR1\npDEgJd+WvR9/kGxAepqnnkXcpM2o7bsio3XWSmrykGFEJnjX1LfkZ+16n5OdIYYm\n0S7U4ryCaXybsVfoTw4doOOEzXCxCCWR0P/l3BPSkedWyWyqGJSCnvDKkbG59qF5\ny/UYE64QE1FLiOsLb4Xmbmx6\n-----END PRIVATE KEY-----\n"; +pub(crate) const KEY_B_N: &str = "wLMKJ8Jo92kzGVR48k3gzrfsfmhYIFMiAYI1tftDRLTfV0pPZKpJoIY4B2Tf8BLHL86dz6adW40vXLQrC6UxGo0yBZEdJ2k2LZ8bSwlbTqdI8YS_pb7VLuctnuZpq749yBvExIPjzLuRZxnYAroMwfvt0Gts0Fpvi1uWbRZyph3k_ypiY7Ku4RWEDGqGiQaxn-N75t-5O830l2MPVQK9e2IV3l-NIOp9gz3YwA5fTAV_3Qy1wn5BiD4s7FeaRixMDvOZifnMkvb6cG7octG4upf_skJUN9XYn3AQcwnGoAQ5Gxmgp5Th6XAubjiH8gLmzf4ikJ47UkKdyknbxvDJsQ"; + +/// A JWKS fetcher backed by an in-memory set, with a call counter (to prove single-flight) and a +/// `rotate` hook (to prove refetch-on-unknown-kid). It can also be made to always fail, to prove +/// the fail-closed path when the IdP is unreachable. +pub(crate) struct TestFetcher { + keys: std::sync::Mutex, + calls: AtomicUsize, + fail: bool, +} + +impl TestFetcher { + pub(crate) fn new(set: JwkSet) -> Self { + Self { + keys: std::sync::Mutex::new(set), + calls: AtomicUsize::new(0), + fail: false, + } + } + + /// A fetcher that always errors — the unreachable-IdP condition. + pub(crate) fn failing() -> Self { + Self { + keys: std::sync::Mutex::new(jwk_set(&[])), + calls: AtomicUsize::new(0), + fail: true, + } + } + + /// Swap the served set — simulates the IdP rotating its signing keys mid-process. + pub(crate) fn rotate(&self, set: JwkSet) { + *self.keys.lock().unwrap() = set; + } + + /// How many times the store actually hit the fetcher. + pub(crate) fn call_count(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } +} + +#[async_trait] +impl JwksFetcher for TestFetcher { + async fn fetch(&self) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + if self.fail { + return Err(AuthError::JwksUnreachable); + } + Ok(self.keys.lock().unwrap().clone()) + } +} + +/// Build a JWK set from `(kid, n)` pairs (all RS256, exponent `AQAB`). +pub(crate) fn jwk_set(entries: &[(&str, &str)]) -> JwkSet { + let keys: Vec = entries + .iter() + .map(|(kid, n)| { + json!({ "kty": "RSA", "use": "sig", "alg": "RS256", "kid": kid, "n": n, "e": E }) + }) + .collect(); + serde_json::from_value(json!({ "keys": keys })).expect("valid JWK set") +} + +pub(crate) fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() +} + +/// A valid claim set: correct `iss`/`aud`, a human `sub`, in-window `exp`/`nbf`, tier `forensic`. +pub(crate) fn base_claims() -> Value { + json!({ + "iss": ISSUER, + "aud": AUDIENCE, + "sub": "user@example.com", + "iat": now() - 10, + "nbf": now() - 10, + "exp": now() + 3600, + "tier": "forensic", + }) +} + +/// Sign `claims` as an RS256 JWT with the given `kid`. +pub(crate) fn sign(pem: &str, kid: &str, claims: &Value) -> String { + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(kid.to_string()); + let key = EncodingKey::from_rsa_pem(pem.as_bytes()).expect("valid RSA PEM"); + encode(&header, claims, &key).expect("sign token") +} + +pub(crate) fn test_config() -> OidcConfig { + OidcConfig { + issuer: ISSUER.into(), + audience: AUDIENCE.into(), + tier_claim: "tier".into(), + algorithm: SigningAlgorithm::Rs256, + } +} + +/// A verifier serving `KEY_A` in-memory, plus the fetcher handle (for call counting / rotation). +pub(crate) fn verifier_with_key_a() -> (Verifier, Arc) { + let fetcher = Arc::new(TestFetcher::new(jwk_set(&[(KID_A, KEY_A_N)]))); + ( + Verifier::with_fetcher(test_config(), fetcher.clone()), + fetcher, + ) +} + +/// Spin a localhost (loopback-only, no external egress) OIDC discovery + JWKS server serving +/// `jwks_body` at its `jwks_uri`. Returns the issuer base URL and the server task handle. +pub(crate) async fn spawn_oidc_server(jwks_body: String) -> (String, tokio::task::JoinHandle<()>) { + use axum::Router; + use axum::http::header::CONTENT_TYPE; + use axum::routing::get; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let discovery = json!({ "jwks_uri": format!("{base}/jwks") }).to_string(); + let app = Router::new() + .route( + "/.well-known/openid-configuration", + get(move || { + let discovery = discovery.clone(); + async move { ([(CONTENT_TYPE, "application/json")], discovery) } + }), + ) + .route( + "/jwks", + get(move || { + let jwks_body = jwks_body.clone(); + async move { ([(CONTENT_TYPE, "application/json")], jwks_body) } + }), + ); + let handle = tokio::spawn(async move { + axum::serve(listener, app.into_make_service()) + .await + .unwrap(); + }); + (base, handle) +} diff --git a/engine/src/engine/dashboard/auth/tests.rs b/engine/src/engine/dashboard/auth/tests.rs index def6933..8c657ce 100644 --- a/engine/src/engine/dashboard/auth/tests.rs +++ b/engine/src/engine/dashboard/auth/tests.rs @@ -1,150 +1,25 @@ //! Unit tests for the OIDC verifier (JEF-485 / ADR-0030). //! -//! Keys are fixed test RSA keypairs embedded below (the `jsonwebtoken` fixture pattern), and the -//! JWKS is served in-memory by [`TestFetcher`] — so the whole suite runs with ZERO egress: the -//! verifier's [`JwksFetcher`] seam is what lets a test hand it a key set without a network fetch. +//! Keys and the in-memory (zero-egress) JWKS fetcher live in [`super::test_support`] — the whole +//! suite mints valid/invalid/rotated tokens through that shared seam without a network fetch. use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; -use async_trait::async_trait; use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use jsonwebtoken::jwk::JwkSet; -use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; -use serde_json::{Value, json}; +use serde_json::json; use super::claims::{Claims, Tier}; use super::jwks::{HttpJwksFetcher, JwksFetcher, JwksStore}; +use super::test_support::{ + AUDIENCE, E, ISSUER, KEY_A_N, KEY_A_PEM, KEY_B_N, KEY_B_PEM, KID_A, KID_B, TestFetcher, + base_claims, jwk_set, now, sign, spawn_oidc_server, test_config, verifier_with_key_a, +}; use super::{ AuthError, ConfigError, Identity, OidcConfig, SigningAlgorithm, Verifier, require_oidc, }; -const ISSUER: &str = "https://issuer.example"; -const AUDIENCE: &str = "protector"; -const KID_A: &str = "key-a"; -const KID_B: &str = "key-b"; -/// RSA public exponent 65537 for every fixture key (base64url of `0x010001`). -const E: &str = "AQAB"; - -// Two fixed test RSA-2048 keypairs (PKCS#8). Test-only; never used outside this suite. -const KEY_A_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCEU9JVOiw5ixgW\nU8pg41O2YjdX3ycGLXzptlAcnnrZ4B0E5lML7EVpWWzDQ4LkcdMRAYBG+NfqjFUU\n4iGdTxxehE4lkL7na4SkC5AgWknPS7kMwlg5TiPbOGmiO1cHIrKKewba4CKZidbc\n0A/h+4D4UdoHJTfPrFyvLzMDxKCWkevz47D40k+BJK6XuKbJ8nX5+qwLakbRm80y\ne3Ys+c0McRHXagnAKSn4BMCA4qxu1a3/kp0obBhFNR2o5RiLu0d59uaS2uW2qS+q\neCA36ttYtDY/zrSd1LeZynHPUZWLzibiuLJ36KYUtyaRWpmtmUZbtpIw/m9Gq+ER\nJL5FlWdLAgMBAAECggEAQIgAEsADwSwr2veRwh4aPN84zgltJn4YZIEcIFjI4GeC\nv1jzNuVKFE4f1DmgI3e+zpRE0leYNDGrbDu62NQzqYQr9/XWo1Szoqxg5OYjCIyM\n+cPs8kVBBy9DlHILxtcM6quEdEjJlsa5mYV9uV7FTlPcV4+23/fWWzhRUI0bI1Hi\nk1iM4Qj7QT/DM6VzVWHyDkL4Kh9SxnyYZ2MWpH7feCrnr2buuHC3oO2Hc2SaTxwB\niILtLzApFJfmaKMkmfuK3goE886fe3Triw4Db5tod3gTm1S/+T1x782GN+kaba1I\nH5CClZvJXN0a8BeSt6QbkPQBoogh28xO1WekAWcKAQKBgQC4TNbChgVE35w9JCRT\neYnJlTg13Pieq2TZmhfH/Muh1UnU+mEV5JQCfiKz1NOly+P3svpi2XEprtFIciU2\nyF1v55ZjOqOTB0nQdSxrP2Bjs0PBUjkwrJ94751J10naXHHZx5jz18LsQClnkoqD\nwetBACndqqntb7kUrXz1kge7KwKBgQC3ztG4OVaWTsgJ/7ekSeT+SUsHaQRs1CIS\nAF0IKPWN6A81eck1OXboaHnaK2rfAVkBC2OzVsFSYtyZjQ2Rr6D1jHFHMNegA1im\nyS4dLyV5/oGbB0b9pXxpjm8cQ0QDB1AUWzLzkiB6xS/OZaQrEsctwGpF/441S+jH\n49Psg4h0YQKBgBaHNf1DOqOnncaPg208vw4IEn3rC+0BUGuU/XExwoZ+tu60yGdP\nsJP5bS6ERnbOzIf7tcWdhMquluB/K3Nd3KYQLf7lLReM3YYAvLRDY/nr8M1RyrHb\neAbla1maWmm5wST41AaCik4sraL+c7YVXzdr2LJC6VCfxoTzjAHMnutPAoGAaorz\nfXme+xlHUqRrakt69Pq/BtiUvBBqf0y+oFA9pbfxuOmS+8sHZcfJefDYzdMWKEjV\nzcpn3L15aXgdeWj4P9zcfIuPMS0/Yc4TcM83RfOEZLxfJf+akgUB2rwS3D6M6H/E\nlPMK6J8MCvNXqbAEzDxQXaq4X6RUlik1Wk8T9YECgYA/9HsG1gWhym7sOrRjKZuo\ndaCPOThXL4diIz3gjLB4CUeXnuPKB8qMhHBHA3DJTOppa2jznIpwkkTjLBS0IxGE\nTPxpfAAVlyJTwpAzQd5gF024sAjjMXcYqwPyHIdOFeRfqw5b1aPJCNFCmJzKuXDb\nvTzQghpuo/rDAj9SCJ+K1A==\n-----END PRIVATE KEY-----\n"; -const KEY_A_N: &str = "hFPSVTosOYsYFlPKYONTtmI3V98nBi186bZQHJ562eAdBOZTC-xFaVlsw0OC5HHTEQGARvjX6oxVFOIhnU8cXoROJZC-52uEpAuQIFpJz0u5DMJYOU4j2zhpojtXByKyinsG2uAimYnW3NAP4fuA-FHaByU3z6xcry8zA8SglpHr8-Ow-NJPgSSul7imyfJ1-fqsC2pG0ZvNMnt2LPnNDHER12oJwCkp-ATAgOKsbtWt_5KdKGwYRTUdqOUYi7tHefbmktrltqkvqnggN-rbWLQ2P860ndS3mcpxz1GVi84m4riyd-imFLcmkVqZrZlGW7aSMP5vRqvhESS-RZVnSw"; - -const KEY_B_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDAswonwmj3aTMZ\nVHjyTeDOt+x+aFggUyIBgjW1+0NEtN9XSk9kqkmghjgHZN/wEscvzp3Ppp1bjS9c\ntCsLpTEajTIFkR0naTYtnxtLCVtOp0jxhL+lvtUu5y2e5mmrvj3IG8TEg+PMu5Fn\nGdgCugzB++3Qa2zQWm+LW5ZtFnKmHeT/KmJjsq7hFYQMaoaJBrGf43vm37k7zfSX\nYw9VAr17YhXeX40g6n2DPdjADl9MBX/dDLXCfkGIPizsV5pGLEwO85mJ+cyS9vpw\nbuhy0bi6l/+yQlQ31difcBBzCcagBDkbGaCnlOHpcC5uOIfyAubN/iKQnjtSQp3K\nSdvG8MmxAgMBAAECggEABvo0ou3qKRM5E3C4lGV3f2SvfoA+uKTp9U4Grdk0PVej\nQqDhMQ7tbY+Olc30QdgcOEHt+ufYiMka7utjJ5/KoGB+cC8p9BReLta1AUmMcdOi\n04PwAIthYrpiL3++UcaorAc9X7Q62l3sTORlquubrKZ3nPVW0lCD+3LMhpSqgBNC\n8lvVSjpto+PDi3WNH7l3dk0ukU52N/neY73mZ9v2TAmlcpvNb97j5SlkirJlxe92\nVCEAckn3ZlPFzr8ZFEaUzZ/iCkzJFF2zrBVkmIJhT09bCNPG/HymbSjmBsDYqNS2\n1xwC2xO8sl1bI+pniQXSN7HiRfKX2ka1fIhui+h/RQKBgQDhXT7TiljtptLFjz1S\n5coalluMXVNIWLMcJopJsZm4Uy8G9AJ6G0x3Tnp0UpaGg/3TDEwXTrQYpHqYXvxY\njCxJzT7FqyrrxCIhJKNVOa0+4VnP0ydRJYGE3pMnAWpPuxKfnmJQA3P3iy+Q4kkw\n6zzI5+/zsFpOINwcgw3UsbTZewKBgQDa5Qui6Hv3kKEXrpNNLO+mtHOrFqcjfV1C\noxZ2siZw7fM/pl86HCEEJpmRFJTQ3sRv6DwUcSfw+dxg0k1b5rL5R8gRBM0A1QET\nW9zDPZF0YVDrx4ACEnn8m0kYhGhmo6LGRsq8ukm3+iI/Hz2cx2jh3I0NxmNy+olg\nPaRW4LETwwKBgQCUU4jMNhw9njTPLm2QKAmS4i8y/SGZVjfcaUlPI4MnHCixjNws\nfdcgFxjlgo3rzue6hjd2h6hlJ6xAqROxO+DSWjHca8H+FsLXyYNuzl1GK4+vByyz\nbdoHF28GlxnfjCK/x8CxJPSokoUl+KlvdwQ0vuLhIsrs7Rex9FegC64aDQKBgGOk\nijSBUhUy8DIAlSs3fmxLjq/eIv1jzvVLmik0FY2os+dQi96++USTcap6TPf7wD4U\n4GyJyh3HD8u/T9m63dPeGjOtFMkBLXkrgwYZW8I3noeGDD5lPMSBx7dyZrf6W1mY\n1ictQeuO4NINHZXlrFfMdyVDHvgzFiAKT2oA5HrTAoGBAN9JyGIqcAHMHxp86VR1\npDEgJd+WvR9/kGxAepqnnkXcpM2o7bsio3XWSmrykGFEJnjX1LfkZ+16n5OdIYYm\n0S7U4ryCaXybsVfoTw4doOOEzXCxCCWR0P/l3BPSkedWyWyqGJSCnvDKkbG59qF5\ny/UYE64QE1FLiOsLb4Xmbmx6\n-----END PRIVATE KEY-----\n"; -const KEY_B_N: &str = "wLMKJ8Jo92kzGVR48k3gzrfsfmhYIFMiAYI1tftDRLTfV0pPZKpJoIY4B2Tf8BLHL86dz6adW40vXLQrC6UxGo0yBZEdJ2k2LZ8bSwlbTqdI8YS_pb7VLuctnuZpq749yBvExIPjzLuRZxnYAroMwfvt0Gts0Fpvi1uWbRZyph3k_ypiY7Ku4RWEDGqGiQaxn-N75t-5O830l2MPVQK9e2IV3l-NIOp9gz3YwA5fTAV_3Qy1wn5BiD4s7FeaRixMDvOZifnMkvb6cG7octG4upf_skJUN9XYn3AQcwnGoAQ5Gxmgp5Th6XAubjiH8gLmzf4ikJ47UkKdyknbxvDJsQ"; - -// --------------------------------------------------------------------------------------------- -// Test helpers: an in-memory JWKS fetcher (no egress) + token signing. -// --------------------------------------------------------------------------------------------- - -/// A JWKS fetcher backed by an in-memory set, with a call counter (to prove single-flight) and a -/// `rotate` hook (to prove refetch-on-unknown-kid). It can also be made to always fail, to prove -/// the fail-closed path when the IdP is unreachable. -struct TestFetcher { - keys: std::sync::Mutex, - calls: AtomicUsize, - fail: bool, -} - -impl TestFetcher { - fn new(set: JwkSet) -> Self { - Self { - keys: std::sync::Mutex::new(set), - calls: AtomicUsize::new(0), - fail: false, - } - } - - /// A fetcher that always errors — the unreachable-IdP condition. - fn failing() -> Self { - Self { - keys: std::sync::Mutex::new(jwk_set(&[])), - calls: AtomicUsize::new(0), - fail: true, - } - } - - /// Swap the served set — simulates the IdP rotating its signing keys mid-process. - fn rotate(&self, set: JwkSet) { - *self.keys.lock().unwrap() = set; - } - - /// How many times the store actually hit the fetcher. - fn call_count(&self) -> usize { - self.calls.load(Ordering::SeqCst) - } -} - -#[async_trait] -impl JwksFetcher for TestFetcher { - async fn fetch(&self) -> Result { - self.calls.fetch_add(1, Ordering::SeqCst); - if self.fail { - return Err(AuthError::JwksUnreachable); - } - Ok(self.keys.lock().unwrap().clone()) - } -} - -/// Build a JWK set from `(kid, n)` pairs (all RS256, exponent `AQAB`). -fn jwk_set(entries: &[(&str, &str)]) -> JwkSet { - let keys: Vec = entries - .iter() - .map(|(kid, n)| { - json!({ "kty": "RSA", "use": "sig", "alg": "RS256", "kid": kid, "n": n, "e": E }) - }) - .collect(); - serde_json::from_value(json!({ "keys": keys })).expect("valid JWK set") -} - -fn now() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() -} - -/// A valid claim set: correct `iss`/`aud`, a human `sub`, in-window `exp`/`nbf`, tier `forensic`. -fn base_claims() -> Value { - json!({ - "iss": ISSUER, - "aud": AUDIENCE, - "sub": "user@example.com", - "iat": now() - 10, - "nbf": now() - 10, - "exp": now() + 3600, - "tier": "forensic", - }) -} - -/// Sign `claims` as an RS256 JWT with the given `kid`. -fn sign(pem: &str, kid: &str, claims: &Value) -> String { - let mut header = Header::new(Algorithm::RS256); - header.kid = Some(kid.to_string()); - let key = EncodingKey::from_rsa_pem(pem.as_bytes()).expect("valid RSA PEM"); - encode(&header, claims, &key).expect("sign token") -} - -fn test_config() -> OidcConfig { - OidcConfig { - issuer: ISSUER.into(), - audience: AUDIENCE.into(), - tier_claim: "tier".into(), - algorithm: SigningAlgorithm::Rs256, - } -} - -/// A verifier serving `KEY_A` in-memory, plus the fetcher handle (for call counting / rotation). -fn verifier_with_key_a() -> (Verifier, Arc) { - let fetcher = Arc::new(TestFetcher::new(jwk_set(&[(KID_A, KEY_A_N)]))); - ( - Verifier::with_fetcher(test_config(), fetcher.clone()), - fetcher, - ) -} - // --------------------------------------------------------------------------------------------- // Acceptance: happy path. // --------------------------------------------------------------------------------------------- @@ -677,42 +552,10 @@ fn from_env_models_unconfigured_configured_and_errors() { } // --------------------------------------------------------------------------------------------- -// HTTP JWKS fetch: response-body size cap over a loopback OIDC server (no external egress). +// HTTP JWKS fetch: response-body size cap over a loopback OIDC server (no external egress). The +// loopback server helper lives in `super::test_support::spawn_oidc_server`. // --------------------------------------------------------------------------------------------- -/// Spin a localhost (loopback-only, no external egress) OIDC discovery + JWKS server serving -/// `jwks_body` at its `jwks_uri`. Returns the issuer base URL and the server task handle. -async fn spawn_oidc_server(jwks_body: String) -> (String, tokio::task::JoinHandle<()>) { - use axum::Router; - use axum::http::header::CONTENT_TYPE; - use axum::routing::get; - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base = format!("http://{}", listener.local_addr().unwrap()); - let discovery = json!({ "jwks_uri": format!("{base}/jwks") }).to_string(); - let app = Router::new() - .route( - "/.well-known/openid-configuration", - get(move || { - let discovery = discovery.clone(); - async move { ([(CONTENT_TYPE, "application/json")], discovery) } - }), - ) - .route( - "/jwks", - get(move || { - let jwks_body = jwks_body.clone(); - async move { ([(CONTENT_TYPE, "application/json")], jwks_body) } - }), - ); - let handle = tokio::spawn(async move { - axum::serve(listener, app.into_make_service()) - .await - .unwrap(); - }); - (base, handle) -} - #[tokio::test] async fn oversized_jwks_body_is_rejected_fail_closed() { // A 2 MiB JWKS body exceeds the 1 MiB cap → rejected before parse (fail closed), never OOM. diff --git a/engine/src/engine/dashboard/mod.rs b/engine/src/engine/dashboard/mod.rs index 7a85d07..53a5db6 100644 --- a/engine/src/engine/dashboard/mod.rs +++ b/engine/src/engine/dashboard/mod.rs @@ -41,11 +41,14 @@ use super::state::{ Findings, JudgementLog, ModelHealth, Readiness, ReadinessConfig, ReversionLog, default_window_report, derive_readiness, }; +use auth::enforce::Enforcer; use view_model::props::{ ActionViewProps, AdmissionViewProps, AlertsViewProps, FindingsViewProps, ReadinessViewProps, StatusStripProps, Tab, }; +pub use view_model::props::AuthMode; + /// The light-theme stylesheet, generated from the `docs/STYLEGUIDE.md` tokens. Served /// same-origin via `include_str!` — no third-party CSS (the zero-egress / no-CDN rule). const DASHBOARD_CSS: &str = include_str!("../../../web/dist/dashboard.css"); @@ -78,6 +81,12 @@ pub struct DashboardState { pub policy_log: Arc, /// The cluster label shown in the strip. pub cluster: String, + /// The SERVER-derived app-level auth mode (ADR-0030 / JEF-487): `Oidc` when the dashboard mounts + /// the enforcing verifier (an issuer is configured), `EdgeOnly` when unconfigured (the loud + /// bypass, §6). Folded into the persistent strip so the client renders the honest pill (JEF-489) + /// and derives nothing. Set by the caller (`run_loop`) from the same config it uses to build the + /// [`auth::enforce::Enforcer`], so the pill can never disagree with what is actually enforced. + pub auth_mode: AuthMode, } impl DashboardState { @@ -121,6 +130,7 @@ impl DashboardState { strip .with_signing_regressions(breach, uncertain) .with_coverage_stall(alert) + .with_auth_mode(self.auth_mode) } /// The standing signing-regression counts `(established, cold)` from the admission-decision log @@ -150,7 +160,8 @@ impl DashboardState { view.strip = view .strip .with_signing_regressions(breach, uncertain) - .with_coverage_stall(alert); + .with_coverage_stall(alert) + .with_auth_mode(self.auth_mode); view } @@ -299,18 +310,22 @@ async fn dashboard_js() -> Response { .into_response() } -/// Build the dashboard router with the read-only state. +/// Build the dashboard router with the read-only state and, when configured, the app-level OIDC +/// enforcement gate (ADR-0030 / JEF-487). /// /// Every response carries the strict same-origin CSP (ADR-0025) via a single /// [`security_headers::set_csp`] layer — the layer covers all routes, so a route added /// later (e.g. a `/api/*.json` snapshot) inherits it without a per-route edit. /// -/// The OIDC verifier + its mountable [`auth::require_oidc`] layer (ADR-0030) are available as a -/// sibling of the CSP layer, but are deliberately NOT mounted here yet: this ticket (JEF-485) -/// ships the verifier primitive + layer only. Enforcement wiring + content negotiation (the loud -/// unconfigured-mode passthrough vs a `401`/`503` deny) is JEF-487, which will `.layer(...)` it on. -pub fn router(state: DashboardState) -> Router { - Router::new() +/// When `auth` is `Some`, the content-negotiating [`auth::enforce::enforce`] gate is mounted +/// UNDER the CSP layer: it verifies every request fail-closed and shapes each denial by route class +/// (302→login for a document, 401 JSON for `/api`, 403 below-tier, 503 JWKS-down). Mounting it +/// *before* the CSP `.layer(...)` makes CSP the OUTER layer, so the strict CSP + `no-store` ride +/// every rejection too (the honesty guards compose WITH auth, they are not replaced by it). When +/// `auth` is `None` the dashboard serves unauthenticated (edge-trust only) — the single loud bypass +/// (§6), which `run_loop` announces at startup; the router simply omits the layer. +pub fn router(state: DashboardState, auth: Option>) -> Router { + let mut router = Router::new() .route("/", get(index)) // The read-only per-view JSON snapshots the Preact client reconciles from (ADR-0025). // GET-only, same router state/authz as the page routes, `no-store`; each returns the @@ -321,7 +336,15 @@ pub fn router(state: DashboardState) -> Router { .route("/api/readiness.json", get(readiness_json)) .route("/api/admission.json", get(admission_json)) .route("/assets/dashboard.css", get(dashboard_css)) - .route("/assets/dashboard.js", get(dashboard_js)) + .route("/assets/dashboard.js", get(dashboard_js)); + // Mount the enforcement gate FIRST (so CSP, added next, is the outer layer wrapping its denials). + if let Some(enforcer) = auth { + router = router.layer(axum::middleware::from_fn_with_state( + enforcer, + auth::enforce::enforce, + )); + } + router .layer(axum::middleware::from_fn(security_headers::set_csp)) .with_state(state) } @@ -331,7 +354,7 @@ pub fn router(state: DashboardState) -> Router { /// sit behind the cluster's own ingress/mesh, not face the internet directly, so it terminates /// no TLS of its own. A bind failure is logged and the task exits — the engine loop is /// unaffected (the dashboard is strictly observational). -pub async fn serve_dashboard(addr: SocketAddr, state: DashboardState) { +pub async fn serve_dashboard(addr: SocketAddr, state: DashboardState, auth: Option>) { let listener = match tokio::net::TcpListener::bind(addr).await { Ok(l) => l, Err(error) => { @@ -340,7 +363,7 @@ pub async fn serve_dashboard(addr: SocketAddr, state: DashboardState) { } }; tracing::info!(%addr, "dashboard listening (read-only, zero-egress)"); - if let Err(error) = axum::serve(listener, router(state).into_make_service()).await { + if let Err(error) = axum::serve(listener, router(state, auth).into_make_service()).await { tracing::error!(%error, "dashboard server stopped"); } } diff --git a/engine/src/engine/dashboard/view_model/action/tests.rs b/engine/src/engine/dashboard/view_model/action/tests.rs index 4f691bb..50a9a57 100644 --- a/engine/src/engine/dashboard/view_model/action/tests.rs +++ b/engine/src/engine/dashboard/view_model/action/tests.rs @@ -36,6 +36,7 @@ fn strip() -> StatusStripProps { escalated_count: 0, signing_regression_breach: 0, signing_regression_uncertain: 0, + auth_mode: crate::engine::dashboard::view_model::props::AuthMode::EdgeOnly, } } diff --git a/engine/src/engine/dashboard/view_model/admission/tests.rs b/engine/src/engine/dashboard/view_model/admission/tests.rs index b3828c3..e1e655f 100644 --- a/engine/src/engine/dashboard/view_model/admission/tests.rs +++ b/engine/src/engine/dashboard/view_model/admission/tests.rs @@ -24,6 +24,7 @@ fn strip() -> StatusStripProps { escalated_count: 0, signing_regression_breach: 0, signing_regression_uncertain: 0, + auth_mode: crate::engine::dashboard::view_model::props::AuthMode::EdgeOnly, } } diff --git a/engine/src/engine/dashboard/view_model/props/mod.rs b/engine/src/engine/dashboard/view_model/props/mod.rs index ca7f675..989b7dd 100644 --- a/engine/src/engine/dashboard/view_model/props/mod.rs +++ b/engine/src/engine/dashboard/view_model/props/mod.rs @@ -52,7 +52,7 @@ pub use signing::{ RegressionKind, RepoStrength, SignerProps, SigningEnforcement, SigningPosture, SigningRegressionProps, SigningRepoProps, SigningRowProps, }; -pub use status::{CoverageChip, StatusStripProps, StripCoverageAlert, Tab}; +pub use status::{AuthMode, CoverageChip, StatusStripProps, StripCoverageAlert, Tab}; #[cfg(test)] mod serialize_tests; diff --git a/engine/src/engine/dashboard/view_model/props/serialize_tests.rs b/engine/src/engine/dashboard/view_model/props/serialize_tests.rs index add5d30..b5b74a2 100644 --- a/engine/src/engine/dashboard/view_model/props/serialize_tests.rs +++ b/engine/src/engine/dashboard/view_model/props/serialize_tests.rs @@ -36,6 +36,7 @@ fn all_clear_strip() -> StatusStripProps { escalated_count: 0, signing_regression_breach: 0, signing_regression_uncertain: 0, + auth_mode: AuthMode::EdgeOnly, } } @@ -145,6 +146,24 @@ fn all_clear_strip_serializes_the_green_honesty_token() { assert_eq!(v["watching"], json!(false)); } +#[test] +fn auth_mode_serializes_as_a_stable_server_derived_token() { + // The client renders the pill (JEF-489) verbatim from this SERVER-derived token — it derives + // nothing. The wire vocabulary is `edge-only` (unconfigured, the conservative default) / `oidc`. + let v = serde_json::to_value(all_clear_strip()).unwrap(); + assert_eq!( + v["auth-mode"], + json!("edge-only"), + "the default/unconfigured strip honestly reports edge-only" + ); + let v = serde_json::to_value(all_clear_strip().with_auth_mode(AuthMode::Oidc)).unwrap(); + assert_eq!( + v["auth-mode"], + json!("oidc"), + "a configured/enforcing dashboard reports oidc" + ); +} + #[test] fn awaiting_strip_never_ships_green_and_reads_watching() { // Case X = something still awaiting the model: NEVER green, reads the calm "watching" token. diff --git a/engine/src/engine/dashboard/view_model/props/status.rs b/engine/src/engine/dashboard/view_model/props/status.rs index d7d42c8..72ad6f8 100644 --- a/engine/src/engine/dashboard/view_model/props/status.rs +++ b/engine/src/engine/dashboard/view_model/props/status.rs @@ -9,6 +9,20 @@ //! performs ZERO honesty derivation. Every string is UNTRUSTED and ships raw (the render layer //! escapes; double-escaping is a bug). +/// Whether the dashboard enforces app-level OIDC auth (ADR-0030 / JEF-487). SERVER-derived from +/// whether an issuer is configured — the client renders the honest pill (JEF-489) and derives +/// nothing. Serializes as the stable wire token `oidc` / `edge-only` under `auth-mode`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AuthMode { + /// An OIDC issuer is configured: protector verifies every request itself (fail-closed). + Oidc, + /// No issuer configured: app-level auth is OFF, relying on edge trust only (the loud bypass, + /// ADR-0030 §6). The most-conservative default so an unset auth-mode never claims `oidc`. + #[default] + EdgeOnly, +} + /// The three honesty axes the status strip carries (brief §3): decided/judging/covered. Never /// collapse into one signal. /// @@ -59,6 +73,9 @@ pub struct StatusStripProps { /// lead (the baseline itself is weak evidence). Maps to UNCERTAIN: blocks the green all-clear /// but reads as the calmer "watching" register, not a breach. pub signing_regression_uncertain: usize, + /// Whether the dashboard enforces app-level OIDC auth (ADR-0030 / JEF-487). SERVER-derived from + /// the presence of a configured issuer; the client renders the pill (JEF-489) verbatim. + pub auth_mode: AuthMode, } impl serde::Serialize for StatusStripProps { @@ -69,7 +86,7 @@ impl serde::Serialize for StatusStripProps { /// drift from the derivation the (now-client) strip render uses. fn serialize(&self, serializer: S) -> Result { use serde::ser::SerializeStruct; - // 15 raw fields + 3 derived honesty tokens (all-clear / watching / judging-state). + // 16 raw fields (incl. auth-mode) + 3 derived honesty tokens (all-clear/watching/judging-state). let mut s = serializer.serialize_struct("StatusStripProps", 19)?; s.serialize_field("cluster", &self.cluster)?; s.serialize_field("armed", &self.armed)?; @@ -91,6 +108,9 @@ impl serde::Serialize for StatusStripProps { "signing-regression-uncertain", &self.signing_regression_uncertain, )?; + // The server-derived app-level auth mode (ADR-0030 / JEF-487) — `oidc` when the verifier is + // configured/enforcing, `edge-only` when unconfigured. The client renders the pill verbatim. + s.serialize_field("auth-mode", &self.auth_mode)?; // The server-derived honesty tokens — the cardinal ADR-0025 contract. s.serialize_field("all-clear", &self.all_clear())?; s.serialize_field("watching", &self.watching())?; @@ -140,6 +160,15 @@ impl StatusStripProps { self } + /// Stamp the SERVER-derived app-level auth mode (ADR-0030 / JEF-487). Builder-style so the pure + /// strip builders keep their minimal signatures and the caller (`DashboardState`) — which alone + /// knows whether an OIDC issuer was configured — folds it in. Honest by construction: the + /// caller sets `Oidc` only when the enforcing verifier is actually mounted. + pub fn with_auth_mode(mut self, auth_mode: AuthMode) -> Self { + self.auth_mode = auth_mode; + self + } + /// Whether any signing regression stands (established or cold) — the honesty side: a standing /// regression forbids the green all-clear (JEF-264 acceptance criterion). pub fn has_signing_regression(&self) -> bool { diff --git a/engine/src/engine/dashboard/view_model/strip.rs b/engine/src/engine/dashboard/view_model/strip.rs index 74096bf..a028109 100644 --- a/engine/src/engine/dashboard/view_model/strip.rs +++ b/engine/src/engine/dashboard/view_model/strip.rs @@ -9,7 +9,7 @@ use std::time::SystemTime; use crate::engine::state::Readiness; use super::posture::human_age; -use super::props::{CoverageChip, StatusStripProps}; +use super::props::{AuthMode, CoverageChip, StatusStripProps}; /// The enrichment feeds shown as coverage chips in the strip, in a stable order. Arm-state and /// journal are reported elsewhere (the mode pill / Readiness tab), not as coverage chips. @@ -89,6 +89,10 @@ pub(super) fn status_strip( // strip carries none of its own. signing_regression_breach: 0, signing_regression_uncertain: 0, + // The app-level auth mode (ADR-0030 / JEF-487) is wired in by the caller that knows whether + // an OIDC issuer was configured (`DashboardState::with_auth_mode`); the pure findings-derived + // strip defaults to the most-conservative `EdgeOnly` (never falsely claims `oidc`). + auth_mode: AuthMode::EdgeOnly, } } diff --git a/engine/src/engine/run_loop.rs b/engine/src/engine/run_loop.rs index f067ca8..e2f1d65 100644 --- a/engine/src/engine/run_loop.rs +++ b/engine/src/engine/run_loop.rs @@ -28,6 +28,46 @@ fn restore_admission_log( restored } +/// Build the dashboard's app-level OIDC gate from the environment (ADR-0030 / JEF-487): the +/// fail-closed access control that closes the port-forward hole. Returns the `(enforcer, auth-mode)` +/// to thread into the dashboard: +/// - issuer CONFIGURED → `Some((Some(enforcer), Oidc))` — verify every request, fail-closed; +/// - issuer ABSENT → `Some((None, EdgeOnly))` — serve edge-trust-only, but announce the single, loud +/// bypass (§6) so an operator upgrading isn't silently unauthenticated; +/// - MISconfigured (issuer set, audience/alg wrong) → `None` — logged and fail-closed: the dashboard +/// is NOT served (never served unauthenticated on a broken config). +#[allow(clippy::type_complexity)] +fn build_dashboard_auth() -> Option<( + Option>, + dashboard::AuthMode, +)> { + match dashboard::auth::OidcConfig::from_env() { + Ok(Some(config)) => { + tracing::info!( + issuer = %config.issuer, + "dashboard OIDC enforcement ENABLED (fail-closed, ADR-0030)" + ); + let enforcer = std::sync::Arc::new(dashboard::auth::enforce::Enforcer::new(config)); + Some((Some(enforcer), dashboard::AuthMode::Oidc)) + } + Ok(None) => { + tracing::warn!( + "dashboard AUTH DISABLED — no OIDC issuer configured; relying on edge trust only \ + (set PROTECTOR_DASHBOARD_OIDC_ISSUER to enforce app-level auth, ADR-0030 §6)" + ); + Some((None, dashboard::AuthMode::EdgeOnly)) + } + Err(error) => { + tracing::error!( + %error, + "dashboard OIDC is MISCONFIGURED — dashboard NOT served (fail-closed; fix the \ + config or unset PROTECTOR_DASHBOARD_OIDC_ISSUER to run edge-trust-only)" + ); + None + } + } +} + /// Choose the actuator. Dry-run when nothing is enabled (the engine can never touch /// the cluster). Otherwise `PROTECTOR_ENGINE_ACTUATOR` selects the mechanism: /// `networkpolicy` isolates the compromised workload with a default-deny @@ -346,24 +386,29 @@ pub async fn run_watch( if let Ok(addr_str) = std::env::var("PROTECTOR_DASHBOARD_ADDR") { match addr_str.parse::() { Ok(addr) => { - let state = dashboard::DashboardState { - findings: engine.findings(), - judgements: journal.clone(), - reversions: engine.reversions(), - // The durable decision journal backs the Trust would-have-acted report - // (replayed read-only). Distinct handle from `journal` (the JudgementLog). - decision_journal: engine.journal(), - // The webhook's admission-decision ring backs the Admission tab (the webhook - // floor). The SAME Arc the webhook engine writes — read-only here. - policy_log: policy_log.clone(), - cluster: std::env::var("PROTECTOR_CLUSTER_LABEL") - .unwrap_or_else(|_| "cluster".to_string()), - // The dashboard is Preact-only after the v4 cutover (ADR-0025 / JEF-398): every - // tab renders the Preact client mount unconditionally — there is no per-tab flag. - // The `PROTECTOR_DASHBOARD_PREACT_TABS` env var is no longer read (the cluster - // chart still sets it harmlessly until the main loop removes it post-deploy). - }; - tokio::spawn(dashboard::serve_dashboard(addr, state)); + // App-level OIDC enforcement (ADR-0030 / JEF-487): the fail-closed gate that closes + // the port-forward hole. `None` is the MISconfigured case — already logged, and the + // dashboard is deliberately NOT served (never served unauthenticated on a bad config). + if let Some((auth, auth_mode)) = build_dashboard_auth() { + let state = dashboard::DashboardState { + findings: engine.findings(), + judgements: journal.clone(), + reversions: engine.reversions(), + // The durable decision journal backs the Trust would-have-acted report + // (replayed read-only). Distinct handle from `journal` (the JudgementLog). + decision_journal: engine.journal(), + // The webhook's admission-decision ring backs the Admission tab (the + // webhook floor). The SAME Arc the webhook engine writes — read-only here. + policy_log: policy_log.clone(), + cluster: std::env::var("PROTECTOR_CLUSTER_LABEL") + .unwrap_or_else(|_| "cluster".to_string()), + // Server-derived (ADR-0030 / JEF-487): mirrors exactly the enforcement + // decision from `build_dashboard_auth`, so the strip's auth pill can never + // claim more than is actually enforced. + auth_mode, + }; + tokio::spawn(dashboard::serve_dashboard(addr, state, auth)); + } } Err(error) => tracing::error!( %error, addr = %addr_str, From d9d31684cf7098e8a0920d90377a141e62ce5183 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Wed, 22 Jul 2026 19:28:39 -0700 Subject: [PATCH 2/2] fix(dashboard): fail loud on a mistyped OIDC min-tier config, never silently allow-all (JEF-487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security review HIGH: `configured_min_tier()` ran PROTECTOR_DASHBOARD_OIDC_MIN_TIER through the lenient token-claim parser (`Tier::from_claim_str`), which floors any unrecognized string to `Redacted` — the LEAST-restrictive threshold. So an operator who set MIN_TIER=raw but typoed it (`raww`, `admin`, …) believed the crown-jewel dashboard was restricted to top-tier operators while the gate actually admitted every valid-token holder. This contradicted the module's own fail-safe convention: a bad audience/algorithm fails loud (ConfigError → dashboard not served), but a bad min-tier degraded silently to allow-all. - New strict `Tier::parse_config` (exact redacted/forensic/raw, case-insensitive; None on anything else) for the OPERATOR config threshold. The lenient `Tier::from_claim_str` is UNCHANGED — an unknown/absent *token* tier must still floor to Redacted (correct for attacker-influenced input). - `configured_min_tier`/`parse_min_tier` now return `Result`; a non-empty unrecognized value is `ConfigError::UnsupportedTier`. `Enforcer::new` returns `Result`, and `build_dashboard_auth`'s Err arm refuses to serve — exactly like MissingAudience / UnsupportedAlgorithm. Unset/blank stays the intended Redacted default (allow all authed). - Tests: pure `parse_min_tier` (raw/forensic/redacted + mixed-case → right threshold; typo → ConfigError; unset/blank → Redacted) and a `build_dashboard_auth` env test (garbage → None/not-served; valid & absent → serves, Oidc). Added a shared ENV_LOCK so the two process-global-env tests can't interleave. Refs JEF-487 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP --- engine/src/engine/dashboard/auth/claims.rs | 21 ++++- engine/src/engine/dashboard/auth/enforce.rs | 73 +++++++++++++--- engine/src/engine/dashboard/auth/mod.rs | 6 ++ .../src/engine/dashboard/auth/test_support.rs | 6 ++ engine/src/engine/dashboard/auth/tests.rs | 7 +- engine/src/engine/run_loop.rs | 86 +++++++++++++++++-- 6 files changed, 177 insertions(+), 22 deletions(-) diff --git a/engine/src/engine/dashboard/auth/claims.rs b/engine/src/engine/dashboard/auth/claims.rs index 040a424..aa318f4 100644 --- a/engine/src/engine/dashboard/auth/claims.rs +++ b/engine/src/engine/dashboard/auth/claims.rs @@ -26,8 +26,10 @@ pub enum Tier { } impl Tier { - /// Map a claim string to a tier, case-insensitively. Any unrecognized value maps to the - /// most-restricted [`Tier::Redacted`] (fail-safe — an unknown label is never permissive). + /// Map a *token claim* string to a tier, case-insensitively. Any unrecognized value maps to the + /// most-restricted [`Tier::Redacted`] (fail-safe — an unknown label in an attacker-influenced + /// token is never permissive). This leniency is CORRECT for a token claim and WRONG for an + /// operator config threshold — use [`Tier::parse_config`] for the latter (it fails loud). pub fn from_claim_str(value: &str) -> Tier { match value.trim().to_ascii_lowercase().as_str() { "raw" => Tier::Raw, @@ -37,6 +39,21 @@ impl Tier { } } + /// Strictly parse an OPERATOR-CONFIGURED tier threshold: an exact match on + /// `redacted`/`forensic`/`raw` (case-insensitive), or `None` for any other value so the caller + /// FAILS LOUD. This is the deliberate opposite of [`Tier::from_claim_str`]: a mistyped config + /// threshold (e.g. `raww`, `admin`) must never silently degrade the gate to the least-restrictive + /// `Redacted` (allow-all) — an operator who typos `raw` must get a loud misconfiguration, not a + /// dashboard that quietly admits every authenticated identity. + pub fn parse_config(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "redacted" => Some(Tier::Redacted), + "forensic" => Some(Tier::Forensic), + "raw" => Some(Tier::Raw), + _ => None, + } + } + /// Resolve the tier from a token's claims at a **configurable** claim path. A missing claim, /// an empty string, a non-string value, or an unrecognized label all resolve to the /// most-restricted [`Tier::Redacted`] (ADR-0030 §6) — never a permissive default. diff --git a/engine/src/engine/dashboard/auth/enforce.rs b/engine/src/engine/dashboard/auth/enforce.rs index 8758694..30831d3 100644 --- a/engine/src/engine/dashboard/auth/enforce.rs +++ b/engine/src/engine/dashboard/auth/enforce.rs @@ -30,7 +30,7 @@ use axum::middleware::Next; use axum::response::{IntoResponse, Response}; use super::claims::Tier; -use super::{AuthError, Identity, OidcConfig, Verifier, authenticate, non_empty_env}; +use super::{AuthError, ConfigError, Identity, OidcConfig, Verifier, authenticate, non_empty_env}; /// The tiny JSON body for a `401` — no graph data, no stack, no which-check-failed detail (ADR-0030 /// §6: which check failed is not the caller's business). @@ -65,14 +65,17 @@ pub struct Enforcer { impl Enforcer { /// Build the production gate from a configured [`OidcConfig`]: a verifier that fetches the /// issuer's keys over HTTPS, the login redirect (configured URL, else the issuer), and the - /// minimum tier (env escape hatch, else the most-restricted default). - pub fn new(config: OidcConfig) -> Self { + /// minimum tier (env escape hatch, else the most-restricted default). A MISTYPED minimum tier is + /// a loud [`ConfigError`] — fail-closed exactly like a bad audience/algorithm, so `run_loop` + /// refuses to serve rather than silently degrade the gate to allow-all. + pub fn new(config: OidcConfig) -> Result { let login_redirect = login_redirect(&config); - Self { + let min_tier = configured_min_tier()?; + Ok(Self { verifier: Verifier::from_config(config), login_redirect, - min_tier: configured_min_tier(), - } + min_tier, + }) } /// Assemble a gate from explicit parts — the seam tests use to inject a [`Verifier`] built over @@ -221,12 +224,25 @@ fn login_redirect(config: &OidcConfig) -> String { non_empty_env(ENV_LOGIN_URL).unwrap_or_else(|| config.issuer.clone()) } -/// The configured minimum viewing tier, or the most-restricted default (which permits every -/// verified identity). An unknown label maps to the floor (fail-safe, never permissive). -fn configured_min_tier() -> Tier { - non_empty_env(ENV_MIN_TIER) - .map(|value| Tier::from_claim_str(&value)) - .unwrap_or_default() +/// The configured minimum viewing tier from the environment, or the most-restricted default (which +/// permits every verified identity) when unset/blank. A non-empty but UNRECOGNIZED value is a loud +/// [`ConfigError`], never a silent degrade to allow-all (the HIGH fix). +fn configured_min_tier() -> Result { + parse_min_tier(non_empty_env(ENV_MIN_TIER).as_deref()) +} + +/// Strictly interpret an operator's minimum-tier config value: unset/blank ⇒ the most-restricted +/// default (`Redacted`, allow every authenticated identity); a recognized tier ⇒ that threshold; a +/// non-empty UNRECOGNIZED value ⇒ [`ConfigError::UnsupportedTier`] (fail loud). Uses the strict +/// [`Tier::parse_config`], NOT the lenient token-claim parser — a mistyped threshold must not +/// silently admit everyone. +fn parse_min_tier(value: Option<&str>) -> Result { + match value.map(str::trim).filter(|value| !value.is_empty()) { + None => Ok(Tier::default()), + Some(value) => { + Tier::parse_config(value).ok_or_else(|| ConfigError::UnsupportedTier(value.to_string())) + } + } } #[cfg(test)] @@ -339,4 +355,37 @@ mod tests { ); } } + + #[test] + fn min_tier_config_parses_the_three_tiers_case_insensitively() { + assert_eq!(parse_min_tier(Some("redacted")).unwrap(), Tier::Redacted); + assert_eq!(parse_min_tier(Some("forensic")).unwrap(), Tier::Forensic); + assert_eq!(parse_min_tier(Some("raw")).unwrap(), Tier::Raw); + // Mixed case + surrounding whitespace still resolve to the right threshold. + assert_eq!(parse_min_tier(Some("RAW")).unwrap(), Tier::Raw); + assert_eq!(parse_min_tier(Some(" Forensic ")).unwrap(), Tier::Forensic); + } + + #[test] + fn min_tier_config_unset_or_blank_defaults_to_redacted_allow_all() { + assert_eq!(parse_min_tier(None).unwrap(), Tier::Redacted); + assert_eq!(parse_min_tier(Some("")).unwrap(), Tier::Redacted); + assert_eq!(parse_min_tier(Some(" ")).unwrap(), Tier::Redacted); + } + + #[test] + fn min_tier_config_fails_loud_on_a_typo_never_silently_allow_all() { + // The crux of the HIGH fix: a mistyped operator threshold must be a loud ConfigError, NOT a + // silent degrade to Redacted (allow-all). Contrast the token-claim parser, which correctly + // floors an unknown *token* tier to Redacted — that path is deliberately left unchanged. + for typo in ["raww", "operator", "admin", "superuser"] { + assert_eq!( + parse_min_tier(Some(typo)), + Err(ConfigError::UnsupportedTier(typo.to_string())), + "a mistyped min-tier `{typo}` must fail loud, not degrade to allow-all" + ); + } + // The token-claim parser is UNCHANGED: an unknown *token* tier still floors to Redacted. + assert_eq!(Tier::from_claim_str("operator"), Tier::Redacted); + } } diff --git a/engine/src/engine/dashboard/auth/mod.rs b/engine/src/engine/dashboard/auth/mod.rs index bd6baf9..9e05f66 100644 --- a/engine/src/engine/dashboard/auth/mod.rs +++ b/engine/src/engine/dashboard/auth/mod.rs @@ -123,6 +123,12 @@ pub enum ConfigError { /// `PROTECTOR_DASHBOARD_OIDC_ALGORITHM` is something other than a supported asymmetric algorithm. #[error("{ENV_ALGORITHM} `{0}` is not a supported asymmetric algorithm (RS256, ES256)")] UnsupportedAlgorithm(String), + /// `PROTECTOR_DASHBOARD_OIDC_MIN_TIER` is a non-empty value that is not one of the recognized + /// tiers — a loud misconfiguration (never silently degraded to the least-restrictive allow-all). + #[error( + "PROTECTOR_DASHBOARD_OIDC_MIN_TIER `{0}` is not a recognized tier (redacted, forensic, raw)" + )] + UnsupportedTier(String), } impl OidcConfig { diff --git a/engine/src/engine/dashboard/auth/test_support.rs b/engine/src/engine/dashboard/auth/test_support.rs index 128c9a4..7a9405c 100644 --- a/engine/src/engine/dashboard/auth/test_support.rs +++ b/engine/src/engine/dashboard/auth/test_support.rs @@ -16,6 +16,12 @@ use serde_json::{Value, json}; use super::jwks::JwksFetcher; use super::{AuthError, OidcConfig, SigningAlgorithm, Verifier}; +/// Serializes the tests that mutate the process-global `PROTECTOR_DASHBOARD_OIDC_*` env — the +/// verifier's `from_env` and the dashboard's `build_dashboard_auth` — so cargo's parallel test +/// threads can't interleave their env writes and read each other's half-set state. Acquire it +/// (`ENV_LOCK.lock()...`) at the top of any such test. +pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + pub(crate) const ISSUER: &str = "https://issuer.example"; pub(crate) const AUDIENCE: &str = "protector"; pub(crate) const KID_A: &str = "key-a"; diff --git a/engine/src/engine/dashboard/auth/tests.rs b/engine/src/engine/dashboard/auth/tests.rs index 8c657ce..0059d23 100644 --- a/engine/src/engine/dashboard/auth/tests.rs +++ b/engine/src/engine/dashboard/auth/tests.rs @@ -502,8 +502,11 @@ async fn layer_returns_503_when_jwks_unreachable() { #[test] fn from_env_models_unconfigured_configured_and_errors() { - // Env is process-global; keep all mutation inside this one test (no other test reads these - // vars) and restore a clean slate at each step to avoid cross-assertion bleed. + // Env is process-global; serialize with the other PROTECTOR_DASHBOARD_OIDC_* env test + // (`build_dashboard_auth`) via the shared lock, and restore a clean slate at each step. + let _env = super::test_support::ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let keys = [ super::ENV_ISSUER, super::ENV_AUDIENCE, diff --git a/engine/src/engine/run_loop.rs b/engine/src/engine/run_loop.rs index e2f1d65..3ac3d84 100644 --- a/engine/src/engine/run_loop.rs +++ b/engine/src/engine/run_loop.rs @@ -43,12 +43,30 @@ fn build_dashboard_auth() -> Option<( )> { match dashboard::auth::OidcConfig::from_env() { Ok(Some(config)) => { - tracing::info!( - issuer = %config.issuer, - "dashboard OIDC enforcement ENABLED (fail-closed, ADR-0030)" - ); - let enforcer = std::sync::Arc::new(dashboard::auth::enforce::Enforcer::new(config)); - Some((Some(enforcer), dashboard::AuthMode::Oidc)) + let issuer = config.issuer.clone(); + // A MISconfigured enforcement policy (e.g. a mistyped min-tier) is a loud ConfigError, + // fail-closed exactly like a bad audience/algorithm: refuse to serve rather than mount a + // gate that silently degrades to allow-all. + match dashboard::auth::enforce::Enforcer::new(config) { + Ok(enforcer) => { + tracing::info!( + %issuer, + "dashboard OIDC enforcement ENABLED (fail-closed, ADR-0030)" + ); + Some(( + Some(std::sync::Arc::new(enforcer)), + dashboard::AuthMode::Oidc, + )) + } + Err(error) => { + tracing::error!( + %error, + "dashboard OIDC enforcement policy is MISCONFIGURED — dashboard NOT served \ + (fail-closed, never silently degraded to allow-all)" + ); + None + } + } } Ok(None) => { tracing::warn!( @@ -712,6 +730,62 @@ mod tests { use kube::Resource; use kube::core::PartialObjectMeta; + /// JEF-487: the dashboard's app-level OIDC gate must fail LOUD on a mistyped minimum-tier config + /// (dashboard NOT served), never silently degrade to allow-all — while a valid or absent value + /// still serves the enforcing gate. Drives the real `build_dashboard_auth` over the env. + #[test] + fn dashboard_oidc_min_tier_config_fails_loud_but_serves_on_valid_or_absent() { + // Serialize with the other PROTECTOR_DASHBOARD_OIDC_* env test (`from_env`) via the shared + // lock, since env is process-global under cargo's parallel test threads. + let _env = crate::engine::dashboard::auth::test_support::ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + const ISSUER: &str = "PROTECTOR_DASHBOARD_OIDC_ISSUER"; + const AUDIENCE: &str = "PROTECTOR_DASHBOARD_OIDC_AUDIENCE"; + const MIN_TIER: &str = "PROTECTOR_DASHBOARD_OIDC_MIN_TIER"; + let clear = || unsafe { + for key in [ + ISSUER, + AUDIENCE, + MIN_TIER, + "PROTECTOR_DASHBOARD_OIDC_TIER_CLAIM", + "PROTECTOR_DASHBOARD_OIDC_ALGORITHM", + "PROTECTOR_DASHBOARD_OIDC_LOGIN_URL", + ] { + std::env::remove_var(key); + } + }; + clear(); + + // A configured issuer + audience with a MISTYPED min-tier → ConfigError → dashboard NOT + // served (the HIGH fix: fail loud, never silently allow-all). + unsafe { + std::env::set_var(ISSUER, "https://issuer.example"); + std::env::set_var(AUDIENCE, "protector"); + std::env::set_var(MIN_TIER, "operator"); // not a real tier + } + assert!( + super::build_dashboard_auth().is_none(), + "a mistyped MIN_TIER must fail loud (dashboard not served), never silently allow-all" + ); + + // A VALID min-tier serves, enforcing (Oidc). + unsafe { std::env::set_var(MIN_TIER, "raw") }; + let (auth, mode) = super::build_dashboard_auth().expect("a valid config serves"); + assert!(auth.is_some(), "a valid config mounts the enforcer"); + assert_eq!(mode, crate::engine::dashboard::AuthMode::Oidc); + + // MIN_TIER UNSET → the Redacted default (allow all authenticated); still serves, enforcing. + unsafe { std::env::remove_var(MIN_TIER) }; + let (auth, mode) = + super::build_dashboard_auth().expect("an absent min-tier defaults and serves"); + assert!(auth.is_some()); + assert_eq!(mode, crate::engine::dashboard::AuthMode::Oidc); + + clear(); + } + /// The reflected element type asks the apiserver for metadata only. `metadata_api()` /// is what drives both `watcher(Api::>, _)` and /// `Api::::list_metadata` to issue `.../secrets` requests that return