Skip to main content

termivar_scanner/authorization_review/
comparison.rs

1//! Four-view, raw-value-free authorization differential comparison.
2
3use 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/// The four independently dispatched views required by V1.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
21#[serde(rename_all = "snake_case")]
22#[non_exhaustive]
23pub enum AuthorizationViewRole {
24    /// First primary-principal response.
25    PrimaryCandidate,
26    /// First peer-principal response.
27    PeerCandidate,
28    /// Independent primary-principal replay.
29    PrimaryReplay,
30    /// Independent peer-principal replay.
31    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/// Bounded response state supplied by a host/runtime before pure comparison.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
47#[serde(rename_all = "snake_case")]
48#[non_exhaustive]
49pub enum AuthorizationReviewBodyState {
50    /// Complete, non-truncated JSON-compatible response captured below.
51    CompleteJson,
52    /// Media type is not JSON-compatible.
53    UnsupportedMedia,
54    /// A non-JSON HTML response was observed.
55    Html,
56    /// Redirect response; it must not be followed.
57    Redirect,
58    /// Typed rate-limit response.
59    RateLimited,
60    /// Server-side failure response.
61    ServerError,
62    /// JSON parsing failed within the bounded parser.
63    MalformedJson,
64    /// Retained bytes are truncated.
65    Truncated,
66    /// Transport or response completion was not proven.
67    Incomplete,
68    /// Shared runtime budget was exhausted.
69    BudgetExhausted,
70    /// Shared cancellation fired.
71    Cancelled,
72    /// Challenge or defense engagement made comparison ambiguous.
73    DefensiveInterference,
74}
75
76/// Normalized, value-free media classification bound to one response view.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
78#[serde(rename_all = "snake_case")]
79#[non_exhaustive]
80pub enum AuthorizationReviewMediaClass {
81    /// `application/json` or a media type with a `+json` structured suffix.
82    JsonCompatible,
83    /// An HTML media type.
84    Html,
85    /// A present normalized media type outside the supported JSON/HTML set.
86    Other,
87    /// No unambiguous normalized media type was available.
88    Missing,
89}
90
91/// Scanner-owned response-receipt correlation identity.
92///
93/// The bytes must be supplied by the host's receipt system, not derived from a
94/// credential. Debug output remains value-free.
95#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
96pub struct AuthorizationViewReceiptId([u8; 32]);
97
98impl AuthorizationViewReceiptId {
99    /// Binds one already domain-separated receipt digest.
100    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/// Stable receipt for one complete four-view comparison invocation.
112///
113/// This identity binds only scanner-owned correlation receipts and redacted
114/// semantic identities. It never hashes credential or raw response material.
115#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
116pub struct AuthorizationDifferentialReceiptId([u8; 32]);
117
118impl AuthorizationDifferentialReceiptId {
119    /// Returns the prefixed lowercase hexadecimal wire identity.
120    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
140/// One role-bound response reduced to bounded fingerprints and typed metadata.
141pub 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    /// Captures a complete JSON view through the existing value-sensitive API
157    /// comparator and immediately discards the raw JSON value.
158    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    /// Captures a typed non-comparable terminal response without retaining a body.
200    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    /// Returns the fixed leg role.
260    pub const fn role(&self) -> AuthorizationViewRole {
261        self.role
262    }
263
264    /// Returns the exact status without response material.
265    pub const fn status(&self) -> Option<u16> {
266        self.status
267    }
268
269    /// Returns the normalized response-media class.
270    pub const fn media_class(&self) -> AuthorizationReviewMediaClass {
271        self.media_class
272    }
273
274    /// Returns the bounded response classification.
275    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/// View capture failure with no raw response values.
303#[derive(Debug, Clone, PartialEq, Eq, Error)]
304#[non_exhaustive]
305pub enum AuthorizationReviewViewError {
306    /// Existing bounded API canonicalization failed.
307    #[error(transparent)]
308    Comparison(#[from] ProfiledApiVisibilityError),
309    /// A captured response status was outside the HTTP status range.
310    #[error("authorization review response status is invalid")]
311    InvalidStatus,
312    /// Terminal construction was attempted with a complete state or invalid status.
313    #[error("authorization review terminal view state is invalid")]
314    InvalidTerminalState,
315}
316
317/// Equivalence result for the three required comparison dimensions.
318#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
319pub struct DimensionEquivalence {
320    status: bool,
321    fields: bool,
322    resources: bool,
323}
324
325impl DimensionEquivalence {
326    /// Returns whether statuses are equivalent.
327    pub const fn status(self) -> bool {
328        self.status
329    }
330
331    /// Returns whether selected field structure is equivalent.
332    pub const fn fields(self) -> bool {
333        self.fields
334    }
335
336    /// Returns whether selected value-sensitive resources are equivalent.
337    pub const fn resources(self) -> bool {
338        self.resources
339    }
340
341    /// Returns true only when all three dimensions agree.
342    pub const fn all(self) -> bool {
343        self.status && self.fields && self.resources
344    }
345}
346
347/// Deterministic non-boolean four-view outcome.
348#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
349#[serde(rename_all = "snake_case")]
350#[non_exhaustive]
351pub enum AuthorizationReviewOutcome {
352    /// Host/runtime eligibility was not established.
353    NotEligible,
354    /// Primary response did not establish a successful JSON baseline.
355    PrimaryBaselineInvalid,
356    /// Primary candidate and replay were not equivalent.
357    PrimaryUnstable,
358    /// Peer was consistently denied or not found.
359    PeerDenied,
360    /// Peer candidate and replay were not equivalent.
361    PeerUnstable,
362    /// Stable primary and peer statuses differed.
363    CrossStatusDifferent,
364    /// Field shapes matched while selected resource values differed.
365    CrossFieldsEquivalentOnly,
366    /// Selected cross-principal resources differed.
367    CrossResourcesDifferent,
368    /// Both principals independently replayed the same selected representation.
369    StableCrossPrincipalEquivalence,
370    /// Challenge or defensive behavior invalidated interpretation.
371    DefensiveInterference,
372    /// Rate limiting invalidated interpretation.
373    RateLimited,
374    /// A redirect was observed and not followed.
375    RedirectObserved,
376    /// Response media was not supported JSON.
377    UnsupportedMedia,
378    /// JSON parsing failed.
379    MalformedJson,
380    /// A successful response used a generic JSON error-envelope shape.
381    GenericJsonErrorEnvelope,
382    /// At least one selected path was absent or null-only.
383    SelectedPathMissing,
384    /// A response was truncated.
385    Truncated,
386    /// Execution or transport did not complete.
387    Incomplete,
388    /// Shared budget was exhausted.
389    BudgetExhausted,
390    /// Host cancellation fired.
391    Cancelled,
392    /// Internal correlation or contract invariants did not reconcile.
393    ContractMismatch,
394}
395
396/// Complete raw-value-free differential receipt.
397#[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    /// Compares exactly four role-complete views without performing I/O.
412    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        // All four complete views are already available. Compute every required
439        // relationship before classification so negative outcomes still carry
440        // the complete, request-free differential audit.
441        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    /// Returns the typed terminal classification.
501    pub const fn outcome(&self) -> AuthorizationReviewOutcome {
502        self.outcome
503    }
504
505    /// Returns the exact comparison algorithm revision.
506    pub const fn algorithm_version(&self) -> &'static str {
507        self.algorithm_version
508    }
509
510    /// Returns the policy identity that governed all four views.
511    pub const fn policy_id(&self) -> AuthorizationReviewPolicyId {
512        self.policy_id
513    }
514
515    /// Returns the exact selected-resource scope identity.
516    pub const fn resource_scope_id(&self) -> AuthorizationResourceScopeId {
517        self.resource_scope_id
518    }
519
520    /// Returns the ordered four-view correlation receipt.
521    pub const fn receipt_id(&self) -> AuthorizationDifferentialReceiptId {
522        self.receipt_id
523    }
524
525    /// Returns primary replay stability when comparison reached that stage.
526    pub const fn primary_stability(&self) -> Option<DimensionEquivalence> {
527        self.primary_stability
528    }
529
530    /// Returns peer replay stability when comparison reached that stage.
531    pub const fn peer_stability(&self) -> Option<DimensionEquivalence> {
532        self.peer_stability
533    }
534
535    /// Returns first-round cross-principal equivalence when available.
536    pub const fn cross_candidate(&self) -> Option<DimensionEquivalence> {
537        self.cross_candidate
538    }
539
540    /// Returns replay cross-principal equivalence when available.
541    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/// Internal correlation/comparator failure.
561#[derive(Debug, Clone, PartialEq, Eq, Error)]
562#[non_exhaustive]
563pub enum AuthorizationDifferentialError {
564    /// The view set did not contain each V1 role exactly once.
565    #[error("authorization differential view roles do not reconcile")]
566    RoleSetMismatch,
567    /// One view was captured under another policy.
568    #[error("authorization differential policy identities do not reconcile")]
569    PolicyMismatch,
570    /// One view was captured for another resource scope.
571    #[error("authorization differential resource identities do not reconcile")]
572    ResourceMismatch,
573    /// Receipt correlation identities were duplicated.
574    #[error("authorization differential receipt identities do not reconcile")]
575    ReceiptMismatch,
576    /// A complete view did not retain the required internal comparison state.
577    #[error("authorization differential view contract does not reconcile")]
578    ViewContractMismatch,
579    /// Existing value-sensitive comparison failed its contract.
580    #[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}