Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions charts/protector/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions charts/protector/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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://<team>.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
Expand Down
4 changes: 4 additions & 0 deletions engine/examples/dashboard_preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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,
}
}

Expand Down
7 changes: 4 additions & 3 deletions engine/src/engine/dashboard/api_json_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>) {
let router = super::router(empty_state());
let router = super::router(empty_state(), None);
let response = router
.oneshot(
Request::builder()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
21 changes: 19 additions & 2 deletions engine/src/engine/dashboard/auth/claims.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<Tier> {
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.
Expand Down
Loading