1use std::{collections::BTreeSet, fmt};
4
5use serde::Serialize;
6use serde_json::Value;
7use sha2::{Digest, Sha256};
8use termivar_core::{
9 ApiSurfaceKind, ApiVisibilityDimension, ApiVisibilityPairKind, ApiVisibilityResult,
10};
11use thiserror::Error;
12
13use super::{
14 AuthorizationPrincipalPairProof, AuthorizationResourceScopeId, AuthorizationReviewPolicy,
15 AuthorizationReviewPolicyId, AUTHORIZATION_REVIEW_ALGORITHM_VERSION,
16};
17use crate::{ApiVisibilityComparator, ProfiledApiVisibilityError, ProfiledApiVisibilityView};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
21#[serde(rename_all = "snake_case")]
22#[non_exhaustive]
23pub enum AuthorizationViewRole {
24 PrimaryCandidate,
26 PeerCandidate,
28 PrimaryReplay,
30 PeerReplay,
32}
33
34impl AuthorizationViewRole {
35 const fn context_id(self) -> &'static str {
36 match self {
37 Self::PrimaryCandidate => "authorization-review:primary:candidate",
38 Self::PeerCandidate => "authorization-review:peer:candidate",
39 Self::PrimaryReplay => "authorization-review:primary:replay",
40 Self::PeerReplay => "authorization-review:peer:replay",
41 }
42 }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
47#[serde(rename_all = "snake_case")]
48#[non_exhaustive]
49pub enum AuthorizationReviewBodyState {
50 CompleteJson,
52 UnsupportedMedia,
54 Html,
56 Redirect,
58 RateLimited,
60 ServerError,
62 MalformedJson,
64 Truncated,
66 Incomplete,
68 BudgetExhausted,
70 Cancelled,
72 DefensiveInterference,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
78#[serde(rename_all = "snake_case")]
79#[non_exhaustive]
80pub enum AuthorizationReviewMediaClass {
81 JsonCompatible,
83 Html,
85 Other,
87 Missing,
89}
90
91#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
96pub struct AuthorizationViewReceiptId([u8; 32]);
97
98impl AuthorizationViewReceiptId {
99 pub const fn from_digest(digest: [u8; 32]) -> Self {
101 Self(digest)
102 }
103}
104
105impl fmt::Debug for AuthorizationViewReceiptId {
106 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
107 formatter.write_str("AuthorizationViewReceiptId(<redacted>)")
108 }
109}
110
111#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
116pub struct AuthorizationDifferentialReceiptId([u8; 32]);
117
118impl AuthorizationDifferentialReceiptId {
119 pub fn to_wire(self) -> String {
121 format!("authorization-differential-sha256:{}", hex(self.0))
122 }
123}
124
125impl fmt::Debug for AuthorizationDifferentialReceiptId {
126 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
127 formatter.write_str(&self.to_wire())
128 }
129}
130
131impl Serialize for AuthorizationDifferentialReceiptId {
132 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
133 where
134 S: serde::Serializer,
135 {
136 serializer.serialize_str(&self.to_wire())
137 }
138}
139
140pub struct AuthorizationReviewView {
142 role: AuthorizationViewRole,
143 policy_id: AuthorizationReviewPolicyId,
144 resource_scope_id: AuthorizationResourceScopeId,
145 status: Option<u16>,
146 media_class: AuthorizationReviewMediaClass,
147 state: AuthorizationReviewBodyState,
148 selected_paths_resolved: bool,
149 material_selected_subtree: bool,
150 generic_json_error_envelope: bool,
151 receipt_id: AuthorizationViewReceiptId,
152 profiled: Option<ProfiledApiVisibilityView>,
153}
154
155impl AuthorizationReviewView {
156 pub fn capture_json(
159 policy: &AuthorizationReviewPolicy,
160 role: AuthorizationViewRole,
161 status: u16,
162 snapshot: &Value,
163 receipt_id: AuthorizationViewReceiptId,
164 ) -> Result<Self, AuthorizationReviewViewError> {
165 if !(100..=599).contains(&status) {
166 return Err(AuthorizationReviewViewError::InvalidStatus);
167 }
168 let generic_json_error_envelope =
169 successful(status) && is_generic_json_error_envelope(policy, snapshot);
170 let selected_paths_resolved = policy
171 .comparison()
172 .selected_paths()
173 .iter()
174 .all(|path| snapshot.pointer(path.as_str()).is_some());
175 let (profiled, material_selected_subtree) = ApiVisibilityComparator::default()
176 .capture_profiled_view_with_material(
177 policy.comparison(),
178 role.context_id(),
179 policy.resource_scope_id().to_wire(),
180 ApiSurfaceKind::JsonHttp,
181 status,
182 snapshot,
183 )?;
184 Ok(Self {
185 role,
186 policy_id: policy.policy_id(),
187 resource_scope_id: policy.resource_scope_id(),
188 status: Some(status),
189 media_class: AuthorizationReviewMediaClass::JsonCompatible,
190 state: AuthorizationReviewBodyState::CompleteJson,
191 selected_paths_resolved,
192 material_selected_subtree,
193 generic_json_error_envelope,
194 receipt_id,
195 profiled: Some(profiled),
196 })
197 }
198
199 pub fn terminal(
201 policy: &AuthorizationReviewPolicy,
202 role: AuthorizationViewRole,
203 status: Option<u16>,
204 media_class: AuthorizationReviewMediaClass,
205 state: AuthorizationReviewBodyState,
206 receipt_id: AuthorizationViewReceiptId,
207 ) -> Result<Self, AuthorizationReviewViewError> {
208 let response_required = matches!(
209 state,
210 AuthorizationReviewBodyState::UnsupportedMedia
211 | AuthorizationReviewBodyState::Html
212 | AuthorizationReviewBodyState::Redirect
213 | AuthorizationReviewBodyState::RateLimited
214 | AuthorizationReviewBodyState::ServerError
215 | AuthorizationReviewBodyState::MalformedJson
216 | AuthorizationReviewBodyState::Truncated
217 | AuthorizationReviewBodyState::DefensiveInterference
218 );
219 let response_forbidden = matches!(
220 state,
221 AuthorizationReviewBodyState::BudgetExhausted | AuthorizationReviewBodyState::Cancelled
222 );
223 let media_mismatch = (state == AuthorizationReviewBodyState::Html
224 && media_class != AuthorizationReviewMediaClass::Html)
225 || (state == AuthorizationReviewBodyState::UnsupportedMedia
226 && !matches!(
227 media_class,
228 AuthorizationReviewMediaClass::Other | AuthorizationReviewMediaClass::Missing
229 ))
230 || (matches!(
231 state,
232 AuthorizationReviewBodyState::MalformedJson
233 | AuthorizationReviewBodyState::Truncated
234 ) && media_class != AuthorizationReviewMediaClass::JsonCompatible);
235 if state == AuthorizationReviewBodyState::CompleteJson
236 || status.is_some_and(|value| !(100..=599).contains(&value))
237 || (response_required && status.is_none())
238 || (response_forbidden
239 && (status.is_some() || media_class != AuthorizationReviewMediaClass::Missing))
240 || media_mismatch
241 {
242 return Err(AuthorizationReviewViewError::InvalidTerminalState);
243 }
244 Ok(Self {
245 role,
246 policy_id: policy.policy_id(),
247 resource_scope_id: policy.resource_scope_id(),
248 status,
249 media_class,
250 state,
251 selected_paths_resolved: false,
252 material_selected_subtree: false,
253 generic_json_error_envelope: false,
254 receipt_id,
255 profiled: None,
256 })
257 }
258
259 pub const fn role(&self) -> AuthorizationViewRole {
261 self.role
262 }
263
264 pub const fn status(&self) -> Option<u16> {
266 self.status
267 }
268
269 pub const fn media_class(&self) -> AuthorizationReviewMediaClass {
271 self.media_class
272 }
273
274 pub const fn state(&self) -> AuthorizationReviewBodyState {
276 self.state
277 }
278}
279
280impl fmt::Debug for AuthorizationReviewView {
281 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
282 formatter
283 .debug_struct("AuthorizationReviewView")
284 .field("role", &self.role)
285 .field("policy_id", &self.policy_id)
286 .field("resource_scope_id", &self.resource_scope_id)
287 .field("status", &self.status)
288 .field("media_class", &self.media_class)
289 .field("state", &self.state)
290 .field("selected_paths_resolved", &self.selected_paths_resolved)
291 .field("material_selected_subtree", &self.material_selected_subtree)
292 .field(
293 "generic_json_error_envelope",
294 &self.generic_json_error_envelope,
295 )
296 .field("receipt_id", &"<redacted>")
297 .field("profiled", &"<redacted>")
298 .finish()
299 }
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Error)]
304#[non_exhaustive]
305pub enum AuthorizationReviewViewError {
306 #[error(transparent)]
308 Comparison(#[from] ProfiledApiVisibilityError),
309 #[error("authorization review response status is invalid")]
311 InvalidStatus,
312 #[error("authorization review terminal view state is invalid")]
314 InvalidTerminalState,
315}
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
319pub struct DimensionEquivalence {
320 status: bool,
321 fields: bool,
322 resources: bool,
323}
324
325impl DimensionEquivalence {
326 pub const fn status(self) -> bool {
328 self.status
329 }
330
331 pub const fn fields(self) -> bool {
333 self.fields
334 }
335
336 pub const fn resources(self) -> bool {
338 self.resources
339 }
340
341 pub const fn all(self) -> bool {
343 self.status && self.fields && self.resources
344 }
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
349#[serde(rename_all = "snake_case")]
350#[non_exhaustive]
351pub enum AuthorizationReviewOutcome {
352 NotEligible,
354 PrimaryBaselineInvalid,
356 PrimaryUnstable,
358 PeerDenied,
360 PeerUnstable,
362 CrossStatusDifferent,
364 CrossFieldsEquivalentOnly,
366 CrossResourcesDifferent,
368 StableCrossPrincipalEquivalence,
370 DefensiveInterference,
372 RateLimited,
374 RedirectObserved,
376 UnsupportedMedia,
378 MalformedJson,
380 GenericJsonErrorEnvelope,
382 SelectedPathMissing,
384 Truncated,
386 Incomplete,
388 BudgetExhausted,
390 Cancelled,
392 ContractMismatch,
394}
395
396#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
398pub struct AuthorizationDifferentialResult {
399 algorithm_version: &'static str,
400 policy_id: AuthorizationReviewPolicyId,
401 resource_scope_id: AuthorizationResourceScopeId,
402 receipt_id: AuthorizationDifferentialReceiptId,
403 outcome: AuthorizationReviewOutcome,
404 primary_stability: Option<DimensionEquivalence>,
405 peer_stability: Option<DimensionEquivalence>,
406 cross_candidate: Option<DimensionEquivalence>,
407 cross_replay: Option<DimensionEquivalence>,
408}
409
410impl AuthorizationDifferentialResult {
411 pub fn compare(
413 policy: &AuthorizationReviewPolicy,
414 principals: AuthorizationPrincipalPairProof,
415 views: [&AuthorizationReviewView; 4],
416 ) -> Result<Self, AuthorizationDifferentialError> {
417 validate_contract(policy, principals, &views)?;
418 let primary_candidate = role(&views, AuthorizationViewRole::PrimaryCandidate)?;
419 let peer_candidate = role(&views, AuthorizationViewRole::PeerCandidate)?;
420 let primary_replay = role(&views, AuthorizationViewRole::PrimaryReplay)?;
421 let peer_replay = role(&views, AuthorizationViewRole::PeerReplay)?;
422 let identity = ResultIdentity::new(
423 policy,
424 primary_candidate,
425 peer_candidate,
426 primary_replay,
427 peer_replay,
428 );
429 if let Some(outcome) = terminal_outcome(&views) {
430 return Ok(Self::terminal(identity, outcome));
431 }
432
433 let primary_candidate_status = complete_status(primary_candidate)?;
434 let peer_candidate_status = complete_status(peer_candidate)?;
435 let primary_replay_status = complete_status(primary_replay)?;
436 let peer_replay_status = complete_status(peer_replay)?;
437
438 let primary_stability = compare_dimensions(
442 policy,
443 "primary-stability",
444 primary_candidate,
445 primary_replay,
446 )?;
447 let peer_stability =
448 compare_dimensions(policy, "peer-stability", peer_candidate, peer_replay)?;
449 let cross_candidate =
450 compare_dimensions(policy, "cross-candidate", primary_candidate, peer_candidate)?;
451 let cross_replay = compare_dimensions(policy, "cross-replay", primary_replay, peer_replay)?;
452
453 let outcome = if views.iter().any(|view| view.generic_json_error_envelope) {
454 AuthorizationReviewOutcome::GenericJsonErrorEnvelope
455 } else if !successful(primary_candidate_status) || !successful(primary_replay_status) {
456 AuthorizationReviewOutcome::PrimaryBaselineInvalid
457 } else if !paths_present(primary_candidate) || !paths_present(primary_replay) {
458 AuthorizationReviewOutcome::SelectedPathMissing
459 } else if primary_candidate_status != primary_replay_status || !primary_stability.all() {
460 AuthorizationReviewOutcome::PrimaryUnstable
461 } else if peer_candidate_status == peer_replay_status
462 && matches!(peer_candidate_status, 401 | 403 | 404)
463 {
464 AuthorizationReviewOutcome::PeerDenied
465 } else if !successful(peer_candidate_status)
466 || !successful(peer_replay_status)
467 || peer_candidate_status != peer_replay_status
468 {
469 AuthorizationReviewOutcome::PeerUnstable
470 } else if !paths_present(peer_candidate) || !paths_present(peer_replay) {
471 AuthorizationReviewOutcome::SelectedPathMissing
472 } else if !peer_stability.all() {
473 AuthorizationReviewOutcome::PeerUnstable
474 } else if !cross_candidate.status || !cross_replay.status {
475 AuthorizationReviewOutcome::CrossStatusDifferent
476 } else if !cross_candidate.resources || !cross_replay.resources {
477 if cross_candidate.fields && cross_replay.fields {
478 AuthorizationReviewOutcome::CrossFieldsEquivalentOnly
479 } else {
480 AuthorizationReviewOutcome::CrossResourcesDifferent
481 }
482 } else if !cross_candidate.fields || !cross_replay.fields {
483 AuthorizationReviewOutcome::CrossResourcesDifferent
484 } else {
485 AuthorizationReviewOutcome::StableCrossPrincipalEquivalence
486 };
487 Ok(Self {
488 algorithm_version: AUTHORIZATION_REVIEW_ALGORITHM_VERSION,
489 policy_id: identity.policy_id,
490 resource_scope_id: identity.resource_scope_id,
491 receipt_id: identity.receipt_id,
492 outcome,
493 primary_stability: Some(primary_stability),
494 peer_stability: Some(peer_stability),
495 cross_candidate: Some(cross_candidate),
496 cross_replay: Some(cross_replay),
497 })
498 }
499
500 pub const fn outcome(&self) -> AuthorizationReviewOutcome {
502 self.outcome
503 }
504
505 pub const fn algorithm_version(&self) -> &'static str {
507 self.algorithm_version
508 }
509
510 pub const fn policy_id(&self) -> AuthorizationReviewPolicyId {
512 self.policy_id
513 }
514
515 pub const fn resource_scope_id(&self) -> AuthorizationResourceScopeId {
517 self.resource_scope_id
518 }
519
520 pub const fn receipt_id(&self) -> AuthorizationDifferentialReceiptId {
522 self.receipt_id
523 }
524
525 pub const fn primary_stability(&self) -> Option<DimensionEquivalence> {
527 self.primary_stability
528 }
529
530 pub const fn peer_stability(&self) -> Option<DimensionEquivalence> {
532 self.peer_stability
533 }
534
535 pub const fn cross_candidate(&self) -> Option<DimensionEquivalence> {
537 self.cross_candidate
538 }
539
540 pub const fn cross_replay(&self) -> Option<DimensionEquivalence> {
542 self.cross_replay
543 }
544
545 fn terminal(identity: ResultIdentity, outcome: AuthorizationReviewOutcome) -> Self {
546 Self {
547 algorithm_version: AUTHORIZATION_REVIEW_ALGORITHM_VERSION,
548 policy_id: identity.policy_id,
549 resource_scope_id: identity.resource_scope_id,
550 receipt_id: identity.receipt_id,
551 outcome,
552 primary_stability: None,
553 peer_stability: None,
554 cross_candidate: None,
555 cross_replay: None,
556 }
557 }
558}
559
560#[derive(Debug, Clone, PartialEq, Eq, Error)]
562#[non_exhaustive]
563pub enum AuthorizationDifferentialError {
564 #[error("authorization differential view roles do not reconcile")]
566 RoleSetMismatch,
567 #[error("authorization differential policy identities do not reconcile")]
569 PolicyMismatch,
570 #[error("authorization differential resource identities do not reconcile")]
572 ResourceMismatch,
573 #[error("authorization differential receipt identities do not reconcile")]
575 ReceiptMismatch,
576 #[error("authorization differential view contract does not reconcile")]
578 ViewContractMismatch,
579 #[error(transparent)]
581 Comparison(#[from] ProfiledApiVisibilityError),
582}
583
584#[derive(Clone, Copy)]
585struct ResultIdentity {
586 policy_id: AuthorizationReviewPolicyId,
587 resource_scope_id: AuthorizationResourceScopeId,
588 receipt_id: AuthorizationDifferentialReceiptId,
589}
590
591impl ResultIdentity {
592 fn new(
593 policy: &AuthorizationReviewPolicy,
594 primary_candidate: &AuthorizationReviewView,
595 peer_candidate: &AuthorizationReviewView,
596 primary_replay: &AuthorizationReviewView,
597 peer_replay: &AuthorizationReviewView,
598 ) -> Self {
599 Self {
600 policy_id: policy.policy_id(),
601 resource_scope_id: policy.resource_scope_id(),
602 receipt_id: differential_receipt_id(
603 policy,
604 [
605 primary_candidate,
606 peer_candidate,
607 primary_replay,
608 peer_replay,
609 ],
610 ),
611 }
612 }
613}
614
615fn validate_contract(
616 policy: &AuthorizationReviewPolicy,
617 _principals: AuthorizationPrincipalPairProof,
618 views: &[&AuthorizationReviewView; 4],
619) -> Result<(), AuthorizationDifferentialError> {
620 let roles = views.iter().map(|view| view.role).collect::<BTreeSet<_>>();
621 if roles.len() != 4
622 || ![
623 AuthorizationViewRole::PrimaryCandidate,
624 AuthorizationViewRole::PeerCandidate,
625 AuthorizationViewRole::PrimaryReplay,
626 AuthorizationViewRole::PeerReplay,
627 ]
628 .iter()
629 .all(|role| roles.contains(role))
630 {
631 return Err(AuthorizationDifferentialError::RoleSetMismatch);
632 }
633 if views
634 .iter()
635 .any(|view| view.policy_id != policy.policy_id())
636 {
637 return Err(AuthorizationDifferentialError::PolicyMismatch);
638 }
639 if views
640 .iter()
641 .any(|view| view.resource_scope_id != policy.resource_scope_id())
642 {
643 return Err(AuthorizationDifferentialError::ResourceMismatch);
644 }
645 if views
646 .iter()
647 .map(|view| view.receipt_id)
648 .collect::<BTreeSet<_>>()
649 .len()
650 != 4
651 {
652 return Err(AuthorizationDifferentialError::ReceiptMismatch);
653 }
654 Ok(())
655}
656
657fn terminal_outcome(views: &[&AuthorizationReviewView; 4]) -> Option<AuthorizationReviewOutcome> {
658 let states = views.iter().map(|view| view.state).collect::<Vec<_>>();
659 for (state, outcome) in [
660 (
661 AuthorizationReviewBodyState::Cancelled,
662 AuthorizationReviewOutcome::Cancelled,
663 ),
664 (
665 AuthorizationReviewBodyState::BudgetExhausted,
666 AuthorizationReviewOutcome::BudgetExhausted,
667 ),
668 (
669 AuthorizationReviewBodyState::Truncated,
670 AuthorizationReviewOutcome::Truncated,
671 ),
672 (
673 AuthorizationReviewBodyState::Incomplete,
674 AuthorizationReviewOutcome::Incomplete,
675 ),
676 (
677 AuthorizationReviewBodyState::Redirect,
678 AuthorizationReviewOutcome::RedirectObserved,
679 ),
680 (
681 AuthorizationReviewBodyState::RateLimited,
682 AuthorizationReviewOutcome::RateLimited,
683 ),
684 (
685 AuthorizationReviewBodyState::DefensiveInterference,
686 AuthorizationReviewOutcome::DefensiveInterference,
687 ),
688 (
689 AuthorizationReviewBodyState::MalformedJson,
690 AuthorizationReviewOutcome::MalformedJson,
691 ),
692 (
693 AuthorizationReviewBodyState::UnsupportedMedia,
694 AuthorizationReviewOutcome::UnsupportedMedia,
695 ),
696 (
697 AuthorizationReviewBodyState::Html,
698 AuthorizationReviewOutcome::UnsupportedMedia,
699 ),
700 (
701 AuthorizationReviewBodyState::ServerError,
702 AuthorizationReviewOutcome::Incomplete,
703 ),
704 ] {
705 if states.contains(&state) {
706 return Some(outcome);
707 }
708 }
709 None
710}
711
712fn role<'a>(
713 views: &[&'a AuthorizationReviewView; 4],
714 expected: AuthorizationViewRole,
715) -> Result<&'a AuthorizationReviewView, AuthorizationDifferentialError> {
716 views
717 .iter()
718 .copied()
719 .find(|view| view.role == expected)
720 .ok_or(AuthorizationDifferentialError::RoleSetMismatch)
721}
722
723fn paths_present(view: &AuthorizationReviewView) -> bool {
724 view.selected_paths_resolved && view.material_selected_subtree
725}
726
727fn is_generic_json_error_envelope(policy: &AuthorizationReviewPolicy, snapshot: &Value) -> bool {
728 fn has_error_envelope_shape(value: &Value) -> bool {
729 value.as_object().is_some_and(|object| {
730 (object.contains_key("error") || object.contains_key("errors"))
731 && object.keys().all(|key| {
732 matches!(
733 key.as_str(),
734 "error"
735 | "errors"
736 | "message"
737 | "messages"
738 | "code"
739 | "status"
740 | "detail"
741 | "details"
742 | "title"
743 | "type"
744 | "trace_id"
745 | "request_id"
746 )
747 })
748 })
749 }
750
751 has_error_envelope_shape(snapshot)
752 || policy
753 .comparison()
754 .selected_paths()
755 .iter()
756 .filter_map(|path| snapshot.pointer(path.as_str()))
757 .any(has_error_envelope_shape)
758}
759
760fn successful(status: u16) -> bool {
761 (200..=299).contains(&status)
762}
763
764fn complete_status(view: &AuthorizationReviewView) -> Result<u16, AuthorizationDifferentialError> {
765 if view.state != AuthorizationReviewBodyState::CompleteJson || view.profiled.is_none() {
766 return Err(AuthorizationDifferentialError::ViewContractMismatch);
767 }
768 view.status
769 .ok_or(AuthorizationDifferentialError::ViewContractMismatch)
770}
771
772fn compare_dimensions(
773 policy: &AuthorizationReviewPolicy,
774 pair_name: &str,
775 left: &AuthorizationReviewView,
776 right: &AuthorizationReviewView,
777) -> Result<DimensionEquivalence, AuthorizationDifferentialError> {
778 let left = left
779 .profiled
780 .as_ref()
781 .ok_or(AuthorizationDifferentialError::ViewContractMismatch)?;
782 let right = right
783 .profiled
784 .as_ref()
785 .ok_or(AuthorizationDifferentialError::ViewContractMismatch)?;
786 let comparator = ApiVisibilityComparator::default();
787 let compare = |dimension: ApiVisibilityDimension| {
788 comparator.compare_profiled(
789 policy.comparison(),
790 format!(
791 "authorization-differential:{}:{}:{}",
792 pair_name,
793 dimension.as_str(),
794 policy.policy_id()
795 ),
796 ApiVisibilityPairKind::AuthorizationContext,
797 dimension,
798 left,
799 right,
800 0,
801 )
802 };
803 Ok(DimensionEquivalence {
804 status: compare(ApiVisibilityDimension::Status)?
805 .comparison()
806 .result()
807 == ApiVisibilityResult::Equivalent,
808 fields: compare(ApiVisibilityDimension::Fields)?
809 .comparison()
810 .result()
811 == ApiVisibilityResult::Equivalent,
812 resources: compare(ApiVisibilityDimension::Resources)?
813 .comparison()
814 .result()
815 == ApiVisibilityResult::Equivalent,
816 })
817}
818
819fn differential_receipt_id(
820 policy: &AuthorizationReviewPolicy,
821 views: [&AuthorizationReviewView; 4],
822) -> AuthorizationDifferentialReceiptId {
823 const DOMAIN: &[u8] = b"security.authorization-differential.receipt.v1\0";
824 let mut hasher = Sha256::new();
825 hasher.update(DOMAIN);
826 framed(
827 &mut hasher,
828 AUTHORIZATION_REVIEW_ALGORITHM_VERSION.as_bytes(),
829 );
830 framed(&mut hasher, &policy.policy_id().as_bytes());
831 framed(&mut hasher, &policy.resource_scope_id().as_bytes());
832 for (role, view) in [
833 (b"primary-candidate".as_slice(), views[0]),
834 (b"peer-candidate".as_slice(), views[1]),
835 (b"primary-replay".as_slice(), views[2]),
836 (b"peer-replay".as_slice(), views[3]),
837 ] {
838 framed(&mut hasher, role);
839 framed(&mut hasher, &view.receipt_id.0);
840 }
841 AuthorizationDifferentialReceiptId(hasher.finalize().into())
842}
843
844fn framed(hasher: &mut Sha256, value: &[u8]) {
845 hasher.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
846 hasher.update(value);
847}
848
849fn hex(bytes: [u8; 32]) -> String {
850 const HEX: &[u8; 16] = b"0123456789abcdef";
851 let mut output = String::with_capacity(64);
852 for byte in bytes {
853 output.push(char::from(HEX[usize::from(byte >> 4)]));
854 output.push(char::from(HEX[usize::from(byte & 0x0f)]));
855 }
856 output
857}
858
859#[cfg(test)]
860mod tests {
861 use serde_json::json;
862 use url::Url;
863
864 use super::*;
865 use crate::authorization_review::{
866 AuthorizationPrincipalPair, PeerAuthorizationPrincipal, PrimaryAuthorizationPrincipal,
867 AUTHORIZATION_REVIEW_POLICY_SCHEMA,
868 };
869
870 fn policy(unordered: bool, ignored: bool) -> AuthorizationReviewPolicy {
871 let ignored = if ignored {
872 "[\"/data/account/updated_at\"]"
873 } else {
874 "[]"
875 };
876 let unordered = if unordered {
877 "[\"/data/account/roles\"]"
878 } else {
879 "[]"
880 };
881 let source = format!(
882 "schema = \"{AUTHORIZATION_REVIEW_POLICY_SCHEMA}\"\nresource = \"/api/accounts/42\"\nresource_handle = \"account-self-profile\"\nexpectation = \"primary-only\"\nmethod = \"GET\"\n[comparison]\nselected_paths = [\"/data/account\"]\nignored_paths = {ignored}\nunordered_array_paths = {unordered}\nmax_diff_paths = 32\n"
883 );
884 AuthorizationReviewPolicy::parse_toml(
885 &Url::parse("https://api.example.test/").unwrap(),
886 source.as_bytes(),
887 )
888 .unwrap()
889 }
890
891 fn proof() -> AuthorizationPrincipalPairProof {
892 AuthorizationPrincipalPair::new(
893 PrimaryAuthorizationPrincipal::new("Bearer primary").unwrap(),
894 PeerAuthorizationPrincipal::new("Bearer peer").unwrap(),
895 )
896 .unwrap()
897 .into_proof()
898 }
899
900 fn receipt(index: u8) -> AuthorizationViewReceiptId {
901 let mut digest = [0_u8; 32];
902 digest[0] = index;
903 AuthorizationViewReceiptId::from_digest(digest)
904 }
905
906 fn json_view(
907 policy: &AuthorizationReviewPolicy,
908 role: AuthorizationViewRole,
909 status: u16,
910 body: Value,
911 ) -> AuthorizationReviewView {
912 AuthorizationReviewView::capture_json(policy, role, status, &body, receipt(role as u8 + 1))
913 .unwrap()
914 }
915
916 fn quartet(
917 policy: &AuthorizationReviewPolicy,
918 primary_candidate: Value,
919 peer_candidate: Value,
920 primary_replay: Value,
921 peer_replay: Value,
922 ) -> [AuthorizationReviewView; 4] {
923 [
924 json_view(
925 policy,
926 AuthorizationViewRole::PrimaryCandidate,
927 200,
928 primary_candidate,
929 ),
930 json_view(
931 policy,
932 AuthorizationViewRole::PeerCandidate,
933 200,
934 peer_candidate,
935 ),
936 json_view(
937 policy,
938 AuthorizationViewRole::PrimaryReplay,
939 200,
940 primary_replay,
941 ),
942 json_view(policy, AuthorizationViewRole::PeerReplay, 200, peer_replay),
943 ]
944 }
945
946 fn compare(
947 policy: &AuthorizationReviewPolicy,
948 views: &[AuthorizationReviewView; 4],
949 ) -> AuthorizationDifferentialResult {
950 AuthorizationDifferentialResult::compare(
951 policy,
952 proof(),
953 [&views[0], &views[1], &views[2], &views[3]],
954 )
955 .unwrap()
956 }
957
958 #[test]
959 fn four_stable_equivalent_views_are_positive_only_on_all_dimensions() {
960 let policy = policy(false, false);
961 let body = json!({"data":{"account":{"id":42,"name":"A"}}});
962 let views = quartet(&policy, body.clone(), body.clone(), body.clone(), body);
963 let result = compare(&policy, &views);
964 assert_eq!(
965 result.outcome(),
966 AuthorizationReviewOutcome::StableCrossPrincipalEquivalence
967 );
968 assert!(result.primary_stability().unwrap().all());
969 assert!(result.peer_stability().unwrap().all());
970 assert!(result.cross_candidate().unwrap().all());
971 assert!(result.cross_replay().unwrap().all());
972 assert_eq!(
973 result.algorithm_version(),
974 AUTHORIZATION_REVIEW_ALGORITHM_VERSION
975 );
976 assert_eq!(result.policy_id(), policy.policy_id());
977 assert_eq!(result.resource_scope_id(), policy.resource_scope_id());
978 assert!(result
979 .receipt_id()
980 .to_wire()
981 .starts_with("authorization-differential-sha256:"));
982 }
983
984 #[test]
985 fn primary_and_peer_instability_fail_closed() {
986 let policy = policy(false, false);
987 let one = json!({"data":{"account":{"id":1}}});
988 let two = json!({"data":{"account":{"id":2}}});
989 let primary_unstable = quartet(&policy, one.clone(), one.clone(), two.clone(), one.clone());
990 assert_eq!(
991 compare(&policy, &primary_unstable).outcome(),
992 AuthorizationReviewOutcome::PrimaryUnstable
993 );
994 let peer_unstable = quartet(&policy, one.clone(), one.clone(), one, two);
995 assert_eq!(
996 compare(&policy, &peer_unstable).outcome(),
997 AuthorizationReviewOutcome::PeerUnstable
998 );
999 assert!(compare(&policy, &primary_unstable)
1000 .cross_candidate()
1001 .is_some());
1002 assert!(compare(&policy, &peer_unstable).cross_replay().is_some());
1003 }
1004
1005 #[test]
1006 fn status_baselines_and_cross_statuses_are_classified_without_shortcuts() {
1007 let policy = policy(false, false);
1008 let body = json!({"data":{"account":{"id":42}}});
1009
1010 let mut invalid_primary = quartet(
1011 &policy,
1012 body.clone(),
1013 body.clone(),
1014 body.clone(),
1015 body.clone(),
1016 );
1017 invalid_primary[0] = json_view(
1018 &policy,
1019 AuthorizationViewRole::PrimaryCandidate,
1020 401,
1021 body.clone(),
1022 );
1023 invalid_primary[2] = json_view(
1024 &policy,
1025 AuthorizationViewRole::PrimaryReplay,
1026 401,
1027 body.clone(),
1028 );
1029 assert_eq!(
1030 compare(&policy, &invalid_primary).outcome(),
1031 AuthorizationReviewOutcome::PrimaryBaselineInvalid
1032 );
1033
1034 let mut unstable_primary = quartet(
1035 &policy,
1036 body.clone(),
1037 body.clone(),
1038 body.clone(),
1039 body.clone(),
1040 );
1041 unstable_primary[2] = json_view(
1042 &policy,
1043 AuthorizationViewRole::PrimaryReplay,
1044 201,
1045 body.clone(),
1046 );
1047 assert_eq!(
1048 compare(&policy, &unstable_primary).outcome(),
1049 AuthorizationReviewOutcome::PrimaryUnstable
1050 );
1051
1052 let mut cross_status = quartet(
1053 &policy,
1054 body.clone(),
1055 body.clone(),
1056 body.clone(),
1057 body.clone(),
1058 );
1059 cross_status[1] = json_view(
1060 &policy,
1061 AuthorizationViewRole::PeerCandidate,
1062 201,
1063 body.clone(),
1064 );
1065 cross_status[3] = json_view(&policy, AuthorizationViewRole::PeerReplay, 201, body);
1066 assert_eq!(
1067 compare(&policy, &cross_status).outcome(),
1068 AuthorizationReviewOutcome::CrossStatusDifferent
1069 );
1070 }
1071
1072 #[test]
1073 fn peer_denials_never_become_positive_or_secure_claims() {
1074 let policy = policy(false, false);
1075 let body = json!({"data":{"account":{"id":42}}});
1076 for status in [401, 403, 404] {
1077 let mut views = quartet(
1078 &policy,
1079 body.clone(),
1080 body.clone(),
1081 body.clone(),
1082 body.clone(),
1083 );
1084 views[1] = json_view(
1085 &policy,
1086 AuthorizationViewRole::PeerCandidate,
1087 status,
1088 body.clone(),
1089 );
1090 views[3] = json_view(
1091 &policy,
1092 AuthorizationViewRole::PeerReplay,
1093 status,
1094 body.clone(),
1095 );
1096 assert_eq!(
1097 compare(&policy, &views).outcome(),
1098 AuthorizationReviewOutcome::PeerDenied
1099 );
1100 }
1101
1102 let mut mixed = quartet(
1103 &policy,
1104 body.clone(),
1105 body.clone(),
1106 body.clone(),
1107 body.clone(),
1108 );
1109 mixed[1] = json_view(&policy, AuthorizationViewRole::PeerCandidate, 403, body);
1110 assert_eq!(
1111 compare(&policy, &mixed).outcome(),
1112 AuthorizationReviewOutcome::PeerUnstable
1113 );
1114 }
1115
1116 #[test]
1117 fn field_shape_equality_is_not_resource_equivalence() {
1118 let policy = policy(false, false);
1119 let primary = json!({"data":{"account":{"id":42,"name":"primary"}}});
1120 let peer = json!({"data":{"account":{"id":43,"name":"peer"}}});
1121 let views = quartet(&policy, primary.clone(), peer.clone(), primary, peer);
1122 let result = compare(&policy, &views);
1123 assert_eq!(
1124 result.outcome(),
1125 AuthorizationReviewOutcome::CrossFieldsEquivalentOnly
1126 );
1127 assert!(result.cross_candidate().unwrap().fields());
1128 assert!(!result.cross_candidate().unwrap().resources());
1129 }
1130
1131 #[test]
1132 fn repeated_generic_json_error_envelopes_never_become_positive() {
1133 let policy = policy(false, false);
1134 let secret = "GENERIC-ERROR-MUST-NOT-LEAK-4F82A1";
1135 for envelope in [
1136 json!({"error":{"category":"temporary","detail":secret}}),
1137 json!({"data":{"account":{"errors":[{"category":"temporary","detail":secret}]}}}),
1138 ] {
1139 let views = quartet(
1140 &policy,
1141 envelope.clone(),
1142 envelope.clone(),
1143 envelope.clone(),
1144 envelope,
1145 );
1146 assert!(views
1147 .iter()
1148 .all(|view| { view.state() == AuthorizationReviewBodyState::CompleteJson }));
1149 let result = compare(&policy, &views);
1150 assert_eq!(
1151 result.outcome(),
1152 AuthorizationReviewOutcome::GenericJsonErrorEnvelope
1153 );
1154 assert!(result.primary_stability().unwrap().all());
1155 assert!(result.peer_stability().unwrap().all());
1156 assert!(result.cross_candidate().unwrap().all());
1157 assert!(result.cross_replay().unwrap().all());
1158 assert!(!format!("{views:?}").contains(secret));
1159 assert!(!serde_json::to_string(&result).unwrap().contains(secret));
1160 }
1161
1162 let resource = json!({"data":{"account":{"id":42}}});
1163 let error = json!({"data":{"account":{"error":"temporary"}}});
1164 let views = quartet(
1165 &policy,
1166 resource.clone(),
1167 error,
1168 resource.clone(),
1169 resource.clone(),
1170 );
1171 assert_eq!(
1172 compare(&policy, &views).outcome(),
1173 AuthorizationReviewOutcome::GenericJsonErrorEnvelope
1174 );
1175
1176 let denied = json!({"error":"denied"});
1177 let views = [
1178 json_view(
1179 &policy,
1180 AuthorizationViewRole::PrimaryCandidate,
1181 200,
1182 resource.clone(),
1183 ),
1184 json_view(
1185 &policy,
1186 AuthorizationViewRole::PeerCandidate,
1187 403,
1188 denied.clone(),
1189 ),
1190 json_view(&policy, AuthorizationViewRole::PrimaryReplay, 200, resource),
1191 json_view(&policy, AuthorizationViewRole::PeerReplay, 403, denied),
1192 ];
1193 assert_eq!(
1194 compare(&policy, &views).outcome(),
1195 AuthorizationReviewOutcome::PeerDenied
1196 );
1197
1198 for resource_with_error_field in [
1199 json!({"data":{"account":{"id":42,"error":"historical-note"}}}),
1200 json!({"data":{"account":{"id":42}},"errors":[]}),
1201 ] {
1202 let views = quartet(
1203 &policy,
1204 resource_with_error_field.clone(),
1205 resource_with_error_field.clone(),
1206 resource_with_error_field.clone(),
1207 resource_with_error_field,
1208 );
1209 assert_eq!(
1210 compare(&policy, &views).outcome(),
1211 AuthorizationReviewOutcome::StableCrossPrincipalEquivalence
1212 );
1213 }
1214 }
1215
1216 #[test]
1217 fn status_equality_without_field_or_resource_equality_is_insufficient() {
1218 let policy = policy(false, false);
1219 let primary = json!({"data":{"account":{"id":42}}});
1220 let peer = json!({"data":{"account":{"other":"value"}}});
1221 let views = quartet(&policy, primary.clone(), peer.clone(), primary, peer);
1222 let result = compare(&policy, &views);
1223 assert_eq!(
1224 result.outcome(),
1225 AuthorizationReviewOutcome::CrossResourcesDifferent
1226 );
1227 assert!(result.cross_candidate().unwrap().status());
1228 assert!(!result.cross_candidate().unwrap().fields());
1229 }
1230
1231 #[test]
1232 fn selected_path_must_resolve_and_be_material_in_all_views() {
1233 let policy = policy(false, false);
1234 for missing in [json!({"data":{}}), json!({"data":{"account":null}})] {
1235 let present = json!({"data":{"account":{"id":42}}});
1236 let views = quartet(
1237 &policy,
1238 present.clone(),
1239 missing.clone(),
1240 present.clone(),
1241 missing,
1242 );
1243 assert_eq!(
1244 compare(&policy, &views).outcome(),
1245 AuthorizationReviewOutcome::SelectedPathMissing
1246 );
1247 }
1248 }
1249
1250 #[test]
1251 fn ignored_volatile_field_and_unordered_arrays_use_existing_profile() {
1252 let ignored = policy(false, true);
1253 let first = json!({"data":{"account":{"id":42,"updated_at":"first"}}});
1254 let second = json!({"data":{"account":{"id":42,"updated_at":"second"}}});
1255 let views = quartet(&ignored, first.clone(), second.clone(), first, second);
1256 assert_eq!(
1257 compare(&ignored, &views).outcome(),
1258 AuthorizationReviewOutcome::StableCrossPrincipalEquivalence
1259 );
1260
1261 let unordered = policy(true, false);
1262 let left = json!({"data":{"account":{"roles":["reader","writer"]}}});
1263 let right = json!({"data":{"account":{"roles":["writer","reader"]}}});
1264 let views = quartet(&unordered, left.clone(), right.clone(), left, right);
1265 assert_eq!(
1266 compare(&unordered, &views).outcome(),
1267 AuthorizationReviewOutcome::StableCrossPrincipalEquivalence
1268 );
1269
1270 let ordered = policy(false, false);
1271 let left = json!({"data":{"account":{"roles":["reader","writer"]}}});
1272 let right = json!({"data":{"account":{"roles":["writer","reader"]}}});
1273 let views = quartet(&ordered, left.clone(), right.clone(), left, right);
1274 assert_eq!(
1275 compare(&ordered, &views).outcome(),
1276 AuthorizationReviewOutcome::CrossFieldsEquivalentOnly
1277 );
1278 }
1279
1280 #[test]
1281 fn ignored_only_selected_content_cannot_become_positive() {
1282 let policy = policy(false, true);
1283 let first = json!({"data":{"account":{"updated_at":"first"}}});
1284 let second = json!({"data":{"account":{"updated_at":"second"}}});
1285 let views = quartet(&policy, first.clone(), second.clone(), first, second);
1286 assert_eq!(
1287 compare(&policy, &views).outcome(),
1288 AuthorizationReviewOutcome::SelectedPathMissing
1289 );
1290 }
1291
1292 #[test]
1293 fn terminal_states_have_deterministic_fail_closed_precedence() {
1294 let policy = policy(false, false);
1295 let body = json!({"data":{"account":{"id":42}}});
1296 for (state, expected) in [
1297 (
1298 AuthorizationReviewBodyState::Cancelled,
1299 AuthorizationReviewOutcome::Cancelled,
1300 ),
1301 (
1302 AuthorizationReviewBodyState::BudgetExhausted,
1303 AuthorizationReviewOutcome::BudgetExhausted,
1304 ),
1305 (
1306 AuthorizationReviewBodyState::Truncated,
1307 AuthorizationReviewOutcome::Truncated,
1308 ),
1309 (
1310 AuthorizationReviewBodyState::Incomplete,
1311 AuthorizationReviewOutcome::Incomplete,
1312 ),
1313 (
1314 AuthorizationReviewBodyState::Redirect,
1315 AuthorizationReviewOutcome::RedirectObserved,
1316 ),
1317 (
1318 AuthorizationReviewBodyState::RateLimited,
1319 AuthorizationReviewOutcome::RateLimited,
1320 ),
1321 (
1322 AuthorizationReviewBodyState::DefensiveInterference,
1323 AuthorizationReviewOutcome::DefensiveInterference,
1324 ),
1325 (
1326 AuthorizationReviewBodyState::MalformedJson,
1327 AuthorizationReviewOutcome::MalformedJson,
1328 ),
1329 (
1330 AuthorizationReviewBodyState::UnsupportedMedia,
1331 AuthorizationReviewOutcome::UnsupportedMedia,
1332 ),
1333 (
1334 AuthorizationReviewBodyState::Html,
1335 AuthorizationReviewOutcome::UnsupportedMedia,
1336 ),
1337 (
1338 AuthorizationReviewBodyState::ServerError,
1339 AuthorizationReviewOutcome::Incomplete,
1340 ),
1341 ] {
1342 let status = if matches!(
1343 state,
1344 AuthorizationReviewBodyState::Cancelled
1345 | AuthorizationReviewBodyState::BudgetExhausted
1346 | AuthorizationReviewBodyState::Incomplete
1347 ) {
1348 None
1349 } else {
1350 Some(500)
1351 };
1352 let media = match state {
1353 AuthorizationReviewBodyState::Html => AuthorizationReviewMediaClass::Html,
1354 AuthorizationReviewBodyState::UnsupportedMedia => {
1355 AuthorizationReviewMediaClass::Other
1356 },
1357 AuthorizationReviewBodyState::Cancelled
1358 | AuthorizationReviewBodyState::BudgetExhausted
1359 | AuthorizationReviewBodyState::Incomplete => {
1360 AuthorizationReviewMediaClass::Missing
1361 },
1362 _ => AuthorizationReviewMediaClass::JsonCompatible,
1363 };
1364 let mut views = quartet(
1365 &policy,
1366 body.clone(),
1367 body.clone(),
1368 body.clone(),
1369 body.clone(),
1370 );
1371 views[1] = AuthorizationReviewView::terminal(
1372 &policy,
1373 AuthorizationViewRole::PeerCandidate,
1374 status,
1375 media,
1376 state,
1377 receipt(2),
1378 )
1379 .unwrap();
1380 assert_eq!(compare(&policy, &views).outcome(), expected);
1381 }
1382 }
1383
1384 #[test]
1385 fn view_construction_requires_real_status_and_consistent_media() {
1386 let policy = policy(false, false);
1387 let body = json!({"data":{"account":{"id":42}}});
1388 for status in [0, 99, 600, u16::MAX] {
1389 assert_eq!(
1390 AuthorizationReviewView::capture_json(
1391 &policy,
1392 AuthorizationViewRole::PrimaryCandidate,
1393 status,
1394 &body,
1395 receipt(1),
1396 )
1397 .unwrap_err(),
1398 AuthorizationReviewViewError::InvalidStatus
1399 );
1400 }
1401 for (status, media, state) in [
1402 (
1403 Some(200),
1404 AuthorizationReviewMediaClass::JsonCompatible,
1405 AuthorizationReviewBodyState::CompleteJson,
1406 ),
1407 (
1408 Some(200),
1409 AuthorizationReviewMediaClass::Missing,
1410 AuthorizationReviewBodyState::Cancelled,
1411 ),
1412 (
1413 None,
1414 AuthorizationReviewMediaClass::Html,
1415 AuthorizationReviewBodyState::Html,
1416 ),
1417 (
1418 Some(200),
1419 AuthorizationReviewMediaClass::JsonCompatible,
1420 AuthorizationReviewBodyState::Html,
1421 ),
1422 (
1423 Some(200),
1424 AuthorizationReviewMediaClass::Other,
1425 AuthorizationReviewBodyState::MalformedJson,
1426 ),
1427 ] {
1428 assert_eq!(
1429 AuthorizationReviewView::terminal(
1430 &policy,
1431 AuthorizationViewRole::PrimaryCandidate,
1432 status,
1433 media,
1434 state,
1435 receipt(1),
1436 )
1437 .unwrap_err(),
1438 AuthorizationReviewViewError::InvalidTerminalState
1439 );
1440 }
1441 let cancelled = AuthorizationReviewView::terminal(
1442 &policy,
1443 AuthorizationViewRole::PrimaryCandidate,
1444 None,
1445 AuthorizationReviewMediaClass::Missing,
1446 AuthorizationReviewBodyState::Cancelled,
1447 receipt(1),
1448 )
1449 .unwrap();
1450 assert_eq!(cancelled.status(), None);
1451 assert_eq!(
1452 cancelled.media_class(),
1453 AuthorizationReviewMediaClass::Missing
1454 );
1455 }
1456
1457 #[test]
1458 fn roles_policies_and_receipts_must_reconcile() {
1459 let policy = policy(false, false);
1460 let other_policy_source = format!(
1461 "schema = \"{AUTHORIZATION_REVIEW_POLICY_SCHEMA}\"\nresource = \"/api/accounts/43\"\nresource_handle = \"account-other\"\nexpectation = \"primary-only\"\nmethod = \"GET\"\n[comparison]\nselected_paths = [\"/data/account\"]\nignored_paths = []\nunordered_array_paths = []\nmax_diff_paths = 32\n"
1462 );
1463 let other_policy = AuthorizationReviewPolicy::parse_toml(
1464 &Url::parse("https://api.example.test/").unwrap(),
1465 other_policy_source.as_bytes(),
1466 )
1467 .unwrap();
1468 let body = json!({"data":{"account":{"id":42}}});
1469 let mut views = quartet(
1470 &policy,
1471 body.clone(),
1472 body.clone(),
1473 body.clone(),
1474 body.clone(),
1475 );
1476 views[3] = json_view(&other_policy, AuthorizationViewRole::PeerReplay, 200, body);
1477 assert_eq!(
1478 AuthorizationDifferentialResult::compare(
1479 &policy,
1480 proof(),
1481 [&views[0], &views[1], &views[2], &views[3]],
1482 )
1483 .unwrap_err(),
1484 AuthorizationDifferentialError::PolicyMismatch
1485 );
1486
1487 let mut role_mismatch = quartet(
1488 &policy,
1489 json!({"data":{"account":{"id":42}}}),
1490 json!({"data":{"account":{"id":42}}}),
1491 json!({"data":{"account":{"id":42}}}),
1492 json!({"data":{"account":{"id":42}}}),
1493 );
1494 role_mismatch[3].role = AuthorizationViewRole::PeerCandidate;
1495 assert_eq!(
1496 AuthorizationDifferentialResult::compare(
1497 &policy,
1498 proof(),
1499 [
1500 &role_mismatch[0],
1501 &role_mismatch[1],
1502 &role_mismatch[2],
1503 &role_mismatch[3],
1504 ],
1505 )
1506 .unwrap_err(),
1507 AuthorizationDifferentialError::RoleSetMismatch
1508 );
1509
1510 let body = json!({"data":{"account":{"id":42}}});
1511 let mut resource_mismatch = quartet(
1512 &policy,
1513 body.clone(),
1514 body.clone(),
1515 body.clone(),
1516 body.clone(),
1517 );
1518 resource_mismatch[3].resource_scope_id = other_policy.resource_scope_id();
1519 assert_eq!(
1520 AuthorizationDifferentialResult::compare(
1521 &policy,
1522 proof(),
1523 [
1524 &resource_mismatch[0],
1525 &resource_mismatch[1],
1526 &resource_mismatch[2],
1527 &resource_mismatch[3],
1528 ],
1529 )
1530 .unwrap_err(),
1531 AuthorizationDifferentialError::ResourceMismatch
1532 );
1533
1534 let mut receipt_mismatch = quartet(
1535 &policy,
1536 body.clone(),
1537 body.clone(),
1538 body.clone(),
1539 body.clone(),
1540 );
1541 receipt_mismatch[3].receipt_id = receipt_mismatch[0].receipt_id;
1542 assert_eq!(
1543 AuthorizationDifferentialResult::compare(
1544 &policy,
1545 proof(),
1546 [
1547 &receipt_mismatch[0],
1548 &receipt_mismatch[1],
1549 &receipt_mismatch[2],
1550 &receipt_mismatch[3],
1551 ],
1552 )
1553 .unwrap_err(),
1554 AuthorizationDifferentialError::ReceiptMismatch
1555 );
1556
1557 let mut view_mismatch = quartet(&policy, body.clone(), body.clone(), body.clone(), body);
1558 view_mismatch[0].profiled = None;
1559 assert_eq!(
1560 AuthorizationDifferentialResult::compare(
1561 &policy,
1562 proof(),
1563 [
1564 &view_mismatch[0],
1565 &view_mismatch[1],
1566 &view_mismatch[2],
1567 &view_mismatch[3],
1568 ],
1569 )
1570 .unwrap_err(),
1571 AuthorizationDifferentialError::ViewContractMismatch
1572 );
1573 }
1574
1575 #[test]
1576 fn result_receipt_is_role_ordered_deterministic_and_materially_bound() {
1577 let policy = policy(false, false);
1578 let body = json!({"data":{"account":{"id":42}}});
1579 let views = quartet(&policy, body.clone(), body.clone(), body.clone(), body);
1580 let first = AuthorizationDifferentialResult::compare(
1581 &policy,
1582 proof(),
1583 [&views[0], &views[1], &views[2], &views[3]],
1584 )
1585 .unwrap();
1586 let reordered = AuthorizationDifferentialResult::compare(
1587 &policy,
1588 proof(),
1589 [&views[3], &views[1], &views[0], &views[2]],
1590 )
1591 .unwrap();
1592 assert_eq!(first.receipt_id(), reordered.receipt_id());
1593
1594 let mut changed = views;
1595 changed[3].receipt_id = receipt(99);
1596 let changed = compare(&policy, &changed);
1597 assert_ne!(first.receipt_id(), changed.receipt_id());
1598 let encoded = serde_json::to_value(&first).unwrap();
1599 assert_eq!(
1600 encoded["algorithm_version"],
1601 AUTHORIZATION_REVIEW_ALGORITHM_VERSION
1602 );
1603 assert_eq!(encoded["policy_id"], policy.policy_id().to_wire());
1604 assert_eq!(
1605 encoded["resource_scope_id"],
1606 policy.resource_scope_id().to_wire()
1607 );
1608 }
1609
1610 #[test]
1611 fn view_debug_and_serialized_result_contain_no_raw_values() {
1612 let policy = policy(false, false);
1613 let secret = "PRIVATE-RESOURCE-HANDLE-MUST-NOT-LEAK-346E2A";
1614 let body = json!({"data":{"account":{"token":secret}}});
1615 let views = quartet(&policy, body.clone(), body.clone(), body.clone(), body);
1616 for view in &views {
1617 assert!(!format!("{view:?}").contains(secret));
1618 }
1619 let result = compare(&policy, &views);
1620 assert!(!serde_json::to_string(&result).unwrap().contains(secret));
1621 }
1622
1623 #[test]
1624 fn arbitrary_bounded_json_never_becomes_positive_without_four_stable_views() {
1625 let policy = policy(false, false);
1626 for value in [
1627 json!(null),
1628 json!({}),
1629 json!([]),
1630 json!({"data":{"account":null}}),
1631 json!({"data":{"account":[]}}),
1632 json!({"data":{"account":{"id":1}}}),
1633 ] {
1634 let other = json!({"data":{"account":{"id":2}}});
1635 let views = quartet(&policy, value.clone(), other.clone(), value, other);
1636 assert_ne!(
1637 compare(&policy, &views).outcome(),
1638 AuthorizationReviewOutcome::StableCrossPrincipalEquivalence
1639 );
1640 }
1641 }
1642}