diff --git a/crates/registry-notary-core/src/model.rs b/crates/registry-notary-core/src/model.rs index 2ea27daab..2805e2071 100644 --- a/crates/registry-notary-core/src/model.rs +++ b/crates/registry-notary-core/src/model.rs @@ -1378,6 +1378,7 @@ pub struct EvidenceFormat { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ClaimResultView { pub evaluation_id: String, pub claim_id: String, @@ -1398,6 +1399,7 @@ pub struct ClaimResultView { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct TargetRefView { #[serde(rename = "type", default, skip_serializing_if = "String::is_empty")] pub entity_type: String, @@ -1409,6 +1411,7 @@ pub struct TargetRefView { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct EvidenceEntityRef { #[serde(rename = "type")] pub entity_type: String, @@ -1435,6 +1438,7 @@ pub const PROVENANCE_GENERATED_BY_CLAIM_EVALUATION: &str = "claim_evaluation"; /// Requester-side identity (client, actor, subject) is deliberately absent; /// those live in restricted audit, never on the public wire. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ClaimProvenance { pub schema_version: String, pub generated_by: ProvenanceGeneratedBy, @@ -1481,6 +1485,7 @@ impl ClaimProvenance { /// `policy_id` here names the *evaluation* policy under which the result was /// produced. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ProvenanceGeneratedBy { #[serde(rename = "type")] pub entry_type: String, @@ -1512,6 +1517,7 @@ pub struct ProvenanceGeneratedBy { /// The consumed side of a claim provenance record: how many Relay consultations /// contributed to the claim. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ProvenanceUsed { pub relay_consultation_count: usize, } diff --git a/crates/registry-notary-server/src/openapi.rs b/crates/registry-notary-server/src/openapi.rs index 994d59313..229730e61 100644 --- a/crates/registry-notary-server/src/openapi.rs +++ b/crates/registry-notary-server/src/openapi.rs @@ -2155,6 +2155,11 @@ fn claim_result_view_schema() -> Value { }, "satisfied": { "type": ["boolean", "null"] }, "disclosure": { "type": "string" }, + "redacted_fields": { + "type": "array", + "items": { "type": "string" }, + "description": "Claim or top-level field names redacted from the public result. Omitted when no redaction was applied." + }, "format": { "type": "string" }, "issued_at": { "type": "string", "format": "date-time" }, "expires_at": { "type": ["string", "null"], "format": "date-time" }, @@ -4049,6 +4054,15 @@ mod tests { doc["components"]["schemas"]["BatchClaimResultView"]["properties"]["value"]["type"], json!(["object", "array", "string", "number", "integer", "boolean", "null"]) ); + assert_eq!( + doc["components"]["schemas"]["ClaimResultView"]["properties"]["redacted_fields"] + ["items"]["type"], + json!("string") + ); + assert!(!doc["components"]["schemas"]["ClaimResultView"]["required"] + .as_array() + .expect("required claim-result fields are an array") + .contains(&json!("redacted_fields"))); let evaluate_request = &doc["components"]["schemas"]["EvaluateRequest"]["properties"]; assert!(evaluate_request.get("subject").is_none()); diff --git a/crates/registryctl/src/project_authoring.rs b/crates/registryctl/src/project_authoring.rs index d603b20fc..641f13379 100644 --- a/crates/registryctl/src/project_authoring.rs +++ b/crates/registryctl/src/project_authoring.rs @@ -21,6 +21,7 @@ use registry_relay::source_plan::{ use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use sha2::{Digest, Sha256}; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; static PROJECT_STARTERS: include_dir::Dir<'_> = include_dir::include_dir!("$CARGO_MANIFEST_DIR/assets/project-starters"); diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index 149557e5b..096c294a2 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -563,22 +563,28 @@ fn offline_private_path( .to_owned()) } +// A remote candidate can have a small NTP offset from the operator host. Thirty +// seconds accommodates that offset without accepting evidence outside this +// single governed request. +const GOVERNED_LIVE_REMOTE_CLOCK_SKEW: time::Duration = time::Duration::seconds(30); + +#[derive(Clone, Copy)] +struct GovernedLiveValidationWindow { + request_started_at: OffsetDateTime, + response_received_at: OffsetDateTime, +} + fn execute_governed_live_test(loaded: &LoadedRegistryProject) -> Result { let environment = loaded .environment_name .as_deref() .ok_or_else(|| anyhow!("live project tests require an environment"))?; - if matches!(environment, "prod" | "production") - || environment.starts_with("prod-") - || environment.ends_with("-prod") - || loaded.environment.as_ref().is_some_and(|environment| { - matches!( - environment.deployment.profile, - DeploymentProfile::Production | DeploymentProfile::EvidenceGrade - ) - }) - { - bail!("live project tests refuse production environments"); + let deployment_profile = loaded + .environment + .as_ref() + .map(|environment| environment.deployment.profile); + if governed_live_environment_is_production(environment, deployment_profile) { + bail!("live project tests refuse production-classified environment names or profiles"); } let origin = std::env::var("REGISTRY_STACK_LIVE_NOTARY_ORIGIN") .context("live Notary origin is absent from the process environment")?; @@ -593,7 +599,7 @@ fn execute_governed_live_test(loaded: &LoadedRegistryProject) -> Result Result Result MAX_LIVE_RESPONSE_BYTES { bail!("governed Notary response exceeded the configured bound"); } let response = parse_json_strict(&response_bytes) .context("governed Notary response was not strict JSON")?; - let returned_claims = validate_live_response(&response, &claims, &expected)?; - Ok(FixtureReport { + let returned_claims = validate_live_response( + &response, + &validated_request, + &expected, + GovernedLiveValidationWindow { + request_started_at, + response_received_at, + }, + )?; + Ok(governed_live_fixture_report(returned_claims)) +} + +fn governed_live_environment_is_production( + environment: &str, + deployment_profile: Option, +) -> bool { + environment + .split(['-', '_', '.']) + .any(|segment| matches!(segment, "prod" | "production")) + || matches!( + deployment_profile, + Some(DeploymentProfile::Production | DeploymentProfile::EvidenceGrade) + ) +} + +fn governed_live_evaluation_request(endpoint: &url::Url, api_key: &str) -> ureq::Request { + ureq::post(endpoint.as_str()) + .set("content-type", "application/json") + .set("accept", registry_notary_core::FORMAT_CLAIM_RESULT_JSON) + .set("x-api-key", api_key) +} + +fn governed_live_fixture_report(returned_claims: Vec) -> FixtureReport { + FixtureReport { integration: "governed-notary-relay".to_string(), fixture: "live-evaluation".to_string(), inputs: Vec::new(), calls: vec!["notary-evaluation".to_string()], outputs: Vec::new(), claims: returned_claims, - outcome: Some("match".to_string()), + // Claim expectations prove disclosed values, not the source-level + // match or no-match outcome that produced them. + outcome: None, expected_error: None, source_access: None, passed: true, failure: None, - }) + } } fn validate_live_relay_readiness(origin: &url::Url) -> Result<()> { @@ -678,8 +718,9 @@ fn validate_live_relay_readiness(origin: &url::Url) -> Result<()> { fn validate_live_response( response: &Value, - requested_claims: &[String], + request: &ValidatedLiveRequest, expected: &Value, + validation_window: GovernedLiveValidationWindow, ) -> Result> { let object = response .as_object() @@ -690,10 +731,11 @@ fn validate_live_response( let results = object["results"] .as_array() .ok_or_else(|| anyhow!("governed Notary response results must be an array"))?; - if results.len() != requested_claims.len() { + if results.len() != request.claims.len() { bail!("governed Notary response did not return every requested claim exactly once"); } - let requested = requested_claims + let requested = request + .claims .iter() .map(String::as_str) .collect::>(); @@ -707,44 +749,306 @@ fn validate_live_response( bail!("live expected-result claims do not exactly match the governed request"); } let mut returned = BTreeSet::new(); + let mut evaluation_id = None; + let mut target_ref = None; + let mut requester_ref = None; for result in results { - let result = result + let result_object = result .as_object() .ok_or_else(|| anyhow!("governed Notary result must be an object"))?; - let claim_id = result - .get("claim_id") - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("governed Notary result lacks a claim_id"))?; + validate_live_result_raw_schema(result, result_object)?; + let result_view: registry_notary_core::ClaimResultView = + serde_json::from_value(result.clone()).map_err(|_| { + anyhow!( + "governed Notary result does not match the closed public claim-result schema" + ) + })?; + if result_view.provenance.schema_version + != registry_notary_core::CLAIM_PROVENANCE_SCHEMA_VERSION + || result_view.provenance.generated_by.entry_type + != registry_notary_core::PROVENANCE_GENERATED_BY_CLAIM_EVALUATION + { + bail!( + "governed Notary result provenance constants do not match the closed public claim-result schema" + ); + } + let generated_by = &result_view.provenance.generated_by; + if generated_by.evaluation_id != result_view.evaluation_id + || generated_by.claim_id != result_view.claim_id + || generated_by.claim_version != result_view.claim_version + { + bail!( + "governed Notary result provenance does not identify the returned claim result" + ); + } + if generated_by.service_id != request.notary_service_id { + bail!("governed Notary result provenance does not identify the selected Notary service"); + } + if generated_by.policy_id.is_some() + || generated_by.policy_version.is_some() + || generated_by.policy_hash.is_some() + { + bail!("governed Notary API-key result carries unexpected named policy provenance"); + } + if evaluation_id + .as_ref() + .is_some_and(|evaluation_id| evaluation_id != &result_view.evaluation_id) + { + bail!("governed Notary response combines results from different evaluations"); + } + evaluation_id.get_or_insert_with(|| result_view.evaluation_id.clone()); + if result_view.format != registry_notary_core::FORMAT_CLAIM_RESULT_JSON { + bail!("governed Notary result has an invalid claim-result format"); + } + validate_live_result_timestamps(&result_view, validation_window)?; + if !result_view.provenance.derived_from.is_empty() { + bail!("governed Notary result provenance derived_from must remain empty"); + } + validate_live_result_reference_handles(&result_view)?; + let result_target_ref = result_object + .get("target_ref") + .ok_or_else(|| anyhow!("governed Notary result lacks its target reference"))?; + if target_ref + .as_ref() + .is_some_and(|target_ref| target_ref != result_target_ref) + { + bail!("governed Notary response combines inconsistent evaluation references"); + } + target_ref.get_or_insert_with(|| result_target_ref.clone()); + let result_requester_ref = result_object.get("requester_ref").cloned(); + if requester_ref + .as_ref() + .is_some_and(|requester_ref| requester_ref != &result_requester_ref) + { + bail!("governed Notary response combines inconsistent evaluation references"); + } + requester_ref.get_or_insert(result_requester_ref); + let claim_id = result_view.claim_id.as_str(); if !requested.contains(claim_id) || !returned.insert(claim_id.to_string()) { bail!("governed Notary response contains an unknown or duplicate claim result"); } + if request.claim_versions.get(claim_id).map(String::as_str) + != Some(result_view.claim_version.as_str()) + { + bail!("governed Notary result claim version does not match the authored project"); + } let expected_result = expected[claim_id] .as_object() .ok_or_else(|| anyhow!("live expected claim result must be an object"))?; - if expected_result + let expected_keys = expected_result .keys() - .any(|key| !matches!(key.as_str(), "value" | "satisfied" | "disclosure")) - || expected_result.is_empty() + .map(String::as_str) + .collect::>(); + if expected_keys != BTreeSet::from(["disclosure", "satisfied", "value"]) + && expected_keys + != BTreeSet::from(["disclosure", "redacted_fields", "satisfied", "value"]) { - bail!("live expected claim result has an unsupported field"); + bail!( + "live expected claim result must contain value, satisfied, disclosure, and optional redacted_fields" + ); } - for field in expected_result.keys() { - if result.get(field) != expected_result.get(field) { + validate_live_result_redaction( + &result_view, + expected_result.get("redacted_fields"), + expected_result.get("disclosure"), + )?; + for field in ["value", "satisfied", "disclosure"] { + if result_object.get(field) != expected_result.get(field) { bail!("governed Notary disclosed claim result did not match the expected fixture"); } } - if result - .get("provenance") - .and_then(|value| value.pointer("/used/relay_consultation_count")) - .and_then(Value::as_u64) - .is_none_or(|count| count == 0) - { + if result_view.provenance.used.relay_consultation_count == 0 { bail!("governed Notary result lacks source-backed provenance"); } } Ok(returned.into_iter().collect()) } +fn validate_live_result_timestamps( + result: ®istry_notary_core::ClaimResultView, + validation_window: GovernedLiveValidationWindow, +) -> Result<()> { + let issued_at = OffsetDateTime::parse(&result.issued_at, &Rfc3339) + .map_err(|_| anyhow!("governed Notary result timestamps do not match the public date-time schema"))?; + let expires_at = result + .expires_at + .as_deref() + .map(|expires_at| { + OffsetDateTime::parse(expires_at, &Rfc3339).map_err(|_| { + anyhow!( + "governed Notary result timestamps do not match the public date-time schema" + ) + }) + }) + .transpose()?; + if validation_window.response_received_at < validation_window.request_started_at { + bail!("governed Notary validation window is invalid"); + } + let earliest_issued_at = + validation_window.request_started_at - GOVERNED_LIVE_REMOTE_CLOCK_SKEW; + let latest_issued_at = + validation_window.response_received_at + GOVERNED_LIVE_REMOTE_CLOCK_SKEW; + if issued_at < earliest_issued_at || issued_at > latest_issued_at { + bail!("governed Notary result timestamps do not bind to the current live evaluation"); + } + if expires_at.is_some_and(|expires_at| { + expires_at <= validation_window.response_received_at || expires_at <= issued_at + }) { + bail!("governed Notary result timestamps do not bind to the current live evaluation"); + } + Ok(()) +} + +// These properties are optional in the public OpenAPI schema, but their types +// exclude null when present. `expires_at` is intentionally not listed because +// the public schema requires that key and permits an explicit null. +const LIVE_RESULT_OPTIONAL_NON_NULL_PATHS: &[&str] = &[ + "/redacted_fields", + "/requester_ref", + "/requester_ref/identifier_schemes", + "/requester_ref/profile", + "/target_ref/type", + "/target_ref/identifier_schemes", + "/target_ref/profile", + "/provenance/generated_by/policy_id", + "/provenance/generated_by/policy_version", + "/provenance/generated_by/policy_hash", +]; + +const LIVE_RESULT_SCHEMA_EXCLUDED_PATHS: &[&str] = &[ + "/provenance/generated_by/pack_id", + "/provenance/generated_by/pack_version", +]; + +fn validate_live_result_raw_schema( + result: &Value, + result_object: &Map, +) -> Result<()> { + if !result_object.contains_key("expires_at") { + bail!( + "governed Notary result does not match the closed public claim-result schema: expires_at is required" + ); + } + if LIVE_RESULT_OPTIONAL_NON_NULL_PATHS + .iter() + .any(|pointer| result.pointer(pointer).is_some_and(Value::is_null)) + { + bail!("governed Notary result optional public field cannot be null"); + } + if LIVE_RESULT_SCHEMA_EXCLUDED_PATHS + .iter() + .any(|pointer| result.pointer(pointer).is_some()) + { + bail!("governed Notary result exceeds the closed public claim-result schema"); + } + Ok(()) +} + +fn validate_live_result_reference_handles( + result: ®istry_notary_core::ClaimResultView, +) -> Result<()> { + if !is_notary_pseudonymous_handle(&result.target_ref.handle) + || result + .requester_ref + .as_ref() + .is_some_and(|requester| !is_notary_pseudonymous_handle(&requester.handle)) + { + bail!("governed Notary result contains an invalid pseudonymous reference handle"); + } + Ok(()) +} + +fn is_notary_pseudonymous_handle(value: &str) -> bool { + let digest = value + .strip_prefix("rnref:v1:hmac-sha256:") + .or_else(|| value.strip_prefix("rnref:v1:sha256:")); + digest.is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + }) +} + +fn validate_live_result_redaction( + result: ®istry_notary_core::ClaimResultView, + expected_redacted_fields: Option<&Value>, + expected_disclosure: Option<&Value>, +) -> Result<()> { + let disclosure = registry_notary_core::DisclosureProfile::parse(&result.disclosure) + .ok_or_else(|| anyhow!("governed Notary result has an invalid disclosure profile"))?; + match disclosure { + registry_notary_core::DisclosureProfile::Redacted => { + let expected_markers = match expected_redacted_fields { + Some(fields) => validate_live_expected_redaction_fields(fields)?, + None => vec![result.claim_id.clone()], + }; + if result.value.is_some() + || result.satisfied.is_some() + || result.redacted_fields != expected_markers + || expected_disclosure.and_then(Value::as_str) != Some("redacted") + { + bail!("governed Notary result violates full-redaction semantics"); + } + } + registry_notary_core::DisclosureProfile::Predicate => { + if !result.redacted_fields.is_empty() || expected_redacted_fields.is_some() { + bail!("governed Notary result exposes a predicate over redacted fields"); + } + } + registry_notary_core::DisclosureProfile::Value => { + if expected_redacted_fields.is_some() { + bail!("live expected redacted_fields apply only to a fully redacted claim result"); + } + if result.redacted_fields.is_empty() { + return Ok(()); + } + let Some(value) = result.value.as_ref().and_then(Value::as_object) else { + bail!("governed Notary result has invalid field-redaction semantics"); + }; + let unique = result + .redacted_fields + .iter() + .map(String::as_str) + .collect::>(); + if result.satisfied.is_some() + || result.redacted_fields.len() > MAX_OUTPUTS + || unique.len() != result.redacted_fields.len() + || unique.iter().any(|field| { + !is_live_top_level_redaction_field(field) || value.contains_key(*field) + }) + { + bail!("governed Notary result has invalid field-redaction semantics"); + } + } + } + Ok(()) +} + +fn validate_live_expected_redaction_fields(fields: &Value) -> Result> { + let fields = fields + .as_array() + .filter(|fields| !fields.is_empty() && fields.len() <= MAX_OUTPUTS) + .ok_or_else(|| anyhow!("live expected redacted_fields have an invalid bounded shape"))?; + let mut unique = BTreeSet::new(); + for field in fields { + let field = field + .as_str() + .filter(|field| is_live_top_level_redaction_field(field)) + .ok_or_else(|| anyhow!("live expected redacted_fields have an invalid bounded shape"))?; + if !unique.insert(field.to_string()) { + bail!("live expected redacted_fields have an invalid bounded shape"); + } + } + Ok(unique.into_iter().collect()) +} + +fn is_live_top_level_redaction_field(field: &str) -> bool { + field != "value" + && !field.contains('.') + && validate_stable_id(field, "live expected redacted field").is_ok() +} + fn validate_live_notary_origin(value: &str) -> Result { if value.len() > 2048 || value.trim() != value { bail!("live Notary origin has an invalid bounded shape"); @@ -851,13 +1155,29 @@ mod external_request_reader_tests { } } -fn validate_live_request(loaded: &LoadedRegistryProject, request: &Value) -> Result> { +#[derive(Debug, PartialEq, Eq)] +struct ValidatedLiveRequest { + claims: Vec, + claim_versions: BTreeMap, + notary_service_id: String, +} + +fn validate_live_request( + loaded: &LoadedRegistryProject, + request: &Value, +) -> Result { let object = request .as_object() .ok_or_else(|| anyhow!("live request must be a JSON object"))?; if contains_sensitive_request_key(request) { bail!("live request contains a forbidden credential-like field"); } + let notary_service_id = loaded + .environment + .as_ref() + .and_then(|environment| environment.deployment.notary.as_ref()) + .map(|notary| notary.service.clone()) + .ok_or_else(|| anyhow!("live request environment does not declare a Notary service"))?; let purpose = object .get("purpose") .and_then(Value::as_str) @@ -879,26 +1199,64 @@ fn validate_live_request(loaded: &LoadedRegistryProject, request: &Value) -> Res bail!("live request claim count is outside the project bound"); } let mut ids = Vec::with_capacity(claims.len()); - let mut unique = BTreeSet::new(); + let mut claim_versions = BTreeMap::new(); + let mut selected_claims = Vec::with_capacity(claims.len()); for claim in claims { - let id = match claim { - Value::String(id) => id.as_str(), - Value::Object(object) => object - .get("id") - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("live request claim reference is invalid"))?, + let (id, requested_version) = match claim { + Value::String(id) => (id.as_str(), None), + Value::Object(object) => ( + object + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("live request claim reference is invalid"))?, + object.get("version"), + ), _ => bail!("live request claim reference is invalid"), }; - if !services + let service = services .iter() - .any(|service| service.claims.contains_key(id)) - || !unique.insert(id) + .find(|service| service.claims.contains_key(id)) + .ok_or_else(|| anyhow!("live request contains an unknown project claim"))?; + let authored_version = service.version.to_string(); + if requested_version.is_some_and(|version| { + version.as_str() != Some(authored_version.as_str()) + }) { + bail!("live request claim version does not match the authored project"); + } + if claim_versions + .insert(id.to_string(), authored_version) + .is_some() { bail!("live request contains an unknown or duplicate project claim"); } ids.push(id.to_string()); + selected_claims.push( + service + .claims + .get(id) + .expect("selected project claim remains present"), + ); + } + let disclosure = match object.get("disclosure") { + Some(Value::String(disclosure)) => disclosure.as_str(), + Some(_) => bail!("live request disclosure profile is invalid"), + None => expanded_disclosure(&selected_claims[0].disclosure).0, + }; + if registry_notary_core::DisclosureProfile::parse(disclosure).is_none() { + bail!("live request disclosure profile is invalid"); + } + if selected_claims.iter().any(|claim| { + !expanded_disclosure(&claim.disclosure) + .1 + .contains(&disclosure) + }) { + bail!("live request disclosure is not allowed for every selected project claim"); } - Ok(ids) + Ok(ValidatedLiveRequest { + claims: ids, + claim_versions, + notary_service_id, + }) } fn contains_sensitive_request_key(value: &Value) -> bool { diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index 750ec2394..8082307ee 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -656,33 +656,1223 @@ outputs: } } + fn governed_live_claim_result( + claim_id: &str, + value: Value, + satisfied: Option, + disclosure: &str, + ) -> Value { + serde_json::to_value(registry_notary_core::ClaimResultView { + evaluation_id: "eval-live-1".to_string(), + claim_id: claim_id.to_string(), + claim_version: "1.0.0".to_string(), + subject_type: "Person".to_string(), + requester_ref: Some(registry_notary_core::EvidenceEntityRef { + entity_type: "Organisation".to_string(), + handle: format!("rnref:v1:hmac-sha256:{}", "1".repeat(64)), + identifier_schemes: Vec::new(), + profile: None, + }), + target_ref: registry_notary_core::TargetRefView { + entity_type: "Person".to_string(), + handle: format!("rnref:v1:hmac-sha256:{}", "2".repeat(64)), + identifier_schemes: vec!["openspp_individual_id".to_string()], + profile: Some("openspp".to_string()), + }, + value: Some(value), + satisfied, + disclosure: disclosure.to_string(), + redacted_fields: Vec::new(), + format: registry_notary_core::FORMAT_CLAIM_RESULT_JSON.to_string(), + issued_at: "2026-07-23T00:00:00Z".to_string(), + expires_at: None, + provenance: registry_notary_core::ClaimProvenance::new( + "registry-notary".to_string(), + "eval-live-1".to_string(), + claim_id.to_string(), + "1.0.0".to_string(), + registry_notary_core::ProvenanceUsed { + relay_consultation_count: 1, + }, + ), + }) + .expect("actual claim result serializes") + } + + fn governed_live_validated_request(claims: &[&str]) -> ValidatedLiveRequest { + ValidatedLiveRequest { + claims: claims.iter().map(|claim| (*claim).to_string()).collect(), + claim_versions: claims + .iter() + .map(|claim| ((*claim).to_string(), "1.0.0".to_string())) + .collect(), + notary_service_id: "registry-notary".to_string(), + } + } + + fn governed_live_eligible_fixture() -> (ValidatedLiveRequest, Value, Value) { + ( + governed_live_validated_request(&["eligible"]), + json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }), + json!({ + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], + }), + ) + } + + fn governed_live_validation_window() -> GovernedLiveValidationWindow { + GovernedLiveValidationWindow { + request_started_at: time::macros::datetime!(2026-07-23 0:00 UTC), + response_received_at: time::macros::datetime!(2026-07-23 0:00:01 UTC), + } + } + + fn validate_live_response( + response: &Value, + request: &ValidatedLiveRequest, + expected: &Value, + ) -> Result> { + super::validate_live_response( + response, + request, + expected, + governed_live_validation_window(), + ) + } + + fn set_governed_live_result_pointer(response: &mut Value, pointer: &str, value: Value) { + let result = response + .pointer_mut("/results/0") + .expect("actual claim result exists"); + let (parent_pointer, field) = pointer + .rsplit_once('/') + .expect("result field pointer has a parent"); + let parent = if parent_pointer.is_empty() { + result.as_object_mut() + } else { + result + .pointer_mut(parent_pointer) + .and_then(Value::as_object_mut) + } + .expect("actual result field parent exists"); + parent.insert(field.to_string(), value); + } + #[test] - fn governed_live_result_requires_exact_disclosure_and_source_provenance() { - let claims = vec!["eligible".to_string()]; - let expected = json!({ "claims": { "eligible": { "satisfied": true } } }); + fn governed_live_production_guard_covers_name_and_profile_symmetry() { + for environment in [ + "prod", + "production", + "prod-us", + "production-us", + "us-prod", + "us-production", + "prod_us", + "production.eu", + "eu_prod", + "eu.production", + "owner-prod-copy", + ] { + assert!( + governed_live_environment_is_production( + environment, + Some(DeploymentProfile::Local) + ), + "production-shaped environment name {environment} was accepted" + ); + } + for environment in [ + "owner-pilot", + "preproduction", + "productionish", + "product-copy-us", + "owner-productionish-copy", + "eu_product", + ] { + assert!( + !governed_live_environment_is_production( + environment, + Some(DeploymentProfile::Local) + ), + "non-production-shaped environment name {environment} was rejected" + ); + } + for (profile, rejected) in [ + (DeploymentProfile::Local, false), + (DeploymentProfile::HostedLab, false), + (DeploymentProfile::Production, true), + (DeploymentProfile::EvidenceGrade, true), + ] { + assert_eq!( + governed_live_environment_is_production("owner-pilot", Some(profile)), + rejected, + "unexpected live classification for deployment profile {}", + profile.as_str() + ); + } + } + + #[test] + fn governed_live_result_accepts_actual_shape_and_requires_source_provenance() { + let request = governed_live_validated_request(&["eligible"]); + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); let response = json!({ - "results": [{ - "claim_id": "eligible", - "satisfied": true, - "provenance": { "used": { "relay_consultation_count": 1 } }, - }], + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], }); assert_eq!( - validate_live_response(&response, &claims, &expected).expect("exact result passes"), - claims + validate_live_response(&response, &request, &expected).expect("exact result passes"), + request.claims ); let mut missing_provenance = response; missing_provenance["results"][0]["provenance"]["used"]["relay_consultation_count"] = json!(0); assert!( - validate_live_response(&missing_provenance, &claims, &expected) + validate_live_response(&missing_provenance, &request, &expected) .expect_err("source-free result must fail") .to_string() .contains("source-backed provenance") ); } + #[test] + fn governed_live_result_requires_explicit_expires_at() { + let request = governed_live_validated_request(&["eligible"]); + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], + }); + + assert_eq!( + response.pointer("/results/0/expires_at"), + Some(&Value::Null) + ); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("explicit null expires_at passes"), + request.claims + ); + + let mut missing_expires_at = response; + assert_eq!( + missing_expires_at["results"][0] + .as_object_mut() + .expect("actual result is an object") + .remove("expires_at"), + Some(Value::Null) + ); + assert!( + validate_live_response(&missing_expires_at, &request, &expected) + .expect_err("missing expires_at must fail closed") + .to_string() + .contains("expires_at is required") + ); + } + + #[test] + fn governed_live_result_requires_canonical_provenance_constants() { + let request = governed_live_validated_request(&["eligible"]); + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], + }); + + assert_eq!( + response.pointer("/results/0/provenance/schema_version"), + Some(&json!( + registry_notary_core::CLAIM_PROVENANCE_SCHEMA_VERSION + )) + ); + assert_eq!( + response.pointer("/results/0/provenance/generated_by/type"), + Some(&json!( + registry_notary_core::PROVENANCE_GENERATED_BY_CLAIM_EVALUATION + )) + ); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("canonical provenance constants pass"), + request.claims + ); + + for (pointer, value) in [ + ( + "/results/0/provenance/schema_version", + json!("registry-notary-claim-provenance/v1"), + ), + ( + "/results/0/provenance/generated_by/type", + json!("stale_evaluation"), + ), + ] { + let mut invalid = response.clone(); + *invalid + .pointer_mut(pointer) + .expect("actual provenance constant exists") = value; + + assert!( + validate_live_response(&invalid, &request, &expected) + .expect_err("non-canonical provenance constant must fail closed") + .to_string() + .contains("provenance constants"), + "provenance constant {pointer} was accepted" + ); + } + } + + #[test] + fn governed_live_result_binds_provenance_to_returned_result() { + let (request, expected, response) = governed_live_eligible_fixture(); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("canonical provenance binding passes"), + request.claims + ); + + for (pointer, value) in [ + ( + "/provenance/generated_by/evaluation_id", + json!("eval-other"), + ), + ("/provenance/generated_by/claim_id", json!("other-claim")), + ( + "/provenance/generated_by/claim_version", + json!("0.9.0"), + ), + ] { + let mut invalid = response.clone(); + set_governed_live_result_pointer(&mut invalid, pointer, value); + + assert!( + validate_live_response(&invalid, &request, &expected) + .expect_err("misbound provenance must fail closed") + .to_string() + .contains("does not identify the returned claim result"), + "provenance binding {pointer} was accepted" + ); + } + } + + #[test] + fn governed_live_result_requires_public_date_time_timestamps() { + let (request, expected, mut response) = governed_live_eligible_fixture(); + set_governed_live_result_pointer( + &mut response, + "/expires_at", + json!("2026-07-24T07:30:00+07:00"), + ); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("canonical RFC3339 timestamps pass"), + request.claims + ); + + for pointer in ["/issued_at", "/expires_at"] { + let mut invalid = response.clone(); + set_governed_live_result_pointer(&mut invalid, pointer, json!("not-rfc3339")); + + assert!( + validate_live_response(&invalid, &request, &expected) + .expect_err("invalid public timestamp must fail closed") + .to_string() + .contains("public date-time schema"), + "invalid timestamp {pointer} was accepted" + ); + } + } + + #[test] + fn governed_live_result_timestamps_bind_to_the_current_request_window() { + let (request, expected, response) = governed_live_eligible_fixture(); + let validation_window = GovernedLiveValidationWindow { + request_started_at: time::macros::datetime!(2026-07-23 0:00 UTC), + response_received_at: time::macros::datetime!(2026-07-23 0:00:10 UTC), + }; + + for issued_at in ["2026-07-22T23:59:30Z", "2026-07-23T00:00:40Z"] { + let mut boundary = response.clone(); + set_governed_live_result_pointer(&mut boundary, "/issued_at", json!(issued_at)); + assert_eq!( + super::validate_live_response( + &boundary, + &request, + &expected, + validation_window, + ) + .expect("inclusive remote clock-skew boundary passes"), + request.claims + ); + } + + let mut unexpired = response.clone(); + set_governed_live_result_pointer( + &mut unexpired, + "/expires_at", + json!("2026-07-23T00:00:11Z"), + ); + assert_eq!( + super::validate_live_response(&unexpired, &request, &expected, validation_window) + .expect("expiry strictly after response receipt passes"), + request.claims + ); + + for (issued_at, expires_at) in [ + ("2026-07-22T23:59:29Z", None), + ("2026-07-23T00:00:41Z", None), + ( + "2026-07-23T00:00:00Z", + Some("2026-07-23T00:00:10Z"), + ), + ( + "2026-07-23T00:00:40Z", + Some("2026-07-23T00:00:20Z"), + ), + ( + "2026-07-23T00:00:20Z", + Some("2026-07-23T00:00:20Z"), + ), + ] { + let mut invalid = response.clone(); + set_governed_live_result_pointer(&mut invalid, "/issued_at", json!(issued_at)); + if let Some(expires_at) = expires_at { + set_governed_live_result_pointer( + &mut invalid, + "/expires_at", + json!(expires_at), + ); + } + let error = + super::validate_live_response(&invalid, &request, &expected, validation_window) + .expect_err("stale, future, or expired result must fail closed") + .to_string(); + assert!(error.contains("current live evaluation")); + assert!(!error.contains(issued_at)); + assert!(expires_at.is_none_or(|expires_at| !error.contains(expires_at))); + } + } + + #[test] + fn governed_live_result_accepts_runtime_redaction_evidence() { + let request = governed_live_validated_request(&["household-reference"]); + let expected = json!({ + "claims": { + "household-reference": { + "value": null, + "satisfied": null, + "disclosure": "redacted", + }, + }, + }); + let mut response = json!({ + "results": [governed_live_claim_result( + "household-reference", + Value::Null, + None, + "redacted", + )], + }); + assert!( + response + .pointer("/results/0/redacted_fields") + .is_none(), + "empty redaction metadata is omitted" + ); + set_governed_live_result_pointer( + &mut response, + "/redacted_fields", + json!(["household-reference"]), + ); + + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("runtime full-redaction evidence passes"), + request.claims + ); + + let mut missing_evidence = response.clone(); + missing_evidence["results"][0] + .as_object_mut() + .expect("actual result is an object") + .remove("redacted_fields"); + assert!( + validate_live_response(&missing_evidence, &request, &expected) + .expect_err("full redaction without redaction evidence must fail closed") + .to_string() + .contains("full-redaction semantics") + ); + + let mut configured_expected = expected.clone(); + configured_expected["claims"]["household-reference"]["redacted_fields"] = + json!(["status", "private_field"]); + let mut configured_response = response.clone(); + set_governed_live_result_pointer( + &mut configured_response, + "/redacted_fields", + json!(["private_field", "status"]), + ); + assert_eq!( + validate_live_response(&configured_response, &request, &configured_expected) + .expect("owner-approved configured redaction fields pass"), + request.claims + ); + + for invalid_markers in [ + json!([]), + json!(["private_field", "private_field"]), + json!(["private_field", "other_claim"]), + json!(["IND-AB12CD34"]), + ] { + let mut invalid = configured_response.clone(); + set_governed_live_result_pointer( + &mut invalid, + "/redacted_fields", + invalid_markers.clone(), + ); + let error = validate_live_response(&invalid, &request, &configured_expected) + .expect_err("runtime redaction fields must match the owner-approved set") + .to_string(); + assert!(error.contains("full-redaction semantics")); + assert!(!error.contains("IND-AB12CD34")); + } + + for invalid_markers in [ + json!([]), + json!([""]), + json!(["household-reference", "household-reference"]), + json!(["other-claim"]), + json!(["IND-AB12CD34"]), + ] { + let mut invalid = response.clone(); + set_governed_live_result_pointer( + &mut invalid, + "/redacted_fields", + invalid_markers.clone(), + ); + let error = validate_live_response(&invalid, &request, &expected) + .expect_err("invalid full-redaction marker set must fail closed") + .to_string(); + assert!(error.contains("full-redaction semantics")); + assert!(!error.contains("IND-AB12CD34")); + } + + for invalid_expected_markers in [ + json!([]), + json!(["private_field", "private_field"]), + json!(["profile.value"]), + json!(["IND-AB12CD34"]), + json!(["ssn=123"]), + ] { + let mut invalid_expected = expected.clone(); + invalid_expected["claims"]["household-reference"]["redacted_fields"] = + invalid_expected_markers.clone(); + let error = validate_live_response(&configured_response, &request, &invalid_expected) + .expect_err("invalid owner redaction fields must fail closed") + .to_string(); + assert!(error.contains("invalid bounded shape")); + assert!(!error.contains("IND-AB12CD34")); + assert!(!error.contains("ssn=123")); + } + } + + #[test] + fn governed_live_result_enforces_field_redaction_semantics() { + let request = governed_live_validated_request(&["profile"]); + let expected = json!({ + "claims": { + "profile": { + "value": { "status": "eligible" }, + "satisfied": null, + "disclosure": "value", + }, + }, + }); + let mut response = json!({ + "results": [governed_live_claim_result( + "profile", + json!({ "status": "eligible" }), + None, + "value", + )], + }); + set_governed_live_result_pointer( + &mut response, + "/redacted_fields", + json!(["private_field"]), + ); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("field-redacted object evidence passes"), + request.claims + ); + + let mut leaked = response.clone(); + leaked["results"][0]["value"]["private_field"] = json!("private source value"); + let error = validate_live_response(&leaked, &request, &expected) + .expect_err("listed redacted field must be absent from the returned value") + .to_string(); + assert!(error.contains("field-redaction semantics")); + assert!(!error.contains("private source value")); + + for (invalid_markers, sensitive_marker) in [ + (json!(["ssn=123"]), "ssn=123"), + (json!(["IND-AB12CD34"]), "IND-AB12CD34"), + (json!(["profile.ssn"]), "profile.ssn"), + (json!(["profile/ssn"]), "profile/ssn"), + ( + json!(["private_field", "private_field"]), + "private_field", + ), + ] { + let mut invalid = response.clone(); + set_governed_live_result_pointer( + &mut invalid, + "/redacted_fields", + invalid_markers, + ); + let error = validate_live_response(&invalid, &request, &expected) + .expect_err("invalid runtime field-redaction marker must fail closed") + .to_string(); + assert!(error.contains("field-redaction semantics")); + assert!(!error.contains(sensitive_marker)); + } + + let excessive_markers = (0..=MAX_OUTPUTS) + .map(|index| format!("private_field_{index}")) + .collect::>(); + let mut excessive = response.clone(); + set_governed_live_result_pointer( + &mut excessive, + "/redacted_fields", + json!(excessive_markers), + ); + let error = validate_live_response(&excessive, &request, &expected) + .expect_err("runtime field-redaction markers must stay bounded") + .to_string(); + assert!(error.contains("field-redaction semantics")); + assert!(!error.contains("private_field_64")); + + let (predicate_request, predicate_expected, mut predicate_response) = + governed_live_eligible_fixture(); + set_governed_live_result_pointer( + &mut predicate_response, + "/redacted_fields", + json!(["private_field"]), + ); + assert!( + validate_live_response( + &predicate_response, + &predicate_request, + &predicate_expected, + ) + .expect_err("predicate over redacted fields must fail closed") + .to_string() + .contains("predicate over redacted fields") + ); + } + + #[test] + fn governed_live_response_requires_one_evaluation_id() { + let request = governed_live_validated_request(&["eligible", "active"]); + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + "active": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [ + governed_live_claim_result("eligible", json!(true), Some(true), "predicate"), + governed_live_claim_result("active", json!(true), Some(true), "predicate"), + ], + }); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("one evaluation id across all results passes"), + request.claim_versions.keys().cloned().collect::>() + ); + + for (pointer, value, sensitive_value) in [ + ( + "/results/1/target_ref/handle", + json!(format!("rnref:v1:hmac-sha256:{}", "3".repeat(64))), + "33333333", + ), + ( + "/results/1/requester_ref/handle", + json!(format!("rnref:v1:hmac-sha256:{}", "4".repeat(64))), + "44444444", + ), + ( + "/results/1/target_ref/profile", + json!("IND-AB12CD34"), + "IND-AB12CD34", + ), + ( + "/results/1/target_ref/identifier_schemes", + json!(["sensitive_scheme"]), + "sensitive_scheme", + ), + ] { + let mut inconsistent = response.clone(); + *inconsistent + .pointer_mut(pointer) + .expect("second result reference field exists") = value; + let error = validate_live_response(&inconsistent, &request, &expected) + .expect_err("inconsistent multi-claim reference must fail closed") + .to_string(); + assert!(error.contains("inconsistent evaluation references")); + assert!(!error.contains(sensitive_value)); + } + + let mut missing_requester = response.clone(); + missing_requester["results"][1] + .as_object_mut() + .expect("second result is an object") + .remove("requester_ref"); + assert!( + validate_live_response(&missing_requester, &request, &expected) + .expect_err("mixed requester presence must fail closed") + .to_string() + .contains("inconsistent evaluation references") + ); + + let mut mixed = response; + mixed["results"][1]["evaluation_id"] = json!("eval-live-2"); + mixed["results"][1]["provenance"]["generated_by"]["evaluation_id"] = + json!("eval-live-2"); + assert!( + validate_live_response(&mixed, &request, &expected) + .expect_err("mixed evaluation ids must fail closed") + .to_string() + .contains("different evaluations") + ); + } + + #[test] + fn governed_live_result_claim_version_matches_authored_request() { + let loaded = load_registry_project(&project_golden("openspp-exact"), Some("local")) + .expect("OpenSPP golden project loads"); + let request = validate_live_request( + &loaded, + &json!({ + "purpose": "social-programme-verification", + "claims": ["social-registry-record-exists"], + "disclosure": "predicate", + }), + ) + .expect("authored request validates"); + let expected = json!({ + "claims": { + "social-registry-record-exists": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let mut response = json!({ + "results": [governed_live_claim_result( + "social-registry-record-exists", + json!(true), + Some(true), + "predicate", + )], + }); + response["results"][0]["claim_version"] = json!("1"); + response["results"][0]["provenance"]["generated_by"]["claim_version"] = json!("1"); + response["results"][0]["provenance"]["generated_by"]["service_id"] = + json!("social-registry-notary"); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("authored claim version passes"), + request.claims + ); + + let mut wrong_version = response; + wrong_version["results"][0]["claim_version"] = json!("2.0.0"); + wrong_version["results"][0]["provenance"]["generated_by"]["claim_version"] = + json!("2.0.0"); + assert!( + validate_live_response(&wrong_version, &request, &expected) + .expect_err("result from a different claim version must fail closed") + .to_string() + .contains("does not match the authored project") + ); + } + + #[test] + fn governed_live_result_service_matches_selected_environment() { + let loaded = load_registry_project(&project_golden("openspp-exact"), Some("local")) + .expect("OpenSPP golden project and environment load"); + let request = validate_live_request( + &loaded, + &json!({ + "purpose": "social-programme-verification", + "claims": ["social-registry-record-exists"], + "disclosure": "predicate", + }), + ) + .expect("authored request validates"); + assert_eq!(request.notary_service_id, "social-registry-notary"); + let expected = json!({ + "claims": { + "social-registry-record-exists": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let mut response = json!({ + "results": [governed_live_claim_result( + "social-registry-record-exists", + json!(true), + Some(true), + "predicate", + )], + }); + response["results"][0]["claim_version"] = json!("1"); + response["results"][0]["provenance"]["generated_by"]["claim_version"] = json!("1"); + response["results"][0]["provenance"]["generated_by"]["service_id"] = + json!("social-registry-notary"); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("selected Notary service passes"), + request.claims + ); + + response["results"][0]["provenance"]["generated_by"]["service_id"] = + json!("stale-notary"); + let error = validate_live_response(&response, &request, &expected) + .expect_err("wrong Notary service must fail closed") + .to_string(); + assert!(error.contains("does not identify the selected Notary service")); + assert!(!error.contains("stale-notary")); + } + + #[test] + fn governed_live_result_requires_notary_pseudonymous_reference_handles() { + let (request, expected, response) = governed_live_eligible_fixture(); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("keyed Notary pseudonymous references pass"), + request.claims + ); + + let mut local = response.clone(); + for pointer in ["/target_ref/handle", "/requester_ref/handle"] { + set_governed_live_result_pointer( + &mut local, + pointer, + json!(format!("rnref:v1:sha256:{}", "a".repeat(64))), + ); + } + assert_eq!( + validate_live_response(&local, &request, &expected) + .expect("local-development Notary pseudonymous references pass"), + request.claims + ); + + for pointer in ["/target_ref/handle", "/requester_ref/handle"] { + for invalid_handle in [ + "person-123".to_string(), + "rnref:v1:hmac-sha256:abcd".to_string(), + format!("rnref:v1:hmac-sha256:{}", "A".repeat(64)), + format!("rnref:v1:sha512:{}", "a".repeat(64)), + "rnref:v1:IND-AB12CD34".to_string(), + ] { + let mut invalid = response.clone(); + set_governed_live_result_pointer( + &mut invalid, + pointer, + json!(invalid_handle.clone()), + ); + let error = validate_live_response(&invalid, &request, &expected) + .expect_err("invalid Notary reference handle must fail closed") + .to_string(); + assert!(error.contains("invalid pseudonymous reference handle")); + assert!(!error.contains("IND-AB12CD34")); + } + } + } + + #[test] + fn governed_live_result_rejects_null_optional_public_fields_recursively() { + let (request, expected, mut response) = governed_live_eligible_fixture(); + set_governed_live_result_pointer(&mut response, "/redacted_fields", json!([])); + for (pointer, value) in [ + ("/requester_ref/identifier_schemes", json!([])), + ("/requester_ref/profile", json!("requester")), + ] { + set_governed_live_result_pointer(&mut response, pointer, value); + } + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("canonical optional public fields pass"), + request.claims + ); + + for pointer in LIVE_RESULT_OPTIONAL_NON_NULL_PATHS { + let mut invalid = response.clone(); + set_governed_live_result_pointer(&mut invalid, pointer, Value::Null); + + assert!( + validate_live_response(&invalid, &request, &expected) + .expect_err("null optional public field must fail closed") + .to_string() + .contains("optional public field cannot be null"), + "null optional public field {pointer} was accepted" + ); + } + } + + #[test] + fn governed_live_api_key_result_rejects_named_policy_provenance() { + let (request, expected, response) = governed_live_eligible_fixture(); + for field in ["policy_id", "policy_version", "policy_hash"] { + assert!( + response + .pointer(&format!( + "/results/0/provenance/generated_by/{field}" + )) + .is_none(), + "machine-client runtime fixture unexpectedly carries {field}" + ); + } + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("API-key result without named policy provenance passes"), + request.claims + ); + + for (pointer, value, sensitive_value) in [ + ( + "/provenance/generated_by/policy_id", + json!("IND-AB12CD34"), + "IND-AB12CD34", + ), + ( + "/provenance/generated_by/policy_version", + json!("secret-policy-v1"), + "secret-policy-v1", + ), + ( + "/provenance/generated_by/policy_hash", + json!("sha256:sensitive-policy-hash"), + "sha256:sensitive-policy-hash", + ), + ] { + let mut invalid = response.clone(); + set_governed_live_result_pointer(&mut invalid, pointer, value); + let error = validate_live_response(&invalid, &request, &expected) + .expect_err("API-key result with named policy provenance must fail closed") + .to_string(); + assert!(error.contains("unexpected named policy provenance")); + assert!(!error.contains(sensitive_value)); + } + } + + #[test] + fn governed_live_result_requires_claim_result_format() { + let (request, expected, mut response) = governed_live_eligible_fixture(); + assert_eq!( + response.pointer("/results/0/format"), + Some(&json!(registry_notary_core::FORMAT_CLAIM_RESULT_JSON)) + ); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("canonical claim-result format passes"), + request.claims + ); + + set_governed_live_result_pointer(&mut response, "/format", json!("application/json")); + assert!( + validate_live_response(&response, &request, &expected) + .expect_err("wrong result format must fail closed") + .to_string() + .contains("invalid claim-result format") + ); + } + + #[test] + fn governed_live_result_rejects_partial_disclosure_expectations() { + let request = governed_live_validated_request(&["eligible"]); + let expected = json!({ + "claims": { + "eligible": { + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [governed_live_claim_result( + "eligible", + json!("unexpected source value"), + Some(true), + "predicate", + )], + }); + + assert!( + validate_live_response(&response, &request, &expected) + .expect_err("partial expected disclosure must fail closed") + .to_string() + .contains("must contain value, satisfied, disclosure") + ); + } + + #[test] + fn governed_live_result_rejects_unknown_result_fields() { + let request = governed_live_validated_request(&["eligible"]); + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let mut response = json!({ + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], + }); + response["results"][0]["raw_value"] = json!("unexpected source value"); + + assert!( + validate_live_response(&response, &request, &expected) + .expect_err("unknown result fields must fail closed") + .to_string() + .contains("closed public claim-result schema") + ); + } + + #[test] + fn governed_live_result_rejects_nested_unknown_fields() { + let request = governed_live_validated_request(&["eligible"]); + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], + }); + + for (pointer, field) in [ + ("/results/0/target_ref", "raw_identifier"), + ("/results/0/requester_ref", "raw_principal"), + ("/results/0/provenance", "raw_source"), + ("/results/0/provenance/generated_by", "raw_policy"), + ("/results/0/provenance/used", "raw_source_count"), + ] { + let mut over_disclosed = response.clone(); + over_disclosed + .pointer_mut(pointer) + .and_then(Value::as_object_mut) + .expect("actual nested result object exists") + .insert(field.to_string(), json!("private")); + + assert!( + validate_live_response(&over_disclosed, &request, &expected) + .expect_err("nested unknown fields must fail closed") + .to_string() + .contains("closed public claim-result schema"), + "nested field {pointer}/{field} was accepted" + ); + } + } + + #[test] + fn governed_live_result_requires_empty_derived_from() { + let request = governed_live_validated_request(&["eligible"]); + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let mut response = json!({ + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], + }); + response["results"][0]["provenance"]["derived_from"] = + json!([{ "raw_source_row": "private" }]); + + assert!( + validate_live_response(&response, &request, &expected) + .expect_err("non-empty derived_from must fail closed") + .to_string() + .contains("derived_from must remain empty") + ); + } + + #[test] + fn governed_live_result_rejects_provenance_fields_outside_public_schema() { + let request = governed_live_validated_request(&["eligible"]); + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], + }); + + for field in ["pack_id", "pack_version"] { + for value in [json!("unsupported"), Value::Null] { + let mut unsupported = response.clone(); + unsupported["results"][0]["provenance"]["generated_by"][field] = value; + + assert!( + validate_live_response(&unsupported, &request, &expected) + .expect_err("fields outside the public schema must fail closed") + .to_string() + .contains("exceeds the closed public claim-result schema"), + "provenance field {field} was accepted" + ); + } + } + } + + #[test] + fn governed_live_request_negotiates_the_claim_result_media_type() { + let endpoint = + url::Url::parse("http://127.0.0.1:8080/v1/evaluations").expect("endpoint parses"); + let request = governed_live_evaluation_request(&endpoint, "test-api-key"); + + assert_eq!(request.header("content-type"), Some("application/json")); + assert_eq!( + request.header("accept"), + Some(registry_notary_core::FORMAT_CLAIM_RESULT_JSON) + ); + } + + #[test] + fn governed_live_report_does_not_infer_a_source_outcome() { + let request = governed_live_validated_request(&["record-exists"]); + let expected = json!({ + "claims": { + "record-exists": { + "value": false, + "satisfied": false, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [governed_live_claim_result( + "record-exists", + json!(false), + Some(false), + "predicate", + )], + }); + let returned_claims = + validate_live_response(&response, &request, &expected).expect("no-match result passes"); + let report = governed_live_fixture_report(returned_claims); + + assert_eq!(report.claims, request.claims); + assert_eq!(report.outcome, None); + assert!( + serde_json::to_value(report) + .expect("report serializes") + .get("outcome") + .is_none(), + "neutral live evidence must not serialize a source outcome" + ); + } + #[test] fn cel_consultation_roots_ignore_string_literals() { assert_eq!( @@ -730,10 +1920,85 @@ outputs: )); } + #[test] + fn live_request_requires_one_compatible_disclosure_profile() { + let loaded = load_registry_project(&project_golden("openspp-exact"), Some("local")) + .expect("OpenSPP golden project loads"); + + for (disclosure, claims) in [ + ( + "predicate", + vec!["social-registry-record-exists", "social-registry-active"], + ), + ("value", vec!["programme-code"]), + ("redacted", vec!["household-reference"]), + ] { + let request = json!({ + "purpose": "social-programme-verification", + "claims": claims, + "disclosure": disclosure, + }); + let validated = validate_live_request(&loaded, &request) + .expect("compatible disclosure request passes"); + assert!(validated + .claim_versions + .values() + .all(|version| version == "1")); + } + + let mixed = json!({ + "purpose": "social-programme-verification", + "claims": [ + "social-registry-record-exists", + "programme-code", + "household-reference", + ], + }); + assert!( + validate_live_request(&loaded, &mixed) + .expect_err("mixed defaults must fail before source access") + .to_string() + .contains("not allowed for every selected project claim") + ); + } + + #[test] + fn live_request_claim_version_must_match_the_authored_service() { + let loaded = load_registry_project(&project_golden("openspp-exact"), Some("local")) + .expect("OpenSPP golden project loads"); + let request = |version| { + json!({ + "purpose": "social-programme-verification", + "claims": [{ + "id": "social-registry-record-exists", + "version": version, + }], + "disclosure": "predicate", + }) + }; + + let validated = + validate_live_request(&loaded, &request("1")).expect("authored version passes"); + assert_eq!( + validated.claim_versions, + BTreeMap::from([( + "social-registry-record-exists".to_string(), + "1".to_string(), + )]) + ); + assert!( + validate_live_request(&loaded, &request("2")) + .expect_err("non-authored request version must fail closed") + .to_string() + .contains("does not match the authored project") + ); + } + #[test] fn live_request_resolves_claims_across_services_with_the_same_purpose() { let project = project_golden("custom-system"); - let mut loaded = load_registry_project(&project, None).expect("golden project loads"); + let mut loaded = + load_registry_project(&project, Some("local")).expect("golden project loads"); let original_id = "household-eligibility"; let mut second: ServiceDeclaration = serde_json::from_value( serde_json::to_value(&loaded.project.services[original_id]) @@ -778,15 +2043,26 @@ outputs: .expect("split service project compiles"); validate_generated_notary(&compiled).expect("split service Notary config activates"); - let claims = validate_live_request( + let request = validate_live_request( &loaded, &json!({ "purpose": "household-support-screening", "claims": ["household-category", "household-eligible"], + "disclosure": "redacted", }), ) .expect("claims from both same-purpose services are valid"); - assert_eq!(claims, ["household-category", "household-eligible"]); + assert_eq!( + request.claims, + ["household-category", "household-eligible"] + ); + assert_eq!( + request.claim_versions, + BTreeMap::from([ + ("household-category".to_string(), "1".to_string()), + ("household-eligible".to_string(), "1".to_string()), + ]) + ); } #[test] diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md new file mode 100644 index 000000000..578fdc65d --- /dev/null +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -0,0 +1,314 @@ +# OpenSPP exact-lookup owner holdout + +This workspace is offline-fixture-only pending the OpenSPP product-owner proof +in [GH#357](https://github.com/registrystack/registry-stack/issues/357). +It is not a supported public starter, evidence of live OpenSPP compatibility, +or an E2 interoperability claim. + +The committed HTTP path, query, fields, version label, identifiers, and +responses describe a synthetic wire shape. +They do not assert that an OpenSPP release exposes this API. + +## Run the offline workspace + +Run these commands from the repository root: + +```sh +cd crates/registryctl/tests/fixtures/project-authoring/openspp-exact +registryctl test --project-dir . --integration individual --fixture social-registry-match --trace +registryctl test --project-dir . --integration individual --fixture social-registry-match --watch +registryctl test --project-dir . +registryctl check --project-dir . --environment local --explain +registryctl build --project-dir . --environment local +``` + +The watch command runs the selected fixture once, waits for an authored file to +change, and reruns it. +Press `Ctrl+C` after observing the rerun. +The other commands exit after reporting their result. + +`test` uses synthetic fixtures and an implementation-owned offline binding. +It does not contact the origin in `environments/local.yaml`. +`check` and `build` validate that environment, but the `.invalid` origins and +placeholder secret references are deliberately non-deployable. + +## Prepare the owner-run workspace + +Use a private copy of this workspace from one tagged Registry Stack release +that contains both +[PR #355](https://github.com/registrystack/registry-stack/pull/355) and +[PR #364](https://github.com/registrystack/registry-stack/pull/364). +Record the tag and use the matching `registryctl`, Relay image, Notary image, +and published image digests. +Atomically pin the candidate adopter deployment to those artifacts. +Do not use a source build, a branch image, the retired monorepo `lab/`, shared +Relay or Notary state, or a hand-edited generated YAML file. +Each Relay authority needs its own dedicated Notary and Notary-owned PostgreSQL +state. + +Select and record one exact OpenSPP version and one read-only operation before +editing the workspace. +Replace every fictional assumption in the private copy: + +| File | Fields to replace and review | +| --- | --- | +| `integrations/individual/integration.yaml` | Replace `id` and the `source.versions.unverified` fixture label with the reviewed integration identity and exact selected OpenSPP version. Classify that version under `source.versions.tested` only after the owner evidence is accepted. | +| `integrations/individual/integration.yaml` | Replace the input name, type, length, pattern, HTTP method, relative path, query, no-match statuses, authentication type, response format, and response-size bound with the selected read-only operation's contract. | +| `integrations/individual/integration.yaml` | Replace output names, types, lengths, and `x-registry-source` pointers. Reassess the `ambiguity` and `subject_mismatch` not-applicable rationales. Add fixtures when either outcome is applicable. | +| `integrations/individual/fixtures/*.yaml` | Replace the synthetic selector, request expectation, source response, normalized outputs, outcome, and claims. Keep all retained fixture data synthetic. | +| `registry-stack.yaml` | Replace `registry.id`, the service id, `purpose`, `legal_basis`, `consent`, `access.scopes`, the consultation input mapping, claim ids and declarations, disclosure modes, and credential profile with reviewed owner decisions. | +| `environments/.yaml` | Replace `integrations.individual.source.origin`, `integrations.individual.source.credential.token.secret`, and `integrations.individual.source.credential.generation` with the private OpenSPP source binding. | +| `environments/.yaml` | Replace every `issuance` field and the `callers.programme-service` map key, API-key fingerprint secret reference, and scopes with candidate-owned values. | +| `environments/.yaml` | Replace every `relay`, `notary_relay`, and `deployment` field. Add the candidate-required state bindings. Keep all deployment values outside public evidence. | + +Use bounded one-request HTTP authoring when it expresses the selected operation. +Use reviewed Rhai only when the operation requires project-owned traversal or +normalization. +Do not add OpenSPP-specific Rust dispatch, restore a Notary source connector, +add an integration sidecar, or edit generated runtime configuration. + +Rerun the focused trace, complete offline fixture suite, check, and build after +every contract change. +For the private owner environment, replace `local` in the check and build +commands with the exact environment filename without `.yaml`. +Activate the generated Relay and Notary Config Bundle inputs through the +documented path for the selected tagged candidate, without modifying generated +files. + +## Prepare the governed live evaluation + +Create the request and expected-result files under `.registry-stack/` in the +private workspace. +That directory is ignored by this workspace. +Use only an owner-approved non-production record. +Notary applies one disclosure profile to the whole evaluation, and this +project's generated disclosure policies deny incompatible downgrades. +Run separate request and expected-result pairs for the predicate, value, and +redacted claims. +The following pairs match the committed synthetic project, so replace the +identifier scheme, value, purpose, claim ids, and expected values when the +authored project changes. + +Predicate request: + +```json +{ + "target": { + "type": "Person", + "identifiers": [ + { + "scheme": "openspp_individual_id", + "value": "IND-AB12CD34" + } + ] + }, + "claims": [ + "social-registry-record-exists", + "social-registry-active" + ], + "disclosure": "predicate", + "format": "application/vnd.registry-notary.claim-result+json", + "purpose": "social-programme-verification" +} +``` + +Predicate expected result: + +```json +{ + "claims": { + "social-registry-record-exists": { + "value": true, + "satisfied": true, + "disclosure": "predicate" + }, + "social-registry-active": { + "value": true, + "satisfied": true, + "disclosure": "predicate" + } + } +} +``` + +Value request: + +```json +{ + "target": { + "type": "Person", + "identifiers": [ + { + "scheme": "openspp_individual_id", + "value": "IND-AB12CD34" + } + ] + }, + "claims": ["programme-code"], + "disclosure": "value", + "format": "application/vnd.registry-notary.claim-result+json", + "purpose": "social-programme-verification" +} +``` + +Value expected result: + +```json +{ + "claims": { + "programme-code": { + "value": "SUPPORT", + "satisfied": null, + "disclosure": "value" + } + } +} +``` + +Redacted request: + +```json +{ + "target": { + "type": "Person", + "identifiers": [ + { + "scheme": "openspp_individual_id", + "value": "IND-AB12CD34" + } + ] + }, + "claims": ["household-reference"], + "disclosure": "redacted", + "format": "application/vnd.registry-notary.claim-result+json", + "purpose": "social-programme-verification" +} +``` + +Redacted expected result: + +```json +{ + "claims": { + "household-reference": { + "value": null, + "satisfied": null, + "disclosure": "redacted" + } + } +} +``` + +`registryctl` sends +`Accept: application/vnd.registry-notary.claim-result+json`, matching the +request's `format`, and validates that claim-result envelope. + +Each expected-result file must contain only a `claims` object. +Its keys must exactly match the corresponding request's claim ids. +Each claim value must contain exactly `value`, `satisfied`, and `disclosure`, +using `null` when the Notary result has no value or satisfaction decision. +For a fully redacted claim whose deployed policy configures top-level redaction +fields, add `redacted_fields` containing that exact owner-approved field set. +The set must contain one to 64 unique bounded top-level field names; order in +the expected file is not significant. Omit `redacted_fields` when the policy +does not configure a field set, in which case the runner requires Notary's +default single claim-id marker. +The live runner rejects keys outside its accepted result, reference, and +provenance structures, including `pack_id` and `pack_version`; validates +public `redacted_fields` against either that owner-approved canonical field set +or the default claim-id marker, without exposing a listed field value; requires +`target_ref` and optional `requester_ref` handles to use the Notary `rnref:v1` +pseudonymous SHA-256 shape; requires every result in the evaluation to carry +the same complete target and optional requester references; rejects nulls for +optional public fields; requires one evaluation id and the canonical +claim-result format; requires RFC3339 timestamps and binds `issued_at` to the +current POST and response-read window with a 30-second remote-clock allowance +in either direction; requires a non-null `expires_at` to remain strictly after +both response receipt and issuance while accepting the schema's explicit null; +rejects named-policy provenance on this API-key flow; binds each provenance +record and claim version to the returned result and authored project; requires +exact `value`, `satisfied`, and `disclosure` matches; requires an empty +`derived_from` array; and requires a non-zero Relay consultation count. +The runner validates the public pseudonym shape but cannot recompute its keyed +digest from the private owner record. +These examples reflect only the committed synthetic fixture and must be +replaced with reviewed expectations for the owner-approved record. + +The governed live test reads exactly four process variables: + +| Variable | Owner-supplied value | +| --- | --- | +| `REGISTRY_STACK_LIVE_NOTARY_ORIGIN` | The non-production candidate Notary origin. Use HTTPS with no path, query, user information, or fragment. HTTP is accepted only for a loopback origin. | +| `REGISTRY_STACK_LIVE_NOTARY_API_KEY` | The deployed Notary caller API key. This is not the OpenSPP source credential. Load it from the owner's secret mechanism, never from a command argument or tracked file. | +| `REGISTRY_STACK_LIVE_REQUEST_FILE` | The absolute path to the strict JSON evaluation request. Use a bounded regular file, not a symbolic link. | +| `REGISTRY_STACK_LIVE_EXPECTED_FILE` | The absolute path to the strict JSON expected-result file. Use a bounded regular file, not a symbolic link. | + +Load the API key into the process environment without echoing it, export the +other three variables, and run: + +```sh +registryctl test --project-dir . --environment --live +``` + +The command refuses environment names containing an exact `prod` or +`production` segment separated by `.`, `_`, or `-`, plus environments whose +`deployment.profile` is `production` or `evidence_grade`. +This guard classifies the selected environment name and profile; it does not +infer deployment classification from the operator-supplied Notary origin. +It first reruns the offline fixtures, checks the candidate Notary's Relay +readiness, and then sends one request to the governed Notary evaluation path. +It requires the returned claim fields to match the expected file and requires +source-backed Relay provenance. +It never sends the request directly to OpenSPP. +Unset all four variables after the run. + +Repeat the live command with separate private request and expected-result files +for each owner-approved match or no-match case. +Use the candidate's governed denial and failure checks for authorization denial +before source access and bounded source failures. +Prove ambiguity and subject mismatch with live cases when the selected operation +makes them applicable, or retain an owner-reviewed not-applicable rationale +that matches the real response contract. + +## Evidence and redaction checklist + +Before asking to close GH#357, record: + +- [ ] Exact OpenSPP version and read-only operation, including which + country-specific mapping files changed. +- [ ] Exact Registry Stack tag, `registryctl` version, adopter commit, Relay and + Notary image digests, and per-authority Notary and PostgreSQL topology. +- [ ] The commands and pass or fail outcomes for focused trace, watch, complete + offline fixtures, check, build, activation, and governed live evaluation. +- [ ] Match and no-match outcomes, plus applicable ambiguity or subject-mismatch + behavior or reviewed reasons that they are not applicable. +- [ ] Authorization denial before source access, bounded failure, disclosure, + redaction, and source-backed provenance outcomes. +- [ ] Confirmation that generated Relay and Notary files were activated + unchanged. +- [ ] Confirmation that no OpenSPP-specific Registry Stack Rust, Notary source + connector, integration sidecar, or direct registry test path was needed. +- [ ] Confirmation that changing the country mapping required only reviewed + project-authored files and fixtures. +- [ ] Any gap fixed in the generic authoring or runtime model, or recorded as an + explicit limitation before the 1.0 decision. + +Before retaining or publishing evidence: + +- [ ] Remove Notary and OpenSPP credentials, secret values, private origins, + private network details, raw selectors, subject identifiers, source rows, + source response bodies, and deployment-specific file paths. +- [ ] Do not retain shell history, environment dumps, packet captures, verbose + HTTP transcripts, or logs that can contain those values. +- [ ] Keep public evidence to tested versions, the operation description, + commands, outcomes, limitations, redaction checks, and non-sensitive + artifact digests. +- [ ] Have the OpenSPP owner review the redacted evidence before publication. + +A successful owner run can support only the wording +`live-authoring-validated OpenSPP starter` for the exact tested version and +operation. +It does not establish E2 interoperability unless the complete common +integration matrix also passes. +Until that evidence exists, this workspace remains an offline fixture and no +public OpenSPP support claim follows from it. diff --git a/products/notary/openapi/registry-notary.openapi.json b/products/notary/openapi/registry-notary.openapi.json index 2eb25cef2..b154e8279 100644 --- a/products/notary/openapi/registry-notary.openapi.json +++ b/products/notary/openapi/registry-notary.openapi.json @@ -358,6 +358,13 @@ "provenance": { "$ref": "#/components/schemas/ClaimProvenance" }, + "redacted_fields": { + "description": "Claim or top-level field names redacted from the public result. Omitted when no redaction was applied.", + "items": { + "type": "string" + }, + "type": "array" + }, "requester_ref": { "$ref": "#/components/schemas/EvidenceEntityRef" },