diff --git a/crates/originweave-policy/src/lib.rs b/crates/originweave-policy/src/lib.rs index 8cab72bb..abb84862 100644 --- a/crates/originweave-policy/src/lib.rs +++ b/crates/originweave-policy/src/lib.rs @@ -12,7 +12,8 @@ mod sensitive_data; pub use sensitive_data::{ DataClassification, DisclosureDecision, DisclosureScope, HandleRevocationReason, HandleUseDecision, HandleUseRequest, SensitiveDataAuthority, SensitiveDataRequest, - SensitiveHandleUseState, SensitiveValueHandleScope, evaluate_disclosure, evaluate_handle_use, + SensitiveHandleUseReservation, SensitiveHandleUseState, SensitiveValueHandleScope, + evaluate_disclosure, evaluate_handle_use, }; use originweave_core::{ diff --git a/crates/originweave-policy/src/sensitive_data.rs b/crates/originweave-policy/src/sensitive_data.rs index 89427a98..b62791e2 100644 --- a/crates/originweave-policy/src/sensitive_data.rs +++ b/crates/originweave-policy/src/sensitive_data.rs @@ -4,6 +4,8 @@ //! protected value itself, performs no I/O, and grants no authority from ambient //! session, network, repository, or model state. +use std::sync::Arc; + use originweave_core::Origin; const MAX_AUTHORITY_IDENTIFIER_BYTES: usize = 128; @@ -167,7 +169,7 @@ pub enum HandleUseDecision { AudienceMismatch, /// The handle is no longer valid at the supplied trusted time. Expired, - /// The bounded use count has already been consumed. + /// The bounded use count has already been consumed or reserved. UseLimitReached, } @@ -256,25 +258,49 @@ impl HandleUseRequest { } } -/// In-process authoritative use-count and revocation state for one opaque sensitive-value handle scope. +/// Opaque in-process identity for one unsettled sensitive-handle use reservation. +/// +/// This is not the sensitive-value handle and carries no protected value or +/// authority fields. Each returned token and the corresponding state entry retain +/// the same allocation-bound identity. A surviving stale token therefore keeps its +/// allocation alive, so a later reservation cannot alias it even after compensation. +/// The token is intentionally in-process-only, non-serializable, and non-copyable. +#[derive(Debug)] +pub struct SensitiveHandleUseReservation { + identity: Arc<[u8; 1]>, +} + +impl PartialEq for SensitiveHandleUseReservation { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.identity, &other.identity) + } +} + +impl Eq for SensitiveHandleUseReservation {} + +/// In-process authoritative use-count, reservation-settlement, and revocation state +/// for one opaque sensitive-value handle scope. /// -/// This value removes the caller-supplied prior-use count from the reservation -/// operation. A successful reservation compares the exact authority, exact -/// non-transferable audience, trusted time, expiry, revocation state, and current -/// count and then increments the count while the caller holds an exclusive mutable -/// borrow of this state. Denied reservations never consume a use. +/// The state supports two reservation modes. [`Self::reserve_use`] retains the +/// earlier conservative behavior and immediately consumes a use without offering +/// compensation. [`Self::reserve_tracked_use`] creates an identity-bound unsettled +/// reservation that a trusted broker must later commit after disclosure or +/// compensate only when it has authoritative proof that no disclosure occurred. +/// Denied reservations never consume capacity. /// /// This is a policy-state primitive, not the trusted broker itself. It contains /// neither the opaque handle token nor protected data and provides no authenticated /// workload identity, durable or cross-process transaction, value resolution, -/// compensation, or persistence. A shared or durable broker must derive the -/// audience from authenticated caller identity, place the state behind its own -/// transactional or locking boundary, persist lifecycle state, and recheck it -/// before disclosure. +/// persistence, or proof that compensation is truthful. A shared or durable broker +/// must derive audience from authenticated caller identity, place reserve/recheck/ +/// disclose/settle behind its own transactional or locking boundary, persist +/// lifecycle state, and recheck authority immediately before disclosure. #[derive(Debug, PartialEq, Eq)] pub struct SensitiveHandleUseState { scope: SensitiveValueHandleScope, reserved_uses: u32, + completed_uses: u32, + outstanding_reservations: Vec, revocation_reason: Option, } @@ -285,16 +311,30 @@ impl SensitiveHandleUseState { Self { scope, reserved_uses: 0, + completed_uses: 0, + outstanding_reservations: Vec::new(), revocation_reason: None, } } - /// Return the number of uses already reserved through this state value. + /// Return uses that are either permanently consumed or currently reserved. #[must_use] pub const fn reserved_uses(&self) -> u32 { self.reserved_uses } + /// Return uses known to have completed or been conservatively consumed. + #[must_use] + pub const fn completed_uses(&self) -> u32 { + self.completed_uses + } + + /// Return the number of tracked reservations awaiting commit or compensation. + #[must_use] + pub fn outstanding_reservations(&self) -> usize { + self.outstanding_reservations.len() + } + /// Return the first authoritative revocation reason, if this state was revoked. #[must_use] pub const fn revocation_reason(&self) -> Option { @@ -305,6 +345,8 @@ impl SensitiveHandleUseState { /// /// Returns `true` only for the state transition from active to revoked. A /// later duplicate call is a no-op and cannot rewrite the original reason. + /// Existing tracked reservations remain settleable so a broker can record a + /// completed disclosure or compensate a failed pre-disclosure attempt. pub fn revoke(&mut self, reason: HandleRevocationReason) -> bool { if self.revocation_reason.is_some() { false @@ -314,19 +356,102 @@ impl SensitiveHandleUseState { } } - /// Reserve one use from the current authoritative count when policy permits it. + /// Reserve and immediately consume one use when policy permits it. /// - /// The audience must be derived by the trusted broker from authenticated caller - /// identity, and the supplied time must come from the broker's trusted clock. - /// Revocation is authoritative and is checked before later request details so a - /// revoked handle cannot expose whether a different scope, audience, expiry, or - /// use-limit condition would otherwise have matched. Every denial leaves the - /// authoritative count unchanged. + /// This compatibility path is deliberately non-compensatable. Callers that + /// need pre-disclosure rollback must use [`Self::reserve_tracked_use`] and + /// settle the returned identity explicitly. Revocation is checked before + /// later request details, and every denial leaves the authoritative count + /// unchanged. pub fn reserve_use( &mut self, authority: SensitiveDataAuthority, audience_id: &str, now_epoch_seconds: u64, + ) -> HandleUseDecision { + let decision = self.evaluate_reservation(authority, audience_id, now_epoch_seconds); + if decision != HandleUseDecision::Authorized { + return decision; + } + self.reserved_uses += 1; + self.completed_uses += 1; + HandleUseDecision::Authorized + } + + /// Reserve one identity-bound use without yet claiming that disclosure completed. + /// + /// The returned reservation is caller-unforgeable only to the extent that this + /// state object itself is protected by the trusted broker. It carries no secret + /// data and no caller-controlled identifier. The state retains a second strong + /// reference to the same allocation while the reservation is outstanding. If a + /// settled token survives, its allocation remains live and therefore cannot be + /// reused by a later reservation. + pub fn reserve_tracked_use( + &mut self, + authority: SensitiveDataAuthority, + audience_id: &str, + now_epoch_seconds: u64, + ) -> Result { + let decision = self.evaluate_reservation(authority, audience_id, now_epoch_seconds); + if decision != HandleUseDecision::Authorized { + return Err(decision); + } + let identity = Arc::new([0_u8]); + self.outstanding_reservations + .push(SensitiveHandleUseReservation { + identity: Arc::clone(&identity), + }); + self.reserved_uses += 1; + Ok(SensitiveHandleUseReservation { identity }) + } + + /// Mark one exact tracked reservation as a completed, permanently consumed use. + /// + /// This method records settlement only; it does not authorize disclosure. A + /// trusted broker must already have performed the required immediate authority + /// recheck and must call this only after the protected value was actually + /// disclosed. Returns `false` for an unknown, stale, compensated, or already + /// committed reservation and leaves all counters unchanged. + pub fn commit_reservation(&mut self, reservation: &SensitiveHandleUseReservation) -> bool { + if let Some(index) = self + .outstanding_reservations + .iter() + .position(|candidate| candidate == reservation) + { + self.outstanding_reservations.swap_remove(index); + self.completed_uses += 1; + true + } else { + false + } + } + + /// Release one exact tracked reservation after authoritative pre-disclosure failure. + /// + /// A trusted broker may call this only when it knows the protected value did + /// not cross the disclosure boundary. Compensation removes only the supplied + /// outstanding identity and restores one unit of capacity. Unknown, stale, + /// committed, or already compensated identities return `false` without + /// changing state. Revocation does not block cleanup compensation. + pub fn compensate_reservation(&mut self, reservation: &SensitiveHandleUseReservation) -> bool { + if let Some(index) = self + .outstanding_reservations + .iter() + .position(|candidate| candidate == reservation) + { + self.outstanding_reservations.swap_remove(index); + self.reserved_uses -= 1; + true + } else { + false + } + } + + fn evaluate_reservation( + &self, + authority: SensitiveDataAuthority, + audience_id: &str, + now_epoch_seconds: u64, ) -> HandleUseDecision { if self.revocation_reason.is_some() { return HandleUseDecision::Revoked; @@ -337,12 +462,7 @@ impl SensitiveHandleUseState { now_epoch_seconds, self.reserved_uses, ); - let decision = evaluate_handle_use(&request, &self.scope); - if decision != HandleUseDecision::Authorized { - return decision; - } - self.reserved_uses += 1; - HandleUseDecision::Authorized + evaluate_handle_use(&request, &self.scope) } } diff --git a/crates/originweave-policy/tests/sensitive_handle_settlement.rs b/crates/originweave-policy/tests/sensitive_handle_settlement.rs new file mode 100644 index 00000000..e94585c6 --- /dev/null +++ b/crates/originweave-policy/tests/sensitive_handle_settlement.rs @@ -0,0 +1,152 @@ +#![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 scope(max_uses: u32) -> SensitiveValueHandleScope { + SensitiveValueHandleScope::new(authority(DESTINATION), AUDIENCE, 2_000, max_uses) +} + +#[test] +fn compensating_exact_failed_reservation_restores_capacity_without_replay() { + let mut state = SensitiveHandleUseState::new(scope(1)); + + let first = state + .reserve_tracked_use(authority(DESTINATION), AUDIENCE, 1_999) + .expect("first reservation must be authorized"); + assert_eq!(state.reserved_uses(), 1); + assert_eq!(state.outstanding_reservations(), 1); + assert_eq!(state.completed_uses(), 0); + assert_eq!( + state.reserve_tracked_use(authority(DESTINATION), AUDIENCE, 1_999), + Err(HandleUseDecision::UseLimitReached) + ); + + assert!(state.compensate_reservation(&first)); + assert_eq!(state.reserved_uses(), 0); + assert_eq!(state.outstanding_reservations(), 0); + assert_eq!(state.completed_uses(), 0); + + let replacement = state + .reserve_tracked_use(authority(DESTINATION), AUDIENCE, 1_999) + .expect("compensation must restore one use of capacity"); + assert_ne!(replacement, first); + assert!(!state.compensate_reservation(&first)); + assert_eq!(state.reserved_uses(), 1); + assert_eq!(state.outstanding_reservations(), 1); +} + +#[test] +fn committed_reservation_remains_consumed_and_cannot_be_compensated() { + let mut state = SensitiveHandleUseState::new(scope(1)); + let reservation = state + .reserve_tracked_use(authority(DESTINATION), AUDIENCE, 1_999) + .expect("reservation must be authorized"); + + assert!(state.commit_reservation(&reservation)); + assert_eq!(state.reserved_uses(), 1); + assert_eq!(state.outstanding_reservations(), 0); + assert_eq!(state.completed_uses(), 1); + assert!(!state.commit_reservation(&reservation)); + assert!(!state.compensate_reservation(&reservation)); + assert_eq!( + state.reserve_tracked_use(authority(DESTINATION), AUDIENCE, 1_999), + Err(HandleUseDecision::UseLimitReached) + ); +} + +#[test] +fn settlement_is_identity_bound_when_multiple_reservations_are_outstanding() { + let mut state = SensitiveHandleUseState::new(scope(3)); + let first = state + .reserve_tracked_use(authority(DESTINATION), AUDIENCE, 1_999) + .expect("first reservation must be authorized"); + let second = state + .reserve_tracked_use(authority(DESTINATION), AUDIENCE, 1_999) + .expect("second reservation must be authorized"); + + assert_ne!(first, second); + assert!(state.compensate_reservation(&first)); + assert_eq!(state.reserved_uses(), 1); + assert_eq!(state.outstanding_reservations(), 1); + assert!(state.commit_reservation(&second)); + assert_eq!(state.reserved_uses(), 1); + assert_eq!(state.outstanding_reservations(), 0); + assert_eq!(state.completed_uses(), 1); +} + +#[test] +fn reservation_token_cannot_settle_a_different_state() { + let mut first_state = SensitiveHandleUseState::new(scope(1)); + let mut second_state = SensitiveHandleUseState::new(scope(1)); + let first = first_state + .reserve_tracked_use(authority(DESTINATION), AUDIENCE, 1_999) + .expect("first-state reservation must be authorized"); + let second = second_state + .reserve_tracked_use(authority(DESTINATION), AUDIENCE, 1_999) + .expect("second-state reservation must be authorized"); + + assert!(!second_state.commit_reservation(&first)); + assert!(!second_state.compensate_reservation(&first)); + assert_eq!(second_state.reserved_uses(), 1); + assert_eq!(second_state.completed_uses(), 0); + assert_eq!(second_state.outstanding_reservations(), 1); + + assert!(first_state.compensate_reservation(&first)); + assert!(second_state.commit_reservation(&second)); + assert_eq!(first_state.reserved_uses(), 0); + assert_eq!(second_state.completed_uses(), 1); + assert_eq!(second_state.outstanding_reservations(), 0); +} + +#[test] +fn denied_or_revoked_state_never_creates_tracked_reservation() { + let mut state = SensitiveHandleUseState::new(scope(2)); + + assert_eq!( + state.reserve_tracked_use(authority("https://other.example"), AUDIENCE, 1_999), + Err(HandleUseDecision::ScopeMismatch) + ); + assert_eq!(state.outstanding_reservations(), 0); + assert!(state.revoke(HandleRevocationReason::PolicyChanged)); + assert_eq!( + state.reserve_tracked_use(authority(DESTINATION), AUDIENCE, 1_999), + Err(HandleUseDecision::Revoked) + ); + assert_eq!(state.reserved_uses(), 0); +} + +#[test] +fn revocation_does_not_prevent_compensating_an_undisclosed_reservation() { + let mut state = SensitiveHandleUseState::new(scope(1)); + let reservation = state + .reserve_tracked_use(authority(DESTINATION), AUDIENCE, 1_999) + .expect("reservation must be authorized before revocation"); + + assert!(state.revoke(HandleRevocationReason::SessionTerminated)); + assert!(state.compensate_reservation(&reservation)); + assert_eq!(state.reserved_uses(), 0); + assert_eq!(state.completed_uses(), 0); + assert_eq!(state.outstanding_reservations(), 0); +}