1use std::collections::BTreeSet;
4use std::fmt;
5
6use serde::{Deserialize, Serialize};
7
8use crate::value::{DictMap, VmValue};
9
10const SUB: &str = "sub";
11const ACT: &str = "act";
12const MAY_ACT: &str = "may_act";
13const SCOPES: &str = "scopes";
14const SCOPE: &str = "scope";
15
16#[derive(Clone, Debug, PartialEq, Eq, Hash)]
25pub struct ActorChain {
26 origin: ActorChainEntry,
27 actors: Vec<ActorChainEntry>,
28 may_act: Option<String>,
29}
30
31pub type Principal = ActorChain;
33
34#[derive(Clone, Debug, PartialEq, Eq, Hash)]
37pub struct ActorChainEntry {
38 subject: String,
39 scopes: BTreeSet<String>,
40}
41
42impl ActorChainEntry {
43 pub fn new(subject: impl Into<String>) -> Self {
44 Self {
45 subject: subject.into(),
46 scopes: BTreeSet::new(),
47 }
48 }
49
50 pub fn with_scopes<I, S>(subject: impl Into<String>, scopes: I) -> Self
51 where
52 I: IntoIterator<Item = S>,
53 S: Into<String>,
54 {
55 Self {
56 subject: subject.into(),
57 scopes: normalize_scopes(scopes),
58 }
59 }
60
61 pub fn subject(&self) -> &str {
62 &self.subject
63 }
64
65 pub fn scopes(&self) -> impl Iterator<Item = &str> {
66 self.scopes.iter().map(String::as_str)
67 }
68
69 pub fn scope_set(&self) -> &BTreeSet<String> {
70 &self.scopes
71 }
72
73 pub fn to_json_value(&self) -> serde_json::Value {
74 let mut node = serde_json::Map::new();
75 node.insert(
76 SUB.to_string(),
77 serde_json::Value::String(self.subject.clone()),
78 );
79 if !self.scopes.is_empty() {
80 node.insert(
81 SCOPES.to_string(),
82 serde_json::Value::Array(
83 self.scopes
84 .iter()
85 .cloned()
86 .map(serde_json::Value::String)
87 .collect(),
88 ),
89 );
90 }
91 serde_json::Value::Object(node)
92 }
93}
94
95#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "kebab-case")]
97pub enum ScopeAttenuationMode {
98 Off,
99 #[default]
100 NonIncreasing,
101 StrictSubset,
102}
103
104impl ScopeAttenuationMode {
105 pub fn as_str(self) -> &'static str {
106 match self {
107 Self::Off => "off",
108 Self::NonIncreasing => "non-increasing",
109 Self::StrictSubset => "strict-subset",
110 }
111 }
112}
113
114#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(default, deny_unknown_fields)]
116pub struct ScopeAttenuationPolicy {
117 pub mode: ScopeAttenuationMode,
118 pub alert_on_violation: bool,
119}
120
121impl Default for ScopeAttenuationPolicy {
122 fn default() -> Self {
123 Self {
124 mode: ScopeAttenuationMode::NonIncreasing,
125 alert_on_violation: true,
126 }
127 }
128}
129
130#[derive(Clone, Debug, PartialEq, Eq)]
131pub struct ScopeAttenuationViolation {
132 parent_subject: String,
133 child_subject: String,
134 parent_scopes: Vec<String>,
135 child_scopes: Vec<String>,
136 extra_scopes: Vec<String>,
137 mode: ScopeAttenuationMode,
138}
139
140impl ScopeAttenuationViolation {
141 fn new(parent: &ActorChainEntry, child: &ActorChainEntry, mode: ScopeAttenuationMode) -> Self {
142 let extra_scopes = child
143 .scopes
144 .difference(&parent.scopes)
145 .cloned()
146 .collect::<Vec<_>>();
147 Self {
148 parent_subject: parent.subject.clone(),
149 child_subject: child.subject.clone(),
150 parent_scopes: parent.scopes.iter().cloned().collect(),
151 child_scopes: child.scopes.iter().cloned().collect(),
152 extra_scopes,
153 mode,
154 }
155 }
156
157 pub fn parent_subject(&self) -> &str {
158 &self.parent_subject
159 }
160
161 pub fn child_subject(&self) -> &str {
162 &self.child_subject
163 }
164
165 pub fn parent_scopes(&self) -> &[String] {
166 &self.parent_scopes
167 }
168
169 pub fn child_scopes(&self) -> &[String] {
170 &self.child_scopes
171 }
172
173 pub fn extra_scopes(&self) -> &[String] {
174 &self.extra_scopes
175 }
176
177 pub fn mode(&self) -> ScopeAttenuationMode {
178 self.mode
179 }
180
181 pub fn to_json_value(&self) -> serde_json::Value {
182 serde_json::json!({
183 "kind": "scope_attenuation_violation",
184 "mode": self.mode.as_str(),
185 "parent_subject": self.parent_subject,
186 "child_subject": self.child_subject,
187 "parent_scopes": self.parent_scopes,
188 "child_scopes": self.child_scopes,
189 "extra_scopes": self.extra_scopes,
190 })
191 }
192}
193
194impl fmt::Display for ScopeAttenuationViolation {
195 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196 if self.extra_scopes.is_empty() {
197 write!(
198 f,
199 "scope attenuation violation: child `{}` must hold a strict subset of parent `{}` scopes",
200 self.child_subject, self.parent_subject
201 )
202 } else {
203 write!(
204 f,
205 "scope attenuation violation: child `{}` has scopes not held by parent `{}`: {}",
206 self.child_subject,
207 self.parent_subject,
208 self.extra_scopes.join(", ")
209 )
210 }
211 }
212}
213
214impl std::error::Error for ScopeAttenuationViolation {}
215
216impl ActorChain {
217 pub fn new(origin: impl Into<String>) -> Self {
219 Self {
220 origin: ActorChainEntry::new(origin),
221 actors: Vec::new(),
222 may_act: None,
223 }
224 }
225
226 pub fn new_with_scopes<I, S>(origin: impl Into<String>, scopes: I) -> Self
227 where
228 I: IntoIterator<Item = S>,
229 S: Into<String>,
230 {
231 Self {
232 origin: ActorChainEntry::with_scopes(origin, scopes),
233 actors: Vec::new(),
234 may_act: None,
235 }
236 }
237
238 pub fn from_parts<I, S>(origin: impl Into<String>, actors: I) -> Self
241 where
242 I: IntoIterator<Item = S>,
243 S: Into<String>,
244 {
245 Self {
246 origin: ActorChainEntry::new(origin),
247 actors: actors.into_iter().map(ActorChainEntry::new).collect(),
248 may_act: None,
249 }
250 }
251
252 pub fn from_entries<I>(origin: ActorChainEntry, actors: I) -> Self
253 where
254 I: IntoIterator<Item = ActorChainEntry>,
255 {
256 Self {
257 origin,
258 actors: actors.into_iter().collect(),
259 may_act: None,
260 }
261 }
262
263 pub fn push(&mut self, actor: impl Into<String>) {
265 self.push_entry(ActorChainEntry::new(actor));
266 }
267
268 pub fn push_entry(&mut self, actor: ActorChainEntry) {
269 self.actors.insert(0, actor);
270 }
271
272 pub fn pushed(mut self, actor: impl Into<String>) -> Self {
274 self.push(actor);
275 self
276 }
277
278 pub fn pushed_with_scopes<I, S>(mut self, actor: impl Into<String>, scopes: I) -> Self
279 where
280 I: IntoIterator<Item = S>,
281 S: Into<String>,
282 {
283 self.push_entry(ActorChainEntry::with_scopes(actor, scopes));
284 self
285 }
286
287 pub fn pushed_entry(mut self, actor: ActorChainEntry) -> Self {
288 self.push_entry(actor);
289 self
290 }
291
292 pub fn current(&self) -> &str {
297 self.actors
298 .first()
299 .map(ActorChainEntry::subject)
300 .unwrap_or(self.origin.subject())
301 }
302
303 pub fn origin(&self) -> &str {
305 self.origin.subject()
306 }
307
308 pub fn origin_entry(&self) -> &ActorChainEntry {
309 &self.origin
310 }
311
312 pub fn current_entry(&self) -> &ActorChainEntry {
313 self.actors.first().unwrap_or(&self.origin)
314 }
315
316 pub fn actors(&self) -> impl Iterator<Item = &str> {
318 self.actors.iter().map(ActorChainEntry::subject)
319 }
320
321 pub fn actor_entries(&self) -> impl Iterator<Item = &ActorChainEntry> {
322 self.actors.iter()
323 }
324
325 pub fn iter(&self) -> ActorChainIter<'_> {
327 ActorChainIter {
328 inner: self.entries(),
329 }
330 }
331
332 pub fn entries(&self) -> ActorChainEntryIter<'_> {
333 ActorChainEntryIter {
334 actors: self.actors.iter(),
335 origin: Some(&self.origin),
336 }
337 }
338
339 pub fn parent_chain(&self) -> Option<Self> {
340 if self.actors.is_empty() {
341 return None;
342 }
343 Some(Self {
344 origin: self.origin.clone(),
345 actors: self.actors.iter().skip(1).cloned().collect(),
346 may_act: self.may_act.clone(),
347 })
348 }
349
350 pub fn is_delegated(&self) -> bool {
351 !self.actors.is_empty()
352 }
353
354 pub fn may_act(&self) -> Option<&str> {
356 self.may_act.as_deref()
357 }
358
359 pub fn set_may_act(&mut self, may_act: impl Into<String>) {
360 self.may_act = Some(may_act.into());
361 }
362
363 pub fn with_may_act(mut self, may_act: impl Into<String>) -> Self {
364 self.set_may_act(may_act);
365 self
366 }
367
368 pub fn clear_may_act(&mut self) {
369 self.may_act = None;
370 }
371
372 pub fn validate_scope_attenuation(
373 &self,
374 policy: &ScopeAttenuationPolicy,
375 ) -> Result<(), ScopeAttenuationViolation> {
376 match policy.mode {
377 ScopeAttenuationMode::Off => return Ok(()),
378 ScopeAttenuationMode::NonIncreasing | ScopeAttenuationMode::StrictSubset => {}
379 }
380
381 let root_to_current = self
382 .entries()
383 .collect::<Vec<_>>()
384 .into_iter()
385 .rev()
386 .collect::<Vec<_>>();
387 for pair in root_to_current.windows(2) {
388 let parent = pair[0];
389 let child = pair[1];
390 let subset = child.scopes.is_subset(&parent.scopes);
391 let strict = child.scopes.len() < parent.scopes.len();
392 let valid = match policy.mode {
393 ScopeAttenuationMode::Off => true,
394 ScopeAttenuationMode::NonIncreasing => subset,
395 ScopeAttenuationMode::StrictSubset => subset && strict,
396 };
397 if !valid {
398 return Err(ScopeAttenuationViolation::new(parent, child, policy.mode));
399 }
400 }
401 Ok(())
402 }
403
404 pub fn to_json_value(&self) -> serde_json::Value {
405 let mut root = principal_json_object(&self.origin);
406 if let Some(act) = actor_nodes_to_json(&self.actors) {
407 root.insert(ACT.to_string(), act);
408 }
409 if let Some(may_act) = &self.may_act {
410 root.insert(MAY_ACT.to_string(), principal_claim_json(may_act));
411 }
412 serde_json::Value::Object(root)
413 }
414
415 pub fn from_json_value(value: &serde_json::Value) -> Result<Self, ActorChainError> {
416 let root = expect_json_object(value, "$")?;
417 let origin = json_entry(root, "$")?;
418 let actors = json_actor_subjects(root.get(ACT), "$.act")?;
419 let may_act = root
420 .get(MAY_ACT)
421 .map(|value| {
422 let may_act = expect_json_object(value, "$.may_act")?;
423 json_sub(may_act, "$.may_act")
424 })
425 .transpose()?;
426 Ok(Self {
427 origin,
428 actors,
429 may_act,
430 })
431 }
432
433 pub fn to_vm_value(&self) -> VmValue {
434 crate::schema::json_to_vm_value(&self.to_json_value())
435 }
436
437 pub fn from_vm_value(value: &VmValue) -> Result<Self, ActorChainError> {
438 let root = expect_vm_dict(value, "$")?;
439 let origin = vm_entry(root, "$")?;
440 let actors = vm_actor_subjects(root.get(ACT), "$.act")?;
441 let may_act = root
442 .get(MAY_ACT)
443 .map(|value| {
444 let may_act = expect_vm_dict(value, "$.may_act")?;
445 vm_sub(may_act, "$.may_act")
446 })
447 .transpose()?;
448 Ok(Self {
449 origin,
450 actors,
451 may_act,
452 })
453 }
454}
455
456impl Serialize for ActorChain {
457 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
458 where
459 S: serde::Serializer,
460 {
461 self.to_json_value().serialize(serializer)
462 }
463}
464
465impl<'de> Deserialize<'de> for ActorChain {
466 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
467 where
468 D: serde::Deserializer<'de>,
469 {
470 let value = serde_json::Value::deserialize(deserializer)?;
471 Self::from_json_value(&value).map_err(serde::de::Error::custom)
472 }
473}
474
475impl TryFrom<&serde_json::Value> for ActorChain {
476 type Error = ActorChainError;
477
478 fn try_from(value: &serde_json::Value) -> Result<Self, Self::Error> {
479 Self::from_json_value(value)
480 }
481}
482
483impl From<&ActorChain> for VmValue {
484 fn from(chain: &ActorChain) -> Self {
485 chain.to_vm_value()
486 }
487}
488
489impl From<ActorChain> for VmValue {
490 fn from(chain: ActorChain) -> Self {
491 chain.to_vm_value()
492 }
493}
494
495impl TryFrom<&VmValue> for ActorChain {
496 type Error = ActorChainError;
497
498 fn try_from(value: &VmValue) -> Result<Self, Self::Error> {
499 Self::from_vm_value(value)
500 }
501}
502
503pub struct ActorChainIter<'a> {
504 inner: ActorChainEntryIter<'a>,
505}
506
507impl<'a> Iterator for ActorChainIter<'a> {
508 type Item = &'a str;
509
510 fn next(&mut self) -> Option<Self::Item> {
511 self.inner.next().map(ActorChainEntry::subject)
512 }
513}
514
515impl<'a> IntoIterator for &'a ActorChain {
516 type Item = &'a str;
517 type IntoIter = ActorChainIter<'a>;
518
519 fn into_iter(self) -> Self::IntoIter {
520 self.iter()
521 }
522}
523
524pub struct ActorChainEntryIter<'a> {
525 actors: std::slice::Iter<'a, ActorChainEntry>,
526 origin: Option<&'a ActorChainEntry>,
527}
528
529impl<'a> Iterator for ActorChainEntryIter<'a> {
530 type Item = &'a ActorChainEntry;
531
532 fn next(&mut self) -> Option<Self::Item> {
533 self.actors.next().or_else(|| self.origin.take())
534 }
535}
536
537#[derive(Clone, Debug, PartialEq, Eq)]
538pub struct ActorChainError {
539 message: String,
540}
541
542impl ActorChainError {
543 fn new(message: impl Into<String>) -> Self {
544 Self {
545 message: message.into(),
546 }
547 }
548
549 pub fn message(&self) -> &str {
550 &self.message
551 }
552}
553
554impl fmt::Display for ActorChainError {
555 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
556 self.message.fmt(f)
557 }
558}
559
560impl std::error::Error for ActorChainError {}
561
562fn actor_nodes_to_json(actors: &[ActorChainEntry]) -> Option<serde_json::Value> {
563 let mut next = None;
564 for actor in actors.iter().rev() {
565 let mut node = principal_json_object(actor);
566 if let Some(act) = next {
567 node.insert(ACT.to_string(), act);
568 }
569 next = Some(serde_json::Value::Object(node));
570 }
571 next
572}
573
574fn principal_json_object(entry: &ActorChainEntry) -> serde_json::Map<String, serde_json::Value> {
575 let mut claim = serde_json::Map::new();
576 claim.insert(
577 SUB.to_string(),
578 serde_json::Value::String(entry.subject.clone()),
579 );
580 if !entry.scopes.is_empty() {
581 claim.insert(
582 SCOPES.to_string(),
583 serde_json::Value::Array(
584 entry
585 .scopes
586 .iter()
587 .cloned()
588 .map(serde_json::Value::String)
589 .collect(),
590 ),
591 );
592 }
593 claim
594}
595
596fn principal_claim_json(subject: &str) -> serde_json::Value {
597 let mut claim = serde_json::Map::new();
598 claim.insert(
599 SUB.to_string(),
600 serde_json::Value::String(subject.to_string()),
601 );
602 serde_json::Value::Object(claim)
603}
604
605fn expect_json_object<'a>(
606 value: &'a serde_json::Value,
607 path: &str,
608) -> Result<&'a serde_json::Map<String, serde_json::Value>, ActorChainError> {
609 value
610 .as_object()
611 .ok_or_else(|| ActorChainError::new(format!("{path} must be an object")))
612}
613
614fn json_sub(
615 object: &serde_json::Map<String, serde_json::Value>,
616 path: &str,
617) -> Result<String, ActorChainError> {
618 object
619 .get(SUB)
620 .and_then(serde_json::Value::as_str)
621 .map(ToString::to_string)
622 .ok_or_else(|| ActorChainError::new(format!("{path}.sub must be a string")))
623}
624
625fn json_entry(
626 object: &serde_json::Map<String, serde_json::Value>,
627 path: &str,
628) -> Result<ActorChainEntry, ActorChainError> {
629 Ok(ActorChainEntry {
630 subject: json_sub(object, path)?,
631 scopes: json_scopes(object, path)?,
632 })
633}
634
635fn json_scopes(
636 object: &serde_json::Map<String, serde_json::Value>,
637 path: &str,
638) -> Result<BTreeSet<String>, ActorChainError> {
639 if let Some(scopes) = object.get(SCOPES) {
640 let items = scopes
641 .as_array()
642 .ok_or_else(|| ActorChainError::new(format!("{path}.scopes must be a list")))?;
643 return items
644 .iter()
645 .enumerate()
646 .map(|(index, item)| {
647 item.as_str()
648 .ok_or_else(|| {
649 ActorChainError::new(format!("{path}.scopes[{index}] must be a string"))
650 })
651 .map(str::to_string)
652 })
653 .collect::<Result<Vec<_>, _>>()
654 .map(normalize_scopes);
655 }
656 if let Some(scope) = object.get(SCOPE) {
657 let raw = scope
658 .as_str()
659 .ok_or_else(|| ActorChainError::new(format!("{path}.scope must be a string")))?;
660 return Ok(normalize_scopes(raw.split_whitespace()));
661 }
662 Ok(BTreeSet::new())
663}
664
665fn json_actor_subjects(
666 first: Option<&serde_json::Value>,
667 first_path: &str,
668) -> Result<Vec<ActorChainEntry>, ActorChainError> {
669 let mut actors = Vec::new();
670 let mut current = first;
671 let mut path = first_path.to_string();
672 while let Some(value) = current {
673 let object = expect_json_object(value, &path)?;
674 actors.push(json_entry(object, &path)?);
675 current = object.get(ACT);
676 path.push_str(".act");
677 }
678 Ok(actors)
679}
680
681fn expect_vm_dict<'a>(value: &'a VmValue, path: &str) -> Result<&'a DictMap, ActorChainError> {
682 value
683 .as_dict()
684 .ok_or_else(|| ActorChainError::new(format!("{path} must be a dict")))
685}
686
687fn vm_sub(object: &DictMap, path: &str) -> Result<String, ActorChainError> {
688 match object.get(SUB) {
689 Some(VmValue::String(subject)) => Ok(subject.to_string()),
690 _ => Err(ActorChainError::new(format!("{path}.sub must be a string"))),
691 }
692}
693
694fn vm_entry(object: &DictMap, path: &str) -> Result<ActorChainEntry, ActorChainError> {
695 Ok(ActorChainEntry {
696 subject: vm_sub(object, path)?,
697 scopes: vm_scopes(object, path)?,
698 })
699}
700
701fn vm_scopes(object: &DictMap, path: &str) -> Result<BTreeSet<String>, ActorChainError> {
702 if let Some(scopes) = object.get(SCOPES) {
703 let VmValue::List(items) = scopes else {
704 return Err(ActorChainError::new(format!(
705 "{path}.scopes must be a list"
706 )));
707 };
708 return items
709 .iter()
710 .enumerate()
711 .map(|(index, item)| match item {
712 VmValue::String(scope) => Ok(scope.to_string()),
713 _ => Err(ActorChainError::new(format!(
714 "{path}.scopes[{index}] must be a string"
715 ))),
716 })
717 .collect::<Result<Vec<_>, _>>()
718 .map(normalize_scopes);
719 }
720 if let Some(scope) = object.get(SCOPE) {
721 let VmValue::String(raw) = scope else {
722 return Err(ActorChainError::new(format!(
723 "{path}.scope must be a string"
724 )));
725 };
726 return Ok(normalize_scopes(raw.split_whitespace()));
727 }
728 Ok(BTreeSet::new())
729}
730
731fn vm_actor_subjects(
732 first: Option<&VmValue>,
733 first_path: &str,
734) -> Result<Vec<ActorChainEntry>, ActorChainError> {
735 let mut actors = Vec::new();
736 let mut current = first;
737 let mut path = first_path.to_string();
738 while let Some(value) = current {
739 let object = expect_vm_dict(value, &path)?;
740 actors.push(vm_entry(object, &path)?);
741 current = object.get(ACT);
742 path.push_str(".act");
743 }
744 Ok(actors)
745}
746
747fn normalize_scopes<I, S>(scopes: I) -> BTreeSet<String>
748where
749 I: IntoIterator<Item = S>,
750 S: Into<String>,
751{
752 scopes
753 .into_iter()
754 .map(Into::into)
755 .map(|scope| scope.trim().to_string())
756 .filter(|scope| !scope.is_empty())
757 .collect()
758}
759
760#[cfg(test)]
761mod tests {
762 use super::*;
763 use proptest::prelude::*;
764 use serde_json::json;
765
766 fn principal() -> impl Strategy<Value = String> {
767 proptest::string::string_regex("[a-z][a-z0-9_]{0,24}").unwrap()
768 }
769
770 fn actor_subjects_from_json(value: &serde_json::Value) -> Vec<String> {
771 let mut subjects = Vec::new();
772 let mut current = value.get(ACT);
773 while let Some(node) = current {
774 subjects.push(node[SUB].as_str().expect("act.sub").to_string());
775 current = node.get(ACT);
776 }
777 subjects
778 }
779
780 #[test]
781 fn push_makes_new_actor_current_and_serializes_rfc_shape() {
782 let chain = ActorChain::new("user")
783 .pushed("service77")
784 .pushed("service16");
785
786 assert_eq!(chain.origin(), "user");
787 assert_eq!(chain.current(), "service16");
788 assert_eq!(
789 chain.iter().collect::<Vec<_>>(),
790 vec!["service16", "service77", "user"]
791 );
792 assert_eq!(
793 serde_json::to_value(&chain).unwrap(),
794 json!({
795 "sub": "user",
796 "act": {
797 "sub": "service16",
798 "act": {
799 "sub": "service77"
800 }
801 }
802 })
803 );
804 }
805
806 #[test]
807 fn may_act_serializes_as_authorized_actor_claim() {
808 let chain = ActorChain::new("user").with_may_act("admin");
809
810 assert_eq!(
811 serde_json::to_value(&chain).unwrap(),
812 json!({
813 "sub": "user",
814 "may_act": {
815 "sub": "admin"
816 }
817 })
818 );
819 }
820
821 #[test]
822 fn vm_value_conversion_round_trips_plain_harn_dicts() {
823 let value = crate::schema::json_to_vm_value(&json!({
824 "sub": "user",
825 "act": {
826 "sub": "service16",
827 "act": {
828 "sub": "service77"
829 }
830 },
831 "may_act": {
832 "sub": "admin"
833 }
834 }));
835
836 let chain = ActorChain::try_from(&value).unwrap();
837 assert_eq!(chain.origin(), "user");
838 assert_eq!(chain.current(), "service16");
839 assert_eq!(
840 chain.actors().collect::<Vec<_>>(),
841 vec!["service16", "service77"]
842 );
843 assert_eq!(chain.may_act(), Some("admin"));
844
845 let encoded = chain.to_vm_value();
846 assert_eq!(ActorChain::try_from(&encoded).unwrap(), chain);
847 }
848
849 #[test]
850 fn scoped_entries_round_trip_and_accept_rfc_scope_string() {
851 let value = json!({
852 "sub": "user:kenneth",
853 "scope": "repo:read repo:write",
854 "act": {
855 "sub": "agent:burin",
856 "scopes": ["repo:read", "repo:read"],
857 "act": {
858 "sub": "agent:merge-captain",
859 "scope": "repo:read"
860 }
861 }
862 });
863
864 let chain = ActorChain::from_json_value(&value).unwrap();
865 assert_eq!(
866 chain.origin_entry().scopes().collect::<Vec<_>>(),
867 vec!["repo:read", "repo:write"]
868 );
869 assert_eq!(
870 chain.current_entry().scopes().collect::<Vec<_>>(),
871 vec!["repo:read"]
872 );
873
874 assert_eq!(
875 chain.to_json_value(),
876 json!({
877 "sub": "user:kenneth",
878 "scopes": ["repo:read", "repo:write"],
879 "act": {
880 "sub": "agent:burin",
881 "scopes": ["repo:read"],
882 "act": {
883 "sub": "agent:merge-captain",
884 "scopes": ["repo:read"]
885 }
886 }
887 })
888 );
889 }
890
891 #[test]
892 fn default_scope_attenuation_allows_equal_or_narrower_scopes_only() {
893 let policy = ScopeAttenuationPolicy::default();
894 let valid = ActorChain::new_with_scopes("user", ["repo:read", "repo:write"])
895 .pushed_with_scopes("agent:burin", ["repo:read", "repo:write"])
896 .pushed_with_scopes("agent:merge-captain", ["repo:read"]);
897 valid.validate_scope_attenuation(&policy).unwrap();
898
899 let invalid = ActorChain::new_with_scopes("user", ["repo:read"])
900 .pushed_with_scopes("agent:burin", ["repo:read", "repo:write"]);
901 let violation = invalid.validate_scope_attenuation(&policy).unwrap_err();
902 assert_eq!(violation.parent_subject(), "user");
903 assert_eq!(violation.child_subject(), "agent:burin");
904 assert_eq!(violation.extra_scopes(), &["repo:write".to_string()]);
905 }
906
907 #[test]
908 fn strict_scope_attenuation_requires_each_hop_to_shrink() {
909 let policy = ScopeAttenuationPolicy {
910 mode: ScopeAttenuationMode::StrictSubset,
911 ..ScopeAttenuationPolicy::default()
912 };
913 let equal = ActorChain::new_with_scopes("user", ["repo:read"])
914 .pushed_with_scopes("agent:burin", ["repo:read"]);
915 let violation = equal.validate_scope_attenuation(&policy).unwrap_err();
916 assert!(violation.extra_scopes().is_empty());
917
918 let narrower = ActorChain::new_with_scopes("user", ["repo:read", "repo:write"])
919 .pushed_with_scopes("agent:burin", ["repo:read"]);
920 narrower.validate_scope_attenuation(&policy).unwrap();
921 }
922
923 #[test]
924 fn parent_chain_removes_current_actor_but_preserves_origin() {
925 let parent = ActorChain::new("user").pushed("agent:root");
926 let child = parent.clone().pushed("agent:child");
927
928 assert_eq!(child.parent_chain(), Some(parent));
929 assert!(ActorChain::new("user").parent_chain().is_none());
930 }
931
932 proptest! {
933 #[test]
934 fn serde_round_trip_preserves_nesting_order(
935 origin in principal(),
936 actors in proptest::collection::vec(principal(), 0..10),
937 may_act in proptest::option::of(principal()),
938 ) {
939 let mut chain = ActorChain::from_parts(origin.clone(), actors.clone());
940 if let Some(may_act) = may_act.as_deref() {
941 chain.set_may_act(may_act);
942 }
943
944 let encoded = serde_json::to_value(&chain).unwrap();
945 prop_assert_eq!(encoded[SUB].as_str(), Some(origin.as_str()));
946 let encoded_actors = actor_subjects_from_json(&encoded);
947 prop_assert_eq!(encoded_actors.as_slice(), actors.as_slice());
948 prop_assert_eq!(
949 encoded.get(MAY_ACT).and_then(|claim| claim[SUB].as_str()),
950 may_act.as_deref()
951 );
952
953 let decoded: ActorChain = serde_json::from_value(encoded.clone()).unwrap();
954 prop_assert_eq!(&decoded, &chain);
955 prop_assert_eq!(serde_json::to_value(&decoded).unwrap(), encoded);
956 prop_assert_eq!(decoded.origin(), origin.as_str());
957 prop_assert_eq!(
958 decoded.current(),
959 actors.first().map(String::as_str).unwrap_or(origin.as_str())
960 );
961
962 let expected_iter = actors
963 .iter()
964 .map(String::as_str)
965 .chain(std::iter::once(origin.as_str()))
966 .collect::<Vec<_>>();
967 prop_assert_eq!(decoded.iter().collect::<Vec<_>>(), expected_iter);
968 }
969 }
970}