diff --git a/charts/protector/templates/deployment.yaml b/charts/protector/templates/deployment.yaml index 08e898b..54b0862 100644 --- a/charts/protector/templates/deployment.yaml +++ b/charts/protector/templates/deployment.yaml @@ -351,6 +351,12 @@ spec: # the journal PVC mounted below. Unset = in-memory only (graceful fallback). - name: PROTECTOR_ENGINE_JOURNAL_PATH value: {{ .Values.engine.journal.path | quote }} + # Durable forensic/raw MCP disclosure audit (JEF-490, ADR-0031 §4) — a DISTINCT + # append-only file on the SAME journal PVC (never overloaded onto the decision + # journal). The "Access" tab reads it; durable-on-PVC so an empty log honestly reads + # "nobody pulled" rather than "resets on restart". Unset = in-memory only (graceful). + - name: PROTECTOR_MCP_AUDIT_PATH + value: {{ .Values.engine.journal.accessAuditPath | quote }} {{- end }} {{- end }} volumeMounts: diff --git a/charts/protector/values.yaml b/charts/protector/values.yaml index c92f091..1850262 100644 --- a/charts/protector/values.yaml +++ b/charts/protector/values.yaml @@ -366,6 +366,11 @@ engine: # File the engine appends to; it rotates to .1 in the same dir (~2 MiB total). # The dir is the PVC mount; must be writable (fsGroup 65532 owns it). path: /var/lib/protector/journal/decisions.jsonl + # Durable forensic/raw MCP disclosure audit (JEF-490, ADR-0031 §4): a DISTINCT append-only + # file on the SAME journal PVC/mount (never overloaded onto the decision journal above). It + # backs the dashboard's "Access" tab; durable-on-PVC so an empty log honestly reads "nobody + # pulled" rather than "resets on restart". Rotates to .1 like the decision journal. + accessAuditPath: /var/lib/protector/journal/mcp-access-audit.jsonl # Persistent claim so the journal survives pod restart/reschedule. RWO (replicaCount 1). size: 256Mi # "" = the cluster's default StorageClass. diff --git a/engine/examples/dashboard_preview.rs b/engine/examples/dashboard_preview.rs index 0ca24d1..1abf6d3 100644 --- a/engine/examples/dashboard_preview.rs +++ b/engine/examples/dashboard_preview.rs @@ -582,6 +582,7 @@ fn build_clear() -> DashboardState { policy_log: sample_policy_log(), cluster: "prod-us-east-1 (PREVIEW — clear)".into(), auth_mode: protector::engine::dashboard::AuthMode::EdgeOnly, + mcp_audit: std::sync::Arc::new(protector::engine::mcp::AccessAuditSink::in_memory()), } } @@ -659,6 +660,7 @@ fn build_watching() -> DashboardState { policy_log: sample_policy_log(), cluster: "prod-us-east-1 (PREVIEW — watching)".into(), auth_mode: protector::engine::dashboard::AuthMode::EdgeOnly, + mcp_audit: std::sync::Arc::new(protector::engine::mcp::AccessAuditSink::in_memory()), } } @@ -819,6 +821,7 @@ fn build_breach() -> DashboardState { policy_log: sample_policy_log(), cluster: "prod-us-east-1 (PREVIEW — breach)".into(), auth_mode: protector::engine::dashboard::AuthMode::EdgeOnly, + mcp_audit: std::sync::Arc::new(protector::engine::mcp::AccessAuditSink::in_memory()), } } @@ -874,6 +877,7 @@ fn build_blind() -> DashboardState { policy_log: Arc::new(PolicyDecisionLog::new()), cluster: "prod-us-east-1 (PREVIEW — blind)".into(), auth_mode: protector::engine::dashboard::AuthMode::EdgeOnly, + mcp_audit: std::sync::Arc::new(protector::engine::mcp::AccessAuditSink::in_memory()), } } @@ -925,6 +929,7 @@ fn resolve_tab(tab: Option<&str>) -> Tab { Some("action") | Some("trust") | Some("activity") => Tab::Action, Some("readiness") => Tab::Readiness, Some("admission") => Tab::Admission, + Some("access") => Tab::Access, _ => Tab::Findings, } } @@ -984,6 +989,18 @@ fn preview_admission(state: &DashboardState) -> view_model::props::AdmissionView view_model::build_admission_view(preview_strip(state), &state.policy_log.snapshot()) } +/// Build the "Access" view props (JEF-490) through the public render path — a raw-tier preview +/// caller over the scenario's (empty) audit sink, so the preview exercises the same builder +/// production serves. +fn preview_access(state: &DashboardState) -> view_model::props::AccessViewProps { + view_model::build_access_view( + preview_strip(state), + protector::engine::dashboard::auth::claims::Tier::Raw, + &state.mcp_audit.records(), + state.mcp_audit.is_durable(), + ) +} + /// Render the ROOT-ONLY document shell for a tab through the dashboard's PUBLIC render path (JEF-408, /// superseding ADR-0025's server-rendered strip/nav): the `` + the Preact `#dash-root` mount. /// ALL body HTML — the status strip, the tab nav, and the view body — is client-rendered from the @@ -1001,6 +1018,7 @@ fn render_json(state: &DashboardState, tab: Tab) -> String { Tab::Action => serde_json::to_string(&preview_action(state)), Tab::Readiness => serde_json::to_string(&preview_readiness(state)), Tab::Admission => serde_json::to_string(&preview_admission(state)), + Tab::Access => serde_json::to_string(&preview_access(state)), } .unwrap_or_else(|e| format!("{{\"error\":\"preview serialize failed: {e}\"}}")) } diff --git a/engine/src/engine/dashboard/api_json_tests.rs b/engine/src/engine/dashboard/api_json_tests.rs index 6c32ce0..159e4ce 100644 --- a/engine/src/engine/dashboard/api_json_tests.rs +++ b/engine/src/engine/dashboard/api_json_tests.rs @@ -28,6 +28,7 @@ fn empty_state() -> DashboardState { policy_log: Arc::new(PolicyDecisionLog::new()), cluster: "prod-test".into(), auth_mode: super::AuthMode::EdgeOnly, + mcp_audit: Arc::new(crate::engine::mcp::AccessAuditSink::in_memory()), } } @@ -73,6 +74,7 @@ async fn every_endpoint_is_get_only_json_and_no_store() { "/api/action.json", "/api/readiness.json", "/api/admission.json", + "/api/access.json", ] { let (status, no_store, body) = get(path).await; assert_eq!(status, StatusCode::OK, "{path} should 200"); @@ -115,7 +117,7 @@ async fn each_endpoint_returns_the_same_view_model_its_tab_renders() { // `Serialize` (the wire is one-directional server→client), so compare on the JSON `Value`. let state = empty_state(); - let cases: [(&str, serde_json::Value); 5] = [ + let cases: [(&str, serde_json::Value); 6] = [ ( "/api/findings.json", serde_json::to_value(state.findings_view()).unwrap(), @@ -136,6 +138,14 @@ async fn each_endpoint_returns_the_same_view_model_its_tab_renders() { "/api/admission.json", serde_json::to_value(state.admission_view()).unwrap(), ), + ( + // Unauthenticated (no enforcer) → the handler defaults to the most-restricted tier. + "/api/access.json", + serde_json::to_value( + state.access_view(crate::engine::dashboard::auth::claims::Tier::Redacted), + ) + .unwrap(), + ), ]; for (path, expected) in cases { diff --git a/engine/src/engine/dashboard/auth/enforce_tests.rs b/engine/src/engine/dashboard/auth/enforce_tests.rs index aca114a..292b402 100644 --- a/engine/src/engine/dashboard/auth/enforce_tests.rs +++ b/engine/src/engine/dashboard/auth/enforce_tests.rs @@ -34,6 +34,7 @@ fn empty_state(auth_mode: AuthMode) -> DashboardState { policy_log: Arc::new(PolicyDecisionLog::new()), cluster: "prod-test".into(), auth_mode, + mcp_audit: Arc::new(crate::engine::mcp::AccessAuditSink::in_memory()), } } @@ -63,6 +64,30 @@ async fn send(auth: Option>, request: Request) -> axum::resp .unwrap() } +/// A dashboard state whose MCP access-audit sink already holds ONE raw pull of a specific +/// crown-jewel entry — the row whose target-class must be redacted to the caller's own tier. +fn state_with_raw_pull() -> DashboardState { + use crate::engine::mcp::EffectiveTier; + use crate::engine::mcp::audit::{AuditRecord, AuditSink}; + + let state = empty_state(AuthMode::Oidc); + state.mcp_audit.emit(AuditRecord::now( + "alice@corp.example", + "workload/app/Pod/web", + "explain_verdict", + EffectiveTier::Raw, + )); + state +} + +/// A signed token whose `tier` claim is `tier` (else the base `forensic`). Same key/issuer as +/// [`valid_token`], so it verifies — only the authorization tier differs. +fn token_with_tier(tier: &str) -> String { + let mut claims = base_claims(); + claims["tier"] = serde_json::json!(tier); + sign(KEY_A_PEM, KID_A, &claims) +} + /// 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); @@ -190,6 +215,85 @@ async fn api_findings_with_valid_token_is_200_with_the_view_model_and_oidc_auth_ // Document GET / — 302 to login, never for /api. // ------------------------------------------------------------------------------------------------- +// ------------------------------------------------------------------------------------------------- +// /api/access.json (JEF-490) — inherits the OIDC gate (401 unauthenticated), and the audit rows are +// redacted to the CALLER's own tier: a redacted-tier caller never learns a raw pull's target; a +// forensic/raw-tier caller does. +// ------------------------------------------------------------------------------------------------- + +#[tokio::test] +async fn api_access_with_no_token_is_401_inheriting_the_oidc_gate() { + // The "Access" endpoint mounts under the SAME router-wide enforce layer — no second gate. An + // unauthenticated call is a 401 on the same path as every other /api route (never a 302). + let response = send( + Some(enforcer(Tier::Redacted)), + get("/api/access.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" + ); + assert_eq!( + response.headers().get(header::CACHE_CONTROL).unwrap(), + "no-store", + "the access snapshot is a per-session-gated, zero-egress read — never edge-cached" + ); +} + +#[tokio::test] +async fn api_access_redacts_a_raw_pulls_target_to_a_redacted_tier_caller() { + // A redacted-tier caller sees THAT a raw pull happened (who/tool/tier) but NOT its target — the + // withheld-workload sentinel, never the crown-jewel workload identity. + let response = router(state_with_raw_pull(), Some(enforcer(Tier::Redacted))) + .oneshot(get( + "/api/access.json", + Some(&token_with_tier("redacted")), + Some("application/json"), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&body_string(response).await).unwrap(); + let row = &v["pulls"][0]; + assert_eq!(row["who"], serde_json::json!("alice@corp.example")); + assert_eq!(row["tier"], serde_json::json!("raw")); + assert_eq!( + row["target"], + serde_json::json!(crate::engine::mcp::WORKLOAD_IDENTITY_WITHHELD), + "a redacted-tier viewer sees the withheld sentinel, never the workload identity" + ); + assert_ne!(row["target"], serde_json::json!("workload/app/Pod/web")); + assert_eq!( + v["tier"], + serde_json::json!("redacted"), + "the caller's own chip" + ); +} + +#[tokio::test] +async fn api_access_reveals_a_raw_pulls_target_to_a_raw_tier_caller() { + // A raw-tier caller (verified token) DOES see the workload-identity target of the same pull. + let response = router(state_with_raw_pull(), Some(enforcer(Tier::Redacted))) + .oneshot(get( + "/api/access.json", + Some(&token_with_tier("raw")), + Some("application/json"), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&body_string(response).await).unwrap(); + let row = &v["pulls"][0]; + assert_eq!( + row["target"], + serde_json::json!("workload/app/Pod/web"), + "a raw-tier viewer's own tier unlocks the target" + ); + assert_eq!(v["tier"], serde_json::json!("raw")); +} + #[tokio::test] async fn document_root_with_no_token_is_302_to_login() { let response = send( diff --git a/engine/src/engine/dashboard/mod.rs b/engine/src/engine/dashboard/mod.rs index 53a5db6..b142305 100644 --- a/engine/src/engine/dashboard/mod.rs +++ b/engine/src/engine/dashboard/mod.rs @@ -41,10 +41,13 @@ use super::state::{ Findings, JudgementLog, ModelHealth, Readiness, ReadinessConfig, ReversionLog, default_window_report, derive_readiness, }; +use crate::engine::mcp::AccessAuditSink; +use auth::Identity; +use auth::claims::Tier; use auth::enforce::Enforcer; use view_model::props::{ - ActionViewProps, AdmissionViewProps, AlertsViewProps, FindingsViewProps, ReadinessViewProps, - StatusStripProps, Tab, + AccessViewProps, ActionViewProps, AdmissionViewProps, AlertsViewProps, FindingsViewProps, + ReadinessViewProps, StatusStripProps, Tab, }; pub use view_model::props::AuthMode; @@ -87,6 +90,11 @@ pub struct DashboardState { /// 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, + /// The durable forensic/raw MCP disclosure audit sink (ADR-0031 §4, JEF-490) — the SAME `Arc` + /// the MCP server appends to. The "Access" tab reads its records (redacted to the caller's own + /// tier); read-only here, like every other handle. Present even when the MCP server isn't served + /// (then it simply holds no records — an honest empty log, not a hidden tab). + pub mcp_audit: Arc, } impl DashboardState { @@ -205,6 +213,17 @@ impl DashboardState { let rows = self.policy_log.snapshot(); view_model::build_admission_view(self.status_strip(), &rows) } + + /// Build the "Access" view props (JEF-490): the persistent strip + the CALLER's own tier chip + + /// the newest-first forensic/raw MCP disclosure pulls, each redacted to the caller's own tier. + /// `caller_tier` comes from the verified [`Identity`] the OIDC layer inserted (or the + /// most-restricted [`Tier::Redacted`] default when unauthenticated/edge-only), so a lower-tier + /// viewer can never learn a higher-tier pull's target. Pure read of the shared audit sink. + fn access_view(&self, caller_tier: Tier) -> AccessViewProps { + let records = self.mcp_audit.records(); + let durable = self.mcp_audit.is_durable(); + view_model::build_access_view(self.status_strip(), caller_tier, &records, durable) + } } /// The tab query parameter (`?tab=action`). Defaults to Findings. The legacy `trust`/`activity` @@ -223,6 +242,7 @@ impl TabQuery { Some("action") | Some("trust") | Some("activity") => Tab::Action, Some("readiness") => Tab::Readiness, Some("admission") => Tab::Admission, + Some("access") => Tab::Access, _ => Tab::Findings, } } @@ -279,6 +299,22 @@ async fn admission_json(State(state): State) -> Response { view_json(state.admission_view()) } +/// `GET /api/access.json` — the read-only "Access" view-model snapshot (JEF-490): the forensic/raw +/// MCP disclosure audit, redacted to the CALLER's own tier. GET-only, `no-store`, inherits the OIDC +/// gate from the router-wide enforce layer (a 401 fires there before this handler runs — no second +/// gate). The caller's tier is read from the verified [`Identity`] the enforce layer inserted; when +/// absent (edge-only / unauthenticated passthrough) it defaults to the most-restricted +/// [`Tier::Redacted`], so a viewer without a verified tier claim can never see forensic/raw targets. +async fn access_json( + State(state): State, + identity: Option>, +) -> Response { + let caller_tier = identity + .map(|axum::Extension(id)| id.tier) + .unwrap_or_default(); + view_json(state.access_view(caller_tier)) +} + /// `GET /assets/dashboard.css` — the light-theme stylesheet, same-origin. /// /// `Cache-Control: no-store` is load-bearing behind Cloudflare Access (JEF-283): Cloudflare @@ -335,6 +371,7 @@ pub fn router(state: DashboardState, auth: Option>) -> Router { .route("/api/action.json", get(action_json)) .route("/api/readiness.json", get(readiness_json)) .route("/api/admission.json", get(admission_json)) + .route("/api/access.json", get(access_json)) .route("/assets/dashboard.css", get(dashboard_css)) .route("/assets/dashboard.js", get(dashboard_js)); // Mount the enforcement gate FIRST (so CSP, added next, is the outer layer wrapping its denials). diff --git a/engine/src/engine/dashboard/page_tests.rs b/engine/src/engine/dashboard/page_tests.rs index d9e9fea..238085a 100644 --- a/engine/src/engine/dashboard/page_tests.rs +++ b/engine/src/engine/dashboard/page_tests.rs @@ -19,12 +19,13 @@ use super::page; use super::view_model::props::Tab; -const TABS: [Tab; 5] = [ +const TABS: [Tab; 6] = [ Tab::Findings, Tab::Alerts, Tab::Action, Tab::Readiness, Tab::Admission, + Tab::Access, ]; #[test] diff --git a/engine/src/engine/dashboard/view_model/access.rs b/engine/src/engine/dashboard/view_model/access.rs new file mode 100644 index 0000000..acaa71e --- /dev/null +++ b/engine/src/engine/dashboard/view_model/access.rs @@ -0,0 +1,111 @@ +//! Map the read-only MCP disclosure audit (JEF-490) into the [`AccessViewProps`] the "Access" tab +//! renders. This is the ONE place the audit rows are REDACTED to the CALLER's own tier: a row that +//! recorded a `raw` pull of a crown-jewel entry shows its target-class ONLY to a viewer whose own +//! verified tier unlocks it (forensic+); a lower-tier viewer sees the SAME withheld-workload +//! sentinel the tool emits — never the target itself. Data layer: touches the audit records + +//! the caller's [`Tier`]; the component never does. +//! +//! The audit `entry` is always a workload identity (or the bulk-scope label) — a forensic-tier fact +//! (topology), never a secret NAME (secrets are objectives, not entries) — so gating the target at +//! `forensic` is both correct and complete. Secret names never ride the audit line at all. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::engine::dashboard::auth::claims::Tier; +use crate::engine::mcp::{AccessRecord, BULK_SCOPE, EffectiveTier, WORKLOAD_IDENTITY_WITHHELD}; + +use super::posture::human_age; +use super::props::{AccessPullRow, AccessTier, AccessViewProps, StatusStripProps, TierRevealRow}; + +/// Build the "Access" view's props: the caller's own tier chip, the per-tier reveal list, and the +/// newest-first forensic/raw pulls redacted to the caller's tier. `records` are newest-first (the +/// sink's snapshot order); `durable` selects the honest empty-state caveat. Pure given its inputs. +pub fn build( + strip: StatusStripProps, + caller_tier: Tier, + records: &[AccessRecord], + durable: bool, +) -> AccessViewProps { + let now = unix_now(); + let pulls: Vec = records + .iter() + .map(|r| pull_row(r, caller_tier, now)) + .collect(); + AccessViewProps { + strip, + tier: AccessTier::from_claim(caller_tier), + reveals: reveal_rows(caller_tier), + pulls, + durable, + } +} + +/// Redact one audit record to the CALLER's own tier. `who`/`tool`/`tier`/`when` are always shown +/// (an operator may see THAT a raw pull happened, and by whom); only the target-class is gated — +/// the crux of the tier-aware audit (a lower-tier viewer never learns WHAT a higher-tier pull hit). +fn pull_row(record: &AccessRecord, caller_tier: Tier, now: u64) -> AccessPullRow { + AccessPullRow { + when: format!( + "{} ago", + human_age(now.saturating_sub(record.time_unix_secs)) + ), + who: record.subject.clone(), + tool: record.tool.clone(), + tier: AccessTier::from_effective(record.tier), + target: redacted_target(&record.entry, caller_tier), + raw: record.tier == EffectiveTier::Raw, + } +} + +/// The target-class shown for a pull, redacted to `caller_tier`. The bulk-scope label is a fixed +/// constant (leaks nothing — the same for every viewer), so it's shown verbatim. A specific entry is +/// a workload identity (a forensic-tier fact): shown at forensic+, else the withheld-workload +/// sentinel — the SAME vocabulary the tool uses (`WORKLOAD_IDENTITY_WITHHELD`), one string across +/// tool + screen. +fn redacted_target(entry: &str, caller_tier: Tier) -> String { + if entry == BULK_SCOPE || caller_tier >= Tier::Forensic { + entry.to_string() + } else { + WORKLOAD_IDENTITY_WITHHELD.to_string() + } +} + +/// The static "what each tier reveals/withholds" list, marking which levels the caller holds. Copy +/// only — no cluster data — so it's identical for every viewer save the `held` flag. +fn reveal_rows(caller_tier: Tier) -> Vec { + let holds = |tier: Tier| caller_tier >= tier; + vec![ + TierRevealRow { + tier: AccessTier::Redacted, + reveals: "verdicts, counts, technique IDs, coverage & freshness".into(), + withholds: "nothing cluster-specific is disclosed at this tier".into(), + held: holds(Tier::Redacted), + }, + TierRevealRow { + tier: AccessTier::Forensic, + reveals: + "judgement prompt+reply, CVE ids + reachability, proven paths, workload & node \ + names" + .into(), + withholds: "secret names stay scrubbed".into(), + held: holds(Tier::Forensic), + }, + TierRevealRow { + tier: AccessTier::Raw, + reveals: "secret names (per-entry only — never a bulk dump)".into(), + withholds: "secret VALUES — never; no tool has a read path to a value".into(), + held: holds(Tier::Raw), + }, + ] +} + +/// Seconds since the Unix epoch, saturating to 0 (pre-epoch never occurs for the wall clock). +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests; diff --git a/engine/src/engine/dashboard/view_model/access/tests.rs b/engine/src/engine/dashboard/view_model/access/tests.rs new file mode 100644 index 0000000..1536ba4 --- /dev/null +++ b/engine/src/engine/dashboard/view_model/access/tests.rs @@ -0,0 +1,128 @@ +//! Tests for the "Access" view_model mapping (JEF-490): the TIER-AWARE redaction of the audit rows +//! to the caller's OWN tier is the crux — a redacted-tier viewer never learns the target of a raw +//! pull; a forensic/raw viewer does. Plus: the bulk-scope label is shown to everyone, the raw +//! keyline flag tracks the pull's tier, and the durable flag / pull count flow through honestly. + +use super::*; +use crate::engine::dashboard::view_model::props::{AccessTier, StatusStripProps}; +use crate::engine::mcp::{EffectiveTier, WORKLOAD_IDENTITY_WITHHELD}; + +fn strip() -> StatusStripProps { + // A minimal strip; the access mapper does not inspect it, just carries it. + StatusStripProps { + cluster: "prod".into(), + armed: false, + model_judging: true, + warming_up: false, + model_attached: true, + coverage: vec![], + coverage_alert: None, + last_pass: None, + breach_count: 0, + awaiting_count: 0, + uncertain_count: 0, + cleared_count: 0, + escalated_count: 0, + signing_regression_breach: 0, + signing_regression_uncertain: 0, + auth_mode: crate::engine::dashboard::view_model::props::AuthMode::EdgeOnly, + } +} + +fn record(subject: &str, entry: &str, tool: &str, tier: EffectiveTier) -> AccessRecord { + AccessRecord { + subject: subject.into(), + entry: entry.into(), + tool: tool.into(), + tier, + time_unix_secs: unix_now().saturating_sub(30), + } +} + +/// A raw pull of a specific crown-jewel entry — the row whose target must NOT leak to a lower tier. +fn raw_pull() -> AccessRecord { + record( + "alice@corp.example", + "workload/app/Pod/web", + "explain_verdict", + EffectiveTier::Raw, + ) +} + +#[test] +fn a_redacted_viewer_never_sees_a_raw_pulls_target_only_the_sentinel() { + let view = build(strip(), Tier::Redacted, &[raw_pull()], true); + assert_eq!(view.pulls.len(), 1); + let row = &view.pulls[0]; + // The who/tool/tier are visible (an operator may see THAT a raw pull happened, and by whom)… + assert_eq!(row.who, "alice@corp.example"); + assert_eq!(row.tool, "explain_verdict"); + assert_eq!(row.tier, AccessTier::Raw); + assert!(row.raw, "a raw pull carries the loud keyline flag"); + // …but the TARGET is withheld — the SAME sentinel the tool emits, never the workload identity. + assert_eq!(row.target, WORKLOAD_IDENTITY_WITHHELD); + assert_ne!( + row.target, "workload/app/Pod/web", + "the crown-jewel target must not leak to a redacted-tier viewer" + ); + // The caller's own chip is redacted, and only the redacted reveal-row is held. + assert_eq!(view.tier, AccessTier::Redacted); + let held: Vec = view + .reveals + .iter() + .filter(|r| r.held) + .map(|r| r.tier) + .collect(); + assert_eq!(held, vec![AccessTier::Redacted]); +} + +#[test] +fn a_forensic_viewer_sees_a_raw_pulls_workload_target() { + let view = build(strip(), Tier::Forensic, &[raw_pull()], true); + let row = &view.pulls[0]; + assert_eq!( + row.target, "workload/app/Pod/web", + "forensic+ unlocks the workload identity target" + ); + assert!( + row.raw, + "the pull's own tier is still raw (the keyline stands)" + ); + assert_eq!(view.tier, AccessTier::Forensic); +} + +#[test] +fn a_raw_viewer_sees_the_target_and_holds_every_reveal_row() { + let view = build(strip(), Tier::Raw, &[raw_pull()], true); + assert_eq!(view.pulls[0].target, "workload/app/Pod/web"); + assert!( + view.reveals.iter().all(|r| r.held), + "a raw-tier caller holds every tier level" + ); +} + +#[test] +fn the_bulk_scope_label_is_shown_to_every_tier() { + // A bulk forensic pull's target is the fixed scope label — it leaks nothing, so even a + // redacted-tier viewer sees it verbatim (never the workload sentinel). + let bulk = record( + "bob@corp.example", + BULK_SCOPE, + "list_findings", + EffectiveTier::Forensic, + ); + let view = build(strip(), Tier::Redacted, &[bulk], false); + assert_eq!(view.pulls[0].target, BULK_SCOPE); + assert!(!view.pulls[0].raw, "a forensic pull is not a raw keyline"); +} + +#[test] +fn durable_flag_and_pull_list_flow_through() { + let empty = build(strip(), Tier::Raw, &[], true); + assert!(empty.pulls.is_empty()); + assert!(empty.durable); + + let populated = build(strip(), Tier::Raw, &[raw_pull(), raw_pull()], false); + assert_eq!(populated.pulls.len(), 2); + assert!(!populated.durable, "in-memory reports non-durable honestly"); +} diff --git a/engine/src/engine/dashboard/view_model/mod.rs b/engine/src/engine/dashboard/view_model/mod.rs index 09a9275..d7437e9 100644 --- a/engine/src/engine/dashboard/view_model/mod.rs +++ b/engine/src/engine/dashboard/view_model/mod.rs @@ -9,6 +9,7 @@ pub mod props; +mod access; mod action; mod admission; mod alerts; @@ -20,12 +21,14 @@ mod strip; use std::time::SystemTime; +use crate::engine::dashboard::auth::claims::Tier; +use crate::engine::mcp::AccessRecord; use crate::engine::policy_log::PolicyDecisionRecord; use crate::engine::state::{CoverageState, Finding, Judgement, Readiness, Report, ReversionRecord}; use props::{ - ActionViewProps, AdmissionViewProps, AlertsViewProps, FindingProps, FindingsViewProps, Posture, - ReadinessViewProps, StatusStripProps, StripCoverageAlert, + AccessViewProps, ActionViewProps, AdmissionViewProps, AlertsViewProps, FindingProps, + FindingsViewProps, Posture, ReadinessViewProps, StatusStripProps, StripCoverageAlert, }; /// Build the persistent status strip with the TRUE findings headline counts (brief §3/§4). The @@ -143,6 +146,19 @@ pub fn build_admission_view( admission::build(strip, rows) } +/// Build the whole "Access" view's props (JEF-490): the persistent strip + the caller's OWN tier +/// chip + the per-tier reveal list + the newest-first forensic/raw disclosure pulls, each redacted +/// to the CALLER's own tier (a lower-tier viewer never sees a higher-tier pull's target). `records` +/// are newest-first; `durable` selects the honest empty-state caveat. Pure given its inputs. +pub fn build_access_view( + strip: StatusStripProps, + caller_tier: Tier, + records: &[AccessRecord], + durable: bool, +) -> AccessViewProps { + access::build(strip, caller_tier, records, durable) +} + /// The standing signing-regression counts `(established, cold)` derived from the admission-decision /// log's regression rows (`SigningRegression/`, JEF-264) — established-baseline regressions /// count toward breach, cold-baseline ones toward uncertain. The caller folds these into the diff --git a/engine/src/engine/dashboard/view_model/props/access.rs b/engine/src/engine/dashboard/view_model/props/access.rs new file mode 100644 index 0000000..eb7ee42 --- /dev/null +++ b/engine/src/engine/dashboard/view_model/props/access.rs @@ -0,0 +1,116 @@ +//! The "Access" view presentation props (JEF-490) — the operator's window onto the read-only MCP +//! server's forensic/raw disclosure audit (ADR-0031 §4). Two halves: +//! +//! - **your access** — the caller's OWN tier ([`AccessTier`]) as a chip, over a `cov-rows`-style +//! list of what each tier reveals vs withholds ([`TierRevealRow`]); +//! - **forensic & raw pulls** — the newest-first audit rows ([`AccessPullRow`]): `when · who · tool +//! · tier · target-class`, each row's target-class ALREADY redacted to the CALLER's own tier by +//! the view_model (a lower-tier viewer sees the withheld-workload sentinel, never the crown-jewel +//! target of a higher-tier pull). +//! +//! Split out of the parent `props` module to keep every file under the repo's 1,000-line cap +//! (CLAUDE.md); re-exported flat so `props::AccessViewProps` etc. resolve unchanged. +//! +//! The wire format (ADR-0025): these props `serde`-serialize as the read-only `/api/access.json` +//! snapshot. [`AccessTier`] serializes to a STABLE lowercase tag (`"redacted"`/`"forensic"`/`"raw"`) +//! so the client's chip switch is exhaustive; every identity/target string is UNTRUSTED and ships +//! RAW (the client auto-escapes — double-escaping is a bug). The tier-aware REDACTION is decided +//! server-side (the target is already the real value or the sentinel), so the client derives nothing. + +use crate::engine::dashboard::auth::claims::Tier; +use crate::engine::mcp::EffectiveTier; + +use super::status::StatusStripProps; + +/// A disclosure tier as the "Access" screen names it — the presentation mirror of the auth [`Tier`] +/// and the MCP [`EffectiveTier`], carried as a stable token so the client renders colour + glyph + +/// WORD (never colour alone). Serialized kebab/lowercase (`"redacted"`/`"forensic"`/`"raw"`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum AccessTier { + /// Safe-by-construction: verdicts, counts, technique IDs, coverage/freshness — nothing + /// cluster-specific. The floor and the fail-safe default. + Redacted, + /// Adds judgement prompt+reply, CVE ids + reachability, proven paths, workload/node names. + Forensic, + /// Adds secret NAMES (per-entry only; never secret VALUES — no read path exists). The loud, + /// scarce posture. + Raw, +} + +impl AccessTier { + /// Project the caller's verified auth [`Tier`] onto the screen's tier. + pub fn from_claim(tier: Tier) -> Self { + match tier { + Tier::Redacted => AccessTier::Redacted, + Tier::Forensic => AccessTier::Forensic, + Tier::Raw => AccessTier::Raw, + } + } + + /// Project a pull's clamped [`EffectiveTier`] onto the screen's tier. + pub fn from_effective(tier: EffectiveTier) -> Self { + match tier { + EffectiveTier::Redacted => AccessTier::Redacted, + EffectiveTier::Forensic => AccessTier::Forensic, + EffectiveTier::Raw => AccessTier::Raw, + } + } +} + +/// One row in the "what each tier reveals/withholds" list (Section 1) — a `cov-rows`-style entry +/// describing one tier level and whether the CALLER holds it. Every string is static, operator-facing +/// copy (no cluster data), but ships as data the client renders. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct TierRevealRow { + /// Which tier this row describes. + pub tier: AccessTier, + /// What this tier reveals. + pub reveals: String, + /// What this tier still withholds. + pub withholds: String, + /// Whether the caller's own tier includes this level (`caller_tier >= this`). + pub held: bool, +} + +/// One audit row (Section 2) — a single forensic/raw disclosure, already redacted to the CALLER's +/// own tier. `when · who · tool · tier · target-class`. Untrusted identity/target strings ship RAW +/// (the client escapes). A `raw` pull carries the loud keyline (`raw`). +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct AccessPullRow { + /// A compact "N ago" for when the pull happened (server-derived from the record's Unix stamp). + pub when: String, + /// The verified human subject that pulled (untrusted — escaped at render). + pub who: String, + /// The tool that served the disclosure. + pub tool: String, + /// The tier the disclosure was rendered at (the pull's own tier, NOT the caller's). + pub tier: AccessTier, + /// The target-class, REDACTED to the caller's own tier: the real workload identity / bulk-scope + /// label at forensic+, else the withheld-workload sentinel (the SAME string the tool emits). + pub target: String, + /// Whether this was a `raw` pull — the loud keyline on the row. + pub raw: bool, +} + +/// The whole "Access" view's props: the persistent strip + the caller's tier chip + the tier-reveal +/// list + the newest-first forensic/raw pulls. `durable` reflects whether the audit sink persists to +/// the PVC — the client picks the honest empty-state sub-line from it (omit "resets on restart" when +/// durable). +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct AccessViewProps { + pub strip: StatusStripProps, + /// The caller's OWN verified tier — the chip in "your access". + pub tier: AccessTier, + /// What each tier reveals/withholds, and which the caller holds (Section 1). + pub reveals: Vec, + /// The newest-first forensic/raw pulls, redacted to the caller's own tier (Section 2). The + /// client reads its length directly for the empty-vs-populated split — no separate count. + pub pulls: Vec, + /// Whether the audit sink is durable (PVC-backed). `false` ⇒ in-memory — the empty state then + /// carries the "this log lives in memory and resets on restart" caveat. + pub durable: bool, +} diff --git a/engine/src/engine/dashboard/view_model/props/mod.rs b/engine/src/engine/dashboard/view_model/props/mod.rs index 989b7dd..3365ea0 100644 --- a/engine/src/engine/dashboard/view_model/props/mod.rs +++ b/engine/src/engine/dashboard/view_model/props/mod.rs @@ -27,6 +27,7 @@ //! pre-existing [`signing`] inventory. Everything is re-exported FLAT here so every consumer's //! `props::TypeName` path resolves unchanged. +mod access; mod action; mod admission; mod alerts; @@ -35,6 +36,7 @@ mod readiness; mod signing; mod status; +pub use access::{AccessPullRow, AccessTier, AccessViewProps, TierRevealRow}; pub use action::{ ActionViewProps, JudgementEntryProps, LeftAloneProps, ReversionProps, WouldActProps, }; 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 b5b74a2..1d2de55 100644 --- a/engine/src/engine/dashboard/view_model/props/serialize_tests.rs +++ b/engine/src/engine/dashboard/view_model/props/serialize_tests.rs @@ -83,6 +83,46 @@ fn live_tag_and_tab_serialize_to_stable_string_tags() { serde_json::to_value(Tab::Admission).unwrap(), json!("admission") ); + assert_eq!(serde_json::to_value(Tab::Access).unwrap(), json!("access")); +} + +#[test] +fn access_tier_serializes_to_stable_lowercase_tags() { + // The "Access" chip switches on this token (colour + glyph + WORD); a rename breaks a test, + // not the client silently. + assert_eq!( + serde_json::to_value(AccessTier::Redacted).unwrap(), + json!("redacted") + ); + assert_eq!( + serde_json::to_value(AccessTier::Forensic).unwrap(), + json!("forensic") + ); + assert_eq!(serde_json::to_value(AccessTier::Raw).unwrap(), json!("raw")); +} + +#[test] +fn an_access_pull_row_ships_the_target_raw_with_the_raw_keyline_flag() { + // The tier-aware REDACTION is decided server-side: the `target` is already the real value or + // the sentinel by the time it serializes. Whatever it holds ships RAW (the client escapes), and + // the `raw` keyline flag is a decided boolean. + let row = AccessPullRow { + when: "30s ago".into(), + who: "alice@corp".into(), + tool: "explain_verdict".into(), + tier: AccessTier::Raw, + target: "workload/app/Pod/web".into(), + raw: true, + }; + let v = serde_json::to_value(row).unwrap(); + assert_eq!(v["tier"], json!("raw")); + assert_eq!(v["raw"], json!(true), "a raw pull carries the loud keyline"); + assert_eq!( + v["who"], + json!("alice@corp"), + "the untrusted subject ships raw (the client escapes)" + ); + assert_eq!(v["target"], json!("workload/app/Pod/web")); } #[test] diff --git a/engine/src/engine/dashboard/view_model/props/status.rs b/engine/src/engine/dashboard/view_model/props/status.rs index 72ad6f8..495150a 100644 --- a/engine/src/engine/dashboard/view_model/props/status.rs +++ b/engine/src/engine/dashboard/view_model/props/status.rs @@ -312,6 +312,10 @@ pub enum Tab { Action, Readiness, Admission, + /// The forensic/raw MCP disclosure audit (JEF-490) — WHO pulled WHAT cluster fact at which tier, + /// itself redacted to the viewer's own tier. The sixth tab, placed last (a distinct audit + /// surface, not part of the live-posture story the first five tell). + Access, } impl Tab { @@ -323,6 +327,7 @@ impl Tab { Tab::Action => "/?tab=action", Tab::Readiness => "/?tab=readiness", Tab::Admission => "/?tab=admission", + Tab::Access => "/?tab=access", } } @@ -334,6 +339,7 @@ impl Tab { Tab::Action => "Action", Tab::Readiness => "Readiness", Tab::Admission => "Admission", + Tab::Access => "Access", } } @@ -347,6 +353,7 @@ impl Tab { Tab::Action => "action", Tab::Readiness => "readiness", Tab::Admission => "admission", + Tab::Access => "access", } } } diff --git a/engine/src/engine/mcp/access_audit.rs b/engine/src/engine/mcp/access_audit.rs new file mode 100644 index 0000000..8979726 --- /dev/null +++ b/engine/src/engine/mcp/access_audit.rs @@ -0,0 +1,260 @@ +//! The DURABLE forensic/raw disclosure audit sink (JEF-490): the durable implementation of the +//! JEF-488 [`AuditSink`](super::audit::AuditSink) seam, plus the read handle the operator "Access" +//! dashboard tab renders from. Every MCP response ABOVE the safe-by-construction `redacted` tier is +//! genuine cluster-data egress (ADR-0031 §4), so it appends ONE append-only record — +//! **subject · entry · tool · tier · time** — bound to the VERIFIED token subject; a `redacted` +//! response never reaches here (the dispatcher gates on `tier.is_disclosure()`). +//! +//! This is a **distinct concern** from the [`DecisionJournal`](crate::engine::journal): a security +//! decision (a breach verdict, an applied cut) is not the same record as a human's disclosure pull, +//! so it lives in its OWN file (`PROTECTOR_MCP_AUDIT_PATH`) with its OWN record type — never +//! overloaded onto the DecisionJournal. It REUSES the journal's proven plumbing PATTERN: an +//! append-only JSON-lines file on the operator-provided PVC, size-bounded with a single-generation +//! rotation, and an **absent/unwritable** volume degrades to in-memory only — it NEVER crashes the +//! read (auditing is best-effort observability, not a gate). +//! +//! It ALSO keeps a bounded in-memory ring (newest-first for the screen), replayed from the durable +//! tail on boot, so the "Access" tab isn't blank after a restart when the volume is durable — and is +//! honestly empty (calm least-privilege) when nothing has been pulled. + +use std::collections::VecDeque; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; + +use super::audit::{AuditRecord, AuditSink}; +use super::tiering::EffectiveTier; + +/// Size cap (bytes) for the active audit file before it rotates. ~1 MiB holds many thousands of +/// disclosure lines while bounding disk use on a small mounted volume; one rolled generation caps +/// total on-disk size at ~`2 × MAX_BYTES`, exactly like the decision journal. +const MAX_BYTES: u64 = 1024 * 1024; + +/// The in-memory ring cap — how many of the newest disclosure records the "Access" screen retains +/// for a fast read. Bounded so a long-lived engine can't grow the ring unboundedly; older lines +/// still live on the durable file (they simply age out of the screen's window). +const MAX_RECORDS: usize = 512; + +/// One durable forensic/raw disclosure line (ADR-0031 §4) — the owned, serializable mirror of an +/// [`AuditRecord`]. Every field is a low-cardinality fact; NO cluster crown-jewel value rides here +/// (the disclosed data was in the tool response, not the audit line). The `entry` is a workload +/// identity (or the bulk-scope label) — itself a forensic-tier fact, so the "Access" screen redacts +/// it to the VIEWER's own tier before rendering. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AccessRecord { + /// The verified human subject (`sub`) the token bound to — WHO saw the cluster fact (with + /// ID-JAG this is the real human `sub`). Untrusted at render — escaped by the client. + pub subject: String, + /// The entry the disclosure was scoped to (a workload identity), or the bulk-scope label for a + /// bulk listing — WHAT was disclosed. A forensic-tier fact; redacted per-viewer on the screen. + pub entry: String, + /// The tool that served it — WHICH read. + pub tool: String, + /// The effective (clamped) tier the response was rendered at — HOW MUCH was disclosed. + pub tier: EffectiveTier, + /// Emission time, seconds since the Unix epoch — WHEN. + pub time_unix_secs: u64, +} + +impl From for AccessRecord { + fn from(r: AuditRecord) -> Self { + Self { + subject: r.subject, + entry: r.entry, + tool: r.tool.to_string(), + tier: r.tier, + time_unix_secs: r.time_unix_secs, + } + } +} + +/// The durable forensic/raw disclosure audit sink. Wraps an optional file path on the journal PVC +/// (`Some` when a writable volume is configured, `None` = in-memory-only) plus a bounded in-memory +/// ring the "Access" screen reads. Every public method is infallible from the caller's view: a +/// write error disables the durable file (logged once) and the sink keeps recording in memory, so a +/// volume that vanishes mid-run never crashes the read — auditing is observability, not a gate. +pub struct AccessAuditSink { + /// The active durable file path, or `None` for the in-memory-only sink. + path: Option, + /// Set once a durable write fails, so we stop retrying (and stop spamming the log) — the sink + /// then behaves as in-memory-only from that point. + disabled: Mutex, + /// The bounded newest-last ring of disclosure records the "Access" screen snapshots. + ring: Mutex>, +} + +impl Default for AccessAuditSink { + fn default() -> Self { + Self { + path: None, + disabled: Mutex::new(false), + ring: Mutex::new(VecDeque::new()), + } + } +} + +impl AccessAuditSink { + /// An in-memory-only sink — records to the ring, never to disk. The honest default when no + /// volume is configured; the "Access" screen then shows the "resets on restart" caveat. + pub fn in_memory() -> Self { + Self::default() + } + + /// Build from the configured path (on the same PVC the decision journal mounts). A probe write + /// verifies the volume is actually writable; if it isn't (absent mount, read-only PVC) the sink + /// degrades to [`in_memory`](Self::in_memory) with a warning — it NEVER errors. Parent dirs are + /// created best-effort so a bare hostPath mount works without a manual `mkdir`. On success the + /// durable tail is replayed into the ring so the screen isn't blank after a restart. + pub fn open(path: impl AsRef) -> Self { + let path = path.as_ref().to_path_buf(); + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + let _ = std::fs::create_dir_all(parent); + } + match OpenOptions::new().create(true).append(true).open(&path) { + Ok(_) => { + let mut ring = VecDeque::new(); + for record in replay(&path) { + push_bounded(&mut ring, record); + } + tracing::info!(path = %path.display(), "mcp access-audit enabled (durable)"); + Self { + path: Some(path), + disabled: Mutex::new(false), + ring: Mutex::new(ring), + } + } + Err(error) => { + tracing::warn!( + path = %path.display(), %error, + "mcp access-audit volume is not writable; recording in-memory only (no crash)" + ); + Self::in_memory() + } + } + } + + /// Build from `PROTECTOR_MCP_AUDIT_PATH`, consistent with the other `PROTECTOR_*_PATH` mounted + /// contracts. Unset/empty ⇒ [`in_memory`](Self::in_memory). + pub fn from_env() -> Self { + match std::env::var("PROTECTOR_MCP_AUDIT_PATH") { + Ok(path) if !path.trim().is_empty() => Self::open(path.trim()), + _ => Self::in_memory(), + } + } + + /// Whether the sink persists to a writable volume (durable across a restart). `false` ⇒ + /// in-memory-only — the "Access" screen then carries the honest "resets on restart" caveat so an + /// empty log is never misread as "nobody ever pulled raw". + pub fn is_durable(&self) -> bool { + self.path.is_some() && !*self.disabled.lock().expect("access-audit mutex poisoned") + } + + /// A NEWEST-FIRST snapshot of the retained disclosure records — what the "Access" screen renders + /// (Section 2 lists forensic/raw pulls newest-first). Cheap clone of the bounded ring. + pub fn records(&self) -> Vec { + self.ring + .lock() + .expect("access-audit mutex poisoned") + .iter() + .rev() + .cloned() + .collect() + } + + /// Append one already-serializable record: push to the ring (bounded) and, best-effort, to the + /// durable file. A durable write error disables the file (logged once) but the ring push always + /// lands — the read never fails. + fn append(&self, record: AccessRecord) { + { + let mut ring = self.ring.lock().expect("access-audit mutex poisoned"); + push_bounded(&mut ring, record.clone()); + } + let Some(path) = &self.path else { return }; + if *self.disabled.lock().expect("access-audit mutex poisoned") { + return; + } + if let Err(error) = try_append(path, &record) { + tracing::warn!( + path = %path.display(), %error, + "mcp access-audit write failed; disabling durable file (in-memory only from here)" + ); + *self.disabled.lock().expect("access-audit mutex poisoned") = true; + } + } +} + +impl AuditSink for AccessAuditSink { + fn emit(&self, record: AuditRecord) { + self.append(record.into()); + } +} + +/// Push a record onto the newest-last ring, evicting the oldest once the cap is reached so the +/// in-memory window stays bounded. +fn push_bounded(ring: &mut VecDeque, record: AccessRecord) { + if ring.len() >= MAX_RECORDS { + ring.pop_front(); + } + ring.push_back(record); +} + +/// Append one disclosure line as JSON. Rotation is checked before the write so the active file +/// stays under [`MAX_BYTES`], mirroring the decision journal's single-generation roll. +fn try_append(path: &Path, record: &AccessRecord) -> std::io::Result<()> { + let mut line = serde_json::to_string(record) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + line.push('\n'); + rotate_if_needed(path)?; + let mut file = OpenOptions::new().create(true).append(true).open(path)?; + file.write_all(line.as_bytes())?; + Ok(()) +} + +/// Replay the durable tail, oldest line first, across the rotation boundary: the rolled generation +/// (`.1`) then the active file. A corrupt/truncated trailing line (a crash mid-write) is +/// skipped, never fatal — exactly the decision journal's tolerant parse. +fn replay(path: &Path) -> Vec { + let mut records = Vec::new(); + for p in [rolled_path(path), path.to_path_buf()] { + if let Ok(contents) = std::fs::read_to_string(&p) { + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Ok(record) = serde_json::from_str::(line) { + records.push(record); + } + } + } + } + records +} + +/// The rolled-generation path for `path`: `.1`. +fn rolled_path(path: &Path) -> PathBuf { + let mut s = path.as_os_str().to_owned(); + s.push(".1"); + PathBuf::from(s) +} + +/// Rotate the active file when it exceeds [`MAX_BYTES`]: move it to `.1` (replacing any prior +/// roll), leaving the caller to create a fresh active file on the next write. +fn rotate_if_needed(path: &Path) -> std::io::Result<()> { + let over_cap = match std::fs::metadata(path) { + Ok(meta) => meta.len() >= MAX_BYTES, + Err(_) => false, + }; + if over_cap { + std::fs::rename(path, rolled_path(path))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/engine/src/engine/mcp/access_audit/tests.rs b/engine/src/engine/mcp/access_audit/tests.rs new file mode 100644 index 0000000..a4269d9 --- /dev/null +++ b/engine/src/engine/mcp/access_audit/tests.rs @@ -0,0 +1,104 @@ +//! Tests for the durable forensic/raw disclosure audit sink (JEF-490): a disclosure records ONE +//! line (subject·entry·tool·tier·time) and round-trips across a "restart" when durable; an absent +//! or unwritable volume degrades to in-memory-only without crashing; and the ring reads +//! newest-first. The end-to-end "a forensic/raw pull records exactly one line, a redacted pull +//! records none" is proven through the real trust-core `dispatch` in the sibling `mcp::tests` +//! (which owns the `Finding` fixtures). + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use super::*; +use crate::engine::mcp::audit::{AuditRecord, AuditSink}; + +/// A unique temp path for one test (no temp-file crate): the system temp dir + the test tag + a +/// per-call nonce (pid + an atomic counter), so parallel tests never collide. +fn temp_path(tag: &str) -> PathBuf { + static NONCE: AtomicU64 = AtomicU64::new(0); + let n = NONCE.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "protector-access-audit-{tag}-{}-{n}.jsonl", + std::process::id() + )) +} + +/// Remove a sink's files (active + rolled) so a test leaves no residue. +fn cleanup(path: &Path) { + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_file(rolled_path(path)); +} + +fn record(subject: &str, entry: &str, tool: &'static str, tier: EffectiveTier) -> AuditRecord { + AuditRecord::now(subject, entry, tool, tier) +} + +#[test] +fn a_durable_sink_round_trips_disclosures_across_a_reopen() { + // The acceptance criterion: forensic/raw pulls written before a "restart" replay after it, so + // the "Access" tab isn't blank on boot when the volume is durable. + let path = temp_path("roundtrip"); + { + let sink = AccessAuditSink::open(&path); + assert!(sink.is_durable(), "a writable path makes the sink durable"); + sink.emit(record( + "alice@corp.example", + "workload/app/Pod/web", + "explain_verdict", + EffectiveTier::Raw, + )); + sink.emit(record( + "bob@corp.example", + "(all findings)", + "list_findings", + EffectiveTier::Forensic, + )); + } + // A fresh sink on the same path (the "post-restart" engine) replays it all, newest-first. + let reopened = AccessAuditSink::open(&path); + let records = reopened.records(); + assert_eq!(records.len(), 2, "both disclosures survive the reopen"); + assert_eq!(records[0].subject, "bob@corp.example", "newest-first"); + assert_eq!(records[0].tier, EffectiveTier::Forensic); + assert_eq!(records[1].subject, "alice@corp.example"); + assert_eq!(records[1].entry, "workload/app/Pod/web"); + assert_eq!(records[1].tool, "explain_verdict"); + assert_eq!(records[1].tier, EffectiveTier::Raw); + assert!(records[1].time_unix_secs > 0, "the WHEN stamp is set"); + cleanup(&path); +} + +#[test] +fn an_in_memory_sink_records_to_the_ring_but_is_not_durable() { + let sink = AccessAuditSink::in_memory(); + assert!( + !sink.is_durable(), + "no volume ⇒ not durable (resets on restart)" + ); + sink.emit(record( + "carol@corp.example", + "workload/app/Pod/api", + "explain_verdict", + EffectiveTier::Raw, + )); + let records = sink.records(); + assert_eq!( + records.len(), + 1, + "the ring retains the record for the screen" + ); + assert_eq!(records[0].subject, "carol@corp.example"); +} + +#[test] +fn an_unwritable_path_degrades_to_in_memory_without_crashing() { + // A path whose parent is a regular file can't be created — the sink degrades to in-memory, + // never panics, and still records to the ring. + let file = temp_path("not-a-dir"); + std::fs::write(&file, b"i am a file, not a directory").unwrap(); + let under_a_file = file.join("audit.jsonl"); + let sink = AccessAuditSink::open(&under_a_file); + assert!(!sink.is_durable(), "an unwritable path is not durable"); + sink.emit(record("x@e", "y", "list_findings", EffectiveTier::Forensic)); + assert_eq!(sink.records().len(), 1, "recording still lands in memory"); + cleanup(&file); +} diff --git a/engine/src/engine/mcp/mod.rs b/engine/src/engine/mcp/mod.rs index d9d7262..058c319 100644 --- a/engine/src/engine/mcp/mod.rs +++ b/engine/src/engine/mcp/mod.rs @@ -22,6 +22,7 @@ //! rmcp ([`server`] adapter + [`transport`] mount) is transport BELOW the boundary; it frames //! JSON-RPC and speaks the discovery/challenge handshake, and makes no trust decision. +pub mod access_audit; pub mod audit; mod dispatch; mod render; @@ -31,8 +32,16 @@ mod tiering; mod tools; mod transport; +pub use access_audit::{AccessAuditSink, AccessRecord}; pub use state::McpState; +pub use tiering::EffectiveTier; +pub use tools::BULK_SCOPE; pub use transport::{MCP_PATH, WELL_KNOWN_PATH, serve_mcp}; +/// The sentinel a `redacted`-tier viewer sees in place of a withheld workload identity — the SAME +/// string the tool emits (`render::S_ENTRY`, JEF-488), re-exported so the "Access" screen (JEF-490) +/// redacts a pull's target-class with ONE shared vocabulary across tool + screen. +pub use render::S_ENTRY as WORKLOAD_IDENTITY_WITHHELD; + #[cfg(test)] mod tests; diff --git a/engine/src/engine/mcp/render.rs b/engine/src/engine/mcp/render.rs index c6b59f8..cdaf86c 100644 --- a/engine/src/engine/mcp/render.rs +++ b/engine/src/engine/mcp/render.rs @@ -24,8 +24,10 @@ use super::tiering::EffectiveTier; /// two egress paths agree on how much "outcome" a summary discloses. const ATTACK_CAP: usize = 8; -/// The sentinel for a withheld entry/workload identity (a path/topology fact — forensic). -const S_ENTRY: &str = "[redacted — workload identity; forensic tier required]"; +/// The sentinel for a withheld entry/workload identity (a path/topology fact — forensic). `pub` so +/// the "Access" audit screen (JEF-490) redacts a pull's target-class with the SAME vocabulary the +/// tool uses — one sentinel string across tool + screen, never a second wording that could diverge. +pub const S_ENTRY: &str = "[redacted — workload identity; forensic tier required]"; /// The sentinel for withheld proven paths (topology — forensic). const S_PATHS: &str = "[redacted — proven path/topology; forensic tier required]"; /// The sentinel for a withheld CVE inventory (forensic). diff --git a/engine/src/engine/mcp/tests.rs b/engine/src/engine/mcp/tests.rs index 91136d3..ed9519d 100644 --- a/engine/src/engine/mcp/tests.rs +++ b/engine/src/engine/mcp/tests.rs @@ -438,6 +438,53 @@ fn explain_verdict_clamps_a_redacted_token_that_asks_for_raw() { ); } +#[test] +fn the_durable_access_sink_records_exactly_one_line_for_a_raw_pull_and_none_for_redacted() { + // JEF-490: the same subject·entry·tool·tier·time contract, proven through the DURABLE sink the + // "Access" tab reads (not just the test RecordingAuditSink) — a raw pull appends one line, a + // redacted pull appends none. + use crate::engine::mcp::access_audit::AccessAuditSink; + + let state = state_with_secret(); + + // A redacted pull discloses nothing cluster-specific → NO audit line. + let redacted_sink = AccessAuditSink::in_memory(); + dispatch( + &state, + &redacted_sink, + "eve@corp.example", + tools::LIST_FINDINGS, + None, + Tier::Redacted, + ) + .expect("list_findings"); + assert!( + redacted_sink.records().is_empty(), + "a redacted pull is NOT audited by the durable sink" + ); + + // A raw explain_verdict IS audited — exactly one line, carrying the verified subject + entry. + let raw_sink = AccessAuditSink::in_memory(); + let mut args = Map::new(); + args.insert("entry".into(), Value::String(ENTRY.into())); + args.insert("tier".into(), Value::String("raw".into())); + dispatch( + &state, + &raw_sink, + "alice@corp.example", + tools::EXPLAIN_VERDICT, + Some(&args), + Tier::Raw, + ) + .expect("explain_verdict"); + let records = raw_sink.records(); + assert_eq!(records.len(), 1, "a raw pull appends exactly one line"); + assert_eq!(records[0].subject, "alice@corp.example"); + assert_eq!(records[0].entry, ENTRY); + assert_eq!(records[0].tool, tools::EXPLAIN_VERDICT); + assert_eq!(records[0].tier, EffectiveTier::Raw); +} + #[test] fn explain_verdict_accepts_the_opaque_ref_and_rejects_an_unknown_entry() { let state = state_with_secret(); diff --git a/engine/src/engine/mcp/tiering.rs b/engine/src/engine/mcp/tiering.rs index 9733bda..def62c1 100644 --- a/engine/src/engine/mcp/tiering.rs +++ b/engine/src/engine/mcp/tiering.rs @@ -6,11 +6,18 @@ //! is the already-clamped result, so a tool cannot accidentally render at the raw ceiling by reading //! the claim directly. The only way to obtain one is [`EffectiveTier::clamp`]. +use serde::{Deserialize, Serialize}; + use crate::engine::dashboard::auth::claims::Tier; /// The tier a response is actually rendered at — the CLAMPED result of `min(requested, ceiling)`. /// Ordered `Redacted < Forensic < Raw`, mirroring [`Tier`], so a per-tool cap can further lower it. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +/// +/// Serializes to the SAME low-cardinality label [`as_str`](Self::as_str) returns (`"redacted"` / +/// `"forensic"` / `"raw"`), so the durable access-audit line (JEF-490) persists a stable, legible +/// tier tag that round-trips on replay. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] pub enum EffectiveTier { /// Safe-by-construction: verdicts, counts, technique IDs, coverage/freshness — nothing /// cluster-specific. The default and the floor. diff --git a/engine/src/engine/run_loop.rs b/engine/src/engine/run_loop.rs index 816ab59..3bb0a2a 100644 --- a/engine/src/engine/run_loop.rs +++ b/engine/src/engine/run_loop.rs @@ -427,6 +427,15 @@ pub async fn run_watch( checking_images: 0, }); + // The durable forensic/raw MCP disclosure audit sink (ADR-0031 §4, JEF-490): the durable + // implementation of the JEF-488 audit seam, built ONCE and shared by BOTH the MCP server (which + // appends a subject·entry·tool·tier·time line for every forensic/raw disclosure) and the + // dashboard's "Access" tab (which reads its records, redacted to the caller's own tier). A + // distinct concern from the DecisionJournal — its OWN file on the journal PVC + // (`PROTECTOR_MCP_AUDIT_PATH`); unset/unwritable ⇒ in-memory only (no crash), and the "Access" + // tab then shows the honest "resets on restart" caveat. + let mcp_audit = std::sync::Arc::new(crate::engine::mcp::AccessAuditSink::from_env()); + // The read-only operator dashboard (ADR-0019), served behind `PROTECTOR_DASHBOARD_ADDR` // (e.g. `0.0.0.0:8080`). Off when unset — zero-egress, in-cluster only. It reads the SAME // `state::` handles the engine writes each pass (findings, the judgement ring, the reversion @@ -456,6 +465,9 @@ pub async fn run_watch( // decision from `build_dashboard_auth`, so the strip's auth pill can never // claim more than is actually enforced. auth_mode, + // The SAME durable audit sink the MCP server appends to (JEF-490) — the + // "Access" tab reads its records, redacted to the caller's own tier. + mcp_audit: mcp_audit.clone(), }; tokio::spawn(dashboard::serve_dashboard(addr, state, auth)); } @@ -484,10 +496,15 @@ pub async fn run_watch( cluster: std::env::var("PROTECTOR_CLUSTER_LABEL") .unwrap_or_else(|_| "cluster".to_string()), }; - // The audit seam (ADR-0031 §4): a structured tracing event for now; JEF-490 - // wires the durable sink + the "Access" dashboard tab into this same trait. - let audit = std::sync::Arc::new(crate::engine::mcp::audit::TracingAuditSink); - tokio::spawn(crate::engine::mcp::serve_mcp(addr, state, verifier, audit)); + // The audit seam (ADR-0031 §4): the DURABLE sink (JEF-490) — the same `Arc` the + // "Access" dashboard tab reads — so every forensic/raw disclosure appends one + // subject·entry·tool·tier·time line the operator can review. + tokio::spawn(crate::engine::mcp::serve_mcp( + addr, + state, + verifier, + mcp_audit.clone(), + )); } } Err(error) => tracing::error!( @@ -778,221 +795,5 @@ pub async fn run_watch( asn_reloader.abort(); Ok(()) } - #[cfg(test)] -mod tests { - //! JEF-268: the Secret informer (reflector watch + initial list) must be - //! metadata-only — protector reasons about a Secret's *identity* (namespace + - //! name), never its contents, so no credential bytes must ever cross the wire or - //! sit in the in-memory store. These tests pin that guarantee to the exact type the - //! informer reflects, `PartialObjectMeta`; a regression to the full `Secret` - //! type (which carries `.data`) fails them. - - use k8s_openapi::api::core::v1::Secret; - 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 - /// `PartialObjectMeta` (no `.data`) rather than full Secret objects. - #[test] - fn secret_informer_requests_metadata_only() { - assert!( - as Resource>::metadata_api(), - "Secret informer must reflect a metadata-only type; a full Secret would \ - fetch and retain credential bytes" - ); - } - - /// Even handed a full Secret payload (as an apiserver bug or a mistaken watch would - /// deliver), the reflected type structurally cannot retain `.data`/`stringData`: it - /// is dropped on deserialize, while the identity the graph needs survives. This is the - /// "no full Secret with `.data` retained" guarantee. - #[test] - fn reflected_secret_drops_data_keeps_identity() { - let full_secret = serde_json::json!({ - "apiVersion": "v1", - "kind": "Secret", - "metadata": { "namespace": "prod", "name": "db-creds" }, - "type": "Opaque", - "data": { "password": "c3VwZXItc2VjcmV0" }, - "stringData": { "token": "super-secret" }, - }); - - let reflected: PartialObjectMeta = - serde_json::from_value(full_secret).expect("deserialize as metadata-only"); - - // Identity — exactly what `SecretMeta` / the graph's secret-objective nodes need — - // is preserved. - assert_eq!(reflected.metadata.namespace.as_deref(), Some("prod")); - assert_eq!(reflected.metadata.name.as_deref(), Some("db-creds")); - - // Round-trip back to JSON and prove no credential bytes survived anywhere. The - // keys are matched quoted (`"data"`) so the `data` inside `"metadata"` doesn't - // give a false positive. - let round_trip = serde_json::to_value(&reflected).expect("serialize"); - let text = round_trip.to_string(); - assert!( - !text.contains("\"data\""), - "reflected Secret must not carry a `data` field" - ); - assert!( - !text.contains("\"stringData\""), - "reflected Secret must not carry a `stringData` field" - ); - assert!( - !text.contains("c3VwZXItc2VjcmV0") && !text.contains("super-secret"), - "no credential bytes may survive into the reflected store" - ); - } - - // JEF-366: the signing-posture and build-provenance sweeps must draw their cosign verifier - // and env-driven bounds from ONE shared source (`super::cosign_observer_parts`) so the two - // builders can never silently drift — the hand-copied `registry_auth()` shape that caused the - // JEF-339 outage. These tests own a clean process env (nextest runs each test in its own - // process, so the `unsafe { set_var }` blocks are isolated) and point the TUF cache at a - // per-test temp dir so `CosignChecker::new` succeeds offline. - use std::time::Duration; - - /// A unique, creatable TUF cache dir for a test, so the checker builds without touching - /// `/tmp/sigstore` or any other test's dir. - fn scratch_tuf_cache(tag: &str) -> std::path::PathBuf { - std::env::temp_dir().join(format!( - "protector-jef366-{tag}-{}-{:?}", - std::process::id(), - std::thread::current().id() - )) - } - - /// Clear the observer env so a test starts from the documented defaults. - /// SAFETY: nextest runs each test in its own process, so this mutation is isolated. - fn clear_observer_env() { - unsafe { - std::env::remove_var("PROTECTOR_VERIFY_TIMEOUT"); - std::env::remove_var("PROTECTOR_CACHE_TTL"); - std::env::remove_var("PROTECTOR_MAX_IMAGES"); - std::env::remove_var("PROTECTOR_OIDC_ISSUER"); - std::env::remove_var("PROTECTOR_PROVENANCE_ENABLE"); - } - } - - /// The shared source of truth returns the documented JEF-326 defaults (20s verify, 300s TTL, - /// 32 images) when nothing is set — the exact bounds both builders inherit. - #[test] - fn cosign_observer_parts_uses_documented_defaults() { - clear_observer_env(); - unsafe { std::env::set_var("PROTECTOR_TUF_CACHE", scratch_tuf_cache("defaults")) }; - - let (_, max_images, cache_ttl) = super::cosign_observer_parts("test") - .expect("checker builds with a creatable cache dir"); - assert_eq!(max_images, 32, "PROTECTOR_MAX_IMAGES default"); - assert_eq!( - cache_ttl, - Duration::from_secs(300), - "PROTECTOR_CACHE_TTL default" - ); - } - - /// Env overrides flow through the single helper — so both sweeps track the same knobs. - #[test] - fn cosign_observer_parts_honors_env_overrides() { - clear_observer_env(); - unsafe { - std::env::set_var("PROTECTOR_TUF_CACHE", scratch_tuf_cache("overrides")); - std::env::set_var("PROTECTOR_CACHE_TTL", "42"); - std::env::set_var("PROTECTOR_MAX_IMAGES", "7"); - } - - let (_, max_images, cache_ttl) = - super::cosign_observer_parts("test").expect("checker builds"); - assert_eq!(max_images, 7); - assert_eq!(cache_ttl, Duration::from_secs(42)); - } - - /// Anti-drift: from ONE env, the signing observer builds AND (once opted in) the provenance - /// scanner builds — both routed through the shared parts, both inheriting the same bounds. If - /// either builder stopped going through `cosign_observer_parts`, this pins the equivalence. - #[test] - fn both_builders_build_from_the_same_env() { - clear_observer_env(); - unsafe { - std::env::set_var("PROTECTOR_TUF_CACHE", scratch_tuf_cache("both")); - std::env::set_var("PROTECTOR_MAX_IMAGES", "9"); - std::env::set_var("PROTECTOR_CACHE_TTL", "77"); - } - - assert!( - super::build_signing_observer().is_some(), - "signing observer must build from a valid env" - ); - - // Provenance is opt-in: off by default, on only when explicitly enabled — the one bit - // that stays distinct from the signing sweep. - assert!( - super::build_provenance_scanner().is_none(), - "provenance scanner is off until PROTECTOR_PROVENANCE_ENABLE is set" - ); - unsafe { std::env::set_var("PROTECTOR_PROVENANCE_ENABLE", "1") }; - assert!( - super::build_provenance_scanner().is_some(), - "provenance scanner must build from the same env once opted in" - ); - } -} +mod tests; diff --git a/engine/src/engine/run_loop/tests.rs b/engine/src/engine/run_loop/tests.rs new file mode 100644 index 0000000..130bcd8 --- /dev/null +++ b/engine/src/engine/run_loop/tests.rs @@ -0,0 +1,216 @@ +//! Unit tests for the engine driver's env-driven builders, extracted from `run_loop.rs` to keep +//! that file under the repo's 1,000-line cap (CLAUDE.md). `super` is the `run_loop` module, so the +//! tests drive its private builders unchanged. +//! +//! JEF-268: the Secret informer (reflector watch + initial list) must be metadata-only — protector +//! reasons about a Secret's *identity* (namespace + name), never its contents, so no credential +//! bytes must ever cross the wire or sit in the in-memory store. These tests pin that guarantee to +//! the exact type the informer reflects, `PartialObjectMeta`; a regression to the full +//! `Secret` type (which carries `.data`) fails them. + +use k8s_openapi::api::core::v1::Secret; +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 +/// `PartialObjectMeta` (no `.data`) rather than full Secret objects. +#[test] +fn secret_informer_requests_metadata_only() { + assert!( + as Resource>::metadata_api(), + "Secret informer must reflect a metadata-only type; a full Secret would \ + fetch and retain credential bytes" + ); +} + +/// Even handed a full Secret payload (as an apiserver bug or a mistaken watch would +/// deliver), the reflected type structurally cannot retain `.data`/`stringData`: it +/// is dropped on deserialize, while the identity the graph needs survives. This is the +/// "no full Secret with `.data` retained" guarantee. +#[test] +fn reflected_secret_drops_data_keeps_identity() { + let full_secret = serde_json::json!({ + "apiVersion": "v1", + "kind": "Secret", + "metadata": { "namespace": "prod", "name": "db-creds" }, + "type": "Opaque", + "data": { "password": "c3VwZXItc2VjcmV0" }, + "stringData": { "token": "super-secret" }, + }); + + let reflected: PartialObjectMeta = + serde_json::from_value(full_secret).expect("deserialize as metadata-only"); + + // Identity — exactly what `SecretMeta` / the graph's secret-objective nodes need — + // is preserved. + assert_eq!(reflected.metadata.namespace.as_deref(), Some("prod")); + assert_eq!(reflected.metadata.name.as_deref(), Some("db-creds")); + + // Round-trip back to JSON and prove no credential bytes survived anywhere. The + // keys are matched quoted (`"data"`) so the `data` inside `"metadata"` doesn't + // give a false positive. + let round_trip = serde_json::to_value(&reflected).expect("serialize"); + let text = round_trip.to_string(); + assert!( + !text.contains("\"data\""), + "reflected Secret must not carry a `data` field" + ); + assert!( + !text.contains("\"stringData\""), + "reflected Secret must not carry a `stringData` field" + ); + assert!( + !text.contains("c3VwZXItc2VjcmV0") && !text.contains("super-secret"), + "no credential bytes may survive into the reflected store" + ); +} + +// JEF-366: the signing-posture and build-provenance sweeps must draw their cosign verifier +// and env-driven bounds from ONE shared source (`super::cosign_observer_parts`) so the two +// builders can never silently drift — the hand-copied `registry_auth()` shape that caused the +// JEF-339 outage. These tests own a clean process env (nextest runs each test in its own +// process, so the `unsafe { set_var }` blocks are isolated) and point the TUF cache at a +// per-test temp dir so `CosignChecker::new` succeeds offline. +use std::time::Duration; + +/// A unique, creatable TUF cache dir for a test, so the checker builds without touching +/// `/tmp/sigstore` or any other test's dir. +fn scratch_tuf_cache(tag: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "protector-jef366-{tag}-{}-{:?}", + std::process::id(), + std::thread::current().id() + )) +} + +/// Clear the observer env so a test starts from the documented defaults. +/// SAFETY: nextest runs each test in its own process, so this mutation is isolated. +fn clear_observer_env() { + unsafe { + std::env::remove_var("PROTECTOR_VERIFY_TIMEOUT"); + std::env::remove_var("PROTECTOR_CACHE_TTL"); + std::env::remove_var("PROTECTOR_MAX_IMAGES"); + std::env::remove_var("PROTECTOR_OIDC_ISSUER"); + std::env::remove_var("PROTECTOR_PROVENANCE_ENABLE"); + } +} + +/// The shared source of truth returns the documented JEF-326 defaults (20s verify, 300s TTL, +/// 32 images) when nothing is set — the exact bounds both builders inherit. +#[test] +fn cosign_observer_parts_uses_documented_defaults() { + clear_observer_env(); + unsafe { std::env::set_var("PROTECTOR_TUF_CACHE", scratch_tuf_cache("defaults")) }; + + let (_, max_images, cache_ttl) = + super::cosign_observer_parts("test").expect("checker builds with a creatable cache dir"); + assert_eq!(max_images, 32, "PROTECTOR_MAX_IMAGES default"); + assert_eq!( + cache_ttl, + Duration::from_secs(300), + "PROTECTOR_CACHE_TTL default" + ); +} + +/// Env overrides flow through the single helper — so both sweeps track the same knobs. +#[test] +fn cosign_observer_parts_honors_env_overrides() { + clear_observer_env(); + unsafe { + std::env::set_var("PROTECTOR_TUF_CACHE", scratch_tuf_cache("overrides")); + std::env::set_var("PROTECTOR_CACHE_TTL", "42"); + std::env::set_var("PROTECTOR_MAX_IMAGES", "7"); + } + + let (_, max_images, cache_ttl) = super::cosign_observer_parts("test").expect("checker builds"); + assert_eq!(max_images, 7); + assert_eq!(cache_ttl, Duration::from_secs(42)); +} + +/// Anti-drift: from ONE env, the signing observer builds AND (once opted in) the provenance +/// scanner builds — both routed through the shared parts, both inheriting the same bounds. If +/// either builder stopped going through `cosign_observer_parts`, this pins the equivalence. +#[test] +fn both_builders_build_from_the_same_env() { + clear_observer_env(); + unsafe { + std::env::set_var("PROTECTOR_TUF_CACHE", scratch_tuf_cache("both")); + std::env::set_var("PROTECTOR_MAX_IMAGES", "9"); + std::env::set_var("PROTECTOR_CACHE_TTL", "77"); + } + + assert!( + super::build_signing_observer().is_some(), + "signing observer must build from a valid env" + ); + + // Provenance is opt-in: off by default, on only when explicitly enabled — the one bit + // that stays distinct from the signing sweep. + assert!( + super::build_provenance_scanner().is_none(), + "provenance scanner is off until PROTECTOR_PROVENANCE_ENABLE is set" + ); + unsafe { std::env::set_var("PROTECTOR_PROVENANCE_ENABLE", "1") }; + assert!( + super::build_provenance_scanner().is_some(), + "provenance scanner must build from the same env once opted in" + ); +} diff --git a/engine/web/dist/dashboard.css b/engine/web/dist/dashboard.css index e5306e5..935fcae 100644 --- a/engine/web/dist/dashboard.css +++ b/engine/web/dist/dashboard.css @@ -1200,3 +1200,54 @@ summary:focus-visible, .prov-absent { color: var(--ink-3); } .prov-checking { color: var(--posture-awaiting); } .provenance-by { color: var(--ink-3); } + +/* ============================ Access view (JEF-490, ADR-0031 §4) ============================ + The forensic/raw MCP disclosure audit. "your access" (the caller's tier + the per-tier reveal + list) sits above "forensic & raw pulls" (the audit table). Honesty over colour: every tier is + carried by glyph + WORD, never colour alone; raw is the loud/scarce breach palette (a crown-jewel + read); redacted is the calm floor. The audit table reuses the `.decisions` table shell so the + machine columns align (keyboard/semantics gate). */ +.view > .access-your { margin-bottom: var(--section-gap); } + +/* the tier chip: colour + glyph + WORD. `raw` reads in the loud breach palette (the scarce, most- + disclosing read); forensic is the caution amber (partial cluster detail); redacted is the calm + floor. The WORD is always present — meaning is never carried by colour alone. */ +.access-tier { + display: inline-flex; align-items: baseline; gap: var(--space-1); + font-size: var(--fs-micro); line-height: var(--lh-micro); font-weight: var(--fw-bold); + letter-spacing: var(--track-micro); text-transform: uppercase; + white-space: nowrap; +} +.access-tier-glyph { font-weight: var(--fw-bold); } +.access-tier-redacted { color: var(--ink-3); } +.access-tier-forensic { color: var(--posture-uncertain); } +.access-tier-raw { color: var(--posture-breach); } + +/* the per-tier reveal rows reuse `.cov-row`; the tier the caller HOLDS gets a soft cleared keyline + + a "your tier" badge (never colour alone — the badge word carries it). */ +.access-reveal-held { border-left: var(--rail-soft) solid var(--cov-present); } +.access-reveal-badge { + color: var(--cov-present); font-weight: var(--fw-bold); + letter-spacing: var(--track-micro); text-transform: uppercase; + border: var(--hairline) solid currentColor; border-radius: var(--radius-chip); + padding: 0 var(--space-1); +} +.access-reveal-label { + color: var(--ink-3); letter-spacing: var(--track-micro); text-transform: uppercase; + margin-right: var(--space-1); +} + +/* the audit table: reuse the `.decisions` shell (aligned columns, ``). A raw pull carries + the loud keyline on its leading cell — the crown-jewel read draws the eye. */ +.access-pulls .access-pull-row:hover { background: var(--surface-hover); } +.access-pull-raw td.access-when { + border-left: var(--rail-strong) solid var(--posture-breach); +} +.access-when { white-space: nowrap; } +.access-who { color: var(--ink-1); word-break: break-all; } +.access-target { color: var(--ink-2); word-break: break-all; font-family: var(--font-data); } + +/* the honest empty state — calm (least-privilege working), never a false "nobody ever pulled". The + in-memory reset note is muted but present so an empty log is never over-read. */ +.empty-access-calm .empty-head { color: var(--ink-1); } +.access-reset-note { color: var(--ink-3); } diff --git a/engine/web/src/access/view.jsx b/engine/web/src/access/view.jsx new file mode 100644 index 0000000..e3616bf --- /dev/null +++ b/engine/web/src/access/view.jsx @@ -0,0 +1,180 @@ +// The "Access" view (JEF-490 / ADR-0031 §4) — the operator's window onto the read-only MCP server's +// forensic/raw disclosure audit. Two sections: +// +// 1. "your access" — the caller's OWN tier as a chip (colour + glyph + WORD, never colour alone; +// glyph aria-hidden; raw = the loud/scarce posture colour), over a `cov-rows`-style list of what +// each tier reveals vs withholds, marking which the caller holds. +// 2. "forensic & raw pulls" — a real semantic `` (aligned columns, ` + + + + + + + ); +} + +/** The honest empty state. Calm — least-privilege is working — but NEVER misread as "nobody ever + * pulled": an in-memory sink adds the "resets on restart" caveat; a durable sink omits it (the log + * is authoritative). */ +function EmptyPulls({ durable }) { + return ( +
+

