From 38d86fca8ff62e4c207c42216e0a835aeb10d2bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 03:23:03 +0900 Subject: [PATCH 1/8] test(sensitive): require non-transferable handle audience --- .../tests/sensitive_handle_reservation.rs | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/crates/originweave-policy/tests/sensitive_handle_reservation.rs b/crates/originweave-policy/tests/sensitive_handle_reservation.rs index 6dd0439f..4b7954bf 100644 --- a/crates/originweave-policy/tests/sensitive_handle_reservation.rs +++ b/crates/originweave-policy/tests/sensitive_handle_reservation.rs @@ -11,6 +11,7 @@ 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( @@ -24,7 +25,7 @@ fn authority(destination: &str) -> SensitiveDataAuthority { } fn scope(max_uses: u32) -> SensitiveValueHandleScope { - SensitiveValueHandleScope::new(authority(DESTINATION), 2_000, max_uses) + SensitiveValueHandleScope::new(authority(DESTINATION), AUDIENCE, 2_000, max_uses) } #[test] @@ -33,17 +34,17 @@ fn reservation_state_consumes_each_authorized_use_exactly_once() { assert_eq!(state.reserved_uses(), 0); assert_eq!( - state.reserve_use(authority(DESTINATION), 1_999), + state.reserve_use(authority(DESTINATION), AUDIENCE, 1_999), HandleUseDecision::Authorized ); assert_eq!(state.reserved_uses(), 1); assert_eq!( - state.reserve_use(authority(DESTINATION), 1_999), + state.reserve_use(authority(DESTINATION), AUDIENCE, 1_999), HandleUseDecision::Authorized ); assert_eq!(state.reserved_uses(), 2); assert_eq!( - state.reserve_use(authority(DESTINATION), 1_999), + state.reserve_use(authority(DESTINATION), AUDIENCE, 1_999), HandleUseDecision::UseLimitReached ); assert_eq!(state.reserved_uses(), 2); @@ -54,23 +55,41 @@ fn denied_reservations_do_not_consume_the_authoritative_count() { let mut state = SensitiveHandleUseState::new(scope(2)); assert_eq!( - state.reserve_use(authority("https://other.example"), 1_999), + state.reserve_use(authority("https://other.example"), AUDIENCE, 1_999), HandleUseDecision::ScopeMismatch ); assert_eq!(state.reserved_uses(), 0); assert_eq!( - state.reserve_use(authority(DESTINATION), 2_000), + state.reserve_use(authority(DESTINATION), "other_service", 1_999), + HandleUseDecision::AudienceMismatch + ); + assert_eq!(state.reserved_uses(), 0); + assert_eq!( + state.reserve_use(authority(DESTINATION), AUDIENCE, 2_000), HandleUseDecision::Expired ); assert_eq!(state.reserved_uses(), 0); } +#[test] +fn invalid_or_empty_audience_never_receives_handle_authority() { + let mut state = SensitiveHandleUseState::new(scope(1)); + + for audience in ["", "browser adapter", "브라우저", "_-_"] { + assert_eq!( + state.reserve_use(authority(DESTINATION), audience, 1_999), + HandleUseDecision::AudienceMismatch + ); + assert_eq!(state.reserved_uses(), 0); + } +} + #[test] fn zero_use_scope_never_reserves_or_wraps_the_counter() { let mut state = SensitiveHandleUseState::new(scope(0)); assert_eq!( - state.reserve_use(authority(DESTINATION), 1_999), + state.reserve_use(authority(DESTINATION), AUDIENCE, 1_999), HandleUseDecision::UseLimitReached ); assert_eq!(state.reserved_uses(), 0); @@ -82,7 +101,7 @@ fn revocation_is_authoritative_idempotent_and_blocks_future_use() { assert_eq!(state.revocation_reason(), None); assert_eq!( - state.reserve_use(authority(DESTINATION), 1_999), + state.reserve_use(authority(DESTINATION), AUDIENCE, 1_999), HandleUseDecision::Authorized ); assert_eq!(state.reserved_uses(), 1); @@ -93,7 +112,7 @@ fn revocation_is_authoritative_idempotent_and_blocks_future_use() { Some(HandleRevocationReason::TaskCompleted) ); assert_eq!( - state.reserve_use(authority(DESTINATION), 1_999), + state.reserve_use(authority(DESTINATION), AUDIENCE, 1_999), HandleUseDecision::Revoked ); assert_eq!(state.reserved_uses(), 1); @@ -118,7 +137,7 @@ fn every_required_revocation_cause_can_be_recorded() { assert!(state.revoke(reason)); assert_eq!(state.revocation_reason(), Some(reason)); assert_eq!( - state.reserve_use(authority(DESTINATION), 1_999), + state.reserve_use(authority(DESTINATION), AUDIENCE, 1_999), HandleUseDecision::Revoked ); assert_eq!(state.reserved_uses(), 0); From 23e6e9678fec70f3bfd6e9779b968b421c09b6bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 03:25:09 +0900 Subject: [PATCH 2/8] feat(sensitive): bind handle use to exact audience --- .../originweave-policy/src/sensitive_data.rs | 94 ++++++++++++------- 1 file changed, 61 insertions(+), 33 deletions(-) diff --git a/crates/originweave-policy/src/sensitive_data.rs b/crates/originweave-policy/src/sensitive_data.rs index d5bcbb44..004b5bf5 100644 --- a/crates/originweave-policy/src/sensitive_data.rs +++ b/crates/originweave-policy/src/sensitive_data.rs @@ -157,12 +157,14 @@ pub fn evaluate_disclosure( /// Result of evaluating one attempted use of an opaque sensitive-value handle. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HandleUseDecision { - /// The supplied exact scope, classification, expiry, and prior-use count permit broker admission. + /// The supplied exact authority, audience, expiry, and prior-use count permit broker admission. Authorized, /// The authoritative in-process handle state was revoked before this use. Revoked, /// 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. + AudienceMismatch, /// The handle is no longer valid at the supplied trusted time. Expired, /// The bounded use count has already been consumed. @@ -192,23 +194,29 @@ pub enum HandleRevocationReason { #[derive(Debug, Clone, PartialEq, Eq)] pub struct SensitiveValueHandleScope { authority: SensitiveDataAuthority, + audience_id: String, expires_at_epoch_seconds: u64, max_uses: u32, } impl SensitiveValueHandleScope { - /// Build an opaque-handle scope with exact authority, exclusive expiry, and bounded use count. + /// Build an opaque-handle scope with exact authority, non-transferable audience, + /// exclusive expiry, and bounded use count. /// - /// A later field reclassification creates a different [`SensitiveDataAuthority`] - /// and therefore requires a newly authorized handle. + /// The audience identifier uses the same bounded ASCII policy-token grammar as + /// other authority identifiers. Invalid audience identifiers remain fail-closed + /// when the scope is evaluated. A later field reclassification or audience + /// change therefore requires a newly authorized handle. #[must_use] - pub const fn new( + pub fn new( authority: SensitiveDataAuthority, + audience_id: &str, expires_at_epoch_seconds: u64, max_uses: u32, ) -> Self { Self { authority, + audience_id: audience_id.to_owned(), expires_at_epoch_seconds, max_uses, } @@ -219,24 +227,29 @@ impl SensitiveValueHandleScope { #[derive(Debug, Clone, PartialEq, Eq)] pub struct HandleUseRequest { authority: SensitiveDataAuthority, + audience_id: String, now_epoch_seconds: u64, uses_so_far: u32, } impl HandleUseRequest { - /// Build a handle-use evaluation request from trusted time and authoritative broker state. + /// Build a handle-use evaluation request from exact authority, caller audience, + /// trusted time, and authoritative broker use state. /// - /// The eventual broker must supply these state values from its own trusted, - /// caller-unforgeable storage; accepting this struct does not make arbitrary - /// caller input authoritative. + /// The eventual broker must derive `audience_id` from authenticated service or + /// workload identity and supply the state values from caller-unforgeable + /// storage. Accepting this value object does not make arbitrary caller input + /// authoritative. #[must_use] - pub const fn new( + pub fn new( authority: SensitiveDataAuthority, + audience_id: &str, now_epoch_seconds: u64, uses_so_far: u32, ) -> Self { Self { authority, + audience_id: audience_id.to_owned(), now_epoch_seconds, uses_so_far, } @@ -246,16 +259,18 @@ impl HandleUseRequest { /// In-process authoritative use-count 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, 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. +/// 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. /// /// This is a policy-state primitive, not the trusted broker itself. It contains -/// neither the opaque handle token nor protected data and provides no durable or -/// cross-process transaction, value resolution, compensation, or persistence. A -/// shared or durable broker must place the state behind its own transactional or -/// locking boundary, persist lifecycle state, and recheck it before disclosure. +/// 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. #[derive(Debug, PartialEq, Eq)] pub struct SensitiveHandleUseState { scope: SensitiveValueHandleScope, @@ -301,23 +316,31 @@ impl SensitiveHandleUseState { /// Reserve one use from the current authoritative count when policy permits it. /// - /// The supplied time must come from the trusted broker boundary. Revocation, - /// exact-scope, expiry, and use-limit denial leaves the authoritative count - /// unchanged. + /// 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. + /// Audience/scope/expiry/use-limit/revocation denial leaves the authoritative + /// count unchanged. pub fn reserve_use( &mut self, authority: SensitiveDataAuthority, + audience_id: &str, now_epoch_seconds: u64, ) -> HandleUseDecision { + let request = HandleUseRequest::new( + authority, + audience_id, + now_epoch_seconds, + self.reserved_uses, + ); + let decision = evaluate_handle_use(&request, &self.scope); + if decision != HandleUseDecision::Authorized { + return decision; + } if self.revocation_reason.is_some() { return HandleUseDecision::Revoked; } - let request = HandleUseRequest::new(authority, now_epoch_seconds, self.reserved_uses); - let decision = evaluate_handle_use(&request, &self.scope); - if decision == HandleUseDecision::Authorized { - self.reserved_uses += 1; - } - decision + self.reserved_uses += 1; + HandleUseDecision::Authorized } } @@ -325,12 +348,12 @@ impl SensitiveHandleUseState { /// /// This pure function does not consume a use, mutate broker state, resolve a /// handle, or release a protected value. It is therefore not standalone -/// enforcement. A trusted broker must obtain trusted time and caller-unforgeable -/// handle state, atomically reserve or increment the use count before value -/// resolution, and recheck the reserved authority immediately before disclosure. -/// Missing or malformed authority identifiers fail closed as a scope mismatch. -/// The authority destination must already have crossed the canonical [`Origin`] -/// boundary. +/// enforcement. A trusted broker must obtain authenticated caller audience, +/// trusted time, and caller-unforgeable handle state, atomically reserve or +/// increment the use count before value resolution, and recheck the reserved +/// authority immediately before disclosure. Missing or malformed authority or +/// audience identifiers fail closed. The authority destination must already have +/// crossed the canonical [`Origin`] boundary. #[must_use] pub fn evaluate_handle_use( request: &HandleUseRequest, @@ -341,6 +364,11 @@ pub fn evaluate_handle_use( || request.authority != scope.authority { HandleUseDecision::ScopeMismatch + } else if !authority_identifier_is_valid(&request.audience_id) + || !authority_identifier_is_valid(&scope.audience_id) + || request.audience_id != scope.audience_id + { + HandleUseDecision::AudienceMismatch } else if request.now_epoch_seconds >= scope.expires_at_epoch_seconds { HandleUseDecision::Expired } else if request.uses_so_far >= scope.max_uses { From 3d375f51bcf0c1043e6f4dbef5c7b0f2c9554daa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 03:25:30 +0900 Subject: [PATCH 3/8] test(sensitive): migrate classification to audience-bound handles --- .../tests/handle_classification.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/originweave-policy/tests/handle_classification.rs b/crates/originweave-policy/tests/handle_classification.rs index 243ea2b0..ee7c51fa 100644 --- a/crates/originweave-policy/tests/handle_classification.rs +++ b/crates/originweave-policy/tests/handle_classification.rs @@ -6,6 +6,8 @@ use originweave_policy::{ SensitiveValueHandleScope, evaluate_handle_use, }; +const AUDIENCE: &str = "trusted_browser_adapter"; + fn destination() -> Origin { Origin::parse("https://shipping.example").expect("canonical destination") } @@ -23,11 +25,21 @@ fn authority(classification: DataClassification) -> SensitiveDataAuthority { #[test] fn opaque_handle_use_requires_the_exact_data_classification() { - let scope = - SensitiveValueHandleScope::new(authority(DataClassification::PersonalData), 2_000, 2); - let permitted = HandleUseRequest::new(authority(DataClassification::PersonalData), 1_999, 0); + let scope = SensitiveValueHandleScope::new( + authority(DataClassification::PersonalData), + AUDIENCE, + 2_000, + 2, + ); + let permitted = HandleUseRequest::new( + authority(DataClassification::PersonalData), + AUDIENCE, + 1_999, + 0, + ); let reclassified = HandleUseRequest::new( authority(DataClassification::SensitivePersonalData), + AUDIENCE, 1_999, 0, ); From 490178b465b44996361371bd80433d09b1bdbe93 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 03:26:13 +0900 Subject: [PATCH 4/8] test(sensitive): cover audience-bound handle policy --- .../tests/sensitive_data_policy.rs | 80 ++++++++++++++++++- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/crates/originweave-policy/tests/sensitive_data_policy.rs b/crates/originweave-policy/tests/sensitive_data_policy.rs index 467fb897..3a223c6d 100644 --- a/crates/originweave-policy/tests/sensitive_data_policy.rs +++ b/crates/originweave-policy/tests/sensitive_data_policy.rs @@ -12,6 +12,7 @@ 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"; #[derive(Clone, Copy)] struct AuthorityCase<'a> { @@ -79,7 +80,25 @@ fn handle_scope( authority: AuthorityCase<'_>, classification: DataClassification, ) -> SensitiveValueHandleScope { - SensitiveValueHandleScope::new(sensitive_authority(authority, classification), 2_000, 2) + SensitiveValueHandleScope::new( + sensitive_authority(authority, classification), + AUDIENCE, + 2_000, + 2, + ) +} + +fn handle_scope_for_audience( + authority: AuthorityCase<'_>, + classification: DataClassification, + audience: &str, +) -> SensitiveValueHandleScope { + SensitiveValueHandleScope::new( + sensitive_authority(authority, classification), + audience, + 2_000, + 2, + ) } fn handle_use( @@ -88,7 +107,22 @@ fn handle_use( now: u64, uses: u32, ) -> HandleUseRequest { - HandleUseRequest::new(sensitive_authority(authority, classification), now, uses) + handle_use_for_audience(authority, classification, AUDIENCE, now, uses) +} + +fn handle_use_for_audience( + authority: AuthorityCase<'_>, + classification: DataClassification, + audience: &str, + now: u64, + uses: u32, +) -> HandleUseRequest { + HandleUseRequest::new( + sensitive_authority(authority, classification), + audience, + now, + uses, + ) } fn assert_disclosure_denied(authority: AuthorityCase<'_>, classification: DataClassification) { @@ -216,7 +250,7 @@ fn every_supported_disclosure_outcome_is_preserved_by_exact_scope() { } #[test] -fn opaque_handle_use_is_bound_to_scope_classification_expiry_and_use_count() { +fn opaque_handle_use_is_bound_to_scope_classification_audience_expiry_and_use_count() { let exact = exact_authority(); let scope = handle_scope(exact, DataClassification::PersonalData); assert_eq!( @@ -231,6 +265,19 @@ fn opaque_handle_use_is_bound_to_scope_classification_expiry_and_use_count() { DataClassification::PersonalData, ); assert_handle_scope_mismatch(exact, DataClassification::SensitivePersonalData); + assert_eq!( + evaluate_handle_use( + &handle_use_for_audience( + exact, + DataClassification::PersonalData, + "other_service", + 1_999, + 1, + ), + &scope, + ), + HandleUseDecision::AudienceMismatch + ); assert_eq!( evaluate_handle_use( &handle_use(exact, DataClassification::PersonalData, 2_000, 1), @@ -247,6 +294,33 @@ fn opaque_handle_use_is_bound_to_scope_classification_expiry_and_use_count() { ); } +#[test] +fn invalid_handle_audience_fails_closed_on_request_and_scope() { + let exact = exact_authority(); + let valid_scope = handle_scope(exact, DataClassification::PersonalData); + let invalid_request = handle_use_for_audience( + exact, + DataClassification::PersonalData, + "", + 1_999, + 0, + ); + assert_eq!( + evaluate_handle_use(&invalid_request, &valid_scope), + HandleUseDecision::AudienceMismatch + ); + + let invalid_scope = + handle_scope_for_audience(exact, DataClassification::PersonalData, "browser adapter"); + assert_eq!( + evaluate_handle_use( + &handle_use(exact, DataClassification::PersonalData, 1_999, 0), + &invalid_scope, + ), + HandleUseDecision::AudienceMismatch + ); +} + #[test] fn handle_scope_mismatch_covers_every_authority_dimension() { assert_handle_scope_mismatch( From 09b9f4e45e6f4d177e7ed11d39ed3e6f7d267082 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 03:32:28 +0900 Subject: [PATCH 5/8] style(policy): apply canonical rustfmt --- crates/originweave-policy/tests/sensitive_data_policy.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/crates/originweave-policy/tests/sensitive_data_policy.rs b/crates/originweave-policy/tests/sensitive_data_policy.rs index 3a223c6d..d5844d1f 100644 --- a/crates/originweave-policy/tests/sensitive_data_policy.rs +++ b/crates/originweave-policy/tests/sensitive_data_policy.rs @@ -298,13 +298,8 @@ fn opaque_handle_use_is_bound_to_scope_classification_audience_expiry_and_use_co fn invalid_handle_audience_fails_closed_on_request_and_scope() { let exact = exact_authority(); let valid_scope = handle_scope(exact, DataClassification::PersonalData); - let invalid_request = handle_use_for_audience( - exact, - DataClassification::PersonalData, - "", - 1_999, - 0, - ); + let invalid_request = + handle_use_for_audience(exact, DataClassification::PersonalData, "", 1_999, 0); assert_eq!( evaluate_handle_use(&invalid_request, &valid_scope), HandleUseDecision::AudienceMismatch From 95f0f1e418024f5dbe7aa613e5fd1e9d88a9417a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 03:37:05 +0900 Subject: [PATCH 6/8] test(policy): preserve revocation precedence with audience binding --- .../tests/sensitive_handle_reservation.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/originweave-policy/tests/sensitive_handle_reservation.rs b/crates/originweave-policy/tests/sensitive_handle_reservation.rs index 4b7954bf..7004df94 100644 --- a/crates/originweave-policy/tests/sensitive_handle_reservation.rs +++ b/crates/originweave-policy/tests/sensitive_handle_reservation.rs @@ -124,6 +124,28 @@ fn revocation_is_authoritative_idempotent_and_blocks_future_use() { ); } +#[test] +fn revocation_precedes_request_mismatch_without_leaking_later_policy_state() { + let mut state = SensitiveHandleUseState::new(scope(3)); + assert!(state.revoke(HandleRevocationReason::SuspiciousUse)); + + for (request_authority, audience, now) in [ + (authority("https://other.example"), AUDIENCE, 1_999), + (authority(DESTINATION), "other_service", 1_999), + (authority(DESTINATION), AUDIENCE, 2_000), + ] { + assert_eq!( + state.reserve_use(request_authority, audience, now), + HandleUseDecision::Revoked + ); + assert_eq!(state.reserved_uses(), 0); + assert_eq!( + state.revocation_reason(), + Some(HandleRevocationReason::SuspiciousUse) + ); + } +} + #[test] fn every_required_revocation_cause_can_be_recorded() { for reason in [ From b83c10977a0d0103fb0307222720f70c6dc7cf3d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 03:40:32 +0900 Subject: [PATCH 7/8] fix(policy): preserve authoritative revocation precedence --- crates/originweave-policy/src/sensitive_data.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/originweave-policy/src/sensitive_data.rs b/crates/originweave-policy/src/sensitive_data.rs index 004b5bf5..89427a98 100644 --- a/crates/originweave-policy/src/sensitive_data.rs +++ b/crates/originweave-policy/src/sensitive_data.rs @@ -318,14 +318,19 @@ impl SensitiveHandleUseState { /// /// 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. - /// Audience/scope/expiry/use-limit/revocation denial leaves the authoritative - /// count unchanged. + /// 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. pub fn reserve_use( &mut self, authority: SensitiveDataAuthority, audience_id: &str, now_epoch_seconds: u64, ) -> HandleUseDecision { + if self.revocation_reason.is_some() { + return HandleUseDecision::Revoked; + } let request = HandleUseRequest::new( authority, audience_id, @@ -336,9 +341,6 @@ impl SensitiveHandleUseState { if decision != HandleUseDecision::Authorized { return decision; } - if self.revocation_reason.is_some() { - return HandleUseDecision::Revoked; - } self.reserved_uses += 1; HandleUseDecision::Authorized } From 8d3ccf0a3b99fd9789210dd9798b422431fab7d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 03:49:54 +0900 Subject: [PATCH 8/8] test(policy): prove concurrent audience-bound reservations --- .../tests/sensitive_handle_reservation.rs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/crates/originweave-policy/tests/sensitive_handle_reservation.rs b/crates/originweave-policy/tests/sensitive_handle_reservation.rs index 7004df94..93a85add 100644 --- a/crates/originweave-policy/tests/sensitive_handle_reservation.rs +++ b/crates/originweave-policy/tests/sensitive_handle_reservation.rs @@ -1,5 +1,8 @@ #![allow(clippy::expect_used)] +use std::sync::{Arc, Barrier, Mutex}; +use std::thread; + use originweave_core::Origin; use originweave_policy::{ DataClassification, HandleRevocationReason, HandleUseDecision, SensitiveDataAuthority, @@ -50,6 +53,60 @@ fn reservation_state_consumes_each_authorized_use_exactly_once() { assert_eq!(state.reserved_uses(), 2); } +#[test] +fn concurrent_reservations_share_one_count_and_never_transfer_audience_authority() { + let state = Arc::new(Mutex::new(SensitiveHandleUseState::new(scope(1)))); + let start = Arc::new(Barrier::new(4)); + let mut workers = Vec::new(); + + for audience in [AUDIENCE, AUDIENCE, "other_service"] { + let state = Arc::clone(&state); + let start = Arc::clone(&start); + workers.push(thread::spawn(move || { + start.wait(); + state + .lock() + .expect("test mutex must remain healthy") + .reserve_use(authority(DESTINATION), audience, 1_999) + })); + } + + start.wait(); + let decisions: Vec<_> = workers + .into_iter() + .map(|worker| worker.join().expect("reservation worker must complete")) + .collect(); + + assert_eq!( + decisions + .iter() + .filter(|decision| **decision == HandleUseDecision::Authorized) + .count(), + 1 + ); + assert_eq!( + decisions + .iter() + .filter(|decision| **decision == HandleUseDecision::UseLimitReached) + .count(), + 1 + ); + assert_eq!( + decisions + .iter() + .filter(|decision| **decision == HandleUseDecision::AudienceMismatch) + .count(), + 1 + ); + assert_eq!( + state + .lock() + .expect("test mutex must remain healthy") + .reserved_uses(), + 1 + ); +} + #[test] fn denied_reservations_do_not_consume_the_authoritative_count() { let mut state = SensitiveHandleUseState::new(scope(2));