Skip to main content

termivar_scanner/authorization_review/
policy.rs

1//! Strict authorization-review policy parsing and semantic identity.
2
3use std::{collections::BTreeSet, fmt};
4
5use serde::{Deserialize, Serialize, Serializer};
6use sha2::{Digest, Sha256};
7use thiserror::Error;
8use url::{ParseError, Url};
9
10use crate::{
11    api_evidence::{ApiComparisonProfile, JsonPathPattern},
12    HttpEvidencePolicy,
13};
14
15/// Purpose-oriented repository policy schema.
16pub const AUTHORIZATION_REVIEW_POLICY_SCHEMA: &str = "security.authorization-review-policy/v1";
17/// Stable comparison and identity algorithm implemented by this module.
18pub const AUTHORIZATION_REVIEW_ALGORITHM_VERSION: &str = "security.authorization-differential/v1";
19/// Maximum accepted policy source bytes.
20pub const HARD_MAX_AUTHORIZATION_REVIEW_POLICY_BYTES: usize = 64 * 1024;
21/// Maximum selected subtrees in V1.
22pub const HARD_MAX_AUTHORIZATION_REVIEW_SELECTED_PATHS: usize = 8;
23/// Maximum ignored subtrees in V1.
24pub const HARD_MAX_AUTHORIZATION_REVIEW_IGNORED_PATHS: usize = 16;
25/// Maximum unordered-array paths in V1.
26pub const HARD_MAX_AUTHORIZATION_REVIEW_UNORDERED_ARRAY_PATHS: usize = 8;
27/// Maximum bytes in one canonical pointer.
28pub const HARD_MAX_AUTHORIZATION_REVIEW_PATH_BYTES: usize = 256;
29/// Maximum retained redacted diff paths in V1.
30pub const HARD_MAX_AUTHORIZATION_REVIEW_DIFF_PATHS: u16 = 32;
31
32const HARD_MAX_AUTHORIZATION_REVIEW_RESOURCE_BYTES: usize = 8 * 1024;
33const HARD_MAX_AUTHORIZATION_REVIEW_HANDLE_BYTES: usize = 128;
34const POLICY_ID_DOMAIN: &[u8] = b"security.authorization-review-policy.identity.v1\0";
35const RESOURCE_SCOPE_DOMAIN: &[u8] = b"security.authorization-review-resource.v1\0";
36const HANDLE_DOMAIN: &[u8] = b"security.authorization-review-handle.v1\0";
37
38/// V1 host-declared relation under review.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
40#[serde(rename_all = "kebab-case")]
41#[non_exhaustive]
42pub enum AuthorizationReviewExpectation {
43    /// The host asserts that only the primary principal should receive the resource.
44    PrimaryOnly,
45}
46
47impl AuthorizationReviewExpectation {
48    const fn wire(self) -> &'static str {
49        match self {
50            Self::PrimaryOnly => "primary-only",
51        }
52    }
53}
54
55/// Read-only method supported by V1.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
57#[non_exhaustive]
58pub enum AuthorizationReviewMethod {
59    /// One bodyless GET request template is replayed across four legs.
60    Get,
61}
62
63impl AuthorizationReviewMethod {
64    const fn wire(self) -> &'static str {
65        match self {
66            Self::Get => "GET",
67        }
68    }
69}
70
71/// Stable semantic identity of a validated policy.
72#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
73pub struct AuthorizationReviewPolicyId([u8; 32]);
74
75impl AuthorizationReviewPolicyId {
76    /// Returns the raw domain-separated digest.
77    pub const fn as_bytes(self) -> [u8; 32] {
78        self.0
79    }
80
81    /// Returns the prefixed lowercase hexadecimal wire identity.
82    pub fn to_wire(self) -> String {
83        format!("authorization-policy-sha256:{}", hex(self.0))
84    }
85}
86
87impl fmt::Debug for AuthorizationReviewPolicyId {
88    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
89        formatter.write_str(&self.to_wire())
90    }
91}
92
93impl fmt::Display for AuthorizationReviewPolicyId {
94    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
95        formatter.write_str(&self.to_wire())
96    }
97}
98
99impl Serialize for AuthorizationReviewPolicyId {
100    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
101    where
102        S: Serializer,
103    {
104        serializer.serialize_str(&self.to_wire())
105    }
106}
107
108/// Pseudonymous exact selected-resource identity.
109#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
110pub struct AuthorizationResourceScopeId([u8; 32]);
111
112impl AuthorizationResourceScopeId {
113    /// Returns the raw domain-separated digest.
114    pub const fn as_bytes(self) -> [u8; 32] {
115        self.0
116    }
117
118    /// Returns the prefixed lowercase hexadecimal wire identity.
119    pub fn to_wire(self) -> String {
120        format!("authorization-resource-sha256:{}", hex(self.0))
121    }
122}
123
124impl fmt::Debug for AuthorizationResourceScopeId {
125    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
126        formatter.write_str(&self.to_wire())
127    }
128}
129
130impl fmt::Display for AuthorizationResourceScopeId {
131    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
132        formatter.write_str(&self.to_wire())
133    }
134}
135
136impl Serialize for AuthorizationResourceScopeId {
137    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
138    where
139        S: Serializer,
140    {
141        serializer.serialize_str(&self.to_wire())
142    }
143}
144
145/// Strict, bounded policy for one exact-origin JSON resource.
146pub struct AuthorizationReviewPolicy {
147    expectation: AuthorizationReviewExpectation,
148    method: AuthorizationReviewMethod,
149    comparison: ApiComparisonProfile,
150    policy_id: AuthorizationReviewPolicyId,
151    resource: Url,
152}
153
154impl AuthorizationReviewPolicy {
155    /// Parses a strict bounded TOML policy and resolves its resource under the
156    /// existing exact-origin HTTP evidence authority contract.
157    pub fn parse_toml(
158        authorized_origin: &Url,
159        source: &[u8],
160    ) -> Result<Self, AuthorizationReviewPolicyError> {
161        if source.len() > HARD_MAX_AUTHORIZATION_REVIEW_POLICY_BYTES {
162            return Err(AuthorizationReviewPolicyError::PolicyTooLarge);
163        }
164        let source = std::str::from_utf8(source)
165            .map_err(|_| AuthorizationReviewPolicyError::MalformedPolicy)?;
166        let wire: WirePolicy =
167            toml::from_str(source).map_err(|_| AuthorizationReviewPolicyError::MalformedPolicy)?;
168        if wire.schema != AUTHORIZATION_REVIEW_POLICY_SCHEMA {
169            return Err(AuthorizationReviewPolicyError::UnsupportedSchema);
170        }
171        let expectation = match wire.expectation.as_str() {
172            "primary-only" => AuthorizationReviewExpectation::PrimaryOnly,
173            _ => return Err(AuthorizationReviewPolicyError::UnsupportedExpectation),
174        };
175        let method = match wire.method.as_str() {
176            "GET" => AuthorizationReviewMethod::Get,
177            _ => return Err(AuthorizationReviewPolicyError::UnsupportedMethod),
178        };
179        validate_handle(&wire.resource_handle)?;
180
181        let resource = resolve_resource(authorized_origin, &wire.resource)?;
182        let comparison = build_comparison_profile(wire.comparison)?;
183        let resource_scope_id = resource_scope_id(&resource);
184        let handle_digest: [u8; 32] = domain_digest(HANDLE_DOMAIN, wire.resource_handle.as_bytes());
185        let policy_id = policy_id(
186            resource_scope_id,
187            handle_digest,
188            expectation,
189            method,
190            &comparison,
191        );
192        Ok(Self {
193            expectation,
194            method,
195            comparison,
196            policy_id,
197            resource,
198        })
199    }
200
201    /// Returns the stable semantic policy identity.
202    pub const fn policy_id(&self) -> AuthorizationReviewPolicyId {
203        self.policy_id
204    }
205
206    /// Returns the pseudonymous exact-resource scope identity.
207    pub fn resource_scope_id(&self) -> AuthorizationResourceScopeId {
208        resource_scope_id(self.execution_resource())
209    }
210
211    /// Returns the sole executable V1 expectation.
212    pub const fn expectation(&self) -> AuthorizationReviewExpectation {
213        self.expectation
214    }
215
216    /// Returns the sole executable V1 method.
217    pub const fn method(&self) -> AuthorizationReviewMethod {
218        self.method
219    }
220
221    /// Returns the checked raw-value-free projection profile.
222    pub const fn comparison(&self) -> &ApiComparisonProfile {
223        &self.comparison
224    }
225
226    /// Returns the selected-path count without exposing path material.
227    pub fn selected_path_count(&self) -> usize {
228        self.comparison.selected_paths().len()
229    }
230
231    /// Returns the ignored-path count without exposing path material.
232    pub fn ignored_path_count(&self) -> usize {
233        self.comparison.ignored_paths().len()
234    }
235
236    /// Returns the unordered-array-path count without exposing path material.
237    pub fn unordered_array_path_count(&self) -> usize {
238        self.comparison.unordered_arrays().len()
239    }
240
241    /// Returns the already validated canonical request target only to the
242    /// crate-owned execution boundary.
243    pub(crate) const fn execution_resource(&self) -> &Url {
244        &self.resource
245    }
246}
247
248impl fmt::Debug for AuthorizationReviewPolicy {
249    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
250        formatter
251            .debug_struct("AuthorizationReviewPolicy")
252            .field("resource", &"<redacted>")
253            .field("resource_handle", &"<redacted>")
254            .field("expectation", &self.expectation)
255            .field("method", &self.method)
256            .field("comparison", &self.comparison)
257            .field("policy_id", &self.policy_id)
258            .field("resource_scope_id", &self.resource_scope_id())
259            .finish()
260    }
261}
262
263/// Static, value-free policy validation failure.
264#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
265#[non_exhaustive]
266pub enum AuthorizationReviewPolicyError {
267    /// Input exceeded the compiled policy source ceiling.
268    #[error("authorization review policy exceeds its compiled byte limit")]
269    PolicyTooLarge,
270    /// TOML, UTF-8, required fields, or a bounded scalar was invalid.
271    #[error("authorization review policy is malformed")]
272    MalformedPolicy,
273    /// The schema identifier is not supported.
274    #[error("authorization review policy schema is unsupported")]
275    UnsupportedSchema,
276    /// V1 implements only primary-only policy review.
277    #[error("authorization review expectation is unsupported")]
278    UnsupportedExpectation,
279    /// V1 implements only bodyless GET review.
280    #[error("authorization review method is unsupported")]
281    UnsupportedMethod,
282    /// The exact selected resource was invalid or outside authority.
283    #[error("authorization review resource is invalid or outside exact-origin authority")]
284    InvalidResource,
285    /// The opaque resource handle violated its bounded token contract.
286    #[error("authorization review resource handle is invalid")]
287    InvalidResourceHandle,
288    /// The comparison path set violated V1's exact bounded profile contract.
289    #[error("authorization review comparison profile is invalid")]
290    InvalidComparisonProfile,
291}
292
293#[derive(Deserialize)]
294#[serde(deny_unknown_fields)]
295struct WirePolicy {
296    schema: String,
297    resource: String,
298    resource_handle: String,
299    expectation: String,
300    method: String,
301    comparison: WireComparison,
302}
303
304#[derive(Deserialize)]
305#[serde(deny_unknown_fields)]
306struct WireComparison {
307    selected_paths: Vec<String>,
308    #[serde(default)]
309    ignored_paths: Vec<String>,
310    #[serde(default)]
311    unordered_array_paths: Vec<String>,
312    max_diff_paths: u16,
313}
314
315fn resolve_resource(
316    authorized_origin: &Url,
317    raw: &str,
318) -> Result<Url, AuthorizationReviewPolicyError> {
319    if raw.is_empty() || raw.len() > HARD_MAX_AUTHORIZATION_REVIEW_RESOURCE_BYTES {
320        return Err(AuthorizationReviewPolicyError::InvalidResource);
321    }
322    let authority = HttpEvidencePolicy::for_origin(authorized_origin.clone())
323        .map_err(|_| AuthorizationReviewPolicyError::InvalidResource)?;
324    let mut origin_root = authorized_origin.clone();
325    origin_root.set_path("/");
326    origin_root.set_query(None);
327    origin_root.set_fragment(None);
328    let resource = match Url::parse(raw) {
329        Ok(resource) => resource,
330        Err(ParseError::RelativeUrlWithoutBase) => origin_root
331            .join(raw)
332            .map_err(|_| AuthorizationReviewPolicyError::InvalidResource)?,
333        Err(_) => return Err(AuthorizationReviewPolicyError::InvalidResource),
334    };
335    if resource.fragment().is_some()
336        || resource.path().is_empty()
337        || resource.as_str().len() > HARD_MAX_AUTHORIZATION_REVIEW_RESOURCE_BYTES
338    {
339        return Err(AuthorizationReviewPolicyError::InvalidResource);
340    }
341    authority
342        .require_permitted_target(&resource)
343        .map_err(|_| AuthorizationReviewPolicyError::InvalidResource)?;
344    Ok(resource)
345}
346
347fn validate_handle(value: &str) -> Result<(), AuthorizationReviewPolicyError> {
348    if value.is_empty()
349        || value.len() > HARD_MAX_AUTHORIZATION_REVIEW_HANDLE_BYTES
350        || !value
351            .bytes()
352            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-'))
353    {
354        return Err(AuthorizationReviewPolicyError::InvalidResourceHandle);
355    }
356    Ok(())
357}
358
359fn build_comparison_profile(
360    wire: WireComparison,
361) -> Result<ApiComparisonProfile, AuthorizationReviewPolicyError> {
362    if wire.selected_paths.is_empty()
363        || wire.selected_paths.len() > HARD_MAX_AUTHORIZATION_REVIEW_SELECTED_PATHS
364        || wire.ignored_paths.len() > HARD_MAX_AUTHORIZATION_REVIEW_IGNORED_PATHS
365        || wire.unordered_array_paths.len() > HARD_MAX_AUTHORIZATION_REVIEW_UNORDERED_ARRAY_PATHS
366        || wire.max_diff_paths == 0
367        || wire.max_diff_paths > HARD_MAX_AUTHORIZATION_REVIEW_DIFF_PATHS
368    {
369        return Err(AuthorizationReviewPolicyError::InvalidComparisonProfile);
370    }
371
372    let selected = parse_exact_paths(wire.selected_paths, true)?;
373    let ignored = parse_exact_paths(wire.ignored_paths, false)?;
374    let unordered = parse_exact_paths(wire.unordered_array_paths, false)?;
375
376    if has_redundant_subtrees(&selected)
377        || has_redundant_subtrees(&ignored)
378        || ignored.iter().any(|path| {
379            !selected
380                .iter()
381                .any(|root| is_strict_descendant(root.as_str(), path.as_str()))
382        })
383        || unordered.iter().any(|path| {
384            !selected
385                .iter()
386                .any(|root| is_within(root.as_str(), path.as_str()))
387        })
388        || unordered.iter().any(|path| {
389            ignored
390                .iter()
391                .any(|ignored| is_within(ignored.as_str(), path.as_str()))
392        })
393    {
394        return Err(AuthorizationReviewPolicyError::InvalidComparisonProfile);
395    }
396
397    ApiComparisonProfile::new(selected, ignored, unordered, wire.max_diff_paths)
398        .map_err(|_| AuthorizationReviewPolicyError::InvalidComparisonProfile)
399}
400
401fn parse_exact_paths(
402    values: Vec<String>,
403    selected: bool,
404) -> Result<Vec<JsonPathPattern>, AuthorizationReviewPolicyError> {
405    let mut unique = BTreeSet::new();
406    let mut parsed = Vec::with_capacity(values.len());
407    for value in values {
408        if value.len() > HARD_MAX_AUTHORIZATION_REVIEW_PATH_BYTES {
409            return Err(AuthorizationReviewPolicyError::InvalidComparisonProfile);
410        }
411        let path = JsonPathPattern::new(value)
412            .map_err(|_| AuthorizationReviewPolicyError::InvalidComparisonProfile)?;
413        if path.as_str().len() > HARD_MAX_AUTHORIZATION_REVIEW_PATH_BYTES
414            || (selected && path.as_str().is_empty())
415            || path.as_str().split('/').any(|token| token == "*")
416            || !unique.insert(path.as_str().to_owned())
417        {
418            return Err(AuthorizationReviewPolicyError::InvalidComparisonProfile);
419        }
420        parsed.push(path);
421    }
422    Ok(parsed)
423}
424
425fn is_within(root: &str, candidate: &str) -> bool {
426    candidate == root
427        || candidate
428            .strip_prefix(root)
429            .is_some_and(|suffix| suffix.starts_with('/'))
430}
431
432fn is_strict_descendant(root: &str, candidate: &str) -> bool {
433    candidate != root && is_within(root, candidate)
434}
435
436fn has_redundant_subtrees(paths: &[JsonPathPattern]) -> bool {
437    paths.iter().enumerate().any(|(index, left)| {
438        paths.iter().skip(index + 1).any(|right| {
439            is_within(left.as_str(), right.as_str()) || is_within(right.as_str(), left.as_str())
440        })
441    })
442}
443
444fn resource_scope_id(resource: &Url) -> AuthorizationResourceScopeId {
445    AuthorizationResourceScopeId(domain_digest(
446        RESOURCE_SCOPE_DOMAIN,
447        resource.as_str().as_bytes(),
448    ))
449}
450
451fn policy_id(
452    resource: AuthorizationResourceScopeId,
453    handle_digest: [u8; 32],
454    expectation: AuthorizationReviewExpectation,
455    method: AuthorizationReviewMethod,
456    comparison: &ApiComparisonProfile,
457) -> AuthorizationReviewPolicyId {
458    let mut hasher = Sha256::new();
459    hasher.update(POLICY_ID_DOMAIN);
460    framed(&mut hasher, AUTHORIZATION_REVIEW_POLICY_SCHEMA.as_bytes());
461    framed(
462        &mut hasher,
463        AUTHORIZATION_REVIEW_ALGORITHM_VERSION.as_bytes(),
464    );
465    framed(&mut hasher, &resource.as_bytes());
466    framed(&mut hasher, &handle_digest);
467    framed(&mut hasher, expectation.wire().as_bytes());
468    framed(&mut hasher, method.wire().as_bytes());
469    framed(&mut hasher, comparison.projection_policy_id().as_bytes());
470    framed(
471        &mut hasher,
472        &u64::try_from(comparison.selected_paths().len())
473            .unwrap_or(u64::MAX)
474            .to_be_bytes(),
475    );
476    framed(
477        &mut hasher,
478        &u64::try_from(comparison.ignored_paths().len())
479            .unwrap_or(u64::MAX)
480            .to_be_bytes(),
481    );
482    framed(
483        &mut hasher,
484        &u64::try_from(comparison.unordered_arrays().len())
485            .unwrap_or(u64::MAX)
486            .to_be_bytes(),
487    );
488    framed(&mut hasher, &comparison.max_diff_paths().to_be_bytes());
489    AuthorizationReviewPolicyId(hasher.finalize().into())
490}
491
492fn domain_digest(domain: &[u8], value: &[u8]) -> [u8; 32] {
493    let mut hasher = Sha256::new();
494    hasher.update(domain);
495    framed(&mut hasher, value);
496    hasher.finalize().into()
497}
498
499fn framed(hasher: &mut Sha256, value: &[u8]) {
500    hasher.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
501    hasher.update(value);
502}
503
504fn hex(bytes: [u8; 32]) -> String {
505    const HEX: &[u8; 16] = b"0123456789abcdef";
506    let mut output = String::with_capacity(64);
507    for byte in bytes {
508        output.push(char::from(HEX[usize::from(byte >> 4)]));
509        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
510    }
511    output
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    const QUERY_SECRET: &str = "RESOURCE-QUERY-MUST-NOT-LEAK-51A9BC";
519    const HANDLE_SECRET: &str = "PRIVATE-RESOURCE-HANDLE-MUST-NOT-LEAK-346E2A";
520
521    fn origin() -> Url {
522        Url::parse("https://api.example.test/root").unwrap()
523    }
524
525    fn source(resource: &str, selected: &[&str], ignored: &[&str], unordered: &[&str]) -> String {
526        format!(
527            "schema = \"{AUTHORIZATION_REVIEW_POLICY_SCHEMA}\"\nresource = \"{resource}\"\nresource_handle = \"account-self-profile\"\nexpectation = \"primary-only\"\nmethod = \"GET\"\n[comparison]\nselected_paths = {selected:?}\nignored_paths = {ignored:?}\nunordered_array_paths = {unordered:?}\nmax_diff_paths = 32\n"
528        )
529    }
530
531    fn source_owned(
532        resource: &str,
533        selected: &[String],
534        ignored: &[String],
535        unordered: &[String],
536    ) -> String {
537        format!(
538            "schema = \"{AUTHORIZATION_REVIEW_POLICY_SCHEMA}\"\nresource = {resource:?}\nresource_handle = \"account-self-profile\"\nexpectation = \"primary-only\"\nmethod = \"GET\"\n[comparison]\nselected_paths = {selected:?}\nignored_paths = {ignored:?}\nunordered_array_paths = {unordered:?}\nmax_diff_paths = 32\n"
539        )
540    }
541
542    fn policy(resource: &str) -> AuthorizationReviewPolicy {
543        AuthorizationReviewPolicy::parse_toml(
544            &origin(),
545            source(
546                resource,
547                &["/data/account"],
548                &["/data/account/updated_at"],
549                &[],
550            )
551            .as_bytes(),
552        )
553        .unwrap()
554    }
555
556    #[test]
557    fn valid_policy_is_bounded_exact_origin_and_redacted() {
558        let policy = policy(&format!("/api/accounts/42?opaque={QUERY_SECRET}"));
559        assert_eq!(
560            policy.expectation(),
561            AuthorizationReviewExpectation::PrimaryOnly
562        );
563        assert_eq!(policy.method(), AuthorizationReviewMethod::Get);
564        assert_eq!(policy.selected_path_count(), 1);
565        assert_eq!(policy.ignored_path_count(), 1);
566        assert_eq!(policy.unordered_array_path_count(), 0);
567        assert_eq!(policy.policy_id().to_wire().len(), 92);
568        let rendered = format!("{policy:?}");
569        assert!(!rendered.contains(QUERY_SECRET));
570        assert!(!rendered.contains("account-self-profile"));
571        assert!(!rendered.contains("/data/account"));
572    }
573
574    #[test]
575    fn relative_resource_is_resolved_from_the_exact_origin_root() {
576        let nested = Url::parse("https://example.test/nested/base/?opaque=ignored").unwrap();
577        let policy = AuthorizationReviewPolicy::parse_toml(
578            &nested,
579            source("api/accounts/42", &["/data/account"], &[], &[]).as_bytes(),
580        )
581        .unwrap();
582        assert_eq!(
583            policy.execution_resource().as_str(),
584            "https://example.test/api/accounts/42"
585        );
586    }
587
588    #[test]
589    fn schema_unknown_fields_expectation_and_method_fail_closed() {
590        let valid = source("/api/accounts/42", &["/data/account"], &[], &[]);
591        for (needle, replacement, expected) in [
592            (
593                AUTHORIZATION_REVIEW_POLICY_SCHEMA,
594                "security.authorization-review-policy/v2",
595                AuthorizationReviewPolicyError::UnsupportedSchema,
596            ),
597            (
598                "expectation = \"primary-only\"",
599                "expectation = \"both-allowed\"",
600                AuthorizationReviewPolicyError::UnsupportedExpectation,
601            ),
602            (
603                "method = \"GET\"",
604                "method = \"POST\"",
605                AuthorizationReviewPolicyError::UnsupportedMethod,
606            ),
607        ] {
608            let mutated = valid.replacen(needle, replacement, 1);
609            assert_eq!(
610                AuthorizationReviewPolicy::parse_toml(&origin(), mutated.as_bytes()).unwrap_err(),
611                expected
612            );
613        }
614        let unknown = format!("{valid}credential = \"secret\"\n");
615        assert_eq!(
616            AuthorizationReviewPolicy::parse_toml(&origin(), unknown.as_bytes()).unwrap_err(),
617            AuthorizationReviewPolicyError::MalformedPolicy
618        );
619        for malformed in [b"not = [".as_slice(), &[0xff]] {
620            assert_eq!(
621                AuthorizationReviewPolicy::parse_toml(&origin(), malformed).unwrap_err(),
622                AuthorizationReviewPolicyError::MalformedPolicy
623            );
624        }
625    }
626
627    #[test]
628    fn policy_source_and_handle_limits_are_strict() {
629        assert_eq!(
630            AuthorizationReviewPolicy::parse_toml(
631                &origin(),
632                &vec![b'x'; HARD_MAX_AUTHORIZATION_REVIEW_POLICY_BYTES + 1]
633            )
634            .unwrap_err(),
635            AuthorizationReviewPolicyError::PolicyTooLarge
636        );
637        let invalid_handle = source("/api/accounts/42", &["/data"], &[], &[])
638            .replace("account-self-profile", "handle with spaces");
639        assert_eq!(
640            AuthorizationReviewPolicy::parse_toml(&origin(), invalid_handle.as_bytes())
641                .unwrap_err(),
642            AuthorizationReviewPolicyError::InvalidResourceHandle
643        );
644        for handle in [
645            String::new(),
646            "h".repeat(HARD_MAX_AUTHORIZATION_REVIEW_HANDLE_BYTES + 1),
647        ] {
648            let invalid = source("/api/accounts/42", &["/data"], &[], &[])
649                .replace("account-self-profile", &handle);
650            assert_eq!(
651                AuthorizationReviewPolicy::parse_toml(&origin(), invalid.as_bytes()).unwrap_err(),
652                AuthorizationReviewPolicyError::InvalidResourceHandle
653            );
654        }
655        let maximum = source("/api/accounts/42", &["/data"], &[], &[]).replace(
656            "account-self-profile",
657            &"h".repeat(HARD_MAX_AUTHORIZATION_REVIEW_HANDLE_BYTES),
658        );
659        assert!(AuthorizationReviewPolicy::parse_toml(&origin(), maximum.as_bytes()).is_ok());
660    }
661
662    #[test]
663    fn resource_binding_reuses_exact_origin_authority() {
664        let relative = policy("/api/accounts/42");
665        let absolute = policy("https://api.example.test:443/api/accounts/42");
666        assert_eq!(relative.resource_scope_id(), absolute.resource_scope_id());
667
668        for invalid in [
669            "http://api.example.test/api/accounts/42",
670            "https://other.example.test/api/accounts/42",
671            "https://api.example.test:444/api/accounts/42",
672            "https://user:secret@api.example.test/api/accounts/42",
673            "/api/accounts/42#fragment",
674            "ftp://api.example.test/api/accounts/42",
675        ] {
676            assert_eq!(
677                AuthorizationReviewPolicy::parse_toml(
678                    &origin(),
679                    source(invalid, &["/data"], &[], &[]).as_bytes()
680                )
681                .unwrap_err(),
682                AuthorizationReviewPolicyError::InvalidResource
683            );
684        }
685        for invalid in [
686            String::new(),
687            format!(
688                "/{}",
689                "a".repeat(HARD_MAX_AUTHORIZATION_REVIEW_RESOURCE_BYTES)
690            ),
691        ] {
692            assert_eq!(
693                AuthorizationReviewPolicy::parse_toml(
694                    &origin(),
695                    source_owned(&invalid, &["/data".to_owned()], &[], &[]).as_bytes(),
696                )
697                .unwrap_err(),
698                AuthorizationReviewPolicyError::InvalidResource
699            );
700        }
701        assert!(AuthorizationReviewPolicy::parse_toml(
702            &origin(),
703            source("/", &["/data/account"], &[], &[]).as_bytes(),
704        )
705        .is_ok());
706    }
707
708    #[test]
709    fn exact_selected_paths_and_nested_rules_are_enforced() {
710        let invalid_profiles = [
711            source("/api/x", &[], &[], &[]),
712            source("/api/x", &[""], &[], &[]),
713            source("/api/x", &["/data/*"], &[], &[]),
714            source("/api/x", &["/data/~2bad"], &[], &[]),
715            source("/api/x", &["/data", "/data"], &[], &[]),
716            source("/api/x", &["/data", "/data/account"], &[], &[]),
717            source("/api/x", &["/data"], &["/outside"], &[]),
718            source("/api/x", &["/data"], &["/data"], &[]),
719            source(
720                "/api/x",
721                &["/data"],
722                &["/data/account", "/data/account/name"],
723                &[],
724            ),
725            source("/api/x", &["/data"], &[], &["/outside"]),
726            source("/api/x", &["/data"], &[], &["/data/*"]),
727            source(
728                "/api/x",
729                &["/data"],
730                &["/data/account"],
731                &["/data/account/roles"],
732            ),
733        ];
734        for invalid in invalid_profiles {
735            assert_eq!(
736                AuthorizationReviewPolicy::parse_toml(&origin(), invalid.as_bytes()).unwrap_err(),
737                AuthorizationReviewPolicyError::InvalidComparisonProfile
738            );
739        }
740    }
741
742    #[test]
743    fn zero_and_over_limit_diff_paths_are_rejected() {
744        let valid = source("/api/x", &["/data"], &[], &[]);
745        for invalid in [
746            valid.replace("max_diff_paths = 32", "max_diff_paths = 0"),
747            valid.replace("max_diff_paths = 32", "max_diff_paths = 33"),
748        ] {
749            assert_eq!(
750                AuthorizationReviewPolicy::parse_toml(&origin(), invalid.as_bytes()).unwrap_err(),
751                AuthorizationReviewPolicyError::InvalidComparisonProfile
752            );
753        }
754    }
755
756    #[test]
757    fn comparison_count_and_path_byte_ceilings_are_enforced() {
758        let selected = (0..=HARD_MAX_AUTHORIZATION_REVIEW_SELECTED_PATHS)
759            .map(|index| format!("/selected/{index}"))
760            .collect::<Vec<_>>();
761        let ignored = (0..=HARD_MAX_AUTHORIZATION_REVIEW_IGNORED_PATHS)
762            .map(|index| format!("/data/ignored-{index}"))
763            .collect::<Vec<_>>();
764        let unordered = (0..=HARD_MAX_AUTHORIZATION_REVIEW_UNORDERED_ARRAY_PATHS)
765            .map(|index| format!("/data/array-{index}"))
766            .collect::<Vec<_>>();
767        let overlong = format!("/{}", "p".repeat(HARD_MAX_AUTHORIZATION_REVIEW_PATH_BYTES));
768        let canonical_overlong = format!("/{}", "~".repeat(128));
769        for invalid in [
770            source_owned("/api/x", &selected, &[], &[]),
771            source_owned("/api/x", &["/data".to_owned()], &ignored, &[]),
772            source_owned("/api/x", &["/data".to_owned()], &[], &unordered),
773            source_owned("/api/x", &[overlong], &[], &[]),
774            source_owned("/api/x", &[canonical_overlong], &[], &[]),
775        ] {
776            assert_eq!(
777                AuthorizationReviewPolicy::parse_toml(&origin(), invalid.as_bytes()).unwrap_err(),
778                AuthorizationReviewPolicyError::InvalidComparisonProfile
779            );
780        }
781    }
782
783    #[test]
784    fn canonical_execution_resource_is_retained_without_debug_disclosure() {
785        let policy = policy("/api/../accounts/42?mode=review");
786        assert_eq!(
787            policy.execution_resource().as_str(),
788            "https://api.example.test/accounts/42?mode=review"
789        );
790        assert!(!format!("{policy:?}").contains("mode=review"));
791    }
792
793    #[test]
794    fn semantic_policy_identity_is_order_independent_and_materially_bound() {
795        let first = AuthorizationReviewPolicy::parse_toml(
796            &origin(),
797            source(
798                "/api/accounts/42",
799                &["/data/account", "/meta/owner"],
800                &["/data/account/updated_at", "/meta/owner/seen_at"],
801                &[],
802            )
803            .as_bytes(),
804        )
805        .unwrap();
806        let reordered = AuthorizationReviewPolicy::parse_toml(
807            &origin(),
808            source(
809                "/api/accounts/42",
810                &["/meta/owner", "/data/account"],
811                &["/meta/owner/seen_at", "/data/account/updated_at"],
812                &[],
813            )
814            .as_bytes(),
815        )
816        .unwrap();
817        assert_eq!(first.policy_id(), reordered.policy_id());
818        assert_ne!(first.policy_id(), policy("/api/accounts/43").policy_id());
819
820        let changed_profile = AuthorizationReviewPolicy::parse_toml(
821            &origin(),
822            source("/api/accounts/42", &["/data/other"], &[], &[]).as_bytes(),
823        )
824        .unwrap();
825        assert_ne!(first.policy_id(), changed_profile.policy_id());
826    }
827
828    #[test]
829    fn query_and_handle_material_change_identity_without_cleartext_output() {
830        let first = policy(&format!("/api/accounts/42?q={QUERY_SECRET}"));
831        let second = policy("/api/accounts/42?q=other");
832        assert_ne!(first.resource_scope_id(), second.resource_scope_id());
833        assert_ne!(first.policy_id(), second.policy_id());
834
835        let source_with_private_handle = source("/api/accounts/42", &["/data"], &[], &[])
836            .replace("account-self-profile", HANDLE_SECRET);
837        let handle_policy =
838            AuthorizationReviewPolicy::parse_toml(&origin(), source_with_private_handle.as_bytes())
839                .unwrap();
840        let ordinary_handle = AuthorizationReviewPolicy::parse_toml(
841            &origin(),
842            source("/api/accounts/42", &["/data"], &[], &[]).as_bytes(),
843        )
844        .unwrap();
845        assert_ne!(handle_policy.policy_id(), ordinary_handle.policy_id());
846        for rendered in [
847            format!("{handle_policy:?}"),
848            handle_policy.policy_id().to_string(),
849            handle_policy.resource_scope_id().to_string(),
850        ] {
851            assert!(!rendered.contains(QUERY_SECRET));
852            assert!(!rendered.contains(HANDLE_SECRET));
853        }
854    }
855}