From 9b317bd35fb20c1dcb87c50ebdfdd721421f8eb9 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 10 Jul 2026 12:30:32 +0700 Subject: [PATCH 1/2] fix(notary): preserve federation denial audit context Signed-off-by: Jeremi Joslin --- .../src/federation/audit.rs | 98 +++++- .../src/federation/claims.rs | 49 ++- .../src/federation/mod.rs | 304 +++++++++++++++--- .../src/federation/signing.rs | 2 +- .../registry-notary-server/src/standalone.rs | 2 +- .../tests/standalone_http.rs | 199 +++++++++++- release/notes/negative-path-coverage-map.md | 26 +- 7 files changed, 589 insertions(+), 91 deletions(-) diff --git a/crates/registry-notary-server/src/federation/audit.rs b/crates/registry-notary-server/src/federation/audit.rs index b4d82456d..6625eea0e 100644 --- a/crates/registry-notary-server/src/federation/audit.rs +++ b/crates/registry-notary-server/src/federation/audit.rs @@ -1,7 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 use axum::response::Response; -use registry_notary_core::{AccessMode, ConfigMetadata, FEDERATION_PROTOCOL_V0_1}; +use registry_notary_core::{ + AccessMode, ConfigMetadata, FederationPeerConfig, FEDERATION_PROTOCOL_V0_1, +}; +use registry_platform_oidc::VerifiedToken; use time::format_description::well_known::Rfc3339; use time::OffsetDateTime; use ulid::Ulid; @@ -10,6 +13,49 @@ use crate::api::evidence_claim_hash; use super::errors::FederationProblem; +#[derive(Clone, Debug, Default)] +pub(super) struct FederationAuditContext { + pub(super) claim_ids: Vec, + pub(super) peer_node_id: Option, + pub(super) issuer: Option, + pub(super) profile: Option, + pub(super) purpose: Option, + pub(super) request_jti: Option, + pub(super) subject_ref_hash: Option, +} + +impl FederationAuditContext { + pub(super) fn from_verified(peer: &FederationPeerConfig, verified: &VerifiedToken) -> Self { + Self { + peer_node_id: Some(peer.node_id.clone()), + issuer: Some(peer.issuer.clone()), + profile: verified + .claims + .extra + .get("profile") + .and_then(|value| value.as_str()) + .map(str::to_string), + purpose: verified + .claims + .extra + .get("purpose") + .and_then(|value| value.as_str()) + .map(str::to_string), + request_jti: verified + .claims + .extra + .get("jti") + .and_then(|value| value.as_str()) + .map(str::to_string), + ..Self::default() + } + } + + pub(super) fn denied(&self, problem: FederationProblem) -> FederationDeniedOutcome { + FederationDeniedOutcome::with_context(problem, self.clone()) + } +} + #[derive(Debug)] pub(super) struct FederationAuditOutcome { pub(super) decision: String, @@ -26,19 +72,55 @@ pub(super) struct FederationAuditOutcome { impl FederationAuditOutcome { pub(super) fn denied(problem: &FederationProblem) -> Self { + Self::denied_with_context(problem, FederationAuditContext::default()) + } + + pub(super) fn denied_with_context( + problem: &FederationProblem, + context: FederationAuditContext, + ) -> Self { Self { decision: "federated_evaluate_denied".to_string(), verification_id: None, - claim_ids: Vec::new(), + claim_ids: context.claim_ids, error_code: Some(problem.code.clone()), - peer_node_id: None, - issuer: None, - profile: None, - purpose: None, - request_jti: None, - subject_ref_hash: None, + peer_node_id: context.peer_node_id, + issuer: context.issuer, + profile: context.profile, + purpose: context.purpose, + request_jti: context.request_jti, + subject_ref_hash: context.subject_ref_hash, } } + + pub(super) fn into_denied(mut self, problem: &FederationProblem) -> Self { + self.decision = "federated_evaluate_denied".to_string(); + self.error_code = Some(problem.code.clone()); + self + } +} + +#[derive(Debug)] +pub(super) struct FederationDeniedOutcome { + pub(super) problem: FederationProblem, + pub(super) audit: FederationAuditOutcome, +} + +impl FederationDeniedOutcome { + pub(super) fn with_context( + problem: FederationProblem, + context: FederationAuditContext, + ) -> Self { + let audit = FederationAuditOutcome::denied_with_context(&problem, context); + Self { problem, audit } + } +} + +impl From for FederationDeniedOutcome { + fn from(problem: FederationProblem) -> Self { + let audit = FederationAuditOutcome::denied(&problem); + Self { problem, audit } + } } pub(super) fn federation_audit_event( diff --git a/crates/registry-notary-server/src/federation/claims.rs b/crates/registry-notary-server/src/federation/claims.rs index d758d1cf9..bd998b500 100644 --- a/crates/registry-notary-server/src/federation/claims.rs +++ b/crates/registry-notary-server/src/federation/claims.rs @@ -84,12 +84,27 @@ pub(super) fn request_subject( verified: &VerifiedToken, profile: &FederationEvaluationProfileConfig, ) -> Result { - let request = verified - .claims - .extra - .get("request") - .and_then(Value::as_object) - .ok_or_else(|| FederationProblem::invalid_request("request object is required"))?; + let subject = request_subject_identifier(verified, profile)?; + let request = request_object(verified)?; + let requested_claims = request + .get("claims") + .and_then(Value::as_array) + .ok_or_else(|| FederationProblem::invalid_request("request.claims is required"))?; + if requested_claims.len() != 1 + || requested_claims.first().and_then(Value::as_str) != Some(profile.claim_id.as_str()) + { + return Err(FederationProblem::forbidden( + "request claims do not match profile", + )); + } + Ok(subject) +} + +pub(super) fn request_subject_identifier( + verified: &VerifiedToken, + profile: &FederationEvaluationProfileConfig, +) -> Result { + let request = request_object(verified)?; let subject = request .get("subject") .and_then(Value::as_object) @@ -107,23 +122,23 @@ pub(super) fn request_subject( "subject id type is not allowed", )); } - let requested_claims = request - .get("claims") - .and_then(Value::as_array) - .ok_or_else(|| FederationProblem::invalid_request("request.claims is required"))?; - if requested_claims.len() != 1 - || requested_claims.first().and_then(Value::as_str) != Some(profile.claim_id.as_str()) - { - return Err(FederationProblem::forbidden( - "request claims do not match profile", - )); - } Ok(SubjectRequest { id: id.to_string(), id_type: Some(id_type.to_string()), }) } +fn request_object( + verified: &VerifiedToken, +) -> Result<&serde_json::Map, FederationProblem> { + verified + .claims + .extra + .get("request") + .and_then(Value::as_object) + .ok_or_else(|| FederationProblem::invalid_request("request object is required")) +} + pub(super) fn source_observation_is_stale( profile: &FederationEvaluationProfileConfig, results: &[registry_notary_core::ClaimResultView], diff --git a/crates/registry-notary-server/src/federation/mod.rs b/crates/registry-notary-server/src/federation/mod.rs index 5c5ff6c8f..5dee9a8a3 100644 --- a/crates/registry-notary-server/src/federation/mod.rs +++ b/crates/registry-notary-server/src/federation/mod.rs @@ -23,19 +23,20 @@ use registry_notary_core::{ FEDERATION_REQUEST_JWT_TYP, FORMAT_CLAIM_RESULT_JSON, }; use registry_platform_crypto::pairwise_subject_ref_hash; +use registry_platform_oidc::VerifiedToken; use registry_platform_replay::{ReplayKey, ReplayScope, RequiredReplayError}; use time::OffsetDateTime; use crate::{api::RegistryNotaryApiState, replay::require_replay_insert}; -use audit::{federation_audit_event, FederationAuditOutcome}; +use audit::{federation_audit_event, FederationAuditContext, FederationDeniedOutcome}; use claims::{ - decode_unverified_jwt_payload, request_subject, source_observation_is_stale, string_claim, - string_extra, validate_federation_claims, + decode_unverified_jwt_payload, request_subject, request_subject_identifier, + source_observation_is_stale, string_claim, string_extra, validate_federation_claims, }; use errors::{apply_denial_latency, federation_problem_response, FederationProblem}; pub(crate) use runtime::FederationRuntimeState; -use signing::FederationSignedOutcome; +use signing::{FederationResponseSigner, FederationSignedOutcome}; pub fn federation_router() -> Router where @@ -58,19 +59,31 @@ async fn federated_evaluate( }; let outcome = handle_federated_evaluate(&headers, Arc::clone(&state), Arc::clone(&runtime), body).await; + finalize_federated_evaluate( + outcome, + started, + state.federation.response_shaping.minimum_denial_latency_ms, + &runtime.response_signer, + runtime.audit.as_ref(), + ) + .await +} + +async fn finalize_federated_evaluate( + outcome: Result, + started: Instant, + minimum_denial_latency_ms: u64, + response_signer: &FederationResponseSigner, + audit_pipeline: Option<&crate::standalone::AuditPipeline>, +) -> Response { let (mut response, audit) = match outcome { - Ok(outcome) => outcome.into_response(&runtime.response_signer).await, - Err(problem) => { - apply_denial_latency( - started, - state.federation.response_shaping.minimum_denial_latency_ms, - ) - .await; - let audit = FederationAuditOutcome::denied(&problem); - (federation_problem_response(problem), audit) + Ok(outcome) => outcome.into_response(response_signer).await, + Err(denied) => { + apply_denial_latency(started, minimum_denial_latency_ms).await; + (federation_problem_response(denied.problem), denied.audit) } }; - if let Some(audit_pipeline) = runtime.audit.as_ref() { + if let Some(audit_pipeline) = audit_pipeline { let event = federation_audit_event(&response, audit, Some(audit_pipeline)); if let Err(error) = audit_pipeline.emit(&event).await { response = crate::standalone::audit_error_response(error); @@ -84,7 +97,7 @@ async fn handle_federated_evaluate( state: Arc, runtime: Arc, body: Body, -) -> Result { +) -> Result { state .enabled_evidence() .map_err(|_| FederationProblem::server_disabled())?; @@ -99,7 +112,8 @@ async fn handle_federated_evaluate( "unsupported-media-type", "Federation request content type must be application/jwt", "federation.unsupported_media_type", - )); + ) + .into()); } let body = to_bytes(body, state.federation.inbound_body_limit_bytes) .await @@ -115,16 +129,16 @@ async fn handle_federated_evaluate( .map(str::trim) .map_err(|_| FederationProblem::invalid_request("request body must be a compact JWS"))?; if token.split('.').count() != 3 { - return Err(FederationProblem::invalid_request( - "request body must be a compact JWS", - )); + return Err( + FederationProblem::invalid_request("request body must be a compact JWS").into(), + ); } let header = decode_header(token).map_err(|_| FederationProblem::invalid_token())?; if header.alg != Algorithm::EdDSA { - return Err(FederationProblem::invalid_token()); + return Err(FederationProblem::invalid_token().into()); } if header.typ.as_deref() != Some(FEDERATION_REQUEST_JWT_TYP) { - return Err(FederationProblem::invalid_token()); + return Err(FederationProblem::invalid_token().into()); } let kid = header .kid @@ -137,7 +151,7 @@ async fn handle_federated_evaluate( .iter() .any(|denied| denied == kid) { - return Err(FederationProblem::forbidden("signing key is denied")); + return Err(FederationProblem::forbidden("signing key is denied").into()); } let unverified = decode_unverified_jwt_payload(token)?; let issuer = string_claim(&unverified, "iss") @@ -154,14 +168,18 @@ async fn handle_federated_evaluate( .iter() .any(|denied| denied == &peer.config.node_id) { - return Err(FederationProblem::forbidden("peer node is denied")); + return Err(FederationProblem::forbidden("peer node is denied").into()); } let verified = peer .verifier .verify(token) .await .map_err(|_| FederationProblem::invalid_token())?; - validate_federation_claims(&state.federation, &peer.config, &verified)?; + let mut audit_context = + verified_federation_audit_context(&state, &runtime, &peer.config, &verified) + .map_err(|denied| *denied)?; + validate_federation_claims(&state.federation, &peer.config, &verified) + .map_err(|problem| audit_context.denied(problem))?; let request_jti = string_extra(&verified, "jti") .ok_or_else(FederationProblem::invalid_token)? .to_string(); @@ -184,13 +202,13 @@ async fn handle_federated_evaluate( &state.federation.node_id, &profile_id, ) - .map_err(|_| FederationProblem::invalid_token())?; - let replay_key = - ReplayKey::new(request_jti.as_str()).map_err(|_| FederationProblem::invalid_token())?; + .map_err(|_| audit_context.denied(FederationProblem::invalid_token()))?; + let replay_key = ReplayKey::new(request_jti.as_str()) + .map_err(|_| audit_context.denied(FederationProblem::invalid_token()))?; let replay_expires_at = OffsetDateTime::from_unix_timestamp( exp.saturating_add(state.federation.clock_leeway_seconds as i64), ) - .map_err(|_| FederationProblem::invalid_token())?; + .map_err(|_| audit_context.denied(FederationProblem::invalid_token()))?; match require_replay_insert( runtime.replay.as_ref(), &replay_scope, @@ -206,24 +224,24 @@ async fn handle_federated_evaluate( runtime .metrics .record_replay("federation_request", "replayed"); - return Err(FederationProblem::new( + return Err(audit_context.denied(FederationProblem::new( StatusCode::CONFLICT, "replay", "Federation request replay detected", "federation.replay", - )); + ))); } Err(RequiredReplayError::Store { .. }) => { runtime.metrics.record_replay("federation_request", "error"); - return Err(FederationProblem::server_error( + return Err(audit_context.denied(FederationProblem::server_error( "required replay protection failed", - )); + ))); } Err(_) => { runtime.metrics.record_replay("federation_request", "error"); - return Err(FederationProblem::server_error( + return Err(audit_context.denied(FederationProblem::server_error( "required replay protection failed", - )); + ))); } } let profile = state @@ -231,8 +249,12 @@ async fn handle_federated_evaluate( .evaluation_profiles .iter() .find(|candidate| candidate.id == profile_id) - .ok_or_else(|| FederationProblem::forbidden("profile is not allowed"))?; - let subject = request_subject(&verified, profile)?; + .ok_or_else(|| { + audit_context.denied(FederationProblem::forbidden("profile is not allowed")) + })?; + audit_context.claim_ids = vec![profile.claim_id.clone()]; + let subject = + request_subject(&verified, profile).map_err(|problem| audit_context.denied(problem))?; let principal = EvidencePrincipal { principal_id: peer.config.node_id.clone(), scopes: peer.config.source_scopes.clone(), @@ -274,7 +296,12 @@ async fn handle_federated_evaluate( subject.id_type.as_deref().unwrap_or(""), &subject.id, ) - .map_err(|_| FederationProblem::server_error("failed to hash subject reference"))?; + .map_err(|_| { + audit_context.denied(FederationProblem::server_error( + "failed to hash subject reference", + )) + })?; + audit_context.subject_ref_hash = Some(subject_hash.clone()); let runtime_eval = state.runtime(); let results = runtime_eval .evaluate_with_source_capability( @@ -289,7 +316,7 @@ async fn handle_federated_evaluate( None, ) .await - .map_err(FederationProblem::from_evidence_error)?; + .map_err(|error| audit_context.denied(FederationProblem::from_evidence_error(error)))?; if source_observation_is_stale(profile, &results) { return Ok(FederationSignedOutcome::evaluation_error( &state.federation, @@ -316,6 +343,52 @@ async fn handle_federated_evaluate( )) } +fn verified_federation_audit_context( + state: &RegistryNotaryApiState, + runtime: &FederationRuntimeState, + peer: ®istry_notary_core::FederationPeerConfig, + verified: &VerifiedToken, +) -> Result> { + let mut context = FederationAuditContext::from_verified(peer, verified); + let Some(profile_id) = context.profile.as_deref() else { + return Ok(context); + }; + if !peer + .allowed_profiles + .iter() + .any(|allowed| allowed == profile_id) + { + return Ok(context); + } + let Some(profile) = state + .federation + .evaluation_profiles + .iter() + .find(|candidate| candidate.id == profile_id) + else { + return Ok(context); + }; + context.claim_ids = vec![profile.claim_id.clone()]; + let Ok(subject) = request_subject_identifier(verified, profile) else { + return Ok(context); + }; + let subject_hash = pairwise_subject_ref_hash( + runtime.pairwise_subject_hash_secret.as_slice(), + &peer.node_id, + &state.federation.node_id, + &profile.id, + subject.id_type.as_deref().unwrap_or(""), + &subject.id, + ) + .map_err(|_| { + Box::new(context.denied(FederationProblem::server_error( + "failed to hash subject reference", + ))) + })?; + context.subject_ref_hash = Some(subject_hash); + Ok(context) +} + fn federation_authorization_details( profile: &FederationEvaluationProfileConfig, ) -> Option { @@ -340,6 +413,161 @@ fn federation_authorization_details( #[cfg(test)] mod tests { use super::*; + use async_trait::async_trait; + use axum::http::HeaderValue; + use registry_notary_core::{FederationConfig, FederationPeerConfig, FEDERATION_PROTOCOL_V0_1}; + use registry_platform_audit::{ + AuditChainHasher, AuditEnvelope, AuditError, AuditSink as PlatformAuditSink, + }; + use registry_platform_crypto::{ + PrivateJwk, PublicJwk, SigningAlgorithm, SigningError, SigningProvider, + }; + use registry_platform_testing::fixtures; + use serde_json::json; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::sync::Mutex; + + use crate::standalone::AuditPipeline; + + #[derive(Default)] + struct MemoryAuditSink { + envelopes: Mutex>, + } + + #[async_trait] + impl PlatformAuditSink for MemoryAuditSink { + async fn write(&self, envelope: &AuditEnvelope) -> Result<(), AuditError> { + self.envelopes.lock().await.push(envelope.clone()); + Ok(()) + } + + async fn tail_hash(&self) -> Result, AuditError> { + Ok(self + .envelopes + .lock() + .await + .last() + .map(|envelope| envelope.record_hash)) + } + + async fn tail_hash_with_hasher( + &self, + _hasher: &AuditChainHasher, + ) -> Result, AuditError> { + Ok(self + .envelopes + .lock() + .await + .last() + .map(|envelope| envelope.record_hash)) + } + } + + struct FailingSigningProvider { + attempts: Arc, + } + + #[async_trait] + impl SigningProvider for FailingSigningProvider { + fn algorithm(&self) -> SigningAlgorithm { + SigningAlgorithm::EdDsa + } + + fn key_id(&self) -> &str { + "registry-platform-testing-ed25519-1" + } + + fn public_jwk(&self) -> PublicJwk { + PrivateJwk::parse(fixtures::ED25519_PRIVATE_JWK) + .expect("fixture private JWK parses") + .public() + } + + async fn sign(&self, _payload: &[u8]) -> Result, SigningError> { + self.attempts.fetch_add(1, Ordering::SeqCst); + Err(SigningError::external("forced federation signing failure")) + } + } + + #[tokio::test] + async fn federation_response_signing_failure_emits_denial_audit_with_context() { + let federation = FederationConfig { + node_id: "did:web:agency-a.example.gov".to_string(), + issuer: "https://agency-a.example.gov".to_string(), + ..FederationConfig::default() + }; + let peer = FederationPeerConfig { + node_id: "did:web:agency-b.example.gov".to_string(), + issuer: "https://agency-b.example.gov".to_string(), + ..FederationPeerConfig::default() + }; + let profile = FederationEvaluationProfileConfig { + id: "farmer_under_4ha".to_string(), + claim_id: "farmer-under-4ha".to_string(), + ..FederationEvaluationProfileConfig::default() + }; + let request_jti = "01J9Z6Q6Q6Q6Q6Q6Q6Q6Q6Q6Q6"; + let outcome = FederationSignedOutcome::evaluation_error( + &federation, + &peer, + FEDERATION_PROTOCOL_V0_1, + &profile, + "https://purpose.example.test/eligibility", + request_jti, + "hmac-sha256:subject".to_string(), + "urn:registry-notary:problem:federation:stale-source-observation", + "Source observation is stale", + ); + let attempts = Arc::new(AtomicUsize::new(0)); + let signer = FederationResponseSigner { + provider: Arc::new(FailingSigningProvider { + attempts: Arc::clone(&attempts), + }), + }; + let sink = Arc::new(MemoryAuditSink::default()); + let audit_pipeline = AuditPipeline::for_sink_dev_only(sink.clone()); + + let response = finalize_federated_evaluate( + Ok(outcome), + Instant::now(), + 0, + &signer, + Some(&audit_pipeline), + ) + .await; + + assert_eq!(attempts.load(Ordering::SeqCst), 1); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!( + response.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("application/problem+json")) + ); + let envelopes = sink.envelopes.lock().await; + assert_eq!(envelopes.len(), 1); + let record = &envelopes[0].record; + assert_eq!(record["decision"], json!("federated_evaluate_denied")); + assert_eq!(record["status"], json!(500)); + assert_eq!(record["error_code"], json!("federation.server_error")); + assert!(record["claim_hash"].is_string()); + assert!(record["federation_peer_id_hash"].is_string()); + assert_eq!( + record["federation_issuer"], + json!("https://agency-b.example.gov") + ); + assert_eq!(record["federation_profile"], json!("farmer_under_4ha")); + assert_eq!( + record["federation_purpose"], + json!("https://purpose.example.test/eligibility") + ); + assert!(record["federation_request_jti_hash"].is_string()); + assert_eq!( + record["federation_subject_ref_hash"], + json!("hmac-sha256:subject") + ); + let serialized = serde_json::to_string(record).expect("audit record serializes"); + assert!(!serialized.contains("did:web:agency-b.example.gov")); + assert!(!serialized.contains(request_jti)); + } #[test] fn federation_profile_can_supply_trusted_policy_context() { diff --git a/crates/registry-notary-server/src/federation/signing.rs b/crates/registry-notary-server/src/federation/signing.rs index 8828cc8eb..8131a97fd 100644 --- a/crates/registry-notary-server/src/federation/signing.rs +++ b/crates/registry-notary-server/src/federation/signing.rs @@ -147,7 +147,7 @@ impl FederationSignedOutcome { (response, self.audit) } Err(problem) => { - let audit = FederationAuditOutcome::denied(&problem); + let audit = self.audit.into_denied(&problem); (federation_problem_response(problem), audit) } } diff --git a/crates/registry-notary-server/src/standalone.rs b/crates/registry-notary-server/src/standalone.rs index 5cba17ca3..8776dae86 100644 --- a/crates/registry-notary-server/src/standalone.rs +++ b/crates/registry-notary-server/src/standalone.rs @@ -4279,7 +4279,7 @@ impl AuditPipeline { } #[cfg(test)] - fn for_sink_dev_only(sink: Arc) -> Self { + pub(crate) fn for_sink_dev_only(sink: Arc) -> Self { Self { sink, chain: Arc::new(OnceCell::new()), diff --git a/crates/registry-notary-server/tests/standalone_http.rs b/crates/registry-notary-server/tests/standalone_http.rs index a5c88f19f..426ed972e 100644 --- a/crates/registry-notary-server/tests/standalone_http.rs +++ b/crates/registry-notary-server/tests/standalone_http.rs @@ -926,7 +926,6 @@ fn verified_federation_response_claims_with_key( serde_json::from_slice(&payload).expect("response payload is JSON") } -#[cfg(feature = "registry-notary-cel")] fn audit_records(path: &std::path::Path) -> Vec { std::fs::read_to_string(path) .expect("audit was written") @@ -1501,6 +1500,53 @@ fn assert_audit_records_do_not_contain(records: &[Value], forbidden: &[&str]) { } } +fn assert_hmac_audit_field(record: &Value, field: &str) { + assert!( + record[field] + .as_str() + .unwrap_or_else(|| panic!("{field} is a string")) + .starts_with("hmac-sha256:"), + "{field} is a keyed HMAC handle" + ); +} + +fn assert_verified_federation_audit_context( + record: &Value, + profile: &str, + purpose: &str, + includes_subject_hash: bool, +) { + assert_hmac_audit_field(record, "federation_peer_id_hash"); + assert_eq!( + record["federation_issuer"], + json!("https://agency-b.example.gov") + ); + assert_eq!(record["federation_profile"], json!(profile)); + assert_eq!(record["federation_purpose"], json!(purpose)); + assert_hmac_audit_field(record, "federation_request_jti_hash"); + if includes_subject_hash { + assert_hmac_audit_field(record, "federation_subject_ref_hash"); + } else { + assert!(record.get("federation_subject_ref_hash").is_none()); + } +} + +fn assert_federation_request_context_is_absent(record: &Value) { + for field in [ + "federation_peer_id_hash", + "federation_issuer", + "federation_profile", + "federation_purpose", + "federation_request_jti_hash", + "federation_subject_ref_hash", + ] { + assert!( + record.get(field).is_none(), + "pre-verification denial unexpectedly recorded {field}" + ); + } +} + #[tokio::test] async fn healthz_ready_opaque_counters_in_503_body() { let server = TestServer::builder() @@ -1657,7 +1703,21 @@ async fn federation_evaluation_returns_signed_response_and_rejects_replay() { assert!(records .iter() .any(|record| record["decision"] == json!("federated_evaluate_denied"))); + let replay_denied = audit_record_with( + &records, + "/federation/v1/evaluations", + "federated_evaluate_denied", + StatusCode::CONFLICT, + "federation.replay", + ); + assert_verified_federation_audit_context( + replay_denied, + "farmer_under_4ha", + "https://purpose.example.test/eligibility", + true, + ); assert!(!audit.contains("person-1")); + assert!(!audit.contains("01J9Z6Q6Q6Q6Q6Q6Q6Q6Q6Q6Q6")); assert!(!audit.contains("source-token")); let metrics = server @@ -1762,6 +1822,24 @@ async fn federation_policy_context_satisfies_governed_source_matching() { denied.assert_status(StatusCode::FORBIDDEN); let body: Value = denied.json(); assert_eq!(body["code"], json!("pdp.jurisdiction_not_permitted")); + let denied_records = audit_records(&denied_audit_path); + let denied_audit = audit_record_with( + &denied_records, + "/federation/v1/evaluations", + "federated_evaluate_denied", + StatusCode::FORBIDDEN, + "pdp.jurisdiction_not_permitted", + ); + assert_verified_federation_audit_context( + denied_audit, + "farmer_under_4ha", + "https://purpose.example.test/eligibility", + true, + ); + assert_audit_records_do_not_contain( + &denied_records, + &["person-1", "01J9Z6Q6Q6Q6Q6Q6Q6Q6Q6G0W1"], + ); } #[tokio::test] @@ -1934,6 +2012,21 @@ async fn federation_denial_happens_before_source_read() { response.assert_status(StatusCode::FORBIDDEN); assert_eq!(source_hits.load(Ordering::SeqCst), 0); + let records = audit_records(&audit_path); + let denied = audit_record_with( + &records, + "/federation/v1/evaluations", + "federated_evaluate_denied", + StatusCode::FORBIDDEN, + "federation.forbidden", + ); + assert_verified_federation_audit_context( + denied, + "farmer_under_4ha", + "https://purpose.example.test/not-allowed", + true, + ); + assert_audit_records_do_not_contain(&records, &["person-1", "01J9Z6Q6Q6Q6Q6Q6Q6Q6Q6Q6Q7"]); let unsupported_media_type = server .post("/federation/v1/evaluations") @@ -2018,7 +2111,20 @@ async fn federation_denial_happens_before_source_read() { .await; unknown_kid_response.assert_status(StatusCode::UNAUTHORIZED); assert_eq!(source_hits.load(Ordering::SeqCst), 0); + let records = audit_records(&audit_path); + let unknown_key_audit = records.last().expect("unknown-key audit record exists"); + assert_eq!( + unknown_key_audit["error_code"], + json!("federation.invalid_token") + ); + assert_federation_request_context_is_absent(unknown_key_audit); + assert!(!audit_record_contains_text( + unknown_key_audit, + "01J9Z6Q6Q6Q6Q6Q6Q6Q6Q6Q7Q6" + )); + assert!(!audit_record_contains_text(unknown_key_audit, "person-1")); + let audit_count_before_bad_signature = records.len(); let bad_signature = tamper_jwt_signature(&federation_request_jwt( "01J9Z6Q6Q6Q6Q6Q6Q6Q6Q6Q7Q7", "https://purpose.example.test/eligibility", @@ -2030,6 +2136,19 @@ async fn federation_denial_happens_before_source_read() { .await; bad_signature_response.assert_status(StatusCode::UNAUTHORIZED); assert_eq!(source_hits.load(Ordering::SeqCst), 0); + let records = audit_records(&audit_path); + assert_eq!(records.len(), audit_count_before_bad_signature + 1); + let bad_signature_audit = &records[audit_count_before_bad_signature]; + assert_eq!( + bad_signature_audit["error_code"], + json!("federation.invalid_token") + ); + assert_federation_request_context_is_absent(bad_signature_audit); + assert!(!audit_record_contains_text( + bad_signature_audit, + "01J9Z6Q6Q6Q6Q6Q6Q6Q6Q6Q7Q7" + )); + assert!(!audit_record_contains_text(bad_signature_audit, "person-1")); let bad_alg = federation_jwt_with_header( json!({ @@ -2065,7 +2184,16 @@ async fn federation_denial_happens_before_source_read() { } #[tokio::test] -async fn federation_emergency_denylist_blocks_before_source_read() { +async fn federation_emergency_kid_denylist_blocks_before_source_read() { + assert_federation_emergency_denylist_blocks_before_source_read(true).await; +} + +#[tokio::test] +async fn federation_emergency_node_id_denylist_blocks_before_source_read() { + assert_federation_emergency_denylist_blocks_before_source_read(false).await; +} + +async fn assert_federation_emergency_denylist_blocks_before_source_read(deny_kid: bool) { set_federation_env(); let source_hits = Arc::new(AtomicUsize::new(0)); let source_hits_for_route = Arc::clone(&source_hits); @@ -2098,22 +2226,27 @@ async fn federation_emergency_denylist_blocks_before_source_read() { audit_path.to_str().expect("audit path is UTF-8"), &format!("{}/jwks", peer_jwks.url()), ); - config - .federation - .emergency_denylist - .kids - .push("registry-platform-testing-ed25519-1".to_string()); - config - .federation - .emergency_denylist - .node_ids - .push("did:web:agency-b.example.gov".to_string()); + if deny_kid { + config + .federation + .emergency_denylist + .kids + .push("registry-platform-testing-ed25519-1".to_string()); + } else { + config + .federation + .emergency_denylist + .node_ids + .push("did:web:agency-b.example.gov".to_string()); + } let app = standalone_router(config).expect("standalone router builds"); let server = TestServer::builder().http_transport().build(app); - let token = federation_request_jwt( - "01J9Z6Q6Q6Q6Q6Q6Q6Q6Q6Q7R0", - "https://purpose.example.test/eligibility", - ); + let request_jti = if deny_kid { + "01J9Z6Q6Q6Q6Q6Q6Q6Q6Q6Q7R0" + } else { + "01J9Z6Q6Q6Q6Q6Q6Q6Q6Q6Q7R1" + }; + let token = federation_request_jwt(request_jti, "https://purpose.example.test/eligibility"); let response = server .post("/federation/v1/evaluations") @@ -2123,6 +2256,16 @@ async fn federation_emergency_denylist_blocks_before_source_read() { response.assert_status(StatusCode::FORBIDDEN); assert_eq!(source_hits.load(Ordering::SeqCst), 0); + let records = audit_records(&audit_path); + let denied = audit_record_with( + &records, + "/federation/v1/evaluations", + "federated_evaluate_denied", + StatusCode::FORBIDDEN, + "federation.forbidden", + ); + assert_federation_request_context_is_absent(denied); + assert_audit_records_do_not_contain(&records, &["person-1", request_jti]); } #[tokio::test] @@ -2175,6 +2318,22 @@ async fn federation_request_claims_must_match_profile_before_source_read() { response.assert_status(StatusCode::FORBIDDEN); assert_eq!(source_hits.load(Ordering::SeqCst), 0); + let records = audit_records(&audit_path); + let denied = audit_record_with( + &records, + "/federation/v1/evaluations", + "federated_evaluate_denied", + StatusCode::FORBIDDEN, + "federation.forbidden", + ); + assert_verified_federation_audit_context( + denied, + "farmer_under_4ha", + "https://purpose.example.test/eligibility", + true, + ); + assert!(denied["claim_hash"].is_string()); + assert_audit_records_do_not_contain(&records, &["person-1", "01J9Z6Q6Q6Q6Q6Q6Q6Q6Q6Q6Q9"]); } #[tokio::test] @@ -2204,6 +2363,7 @@ async fn federation_stale_source_observation_returns_signed_evaluation_error() { audit_path.to_str().expect("audit path is UTF-8"), &format!("{}/jwks", peer_jwks.url()), ); + config.cel.eval_timeout_ms = 10_000; config.federation.evaluation_profiles[0].max_source_observed_age_seconds = Some(0); let app = standalone_router(config).expect("standalone router builds"); let server = TestServer::builder().http_transport().build(app); @@ -2238,6 +2398,13 @@ async fn federation_stale_source_observation_returns_signed_evaluation_error() { .as_str() .expect("subject ref hash is string") .starts_with("hmac-sha256:")); + assert_verified_federation_audit_context( + error, + "farmer_under_4ha", + "https://purpose.example.test/eligibility", + true, + ); + assert_audit_records_do_not_contain(&records, &["person-1", "01J9Z6Q6Q6Q6Q6Q6Q6Q6Q6Q6Q8"]); } #[tokio::test] diff --git a/release/notes/negative-path-coverage-map.md b/release/notes/negative-path-coverage-map.md index ee6279230..32a3e87bf 100644 --- a/release/notes/negative-path-coverage-map.md +++ b/release/notes/negative-path-coverage-map.md @@ -2,7 +2,7 @@ Issue: [#200](https://github.com/registrystack/registry-stack/issues/200) -Generated: 2026-07-09 +Generated: 2026-07-10 This is the public release-readiness map for negative-path coverage. It maps the internal checklist row identifiers to public evidence or disposition without @@ -219,20 +219,26 @@ public evidence or disposition. Tests assert stable problem responses, no unintended credential issuance, no credential material, redacted audit records, `source_read_count = 0`, and `forwarded = false` on credential denial paths. -- `NP-29`: Partial. +- `NP-29`: Covered. Public anchors: `crates/registry-notary-server/tests/standalone_http.rs::federation_evaluation_returns_signed_response_and_rejects_replay`, `crates/registry-notary-server/tests/standalone_http.rs::federation_auth_exempt_route_still_requires_valid_jws`, `crates/registry-notary-server/tests/standalone_http.rs::federation_denial_happens_before_source_read`, + `crates/registry-notary-server/tests/standalone_http.rs::federation_emergency_kid_denylist_blocks_before_source_read`, + `crates/registry-notary-server/tests/standalone_http.rs::federation_emergency_node_id_denylist_blocks_before_source_read`, + `crates/registry-notary-server/tests/standalone_http.rs::federation_request_claims_must_match_profile_before_source_read`, + `crates/registry-notary-server/tests/standalone_http.rs::federation_policy_context_satisfies_governed_source_matching`, `crates/registry-notary-server/tests/standalone_http.rs::federation_stale_source_observation_returns_signed_evaluation_error`, - and `crates/registry-notary-server/src/federation/audit.rs::federation_audit_event`. - Disposition: federation coverage already exercises disabled-route behavior, - invalid JWS denial, replay denial, no-source-read denials, and signed stale - source errors with audit redaction. The remaining blocker is complete - denied-audit context parity for post-verification federation denials; current - denied outcomes do not carry every peer/profile/purpose/JTI/subject hash field - that success and signed-error outcomes can carry, so this needs maintainer - decision or explicit deferral before release sign-off. + and `crates/registry-notary-server/src/federation/mod.rs::federation_response_signing_failure_emits_denial_audit_with_context`. + Disposition: pre-verification signature and emergency-denylist denials omit + untrusted request context. After signature verification, denial records retain + the configured issuer and the request profile and purpose, hash the peer id and + request JTI, and include a pairwise subject-reference hash when the locally + allowed profile and subject are structurally valid. Tests cover replay, + policy, claim-mismatch, unknown-key, denylisted-key, denylisted-node, and + signed stale-source outcomes; preserve no-source-read ordering; and assert + that raw subject ids and request JTIs do not reach audit records. + Response-signing failures retain the already assembled redacted context. ## Release Decision From e4002dc53ec0c584193b2be77a22edff504ad752 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 10 Jul 2026 18:21:07 +0700 Subject: [PATCH 2/2] fix(notary): retain federation scope audit context Signed-off-by: Jeremi Joslin --- .../src/federation/audit.rs | 6 +++++- .../registry-notary-server/src/federation/mod.rs | 15 ++++++++++----- .../src/federation/signing.rs | 2 ++ .../tests/standalone_http.rs | 9 +++++++++ release/notes/negative-path-coverage-map.md | 7 ++++--- 5 files changed, 30 insertions(+), 9 deletions(-) diff --git a/crates/registry-notary-server/src/federation/audit.rs b/crates/registry-notary-server/src/federation/audit.rs index 6625eea0e..de357e207 100644 --- a/crates/registry-notary-server/src/federation/audit.rs +++ b/crates/registry-notary-server/src/federation/audit.rs @@ -16,6 +16,7 @@ use super::errors::FederationProblem; #[derive(Clone, Debug, Default)] pub(super) struct FederationAuditContext { pub(super) claim_ids: Vec, + pub(super) scopes_used: Vec, pub(super) peer_node_id: Option, pub(super) issuer: Option, pub(super) profile: Option, @@ -27,6 +28,7 @@ pub(super) struct FederationAuditContext { impl FederationAuditContext { pub(super) fn from_verified(peer: &FederationPeerConfig, verified: &VerifiedToken) -> Self { Self { + scopes_used: peer.source_scopes.clone(), peer_node_id: Some(peer.node_id.clone()), issuer: Some(peer.issuer.clone()), profile: verified @@ -61,6 +63,7 @@ pub(super) struct FederationAuditOutcome { pub(super) decision: String, pub(super) verification_id: Option, pub(super) claim_ids: Vec, + pub(super) scopes_used: Vec, pub(super) error_code: Option, pub(super) peer_node_id: Option, pub(super) issuer: Option, @@ -83,6 +86,7 @@ impl FederationAuditOutcome { decision: "federated_evaluate_denied".to_string(), verification_id: None, claim_ids: context.claim_ids, + scopes_used: context.scopes_used, error_code: Some(problem.code.clone()), peer_node_id: context.peer_node_id, issuer: context.issuer, @@ -138,7 +142,7 @@ pub(super) fn federation_audit_event( event_id: Ulid::new().to_string(), occurred_at, principal_id_hash: None, - scopes_used: Vec::new(), + scopes_used: audit.scopes_used, decision: audit.decision, method: "POST".to_string(), path: "/federation/v1/evaluations".to_string(), diff --git a/crates/registry-notary-server/src/federation/mod.rs b/crates/registry-notary-server/src/federation/mod.rs index 5dee9a8a3..22f7dfd68 100644 --- a/crates/registry-notary-server/src/federation/mod.rs +++ b/crates/registry-notary-server/src/federation/mod.rs @@ -181,20 +181,20 @@ async fn handle_federated_evaluate( validate_federation_claims(&state.federation, &peer.config, &verified) .map_err(|problem| audit_context.denied(problem))?; let request_jti = string_extra(&verified, "jti") - .ok_or_else(FederationProblem::invalid_token)? + .ok_or_else(|| audit_context.denied(FederationProblem::invalid_token()))? .to_string(); let exp = verified .claims .exp - .ok_or_else(FederationProblem::invalid_token)?; + .ok_or_else(|| audit_context.denied(FederationProblem::invalid_token()))?; let protocol = string_extra(&verified, "protocol") - .ok_or_else(FederationProblem::invalid_request_owned)? + .ok_or_else(|| audit_context.denied(FederationProblem::invalid_request_owned()))? .to_string(); let profile_id = string_extra(&verified, "profile") - .ok_or_else(FederationProblem::invalid_request_owned)? + .ok_or_else(|| audit_context.denied(FederationProblem::invalid_request_owned()))? .to_string(); let purpose = string_extra(&verified, "purpose") - .ok_or_else(FederationProblem::invalid_request_owned)? + .ok_or_else(|| audit_context.denied(FederationProblem::invalid_request_owned()))? .to_string(); let replay_scope = ReplayScope::federation_request_jwt( &state.federation.node_id, @@ -499,6 +499,7 @@ mod tests { let peer = FederationPeerConfig { node_id: "did:web:agency-b.example.gov".to_string(), issuer: "https://agency-b.example.gov".to_string(), + source_scopes: vec!["farmer_registry:evidence_verification".to_string()], ..FederationPeerConfig::default() }; let profile = FederationEvaluationProfileConfig { @@ -549,6 +550,10 @@ mod tests { assert_eq!(record["status"], json!(500)); assert_eq!(record["error_code"], json!("federation.server_error")); assert!(record["claim_hash"].is_string()); + assert_eq!( + record["scopes_used"], + json!(["farmer_registry:evidence_verification"]) + ); assert!(record["federation_peer_id_hash"].is_string()); assert_eq!( record["federation_issuer"], diff --git a/crates/registry-notary-server/src/federation/signing.rs b/crates/registry-notary-server/src/federation/signing.rs index 8131a97fd..a939740c8 100644 --- a/crates/registry-notary-server/src/federation/signing.rs +++ b/crates/registry-notary-server/src/federation/signing.rs @@ -78,6 +78,7 @@ impl FederationSignedOutcome { decision: "federated_evaluate".to_string(), verification_id: Some(evaluation_id), claim_ids: vec![profile.claim_id.clone()], + scopes_used: peer.source_scopes.clone(), error_code: None, peer_node_id: Some(peer.node_id.clone()), issuer: Some(peer.issuer.clone()), @@ -122,6 +123,7 @@ impl FederationSignedOutcome { decision: "federated_evaluate_error".to_string(), verification_id: None, claim_ids: vec![profile.claim_id.clone()], + scopes_used: peer.source_scopes.clone(), error_code: Some("federation.stale_source_observation".to_string()), peer_node_id: Some(peer.node_id.clone()), issuer: Some(peer.issuer.clone()), diff --git a/crates/registry-notary-server/tests/standalone_http.rs b/crates/registry-notary-server/tests/standalone_http.rs index 426ed972e..d774c77e3 100644 --- a/crates/registry-notary-server/tests/standalone_http.rs +++ b/crates/registry-notary-server/tests/standalone_http.rs @@ -1516,6 +1516,10 @@ fn assert_verified_federation_audit_context( purpose: &str, includes_subject_hash: bool, ) { + assert_eq!( + record["scopes_used"], + json!(["farmer_registry:evidence_verification"]) + ); assert_hmac_audit_field(record, "federation_peer_id_hash"); assert_eq!( record["federation_issuer"], @@ -1532,6 +1536,7 @@ fn assert_verified_federation_audit_context( } fn assert_federation_request_context_is_absent(record: &Value) { + assert_eq!(record["scopes_used"], json!([])); for field in [ "federation_peer_id_hash", "federation_issuer", @@ -1683,6 +1688,10 @@ async fn federation_evaluation_returns_signed_response_and_rejects_replay() { json!("https://agency-b.example.gov") ); assert_eq!(allowed["federation_profile"], json!("farmer_under_4ha")); + assert_eq!( + allowed["scopes_used"], + json!(["farmer_registry:evidence_verification"]) + ); assert_eq!( allowed["federation_purpose"], json!("https://purpose.example.test/eligibility") diff --git a/release/notes/negative-path-coverage-map.md b/release/notes/negative-path-coverage-map.md index 32a3e87bf..b4a4ee62d 100644 --- a/release/notes/negative-path-coverage-map.md +++ b/release/notes/negative-path-coverage-map.md @@ -232,9 +232,10 @@ public evidence or disposition. and `crates/registry-notary-server/src/federation/mod.rs::federation_response_signing_failure_emits_denial_audit_with_context`. Disposition: pre-verification signature and emergency-denylist denials omit untrusted request context. After signature verification, denial records retain - the configured issuer and the request profile and purpose, hash the peer id and - request JTI, and include a pairwise subject-reference hash when the locally - allowed profile and subject are structurally valid. Tests cover replay, + the configured issuer and peer source scopes plus the request profile and + purpose, hash the peer id and request JTI, and include a pairwise + subject-reference hash when the locally allowed profile and subject are + structurally valid. Tests cover replay, policy, claim-mismatch, unknown-key, denylisted-key, denylisted-node, and signed stale-source outcomes; preserve no-source-read ordering; and assert that raw subject ids and request JTIs do not reach audit records.