From d79e5a6af50ce98b8d9921136aee95d554540adf Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 10 Jul 2026 12:52:21 +0700 Subject: [PATCH 1/3] fix(relay): scope-gate PDP context headers Signed-off-by: Jeremi Joslin --- crates/registry-relay/CHANGELOG.md | 5 + crates/registry-relay/docs/api.md | 25 ++++ crates/registry-relay/src/api/governed.rs | 122 +++++++++++++++++- .../src/content/docs/spec/rs-pr-relay.mdx | 5 +- 4 files changed, 150 insertions(+), 7 deletions(-) diff --git a/crates/registry-relay/CHANGELOG.md b/crates/registry-relay/CHANGELOG.md index 84db5c5c..182e4331 100644 --- a/crates/registry-relay/CHANGELOG.md +++ b/crates/registry-relay/CHANGELOG.md @@ -76,6 +76,11 @@ `/ready` is always excluded because appending a readiness audit record after its zero-backlog comparison would invalidate the next readiness probe. +- BREAKING: Governed reads now ignore `x-registry-subject-ref`, + `x-registry-relationship`, `x-registry-on-behalf-of`, and + `x-registry-credential-format` unless the authenticated principal has the + exact `registry:trust::` scope. These optional trust-context + fields are now scope-gated before policy evaluation. - BREAKING: Removed Relay-local credential issuance before 1.0. Relay no longer accepts `provenance` or entity `publicschema` config, no longer serves `/.well-known/did.json`, `/schemas/{claim_type}/{version}`, or diff --git a/crates/registry-relay/docs/api.md b/crates/registry-relay/docs/api.md index 29c5e55f..10af8386 100644 --- a/crates/registry-relay/docs/api.md +++ b/crates/registry-relay/docs/api.md @@ -163,6 +163,31 @@ Data-Purpose: https://data.example.gov/purposes/service-intake-check Use stable, reviewable purpose IRIs. Do not put secrets, bearer tokens, or personal data in this header; it is recorded in audit logs. +## Governed request context + +Registry Relay treats client-supplied Policy Decision Point (PDP) context as +untrusted. Relay passes each header in this table to the PDP only when the +authenticated principal has the exact +`registry:trust::` scope. + +| Header | Scope field | Classification | +| --- | --- | --- | +| `x-registry-subject-ref` | `subject_ref` | Scope-gated | +| `x-registry-relationship` | `relationship` | Scope-gated | +| `x-registry-on-behalf-of` | `on_behalf_of` | Scope-gated | +| `x-registry-credential-format` | `requested_credential_format` | Scope-gated | +| `x-registry-source-observed-at-unix-seconds` | `source_observed_at_unix_seconds` | Scope-gated | + +An absent or nonmatching scope makes the header absent from PDP context. A +policy that requires the field or matches its value then denies the request. +`Data-Purpose` remains a caller-stated purpose, not proof of identity, +delegation, legal basis, consent, or source freshness. + +Policy authors must not make a Permit decision depend on unauthenticated +request context. Use only server-derived context or values authenticated by an +exact-value trust scope. An adapter that adds another client-supplied +trust-context field must apply the same guard before the PDP evaluates it. + ## Metadata, catalog, and OpenAPI `GET /metadata/catalog` and `GET /metadata/dcat/bregdcat-ap` return only datasets visible to the authenticated principal's metadata scopes. diff --git a/crates/registry-relay/src/api/governed.rs b/crates/registry-relay/src/api/governed.rs index 8715020d..63d7ab78 100644 --- a/crates/registry-relay/src/api/governed.rs +++ b/crates/registry-relay/src/api/governed.rs @@ -26,6 +26,10 @@ pub(crate) const TRUST_JURISDICTION_HEADER: &str = "x-registry-trust-jurisdictio pub(crate) const TRUST_ASSURANCE_HEADER: &str = "x-registry-trust-assurance"; pub(crate) const TRUST_LEGAL_BASIS_HEADER: &str = "x-registry-trust-legal-basis"; pub(crate) const TRUST_CONSENT_HEADER: &str = "x-registry-trust-consent"; +const TRUST_SUBJECT_REF_HEADER: &str = "x-registry-subject-ref"; +const TRUST_RELATIONSHIP_HEADER: &str = "x-registry-relationship"; +const TRUST_ON_BEHALF_OF_HEADER: &str = "x-registry-on-behalf-of"; +const TRUST_REQUESTED_CREDENTIAL_FORMAT_HEADER: &str = "x-registry-credential-format"; const TRUST_SOURCE_OBSERVED_AT_UNIX_SECONDS_HEADER: &str = "x-registry-source-observed-at-unix-seconds"; pub(crate) const TRUST_SOURCE_OBSERVED_AGE_SECONDS_HEADER: &str = @@ -294,13 +298,36 @@ fn request_pdp_context( ) .map(ToOwned::to_owned), requester_identity: principal.map(|principal| principal.principal_id.clone()), - subject_ref: trust_header_value(headers, "x-registry-subject-ref").map(ToOwned::to_owned), - relationship: trust_header_value(headers, "x-registry-relationship").map(ToOwned::to_owned), - on_behalf_of: trust_header_value(headers, "x-registry-on-behalf-of").map(ToOwned::to_owned), + subject_ref: verified_trust_header_value( + headers, + principal, + TRUST_SUBJECT_REF_HEADER, + "subject_ref", + ) + .map(ToOwned::to_owned), + relationship: verified_trust_header_value( + headers, + principal, + TRUST_RELATIONSHIP_HEADER, + "relationship", + ) + .map(ToOwned::to_owned), + on_behalf_of: verified_trust_header_value( + headers, + principal, + TRUST_ON_BEHALF_OF_HEADER, + "on_behalf_of", + ) + .map(ToOwned::to_owned), requested_fact: Some(requested_fact.to_string()), requested_disclosure: Some(request_info.requested_disclosure.to_string()), - requested_credential_format: trust_header_value(headers, "x-registry-credential-format") - .map(ToOwned::to_owned), + requested_credential_format: verified_trust_header_value( + headers, + principal, + TRUST_REQUESTED_CREDENTIAL_FORMAT_HEADER, + "requested_credential_format", + ) + .map(ToOwned::to_owned), source_binding: Some(source_binding.to_string()), route_identity: Some(request_info.route_identity.to_string()), checked_scopes: principal @@ -764,6 +791,7 @@ fn hex_lower(bytes: &[u8]) -> String { mod tests { use super::*; use crate::auth::{AuthMode, ScopeSet}; + use axum::http::HeaderValue; use registry_manifest_core::OdrlEnforcementProfile; fn config_with_selector() -> Config { @@ -1012,4 +1040,88 @@ datasets: [] BTreeSet::from(["social_registry:rows".to_string()]) ); } + + fn policy_input_context(scopes: &[&str]) -> PdpRequestContext { + let principal = Principal { + principal_id: "client-a".to_string(), + scopes: scopes.iter().copied().collect::(), + auth_mode: AuthMode::ApiKey, + }; + let mut headers = HeaderMap::new(); + headers.insert( + TRUST_SUBJECT_REF_HEADER, + HeaderValue::from_static("subject:123"), + ); + headers.insert( + TRUST_RELATIONSHIP_HEADER, + HeaderValue::from_static("guardian"), + ); + headers.insert( + TRUST_ON_BEHALF_OF_HEADER, + HeaderValue::from_static("agency:benefits"), + ); + headers.insert( + TRUST_REQUESTED_CREDENTIAL_FORMAT_HEADER, + HeaderValue::from_static("sd_jwt_vc"), + ); + headers.insert( + TRUST_SOURCE_OBSERVED_AT_UNIX_SECONDS_HEADER, + HeaderValue::from_static("9999999999"), + ); + let request_info = GovernedRequestInfo { + route_identity: "relay.entity.collection", + requested_disclosure: "entity_collection", + checked_scope: "social_registry:rows", + redaction_projection: GovernedRedactionProjection::EntityFields, + }; + + request_pdp_context( + "testing", + &headers, + Some(&principal), + "individual", + "relay:social_registry:individuals_table", + &request_info, + ) + .expect("PDP context builds") + } + + #[test] + fn request_pdp_context_ignores_policy_inputs_without_exact_value_scopes() { + let context = policy_input_context(&[ + "social_registry:rows", + "registry:trust:subject_ref:subject:other", + "registry:trust:relationship:self", + "registry:trust:on_behalf_of:agency:other", + "registry:trust:requested_credential_format:jwt_vc_json", + "registry:trust:source_observed_at_unix_seconds:1", + ]); + + assert_eq!(context.subject_ref, None); + assert_eq!(context.relationship, None); + assert_eq!(context.on_behalf_of, None); + assert_eq!(context.requested_credential_format, None); + assert_eq!(context.source_observed_at_unix_seconds, None); + } + + #[test] + fn request_pdp_context_accepts_exact_value_scoped_policy_inputs() { + let context = policy_input_context(&[ + "social_registry:rows", + "registry:trust:subject_ref:subject:123", + "registry:trust:relationship:guardian", + "registry:trust:on_behalf_of:agency:benefits", + "registry:trust:requested_credential_format:sd_jwt_vc", + "registry:trust:source_observed_at_unix_seconds:9999999999", + ]); + + assert_eq!(context.subject_ref.as_deref(), Some("subject:123")); + assert_eq!(context.relationship.as_deref(), Some("guardian")); + assert_eq!(context.on_behalf_of.as_deref(), Some("agency:benefits")); + assert_eq!( + context.requested_credential_format.as_deref(), + Some("sd_jwt_vc") + ); + assert_eq!(context.source_observed_at_unix_seconds, Some(9_999_999_999)); + } } diff --git a/docs/site/src/content/docs/spec/rs-pr-relay.mdx b/docs/site/src/content/docs/spec/rs-pr-relay.mdx index aa8acccc..2baa7e12 100644 --- a/docs/site/src/content/docs/spec/rs-pr-relay.mdx +++ b/docs/site/src/content/docs/spec/rs-pr-relay.mdx @@ -49,6 +49,7 @@ The key words in this document are interpreted per [RS-DOC](../rs-doc/) Section | 0.2.1 | 2026-07-07 | draft | Documented additional request-context headers alongside REQ-PR-RELAY-020, noting they are not currently wired to any Relay policy decision. | | 0.2.2 | 2026-07-09 | draft | Clarified that `x-registry-source-observed-at-unix-seconds` is scope guarded trust context, leaving four additional request-context headers outside Relay policy decisions. | | 0.3.0 | 2026-07-09 | draft | Removed Relay-local signed response credential requirements and made Registry Notary the only credential issuance surface. | +| 0.3.1 | 2026-07-10 | draft | Scope-gated the remaining client-supplied trust-context headers and documented the policy-authoring boundary. | ## 1. Scope and references @@ -110,9 +111,9 @@ Governed Evidence Gateway policy enforcement is an additional runtime decision, REQ-PR-RELAY-019: A governed Relay read MUST receive a PDP permit before returning entity-backed data or aggregate data. If the PDP denies the request, Relay MUST fail closed and return a stable `pdp.*` problem code rather than falling back to an ungoverned read. The stable denial codes include `pdp.purpose_not_permitted`, `pdp.assurance_insufficient`, `pdp.evidence_stale`, `pdp.legal_basis_required`, `pdp.consent_required`, `pdp.jurisdiction_not_permitted`, `pdp.unsupported_policy_term`, `pdp.policy_id_required`, and `pdp.policy_hash_invalid`. -REQ-PR-RELAY-020: Relay MUST ignore request trust-context headers unless the authenticated principal is scoped to assert that exact trust value. The supported trust-context headers are `x-registry-trust-jurisdiction`, `x-registry-trust-assurance`, `x-registry-trust-legal-basis`, `x-registry-trust-consent`, `x-registry-source-observed-age-seconds`, and `x-registry-source-observed-at-unix-seconds`. Unscoped or malformed trust metadata MUST NOT satisfy the PDP gate. +REQ-PR-RELAY-020: Relay MUST ignore request trust-context headers unless the authenticated principal is scoped to assert that exact trust value. The supported trust-context headers are `x-registry-trust-jurisdiction`, `x-registry-trust-assurance`, `x-registry-trust-legal-basis`, `x-registry-trust-consent`, `x-registry-subject-ref`, `x-registry-relationship`, `x-registry-on-behalf-of`, `x-registry-credential-format`, `x-registry-source-observed-age-seconds`, and `x-registry-source-observed-at-unix-seconds`. Unscoped or malformed trust metadata MUST NOT satisfy the PDP gate. -Relay also parses four further request-context headers without a scope guard: `x-registry-subject-ref`, `x-registry-relationship`, `x-registry-on-behalf-of`, and `x-registry-credential-format`. These populate the request context for the PDP, but none of them are currently wired to any Relay policy decision. +Policy authors MUST NOT treat client-supplied context as trust evidence unless Relay derives it from authenticated state or accepts it through the exact-value scope guard defined by REQ-PR-RELAY-020. `Data-Purpose` expresses the caller's requested use; it is not proof of identity, delegation, legal basis, consent, or source freshness. REQ-PR-RELAY-021: Aggregate routes MUST perform ordinary aggregate and source-read scope authorization before invoking governed PDP enforcement. A scope denial MUST return `auth.scope_denied` and MUST NOT report PDP policy provenance. For aggregate execution, the PDP checked-scope value MUST be the source entity read scope unless the aggregate is configured for aggregate-only execution, in which case it MUST be the aggregate scope. From 37f4cef54f9cb27046e182934c34458f38bd73b8 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 10 Jul 2026 14:01:02 +0700 Subject: [PATCH 2/3] fix(relay): audit scoped PDP context Signed-off-by: Jeremi Joslin --- crates/registry-relay/CHANGELOG.md | 4 +- crates/registry-relay/docs/api.md | 11 +- crates/registry-relay/src/api/governed.rs | 253 +++++++++++++++++++--- 3 files changed, 232 insertions(+), 36 deletions(-) diff --git a/crates/registry-relay/CHANGELOG.md b/crates/registry-relay/CHANGELOG.md index 182e4331..522c33fc 100644 --- a/crates/registry-relay/CHANGELOG.md +++ b/crates/registry-relay/CHANGELOG.md @@ -80,7 +80,9 @@ `x-registry-relationship`, `x-registry-on-behalf-of`, and `x-registry-credential-format` unless the authenticated principal has the exact `registry:trust::` scope. These optional trust-context - fields are now scope-gated before policy evaluation. + fields are now scope-gated before policy evaluation. PDP decision audits + record the presence of these authenticated inputs without recording their + values. - BREAKING: Removed Relay-local credential issuance before 1.0. Relay no longer accepts `provenance` or entity `publicschema` config, no longer serves `/.well-known/did.json`, `/schemas/{claim_type}/{version}`, or diff --git a/crates/registry-relay/docs/api.md b/crates/registry-relay/docs/api.md index 10af8386..a25a9a26 100644 --- a/crates/registry-relay/docs/api.md +++ b/crates/registry-relay/docs/api.md @@ -166,16 +166,21 @@ Use stable, reviewable purpose IRIs. Do not put secrets, bearer tokens, or perso ## Governed request context Registry Relay treats client-supplied Policy Decision Point (PDP) context as -untrusted. Relay passes each header in this table to the PDP only when the -authenticated principal has the exact -`registry:trust::` scope. +untrusted. The supported client-supplied trust-context headers are listed +below. Relay passes each one to the PDP only when the authenticated principal +has the exact `registry:trust::` scope. | Header | Scope field | Classification | | --- | --- | --- | +| `x-registry-trust-jurisdiction` | `jurisdiction` | Scope-gated | +| `x-registry-trust-assurance` | `assurance` | Scope-gated | +| `x-registry-trust-legal-basis` | `legal_basis` | Scope-gated | +| `x-registry-trust-consent` | `consent` | Scope-gated | | `x-registry-subject-ref` | `subject_ref` | Scope-gated | | `x-registry-relationship` | `relationship` | Scope-gated | | `x-registry-on-behalf-of` | `on_behalf_of` | Scope-gated | | `x-registry-credential-format` | `requested_credential_format` | Scope-gated | +| `x-registry-source-observed-age-seconds` | `source_observed_age_seconds` | Scope-gated | | `x-registry-source-observed-at-unix-seconds` | `source_observed_at_unix_seconds` | Scope-gated | An absent or nonmatching scope makes the header absent from PDP context. A diff --git a/crates/registry-relay/src/api/governed.rs b/crates/registry-relay/src/api/governed.rs index 63d7ab78..197bfaea 100644 --- a/crates/registry-relay/src/api/governed.rs +++ b/crates/registry-relay/src/api/governed.rs @@ -235,7 +235,7 @@ pub(crate) fn require_governed_read_access( .map(|policy| policy.unsupported_odrl_terms) .unwrap_or_default(), }; - match pdp_decide(&context, &policy) { + match relay_pdp_decide(&context, &policy) { PdpDecision::Permit(audit) => Ok(GovernedReadDecision { audit: Some(audit), redaction_fields: BTreeSet::new(), @@ -258,6 +258,31 @@ pub(crate) fn require_governed_read_access( } } +fn relay_pdp_decide(context: &PdpRequestContext, policy: &PdpPolicyInput) -> PdpDecision { + let mut decision = pdp_decide(context, policy); + // Keep this enrichment at the Relay boundary. Other platform PDP callers + // do not share Relay's exact-value scope semantics for these fields. + let audit = match &mut decision { + PdpDecision::Permit(audit) + | PdpDecision::PermitWithRedaction { audit, .. } + | PdpDecision::Deny { audit, .. } => audit, + }; + audit.trust_provenance.extend( + [ + ("subject_ref", context.subject_ref.as_ref()), + ("relationship", context.relationship.as_ref()), + ("on_behalf_of", context.on_behalf_of.as_ref()), + ( + "requested_credential_format", + context.requested_credential_format.as_ref(), + ), + ] + .into_iter() + .filter_map(|(field, value)| value.map(|_| field.to_string())), + ); + decision +} + #[allow(clippy::result_large_err)] fn request_pdp_context( purpose: &str, @@ -793,6 +818,7 @@ mod tests { use crate::auth::{AuthMode, ScopeSet}; use axum::http::HeaderValue; use registry_manifest_core::OdrlEnforcementProfile; + use registry_platform_pdp::RequiredContextField; fn config_with_selector() -> Config { serde_saphyr::from_str( @@ -1086,42 +1112,205 @@ datasets: [] .expect("PDP context builds") } + fn policy_with_purpose(purpose: &str) -> PdpPolicyInput { + serde_json::from_value(serde_json::json!({ + "policy_id": "relay.scope-gated-context-test", + "policy_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "purpose_constraints": [[purpose]] + })) + .expect("scope-gated context policy parses") + } + + #[derive(Clone, Copy, Debug)] + enum ScopeGatedPolicyField { + SubjectRef, + Relationship, + OnBehalfOf, + RequestedCredentialFormat, + SourceObservedAt, + } + + impl ScopeGatedPolicyField { + fn exact_scope(self) -> &'static str { + match self { + Self::SubjectRef => "registry:trust:subject_ref:subject:123", + Self::Relationship => "registry:trust:relationship:guardian", + Self::OnBehalfOf => "registry:trust:on_behalf_of:agency:benefits", + Self::RequestedCredentialFormat => { + "registry:trust:requested_credential_format:sd_jwt_vc" + } + Self::SourceObservedAt => { + "registry:trust:source_observed_at_unix_seconds:9999999999" + } + } + } + + fn mismatched_scope(self) -> &'static str { + match self { + Self::SubjectRef => "registry:trust:subject_ref:subject:other", + Self::Relationship => "registry:trust:relationship:self", + Self::OnBehalfOf => "registry:trust:on_behalf_of:agency:other", + Self::RequestedCredentialFormat => { + "registry:trust:requested_credential_format:jwt_vc_json" + } + Self::SourceObservedAt => "registry:trust:source_observed_at_unix_seconds:1", + } + } + + fn policy(self) -> PdpPolicyInput { + let mut policy = policy_with_purpose("testing"); + match self { + Self::SubjectRef => { + policy + .required_context + .insert(RequiredContextField::SubjectRef); + } + Self::Relationship => { + policy.allowed_relationships = vec!["guardian".to_string()]; + } + Self::OnBehalfOf => { + policy + .required_context + .insert(RequiredContextField::OnBehalfOf); + } + Self::RequestedCredentialFormat => { + policy.allowed_credential_formats = vec!["sd_jwt_vc".to_string()]; + } + Self::SourceObservedAt => { + policy + .required_context + .insert(RequiredContextField::SourceFreshness); + } + } + policy + } + + fn denied_code(self) -> &'static str { + match self { + Self::SubjectRef | Self::OnBehalfOf | Self::SourceObservedAt => { + registry_platform_pdp::CONTEXT_REQUIRED + } + Self::Relationship => registry_platform_pdp::RELATIONSHIP_NOT_PERMITTED, + Self::RequestedCredentialFormat => { + registry_platform_pdp::CREDENTIAL_FORMAT_NOT_PERMITTED + } + } + } + } + + fn assert_policy_denied( + field: ScopeGatedPolicyField, + context: &PdpRequestContext, + policy: &PdpPolicyInput, + ) { + match relay_pdp_decide(context, policy) { + PdpDecision::Deny { + stable_problem_code, + .. + } => assert_eq!(stable_problem_code, field.denied_code(), "{field:?}"), + decision => panic!("{field:?} should deny, got {decision:?}"), + } + } + #[test] - fn request_pdp_context_ignores_policy_inputs_without_exact_value_scopes() { - let context = policy_input_context(&[ - "social_registry:rows", - "registry:trust:subject_ref:subject:other", - "registry:trust:relationship:self", - "registry:trust:on_behalf_of:agency:other", - "registry:trust:requested_credential_format:jwt_vc_json", - "registry:trust:source_observed_at_unix_seconds:1", - ]); + fn scope_gated_policy_inputs_require_the_exact_value_scope_to_permit() { + for field in [ + ScopeGatedPolicyField::SubjectRef, + ScopeGatedPolicyField::Relationship, + ScopeGatedPolicyField::OnBehalfOf, + ScopeGatedPolicyField::RequestedCredentialFormat, + ScopeGatedPolicyField::SourceObservedAt, + ] { + let policy = field.policy(); + + let absent_scope_context = policy_input_context(&["social_registry:rows"]); + assert_policy_denied(field, &absent_scope_context, &policy); + + let mismatched_scope_context = + policy_input_context(&["social_registry:rows", field.mismatched_scope()]); + assert_policy_denied(field, &mismatched_scope_context, &policy); + + let exact_scope_context = + policy_input_context(&["social_registry:rows", field.exact_scope()]); + assert!( + matches!( + relay_pdp_decide(&exact_scope_context, &policy), + PdpDecision::Permit(_) + ), + "{field:?} should permit with its exact value scope" + ); + } + } - assert_eq!(context.subject_ref, None); - assert_eq!(context.relationship, None); - assert_eq!(context.on_behalf_of, None); - assert_eq!(context.requested_credential_format, None); - assert_eq!(context.source_observed_at_unix_seconds, None); + fn assert_relay_only_provenance(expected_permit: bool) { + let cases: &[(&str, &[&str], bool)] = &[ + ("unscoped", &["social_registry:rows"], false), + ( + "mismatched", + &[ + "social_registry:rows", + "registry:trust:subject_ref:subject:other", + "registry:trust:relationship:self", + "registry:trust:on_behalf_of:agency:other", + "registry:trust:requested_credential_format:jwt_vc_json", + ], + false, + ), + ( + "exact-scoped", + &[ + "social_registry:rows", + "registry:trust:subject_ref:subject:123", + "registry:trust:relationship:guardian", + "registry:trust:on_behalf_of:agency:benefits", + "registry:trust:requested_credential_format:sd_jwt_vc", + ], + true, + ), + ]; + let policy = policy_with_purpose(if expected_permit { + "testing" + } else { + "other-purpose" + }); + + for (label, scopes, expected_provenance) in cases { + let context = policy_input_context(scopes); + let decision = relay_pdp_decide(&context, &policy); + let audit = match (expected_permit, decision) { + (true, PdpDecision::Permit(audit)) => audit, + (false, PdpDecision::Deny { audit, .. }) => audit, + (_, decision) => panic!("{label} produced an unexpected decision: {decision:?}"), + }; + let expected = if *expected_provenance { + BTreeSet::from([ + "on_behalf_of".to_string(), + "relationship".to_string(), + "requested_credential_format".to_string(), + "subject_ref".to_string(), + ]) + } else { + BTreeSet::new() + }; + assert_eq!(audit.trust_provenance, expected, "{label}"); + + let serialized = serde_json::to_string(&audit).expect("decision audit serializes"); + for raw_value in ["subject:123", "guardian", "agency:benefits", "sd_jwt_vc"] { + assert!( + !serialized.contains(raw_value), + "{label} audit must not include raw context value {raw_value}" + ); + } + } } #[test] - fn request_pdp_context_accepts_exact_value_scoped_policy_inputs() { - let context = policy_input_context(&[ - "social_registry:rows", - "registry:trust:subject_ref:subject:123", - "registry:trust:relationship:guardian", - "registry:trust:on_behalf_of:agency:benefits", - "registry:trust:requested_credential_format:sd_jwt_vc", - "registry:trust:source_observed_at_unix_seconds:9999999999", - ]); + fn relay_permit_audit_records_only_exact_scoped_context_field_names() { + assert_relay_only_provenance(true); + } - assert_eq!(context.subject_ref.as_deref(), Some("subject:123")); - assert_eq!(context.relationship.as_deref(), Some("guardian")); - assert_eq!(context.on_behalf_of.as_deref(), Some("agency:benefits")); - assert_eq!( - context.requested_credential_format.as_deref(), - Some("sd_jwt_vc") - ); - assert_eq!(context.source_observed_at_unix_seconds, Some(9_999_999_999)); + #[test] + fn relay_deny_audit_records_only_exact_scoped_context_field_names() { + assert_relay_only_provenance(false); } } From 3b32b28238d80b386a87ee0f5e827d03d7132395 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 10 Jul 2026 19:00:46 +0700 Subject: [PATCH 3/3] fix(relay): pseudonymize exact trust scopes in audit Signed-off-by: Jeremi Joslin --- crates/registry-relay/CHANGELOG.md | 9 +- crates/registry-relay/docs/api.md | 8 + crates/registry-relay/src/api/aggregates.rs | 6 +- .../src/api/attribute_release.rs | 10 +- crates/registry-relay/src/api/entity.rs | 8 +- crates/registry-relay/src/api/governed.rs | 140 +++++++++++--- crates/registry-relay/src/api/ogc/edr.rs | 6 +- crates/registry-relay/src/api/ogc/features.rs | 6 +- crates/registry-relay/src/api/spdci.rs | 71 +++++-- crates/registry-relay/src/audit/mod.rs | 77 +++++++- crates/registry-relay/src/auth/scopes.rs | 172 ++++++++++++++++- crates/registry-relay/tests/audit_record.rs | 176 ++++++++++++++++++ crates/registry-relay/tests/entity_routes.rs | 102 +++++++++- .../content/docs/explanation/threat-model.mdx | 10 +- .../src/content/docs/spec/rs-pr-relay.mdx | 4 +- 15 files changed, 726 insertions(+), 79 deletions(-) diff --git a/crates/registry-relay/CHANGELOG.md b/crates/registry-relay/CHANGELOG.md index 522c33fc..3d08b65e 100644 --- a/crates/registry-relay/CHANGELOG.md +++ b/crates/registry-relay/CHANGELOG.md @@ -80,9 +80,12 @@ `x-registry-relationship`, `x-registry-on-behalf-of`, and `x-registry-credential-format` unless the authenticated principal has the exact `registry:trust::` scope. These optional trust-context - fields are now scope-gated before policy evaluation. PDP decision audits - record the presence of these authenticated inputs without recording their - values. + fields are now scope-gated before policy evaluation. Audit records retain + ordinary route scopes in `scopes_used`, replace each value-bearing trust + scope with a field-bound + `registry:trust::hmac-sha256:` handle under the deployment + audit key, and record authenticated trust-context field names in + `pdp_trust_provenance` without their values. - BREAKING: Removed Relay-local credential issuance before 1.0. Relay no longer accepts `provenance` or entity `publicschema` config, no longer serves `/.well-known/did.json`, `/schemas/{claim_type}/{version}`, or diff --git a/crates/registry-relay/docs/api.md b/crates/registry-relay/docs/api.md index a25a9a26..95034ac3 100644 --- a/crates/registry-relay/docs/api.md +++ b/crates/registry-relay/docs/api.md @@ -183,6 +183,14 @@ has the exact `registry:trust::` scope. | `x-registry-source-observed-age-seconds` | `source_observed_age_seconds` | Scope-gated | | `x-registry-source-observed-at-unix-seconds` | `source_observed_at_unix_seconds` | Scope-gated | +Audit records retain ordinary route scopes unchanged in `scopes_used`. Each +value-bearing trust scope is replaced by its canonical field-bound form, +`registry:trust::hmac-sha256:`, computed under the deployment +audit key. This preserves pseudonymous authorization evidence without storing +the raw value. `pdp_trust_provenance` separately records authenticated field +names without their values, including a malformed authenticated freshness +field when parsing denies the request before PDP evaluation. + An absent or nonmatching scope makes the header absent from PDP context. A policy that requires the field or matches its value then denies the request. `Data-Purpose` remains a caller-stated purpose, not proof of identity, diff --git a/crates/registry-relay/src/api/aggregates.rs b/crates/registry-relay/src/api/aggregates.rs index eda3a31b..d2fee8c0 100644 --- a/crates/registry-relay/src/api/aggregates.rs +++ b/crates/registry-relay/src/api/aggregates.rs @@ -18,8 +18,8 @@ use serde_json::{json, Value}; use tokio::sync::watch; use crate::api::governed::{ - attach_pdp_audit, require_governed_read_access, GovernedAccessError, GovernedReadDecision, - GovernedRedactionProjection, GovernedRequestInfo, + attach_governed_error_audit, attach_pdp_audit, require_governed_read_access, + GovernedAccessError, GovernedReadDecision, GovernedRedactionProjection, GovernedRequestInfo, }; use crate::audit::{AuditContextExt, ErrorCodeExt}; use crate::auth::scopes::require_scope; @@ -695,7 +695,7 @@ fn aggregate_access_error_response( aggregate_id: Some(path.aggregate_id.clone()), ..AuditContextExt::default() }); - attach_pdp_audit(&mut audit_context, error.pdp_audit.as_ref()); + attach_governed_error_audit(&mut audit_context, &error); let mut response = error.error.into_response(); if let Some(context) = audit_context { response.extensions_mut().insert(context); diff --git a/crates/registry-relay/src/api/attribute_release.rs b/crates/registry-relay/src/api/attribute_release.rs index 93b18f38..26d92b89 100644 --- a/crates/registry-relay/src/api/attribute_release.rs +++ b/crates/registry-relay/src/api/attribute_release.rs @@ -34,8 +34,9 @@ use time::format_description::well_known::Rfc3339; use time::OffsetDateTime; use crate::api::governed::{ - attach_pdp_audit, purpose_header_value, require_governed_read_access, GovernedAccessError, - GovernedRedactionProjection, GovernedRequestInfo, + attach_pdp_audit, attach_pdp_trust_provenance, purpose_header_value, + require_governed_read_access, GovernedAccessError, GovernedRedactionProjection, + GovernedRequestInfo, }; use crate::attribute_release::AttributeReleaseEvaluator; use crate::audit::AuditContextExt; @@ -137,6 +138,7 @@ async fn resolve( cardinality_outcome: Some("one".to_string()), availability_class: Some("available".to_string()), pdp_audit: success.pdp_audit, + pdp_trust_provenance: BTreeSet::new(), }, ); with_release_cache_headers(response, success_max_age) @@ -173,6 +175,7 @@ struct ResolveAudit { cardinality_outcome: Option, availability_class: Option, pdp_audit: Option, + pdp_trust_provenance: BTreeSet, } /// Error carrying the audit context accumulated up to the point of failure, so @@ -205,6 +208,7 @@ impl ResolveRunError { cardinality_outcome, availability_class, pdp_audit, + pdp_trust_provenance: BTreeSet::new(), }, } } @@ -225,6 +229,7 @@ impl From for ResolveRunError { error: error.error, audit: ResolveAudit { pdp_audit: error.pdp_audit, + pdp_trust_provenance: error.pdp_trust_provenance, ..ResolveAudit::default() }, } @@ -844,6 +849,7 @@ fn with_audit_context(mut response: Response, route: &RouteState, audit: Resolve ..AuditContextExt::default() }); attach_pdp_audit(&mut context, audit.pdp_audit.as_ref()); + attach_pdp_trust_provenance(&mut context, &audit.pdp_trust_provenance); if let Some(context) = context { response.extensions_mut().insert(context); } diff --git a/crates/registry-relay/src/api/entity.rs b/crates/registry-relay/src/api/entity.rs index cfe66dd6..67f3d765 100644 --- a/crates/registry-relay/src/api/entity.rs +++ b/crates/registry-relay/src/api/entity.rs @@ -19,9 +19,9 @@ use serde_json::{json, Value}; use tokio::sync::watch; use crate::api::governed::{ - attach_pdp_audit, entity_etag as governed_entity_etag, governed_cache_variant, - require_governed_read_access, strong_etag as governed_strong_etag, GovernedAccessError, - GovernedReadDecision, GovernedRedactionProjection, GovernedRequestInfo, + attach_governed_error_audit, attach_pdp_audit, entity_etag as governed_entity_etag, + governed_cache_variant, require_governed_read_access, strong_etag as governed_strong_etag, + GovernedAccessError, GovernedReadDecision, GovernedRedactionProjection, GovernedRequestInfo, }; use crate::audit::{AuditContextExt, ErrorCodeExt}; use crate::auth::scopes::require_scope; @@ -1047,7 +1047,7 @@ fn access_error_response( error: GovernedAccessError, mut context: Option, ) -> Response { - attach_pdp_audit(&mut context, error.pdp_audit.as_ref()); + attach_governed_error_audit(&mut context, &error); with_optional_audit_context(error.error.into_response(), context) } diff --git a/crates/registry-relay/src/api/governed.rs b/crates/registry-relay/src/api/governed.rs index 197bfaea..a86a11a2 100644 --- a/crates/registry-relay/src/api/governed.rs +++ b/crates/registry-relay/src/api/governed.rs @@ -15,7 +15,10 @@ use registry_platform_pdp::{ use sha2::{Digest, Sha256}; use crate::audit::AuditContextExt; -use crate::auth::Principal; +use crate::auth::{ + scopes::{format_trust_context_scope, TrustContextField}, + Principal, +}; use crate::config::{Config, EntityApiConfig, EntityConfig}; use crate::entity::EntityModel; use crate::error::{AuthError, Error, InternalError, PdpError}; @@ -34,7 +37,6 @@ const TRUST_SOURCE_OBSERVED_AT_UNIX_SECONDS_HEADER: &str = "x-registry-source-observed-at-unix-seconds"; pub(crate) const TRUST_SOURCE_OBSERVED_AGE_SECONDS_HEADER: &str = "x-registry-source-observed-age-seconds"; -const TRUST_SCOPE_PREFIX: &str = "registry:trust"; const ODRL_PURPOSE: &str = "http://www.w3.org/ns/odrl/2/purpose"; const ODRL_SPATIAL: &str = "http://www.w3.org/ns/odrl/2/spatial"; @@ -119,6 +121,7 @@ pub(crate) struct GovernedRequestInfo<'a> { pub(crate) struct GovernedAccessError { pub(crate) error: Error, pub(crate) pdp_audit: Option, + pub(crate) pdp_trust_provenance: BTreeSet, } impl GovernedAccessError { @@ -126,6 +129,7 @@ impl GovernedAccessError { Self { error: error.into(), pdp_audit: None, + pdp_trust_provenance: BTreeSet::new(), } } @@ -133,8 +137,14 @@ impl GovernedAccessError { Self { error, pdp_audit: Some(audit), + pdp_trust_provenance: BTreeSet::new(), } } + + fn with_trust_provenance(mut self, field: &str) -> Self { + self.pdp_trust_provenance.insert(field.to_string()); + self + } } impl From for GovernedAccessError { @@ -298,28 +308,28 @@ fn request_pdp_context( headers, principal, TRUST_LEGAL_BASIS_HEADER, - "legal_basis", + TrustContextField::LegalBasis, ) .map(ToOwned::to_owned), consent_ref: verified_trust_header_value( headers, principal, TRUST_CONSENT_HEADER, - "consent", + TrustContextField::Consent, ) .map(ToOwned::to_owned), asserted_assurance: verified_trust_header_value( headers, principal, TRUST_ASSURANCE_HEADER, - "assurance", + TrustContextField::Assurance, ) .map(ToOwned::to_owned), jurisdiction: verified_trust_header_value( headers, principal, TRUST_JURISDICTION_HEADER, - "jurisdiction", + TrustContextField::Jurisdiction, ) .map(ToOwned::to_owned), requester_identity: principal.map(|principal| principal.principal_id.clone()), @@ -327,21 +337,21 @@ fn request_pdp_context( headers, principal, TRUST_SUBJECT_REF_HEADER, - "subject_ref", + TrustContextField::SubjectRef, ) .map(ToOwned::to_owned), relationship: verified_trust_header_value( headers, principal, TRUST_RELATIONSHIP_HEADER, - "relationship", + TrustContextField::Relationship, ) .map(ToOwned::to_owned), on_behalf_of: verified_trust_header_value( headers, principal, TRUST_ON_BEHALF_OF_HEADER, - "on_behalf_of", + TrustContextField::OnBehalfOf, ) .map(ToOwned::to_owned), requested_fact: Some(requested_fact.to_string()), @@ -350,7 +360,7 @@ fn request_pdp_context( headers, principal, TRUST_REQUESTED_CREDENTIAL_FORMAT_HEADER, - "requested_credential_format", + TrustContextField::RequestedCredentialFormat, ) .map(ToOwned::to_owned), source_binding: Some(source_binding.to_string()), @@ -363,20 +373,21 @@ fn request_pdp_context( headers, principal, TRUST_SOURCE_OBSERVED_AT_UNIX_SECONDS_HEADER, - "source_observed_at_unix_seconds", + TrustContextField::SourceObservedAtUnixSeconds, ) - .map(parse_unix_seconds) + .map(|value| parse_unix_seconds(value, "source_observed_at_unix_seconds")) .transpose()?, source_observed_age_seconds: source_observed_age_seconds(headers, principal)?, }) } #[allow(clippy::result_large_err)] -fn parse_unix_seconds(value: &str) -> Result { +fn parse_unix_seconds(value: &str, provenance_field: &str) -> Result { value.parse::().map_err(|_| { GovernedAccessError::from_error(PdpError::from_stable_code( registry_platform_pdp::EVIDENCE_STALE, )) + .with_trust_provenance(provenance_field) }) } @@ -396,22 +407,15 @@ fn verified_trust_header_value<'a>( headers: &'a HeaderMap, principal: Option<&Principal>, name: &str, - field: &str, + field: TrustContextField, ) -> Option<&'a str> { let value = trust_header_value(headers, name)?; + let required_scope = format_trust_context_scope(field, value)?; principal - .filter(|principal| { - principal - .scopes - .contains(&trust_context_scope(field, value)) - }) + .filter(|principal| principal.scopes.contains(&required_scope)) .map(|_| value) } -fn trust_context_scope(field: &str, value: &str) -> String { - format!("{TRUST_SCOPE_PREFIX}:{field}:{value}") -} - #[allow(clippy::result_large_err)] fn source_observed_age_seconds( headers: &HeaderMap, @@ -420,17 +424,19 @@ fn source_observed_age_seconds( let Some(value) = trust_header_value(headers, TRUST_SOURCE_OBSERVED_AGE_SECONDS_HEADER) else { return Ok(None); }; - if !principal.is_some_and(|principal| { - principal - .scopes - .contains(&trust_context_scope("source_observed_age_seconds", value)) - }) { + let Some(required_scope) = + format_trust_context_scope(TrustContextField::SourceObservedAgeSeconds, value) + else { + return Ok(None); + }; + if !principal.is_some_and(|principal| principal.scopes.contains(&required_scope)) { return Ok(None); } value.parse::().map(Some).map_err(|_| { GovernedAccessError::from_error(PdpError::from_stable_code( registry_platform_pdp::EVIDENCE_STALE, )) + .with_trust_provenance("source_observed_age_seconds") }) } @@ -485,6 +491,34 @@ pub(crate) fn attach_pdp_audit( .then(|| audit.trust_provenance.iter().cloned().collect()); } +pub(crate) fn attach_pdp_trust_provenance( + context: &mut Option, + provenance: &BTreeSet, +) { + if provenance.is_empty() { + return; + } + let Some(context) = context.as_mut() else { + return; + }; + let mut combined: BTreeSet = context + .pdp_trust_provenance + .take() + .unwrap_or_default() + .into_iter() + .collect(); + combined.extend(provenance.iter().cloned()); + context.pdp_trust_provenance = Some(combined.into_iter().collect()); +} + +pub(crate) fn attach_governed_error_audit( + context: &mut Option, + error: &GovernedAccessError, +) { + attach_pdp_audit(context, error.pdp_audit.as_ref()); + attach_pdp_trust_provenance(context, &error.pdp_trust_provenance); +} + pub(crate) fn governed_cache_variant<'a>( base: &str, decisions: impl IntoIterator, @@ -1067,6 +1101,56 @@ datasets: [] ); } + #[test] + fn malformed_exact_scoped_freshness_records_trusted_field_provenance() { + let request_info = GovernedRequestInfo { + route_identity: "relay.entity.collection", + requested_disclosure: "entity_collection", + checked_scope: "social_registry:rows", + redaction_projection: GovernedRedactionProjection::EntityFields, + }; + for (header, field) in [ + ( + TRUST_SOURCE_OBSERVED_AT_UNIX_SECONDS_HEADER, + TrustContextField::SourceObservedAtUnixSeconds, + ), + ( + TRUST_SOURCE_OBSERVED_AGE_SECONDS_HEADER, + TrustContextField::SourceObservedAgeSeconds, + ), + ] { + let malformed_value = "not-a-unix-second"; + let principal = Principal { + principal_id: "client-a".to_string(), + scopes: [ + "social_registry:rows".to_string(), + format_trust_context_scope(field, malformed_value) + .expect("non-empty exact trust scope formats"), + ] + .into_iter() + .collect::(), + auth_mode: AuthMode::ApiKey, + }; + let mut headers = HeaderMap::new(); + headers.insert(header, HeaderValue::from_static(malformed_value)); + + let error = request_pdp_context( + "testing", + &headers, + Some(&principal), + "individual", + "relay:social_registry:individuals_table", + &request_info, + ) + .expect_err("malformed authenticated freshness must deny"); + + assert_eq!( + error.pdp_trust_provenance, + BTreeSet::from([field.as_str().to_string()]) + ); + } + } + fn policy_input_context(scopes: &[&str]) -> PdpRequestContext { let principal = Principal { principal_id: "client-a".to_string(), diff --git a/crates/registry-relay/src/api/ogc/edr.rs b/crates/registry-relay/src/api/ogc/edr.rs index 5db289c0..184c722d 100644 --- a/crates/registry-relay/src/api/ogc/edr.rs +++ b/crates/registry-relay/src/api/ogc/edr.rs @@ -16,8 +16,8 @@ use serde_json::{json, Map, Value}; use tokio::sync::watch; use crate::api::governed::{ - attach_pdp_audit, require_governed_read_access, GovernedAccessError, GovernedReadDecision, - GovernedRedactionProjection, GovernedRequestInfo, + attach_governed_error_audit, attach_pdp_audit, require_governed_read_access, + GovernedAccessError, GovernedReadDecision, GovernedRedactionProjection, GovernedRequestInfo, }; use crate::audit::AuditContextExt; use crate::auth::scopes::require_scope; @@ -947,7 +947,7 @@ fn edr_access_error_response( geometry_vertex_count: Some(geometry_vertex_count), ..AuditContextExt::default() }); - attach_pdp_audit(&mut audit_context, error.pdp_audit.as_ref()); + attach_governed_error_audit(&mut audit_context, &error); let mut response = error.error.into_response(); if let Some(context) = audit_context { response.extensions_mut().insert(context); diff --git a/crates/registry-relay/src/api/ogc/features.rs b/crates/registry-relay/src/api/ogc/features.rs index edd1b9b3..6a517ef7 100644 --- a/crates/registry-relay/src/api/ogc/features.rs +++ b/crates/registry-relay/src/api/ogc/features.rs @@ -18,8 +18,8 @@ use time::format_description::well_known::Rfc3339; use time::{Date, OffsetDateTime}; use crate::api::governed::{ - attach_pdp_audit, require_governed_read_access, GovernedAccessError, - GovernedRedactionProjection, GovernedRequestInfo, + attach_governed_error_audit, attach_pdp_audit, require_governed_read_access, + GovernedAccessError, GovernedRedactionProjection, GovernedRequestInfo, }; use crate::audit::{AuditContextExt, ErrorCodeExt}; use crate::auth::scopes::require_scope; @@ -570,7 +570,7 @@ fn ogc_access_error_response( audit: OgcAuditContext, ) -> Response { let mut context = Some(audit_context(entity, spatial, dataset_id, audit)); - attach_pdp_audit(&mut context, error.pdp_audit.as_ref()); + attach_governed_error_audit(&mut context, &error); with_audit_context( error.error.into_response(), context.expect("audit context is present"), diff --git a/crates/registry-relay/src/api/spdci.rs b/crates/registry-relay/src/api/spdci.rs index 75f00736..bacec8fa 100644 --- a/crates/registry-relay/src/api/spdci.rs +++ b/crates/registry-relay/src/api/spdci.rs @@ -23,8 +23,8 @@ use time::OffsetDateTime; use ulid::Ulid; use crate::api::governed::{ - attach_pdp_audit, require_governed_read_access, GovernedAccessError, GovernedReadDecision, - GovernedRedactionProjection, GovernedRequestInfo, + attach_pdp_audit, attach_pdp_trust_provenance, require_governed_read_access, + GovernedAccessError, GovernedReadDecision, GovernedRedactionProjection, GovernedRequestInfo, }; use crate::audit::AuditContextExt; use crate::auth::scopes::require_scope; @@ -124,11 +124,22 @@ async fn disabled_status( Err(error) => return error.into_response(), }; let result = run_disabled_status(&runtime, &route, headers, principal, body).await; - let (response, row_count, pdp_audit) = match result { - Ok(value) => value, - Err(error) => (error.error.into_response(), 0, error.pdp_audit), + let (response, row_count, pdp_audit, pdp_trust_provenance) = match result { + Ok((response, row_count, pdp_audit)) => (response, row_count, pdp_audit, BTreeSet::new()), + Err(error) => ( + error.error.into_response(), + 0, + error.pdp_audit, + error.pdp_trust_provenance, + ), }; - with_audit_context(response, &route, row_count, pdp_audit.as_ref()) + with_audit_context( + response, + &route, + row_count, + pdp_audit.as_ref(), + &pdp_trust_provenance, + ) } async fn run_disabled_status( @@ -159,6 +170,7 @@ async fn run_disabled_status( return Err(SpdciRunError { error: AuthError::PurposeDenied.into(), pdp_audit: governed_decision.audit, + pdp_trust_provenance: BTreeSet::new(), }); } let request = SpdciRequest::from_body(body, &route.config)?; @@ -218,11 +230,24 @@ async fn sync_search_response( body, ) .await; - let (response, total_count, pdp_audit) = match result { - Ok(value) => value, - Err(error) => (error.error.into_response(), 0, error.pdp_audit), + let (response, total_count, pdp_audit, pdp_trust_provenance) = match result { + Ok((response, total_count, pdp_audit)) => { + (response, total_count, pdp_audit, BTreeSet::new()) + } + Err(error) => ( + error.error.into_response(), + 0, + error.pdp_audit, + error.pdp_trust_provenance, + ), }; - with_search_audit_context(response, &route, total_count, pdp_audit.as_ref()) + with_search_audit_context( + response, + &route, + total_count, + pdp_audit.as_ref(), + &pdp_trust_provenance, + ) } async fn run_sync_search_response( @@ -335,11 +360,22 @@ async fn search_response( body, ) .await; - let (response, row_count, pdp_audit) = match result { - Ok(value) => value, - Err(error) => (error.error.into_response(), 0, error.pdp_audit), + let (response, row_count, pdp_audit, pdp_trust_provenance) = match result { + Ok((response, row_count, pdp_audit)) => (response, row_count, pdp_audit, BTreeSet::new()), + Err(error) => ( + error.error.into_response(), + 0, + error.pdp_audit, + error.pdp_trust_provenance, + ), }; - with_audit_context(response, &route, row_count, pdp_audit.as_ref()) + with_audit_context( + response, + &route, + row_count, + pdp_audit.as_ref(), + &pdp_trust_provenance, + ) } #[allow(clippy::too_many_arguments)] @@ -412,6 +448,7 @@ struct SearchRouteState { struct SpdciRunError { error: Error, pdp_audit: Option, + pdp_trust_provenance: BTreeSet, } impl From for SpdciRunError { @@ -419,6 +456,7 @@ impl From for SpdciRunError { Self { error, pdp_audit: None, + pdp_trust_provenance: BTreeSet::new(), } } } @@ -428,6 +466,7 @@ impl From for SpdciRunError { Self { error: error.error, pdp_audit: error.pdp_audit, + pdp_trust_provenance: error.pdp_trust_provenance, } } } @@ -1202,6 +1241,7 @@ fn with_audit_context( route: &RouteState, row_count: u64, pdp_audit: Option<&PdpDecisionAudit>, + pdp_trust_provenance: &BTreeSet, ) -> Response { let mut context = Some(AuditContextExt { dataset_id: Some(route.config.dataset.to_string()), @@ -1211,6 +1251,7 @@ fn with_audit_context( ..AuditContextExt::default() }); attach_pdp_audit(&mut context, pdp_audit); + attach_pdp_trust_provenance(&mut context, pdp_trust_provenance); if let Some(context) = context { response.extensions_mut().insert(context); } @@ -1222,6 +1263,7 @@ fn with_search_audit_context( route: &SearchRouteState, row_count: u64, pdp_audit: Option<&PdpDecisionAudit>, + pdp_trust_provenance: &BTreeSet, ) -> Response { let mut context = Some(AuditContextExt { dataset_id: Some(route.config.dataset.to_string()), @@ -1231,6 +1273,7 @@ fn with_search_audit_context( ..AuditContextExt::default() }); attach_pdp_audit(&mut context, pdp_audit); + attach_pdp_trust_provenance(&mut context, pdp_trust_provenance); if let Some(context) = context { response.extensions_mut().insert(context); } diff --git a/crates/registry-relay/src/audit/mod.rs b/crates/registry-relay/src/audit/mod.rs index 8c83808e..5b8eb441 100644 --- a/crates/registry-relay/src/audit/mod.rs +++ b/crates/registry-relay/src/audit/mod.rs @@ -12,7 +12,7 @@ //! Integration: //! - The middleware reads `Principal` from request extensions when //! present and projects its identity into `principal_id`, `auth_mode`, -//! and `scopes_used`. +//! and privacy-preserving `scopes_used`. //! - The error module attaches a stable error code on failure //! responses via the `ErrorCodeExt` response extension defined in //! this module; the audit middleware reads it and records it. @@ -36,6 +36,9 @@ use tokio::sync::OnceCell; use tracing::error; use ulid::Ulid; +use crate::auth::scopes::{ + format_trust_context_scope, parse_trust_context_scope, ParsedTrustContextScope, +}; use crate::runtime_config::RuntimeSnapshot; pub mod file; @@ -97,6 +100,37 @@ impl Default for AuditSettings { /// an audit record cannot be written. Documented in `docs/configuration.md`. pub const AUDIT_WRITE_FAILED_CODE: &str = "audit.write_failed"; +const TRUST_SCOPE_HASH_FIELD_PREFIX: &str = "trust_scope:"; + +fn scopes_used_for_audit( + principal: Option<&crate::auth::Principal>, + hasher: &AuditKeyHasher, +) -> Option> { + let Some(principal) = principal else { + return Some(Vec::new()); + }; + principal + .scopes + .iter() + .map(|scope| redact_scope_for_audit(scope, hasher)) + .collect() +} + +fn redact_scope_for_audit(scope: &str, hasher: &AuditKeyHasher) -> Option { + let (field, value) = match parse_trust_context_scope(scope) { + ParsedTrustContextScope::NotReserved => return Some(scope.to_string()), + ParsedTrustContextScope::Malformed => return None, + ParsedTrustContextScope::Canonical { field, value } => (field, value), + }; + if !matches!(hasher, AuditKeyHasher::Keyed(_)) { + return None; + } + let hash_field = format!("{TRUST_SCOPE_HASH_FIELD_PREFIX}{}", field.as_str()); + let handle = sensitive_value_hash_keyed(hasher, &hash_field, value); + debug_assert!(handle.starts_with("hmac-sha256:")); + format_trust_context_scope(field, &handle) +} + /// A wrapper for sensitive string values that prints `[REDACTED]` via /// `Debug` and `Display`, defending against accidental inclusion in logs /// or panic messages. Convert from `String` or `&str` via `From`/`Into`. @@ -359,7 +393,11 @@ pub struct AuditRecord { pub pdp_checked_scopes: Option>, /// Redacted inventory of trust assertions PDP evaluated. pub pdp_trust_provenance: Option>, - /// Scopes actually checked on this request, in declaration order. + /// Scopes present on the authenticated principal, in stable scope-set order. + /// Exact-value trust scopes use canonical + /// `registry:trust::hmac-sha256:` handles so their raw values + /// are not disclosed while their authorization evidence remains linkable + /// under the deployment audit key. pub scopes_used: Vec, /// Redacted parameter inventory (names + ops, never values). pub query_params: Value, @@ -957,11 +995,6 @@ pub async fn audit_layer( let auth_mode = principal .as_ref() .map(|p| auth_mode_label(p.auth_mode).to_string()); - let scopes_used: Vec = principal - .as_ref() - .map(|p| p.scopes.iter().map(|s| s.to_string()).collect()) - .unwrap_or_default(); - let endpoint_kind = classify_endpoint(&path); // Readiness is never self-audited. Evidence-grade shipping requires the // cursor watermark to equal the live audit tail, so appending the probe @@ -973,6 +1006,14 @@ pub async fn audit_layer( return response; } + let scopes_used = match scopes_used_for_audit(principal.as_ref(), &settings.hash_hasher) { + Some(scopes) => scopes, + None => { + error!("audit trust-scope redaction requires a keyed hasher and a canonical scope"); + return audit_write_failed_response(); + } + }; + let query_params = redact_query_with_secret_and_fields( settings.hash_hasher.clone(), &query, @@ -1469,6 +1510,28 @@ mod tests { assert!(s.ends_with('Z')); } + #[test] + fn exact_trust_scope_redaction_requires_a_keyed_field_bound_handle() { + let scope = "registry:trust:subject_ref:subject:123"; + assert_eq!( + redact_scope_for_audit(scope, &AuditKeyHasher::unkeyed_dev_only()), + None + ); + + let hasher = AuditKeyHasher::Keyed( + AuditHashSecret::new(b"audit-module-trust-scope-test-secret".to_vec()) + .expect("test audit secret is strong enough"), + ); + let redacted = redact_scope_for_audit(scope, &hasher).expect("keyed redaction succeeds"); + assert!(redacted.starts_with("registry:trust:subject_ref:hmac-sha256:")); + assert!(!redacted.contains("subject:123")); + assert_ne!( + redacted, + redact_scope_for_audit("registry:trust:on_behalf_of:subject:123", &hasher,) + .expect("other field redaction succeeds") + ); + } + #[test] fn sensitive_names_are_case_insensitive() { assert!(is_sensitive_param("Token")); diff --git a/crates/registry-relay/src/auth/scopes.rs b/crates/registry-relay/src/auth/scopes.rs index 0a5b6632..1c3e5a8e 100644 --- a/crates/registry-relay/src/auth/scopes.rs +++ b/crates/registry-relay/src/auth/scopes.rs @@ -1,11 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 //! Scope set and authorisation helpers. //! -//! V1 keeps scopes as plain strings: a scope is whatever appears in -//! `auth.api_keys[].scopes` in the config file and whatever appears in -//! the per-resource `metadata_scope` / `aggregate_scope` -//! fields. The gateway does not parse or namespace them; it only -//! checks membership. +//! Authorization scopes remain plain strings and use membership checks. The +//! reserved `registry:trust` namespace is the exception: governed request +//! context and audit projection share the canonical grammar defined here. use std::collections::BTreeSet; @@ -13,6 +11,97 @@ use crate::error::{AuthError, Error}; use super::Principal; +pub(crate) const TRUST_CONTEXT_SCOPE_PREFIX: &str = "registry:trust"; + +/// Fields that may carry exact-value governed trust context in a scope. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TrustContextField { + LegalBasis, + Consent, + Assurance, + Jurisdiction, + SubjectRef, + Relationship, + OnBehalfOf, + RequestedCredentialFormat, + SourceObservedAtUnixSeconds, + SourceObservedAgeSeconds, +} + +impl TrustContextField { + const ALL: [Self; 10] = [ + Self::LegalBasis, + Self::Consent, + Self::Assurance, + Self::Jurisdiction, + Self::SubjectRef, + Self::Relationship, + Self::OnBehalfOf, + Self::RequestedCredentialFormat, + Self::SourceObservedAtUnixSeconds, + Self::SourceObservedAgeSeconds, + ]; + + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::LegalBasis => "legal_basis", + Self::Consent => "consent", + Self::Assurance => "assurance", + Self::Jurisdiction => "jurisdiction", + Self::SubjectRef => "subject_ref", + Self::Relationship => "relationship", + Self::OnBehalfOf => "on_behalf_of", + Self::RequestedCredentialFormat => "requested_credential_format", + Self::SourceObservedAtUnixSeconds => "source_observed_at_unix_seconds", + Self::SourceObservedAgeSeconds => "source_observed_age_seconds", + } + } + + fn parse(value: &str) -> Option { + Self::ALL.into_iter().find(|field| field.as_str() == value) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ParsedTrustContextScope<'a> { + NotReserved, + Malformed, + Canonical { + field: TrustContextField, + value: &'a str, + }, +} + +/// Parse the reserved trust-context namespace without exposing unrecognized +/// field names to audit output. Values are split once so they may contain +/// colons. +pub(crate) fn parse_trust_context_scope(scope: &str) -> ParsedTrustContextScope<'_> { + if scope == TRUST_CONTEXT_SCOPE_PREFIX { + return ParsedTrustContextScope::Malformed; + } + let Some(payload) = scope + .strip_prefix(TRUST_CONTEXT_SCOPE_PREFIX) + .and_then(|suffix| suffix.strip_prefix(':')) + else { + return ParsedTrustContextScope::NotReserved; + }; + let Some((field, value)) = payload.split_once(':') else { + return ParsedTrustContextScope::Malformed; + }; + let Some(field) = TrustContextField::parse(field) else { + return ParsedTrustContextScope::Malformed; + }; + if value.is_empty() { + return ParsedTrustContextScope::Malformed; + } + ParsedTrustContextScope::Canonical { field, value } +} + +/// Format a canonical exact-value trust-context scope. +pub(crate) fn format_trust_context_scope(field: TrustContextField, value: &str) -> Option { + (!value.is_empty()).then(|| format!("{TRUST_CONTEXT_SCOPE_PREFIX}:{}:{value}", field.as_str())) +} + /// Set of scopes carried on a [`Principal`]. /// /// Wraps `BTreeSet` for stable iteration order (matters for @@ -129,4 +218,77 @@ mod tests { let err = require_scope(&p, "admin").expect_err("missing scope denied"); assert_eq!(err.code(), "auth.scope_denied"); } + + #[test] + fn trust_context_scope_parser_enforces_reserved_namespace_boundaries() { + let cases = [ + ("social_registry:rows", ParsedTrustContextScope::NotReserved), + ( + "registry:trusted:subject_ref:value", + ParsedTrustContextScope::NotReserved, + ), + ("registry:trust", ParsedTrustContextScope::Malformed), + ("registry:trust:", ParsedTrustContextScope::Malformed), + ( + "registry:trust:subject_ref", + ParsedTrustContextScope::Malformed, + ), + ("registry:trust::value", ParsedTrustContextScope::Malformed), + ( + "registry:trust:subject_ref:", + ParsedTrustContextScope::Malformed, + ), + ( + "registry:trust:unknown:value", + ParsedTrustContextScope::Malformed, + ), + ( + "registry:trust:subject_ref:subject:123", + ParsedTrustContextScope::Canonical { + field: TrustContextField::SubjectRef, + value: "subject:123", + }, + ), + ]; + + for (scope, expected) in cases { + assert_eq!(parse_trust_context_scope(scope), expected, "scope={scope}"); + } + } + + #[test] + fn trust_context_scope_formatter_and_parser_share_the_exact_field_set() { + let expected_fields = [ + "legal_basis", + "consent", + "assurance", + "jurisdiction", + "subject_ref", + "relationship", + "on_behalf_of", + "requested_credential_format", + "source_observed_at_unix_seconds", + "source_observed_age_seconds", + ]; + assert_eq!( + TrustContextField::ALL.map(TrustContextField::as_str), + expected_fields + ); + + for field in TrustContextField::ALL { + let scope = format_trust_context_scope(field, "value:with:colons") + .expect("non-empty value formats"); + assert_eq!( + parse_trust_context_scope(&scope), + ParsedTrustContextScope::Canonical { + field, + value: "value:with:colons", + } + ); + } + assert_eq!( + format_trust_context_scope(TrustContextField::SubjectRef, ""), + None + ); + } } diff --git a/crates/registry-relay/tests/audit_record.rs b/crates/registry-relay/tests/audit_record.rs index bea2f3ba..540db18c 100644 --- a/crates/registry-relay/tests/audit_record.rs +++ b/crates/registry-relay/tests/audit_record.rs @@ -1086,6 +1086,182 @@ async fn middleware_projects_principal_when_auth_runs_inside_audit() { assert!(scopes.contains("rows"), "rows missing from {scopes:?}"); } +#[tokio::test] +async fn middleware_pseudonymizes_value_bearing_trust_scopes_in_record() { + use registry_relay::auth::api_key::{ApiKeyAuth, ApiKeyEntry}; + use registry_relay::auth::middleware::auth_layer; + use sha2::{Digest, Sha256}; + + const VALID_KEY: &str = "test-bearer-token-trust-scope-redaction"; + const SUBJECT_REF: &str = "subject:123456789"; + + async fn governed_probe() -> axum::response::Response { + let mut response = StatusCode::OK.into_response(); + response.extensions_mut().insert(AuditContextExt { + pdp_trust_provenance: Some(vec!["subject_ref".to_string()]), + ..AuditContextExt::default() + }); + response + } + + let fingerprint = format!( + "sha256:{}", + hex_lower_local(&Sha256::digest(VALID_KEY.as_bytes())) + ); + let trust_scope = format!("registry:trust:subject_ref:{SUBJECT_REF}"); + let entry = ApiKeyEntry::new( + "statistics_office".to_string(), + ScopeSet::from_iter(["social_registry:rows".to_string(), trust_scope.clone()]), + fingerprint, + ) + .expect("fingerprint parses"); + let provider = Arc::new(ApiKeyAuth::new(vec![entry])); + let (sink, pipeline) = in_memory_pipeline(); + let hasher = AuditKeyHasher::Keyed( + AuditHashSecret::new(b"trust-scope-test-audit-secret-32-bytes".to_vec()) + .expect("test audit secret is strong enough"), + ); + let expected_scope = format!( + "registry:trust:subject_ref:{}", + sensitive_value_hash_keyed(&hasher, "trust_scope:subject_ref", SUBJECT_REF) + ); + let settings = AuditSettings { + hash_hasher: hasher, + ..AuditSettings::default() + }; + let protected = auth_layer(Router::new().route("/probe", get(governed_probe)), provider); + let app = Router::new() + .merge(protected) + .layer(from_fn(audit_layer)) + .layer(Extension(settings)) + .layer(Extension(pipeline)); + + let req = Request::builder() + .method(Method::GET) + .uri("/probe") + .header("authorization", format!("Bearer {VALID_KEY}")) + .body(Body::empty()) + .expect("request builds"); + let response = app.oneshot(req).await.expect("request completes"); + assert_eq!(response.status(), StatusCode::OK); + + let records = sink.snapshot(); + assert_eq!(records.len(), 1); + let record = captured_record(&records[0]); + assert_eq!( + record["scopes_used"], + serde_json::json!([expected_scope, "social_registry:rows"]) + ); + assert!(record["scopes_used"][0] + .as_str() + .expect("redacted trust scope is a string") + .starts_with("registry:trust:subject_ref:hmac-sha256:")); + assert_eq!( + record["pdp_trust_provenance"], + serde_json::json!(["subject_ref"]) + ); + assert!(!records[0].contains(SUBJECT_REF)); + assert!(!records[0].contains(&trust_scope)); +} + +#[tokio::test] +async fn middleware_fails_closed_for_malformed_or_unknown_trust_scopes() { + let malformed_scopes = [ + "registry:trust", + "registry:trust:", + "registry:trust:subject_ref", + "registry:trust::value", + "registry:trust:subject_ref:", + "registry:trust:unknown:value", + ]; + + for trust_scope in malformed_scopes { + let (sink, pipeline) = in_memory_pipeline(); + let principal = Principal { + principal_id: "statistics_office".to_string(), + scopes: ScopeSet::from_iter(["social_registry:rows", trust_scope]), + auth_mode: AuthMode::Oidc, + }; + let settings = AuditSettings { + hash_hasher: AuditKeyHasher::Keyed( + AuditHashSecret::new(b"malformed-trust-scope-test-secret".to_vec()) + .expect("test audit secret is strong enough"), + ), + ..AuditSettings::default() + }; + let app = Router::new() + .route("/probe", get(|| async { StatusCode::OK })) + .layer(from_fn(audit_layer)) + .layer(Extension(principal)) + .layer(Extension(settings)) + .layer(Extension(pipeline)); + + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/probe") + .body(Body::empty()) + .expect("request builds"), + ) + .await + .expect("request completes"); + assert_eq!( + response.status(), + StatusCode::SERVICE_UNAVAILABLE, + "scope={trust_scope}" + ); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body reads"); + assert_eq!( + serde_json::from_slice::(&body).expect("problem body is JSON")["code"], + "audit.write_failed", + "scope={trust_scope}" + ); + assert!(sink.snapshot().is_empty(), "scope={trust_scope}"); + } +} + +#[tokio::test] +async fn middleware_fails_closed_instead_of_hashing_trust_scope_without_key() { + let (sink, pipeline) = in_memory_pipeline(); + let principal = Principal { + principal_id: "statistics_office".to_string(), + scopes: ScopeSet::from_iter([ + "social_registry:rows", + "registry:trust:subject_ref:subject:123456789", + ]), + auth_mode: AuthMode::ApiKey, + }; + let app = Router::new() + .route("/probe", get(|| async { StatusCode::OK })) + .layer(from_fn(audit_layer)) + .layer(Extension(principal)) + .layer(Extension(AuditSettings::default())) + .layer(Extension(pipeline)); + + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/probe") + .body(Body::empty()) + .expect("request builds"), + ) + .await + .expect("request completes"); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body reads"); + assert_eq!( + serde_json::from_slice::(&body).expect("problem body is JSON")["code"], + "audit.write_failed" + ); + assert!(sink.snapshot().is_empty()); +} + fn hex_lower_local(bytes: &[u8]) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; let mut out = String::with_capacity(bytes.len() * 2); diff --git a/crates/registry-relay/tests/entity_routes.rs b/crates/registry-relay/tests/entity_routes.rs index b1b64c5c..76469537 100644 --- a/crates/registry-relay/tests/entity_routes.rs +++ b/crates/registry-relay/tests/entity_routes.rs @@ -12,7 +12,10 @@ use datafusion::datasource::MemTable; use datafusion::execution::context::SessionContext; use registry_manifest_core as metadata_core; use registry_relay::api::{aggregates_router, entity_router, metadata_router, CursorSigner}; -use registry_relay::audit::{audit_layer, AuditPipeline, InMemorySink}; +use registry_relay::audit::{ + audit_layer, sensitive_value_hash_keyed, AuditHashSecret, AuditKeyHasher, AuditPipeline, + AuditSettings, InMemorySink, +}; use registry_relay::auth::{AuthMode, Principal, ScopeSet}; use registry_relay::config::{self, DatasetId, ResourceId}; use registry_relay::entity::EntityRegistry; @@ -21,6 +24,7 @@ use registry_relay::ingest::{ }; use registry_relay::query::EntityQueryEngine; use serde_json::Value; +use std::collections::BTreeSet; use std::sync::Arc; use tempfile::TempDir; use tokio::sync::watch; @@ -335,6 +339,13 @@ const ENTITY_ROUTE_SCOPES_WITHOUT_TRUST_ASSERTIONS: &[&str] = &[ "social_registry:evidence_verification", ]; +fn test_audit_hasher() -> AuditKeyHasher { + AuditKeyHasher::Keyed( + AuditHashSecret::new(b"entity-route-test-audit-secret-32-bytes".to_vec()) + .expect("test audit secret is strong enough"), + ) +} + async fn server_with_query() -> TestServer { server_with_query_version("01J5K8M0000000000000000000").await } @@ -438,6 +449,17 @@ async fn server_with_query_and_governed_entity_policy_without_trust_assertion_sc async fn server_with_query_audit_and_governed_entity_policy_without_trust_assertion_scopes( governed_policy_yaml: &str, +) -> (TestServer, InMemorySink) { + server_with_query_audit_and_governed_entity_policy_and_scopes( + governed_policy_yaml, + ENTITY_ROUTE_SCOPES_WITHOUT_TRUST_ASSERTIONS, + ) + .await +} + +async fn server_with_query_audit_and_governed_entity_policy_and_scopes( + governed_policy_yaml: &str, + principal_scopes: &[&str], ) -> (TestServer, InMemorySink) { let audit_sink = InMemorySink::new(); let audit_pipeline: Arc = AuditPipeline::from_sink(audit_sink.clone()); @@ -445,13 +467,18 @@ async fn server_with_query_audit_and_governed_entity_policy_without_trust_assert "01J5K8M0000000000000000000", "01J5K8M0000000000000000000", Arc::new(CursorSigner::new_random()), - ENTITY_ROUTE_SCOPES_WITHOUT_TRUST_ASSERTIONS, + principal_scopes, Some("baseline-dpi/v1"), test_evidence_metadata(), Some(governed_policy_yaml), ) .await .layer(from_fn(audit_layer)) + .layer(Extension(principal(principal_scopes))) + .layer(Extension(AuditSettings { + hash_hasher: test_audit_hasher(), + ..AuditSettings::default() + })) .layer(Extension(audit_pipeline)); (TestServer::new(app), audit_sink) } @@ -468,6 +495,11 @@ async fn server_with_query_audit_and_ecosystem_binding_selector() -> (TestServer ) .await .layer(from_fn(audit_layer)) + .layer(Extension(principal(ENTITY_ROUTE_SCOPES))) + .layer(Extension(AuditSettings { + hash_hasher: test_audit_hasher(), + ..AuditSettings::default() + })) .layer(Extension(audit_pipeline)); (TestServer::new(app), audit_sink) } @@ -1539,6 +1571,72 @@ async fn governed_entity_policy_ignores_unverified_source_freshness_header_witho ); } +#[tokio::test] +async fn governed_entity_malformed_exact_scoped_freshness_is_redacted_with_provenance() { + let policy = r#" governed_policy: + permitted_purposes: + - https://data.example.test/purposes/testing + max_source_age_seconds: 30 + trusted_context: {} +"#; + let malformed_freshness = "not-a-number-987654321"; + let trust_scope = format!("registry:trust:source_observed_age_seconds:{malformed_freshness}"); + let scopes = [ + "social_registry:metadata", + "social_registry:rows", + "social_registry:evidence_verification", + trust_scope.as_str(), + ]; + let (server, audit_sink) = + server_with_query_audit_and_governed_entity_policy_and_scopes(policy, &scopes).await; + + let denied = server + .get("/v1/datasets/social_registry/entities/individual/records?id=p-1") + .add_header("data-purpose", "https://data.example.test/purposes/testing") + .add_header( + "x-registry-source-observed-age-seconds", + malformed_freshness, + ) + .await; + + denied.assert_status(StatusCode::FORBIDDEN); + let body = denied.json::(); + assert_eq!(body["code"], "pdp.evidence_stale"); + assert!(!serde_json::to_string(&body) + .expect("response body serializes") + .contains(malformed_freshness)); + + let records = audit_sink.snapshot(); + assert_eq!(records.len(), 1); + let audit_text = &records[0]; + let record = audit_record_from_envelope(audit_text); + assert_eq!(record["status_code"], 403); + assert_eq!(record["error_code"], "pdp.evidence_stale"); + assert_eq!( + record["pdp_trust_provenance"], + serde_json::json!(["source_observed_age_seconds"]) + ); + let expected_scope = format!( + "registry:trust:source_observed_age_seconds:{}", + sensitive_value_hash_keyed( + &test_audit_hasher(), + "trust_scope:source_observed_age_seconds", + malformed_freshness, + ) + ); + let scopes_used: BTreeSet<&str> = record["scopes_used"] + .as_array() + .expect("scopes_used is an array") + .iter() + .map(|scope| scope.as_str().expect("scope is a string")) + .collect(); + assert!(scopes_used.contains("social_registry:rows")); + assert!(scopes_used.contains(expected_scope.as_str())); + assert!(expected_scope.starts_with("registry:trust:source_observed_age_seconds:hmac-sha256:")); + assert!(!audit_text.contains(malformed_freshness)); + assert!(!audit_text.contains(&trust_scope)); +} + #[tokio::test] async fn governed_entity_policy_ignores_unverified_source_observed_at_header_without_leak() { let policy = r#" governed_policy: diff --git a/docs/site/src/content/docs/explanation/threat-model.mdx b/docs/site/src/content/docs/explanation/threat-model.mdx index 6b522c01..bded45a1 100644 --- a/docs/site/src/content/docs/explanation/threat-model.mdx +++ b/docs/site/src/content/docs/explanation/threat-model.mdx @@ -199,11 +199,15 @@ leakage, and privacy regressions that expose raw subject identifiers. **Audit as a control.** Every request touching person-level data can be recorded with the hashed principal, request id, the principal's granted scopes, and `Data-Purpose` context when present, and a deployment can run audit fail-closed, so an unrecordable request does -not return success. Two caveats for the auditor. First, the recorded scope list is not a +not return success. Relay records ordinary scopes unchanged but replaces each exact-value +trust scope with a field-bound `registry:trust::hmac-sha256:` handle under +the deployment audit key. The handle supports investigation and same-key correlation +without disclosing the trusted value. Two caveats for the auditor. First, the recorded scope list is not a per-route proof of which scope check authorized the request, and per-route audit coverage is something to verify in your deployment. Second, Notary records hashed principal and -correlation identifiers, so operator-side investigation depends on retaining the hashing -secret and request context. +correlation identifiers, and Relay's trust-scope handles have the same operational +dependency, so operator-side investigation depends on retaining the hashing secret and +request context. ## Residual risks and what is left to the operator diff --git a/docs/site/src/content/docs/spec/rs-pr-relay.mdx b/docs/site/src/content/docs/spec/rs-pr-relay.mdx index 2baa7e12..5b2e107c 100644 --- a/docs/site/src/content/docs/spec/rs-pr-relay.mdx +++ b/docs/site/src/content/docs/spec/rs-pr-relay.mdx @@ -49,7 +49,7 @@ The key words in this document are interpreted per [RS-DOC](../rs-doc/) Section | 0.2.1 | 2026-07-07 | draft | Documented additional request-context headers alongside REQ-PR-RELAY-020, noting they are not currently wired to any Relay policy decision. | | 0.2.2 | 2026-07-09 | draft | Clarified that `x-registry-source-observed-at-unix-seconds` is scope guarded trust context, leaving four additional request-context headers outside Relay policy decisions. | | 0.3.0 | 2026-07-09 | draft | Removed Relay-local signed response credential requirements and made Registry Notary the only credential issuance surface. | -| 0.3.1 | 2026-07-10 | draft | Scope-gated the remaining client-supplied trust-context headers and documented the policy-authoring boundary. | +| 0.3.1 | 2026-07-10 | draft | Scope-gated the remaining client-supplied trust-context headers, documented the policy-authoring boundary, and replaced exact trust values in audit records with keyed field-bound handles. | ## 1. Scope and references @@ -164,7 +164,7 @@ REQ-PR-RELAY-014: A Relay entity or aggregate response MUST remain an ordinary n Registry Relay records every data and metadata read in a JSONL audit trail. Audit covers the principal, the scopes exercised, and the request, so a reviewer can reconstruct who read what under which permission. -REQ-PR-RELAY-015: Every record read and every metadata request MUST produce a JSONL audit record carrying the caller's `principal_id`, the `scopes_used`, and the `request_id`. This is the protocol-level form of REQ-ARC-G-004. +REQ-PR-RELAY-015: Every record read and every metadata request MUST produce a JSONL audit record carrying the caller's `principal_id`, the `scopes_used`, and the `request_id`. Raw value-bearing `registry:trust::` scopes MUST NOT appear in `scopes_used`; each such scope MUST be replaced by the canonical field-bound `registry:trust::hmac-sha256:` handle computed under the deployment audit key. The audit record MUST retain authenticated trust-context field names without their values in `pdp_trust_provenance`, including an authenticated freshness field whose malformed value causes denial before PDP evaluation. This is the protocol-level form of REQ-ARC-G-004. REQ-PR-RELAY-016: Error responses MUST use the problem-details media type `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/info/rfc9457)).