Skip to main content

termivar_scanner/
authorization_review.rs

1//! Transport-neutral contracts for bounded authorization differential review.
2//!
3//! This module owns no transport, request broker, runtime budget, credential
4//! source, or report. It validates one declared policy and two host-supplied
5//! principal contexts, captures four raw-value-free JSON views through the
6//! existing API visibility comparator, and classifies their relationship.
7
8use std::fmt;
9
10use thiserror::Error;
11
12use crate::{
13    payload_strategies::ApiAuthorizationContextPairStrategy,
14    payload_strategy::{PayloadSeed, PayloadStrategy, PayloadStrategyLimits, PayloadVariantRole},
15};
16
17mod comparison;
18mod policy;
19
20pub use comparison::{
21    AuthorizationDifferentialError, AuthorizationDifferentialReceiptId,
22    AuthorizationDifferentialResult, AuthorizationReviewBodyState, AuthorizationReviewMediaClass,
23    AuthorizationReviewOutcome, AuthorizationReviewView, AuthorizationReviewViewError,
24    AuthorizationViewReceiptId, AuthorizationViewRole, DimensionEquivalence,
25};
26pub use policy::{
27    AuthorizationResourceScopeId, AuthorizationReviewExpectation, AuthorizationReviewMethod,
28    AuthorizationReviewPolicy, AuthorizationReviewPolicyError, AuthorizationReviewPolicyId,
29    AUTHORIZATION_REVIEW_ALGORITHM_VERSION, AUTHORIZATION_REVIEW_POLICY_SCHEMA,
30    HARD_MAX_AUTHORIZATION_REVIEW_DIFF_PATHS, HARD_MAX_AUTHORIZATION_REVIEW_IGNORED_PATHS,
31    HARD_MAX_AUTHORIZATION_REVIEW_PATH_BYTES, HARD_MAX_AUTHORIZATION_REVIEW_POLICY_BYTES,
32    HARD_MAX_AUTHORIZATION_REVIEW_SELECTED_PATHS,
33    HARD_MAX_AUTHORIZATION_REVIEW_UNORDERED_ARRAY_PATHS,
34};
35
36/// Host-supplied primary-principal `Authorization` header value.
37///
38/// The value is move-only, never serialized, and exposed only to the scanner's
39/// request-composition boundary after a complete pair has been validated.
40pub struct PrimaryAuthorizationPrincipal {
41    value: String,
42}
43
44impl PrimaryAuthorizationPrincipal {
45    /// Validates one complete bounded HTTP Authorization header value.
46    pub fn new(value: impl Into<Vec<u8>>) -> Result<Self, AuthorizationPrincipalError> {
47        Ok(Self {
48            value: validate_authorization_header(value.into())?,
49        })
50    }
51}
52
53impl fmt::Debug for PrimaryAuthorizationPrincipal {
54    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
55        formatter.write_str("PrimaryAuthorizationPrincipal(<redacted>)")
56    }
57}
58
59/// Host-supplied peer-principal `Authorization` header value.
60///
61/// The peer label denotes only a comparison role. It does not assert attacker,
62/// privilege, ownership, tenant, or business-policy meaning.
63pub struct PeerAuthorizationPrincipal {
64    value: String,
65}
66
67impl PeerAuthorizationPrincipal {
68    /// Validates one complete bounded HTTP Authorization header value.
69    pub fn new(value: impl Into<Vec<u8>>) -> Result<Self, AuthorizationPrincipalError> {
70        Ok(Self {
71            value: validate_authorization_header(value.into())?,
72        })
73    }
74}
75
76impl fmt::Debug for PeerAuthorizationPrincipal {
77    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78        formatter.write_str("PeerAuthorizationPrincipal(<redacted>)")
79    }
80}
81
82/// Exactly two distinct, role-bound principal contexts.
83pub struct AuthorizationPrincipalPair {
84    primary: PrimaryAuthorizationPrincipal,
85    peer: PeerAuthorizationPrincipal,
86}
87
88/// Crate-private, move-only transfer into the native request-composition seam.
89///
90/// This type deliberately implements neither `Debug` nor serialization. The
91/// role-bound values and their value-free distinctness proof move together, so
92/// a runtime cannot accidentally detach the proof from the pair it validated.
93pub(crate) struct AuthorizationPrincipalExecutionHandoff {
94    pub(crate) primary_authorization: String,
95    pub(crate) peer_authorization: String,
96    pub(crate) proof: AuthorizationPrincipalPairProof,
97}
98
99impl AuthorizationPrincipalPair {
100    /// Creates a pair and rejects identical credential bytes before any I/O.
101    pub fn new(
102        primary: PrimaryAuthorizationPrincipal,
103        peer: PeerAuthorizationPrincipal,
104    ) -> Result<Self, AuthorizationPrincipalError> {
105        if primary.value.as_bytes() == peer.value.as_bytes() {
106            return Err(AuthorizationPrincipalError::IdenticalCredentials);
107        }
108        Ok(Self { primary, peer })
109    }
110
111    /// Consumes both credentials and retains only the value-free proof.
112    pub fn into_proof(self) -> AuthorizationPrincipalPairProof {
113        let AuthorizationPrincipalExecutionHandoff {
114            primary_authorization,
115            peer_authorization,
116            proof,
117        } = self.into_execution_handoff();
118        drop((primary_authorization, peer_authorization));
119        proof
120    }
121
122    /// Atomically transfers both role-bound values and their distinctness proof
123    /// to the crate-owned request-composition boundary.
124    pub(crate) fn into_execution_handoff(self) -> AuthorizationPrincipalExecutionHandoff {
125        AuthorizationPrincipalExecutionHandoff {
126            primary_authorization: self.primary.value,
127            peer_authorization: self.peer.value,
128            proof: AuthorizationPrincipalPairProof { _private: () },
129        }
130    }
131}
132
133impl fmt::Debug for AuthorizationPrincipalPair {
134    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
135        formatter.write_str("AuthorizationPrincipalPair(<redacted>)")
136    }
137}
138
139/// Value-free evidence that a distinct primary/peer pair was checked.
140///
141/// Its field is private, so comparison authority cannot be minted with a
142/// public struct literal. This proof carries no credential identity.
143#[derive(PartialEq, Eq)]
144pub struct AuthorizationPrincipalPairProof {
145    _private: (),
146}
147
148impl fmt::Debug for AuthorizationPrincipalPairProof {
149    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
150        formatter.write_str("AuthorizationPrincipalPairProof(<redacted>)")
151    }
152}
153
154/// Static, value-free principal validation failure.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
156#[non_exhaustive]
157pub enum AuthorizationPrincipalError {
158    /// One value was empty, unsafe, non-ASCII, or above the shared ceiling.
159    #[error("authorization principal is not a bounded safe HTTP header value")]
160    InvalidValue,
161    /// Distinct roles cannot disguise the same credential bytes.
162    #[error("authorization review requires distinct principal credentials")]
163    IdenticalCredentials,
164}
165
166fn validate_authorization_header(value: Vec<u8>) -> Result<String, AuthorizationPrincipalError> {
167    let limits = PayloadStrategyLimits::default();
168    let seed =
169        PayloadSeed::new(value, limits).map_err(|_| AuthorizationPrincipalError::InvalidValue)?;
170    let artifact = ApiAuthorizationContextPairStrategy::new()
171        .derive_one(PayloadVariantRole::Candidate, &seed, limits)
172        .map_err(|_| AuthorizationPrincipalError::InvalidValue)?;
173    String::from_utf8(artifact.as_bytes().to_vec())
174        .map_err(|_| AuthorizationPrincipalError::InvalidValue)
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    const PRIMARY_SECRET: &str = "PRIMARY-AUTHORIZATION-MUST-NOT-LEAK-7C3A19";
182    const PEER_SECRET: &str = "PEER-AUTHORIZATION-MUST-NOT-LEAK-82FD44";
183
184    fn principals() -> AuthorizationPrincipalPair {
185        AuthorizationPrincipalPair::new(
186            PrimaryAuthorizationPrincipal::new(format!("Bearer {PRIMARY_SECRET}")).unwrap(),
187            PeerAuthorizationPrincipal::new(format!("Bearer {PEER_SECRET}")).unwrap(),
188        )
189        .unwrap()
190    }
191
192    #[test]
193    fn principal_pair_is_distinct_move_only_and_value_free() {
194        let pair = principals();
195        let rendered = format!("{pair:?}");
196        assert!(!rendered.contains(PRIMARY_SECRET));
197        assert!(!rendered.contains(PEER_SECRET));
198        assert_eq!(
199            format!("{:?}", pair.into_proof()),
200            "AuthorizationPrincipalPairProof(<redacted>)"
201        );
202    }
203
204    #[test]
205    fn execution_handoff_keeps_roles_and_proof_atomic() {
206        let handoff = principals().into_execution_handoff();
207        assert_eq!(
208            handoff.primary_authorization,
209            format!("Bearer {PRIMARY_SECRET}")
210        );
211        assert_eq!(handoff.peer_authorization, format!("Bearer {PEER_SECRET}"));
212        assert_eq!(
213            format!("{:?}", handoff.proof),
214            "AuthorizationPrincipalPairProof(<redacted>)"
215        );
216    }
217
218    #[test]
219    fn identical_credentials_are_rejected_without_echoing_values() {
220        let error = AuthorizationPrincipalPair::new(
221            PrimaryAuthorizationPrincipal::new("Bearer same").unwrap(),
222            PeerAuthorizationPrincipal::new("Bearer same").unwrap(),
223        )
224        .unwrap_err();
225        assert_eq!(error, AuthorizationPrincipalError::IdenticalCredentials);
226        assert!(!error.to_string().contains("Bearer"));
227        assert!(!format!("{error:?}").contains("same"));
228    }
229
230    #[test]
231    fn unsafe_and_oversized_credentials_fail_closed() {
232        for invalid in [
233            b"".as_slice(),
234            b" leading",
235            b"trailing ",
236            b"line\nbreak",
237            b"carriage\rreturn",
238            b"tab\tvalue",
239            b"null\0value",
240            &[0xff],
241        ] {
242            assert_eq!(
243                PrimaryAuthorizationPrincipal::new(invalid.to_vec()).unwrap_err(),
244                AuthorizationPrincipalError::InvalidValue
245            );
246            assert_eq!(
247                PeerAuthorizationPrincipal::new(invalid.to_vec()).unwrap_err(),
248                AuthorizationPrincipalError::InvalidValue
249            );
250        }
251
252        let oversized = vec![b'x'; crate::DEFAULT_MAX_PAYLOAD_ARTIFACT_BYTES as usize + 1];
253        assert_eq!(
254            PrimaryAuthorizationPrincipal::new(oversized.clone()).unwrap_err(),
255            AuthorizationPrincipalError::InvalidValue
256        );
257        assert_eq!(
258            PeerAuthorizationPrincipal::new(oversized).unwrap_err(),
259            AuthorizationPrincipalError::InvalidValue
260        );
261    }
262
263    #[test]
264    fn role_debug_is_exactly_redacted() {
265        let primary =
266            PrimaryAuthorizationPrincipal::new(format!("Bearer {PRIMARY_SECRET}")).unwrap();
267        let peer = PeerAuthorizationPrincipal::new(format!("Bearer {PEER_SECRET}")).unwrap();
268        assert_eq!(
269            format!("{primary:?}"),
270            "PrimaryAuthorizationPrincipal(<redacted>)"
271        );
272        assert_eq!(
273            format!("{peer:?}"),
274            "PeerAuthorizationPrincipal(<redacted>)"
275        );
276    }
277}