1use std::{
10 collections::{BTreeMap, BTreeSet},
11 fmt,
12 sync::{Arc, Mutex},
13};
14
15use async_trait::async_trait;
16use serde::Serialize;
17use sha2::{Digest, Sha256};
18use termivar_core::{
19 ConfidenceScore, EntityId, Evidence, EvidenceId, EvidenceKind, EvidenceSource, EvidenceValue,
20 KnowledgePredicate,
21};
22
23use super::{
24 assessment_defense::{
25 project_assessment_defense_signal, AssessmentDefenseBodyCoverage,
26 AssessmentDefenseProjectionContext, AssessmentDefenseSignal,
27 },
28 assessment_item::{
29 AssessmentCapabilityDescriptor, AssessmentItemProjectionError, AssessmentItemTarget,
30 AssessmentProjectionContext,
31 },
32};
33use crate::{
34 authorization_review::{
35 AuthorizationDifferentialError, AuthorizationDifferentialResult,
36 AuthorizationPrincipalExecutionHandoff, AuthorizationPrincipalPair,
37 AuthorizationPrincipalPairProof, AuthorizationResourceScopeId,
38 AuthorizationReviewBodyState, AuthorizationReviewMediaClass, AuthorizationReviewOutcome,
39 AuthorizationReviewPolicy, AuthorizationReviewPolicyId, AuthorizationReviewView,
40 AuthorizationReviewViewError, AuthorizationViewReceiptId, AuthorizationViewRole,
41 },
42 defense::DefenseState,
43 http_evidence::{AuthorizationResponseDefense, HttpRequestBroker, HttpRequestBrokerError},
44 DecisionActionExecutor, DecisionActionOrigin, DecisionExecutionRequest, DecisionExecutionStage,
45 DecisionExecutorError, DecisionExecutorRegistry, KnowledgeBase, RuntimeLimitExceeded,
46 TransportDispatchOutcome,
47};
48
49pub const RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID: &str =
51 "web.review.authorization.resource-differential";
52pub const RESOURCE_AUTHORIZATION_REVIEW_CAPABILITY_ID: &str =
54 "authorization.resource-cross-principal-equivalence@1";
55pub const MAX_AUTHORIZATION_REVIEW_RESOURCES: usize = 1;
57pub const MAX_AUTHORIZATION_REVIEW_REQUESTS: usize = 4;
59pub const MAX_AUTHORIZATION_REVIEW_ACTIVE_VERIFICATIONS: usize = 1;
61pub(super) const AUTHORIZATION_REVIEW_ACTION_CYCLE_ALLOWANCE: u32 = 1;
63
64const RESOURCE_AUTHORIZATION_EXECUTOR_ID: &str = "http.authorization-resource-review";
65const RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE: &str = "web.authorization-review.transport";
66const RESOURCE_AUTHORIZATION_RECEIPT_DOMAIN: &[u8] =
67 b"security.authorization-review.response-receipt.v1\0";
68const RESOURCE_AUTHORIZATION_REQUEST_DOMAIN: &[u8] =
69 b"security.authorization-review.request-template.v1\0";
70#[cfg(feature = "secret-exposure-review")]
71const MAX_VALUE_FREE_AUTHORIZATION_PHASE_RECORDS: usize = 20;
72
73#[derive(Clone, Copy)]
74enum AuthorizationResponsePrivacy {
75 Historical,
76 #[cfg(feature = "secret-exposure-review")]
77 ValueFree,
78}
79
80impl AuthorizationResponsePrivacy {
81 const fn is_value_free(self) -> bool {
82 match self {
83 Self::Historical => false,
84 #[cfg(feature = "secret-exposure-review")]
85 Self::ValueFree => true,
86 }
87 }
88}
89
90pub(super) const AUTHORIZATION_DEFENSE_STATUS_PREDICATE: &str = "defense-status";
91pub(super) const AUTHORIZATION_DEFENSE_CHALLENGE_PREDICATE: &str = "defense-challenge";
92pub(super) const AUTHORIZATION_DEFENSE_RATE_LIMIT_PREDICATE: &str = "defense-rate-limit";
93pub(super) const AUTHORIZATION_DEFENSE_SCOPE_PREDICATE: &str = "defense-resource-scope";
94pub(super) const AUTHORIZATION_NO_RESPONSE_TERMINAL_PREDICATE: &str = "no-response-terminal";
95
96const RESOURCE_AUTHORIZATION_CAPABILITY: AssessmentCapabilityDescriptor =
97 AssessmentCapabilityDescriptor::differential_review(
98 RESOURCE_AUTHORIZATION_REVIEW_CAPABILITY_ID,
99 "Unexpected cross-principal resource equivalence observed",
100 "Authorization review",
101 "Under an operator-declared primary-only policy, two distinct authenticated principal contexts repeatedly received equivalent selected JSON resource representations.",
102 None,
103 900_000,
104 None,
105 "authorization.resource-policy-review@1",
106 "Review the intended resource-level authorization policy and verify server-side ownership, tenant, and role checks for the selected resource.",
107 );
108
109#[derive(Clone, PartialEq, Eq, Serialize)]
111pub struct WebAssessmentAuthorizationAudit {
112 policy_id: AuthorizationReviewPolicyId,
113 selected_path_count: u8,
114 ignored_path_count: u8,
115 request_count: u8,
116 outcome: AuthorizationReviewOutcome,
117 primary_stable: Option<bool>,
118 peer_stable: Option<bool>,
119 cross_resources_equivalent: Option<bool>,
120 item_projected: bool,
121}
122
123impl WebAssessmentAuthorizationAudit {
124 pub const fn policy_id(&self) -> AuthorizationReviewPolicyId {
126 self.policy_id
127 }
128
129 pub const fn selected_path_count(&self) -> u8 {
131 self.selected_path_count
132 }
133
134 pub const fn ignored_path_count(&self) -> u8 {
136 self.ignored_path_count
137 }
138
139 pub const fn request_count(&self) -> u8 {
141 self.request_count
142 }
143
144 pub const fn outcome(&self) -> AuthorizationReviewOutcome {
146 self.outcome
147 }
148
149 pub const fn primary_stable(&self) -> Option<bool> {
151 self.primary_stable
152 }
153
154 pub const fn peer_stable(&self) -> Option<bool> {
156 self.peer_stable
157 }
158
159 pub const fn cross_resources_equivalent(&self) -> Option<bool> {
161 self.cross_resources_equivalent
162 }
163
164 pub const fn item_projected(&self) -> bool {
166 self.item_projected
167 }
168
169 fn from_result(
170 policy: &AuthorizationReviewPolicy,
171 request_count: usize,
172 result: &AuthorizationDifferentialResult,
173 ) -> Self {
174 Self {
175 policy_id: result.policy_id(),
176 selected_path_count: u8::try_from(policy.selected_path_count()).unwrap_or(u8::MAX),
177 ignored_path_count: u8::try_from(policy.ignored_path_count()).unwrap_or(u8::MAX),
178 request_count: u8::try_from(request_count).unwrap_or(u8::MAX),
179 outcome: result.outcome(),
180 primary_stable: result.primary_stability().map(|value| value.all()),
181 peer_stable: result.peer_stability().map(|value| value.all()),
182 cross_resources_equivalent: result
183 .cross_candidate()
184 .zip(result.cross_replay())
185 .map(|(candidate, replay)| candidate.resources() && replay.resources()),
186 item_projected: result.outcome()
187 == AuthorizationReviewOutcome::StableCrossPrincipalEquivalence,
188 }
189 }
190
191 fn stopped(
192 policy: &AuthorizationReviewPolicy,
193 request_count: usize,
194 outcome: AuthorizationReviewOutcome,
195 ) -> Self {
196 Self {
197 policy_id: policy.policy_id(),
198 selected_path_count: u8::try_from(policy.selected_path_count()).unwrap_or(u8::MAX),
199 ignored_path_count: u8::try_from(policy.ignored_path_count()).unwrap_or(u8::MAX),
200 request_count: u8::try_from(request_count).unwrap_or(u8::MAX),
201 outcome,
202 primary_stable: None,
203 peer_stable: None,
204 cross_resources_equivalent: None,
205 item_projected: false,
206 }
207 }
208}
209
210impl fmt::Debug for WebAssessmentAuthorizationAudit {
211 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
212 formatter
213 .debug_struct("WebAssessmentAuthorizationAudit")
214 .field("policy_id", &self.policy_id)
215 .field("selected_path_count", &self.selected_path_count)
216 .field("ignored_path_count", &self.ignored_path_count)
217 .field("request_count", &self.request_count)
218 .field("outcome", &self.outcome)
219 .field("primary_stable", &self.primary_stable)
220 .field("peer_stable", &self.peer_stable)
221 .field(
222 "cross_resources_equivalent",
223 &self.cross_resources_equivalent,
224 )
225 .field("item_projected", &self.item_projected)
226 .finish()
227 }
228}
229
230pub(super) struct ResourceAuthorizationReviewConfig {
232 policy: AuthorizationReviewPolicy,
233 principals: AuthorizationPrincipalExecutionHandoff,
234 privacy: AuthorizationResponsePrivacy,
235}
236
237impl ResourceAuthorizationReviewConfig {
238 pub(super) fn new(
239 policy: AuthorizationReviewPolicy,
240 principals: AuthorizationPrincipalPair,
241 ) -> Self {
242 Self {
243 policy,
244 principals: principals.into_execution_handoff(),
245 privacy: AuthorizationResponsePrivacy::Historical,
246 }
247 }
248
249 #[cfg(feature = "secret-exposure-review")]
252 pub(super) fn with_value_free_response_privacy(mut self) -> Self {
253 self.privacy = AuthorizationResponsePrivacy::ValueFree;
254 self
255 }
256
257 pub(super) fn execution_resource(&self) -> &url::Url {
258 self.policy.execution_resource()
259 }
260}
261
262impl fmt::Debug for ResourceAuthorizationReviewConfig {
263 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
264 formatter.write_str("ResourceAuthorizationReviewConfig(<redacted>)")
265 }
266}
267
268pub(super) struct ResourceAuthorizationRuntimeBinding {
274 policy: Arc<AuthorizationReviewPolicy>,
275 proof: AuthorizationPrincipalPairProof,
276 subject: EntityId,
277 executor: Arc<ResourceAuthorizationDecisionExecutor>,
278}
279
280impl ResourceAuthorizationRuntimeBinding {
281 pub(super) fn new(
284 config: ResourceAuthorizationReviewConfig,
285 requests: HttpRequestBroker,
286 subject: EntityId,
287 ) -> Result<Self, ResourceAuthorizationRuntimeInvariantError> {
288 let ResourceAuthorizationReviewConfig {
289 policy,
290 principals,
291 privacy,
292 } = config;
293 let AuthorizationPrincipalExecutionHandoff {
294 primary_authorization,
295 peer_authorization,
296 proof,
297 } = principals;
298 let policy = Arc::new(policy);
299 let executor = Arc::new(ResourceAuthorizationDecisionExecutor {
300 requests,
301 policy: Arc::clone(&policy),
302 subject: subject.clone(),
303 primary_authorization,
304 peer_authorization,
305 privacy,
306 state: Mutex::new(ResourceAuthorizationExecutionState::default()),
307 });
308 Ok(Self {
309 policy,
310 proof,
311 subject,
312 executor,
313 })
314 }
315
316 pub(super) fn install_into_parent_registry(
319 &self,
320 registry: &mut DecisionExecutorRegistry,
321 ) -> Result<(), ResourceAuthorizationRuntimeInvariantError> {
322 let before = registry.len();
323 let registered: Arc<dyn DecisionActionExecutor> = self.executor.clone();
324 registry
325 .register(registered)
326 .map_err(|_| ResourceAuthorizationRuntimeInvariantError::Catalog)?;
327 for stage in [
328 DecisionExecutionStage::Passive,
329 DecisionExecutionStage::Active,
330 ] {
331 registry
332 .route_action(
333 stage,
334 RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID,
335 RESOURCE_AUTHORIZATION_EXECUTOR_ID,
336 )
337 .map_err(|_| ResourceAuthorizationRuntimeInvariantError::Catalog)?;
338 }
339 (registry.len() == before.saturating_add(1)
340 && registry.contains(RESOURCE_AUTHORIZATION_EXECUTOR_ID))
341 .then_some(())
342 .ok_or(ResourceAuthorizationRuntimeInvariantError::Catalog)
343 }
344
345 pub(super) fn finalize(
348 self,
349 knowledge: &KnowledgeBase,
350 standard: &super::StandardWebDecisionRunReport,
351 expected_value_free: bool,
352 forced_outcome: Option<AuthorizationReviewOutcome>,
353 forced_runtime_limit: Option<RuntimeLimitExceeded>,
354 ) -> Result<ResourceAuthorizationRuntimeResult, ResourceAuthorizationRuntimeInvariantError>
355 {
356 if self.executor.privacy.is_value_free() != expected_value_free {
357 return Err(ResourceAuthorizationRuntimeInvariantError::Catalog);
358 }
359 let transport = standard.transport();
360 if transport.omitted_receipt_count() != 0 {
361 return Err(ResourceAuthorizationRuntimeInvariantError::Catalog);
362 }
363 let authorization_receipts = transport
364 .receipts()
365 .iter()
366 .filter(|receipt| receipt.action_id() == RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID)
367 .collect::<Vec<_>>();
368 let request_count = authorization_receipts.len();
369 if request_count > MAX_AUTHORIZATION_REVIEW_REQUESTS {
370 return Err(ResourceAuthorizationRuntimeInvariantError::Catalog);
371 }
372 if !authorization_transport_prefix_is_valid(&authorization_receipts) {
373 return Err(ResourceAuthorizationRuntimeInvariantError::Catalog);
374 }
375 let state = self.executor.take_state()?;
376 #[cfg(feature = "secret-exposure-review")]
377 if self.executor.privacy.is_value_free() {
378 let parent_interrupted = forced_outcome == Some(AuthorizationReviewOutcome::Cancelled)
379 || (forced_outcome == Some(AuthorizationReviewOutcome::BudgetExhausted)
380 && forced_runtime_limit.as_ref() == standard.limit_exceeded()
381 && standard.limit_exceeded().is_some_and(|limit| {
382 limit.dimension() == crate::RuntimeBudgetDimension::WallTime
383 }));
384 validate_value_free_authorization_commits(
385 standard,
386 knowledge,
387 &self.policy,
388 &self.subject,
389 &state,
390 parent_interrupted,
391 )?;
392 }
393 let terminal = forced_outcome.or(state.terminal);
394 let runtime_limit = if forced_outcome.is_some() {
395 forced_runtime_limit
396 } else {
397 state.runtime_limit
398 };
399 if let Some(outcome) = terminal {
400 return Ok(ResourceAuthorizationRuntimeResult::Stopped {
401 audit: WebAssessmentAuthorizationAudit::stopped(
402 &self.policy,
403 request_count,
404 outcome,
405 ),
406 runtime_limit,
407 });
408 }
409 let mut captured = state.captured;
410 let exact_roles = [
411 AuthorizationViewRole::PrimaryCandidate,
412 AuthorizationViewRole::PeerCandidate,
413 AuthorizationViewRole::PrimaryReplay,
414 AuthorizationViewRole::PeerReplay,
415 ]
416 .into_iter()
417 .collect::<BTreeSet<_>>();
418 let captured_roles = captured.keys().copied().collect::<BTreeSet<_>>();
419 if request_count == 0 && captured_roles.is_empty() {
420 return Ok(ResourceAuthorizationRuntimeResult::Stopped {
421 audit: WebAssessmentAuthorizationAudit::stopped(
422 &self.policy,
423 0,
424 AuthorizationReviewOutcome::NotEligible,
425 ),
426 runtime_limit: None,
427 });
428 }
429 if request_count != MAX_AUTHORIZATION_REVIEW_REQUESTS || captured_roles != exact_roles {
430 return Ok(ResourceAuthorizationRuntimeResult::Stopped {
431 audit: WebAssessmentAuthorizationAudit::stopped(
432 &self.policy,
433 request_count,
434 AuthorizationReviewOutcome::Incomplete,
435 ),
436 runtime_limit: None,
437 });
438 }
439 let primary_candidate = take_role(&mut captured, AuthorizationViewRole::PrimaryCandidate)?;
440 let peer_candidate = take_role(&mut captured, AuthorizationViewRole::PeerCandidate)?;
441 let primary_replay = take_role(&mut captured, AuthorizationViewRole::PrimaryReplay)?;
442 let peer_replay = take_role(&mut captured, AuthorizationViewRole::PeerReplay)?;
443 if !captured.is_empty()
444 || authorization_receipts.iter().any(|receipt| {
445 receipt.request_body_bytes() != 0
446 || receipt.outcome() != TransportDispatchOutcome::Completed
447 })
448 {
449 return Err(ResourceAuthorizationRuntimeInvariantError::Catalog);
450 }
451 let result = AuthorizationDifferentialResult::compare(
452 &self.policy,
453 self.proof,
454 [
455 &primary_candidate.view,
456 &peer_candidate.view,
457 &primary_replay.view,
458 &peer_replay.view,
459 ],
460 )?;
461 let primary_evidence_ids = vec![
462 primary_candidate.evidence_id.clone(),
463 primary_replay.evidence_id.clone(),
464 ];
465 let peer_evidence_ids = vec![
466 peer_candidate.evidence_id.clone(),
467 peer_replay.evidence_id.clone(),
468 ];
469 let unique = primary_evidence_ids
470 .iter()
471 .chain(peer_evidence_ids.iter())
472 .collect::<BTreeSet<_>>();
473 if unique.len() != MAX_AUTHORIZATION_REVIEW_REQUESTS
474 || unique
475 .iter()
476 .any(|evidence_id| knowledge.evidence(evidence_id).is_none())
477 {
478 return Err(ResourceAuthorizationRuntimeInvariantError::Catalog);
479 }
480 let audit =
481 WebAssessmentAuthorizationAudit::from_result(&self.policy, request_count, &result);
482 Ok(ResourceAuthorizationRuntimeResult::Complete(Box::new(
483 CommittedResourceAuthorizationReview {
484 subject: self.subject,
485 policy_id: self.policy.policy_id(),
486 resource_scope_id: self.policy.resource_scope_id(),
487 result,
488 primary_evidence_ids,
489 peer_evidence_ids,
490 audit,
491 },
492 )))
493 }
494}
495
496fn authorization_transport_prefix_is_valid(receipts: &[&crate::TransportDispatchReceipt]) -> bool {
497 receipts.iter().enumerate().all(|(index, receipt)| {
498 let expected_stage = if index == 2 {
499 DecisionExecutionStage::Active
500 } else {
501 DecisionExecutionStage::Passive
502 };
503 let expected_origin = (expected_stage == DecisionExecutionStage::Passive)
504 .then_some(DecisionActionOrigin::Planned);
505 receipt.stage() == expected_stage && receipt.origin() == expected_origin
506 })
507}
508
509#[cfg(feature = "secret-exposure-review")]
512fn validate_value_free_authorization_commits(
513 standard: &super::StandardWebDecisionRunReport,
514 knowledge: &KnowledgeBase,
515 policy: &AuthorizationReviewPolicy,
516 subject: &EntityId,
517 state: &ResourceAuthorizationExecutionState,
518 parent_interrupted: bool,
519) -> Result<(), ResourceAuthorizationRuntimeInvariantError> {
520 use crate::{KnowledgeWrite, StandardWebDecisionRuntimeTurn};
521 let invalid = || ResourceAuthorizationRuntimeInvariantError::Catalog;
522 let receipts = standard
523 .turns()
524 .iter()
525 .filter_map(|turn| match turn {
526 StandardWebDecisionRuntimeTurn::Outcome { evidence, .. } => Some(evidence.as_ref()),
527 StandardWebDecisionRuntimeTurn::Planning(_) => None,
528 })
529 .chain(standard.unverified_evidence())
530 .filter(|receipt| {
531 receipt.case().action_id() == RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID
532 || receipt.executor_id() == RESOURCE_AUTHORIZATION_EXECUTOR_ID
533 })
534 .collect::<Vec<_>>();
535 let staged = state.staged_value_free_batches.len();
541 if receipts.len() > 2
542 || staged > 2
543 || (if parent_interrupted {
544 staged < receipts.len() || staged > receipts.len().saturating_add(1)
545 } else {
546 receipts.len() != staged
547 })
548 || (!parent_interrupted && receipts.is_empty() && state.terminal.is_some())
549 || (state.runtime_limit.is_some()
550 != (state.terminal == Some(AuthorizationReviewOutcome::BudgetExhausted)))
551 {
552 return Err(invalid());
553 }
554 let wire = standard
555 .transport()
556 .receipts()
557 .iter()
558 .filter(|receipt| receipt.action_id() == RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID)
559 .collect::<Vec<_>>();
560 let roles = [
561 AuthorizationViewRole::PrimaryCandidate,
562 AuthorizationViewRole::PeerCandidate,
563 AuthorizationViewRole::PrimaryReplay,
564 AuthorizationViewRole::PeerReplay,
565 ];
566 if state.captured.len() > roles.len()
567 || wire.len() < state.captured.len()
568 || wire.len() > state.captured.len().saturating_add(1)
569 || !roles[..state.captured.len()]
570 .iter()
571 .all(|role| state.captured.contains_key(role))
572 || (wire.len() > state.captured.len()
573 && ((!parent_interrupted && state.terminal.is_none())
574 || wire.last().is_some_and(|receipt| {
575 receipt.outcome() == TransportDispatchOutcome::Completed
576 })))
577 {
578 return Err(invalid());
579 }
580 let mut committed_ids = BTreeSet::new();
581 let mut committed_roles = BTreeSet::new();
582 let mut case_id = None;
583 for (index, receipt) in receipts.iter().enumerate() {
584 let stage = if index == 0 {
585 DecisionExecutionStage::Passive
586 } else {
587 DecisionExecutionStage::Active
588 };
589 let case = receipt.case();
590 if receipt.stage() != stage
591 || receipt.executor_id() != RESOURCE_AUTHORIZATION_EXECUTOR_ID
592 || case.action_id() != RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID
593 || case.subject() != subject
594 || case.applies_hypothesis_transition()
595 || case.payload_strategy().is_some()
596 || case_id.is_some_and(|previous| previous != case.id())
597 || receipt.evidence().len() != receipt.writes().len()
598 || receipt.evidence().len() > MAX_VALUE_FREE_AUTHORIZATION_PHASE_RECORDS
599 || receipt.evidence()
600 != state
601 .staged_value_free_batches
602 .get(&stage)
603 .ok_or_else(invalid)?
604 .as_slice()
605 {
606 return Err(invalid());
607 }
608 case_id = Some(case.id());
609 for (item, write) in receipt.write_set() {
610 let predicate = item.predicate();
611 let allowed = if predicate.namespace() == RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE {
612 matches!(
613 predicate.name(),
614 "leg"
615 | "leg-transport"
616 | "phase-terminal"
617 | "no-response-terminal"
618 | "defense-status"
619 | "defense-challenge"
620 | "defense-rate-limit"
621 | "defense-resource-scope"
622 )
623 } else if predicate.namespace()
624 == super::assessment_defense::ASSESSMENT_DEFENSE_NAMESPACE
625 {
626 matches!(
627 predicate.name(),
628 "observation_v1" | "body_coverage" | "posture" | "challenge" | "rate_limit"
629 )
630 } else {
631 predicate
632 == &termivar_core::HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED
633 .into_knowledge()
634 || predicate
635 == &crate::web_actions::native_web_review_response_marker_predicate()
636 };
637 if !allowed
638 || !matches!(write, KnowledgeWrite::Inserted | KnowledgeWrite::Unchanged)
639 || !committed_ids.insert(item.id().clone())
640 || item.subject() != subject
641 || item.source().component() != RESOURCE_AUTHORIZATION_EXECUTOR_ID
642 || item.source().correlation_id() != Some(case.id())
643 || item.reliability() != ConfidenceScore::MAX
644 || knowledge.evidence(item.id()).as_ref() != Some(item)
645 {
646 return Err(invalid());
647 }
648 }
649 super::assessment_defense::CommittedAssessmentDefenseLedger::default()
652 .ingest_receipt(receipt, knowledge, true)
653 .map_err(|_| invalid())?;
654 let mut phase_bytes = 0_u64;
655 let mut phase_roles = 0;
656 for role in ResourceAuthorizationDecisionExecutor::roles_for_stage(stage) {
657 let Some(leg) = state.captured.get(&role) else {
658 continue;
659 };
660 let metadata = leg.value_free_transport.as_ref().ok_or_else(invalid)?;
661 let root = receipt
662 .evidence()
663 .iter()
664 .find(|item| item.id() == &metadata.evidence_id)
665 .ok_or_else(invalid)?;
666 let classification = receipt
667 .evidence()
668 .iter()
669 .find(|item| item.id() == &leg.evidence_id)
670 .ok_or_else(invalid)?;
671 let root_predicate = KnowledgePredicate::new(
672 RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE,
673 "leg-transport",
674 )?;
675 let leg_predicate =
676 KnowledgePredicate::new(RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE, "leg")?;
677 let expected_classification = vec![
678 format!("role={}", role_name(role)),
679 format!("policy={}", policy.policy_id()),
680 format!("resource={}", policy.resource_scope_id()),
681 format!(
682 "request={}",
683 encode_digest(request_template_digest(policy, role))
684 ),
685 format!("status={}", leg.view.status().ok_or_else(invalid)?),
686 format!("media={}", media_name(leg.view.media_class())),
687 format!("state={}", state_name(leg.view.state())),
688 ];
689 let derivation = classification.origin().derivation().ok_or_else(invalid)?;
690 let wire_index = roles
691 .iter()
692 .position(|candidate| *candidate == role)
693 .ok_or_else(invalid)?;
694 let transport = wire.get(wire_index).ok_or_else(invalid)?;
695 if !committed_roles.insert(role)
696 || receipt.evidence().get(phase_roles * 2).map(Evidence::id) != Some(root.id())
697 || receipt
698 .evidence()
699 .get(phase_roles * 2 + 1)
700 .map(Evidence::id)
701 != Some(classification.id())
702 || leg.view.role() != role
703 || root.predicate() != &root_predicate
704 || root.kind()
705 != &EvidenceKind::Custom("authorization-review-leg-transport".to_owned())
706 || !root.origin().is_direct()
707 || root.source().method() != "bounded-json-transport"
708 || root.value()
709 != &EvidenceValue::TextList(value_free_transport_values(
710 role, &leg.view, metadata,
711 ))
712 || classification.predicate() != &leg_predicate
713 || classification.kind()
714 != &EvidenceKind::Custom("authorization-review-leg".to_owned())
715 || classification.source().method() != "bounded-json-view"
716 || classification.value() != &EvidenceValue::TextList(expected_classification)
717 || derivation.parents() != [root.id().clone()]
718 || derivation.algorithm().name() != "web.authorization-review.value-free-leg"
719 || derivation.algorithm().version() != 1
720 || transport.request_body_bytes() != 0
721 || metadata.response_bytes > transport.response_bytes()
722 || (metadata.complete
723 && !metadata.truncated
724 && metadata.response_bytes != transport.response_bytes())
725 || (leg.view.state() == AuthorizationReviewBodyState::CompleteJson
726 && (!metadata.complete
727 || metadata.truncated
728 || metadata.defense != AuthorizationResponseDefense::Clear))
729 {
730 return Err(invalid());
731 }
732 phase_bytes = phase_bytes
733 .checked_add(metadata.response_bytes)
734 .ok_or_else(invalid)?;
735 phase_roles += 1;
736 }
737 let last_leg = ResourceAuthorizationDecisionExecutor::roles_for_stage(stage)
740 .into_iter()
741 .filter_map(|role| state.captured.get(&role))
742 .next_back();
743 let defense_names = [
744 AUTHORIZATION_DEFENSE_STATUS_PREDICATE,
745 AUTHORIZATION_DEFENSE_CHALLENGE_PREDICATE,
746 AUTHORIZATION_DEFENSE_RATE_LIMIT_PREDICATE,
747 AUTHORIZATION_DEFENSE_SCOPE_PREDICATE,
748 ];
749 if let Some(last_leg) = last_leg {
750 let metadata = last_leg.value_free_transport.as_ref().ok_or_else(invalid)?;
751 let values = [
752 EvidenceValue::Unsigned(u64::from(last_leg.view.status().ok_or_else(invalid)?)),
753 EvidenceValue::Boolean(metadata.defense == AuthorizationResponseDefense::Challenge),
754 EvidenceValue::Boolean(
755 metadata.defense == AuthorizationResponseDefense::RateLimited,
756 ),
757 EvidenceValue::Text(policy.resource_scope_id().to_wire()),
758 ];
759 for (name, value) in defense_names.into_iter().zip(values) {
760 let item = authorization_receipt_fact(
761 receipt,
762 &KnowledgePredicate::new(RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE, name)?,
763 )?;
764 if item.kind()
765 != &EvidenceKind::Custom("authorization-review-defense-base".to_owned())
766 || !item.origin().is_direct()
767 || item.source().method() != "defense-base"
768 || item.value() != &value
769 {
770 return Err(invalid());
771 }
772 }
773 } else if receipt.evidence().iter().any(|item| {
774 item.predicate().namespace() == RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE
775 && defense_names.contains(&item.predicate().name())
776 }) {
777 return Err(invalid());
778 }
779 for name in ["leg", "leg-transport"] {
780 if receipt
781 .evidence()
782 .iter()
783 .filter(|item| {
784 item.predicate().namespace() == RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE
785 && item.predicate().name() == name
786 })
787 .count()
788 != phase_roles
789 {
790 return Err(invalid());
791 }
792 }
793 let bytes = authorization_receipt_fact(
794 receipt,
795 &termivar_core::HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
796 )?;
797 if bytes.kind() != &EvidenceKind::Content
798 || !bytes.origin().is_direct()
799 || bytes.source().method() != "response-body-size"
800 || bytes.value() != &EvidenceValue::Unsigned(phase_bytes)
801 {
802 return Err(invalid());
803 }
804 let terminal = authorization_receipt_fact(
805 receipt,
806 &crate::web_actions::authorization_review_phase_terminal_predicate(),
807 )?;
808 let no_response = authorization_receipt_fact(
809 receipt,
810 &KnowledgePredicate::new(
811 RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE,
812 AUTHORIZATION_NO_RESPONSE_TERMINAL_PREDICATE,
813 )?,
814 )?;
815 for item in [terminal, no_response] {
816 if item.kind() != &EvidenceKind::Custom("authorization-review-phase".to_owned())
817 || !item.origin().is_direct()
818 || item.source().method() != "phase-terminal"
819 || !matches!(item.value(), EvidenceValue::Boolean(_))
820 {
821 return Err(invalid());
822 }
823 }
824 let is_terminal = terminal.value() == &EvidenceValue::Boolean(true);
825 if (is_terminal && (state.terminal.is_none() || index + 1 != receipts.len()))
826 || (!parent_interrupted
827 && index + 1 == receipts.len()
828 && is_terminal != state.terminal.is_some())
829 || (!is_terminal && phase_roles != 2)
830 || no_response.value() != &EvidenceValue::Boolean(is_terminal && phase_roles == 0)
831 {
832 return Err(invalid());
833 }
834 let observed_terminal =
835 last_leg.and_then(|leg| terminal_outcome_for_state(leg.view.state()));
836 if is_terminal {
837 if let Some(observed) = observed_terminal {
838 if state.terminal != Some(observed) {
839 return Err(invalid());
840 }
841 } else if phase_roles >= 2
842 || !matches!(
843 state.terminal,
844 Some(
845 AuthorizationReviewOutcome::Incomplete
846 | AuthorizationReviewOutcome::BudgetExhausted
847 )
848 )
849 {
850 return Err(invalid());
854 }
855 } else if observed_terminal.is_some() {
856 return Err(invalid());
857 }
858 let marker = crate::web_actions::native_web_review_response_marker_predicate();
859 let markers = receipt
860 .evidence()
861 .iter()
862 .filter(|item| item.predicate() == &marker)
863 .collect::<Vec<_>>();
864 if stage == DecisionExecutionStage::Active && !is_terminal {
865 if markers.len() != 1
866 || !markers[0].origin().is_direct()
867 || markers[0].kind()
868 != &EvidenceKind::Custom("native-web-review-response".to_owned())
869 || markers[0].source().method() != "pair-complete"
870 || markers[0].value() != &EvidenceValue::Boolean(true)
871 {
872 return Err(invalid());
873 }
874 } else if !markers.is_empty() {
875 return Err(invalid());
876 }
877 }
878 if committed_roles.len() != state.captured.len() && !parent_interrupted {
879 return Err(invalid());
880 }
881 Ok(())
882}
883
884#[cfg(feature = "secret-exposure-review")]
885fn authorization_receipt_fact<'a>(
886 receipt: &'a crate::DecisionEvidenceReceipt,
887 predicate: &KnowledgePredicate,
888) -> Result<&'a Evidence, ResourceAuthorizationRuntimeInvariantError> {
889 let mut matches = receipt
890 .evidence()
891 .iter()
892 .filter(|item| item.predicate() == predicate);
893 let item = matches
894 .next()
895 .ok_or(ResourceAuthorizationRuntimeInvariantError::Catalog)?;
896 if matches.next().is_some() {
897 return Err(ResourceAuthorizationRuntimeInvariantError::Catalog);
898 }
899 Ok(item)
900}
901
902impl fmt::Debug for ResourceAuthorizationRuntimeBinding {
903 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
904 formatter.write_str("ResourceAuthorizationRuntimeBinding(<redacted>)")
905 }
906}
907
908#[derive(Debug, thiserror::Error)]
910pub(super) enum ResourceAuthorizationRuntimeInvariantError {
911 #[error("authorization review execution catalog violated its closed contract")]
912 Catalog,
913 #[error("authorization review view reduction failed")]
914 View(#[from] AuthorizationReviewViewError),
915 #[error("authorization review differential contract failed")]
916 Differential(#[from] AuthorizationDifferentialError),
917 #[error("authorization review evidence construction failed")]
918 Evidence(#[from] termivar_core::ReasoningModelError),
919 #[error("authorization review evidence commit failed")]
920 Knowledge(#[from] crate::KnowledgeBaseError),
921}
922
923pub(super) enum ResourceAuthorizationRuntimeResult {
924 Complete(Box<CommittedResourceAuthorizationReview>),
925 Stopped {
926 audit: WebAssessmentAuthorizationAudit,
927 runtime_limit: Option<RuntimeLimitExceeded>,
928 },
929}
930
931pub(super) struct CommittedResourceAuthorizationReview {
933 subject: EntityId,
934 policy_id: AuthorizationReviewPolicyId,
935 resource_scope_id: AuthorizationResourceScopeId,
936 result: AuthorizationDifferentialResult,
937 primary_evidence_ids: Vec<EvidenceId>,
938 peer_evidence_ids: Vec<EvidenceId>,
939 audit: WebAssessmentAuthorizationAudit,
940}
941
942impl CommittedResourceAuthorizationReview {
943 pub(super) const fn outcome(&self) -> AuthorizationReviewOutcome {
944 self.result.outcome()
945 }
946
947 pub(super) const fn audit(&self) -> &WebAssessmentAuthorizationAudit {
948 &self.audit
949 }
950}
951
952impl fmt::Debug for CommittedResourceAuthorizationReview {
953 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
954 formatter
955 .debug_struct("CommittedResourceAuthorizationReview")
956 .field("subject", &"<pseudonymous>")
957 .field("policy_id", &self.policy_id)
958 .field("resource_scope_id", &self.resource_scope_id)
959 .field("outcome", &self.result.outcome())
960 .field("evidence_count", &4)
961 .field("audit", &self.audit)
962 .finish()
963 }
964}
965
966struct CapturedAuthorizationLeg {
967 view: AuthorizationReviewView,
968 evidence_id: EvidenceId,
969 #[cfg(feature = "secret-exposure-review")]
970 value_free_transport: Option<ValueFreeAuthorizationTransport>,
971}
972
973#[cfg(feature = "secret-exposure-review")]
974struct ValueFreeAuthorizationTransport {
975 evidence_id: EvidenceId,
976 response_bytes: u64,
977 complete: bool,
978 truncated: bool,
979 defense: AuthorizationResponseDefense,
980}
981
982#[derive(Default)]
983struct ResourceAuthorizationExecutionState {
984 captured: BTreeMap<AuthorizationViewRole, CapturedAuthorizationLeg>,
985 terminal: Option<AuthorizationReviewOutcome>,
986 runtime_limit: Option<RuntimeLimitExceeded>,
987 #[cfg(feature = "secret-exposure-review")]
990 staged_value_free_batches: BTreeMap<DecisionExecutionStage, Vec<Evidence>>,
991}
992
993struct ResourceAuthorizationDecisionExecutor {
994 requests: HttpRequestBroker,
995 policy: Arc<AuthorizationReviewPolicy>,
996 subject: EntityId,
997 primary_authorization: String,
998 peer_authorization: String,
999 privacy: AuthorizationResponsePrivacy,
1000 state: Mutex<ResourceAuthorizationExecutionState>,
1001}
1002
1003impl ResourceAuthorizationDecisionExecutor {
1004 fn roles_for_stage(stage: DecisionExecutionStage) -> [AuthorizationViewRole; 2] {
1005 match stage {
1006 DecisionExecutionStage::Passive => [
1007 AuthorizationViewRole::PrimaryCandidate,
1008 AuthorizationViewRole::PeerCandidate,
1009 ],
1010 DecisionExecutionStage::Active => [
1011 AuthorizationViewRole::PrimaryReplay,
1012 AuthorizationViewRole::PeerReplay,
1013 ],
1014 }
1015 }
1016
1017 fn authorization_for(&self, role: AuthorizationViewRole) -> &str {
1018 match role {
1019 AuthorizationViewRole::PrimaryCandidate | AuthorizationViewRole::PrimaryReplay => {
1020 &self.primary_authorization
1021 },
1022 AuthorizationViewRole::PeerCandidate | AuthorizationViewRole::PeerReplay => {
1023 &self.peer_authorization
1024 },
1025 }
1026 }
1027
1028 fn take_state(
1029 &self,
1030 ) -> Result<ResourceAuthorizationExecutionState, ResourceAuthorizationRuntimeInvariantError>
1031 {
1032 let mut state = self
1033 .state
1034 .lock()
1035 .map_err(|_| ResourceAuthorizationRuntimeInvariantError::Catalog)?;
1036 Ok(std::mem::take(&mut *state))
1037 }
1038
1039 fn phase_is_ready(&self, stage: DecisionExecutionStage) -> Result<bool, DecisionExecutorError> {
1040 let state = self.state.lock().map_err(|_| {
1041 DecisionExecutorError::new("authorization capture state is unavailable")
1042 })?;
1043 let expected = match stage {
1044 DecisionExecutionStage::Passive => {
1045 state.captured.is_empty() && state.terminal.is_none()
1046 },
1047 DecisionExecutionStage::Active => {
1048 state.terminal.is_none()
1049 && state
1050 .captured
1051 .contains_key(&AuthorizationViewRole::PrimaryCandidate)
1052 && state
1053 .captured
1054 .contains_key(&AuthorizationViewRole::PeerCandidate)
1055 && !state
1056 .captured
1057 .contains_key(&AuthorizationViewRole::PrimaryReplay)
1058 && !state
1059 .captured
1060 .contains_key(&AuthorizationViewRole::PeerReplay)
1061 },
1062 };
1063 Ok(expected)
1064 }
1065
1066 fn commit_leg(
1067 &self,
1068 role: AuthorizationViewRole,
1069 leg: CapturedAuthorizationLeg,
1070 ) -> Result<(), DecisionExecutorError> {
1071 let replaced = self
1072 .state
1073 .lock()
1074 .map_err(|_| DecisionExecutorError::new("authorization capture state is unavailable"))?
1075 .captured
1076 .insert(role, leg);
1077 if replaced.is_some() {
1078 return Err(DecisionExecutorError::new(
1079 "authorization review attempted a duplicate leg",
1080 ));
1081 }
1082 Ok(())
1083 }
1084
1085 fn stop(&self, outcome: AuthorizationReviewOutcome) -> Result<(), DecisionExecutorError> {
1086 let mut state = self.state.lock().map_err(|_| {
1087 DecisionExecutorError::new("authorization capture state is unavailable")
1088 })?;
1089 if state.terminal.replace(outcome).is_some() {
1090 return Err(DecisionExecutorError::new(
1091 "authorization review attempted a duplicate terminal state",
1092 ));
1093 }
1094 Ok(())
1095 }
1096
1097 fn stop_at_runtime_limit(
1098 &self,
1099 limit: RuntimeLimitExceeded,
1100 ) -> Result<(), DecisionExecutorError> {
1101 let mut state = self.state.lock().map_err(|_| {
1102 DecisionExecutorError::new("authorization capture state is unavailable")
1103 })?;
1104 if state
1105 .terminal
1106 .replace(AuthorizationReviewOutcome::BudgetExhausted)
1107 .is_some()
1108 || state.runtime_limit.replace(limit).is_some()
1109 {
1110 return Err(DecisionExecutorError::new(
1111 "authorization review attempted a duplicate terminal state",
1112 ));
1113 }
1114 Ok(())
1115 }
1116}
1117
1118impl Drop for ResourceAuthorizationDecisionExecutor {
1119 fn drop(&mut self) {
1120 self.primary_authorization.clear();
1121 self.peer_authorization.clear();
1122 }
1123}
1124
1125#[async_trait]
1126impl DecisionActionExecutor for ResourceAuthorizationDecisionExecutor {
1127 fn id(&self) -> &str {
1128 RESOURCE_AUTHORIZATION_EXECUTOR_ID
1129 }
1130
1131 async fn execute(
1132 &self,
1133 request: &DecisionExecutionRequest,
1134 ) -> Result<Vec<Evidence>, DecisionExecutorError> {
1135 if request.case().action_id() != RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID
1136 || request.case().subject() != &self.subject
1137 || request.case().applies_hypothesis_transition()
1138 || request.case().payload_strategy().is_some()
1139 || request.delay_ms().is_some()
1140 || (request.stage() == DecisionExecutionStage::Passive && request.origin().is_none())
1141 || (request.stage() == DecisionExecutionStage::Active && request.origin().is_some())
1142 || !self.phase_is_ready(request.stage())?
1143 {
1144 return Err(DecisionExecutorError::new(
1145 "authorization executor request violated its closed route contract",
1146 ));
1147 }
1148
1149 let roles = Self::roles_for_stage(request.stage());
1150 let mut evidence = Vec::new();
1151 let mut response_bytes = 0_u64;
1152 let mut last_defense = None;
1153 for role in roles {
1157 let isolated = match self.requests.isolated_authorization_review() {
1158 Ok(isolated) => isolated,
1159 Err(_) => {
1160 self.stop(AuthorizationReviewOutcome::Incomplete)?;
1161 break;
1162 },
1163 };
1164 let transport_stage = if role == AuthorizationViewRole::PrimaryReplay {
1165 DecisionExecutionStage::Active
1166 } else {
1167 DecisionExecutionStage::Passive
1168 };
1169 let origin = (transport_stage == DecisionExecutionStage::Passive)
1170 .then_some(DecisionActionOrigin::Planned);
1171 let response = isolated
1172 .collect_authorized_json_get_for_runtime(
1173 RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID,
1174 transport_stage,
1175 origin,
1176 request.limits(),
1177 self.policy.execution_resource(),
1178 self.authorization_for(role),
1179 )
1180 .await;
1181 let response = match response {
1182 Ok(response) => response,
1183 Err(HttpRequestBrokerError::RuntimeLimit(limit)) => {
1184 self.stop_at_runtime_limit(limit)?;
1185 break;
1186 },
1187 Err(HttpRequestBrokerError::Http(_)) => {
1188 self.stop(AuthorizationReviewOutcome::Incomplete)?;
1189 break;
1190 },
1191 };
1192 response_bytes = response_bytes
1193 .saturating_add(u64::try_from(response.body().len()).unwrap_or(u64::MAX));
1194 let captured = capture_response(
1195 &self.policy,
1196 &self.subject,
1197 request.case().id(),
1198 role,
1199 response,
1200 self.privacy,
1201 )?;
1202 last_defense = Some(captured.defense);
1203 let terminal = terminal_outcome_for_state(captured.leg.view.state());
1204 evidence.extend(captured.evidence);
1205 self.commit_leg(role, captured.leg)?;
1206 if let Some(outcome) = terminal {
1207 self.stop(outcome)?;
1208 break;
1209 }
1210 }
1211 let terminal = self
1212 .state
1213 .lock()
1214 .map_err(|_| DecisionExecutorError::new("authorization capture state is unavailable"))?
1215 .terminal;
1216 append_phase_evidence(
1217 &mut evidence,
1218 &self.policy,
1219 request,
1220 response_bytes,
1221 last_defense,
1222 terminal,
1223 )?;
1224 #[cfg(feature = "secret-exposure-review")]
1225 if self.privacy.is_value_free() {
1226 let mut state = self.state.lock().map_err(|_| {
1227 DecisionExecutorError::new("authorization capture state is unavailable")
1228 })?;
1229 if evidence.len() > MAX_VALUE_FREE_AUTHORIZATION_PHASE_RECORDS
1230 || state.staged_value_free_batches.len() >= 2
1231 || state
1232 .staged_value_free_batches
1233 .insert(request.stage(), evidence.clone())
1234 .is_some()
1235 {
1236 return Err(DecisionExecutorError::new(
1237 "authorization privacy phase was duplicated",
1238 ));
1239 }
1240 }
1241 Ok(evidence)
1242 }
1243}
1244
1245struct CapturedResponse {
1246 leg: CapturedAuthorizationLeg,
1247 evidence: Vec<Evidence>,
1248 defense: AuthorizationPhaseDefense,
1249}
1250
1251#[derive(Clone, Copy)]
1252struct AuthorizationPhaseDefense {
1253 status: u16,
1254 classification: AuthorizationResponseDefense,
1255}
1256
1257fn capture_response(
1258 policy: &AuthorizationReviewPolicy,
1259 subject: &EntityId,
1260 correlation_id: &str,
1261 role: AuthorizationViewRole,
1262 response: crate::http_evidence::CollectedHttpResponse,
1263 privacy: AuthorizationResponsePrivacy,
1264) -> Result<CapturedResponse, DecisionExecutorError> {
1265 if response.final_url() != policy.execution_resource() {
1266 return Err(DecisionExecutorError::new(
1267 "authorization review transport changed the exact resource",
1268 ));
1269 }
1270 let media_type = response.normalized_media_type();
1271 let media_class = if response.has_json_compatible_media_type() {
1272 AuthorizationReviewMediaClass::JsonCompatible
1273 } else if matches!(
1274 media_type.as_deref(),
1275 Some("text/html" | "application/xhtml+xml")
1276 ) {
1277 AuthorizationReviewMediaClass::Html
1278 } else if media_type.is_some() {
1279 AuthorizationReviewMediaClass::Other
1280 } else {
1281 AuthorizationReviewMediaClass::Missing
1282 };
1283 let status = response.status();
1284 let response_digest: [u8; 32] = Sha256::digest(response.body()).into();
1285 let receipt_digest = view_receipt_digest(policy, role, status, media_class, response_digest);
1286 let receipt_id = AuthorizationViewReceiptId::from_digest(receipt_digest);
1287 let defense = response.authorization_response_defense();
1288 let view = if response.body_truncated() {
1289 AuthorizationReviewView::terminal(
1290 policy,
1291 role,
1292 Some(status),
1293 media_class,
1294 AuthorizationReviewBodyState::Truncated,
1295 receipt_id,
1296 )
1297 } else if !response.body_complete() {
1298 AuthorizationReviewView::terminal(
1299 policy,
1300 role,
1301 Some(status),
1302 media_class,
1303 AuthorizationReviewBodyState::Incomplete,
1304 receipt_id,
1305 )
1306 } else if defense == AuthorizationResponseDefense::RateLimited {
1307 AuthorizationReviewView::terminal(
1308 policy,
1309 role,
1310 Some(status),
1311 media_class,
1312 AuthorizationReviewBodyState::RateLimited,
1313 receipt_id,
1314 )
1315 } else if defense == AuthorizationResponseDefense::Challenge {
1316 AuthorizationReviewView::terminal(
1317 policy,
1318 role,
1319 Some(status),
1320 media_class,
1321 AuthorizationReviewBodyState::DefensiveInterference,
1322 receipt_id,
1323 )
1324 } else if (300..400).contains(&status) {
1325 AuthorizationReviewView::terminal(
1326 policy,
1327 role,
1328 Some(status),
1329 media_class,
1330 AuthorizationReviewBodyState::Redirect,
1331 receipt_id,
1332 )
1333 } else if (500..600).contains(&status) {
1334 AuthorizationReviewView::terminal(
1335 policy,
1336 role,
1337 Some(status),
1338 media_class,
1339 AuthorizationReviewBodyState::ServerError,
1340 receipt_id,
1341 )
1342 } else if media_class == AuthorizationReviewMediaClass::Html {
1343 AuthorizationReviewView::terminal(
1344 policy,
1345 role,
1346 Some(status),
1347 media_class,
1348 AuthorizationReviewBodyState::Html,
1349 receipt_id,
1350 )
1351 } else if media_class != AuthorizationReviewMediaClass::JsonCompatible {
1352 AuthorizationReviewView::terminal(
1353 policy,
1354 role,
1355 Some(status),
1356 media_class,
1357 AuthorizationReviewBodyState::UnsupportedMedia,
1358 receipt_id,
1359 )
1360 } else {
1361 match serde_json::from_slice::<serde_json::Value>(response.body()) {
1362 Ok(json) => {
1363 AuthorizationReviewView::capture_json(policy, role, status, &json, receipt_id)
1364 },
1365 Err(_) => AuthorizationReviewView::terminal(
1366 policy,
1367 role,
1368 Some(status),
1369 media_class,
1370 AuthorizationReviewBodyState::MalformedJson,
1371 receipt_id,
1372 ),
1373 }
1374 }
1375 .map_err(|_| DecisionExecutorError::new("authorization response reduction failed"))?;
1376
1377 let request_digest = request_template_digest(policy, role);
1378 let mut values = vec![
1379 format!("role={}", role_name(role)),
1380 format!("policy={}", policy.policy_id()),
1381 format!("resource={}", policy.resource_scope_id()),
1382 format!("request={}", encode_digest(request_digest)),
1383 ];
1384 if !privacy.is_value_free() {
1385 values.push(format!("response={}", encode_digest(response_digest)));
1387 values.push(format!("receipt={}", encode_digest(receipt_digest)));
1388 }
1389 values.extend([
1390 format!("status={status}"),
1391 format!("media={}", media_name(media_class)),
1392 format!("state={}", state_name(view.state())),
1393 ]);
1394 let evidence = Evidence::new(
1395 subject.clone(),
1396 EvidenceKind::Custom("authorization-review-leg".to_owned()),
1397 KnowledgePredicate::new(RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE, "leg").map_err(
1398 |_| DecisionExecutorError::new("authorization evidence predicate is invalid"),
1399 )?,
1400 EvidenceValue::TextList(values),
1401 EvidenceSource::new(RESOURCE_AUTHORIZATION_EXECUTOR_ID, "bounded-json-view")
1402 .and_then(|source| source.with_correlation_id(correlation_id))
1403 .map_err(|_| DecisionExecutorError::new("authorization evidence source is invalid"))?,
1404 ConfidenceScore::MAX,
1405 );
1406 #[cfg(feature = "secret-exposure-review")]
1407 let (evidence, value_free_transport) = if privacy.is_value_free() {
1408 let metadata = ValueFreeAuthorizationTransport {
1409 evidence_id: evidence.id().clone(),
1411 response_bytes: u64::try_from(response.body().len()).unwrap_or(u64::MAX),
1412 complete: response.body_complete(),
1413 truncated: response.body_truncated(),
1414 defense,
1415 };
1416 let root = Evidence::new(
1417 subject.clone(),
1418 EvidenceKind::Custom("authorization-review-leg-transport".to_owned()),
1419 KnowledgePredicate::new(RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE, "leg-transport")
1420 .map_err(|_| DecisionExecutorError::new("authorization predicate is invalid"))?,
1421 EvidenceValue::TextList(value_free_transport_values(role, &view, &metadata)),
1422 EvidenceSource::new(RESOURCE_AUTHORIZATION_EXECUTOR_ID, "bounded-json-transport")
1423 .and_then(|source| source.with_correlation_id(correlation_id))
1424 .map_err(|_| {
1425 DecisionExecutorError::new("authorization evidence source is invalid")
1426 })?,
1427 ConfidenceScore::MAX,
1428 );
1429 let metadata = ValueFreeAuthorizationTransport {
1430 evidence_id: root.id().clone(),
1431 ..metadata
1432 };
1433 let derivation = termivar_core::EvidenceDerivation::new(
1434 vec![root.id().clone()],
1435 termivar_core::DerivationAlgorithm::new("web.authorization-review.value-free-leg", 1)
1436 .map_err(|_| DecisionExecutorError::new("authorization derivation is invalid"))?,
1437 )
1438 .map_err(|_| DecisionExecutorError::new("authorization lineage is invalid"))?;
1439 (
1440 vec![root, evidence.derived_from(derivation)],
1441 Some(metadata),
1442 )
1443 } else {
1444 (vec![evidence], None)
1445 };
1446 #[cfg(not(feature = "secret-exposure-review"))]
1447 let evidence = vec![evidence];
1448 let evidence_id = evidence
1449 .last()
1450 .expect("an authorization leg always produces its classification")
1451 .id()
1452 .clone();
1453 Ok(CapturedResponse {
1454 leg: CapturedAuthorizationLeg {
1455 view,
1456 evidence_id,
1457 #[cfg(feature = "secret-exposure-review")]
1458 value_free_transport,
1459 },
1460 evidence,
1461 defense: AuthorizationPhaseDefense {
1462 status,
1463 classification: defense,
1464 },
1465 })
1466}
1467
1468#[cfg(feature = "secret-exposure-review")]
1469fn value_free_transport_values(
1470 role: AuthorizationViewRole,
1471 view: &AuthorizationReviewView,
1472 metadata: &ValueFreeAuthorizationTransport,
1473) -> Vec<String> {
1474 vec![
1475 format!("role={}", role_name(role)),
1476 "method=GET".to_owned(),
1477 format!("status={}", view.status().unwrap_or(0)),
1478 format!("media={}", media_name(view.media_class())),
1479 format!("complete={}", metadata.complete),
1480 format!("truncated={}", metadata.truncated),
1481 format!("bytes={}", metadata.response_bytes),
1482 format!(
1483 "defense={}",
1484 match metadata.defense {
1485 AuthorizationResponseDefense::Clear => "clear",
1486 AuthorizationResponseDefense::Challenge => "challenge",
1487 AuthorizationResponseDefense::RateLimited => "rate-limited",
1488 }
1489 ),
1490 ]
1491}
1492
1493fn take_role(
1494 captured: &mut BTreeMap<AuthorizationViewRole, CapturedAuthorizationLeg>,
1495 role: AuthorizationViewRole,
1496) -> Result<CapturedAuthorizationLeg, ResourceAuthorizationRuntimeInvariantError> {
1497 captured
1498 .remove(&role)
1499 .ok_or(ResourceAuthorizationRuntimeInvariantError::Catalog)
1500}
1501
1502fn terminal_outcome_for_state(
1503 state: AuthorizationReviewBodyState,
1504) -> Option<AuthorizationReviewOutcome> {
1505 Some(match state {
1506 AuthorizationReviewBodyState::CompleteJson => return None,
1507 AuthorizationReviewBodyState::RateLimited => AuthorizationReviewOutcome::RateLimited,
1508 AuthorizationReviewBodyState::DefensiveInterference => {
1509 AuthorizationReviewOutcome::DefensiveInterference
1510 },
1511 AuthorizationReviewBodyState::Redirect => AuthorizationReviewOutcome::RedirectObserved,
1512 AuthorizationReviewBodyState::UnsupportedMedia | AuthorizationReviewBodyState::Html => {
1513 AuthorizationReviewOutcome::UnsupportedMedia
1514 },
1515 AuthorizationReviewBodyState::MalformedJson => AuthorizationReviewOutcome::MalformedJson,
1516 AuthorizationReviewBodyState::Truncated => AuthorizationReviewOutcome::Truncated,
1517 AuthorizationReviewBodyState::BudgetExhausted => {
1518 AuthorizationReviewOutcome::BudgetExhausted
1519 },
1520 AuthorizationReviewBodyState::Cancelled => AuthorizationReviewOutcome::Cancelled,
1521 AuthorizationReviewBodyState::ServerError | AuthorizationReviewBodyState::Incomplete => {
1522 AuthorizationReviewOutcome::Incomplete
1523 },
1524 })
1525}
1526
1527fn append_phase_evidence(
1528 evidence: &mut Vec<Evidence>,
1529 policy: &AuthorizationReviewPolicy,
1530 request: &DecisionExecutionRequest,
1531 response_bytes: u64,
1532 defense: Option<AuthorizationPhaseDefense>,
1533 terminal: Option<AuthorizationReviewOutcome>,
1534) -> Result<(), DecisionExecutorError> {
1535 let source = |method: &'static str| {
1536 EvidenceSource::new(RESOURCE_AUTHORIZATION_EXECUTOR_ID, method)
1537 .and_then(|source| source.with_correlation_id(request.case().id()))
1538 .map_err(|_| DecisionExecutorError::new("authorization evidence source is invalid"))
1539 };
1540 let direct = |kind: EvidenceKind,
1541 predicate: KnowledgePredicate,
1542 value: EvidenceValue,
1543 method: &'static str|
1544 -> Result<Evidence, DecisionExecutorError> {
1545 Ok(Evidence::new(
1546 request.case().subject().clone(),
1547 kind,
1548 predicate,
1549 value,
1550 source(method)?,
1551 ConfidenceScore::MAX,
1552 ))
1553 };
1554 evidence.push(direct(
1555 EvidenceKind::Content,
1556 termivar_core::HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
1557 EvidenceValue::Unsigned(response_bytes),
1558 "response-body-size",
1559 )?);
1560 let no_response_terminal = defense.is_none() && terminal.is_some();
1561 evidence.push(direct(
1562 EvidenceKind::Custom("authorization-review-phase".to_owned()),
1563 KnowledgePredicate::new(
1564 RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE,
1565 AUTHORIZATION_NO_RESPONSE_TERMINAL_PREDICATE,
1566 )
1567 .map_err(|_| DecisionExecutorError::new("authorization predicate is invalid"))?,
1568 EvidenceValue::Boolean(no_response_terminal),
1569 "phase-terminal",
1570 )?);
1571 evidence.push(direct(
1572 EvidenceKind::Custom("authorization-review-phase".to_owned()),
1573 crate::web_actions::authorization_review_phase_terminal_predicate(),
1574 EvidenceValue::Boolean(terminal.is_some()),
1575 "phase-terminal",
1576 )?);
1577
1578 if request.stage() == DecisionExecutionStage::Active && terminal.is_none() {
1579 evidence.push(direct(
1580 EvidenceKind::Custom("native-web-review-response".to_owned()),
1581 crate::web_actions::native_web_review_response_marker_predicate(),
1582 EvidenceValue::Boolean(true),
1583 "pair-complete",
1584 )?);
1585 }
1586
1587 let Some(defense) = defense else {
1588 return Ok(());
1589 };
1590 let (challenged, rate_limited) = match defense.classification {
1591 AuthorizationResponseDefense::Clear => (false, false),
1592 AuthorizationResponseDefense::Challenge => (true, false),
1593 AuthorizationResponseDefense::RateLimited => (false, true),
1594 };
1595 let status = defense.status;
1596 let base = [
1597 (
1598 AUTHORIZATION_DEFENSE_STATUS_PREDICATE,
1599 EvidenceValue::Unsigned(u64::from(status)),
1600 ),
1601 (
1602 AUTHORIZATION_DEFENSE_CHALLENGE_PREDICATE,
1603 EvidenceValue::Boolean(challenged),
1604 ),
1605 (
1606 AUTHORIZATION_DEFENSE_RATE_LIMIT_PREDICATE,
1607 EvidenceValue::Boolean(rate_limited),
1608 ),
1609 (
1610 AUTHORIZATION_DEFENSE_SCOPE_PREDICATE,
1611 EvidenceValue::Text(policy.resource_scope_id().to_wire()),
1612 ),
1613 ];
1614 let mut parents = Vec::with_capacity(base.len() + 1);
1615 parents.push(
1616 evidence
1617 .iter()
1618 .find(|item| {
1619 item.predicate()
1620 == &termivar_core::HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED
1621 .into_knowledge()
1622 })
1623 .ok_or_else(|| DecisionExecutorError::new("authorization byte evidence is missing"))?
1624 .id()
1625 .clone(),
1626 );
1627 for (name, value) in base {
1628 let item = direct(
1629 EvidenceKind::Custom("authorization-review-defense-base".to_owned()),
1630 KnowledgePredicate::new(RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE, name)
1631 .map_err(|_| DecisionExecutorError::new("authorization predicate is invalid"))?,
1632 value,
1633 "defense-base",
1634 )?;
1635 parents.push(item.id().clone());
1636 evidence.push(item);
1637 }
1638 let signal = AssessmentDefenseSignal::new(
1639 DefenseState::from_authorization_projection(status, challenged, rate_limited),
1640 AssessmentDefenseBodyCoverage::MetadataOnly,
1641 false,
1642 );
1643 evidence.extend(
1644 project_assessment_defense_signal(
1645 &signal,
1646 AssessmentDefenseProjectionContext {
1647 subject: request.case().subject(),
1648 case_id: request.case().id(),
1649 executor_id: RESOURCE_AUTHORIZATION_EXECUTOR_ID,
1650 reliability: ConfidenceScore::MAX,
1651 parents,
1652 },
1653 )
1654 .map_err(|_| DecisionExecutorError::new("authorization defense projection failed"))?,
1655 );
1656 Ok(())
1657}
1658
1659fn view_receipt_digest(
1660 policy: &AuthorizationReviewPolicy,
1661 role: AuthorizationViewRole,
1662 status: u16,
1663 media: AuthorizationReviewMediaClass,
1664 response_digest: [u8; 32],
1665) -> [u8; 32] {
1666 let mut digest = Sha256::new();
1667 update_framed(&mut digest, RESOURCE_AUTHORIZATION_RECEIPT_DOMAIN);
1668 update_framed(&mut digest, &policy.policy_id().as_bytes());
1669 update_framed(&mut digest, &policy.resource_scope_id().as_bytes());
1670 update_framed(&mut digest, role_name(role).as_bytes());
1671 update_framed(&mut digest, &status.to_be_bytes());
1672 update_framed(&mut digest, media_name(media).as_bytes());
1673 update_framed(&mut digest, &response_digest);
1674 digest.finalize().into()
1675}
1676
1677fn request_template_digest(
1678 policy: &AuthorizationReviewPolicy,
1679 role: AuthorizationViewRole,
1680) -> [u8; 32] {
1681 let mut digest = Sha256::new();
1682 update_framed(&mut digest, RESOURCE_AUTHORIZATION_REQUEST_DOMAIN);
1683 update_framed(&mut digest, &policy.policy_id().as_bytes());
1684 update_framed(&mut digest, &policy.resource_scope_id().as_bytes());
1685 update_framed(&mut digest, b"GET");
1686 update_framed(&mut digest, b"application/json");
1687 let principal_role: &[u8] = match role {
1688 AuthorizationViewRole::PrimaryCandidate | AuthorizationViewRole::PrimaryReplay => {
1689 b"primary".as_slice()
1690 },
1691 AuthorizationViewRole::PeerCandidate | AuthorizationViewRole::PeerReplay => {
1692 b"peer".as_slice()
1693 },
1694 };
1695 update_framed(&mut digest, principal_role);
1696 digest.finalize().into()
1697}
1698
1699fn update_framed(digest: &mut Sha256, value: &[u8]) {
1700 digest.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
1701 digest.update(value);
1702}
1703
1704fn encode_digest(value: [u8; 32]) -> String {
1705 const HEX: &[u8; 16] = b"0123456789abcdef";
1706 let mut output = String::with_capacity(64);
1707 for byte in value {
1708 output.push(char::from(HEX[usize::from(byte >> 4)]));
1709 output.push(char::from(HEX[usize::from(byte & 0x0f)]));
1710 }
1711 output
1712}
1713
1714const fn role_name(role: AuthorizationViewRole) -> &'static str {
1715 match role {
1716 AuthorizationViewRole::PrimaryCandidate => "primary_candidate",
1717 AuthorizationViewRole::PeerCandidate => "peer_candidate",
1718 AuthorizationViewRole::PrimaryReplay => "primary_replay",
1719 AuthorizationViewRole::PeerReplay => "peer_replay",
1720 }
1721}
1722
1723const fn media_name(media: AuthorizationReviewMediaClass) -> &'static str {
1724 match media {
1725 AuthorizationReviewMediaClass::JsonCompatible => "json_compatible",
1726 AuthorizationReviewMediaClass::Html => "html",
1727 AuthorizationReviewMediaClass::Other => "other",
1728 AuthorizationReviewMediaClass::Missing => "missing",
1729 }
1730}
1731
1732const fn state_name(state: AuthorizationReviewBodyState) -> &'static str {
1733 match state {
1734 AuthorizationReviewBodyState::CompleteJson => "complete_json",
1735 AuthorizationReviewBodyState::UnsupportedMedia => "unsupported_media",
1736 AuthorizationReviewBodyState::Html => "html",
1737 AuthorizationReviewBodyState::Redirect => "redirect",
1738 AuthorizationReviewBodyState::RateLimited => "rate_limited",
1739 AuthorizationReviewBodyState::ServerError => "server_error",
1740 AuthorizationReviewBodyState::MalformedJson => "malformed_json",
1741 AuthorizationReviewBodyState::Truncated => "truncated",
1742 AuthorizationReviewBodyState::Incomplete => "incomplete",
1743 AuthorizationReviewBodyState::BudgetExhausted => "budget_exhausted",
1744 AuthorizationReviewBodyState::Cancelled => "cancelled",
1745 AuthorizationReviewBodyState::DefensiveInterference => "defensive_interference",
1746 }
1747}
1748
1749pub(super) fn project_resource_authorization_item(
1751 context: &mut AssessmentProjectionContext,
1752 knowledge: &KnowledgeBase,
1753 review: &CommittedResourceAuthorizationReview,
1754) -> Result<(), AssessmentItemProjectionError> {
1755 let all = review
1756 .primary_evidence_ids
1757 .iter()
1758 .chain(review.peer_evidence_ids.iter())
1759 .cloned()
1760 .collect::<BTreeSet<_>>();
1761 if all.len() != MAX_AUTHORIZATION_REVIEW_REQUESTS {
1762 return Err(AssessmentItemProjectionError::DuplicateEvidenceReference);
1763 }
1764 for evidence_id in all {
1765 context.register_evidence(knowledge, &evidence_id)?;
1766 }
1767 if review.outcome() == AuthorizationReviewOutcome::StableCrossPrincipalEquivalence {
1768 let mut target_digest = Sha256::new();
1769 update_framed(
1770 &mut target_digest,
1771 b"security.authorization-review.item-target.v1\0",
1772 );
1773 update_framed(&mut target_digest, &review.policy_id.as_bytes());
1774 update_framed(&mut target_digest, &review.resource_scope_id.as_bytes());
1775 update_framed(
1776 &mut target_digest,
1777 crate::authorization_review::AUTHORIZATION_REVIEW_ALGORITHM_VERSION.as_bytes(),
1778 );
1779 let target = AssessmentItemTarget::authorization_resource(format!(
1780 "authorization-resource@1:{}",
1781 encode_digest(target_digest.finalize().into())
1782 ))?;
1783 context.project_differential(
1784 &RESOURCE_AUTHORIZATION_CAPABILITY,
1785 knowledge,
1786 &review.subject,
1787 &target,
1788 &review.primary_evidence_ids,
1789 &review.peer_evidence_ids,
1790 )?;
1791 }
1792 Ok(())
1793}
1794
1795#[cfg(test)]
1796mod tests {
1797 use super::*;
1798
1799 #[test]
1800 fn closed_catalog_and_audit_debug_are_bounded() {
1801 assert_eq!(MAX_AUTHORIZATION_REVIEW_RESOURCES, 1);
1802 assert_eq!(MAX_AUTHORIZATION_REVIEW_REQUESTS, 4);
1803 assert_eq!(MAX_AUTHORIZATION_REVIEW_ACTIVE_VERIFICATIONS, 1);
1804 assert_eq!(
1805 ResourceAuthorizationDecisionExecutor::roles_for_stage(DecisionExecutionStage::Passive),
1806 [
1807 AuthorizationViewRole::PrimaryCandidate,
1808 AuthorizationViewRole::PeerCandidate
1809 ]
1810 );
1811 assert_eq!(
1812 ResourceAuthorizationDecisionExecutor::roles_for_stage(DecisionExecutionStage::Active),
1813 [
1814 AuthorizationViewRole::PrimaryReplay,
1815 AuthorizationViewRole::PeerReplay
1816 ]
1817 );
1818 }
1819
1820 #[test]
1821 fn request_identity_is_role_stable_and_credential_free() {
1822 let origin = url::Url::parse("https://example.test/").unwrap();
1823 let source = br#"
1824schema = "security.authorization-review-policy/v1"
1825resource = "/api/accounts/42"
1826resource_handle = "account-self-profile"
1827expectation = "primary-only"
1828method = "GET"
1829[comparison]
1830selected_paths = ["/data/account"]
1831ignored_paths = []
1832unordered_array_paths = []
1833max_diff_paths = 8
1834"#;
1835 let policy = AuthorizationReviewPolicy::parse_toml(&origin, source).unwrap();
1836 assert_eq!(
1837 request_template_digest(&policy, AuthorizationViewRole::PrimaryCandidate),
1838 request_template_digest(&policy, AuthorizationViewRole::PrimaryReplay)
1839 );
1840 assert_ne!(
1841 request_template_digest(&policy, AuthorizationViewRole::PrimaryCandidate),
1842 request_template_digest(&policy, AuthorizationViewRole::PeerCandidate)
1843 );
1844 }
1845
1846 #[cfg(feature = "secret-exposure-review")]
1847 mod privacy_tests {
1848 use super::*;
1849 use crate::{
1850 runtime_budget::RequestAccountingBroker, DecisionEvidenceReceipt, DecisionLoopCommand,
1851 DecisionRunnerAdapter, DecisionStopReason, KnowledgeWrite, RuntimeBudget, RuntimeUsage,
1852 StandardWebDecisionRunReport, VerificationCase,
1853 };
1854 use termivar_core::{DerivationAlgorithm, EvidenceDerivation};
1855
1856 const CASE: &str = "case:synthetic-authorization-value-free";
1857 const BODY: &[u8] = br#"{"data":{"account":{"id":"alpha"}}}"#;
1858
1859 fn subject() -> EntityId {
1860 EntityId::new("endpoint:https://example.test/").unwrap()
1861 }
1862
1863 fn policy() -> AuthorizationReviewPolicy {
1864 AuthorizationReviewPolicy::parse_toml(
1865 &url::Url::parse("https://example.test/").unwrap(),
1866 br#"schema = "security.authorization-review-policy/v1"
1867resource = "/api/accounts/42"
1868resource_handle = "synthetic-selected-account"
1869expectation = "primary-only"
1870method = "GET"
1871[comparison]
1872selected_paths = ["/data/account"]
1873ignored_paths = []
1874unordered_array_paths = []
1875max_diff_paths = 8
1876"#,
1877 )
1878 .unwrap()
1879 }
1880
1881 fn fact(name: &str, kind: EvidenceKind, value: EvidenceValue, method: &str) -> Evidence {
1882 Evidence::new(
1883 subject(),
1884 kind,
1885 KnowledgePredicate::new(RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE, name).unwrap(),
1886 value,
1887 EvidenceSource::new(RESOURCE_AUTHORIZATION_EXECUTOR_ID, method)
1888 .unwrap()
1889 .with_correlation_id(CASE)
1890 .unwrap(),
1891 ConfidenceScore::MAX,
1892 )
1893 }
1894
1895 fn independent_request(policy: &AuthorizationReviewPolicy) -> String {
1898 let mut sha = Sha256::new();
1899 for part in [
1900 b"security.authorization-review.request-template.v1\0".as_slice(),
1901 &policy.policy_id().as_bytes(),
1902 &policy.resource_scope_id().as_bytes(),
1903 b"GET",
1904 b"application/json",
1905 b"primary",
1906 ] {
1907 sha.update((part.len() as u64).to_be_bytes());
1908 sha.update(part);
1909 }
1910 format!("{:x}", sha.finalize())
1911 }
1912
1913 fn literal_batch(policy: &AuthorizationReviewPolicy) -> Vec<Evidence> {
1914 let mut batch = Vec::new();
1915 for role in ["primary_candidate", "peer_candidate"] {
1916 let request = if role == "primary_candidate" {
1917 independent_request(policy)
1918 } else {
1919 let mut sha = Sha256::new();
1920 for part in [
1921 b"security.authorization-review.request-template.v1\0".as_slice(),
1922 &policy.policy_id().as_bytes(),
1923 &policy.resource_scope_id().as_bytes(),
1924 b"GET",
1925 b"application/json",
1926 b"peer",
1927 ] {
1928 sha.update((part.len() as u64).to_be_bytes());
1929 sha.update(part);
1930 }
1931 format!("{:x}", sha.finalize())
1932 };
1933 let root = fact(
1934 "leg-transport",
1935 EvidenceKind::Custom("authorization-review-leg-transport".to_owned()),
1936 EvidenceValue::TextList(vec![
1937 format!("role={role}"),
1938 "method=GET".to_owned(),
1939 "status=200".to_owned(),
1940 "media=json_compatible".to_owned(),
1941 "complete=true".to_owned(),
1942 "truncated=false".to_owned(),
1943 format!("bytes={}", BODY.len()),
1944 "defense=clear".to_owned(),
1945 ]),
1946 "bounded-json-transport",
1947 );
1948 let leg = fact(
1949 "leg",
1950 EvidenceKind::Custom("authorization-review-leg".to_owned()),
1951 EvidenceValue::TextList(vec![
1952 format!("role={role}"),
1953 format!("policy={}", policy.policy_id()),
1954 format!("resource={}", policy.resource_scope_id()),
1955 format!("request={request}"),
1956 "status=200".to_owned(),
1957 "media=json_compatible".to_owned(),
1958 "state=complete_json".to_owned(),
1959 ]),
1960 "bounded-json-view",
1961 )
1962 .derived_from(
1963 EvidenceDerivation::new(
1964 vec![root.id().clone()],
1965 DerivationAlgorithm::new("web.authorization-review.value-free-leg", 1)
1966 .unwrap(),
1967 )
1968 .unwrap(),
1969 );
1970 batch.extend([root, leg]);
1971 }
1972 let bytes = Evidence::new(
1973 subject(),
1974 EvidenceKind::Content,
1975 termivar_core::HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
1976 EvidenceValue::Unsigned((BODY.len() * 2) as u64),
1977 EvidenceSource::new(RESOURCE_AUTHORIZATION_EXECUTOR_ID, "response-body-size")
1978 .unwrap()
1979 .with_correlation_id(CASE)
1980 .unwrap(),
1981 ConfidenceScore::MAX,
1982 );
1983 let mut defense_parents = vec![bytes.id().clone()];
1984 batch.push(bytes);
1985 for name in ["no-response-terminal", "phase-terminal"] {
1986 batch.push(fact(
1987 name,
1988 EvidenceKind::Custom("authorization-review-phase".to_owned()),
1989 EvidenceValue::Boolean(false),
1990 "phase-terminal",
1991 ));
1992 }
1993 for (name, value) in [
1994 ("defense-status", EvidenceValue::Unsigned(200)),
1995 ("defense-challenge", EvidenceValue::Boolean(false)),
1996 ("defense-rate-limit", EvidenceValue::Boolean(false)),
1997 (
1998 "defense-resource-scope",
1999 EvidenceValue::Text(policy.resource_scope_id().to_wire()),
2000 ),
2001 ] {
2002 let item = fact(
2003 name,
2004 EvidenceKind::Custom("authorization-review-defense-base".to_owned()),
2005 value,
2006 "defense-base",
2007 );
2008 defense_parents.push(item.id().clone());
2009 batch.push(item);
2010 }
2011 batch.extend(
2014 project_assessment_defense_signal(
2015 &AssessmentDefenseSignal::new(
2016 DefenseState::from_authorization_projection(200, false, false),
2017 AssessmentDefenseBodyCoverage::MetadataOnly,
2018 false,
2019 ),
2020 AssessmentDefenseProjectionContext {
2021 subject: &subject(),
2022 case_id: CASE,
2023 executor_id: RESOURCE_AUTHORIZATION_EXECUTOR_ID,
2024 reliability: ConfidenceScore::MAX,
2025 parents: defense_parents,
2026 },
2027 )
2028 .unwrap(),
2029 );
2030 batch
2031 }
2032
2033 fn state(
2034 policy: &AuthorizationReviewPolicy,
2035 original: &[Evidence],
2036 staged: Vec<Evidence>,
2037 ) -> ResourceAuthorizationExecutionState {
2038 let mut captured = BTreeMap::new();
2039 for (index, role) in [
2040 AuthorizationViewRole::PrimaryCandidate,
2041 AuthorizationViewRole::PeerCandidate,
2042 ]
2043 .into_iter()
2044 .enumerate()
2045 {
2046 let view = AuthorizationReviewView::capture_json(
2049 policy,
2050 role,
2051 200,
2052 &serde_json::from_slice(BODY).unwrap(),
2053 AuthorizationViewReceiptId::from_digest([index as u8 + 1; 32]),
2054 )
2055 .unwrap();
2056 captured.insert(
2057 role,
2058 CapturedAuthorizationLeg {
2059 view,
2060 evidence_id: original[index * 2 + 1].id().clone(),
2061 value_free_transport: Some(ValueFreeAuthorizationTransport {
2062 evidence_id: original[index * 2].id().clone(),
2063 response_bytes: BODY.len() as u64,
2064 complete: true,
2065 truncated: false,
2066 defense: AuthorizationResponseDefense::Clear,
2067 }),
2068 },
2069 );
2070 }
2071 ResourceAuthorizationExecutionState {
2072 captured,
2073 staged_value_free_batches: BTreeMap::from([(
2074 DecisionExecutionStage::Passive,
2075 staged,
2076 )]),
2077 ..ResourceAuthorizationExecutionState::default()
2078 }
2079 }
2080
2081 struct LiteralExecutor {
2082 id: &'static str,
2083 batch: Vec<Evidence>,
2084 }
2085 #[async_trait]
2086 impl DecisionActionExecutor for LiteralExecutor {
2087 fn id(&self) -> &str {
2088 self.id
2089 }
2090 async fn execute(
2091 &self,
2092 _: &DecisionExecutionRequest,
2093 ) -> Result<Vec<Evidence>, DecisionExecutorError> {
2094 assert!(self.batch.len() <= 32);
2095 Ok(self.batch.clone())
2096 }
2097 }
2098
2099 async fn commit(
2100 batch: Vec<Evidence>,
2101 action: &str,
2102 executor: &'static str,
2103 stage: DecisionExecutionStage,
2104 ) -> (KnowledgeBase, DecisionEvidenceReceipt) {
2105 try_commit(batch, action, executor, stage).await.unwrap()
2106 }
2107
2108 async fn try_commit(
2109 batch: Vec<Evidence>,
2110 action: &str,
2111 executor: &'static str,
2112 stage: DecisionExecutionStage,
2113 ) -> Result<(KnowledgeBase, DecisionEvidenceReceipt), crate::DecisionRunnerError> {
2114 let batch = if executor == RESOURCE_AUTHORIZATION_EXECUTOR_ID {
2115 batch
2116 } else {
2117 batch
2120 .into_iter()
2121 .map(|item| {
2122 let source = EvidenceSource::new(executor, item.source().method())
2123 .unwrap()
2124 .with_correlation_id(CASE)
2125 .unwrap();
2126 replaced(
2127 &item,
2128 item.value().clone(),
2129 item.kind().clone(),
2130 source,
2131 None,
2132 )
2133 })
2134 .collect()
2135 };
2136 let knowledge = KnowledgeBase::new();
2137 let mut registry = DecisionExecutorRegistry::new();
2138 registry
2139 .register(Arc::new(LiteralExecutor {
2140 id: executor,
2141 batch,
2142 }))
2143 .unwrap();
2144 registry.route_action(stage, action, executor).unwrap();
2145 let case = VerificationCase::new(
2146 CASE,
2147 subject(),
2148 action,
2149 "hypothesis:synthetic-authorization",
2150 )
2151 .unwrap()
2152 .without_hypothesis_transition();
2153 let command = match stage {
2154 DecisionExecutionStage::Passive => DecisionLoopCommand::ExecuteAction {
2155 case,
2156 executor: None,
2157 origin: DecisionActionOrigin::Planned,
2158 delay_ms: None,
2159 },
2160 DecisionExecutionStage::Active => {
2161 DecisionLoopCommand::CollectActiveEvidence { case }
2162 },
2163 };
2164 let receipt = DecisionRunnerAdapter::new(registry)
2165 .execute_command(&command, &knowledge)
2166 .await?;
2167 Ok((knowledge, receipt))
2168 }
2169
2170 fn report(receipt: Option<DecisionEvidenceReceipt>) -> StandardWebDecisionRunReport {
2171 let accounting = RequestAccountingBroker::new(RuntimeBudget::default());
2172 for _ in 0..2 {
2173 let mut lease = accounting
2174 .try_begin(
2175 RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID,
2176 DecisionExecutionStage::Passive,
2177 Some(DecisionActionOrigin::Planned),
2178 )
2179 .unwrap();
2180 assert_eq!(
2181 lease.observe_response_bytes(BODY.len() as u64),
2182 BODY.len() as u64
2183 );
2184 lease.finish(TransportDispatchOutcome::Completed);
2185 }
2186 StandardWebDecisionRunReport {
2187 bootstrap: None,
2188 turns: Vec::new(),
2189 unverified_evidence: receipt,
2190 terminal: DecisionLoopCommand::Halt {
2191 reason: DecisionStopReason::NoEligibleAction,
2192 },
2193 usage: RuntimeUsage::default(),
2194 transport: accounting.dispatch_audit(),
2195 limit_exceeded: None,
2196 execution_failure: None,
2197 }
2198 }
2199
2200 fn replaced(
2201 item: &Evidence,
2202 value: EvidenceValue,
2203 kind: EvidenceKind,
2204 source: EvidenceSource,
2205 parents: Option<Vec<EvidenceId>>,
2206 ) -> Evidence {
2207 let mut next = Evidence::with_id_at(
2208 item.id().clone(),
2209 item.subject().clone(),
2210 kind,
2211 item.predicate().clone(),
2212 value,
2213 source,
2214 item.reliability(),
2215 item.observed_at_ms(),
2216 );
2217 if let Some(derivation) = item.origin().derivation() {
2218 next = next.derived_from(
2219 EvidenceDerivation::new(
2220 parents.unwrap_or_else(|| derivation.parents().to_vec()),
2221 derivation.algorithm().clone(),
2222 )
2223 .unwrap(),
2224 );
2225 }
2226 next
2227 }
2228
2229 #[tokio::test]
2230 async fn authorization_privacy_commits_require_literal_role_roots_and_real_parent_writes() {
2231 let policy = policy();
2232 let original = literal_batch(&policy);
2233 let (knowledge, receipt) = commit(
2234 original.clone(),
2235 RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID,
2236 RESOURCE_AUTHORIZATION_EXECUTOR_ID,
2237 DecisionExecutionStage::Passive,
2238 )
2239 .await;
2240 assert!(validate_value_free_authorization_commits(
2241 &report(Some(receipt.clone())),
2242 &knowledge,
2243 &policy,
2244 &subject(),
2245 &state(&policy, &original, original.clone()),
2246 false,
2247 )
2248 .is_ok());
2249 assert!(validate_value_free_authorization_commits(
2250 &report(None),
2251 &knowledge,
2252 &policy,
2253 &subject(),
2254 &state(&policy, &original, original.clone()),
2255 false,
2256 )
2257 .is_err());
2258 for mutation in 0..15 {
2259 let mut batch = original.clone();
2260 match mutation {
2261 0 => {
2262 let _ = batch.remove(0);
2263 },
2264 1 => {
2265 let _ = batch.remove(1);
2266 },
2267 2 => batch.push(batch[0].clone()),
2268 3 => batch.swap(0, 2),
2269 4 => {
2270 batch[0] = replaced(
2271 &batch[0],
2272 EvidenceValue::TextList(vec!["role=peer_candidate".to_owned()]),
2273 batch[0].kind().clone(),
2274 batch[0].source().clone(),
2275 None,
2276 )
2277 },
2278 5 => {
2279 batch[0] = replaced(
2280 &batch[0],
2281 batch[0].value().clone(),
2282 EvidenceKind::Content,
2283 batch[0].source().clone(),
2284 None,
2285 )
2286 },
2287 6 => {
2288 batch[0] = replaced(
2289 &batch[0],
2290 batch[0].value().clone(),
2291 batch[0].kind().clone(),
2292 EvidenceSource::new(
2293 RESOURCE_AUTHORIZATION_EXECUTOR_ID,
2294 "forged-transport",
2295 )
2296 .unwrap()
2297 .with_correlation_id(CASE)
2298 .unwrap(),
2299 None,
2300 )
2301 },
2302 7 => {
2303 batch[1] = replaced(
2304 &batch[1],
2305 batch[1].value().clone(),
2306 batch[1].kind().clone(),
2307 batch[1].source().clone(),
2308 Some(vec![batch[2].id().clone()]),
2309 )
2310 },
2311 8 => {
2312 batch[1] = replaced(
2313 &batch[1],
2314 EvidenceValue::TextList(vec![
2315 "response=low-entropy-body-sha256".to_owned()
2316 ]),
2317 batch[1].kind().clone(),
2318 batch[1].source().clone(),
2319 None,
2320 )
2321 },
2322 9 => {
2323 let index = batch
2324 .iter()
2325 .position(|row| row.predicate().name() == "defense-rate-limit")
2326 .unwrap();
2327 batch[index] = replaced(
2328 &batch[index],
2329 EvidenceValue::Boolean(true),
2330 batch[index].kind().clone(),
2331 batch[index].source().clone(),
2332 None,
2333 );
2334 },
2335 10 => {
2336 batch[4] = replaced(
2337 &batch[4],
2338 EvidenceValue::Unsigned(1),
2339 batch[4].kind().clone(),
2340 batch[4].source().clone(),
2341 None,
2342 );
2343 },
2344 11 => batch.push(fact(
2345 "unknown-fact",
2346 EvidenceKind::Content,
2347 EvidenceValue::Text("hidden-body-identity".to_owned()),
2348 "unknown",
2349 )),
2350 12 => {
2351 let index = batch
2352 .iter()
2353 .position(|row| row.predicate().name() == "phase-terminal")
2354 .unwrap();
2355 batch[index] = replaced(
2356 &batch[index],
2357 EvidenceValue::Boolean(true),
2358 batch[index].kind().clone(),
2359 batch[index].source().clone(),
2360 None,
2361 );
2362 },
2363 13 => {
2364 let index = batch
2365 .iter()
2366 .position(|row| row.predicate().name() == "no-response-terminal")
2367 .unwrap();
2368 batch[index] = replaced(
2369 &batch[index],
2370 EvidenceValue::Boolean(true),
2371 batch[index].kind().clone(),
2372 batch[index].source().clone(),
2373 None,
2374 );
2375 },
2376 14 => {
2377 let _ = batch.pop();
2378 },
2379 _ => unreachable!(),
2380 }
2381 let committed = try_commit(
2385 batch.clone(),
2386 RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID,
2387 RESOURCE_AUTHORIZATION_EXECUTOR_ID,
2388 DecisionExecutionStage::Passive,
2389 )
2390 .await;
2391 let (knowledge, receipt) = match committed {
2392 Ok(committed) => committed,
2393 Err(error) => {
2394 assert!(
2395 matches!(mutation, 0 | 3 | 7),
2396 "unexpected upstream rejection mutation={mutation}"
2397 );
2398 assert!(format!("{error:?}").contains("MissingDerivationParent"));
2399 continue;
2400 },
2401 };
2402 assert!(
2403 validate_value_free_authorization_commits(
2404 &report(Some(receipt)),
2405 &knowledge,
2406 &policy,
2407 &subject(),
2408 &state(&policy, &original, batch),
2409 false,
2410 )
2411 .is_err(),
2412 "mutation={mutation}"
2413 );
2414 }
2415 }
2416
2417 #[tokio::test]
2418 async fn authorization_privacy_terminal_state_must_agree_with_last_committed_phase() {
2419 let policy = policy();
2420 let batch = literal_batch(&policy);
2421 let (knowledge, receipt) = commit(
2422 batch.clone(),
2423 RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID,
2424 RESOURCE_AUTHORIZATION_EXECUTOR_ID,
2425 DecisionExecutionStage::Passive,
2426 )
2427 .await;
2428 for outcome in [
2429 AuthorizationReviewOutcome::RateLimited,
2430 AuthorizationReviewOutcome::Incomplete,
2431 ] {
2432 let mut staged = state(&policy, &batch, batch.clone());
2433 staged.terminal = Some(outcome);
2437 assert!(validate_value_free_authorization_commits(
2438 &report(Some(receipt.clone())),
2439 &knowledge,
2440 &policy,
2441 &subject(),
2442 &staged,
2443 false,
2444 )
2445 .is_err());
2446 }
2447 }
2448
2449 #[tokio::test]
2450 async fn authorization_privacy_commits_reject_wrong_route_or_uncommitted_storage() {
2451 let policy = policy();
2452 let batch = literal_batch(&policy);
2453 for (action, executor, stage) in [
2454 (
2455 "web.review.unrelated",
2456 RESOURCE_AUTHORIZATION_EXECUTOR_ID,
2457 DecisionExecutionStage::Passive,
2458 ),
2459 (
2460 RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID,
2461 "http.unrelated",
2462 DecisionExecutionStage::Passive,
2463 ),
2464 (
2465 RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID,
2466 RESOURCE_AUTHORIZATION_EXECUTOR_ID,
2467 DecisionExecutionStage::Active,
2468 ),
2469 ] {
2470 let (knowledge, receipt) = commit(batch.clone(), action, executor, stage).await;
2471 assert!(validate_value_free_authorization_commits(
2472 &report(Some(receipt)),
2473 &knowledge,
2474 &policy,
2475 &subject(),
2476 &state(&policy, &batch, batch.clone()),
2477 false,
2478 )
2479 .is_err());
2480 }
2481 let (knowledge, receipt) = commit(
2482 batch.clone(),
2483 RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID,
2484 RESOURCE_AUTHORIZATION_EXECUTOR_ID,
2485 DecisionExecutionStage::Passive,
2486 )
2487 .await;
2488 for writes in [
2489 vec![KnowledgeWrite::Inserted; batch.len() - 1],
2490 vec![KnowledgeWrite::Updated; batch.len()],
2491 ] {
2492 let altered = receipt.with_test_committed_batch(
2493 batch.clone(),
2494 writes,
2495 knowledge.snapshot_for_subject(&subject()),
2496 );
2497 assert!(validate_value_free_authorization_commits(
2498 &report(Some(altered)),
2499 &knowledge,
2500 &policy,
2501 &subject(),
2502 &state(&policy, &batch, batch.clone()),
2503 false,
2504 )
2505 .is_err());
2506 }
2507 assert!(validate_value_free_authorization_commits(
2508 &report(Some(receipt)),
2509 &KnowledgeBase::new(),
2510 &policy,
2511 &subject(),
2512 &state(&policy, &batch, batch.clone()),
2513 false,
2514 )
2515 .is_err());
2516 }
2517 }
2518}