no forensic or raw pulls recorded

+

+ least-privilege is working {"\u{2014}"} nobody has pulled cluster-specific detail above the + redacted floor + {durable ? ( + "." + ) : ( + <> + , within this log's window.{" "} + this log lives in memory and resets on restart. + + )} +

+
+ ); +} + +/** A reconcile key for one audit row. Pulls have no server id (an append-only log), so key on a + * content hash of the row's fields plus the index — stable enough that an unchanged tail reconciles + * in place, while a genuinely new top row is a new node. */ +function pullKey(p, i) { + const parts = [p.when, p.who, p.tool, p.tier, p.target, String(i)]; + return `pull-${contentHash(parts.join(" "))}`; +} diff --git a/engine/web/src/app.jsx b/engine/web/src/app.jsx index 71e28d7..d65ad15 100644 --- a/engine/web/src/app.jsx +++ b/engine/web/src/app.jsx @@ -27,6 +27,7 @@ import { AlertsView } from "./alerts/view.jsx"; import { ActionView } from "./action/view.jsx"; import { ReadinessView } from "./readiness/view.jsx"; import { AdmissionView } from "./admission/view.jsx"; +import { AccessView } from "./access/view.jsx"; const TABS = [ { id: "findings", label: "Findings", href: "/" }, @@ -34,6 +35,7 @@ const TABS = [ { id: "action", label: "Action", href: "/?tab=action" }, { id: "readiness", label: "Readiness", href: "/?tab=readiness" }, { id: "admission", label: "Admission", href: "/?tab=admission" }, + { id: "access", label: "Access", href: "/?tab=access" }, ]; /** @@ -251,7 +253,14 @@ function TabNav({ activeTab, onSwap }) { /** Read the active tab from `?tab=` (the same vocabulary the server's `TabQuery` resolves). */ export function tabFromLocation() { const t = new URLSearchParams(window.location.search).get("tab"); - if (t === "alerts" || t === "action" || t === "readiness" || t === "admission") return t; + if ( + t === "alerts" || + t === "action" || + t === "readiness" || + t === "admission" || + t === "access" + ) + return t; return "findings"; } @@ -276,6 +285,8 @@ function ActiveView({ activeTab, data }) { return ; case "admission": return ; + case "access": + return ; default: return ; } diff --git a/engine/web/test/access.test.jsx b/engine/web/test/access.test.jsx new file mode 100644 index 0000000..9fbc08a --- /dev/null +++ b/engine/web/test/access.test.jsx @@ -0,0 +1,99 @@ +// Access view tests (JEF-490 / ADR-0031 §4): the tier chip carries colour + glyph + WORD (never +// colour alone; glyph aria-hidden); Section 2 is a real semantic
`), one row +// per disclosure newest-first: when · who · tool · tier · target-class. A `raw` pull carries the +// loud keyline. Every identity/target string is a JSX child (Preact auto-escapes). +// +// The tier-aware REDACTION is entirely server-side: the `target` this view renders is ALREADY the +// real workload identity (for a viewer whose tier unlocks it) or the withheld-workload sentinel (for +// a lower-tier viewer) — the client derives nothing, it only displays. The empty state honestly +// distinguishes "nobody pulled above redacted" (calm, least-privilege working) from the log-reset +// fact: the "resets on restart" sub-line shows ONLY when the audit sink is in-memory (`durable:false`). + +import { contentHash } from "../keys.js"; + +// The disclosure tiers, each carried as colour + glyph + WORD. `raw` is the loud/scarce posture +// (the breach palette): a raw disclosure is the crown-jewel read. `redacted` is the calm floor. +const TIER = { + redacted: { glyph: "\u{25CB}", word: "redacted" }, // ○ — minimal, safe-by-construction + forensic: { glyph: "\u{25D0}", word: "forensic" }, // ◐ — partial cluster detail + raw: { glyph: "\u{25CF}", word: "raw" }, // ● — the loud, scarce full disclosure +}; + +/** Look up a tier's presentation, defaulting to `redacted` (never a louder tier) for an unknown + * tag — an unrecognised tier must never read as more-disclosing than it is. */ +function tierOf(tag) { + return TIER[tag] || TIER.redacted; +} + +/** The caller's / a pull's tier as a chip: colour + glyph + WORD (never colour alone), the glyph + * aria-hidden so the WORD carries it for a screen reader. */ +function TierChip({ tier }) { + const t = tierOf(tier); + return ( + + + {t.word} + + ); +} + +/** + * @param {object} props + * @param {any} props.view the Access view props (`{ strip, tier, reveals, pulls, pull-count, + * durable }`, serde kebab-case). + */ +export function AccessView({ view }) { + const reveals = Array.isArray(view.reveals) ? view.reveals : []; + const pulls = Array.isArray(view.pulls) ? view.pulls : []; + const durable = view.durable === true; + return ( +
+
+

your access

+

+ your token grants the tier. every disclosure above the + redacted floor is cluster-data egress {"\u{2014}"} recorded below, and itself redacted to + your tier. +

+
    + {reveals.map((r) => ( + + ))} +
+
+ +
+

forensic & raw pulls

+ {pulls.length === 0 ? ( + + ) : ( + + + + + + + + + + + + {pulls.map((p, i) => ( + + ))} + +
+ when + + who + + tool + + tier + + target-class +
+ )} +
+
+ ); +} + +/** One "what this tier reveals/withholds" row. The tier the caller HOLDS is marked (never colour + * alone — a "your tier" badge + the held keyline). */ +function RevealRow({ r }) { + const held = r.held === true; + return ( +
  • +
    + + {held ? your tier : null} +
    +

    + reveals {r.reveals} +

    +

    + withholds {r.withholds} +

    +
  • + ); +} + +/** One audit row: when · who · tool · tier · target-class. A `raw` pull carries the loud keyline. + * Identity/target are JSX children (auto-escaped). */ +function PullRow({ p }) { + return ( +
    {p.when}{p.who}{p.tool} + + {p.target}
    with headers; a raw pull +// row carries the loud keyline; untrusted identity/target strings render inert (escaped); and the +// empty state honestly distinguishes an in-memory (resets on restart) from a durable log. + +import { describe, it, expect, beforeEach } from "vitest"; +import { render, cleanup } from "@testing-library/preact"; +import { AccessView } from "../src/access/view.jsx"; +import { accessView, accessReveal, accessPull } from "./fixtures.js"; + +beforeEach(() => { + sessionStorage.clear(); + cleanup(); +}); + +describe("Access — your tier chip", () => { + it("carries word + glyph (aria-hidden), never colour alone", () => { + const { container } = render( + , + ); + // The caller's chip appears (in the section sub-line) with the WORD always present. + const chip = container.querySelector(".access-tier-raw"); + expect(chip).toBeTruthy(); + expect(chip.querySelector(".access-tier-word").textContent).toBe("raw"); + // The glyph is decorative — aria-hidden so the WORD carries it for a screen reader. + expect(chip.querySelector(".access-tier-glyph").getAttribute("aria-hidden")).toBe("true"); + }); + + it("marks the tier the caller holds with a badge (not colour alone)", () => { + const { container } = render( + , + ); + const held = container.querySelector('li[data-tier="redacted"]'); + expect(held.classList.contains("access-reveal-held")).toBe(true); + expect(held.querySelector(".access-reveal-badge").textContent).toContain("your tier"); + const notHeld = container.querySelector('li[data-tier="raw"]'); + expect(notHeld.classList.contains("access-reveal-held")).toBe(false); + }); +}); + +describe("Access — the forensic/raw pulls table", () => { + it("is a real
    with column headers", () => { + const { container } = render(); + const table = container.querySelector("table.access-pulls"); + expect(table).toBeTruthy(); + const headers = [...table.querySelectorAll("thead th[scope=col]")].map((th) => + th.textContent.trim(), + ); + expect(headers).toEqual(["when", "who", "tool", "tier", "target-class"]); + }); + + it("gives a raw pull row the loud keyline; a forensic row stays calm", () => { + const { container } = render( + , + ); + const rows = container.querySelectorAll("tr.access-pull-row"); + expect(rows[0].classList.contains("access-pull-raw")).toBe(true); + expect(rows[1].classList.contains("access-pull-raw")).toBe(false); + }); + + it("renders an XSS-laden subject/target as inert text, never live HTML", () => { + window.__pwned = undefined; + const XSS = ''; + const { container } = render( + , + ); + expect(container.querySelector("img")).toBeNull(); + expect(window.__pwned).toBeUndefined(); + expect(container.textContent).toContain(XSS); + }); +}); + +describe("Access — honest empty state", () => { + it("an in-memory log carries the resets-on-restart caveat (never 'nobody ever pulled')", () => { + const { container } = render(); + expect(container.querySelector(".empty-access-calm")).toBeTruthy(); + expect(container.textContent).toContain("no forensic or raw pulls recorded"); + expect(container.textContent).toContain("resets on restart"); + }); + + it("a durable log omits the resets caveat (the log is authoritative)", () => { + const { container } = render(); + expect(container.querySelector(".empty-access-calm")).toBeTruthy(); + expect(container.textContent).toContain("no forensic or raw pulls recorded"); + expect(container.textContent).not.toContain("resets on restart"); + }); +}); diff --git a/engine/web/test/fixtures.js b/engine/web/test/fixtures.js index 122d5a1..205d325 100644 --- a/engine/web/test/fixtures.js +++ b/engine/web/test/fixtures.js @@ -207,6 +207,37 @@ export function signingRepo(repo, images, overrides = {}) { }; } +// ----- JEF-490: fixtures for the "Access" view (forensic/raw MCP disclosure audit). + +/** One tier-reveal row (Section 1: what a tier reveals/withholds + whether the caller holds it). */ +export function accessReveal(tier, overrides = {}) { + return { + tier, + reveals: `what ${tier} reveals`, + withholds: `what ${tier} withholds`, + held: false, + ...overrides, + }; +} + +/** One forensic/raw pull row (Section 2), already redacted to the caller's tier by the server. */ +export function accessPull(overrides = {}) { + return { + when: "30s ago", + who: "alice@corp.example", + tool: "explain_verdict", + tier: "raw", + target: "workload/app/Pod/web", + raw: true, + ...overrides, + }; +} + +/** A whole Access view. `tier` is the caller's own tier; `durable` selects the empty-state caveat. */ +export function accessView({ tier = "redacted", reveals = [], pulls = [], durable = true } = {}) { + return { strip: strip(), tier, reveals, pulls, durable }; +} + /** A whole Admission view. */ export function admissionView(overrides = {}) { return {