1use runx_contracts::schema::{IsoDateTime, NonEmptyString};
22use runx_contracts::{
23 AuthorityBounds, AuthorityCapability, AuthorityResourceFamily, AuthoritySubsetComparison,
24 AuthoritySubsetProof, AuthoritySubsetRelation, AuthoritySubsetResult, AuthorityTerm,
25 AuthorityVerb, Reference, sha256_hex,
26};
27use serde::{Deserialize, Serialize};
28use thiserror::Error;
29
30use super::authority_algebra::{
31 items_subset, optional_ref_bound_subset, parent_items_preserved, same_reference_address,
32};
33
34pub trait FamilySubsetComparator {
51 fn comparison_algorithm(&self) -> &str;
54
55 fn narrow_bounds(
61 &self,
62 parent: &AuthorityTerm,
63 requested: &AuthorityBounds,
64 ) -> AuthorityBounds {
65 let _ = parent;
66 requested.clone()
67 }
68
69 fn bounds_subset(&self, child: &AuthorityTerm, parent: &AuthorityTerm) -> bool;
72}
73
74#[derive(Clone, Debug, Default)]
84pub struct ScopeBoundsComparator;
85
86impl ScopeBoundsComparator {
87 pub const ALGORITHM: &'static str = "runx.scope-bounds-subset.v1";
89}
90
91impl FamilySubsetComparator for ScopeBoundsComparator {
92 fn comparison_algorithm(&self) -> &str {
93 Self::ALGORITHM
94 }
95
96 fn bounds_subset(&self, child: &AuthorityTerm, parent: &AuthorityTerm) -> bool {
100 let AuthorityBounds {
106 repo_path_globs: child_repo_path_globs,
107 branch_patterns: child_branch_patterns,
108 filesystem_roots: child_filesystem_roots,
109 network_destinations: child_network_destinations,
110 deployment_environments: child_deployment_environments,
111 token_audiences: child_token_audiences,
112 max_cost_units: _, effect_limits: child_effect_limits,
117 effects: child_effects,
118 max_runtime_ms: child_max_runtime_ms,
119 max_fanout: child_max_fanout,
120 max_child_depth: child_max_child_depth,
121 } = &child.bounds;
122 let AuthorityBounds {
123 repo_path_globs: parent_repo_path_globs,
124 branch_patterns: parent_branch_patterns,
125 filesystem_roots: parent_filesystem_roots,
126 network_destinations: parent_network_destinations,
127 deployment_environments: parent_deployment_environments,
128 token_audiences: parent_token_audiences,
129 max_cost_units: _, effect_limits: _, effects: _, max_runtime_ms: parent_max_runtime_ms,
133 max_fanout: parent_max_fanout,
134 max_child_depth: parent_max_child_depth,
135 } = &parent.bounds;
136
137 child_effect_limits.is_empty()
141 && child_effects.is_empty()
142 && items_subset(child_repo_path_globs, parent_repo_path_globs)
143 && items_subset(child_branch_patterns, parent_branch_patterns)
144 && items_subset(child_filesystem_roots, parent_filesystem_roots)
145 && items_subset(child_network_destinations, parent_network_destinations)
146 && items_subset(
147 child_deployment_environments,
148 parent_deployment_environments,
149 )
150 && items_subset(child_token_audiences, parent_token_audiences)
151 && max_cost_units_subset(&child.bounds, &parent.bounds)
152 && optional_ref_bound_subset(
153 child_max_runtime_ms.as_ref(),
154 parent_max_runtime_ms.as_ref(),
155 )
156 && optional_ref_bound_subset(child_max_fanout.as_ref(), parent_max_fanout.as_ref())
157 && optional_ref_bound_subset(
158 child_max_child_depth.as_ref(),
159 parent_max_child_depth.as_ref(),
160 )
161 }
162}
163
164fn max_cost_units_subset(child: &AuthorityBounds, parent: &AuthorityBounds) -> bool {
169 match (
170 child.max_cost_units.as_ref().and_then(|n| n.as_f64()),
171 parent.max_cost_units.as_ref().and_then(|n| n.as_f64()),
172 ) {
173 (Some(child), Some(parent)) => child <= parent,
175 (None, Some(_)) => false,
179 (_, None) => parent.max_cost_units.is_none(),
182 }
183}
184
185#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
199#[serde(deny_unknown_fields)]
200pub struct AttenuationRequest {
201 pub principal_ref: Reference,
203 pub resource_ref: Reference,
206 pub resource_family: AuthorityResourceFamily,
208 pub verbs: Vec<AuthorityVerb>,
210 pub capabilities: Vec<AuthorityCapability>,
212 pub bounds: AuthorityBounds,
215 pub expires_at: Option<IsoDateTime>,
218}
219
220#[derive(Clone, Debug, Error, PartialEq, Eq)]
222pub enum AttenuationError {
223 #[error("requested resource family does not match the parent authority")]
225 ResourceFamilyMismatch,
226 #[error("requested resource address does not match the parent authority")]
228 ResourceAddressMismatch,
229 #[error("requested verbs are not a subset of the parent authority")]
231 VerbsNotSubset,
232 #[error("requested capabilities are not a subset of the parent authority")]
234 CapabilitiesNotSubset,
235 #[error("requested expiry outlives the parent authority")]
238 ExpiryNotSubset,
239 #[error("derived child authority is not a subset of the parent authority")]
243 ChildNotSubset,
244}
245
246pub fn mint_attenuated(
266 parent: &AuthorityTerm,
267 request: &AttenuationRequest,
268 comparator: &dyn FamilySubsetComparator,
269 checked_at: IsoDateTime,
270) -> Result<(AuthorityTerm, AuthoritySubsetProof), AttenuationError> {
271 if request.resource_family != parent.resource_family {
275 return Err(AttenuationError::ResourceFamilyMismatch);
276 }
277 if !same_reference_address(&request.resource_ref, &parent.resource_ref) {
278 return Err(AttenuationError::ResourceAddressMismatch);
279 }
280 if !items_subset(&request.verbs, &parent.verbs) {
281 return Err(AttenuationError::VerbsNotSubset);
282 }
283 if !items_subset(&request.capabilities, &parent.capabilities) {
284 return Err(AttenuationError::CapabilitiesNotSubset);
285 }
286 if !optional_ref_bound_subset(request.expires_at.as_ref(), parent.expires_at.as_ref()) {
287 return Err(AttenuationError::ExpiryNotSubset);
288 }
289
290 let bounds = comparator.narrow_bounds(parent, &request.bounds);
295 let term_id = derive_term_id(parent, request, &bounds);
296 let child = AuthorityTerm {
297 term_id,
298 principal_ref: request.principal_ref.clone(),
299 resource_ref: request.resource_ref.clone(),
300 resource_family: request.resource_family.clone(),
301 verbs: request.verbs.clone(),
302 bounds,
303 conditions: parent.conditions.clone(),
304 approvals: parent.approvals.clone(),
305 capabilities: request.capabilities.clone(),
306 expires_at: request.expires_at.clone(),
307 issued_by_ref: parent.issued_by_ref.clone(),
308 credential_ref: parent.credential_ref.clone(),
309 };
310
311 if !is_authority_subset(&child, parent, comparator) {
315 return Err(AttenuationError::ChildNotSubset);
316 }
317
318 let proof = AuthoritySubsetProof {
319 parent_authority_ref: parent.resource_ref.clone(),
320 comparison_algorithm: NonEmptyString::from(comparator.comparison_algorithm().to_owned()),
321 result: AuthoritySubsetResult::Subset,
322 compared_terms: vec![AuthoritySubsetComparison {
323 child_term_id: child.term_id.clone(),
324 parent_term_id: parent.term_id.clone(),
325 relation: AuthoritySubsetRelation::Subset,
326 }],
327 proof_ref: None,
328 checked_at,
329 };
330
331 Ok((child, proof))
332}
333
334#[must_use]
343pub fn is_authority_subset(
344 child: &AuthorityTerm,
345 parent: &AuthorityTerm,
346 comparator: &dyn FamilySubsetComparator,
347) -> bool {
348 child.resource_family == parent.resource_family
349 && same_reference_address(&child.resource_ref, &parent.resource_ref)
350 && items_subset(&child.verbs, &parent.verbs)
351 && items_subset(&child.capabilities, &parent.capabilities)
352 && parent_items_preserved(&child.conditions, &parent.conditions)
353 && parent_items_preserved(&child.approvals, &parent.approvals)
354 && optional_ref_bound_subset(child.expires_at.as_ref(), parent.expires_at.as_ref())
355 && comparator.bounds_subset(child, parent)
356}
357
358fn derive_term_id(
364 parent: &AuthorityTerm,
365 request: &AttenuationRequest,
366 bounds: &AuthorityBounds,
367) -> NonEmptyString {
368 let fingerprint = serde_json::json!({
369 "parent_term_id": parent.term_id.as_str(),
370 "principal_ref": request.principal_ref,
371 "resource_ref": request.resource_ref,
372 "resource_family": request.resource_family,
373 "verbs": request.verbs,
374 "capabilities": request.capabilities,
375 "bounds": bounds,
376 "expires_at": request.expires_at.as_ref().map(IsoDateTime::as_str),
377 });
378 let digest = sha256_hex(fingerprint.to_string().as_bytes());
379 NonEmptyString::from(format!("mint-{digest}"))
380}
381
382pub fn ensure_subset_proof(
396 proof: Option<&AuthoritySubsetProof>,
397 child: &AuthorityTerm,
398 parent: &AuthorityTerm,
399) -> Result<(), SubsetProofError> {
400 let Some(proof) = proof else {
401 return Err(SubsetProofError::Missing);
402 };
403 if proof.comparison_algorithm.trim().is_empty() || proof.checked_at.trim().is_empty() {
404 return Err(SubsetProofError::Invalid);
405 }
406 if proof.parent_authority_ref != parent.resource_ref {
407 return Err(SubsetProofError::Invalid);
408 }
409 if !matches!(proof.result, AuthoritySubsetResult::Subset) {
410 return Err(SubsetProofError::Invalid);
411 }
412 let compared_terms_match = proof.compared_terms.iter().any(|comparison| {
413 comparison.child_term_id == child.term_id
414 && comparison.parent_term_id == parent.term_id
415 && matches!(
416 comparison.relation,
417 AuthoritySubsetRelation::Subset | AuthoritySubsetRelation::Equal
418 )
419 });
420 if !compared_terms_match {
421 return Err(SubsetProofError::Invalid);
422 }
423 Ok(())
424}
425
426#[derive(Clone, Debug, Error, PartialEq, Eq)]
428pub enum SubsetProofError {
429 #[error("authority attenuation requires a subset proof")]
431 Missing,
432 #[error("authority attenuation subset proof is invalid")]
434 Invalid,
435}
436
437#[cfg(test)]
438mod tests {
439 use super::{
440 AttenuationError, AttenuationRequest, FamilySubsetComparator, ScopeBoundsComparator,
441 SubsetProofError, ensure_subset_proof, is_authority_subset, mint_attenuated,
442 };
443 use runx_contracts::schema::IsoDateTime;
444 use runx_contracts::{
445 AuthorityApproval, AuthorityBounds, AuthorityCapability, AuthorityCondition,
446 AuthorityConditionPredicate, AuthorityEffectGuard, AuthorityEffectLimit,
447 AuthorityResourceFamily, AuthoritySubsetRelation, AuthorityTerm, AuthorityVerb, JsonNumber,
448 Reference, ReferenceType,
449 };
450
451 const CHECKED_AT: &str = "2026-06-23T00:00:00Z";
452
453 fn reference(reference_type: ReferenceType, uri: &str) -> Reference {
454 Reference::with_uri(reference_type, uri)
455 }
456
457 fn condition() -> AuthorityCondition {
458 AuthorityCondition {
459 condition_id: "condition_approval".into(),
460 predicate: AuthorityConditionPredicate::ApprovalPresent,
461 refs: Vec::new(),
462 parameters: None,
463 }
464 }
465
466 fn approval() -> AuthorityApproval {
467 AuthorityApproval {
468 approval_ref: reference(ReferenceType::Decision, "runx:decision:approval"),
469 approved_by_ref: None,
470 approved_at: None,
471 criterion_ids: Vec::new(),
472 }
473 }
474
475 fn parent_charter() -> AuthorityTerm {
478 AuthorityTerm {
479 term_id: "charter".into(),
480 principal_ref: reference(ReferenceType::Principal, "runx:principal:agency"),
481 resource_ref: reference(ReferenceType::Repository, "runx:repository:monorepo"),
482 resource_family: AuthorityResourceFamily::Workspace,
483 verbs: vec![
484 AuthorityVerb::Read,
485 AuthorityVerb::Write,
486 AuthorityVerb::Review,
487 ],
488 bounds: AuthorityBounds {
489 repo_path_globs: vec!["apps/**".into(), "packages/**".into()],
490 network_destinations: vec!["api.internal".into(), "cdn.internal".into()],
491 max_fanout: Some(8),
492 max_child_depth: Some(3),
493 ..AuthorityBounds::default()
494 },
495 conditions: vec![condition()],
496 approvals: vec![approval()],
497 capabilities: vec![
498 AuthorityCapability::FilesystemRead,
499 AuthorityCapability::NetworkEgress,
500 ],
501 expires_at: Some("2026-12-31T00:00:00Z".into()),
502 issued_by_ref: reference(ReferenceType::Principal, "runx:principal:operator"),
503 credential_ref: None,
504 }
505 }
506
507 fn member_request() -> AttenuationRequest {
509 AttenuationRequest {
510 principal_ref: reference(ReferenceType::Principal, "runx:principal:member"),
511 resource_ref: reference(ReferenceType::Repository, "runx:repository:monorepo"),
512 resource_family: AuthorityResourceFamily::Workspace,
513 verbs: vec![AuthorityVerb::Read],
514 capabilities: vec![AuthorityCapability::FilesystemRead],
515 bounds: AuthorityBounds {
516 repo_path_globs: vec!["apps/**".into()],
517 network_destinations: vec!["api.internal".into()],
518 max_fanout: Some(2),
519 max_child_depth: Some(1),
520 ..AuthorityBounds::default()
521 },
522 expires_at: Some("2026-06-30T00:00:00Z".into()),
523 }
524 }
525
526 fn checked_at() -> IsoDateTime {
527 CHECKED_AT.into()
528 }
529
530 #[test]
531 fn valid_narrowing_yields_child_and_proof_the_validator_accepts() -> Result<(), AttenuationError>
532 {
533 let parent = parent_charter();
534 let (child, proof) = mint_attenuated(
535 &parent,
536 &member_request(),
537 &ScopeBoundsComparator,
538 checked_at(),
539 )?;
540
541 assert_eq!(child.verbs, vec![AuthorityVerb::Read]);
544 assert_eq!(
545 child.capabilities,
546 vec![AuthorityCapability::FilesystemRead]
547 );
548 assert_eq!(child.conditions, parent.conditions);
549 assert_eq!(child.approvals, parent.approvals);
550 assert_eq!(child.resource_family, parent.resource_family);
551
552 assert_eq!(proof.parent_authority_ref, parent.resource_ref);
554 assert_eq!(proof.comparison_algorithm, ScopeBoundsComparator::ALGORITHM);
555 assert_eq!(proof.compared_terms.len(), 1);
556 assert_eq!(proof.compared_terms[0].child_term_id, child.term_id);
557 assert_eq!(proof.compared_terms[0].parent_term_id, parent.term_id);
558 assert_eq!(
559 proof.compared_terms[0].relation,
560 AuthoritySubsetRelation::Subset
561 );
562 assert_eq!(ensure_subset_proof(Some(&proof), &child, &parent), Ok(()));
563 assert!(is_authority_subset(&child, &parent, &ScopeBoundsComparator));
564 Ok(())
565 }
566
567 #[test]
568 fn term_id_is_deterministic() -> Result<(), AttenuationError> {
569 let parent = parent_charter();
570 let first = mint_attenuated(
571 &parent,
572 &member_request(),
573 &ScopeBoundsComparator,
574 checked_at(),
575 )?
576 .0;
577 let second = mint_attenuated(
578 &parent,
579 &member_request(),
580 &ScopeBoundsComparator,
581 checked_at(),
582 )?
583 .0;
584
585 assert_eq!(first.term_id, second.term_id);
586 assert!(first.term_id.as_str().starts_with("mint-"));
587 Ok(())
588 }
589
590 #[test]
591 fn widening_a_verb_errors_fail_closed() {
592 let parent = parent_charter();
593 let mut request = member_request();
594 request.verbs = vec![AuthorityVerb::Delete];
595
596 assert_eq!(
597 mint_attenuated(&parent, &request, &ScopeBoundsComparator, checked_at()).map(|_| ()),
598 Err(AttenuationError::VerbsNotSubset)
599 );
600 }
601
602 #[test]
603 fn widening_a_capability_errors_fail_closed() {
604 let parent = parent_charter();
605 let mut request = member_request();
606 request.capabilities = vec![AuthorityCapability::SecretRead];
607
608 assert_eq!(
609 mint_attenuated(&parent, &request, &ScopeBoundsComparator, checked_at()).map(|_| ()),
610 Err(AttenuationError::CapabilitiesNotSubset)
611 );
612 }
613
614 #[test]
615 fn widening_a_string_bound_errors_fail_closed() {
616 let parent = parent_charter();
617 let mut request = member_request();
618 request.bounds.repo_path_globs = vec!["secrets/**".into()];
620
621 assert_eq!(
622 mint_attenuated(&parent, &request, &ScopeBoundsComparator, checked_at()).map(|_| ()),
623 Err(AttenuationError::ChildNotSubset)
624 );
625 }
626
627 #[test]
628 fn widening_a_numeric_bound_errors_fail_closed() {
629 let parent = parent_charter();
630 let mut request = member_request();
631 request.bounds.max_fanout = Some(99);
632
633 assert_eq!(
634 mint_attenuated(&parent, &request, &ScopeBoundsComparator, checked_at()).map(|_| ()),
635 Err(AttenuationError::ChildNotSubset)
636 );
637 }
638
639 #[test]
640 fn widening_max_cost_units_errors_fail_closed() {
641 let mut parent = parent_charter();
642 parent.bounds.max_cost_units = Some(JsonNumber::U64(100));
643 let mut request = member_request();
644 request.bounds.max_cost_units = Some(JsonNumber::U64(500));
645
646 assert_eq!(
647 mint_attenuated(&parent, &request, &ScopeBoundsComparator, checked_at()).map(|_| ()),
648 Err(AttenuationError::ChildNotSubset)
649 );
650 }
651
652 #[test]
653 fn dropping_max_cost_units_when_parent_caps_it_errors_fail_closed() {
654 let mut parent = parent_charter();
655 parent.bounds.max_cost_units = Some(JsonNumber::U64(100));
656 let mut request = member_request();
657 request.bounds.max_cost_units = None;
658
659 assert_eq!(
660 mint_attenuated(&parent, &request, &ScopeBoundsComparator, checked_at()).map(|_| ()),
661 Err(AttenuationError::ChildNotSubset)
662 );
663 }
664
665 #[test]
666 fn narrowing_max_cost_units_succeeds() -> Result<(), AttenuationError> {
667 let mut parent = parent_charter();
668 parent.bounds.max_cost_units = Some(JsonNumber::U64(500));
669 let mut request = member_request();
670 request.bounds.max_cost_units = Some(JsonNumber::U64(100));
671
672 let (child, _) = mint_attenuated(&parent, &request, &ScopeBoundsComparator, checked_at())?;
673 assert!(is_authority_subset(&child, &parent, &ScopeBoundsComparator));
674 Ok(())
675 }
676
677 #[test]
678 fn effect_bearing_child_under_scope_comparator_errors_fail_closed() {
679 let parent = parent_charter();
683
684 let mut limit_request = member_request();
685 limit_request.bounds.effect_limits = vec![AuthorityEffectLimit {
686 family: "deployment".into(),
687 unit: "USD".into(),
688 max_per_call_units: Some(1_000),
689 max_per_run_units: Some(5_000),
690 max_per_period_units: None,
691 period: None,
692 channels: vec!["stripe".into()],
693 realm: None,
694 peer: None,
695 operation: None,
696 preflight_ttl_ms: None,
697 approval_threshold_units: None,
698 authorization_form: None,
699 preflight_required: false,
700 commitment_required: false,
701 idempotency_required: false,
702 recovery_required: false,
703 receipt_before_success: false,
704 single_use_capability: false,
705 }];
706 assert_eq!(
707 mint_attenuated(
708 &parent,
709 &limit_request,
710 &ScopeBoundsComparator,
711 checked_at()
712 )
713 .map(|_| ()),
714 Err(AttenuationError::ChildNotSubset)
715 );
716
717 let mut guard_request = member_request();
718 guard_request.bounds.effects = vec![AuthorityEffectGuard {
719 family: "deployment".into(),
720 guard_kinds: Vec::new(),
721 proof_kinds: Vec::new(),
722 }];
723 assert_eq!(
724 mint_attenuated(
725 &parent,
726 &guard_request,
727 &ScopeBoundsComparator,
728 checked_at()
729 )
730 .map(|_| ()),
731 Err(AttenuationError::ChildNotSubset)
732 );
733 }
734
735 #[test]
736 fn widening_expiry_errors_fail_closed() {
737 let parent = parent_charter();
738 let mut request = member_request();
739 request.expires_at = Some("2027-01-01T00:00:00Z".into());
740
741 assert_eq!(
742 mint_attenuated(&parent, &request, &ScopeBoundsComparator, checked_at()).map(|_| ()),
743 Err(AttenuationError::ExpiryNotSubset)
744 );
745 }
746
747 #[test]
748 fn dropping_expiry_when_parent_bounds_it_errors_fail_closed() {
749 let parent = parent_charter();
750 let mut request = member_request();
751 request.expires_at = None;
752
753 assert_eq!(
754 mint_attenuated(&parent, &request, &ScopeBoundsComparator, checked_at()).map(|_| ()),
755 Err(AttenuationError::ExpiryNotSubset)
756 );
757 }
758
759 #[test]
760 fn mismatched_resource_family_errors_fail_closed() {
761 let parent = parent_charter();
762 let mut request = member_request();
763 request.resource_family = AuthorityResourceFamily::Effect;
764
765 assert_eq!(
766 mint_attenuated(&parent, &request, &ScopeBoundsComparator, checked_at()).map(|_| ()),
767 Err(AttenuationError::ResourceFamilyMismatch)
768 );
769 }
770
771 #[test]
772 fn mismatched_resource_address_errors_fail_closed() {
773 let parent = parent_charter();
774 let mut request = member_request();
775 request.resource_ref = reference(ReferenceType::Repository, "runx:repository:other");
776
777 assert_eq!(
778 mint_attenuated(&parent, &request, &ScopeBoundsComparator, checked_at()).map(|_| ()),
779 Err(AttenuationError::ResourceAddressMismatch)
780 );
781 }
782
783 #[test]
784 fn conditions_and_approvals_cannot_be_dropped() -> Result<(), AttenuationError> {
785 let parent = parent_charter();
788 let (mut child, _) = mint_attenuated(
789 &parent,
790 &member_request(),
791 &ScopeBoundsComparator,
792 checked_at(),
793 )?;
794
795 assert_eq!(child.conditions, parent.conditions);
796 assert_eq!(child.approvals, parent.approvals);
797
798 child.conditions = Vec::new();
801 child.approvals = Vec::new();
802 assert!(!is_authority_subset(
803 &child,
804 &parent,
805 &ScopeBoundsComparator
806 ));
807 Ok(())
808 }
809
810 #[test]
811 fn ensure_subset_proof_rejects_absent_proof() -> Result<(), AttenuationError> {
812 let parent = parent_charter();
813 let (child, _) = mint_attenuated(
814 &parent,
815 &member_request(),
816 &ScopeBoundsComparator,
817 checked_at(),
818 )?;
819
820 assert_eq!(
821 ensure_subset_proof(None, &child, &parent),
822 Err(SubsetProofError::Missing)
823 );
824 Ok(())
825 }
826
827 #[test]
828 fn ensure_subset_proof_rejects_mismatched_terms() -> Result<(), AttenuationError> {
829 let parent = parent_charter();
830 let (child, mut proof) = mint_attenuated(
831 &parent,
832 &member_request(),
833 &ScopeBoundsComparator,
834 checked_at(),
835 )?;
836 proof.compared_terms[0].child_term_id = "other".into();
837
838 assert_eq!(
839 ensure_subset_proof(Some(&proof), &child, &parent),
840 Err(SubsetProofError::Invalid)
841 );
842 Ok(())
843 }
844
845 struct ExactAudienceComparator;
850
851 impl FamilySubsetComparator for ExactAudienceComparator {
852 fn comparison_algorithm(&self) -> &str {
853 "test.exact-audience.v1"
854 }
855
856 fn bounds_subset(&self, child: &AuthorityTerm, parent: &AuthorityTerm) -> bool {
857 child.bounds.token_audiences == parent.bounds.token_audiences
858 }
859 }
860
861 #[test]
862 fn stub_family_comparator_drives_the_bounds_decision() -> Result<(), AttenuationError> {
863 let mut parent = parent_charter();
864 parent.bounds.token_audiences = vec!["aud:a".into()];
865 let mut request = member_request();
866 request.bounds.token_audiences = vec!["aud:a".into()];
867
868 let (child, proof) =
869 mint_attenuated(&parent, &request, &ExactAudienceComparator, checked_at())?;
870 assert_eq!(proof.comparison_algorithm, "test.exact-audience.v1");
871 assert!(is_authority_subset(
872 &child,
873 &parent,
874 &ExactAudienceComparator
875 ));
876
877 let mut widened = member_request();
879 widened.bounds.token_audiences = vec!["aud:b".into()];
880 assert_eq!(
881 mint_attenuated(&parent, &widened, &ExactAudienceComparator, checked_at()).map(|_| ()),
882 Err(AttenuationError::ChildNotSubset)
883 );
884 Ok(())
885 }
886}