Skip to main content

harn_vm/
actor_chain.rs

1//! RFC 8693 actor/principal chain support.
2
3use 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/// A first-class RFC 8693 §4.1 actor chain.
17///
18/// `origin` serializes as the top-level `sub` claim: the human principal the
19/// request is ultimately on behalf of. `actors` serializes as nested `act`
20/// claims in RFC order, with the current actor outermost and prior actors
21/// nested beneath it. For authorization, only the top-level token claims and
22/// [`ActorChain::current`] are authoritative. The audit-not-authz rule is that
23/// nested actors are audit history only and must not be used to grant access.
24#[derive(Clone, Debug, PartialEq, Eq, Hash)]
25pub struct ActorChain {
26    origin: ActorChainEntry,
27    actors: Vec<ActorChainEntry>,
28    may_act: Option<String>,
29}
30
31/// Alias for APIs that name the full acting chain as a principal.
32pub type Principal = ActorChain;
33
34/// One subject in an [`ActorChain`], plus the authority scopes that subject
35/// carried at that hop when the host knows them.
36#[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    /// Create a chain with only an originating subject and no acting agents.
218    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    /// Build a chain from actors already ordered current-first, matching the
239    /// RFC 8693 nested `act` traversal order.
240    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    /// Insert `actor` as the new current actor.
264    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    /// Chainable form of [`ActorChain::push`].
273    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    /// The actor that authorization checks should consider authoritative.
293    ///
294    /// When no delegation has occurred, the originating subject is also the
295    /// current actor.
296    pub fn current(&self) -> &str {
297        self.actors
298            .first()
299            .map(ActorChainEntry::subject)
300            .unwrap_or(self.origin.subject())
301    }
302
303    /// The originating human principal carried in the top-level `sub` claim.
304    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    /// Acting agents in RFC nested-`act` order: current actor first.
317    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    /// All principals from current actor through the originating subject.
326    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    /// Authorized actor hint from the optional RFC 8693 §4.4 `may_act` claim.
355    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}