1use 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
15pub const AUTHORIZATION_REVIEW_POLICY_SCHEMA: &str = "security.authorization-review-policy/v1";
17pub const AUTHORIZATION_REVIEW_ALGORITHM_VERSION: &str = "security.authorization-differential/v1";
19pub const HARD_MAX_AUTHORIZATION_REVIEW_POLICY_BYTES: usize = 64 * 1024;
21pub const HARD_MAX_AUTHORIZATION_REVIEW_SELECTED_PATHS: usize = 8;
23pub const HARD_MAX_AUTHORIZATION_REVIEW_IGNORED_PATHS: usize = 16;
25pub const HARD_MAX_AUTHORIZATION_REVIEW_UNORDERED_ARRAY_PATHS: usize = 8;
27pub const HARD_MAX_AUTHORIZATION_REVIEW_PATH_BYTES: usize = 256;
29pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
40#[serde(rename_all = "kebab-case")]
41#[non_exhaustive]
42pub enum AuthorizationReviewExpectation {
43 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
57#[non_exhaustive]
58pub enum AuthorizationReviewMethod {
59 Get,
61}
62
63impl AuthorizationReviewMethod {
64 const fn wire(self) -> &'static str {
65 match self {
66 Self::Get => "GET",
67 }
68 }
69}
70
71#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
73pub struct AuthorizationReviewPolicyId([u8; 32]);
74
75impl AuthorizationReviewPolicyId {
76 pub const fn as_bytes(self) -> [u8; 32] {
78 self.0
79 }
80
81 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#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
110pub struct AuthorizationResourceScopeId([u8; 32]);
111
112impl AuthorizationResourceScopeId {
113 pub const fn as_bytes(self) -> [u8; 32] {
115 self.0
116 }
117
118 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
145pub struct AuthorizationReviewPolicy {
147 expectation: AuthorizationReviewExpectation,
148 method: AuthorizationReviewMethod,
149 comparison: ApiComparisonProfile,
150 policy_id: AuthorizationReviewPolicyId,
151 resource: Url,
152}
153
154impl AuthorizationReviewPolicy {
155 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 pub const fn policy_id(&self) -> AuthorizationReviewPolicyId {
203 self.policy_id
204 }
205
206 pub fn resource_scope_id(&self) -> AuthorizationResourceScopeId {
208 resource_scope_id(self.execution_resource())
209 }
210
211 pub const fn expectation(&self) -> AuthorizationReviewExpectation {
213 self.expectation
214 }
215
216 pub const fn method(&self) -> AuthorizationReviewMethod {
218 self.method
219 }
220
221 pub const fn comparison(&self) -> &ApiComparisonProfile {
223 &self.comparison
224 }
225
226 pub fn selected_path_count(&self) -> usize {
228 self.comparison.selected_paths().len()
229 }
230
231 pub fn ignored_path_count(&self) -> usize {
233 self.comparison.ignored_paths().len()
234 }
235
236 pub fn unordered_array_path_count(&self) -> usize {
238 self.comparison.unordered_arrays().len()
239 }
240
241 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
265#[non_exhaustive]
266pub enum AuthorizationReviewPolicyError {
267 #[error("authorization review policy exceeds its compiled byte limit")]
269 PolicyTooLarge,
270 #[error("authorization review policy is malformed")]
272 MalformedPolicy,
273 #[error("authorization review policy schema is unsupported")]
275 UnsupportedSchema,
276 #[error("authorization review expectation is unsupported")]
278 UnsupportedExpectation,
279 #[error("authorization review method is unsupported")]
281 UnsupportedMethod,
282 #[error("authorization review resource is invalid or outside exact-origin authority")]
284 InvalidResource,
285 #[error("authorization review resource handle is invalid")]
287 InvalidResourceHandle,
288 #[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}