Skip to content

feat(dashboard): enforce OIDC verifier on the dashboard + /api router, fail-closed (JEF-487)#261

Merged
thejefflarson merged 2 commits into
mainfrom
thejefflarson/jef-487-enforce-the-oidc-verifier-on-the-dashboard-api-router-fail
Jul 23, 2026
Merged

feat(dashboard): enforce OIDC verifier on the dashboard + /api router, fail-closed (JEF-487)#261
thejefflarson merged 2 commits into
mainfrom
thejefflarson/jef-487-enforce-the-oidc-verifier-on-the-dashboard-api-router-fail

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

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) — the Enforcer + enforce axum middleware. Verification is NOT re-implemented: it routes through a shared auth::authenticate seam that require_oidc also uses. Content-negotiated denials by route class:
    • /api/*.json (path prefix — authoritative) or a JSON-preferring Accept401 {"error":"unauthenticated"}, never 302 (a redirected fetch dies on CSP connect-src 'self').
    • document GET /302 to login (from OidcConfig.issuer / PROTECTOR_DASHBOARD_OIDC_LOGIN_URL).
    • valid token below the configured minimum tier → 403.
    • JWKS-unreachable → 503 (both classes) — matches the verifier's AuthError::status; never a bypass.
    • Every denial is no-store; the gate mounts under the CSP layer so the strict CSP rides every rejection.
  • Config threading (run_loop.rs)build_dashboard_auth() builds the config + enforcer. Issuer configured → enforce; issuer absent → serve edge-trust-only with a loud startup WARN (the single §6 bypass); misconfig (issuer set, audience/alg wrong) → 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; the client derives nothing.
  • Helmengine.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 in route_class and asserted by two tests (with and without a JSON Accept).

Tests

auth/enforce_tests.rs drives the real dashboard::router with a configured Enforcer over an in-memory JWKS (zero egress, via the JEF-485 test_support seam, extracted here so both suites share one fixture): no/tampered/expired token → 401; valid → 200 (+ auth-mode: oidc); GET / → 302; /api never 302'd; 403 below-tier; 503 JWKS-down; CSP + no-store on rejections; unconfigured router serves without rejecting. Full suite green (856 unit + integration), cargo fmt/clippy -D warnings clean, helm template renders 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 literal PROTECTOR_OIDC_*. PROTECTOR_OIDC_ISSUER is 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 to token.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 ../cluster repo — not touched here)

  • Cloudflare Access application in front of the tunnel + the engine→IdP NetworkPolicy (sanctioned JWKS lane).

Closes JEF-487

🤖 Generated with Claude Code

https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP

thejefflarson and others added 2 commits July 22, 2026 19:12
…, 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
thejefflarson enabled auto-merge (squash) July 23, 2026 02:33
@thejefflarson
thejefflarson merged commit 8836417 into main Jul 23, 2026
6 checks passed
@thejefflarson
thejefflarson deleted the thejefflarson/jef-487-enforce-the-oidc-verifier-on-the-dashboard-api-router-fail branch July 23, 2026 02:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant