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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions charts/protector/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions charts/protector/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,11 @@ engine:
# File the engine appends to; it rotates to <path>.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 <path>.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.
Expand Down
18 changes: 18 additions & 0 deletions engine/examples/dashboard_preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
}
}

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

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

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

Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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 `<head>` + the Preact `#dash-root` mount.
/// ALL body HTML — the status strip, the tab nav, and the view body — is client-rendered from the
Expand All @@ -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}\"}}"))
}
Expand Down
12 changes: 11 additions & 1 deletion engine/src/engine/dashboard/api_json_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
}
}

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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(),
Expand All @@ -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 {
Expand Down
104 changes: 104 additions & 0 deletions engine/src/engine/dashboard/auth/enforce_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
}
}

Expand Down Expand Up @@ -63,6 +64,30 @@ async fn send(auth: Option<Arc<Enforcer>>, request: Request<Body>) -> 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<Body> {
let mut builder = Request::builder().uri(uri);
Expand Down Expand Up @@ -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(
Expand Down
41 changes: 39 additions & 2 deletions engine/src/engine/dashboard/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<AccessAuditSink>,
}

impl DashboardState {
Expand Down Expand Up @@ -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`
Expand All @@ -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,
}
}
Expand Down Expand Up @@ -279,6 +299,22 @@ async fn admission_json(State(state): State<DashboardState>) -> 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<DashboardState>,
identity: Option<axum::Extension<Identity>>,
) -> 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
Expand Down Expand Up @@ -335,6 +371,7 @@ pub fn router(state: DashboardState, auth: Option<Arc<Enforcer>>) -> 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).
Expand Down
3 changes: 2 additions & 1 deletion engine/src/engine/dashboard/page_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading