feat(dashboard): enforce OIDC verifier on the dashboard + /api router, fail-closed (JEF-487)#261
Merged
thejefflarson merged 2 commits intoJul 23, 2026
Conversation
…, fail-closed (JEF-487)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
…ilently allow-all (JEF-487) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
thejefflarson
enabled auto-merge (squash)
July 23, 2026 02:33
thejefflarson
deleted the
thejefflarson/jef-487-enforce-the-oidc-verifier-on-the-dashboard-api-router-fail
branch
July 23, 2026 02:36
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Wires 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 (
kubectl port-forward/ any pod on the pod network) reads the entire security graph unauthenticated.What changed
auth/enforce.rs(new) — theEnforcer+enforceaxum middleware. Verification is NOT re-implemented: it routes through a sharedauth::authenticateseam thatrequire_oidcalso uses. Content-negotiated denials by route class:/api/*.json(path prefix — authoritative) or a JSON-preferringAccept→ 401{"error":"unauthenticated"}, never 302 (a redirectedfetchdies on CSPconnect-src 'self').GET /→ 302 to login (fromOidcConfig.issuer/PROTECTOR_DASHBOARD_OIDC_LOGIN_URL).AuthError::status; never a bypass.no-store; the gate mounts under the CSP layer so the strict CSP rides every rejection.run_loop.rs) —build_dashboard_auth()builds the config + enforcer. Issuer configured → enforce; issuer absent → serve edge-trust-only with a loud startupWARN(the single §6 bypass); misconfig (issuer set, audience/alg wrong) → dashboard NOT served (fail-closed, never unauthenticated).auth-mode(oidc/edge-only) onStatusStripPropsfor the JEF-489 pill — mirrors exactly what is enforced; the client derives nothing.engine.dashboard.oidc.{issuer,audience,tierClaim}→ env in the deployment.AuthError → status
JwksUnreachable→ 503 (both classes); below-tier → 403 (both); every other verification failure → 401 for/api, 302→login for a document./api-never-302 is guaranteed by the authoritative path-prefix rule inroute_classand asserted by two tests (with and without a JSONAccept).Tests
auth/enforce_tests.rsdrives the realdashboard::routerwith a configuredEnforcerover an in-memory JWKS (zero egress, via the JEF-485test_supportseam, extracted here so both suites share one fixture): no/tampered/expired token → 401; valid → 200 (+auth-mode: oidc);GET /→ 302;/apinever 302'd; 403 below-tier; 503 JWKS-down; CSP +no-storeon rejections; unconfigured router serves without rejecting. Full suite green (856 unit + integration),cargo fmt/clippy -D warningsclean,helm templaterenders with and without the oidc block, no file >1000 lines.DECISION NEEDED (ratify)
Namespaced the dashboard-auth env vars as
PROTECTOR_DASHBOARD_OIDC_*rather than the ticket's literalPROTECTOR_OIDC_*.PROTECTOR_OIDC_ISSUERis already consumed by the cosign signature-verification path (run_loop.rs::cosign_observer_parts,main.rs,cosign.rs) — the cert-identity issuer, set unconditionally by the chart totoken.actions.githubusercontent.com. Sharing the name would 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. Distinct namespace resolves it cleanly.Human follow-ups (separate
../clusterrepo — not touched here)Closes JEF-487
🤖 Generated with Claude Code
https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP