diff --git a/crates/originweave-policy/src/sensitive_data.rs b/crates/originweave-policy/src/sensitive_data.rs index b62791e2..5d09b595 100644 --- a/crates/originweave-policy/src/sensitive_data.rs +++ b/crates/originweave-policy/src/sensitive_data.rs @@ -163,6 +163,8 @@ pub enum HandleUseDecision { Authorized, /// The authoritative in-process handle state was revoked before this use. Revoked, + /// The supplied tracked-use identity is not outstanding in this exact state. + ReservationNotOutstanding, /// Tenant, task, field, purpose, destination, or classification did not match the handle scope. ScopeMismatch, /// The caller audience was invalid or did not match the handle's non-transferable audience. @@ -405,6 +407,51 @@ impl SensitiveHandleUseState { Ok(SensitiveHandleUseReservation { identity }) } + /// Recheck the exact outstanding tracked reservation immediately before disclosure. + /// + /// This does not reserve another use and does not mutate settlement state. The + /// trusted broker must supply authenticated audience, trusted time, and the exact + /// authority that applies at disclosure time, and must call this inside the same + /// transaction or locking boundary that guards value disclosure. Revocation is + /// checked before reservation membership and request detail so a revoked state + /// does not disclose whether a foreign or stale reservation would otherwise match. + /// An outstanding reservation also proves that this immutable state scope passed + /// authority and audience validation when it was admitted; recheck therefore + /// validates the caller-supplied authority and audience without duplicating + /// unreachable scope-validation branches. A use-limit check is intentionally + /// omitted because the outstanding reservation already consumed its bounded use + /// capacity. + #[must_use] + pub fn recheck_reservation( + &self, + reservation: &SensitiveHandleUseReservation, + authority: SensitiveDataAuthority, + audience_id: &str, + now_epoch_seconds: u64, + ) -> HandleUseDecision { + if self.revocation_reason.is_some() { + return HandleUseDecision::Revoked; + } + if !self + .outstanding_reservations + .iter() + .any(|candidate| candidate == reservation) + { + return HandleUseDecision::ReservationNotOutstanding; + } + if !authority.is_complete() || authority != self.scope.authority { + HandleUseDecision::ScopeMismatch + } else if !authority_identifier_is_valid(audience_id) + || audience_id != self.scope.audience_id + { + HandleUseDecision::AudienceMismatch + } else if now_epoch_seconds >= self.scope.expires_at_epoch_seconds { + HandleUseDecision::Expired + } else { + HandleUseDecision::Authorized + } + } + /// Mark one exact tracked reservation as a completed, permanently consumed use. /// /// This method records settlement only; it does not authorize disclosure. A diff --git a/crates/originweave-policy/tests/sensitive_handle_recheck.rs b/crates/originweave-policy/tests/sensitive_handle_recheck.rs new file mode 100644 index 00000000..65aa6560 --- /dev/null +++ b/crates/originweave-policy/tests/sensitive_handle_recheck.rs @@ -0,0 +1,144 @@ +#![allow(clippy::expect_used)] + +use originweave_core::Origin; +use originweave_policy::{ + DataClassification, HandleRevocationReason, HandleUseDecision, SensitiveDataAuthority, + SensitiveHandleUseState, SensitiveValueHandleScope, +}; + +const TENANT: &str = "tenant_alpha"; +const TASK: &str = "task_ship_order"; +const FIELD: &str = "shipping_address"; +const PURPOSE: &str = "fulfill_order"; +const DESTINATION: &str = "https://shipping.example"; +const AUDIENCE: &str = "trusted_browser_adapter"; + +fn authority(destination: &str) -> SensitiveDataAuthority { + SensitiveDataAuthority::new( + TENANT, + TASK, + FIELD, + PURPOSE, + Origin::parse(destination).expect("test origin must be valid"), + DataClassification::PersonalData, + ) +} + +fn authority_with_tenant(tenant_id: &str) -> SensitiveDataAuthority { + SensitiveDataAuthority::new( + tenant_id, + TASK, + FIELD, + PURPOSE, + Origin::parse(DESTINATION).expect("test origin must be valid"), + DataClassification::PersonalData, + ) +} + +fn scope(max_uses: u32) -> SensitiveValueHandleScope { + SensitiveValueHandleScope::new(authority(DESTINATION), AUDIENCE, 2_000, max_uses) +} + +#[test] +fn outstanding_reservation_can_be_rechecked_without_consuming_another_use() { + let mut state = SensitiveHandleUseState::new(scope(1)); + let reservation = state + .reserve_tracked_use(authority(DESTINATION), AUDIENCE, 1_900) + .expect("reservation must be authorized"); + + assert_eq!(state.reserved_uses(), 1); + assert_eq!( + state.recheck_reservation(&reservation, authority(DESTINATION), AUDIENCE, 1_999), + HandleUseDecision::Authorized + ); + assert_eq!(state.reserved_uses(), 1); + assert_eq!(state.outstanding_reservations(), 1); +} + +#[test] +fn foreign_or_settled_reservation_cannot_be_rechecked() { + let mut first_state = SensitiveHandleUseState::new(scope(2)); + let mut second_state = SensitiveHandleUseState::new(scope(2)); + let first = first_state + .reserve_tracked_use(authority(DESTINATION), AUDIENCE, 1_900) + .expect("first reservation must be authorized"); + let second = second_state + .reserve_tracked_use(authority(DESTINATION), AUDIENCE, 1_900) + .expect("second reservation must be authorized"); + + assert_eq!( + second_state.recheck_reservation(&first, authority(DESTINATION), AUDIENCE, 1_999), + HandleUseDecision::ReservationNotOutstanding + ); + assert!(first_state.compensate_reservation(&first)); + assert_eq!( + first_state.recheck_reservation(&first, authority(DESTINATION), AUDIENCE, 1_999), + HandleUseDecision::ReservationNotOutstanding + ); + assert!(second_state.commit_reservation(&second)); + assert_eq!( + second_state.recheck_reservation(&second, authority(DESTINATION), AUDIENCE, 1_999), + HandleUseDecision::ReservationNotOutstanding + ); +} + +#[test] +fn recheck_revalidates_scope_audience_and_expiry() { + let mut state = SensitiveHandleUseState::new(scope(1)); + let reservation = state + .reserve_tracked_use(authority(DESTINATION), AUDIENCE, 1_900) + .expect("reservation must be authorized"); + + assert_eq!( + state.recheck_reservation( + &reservation, + authority("https://other.example"), + AUDIENCE, + 1_999, + ), + HandleUseDecision::ScopeMismatch + ); + assert_eq!( + state.recheck_reservation(&reservation, authority_with_tenant(""), AUDIENCE, 1_999), + HandleUseDecision::ScopeMismatch + ); + assert_eq!( + state.recheck_reservation( + &reservation, + authority(DESTINATION), + "other_browser_adapter", + 1_999, + ), + HandleUseDecision::AudienceMismatch + ); + assert_eq!( + state.recheck_reservation(&reservation, authority(DESTINATION), "", 1_999), + HandleUseDecision::AudienceMismatch + ); + assert_eq!( + state.recheck_reservation(&reservation, authority(DESTINATION), AUDIENCE, 2_000), + HandleUseDecision::Expired + ); + assert_eq!(state.reserved_uses(), 1); + assert_eq!(state.outstanding_reservations(), 1); +} + +#[test] +fn revocation_precedes_reservation_and_request_details_on_recheck() { + let mut active_state = SensitiveHandleUseState::new(scope(1)); + let foreign = active_state + .reserve_tracked_use(authority(DESTINATION), AUDIENCE, 1_900) + .expect("foreign reservation must be authorized"); + let mut revoked_state = SensitiveHandleUseState::new(scope(1)); + assert!(revoked_state.revoke(HandleRevocationReason::PolicyChanged)); + + assert_eq!( + revoked_state.recheck_reservation( + &foreign, + authority("https://other.example"), + "other_browser_adapter", + 2_001, + ), + HandleUseDecision::Revoked + ); +}