Skip to main content

khive_runtime/
event_actor_scope.rs

1use crate::{split_stamped_label, NamespaceToken, RuntimeError, RuntimeResult};
2
3/// Actor labels admitted by a caller-scoped event read.
4#[derive(Debug)]
5pub struct EventActorReadScope {
6    /// Exact stored labels to match; an empty list means an authorized aggregate read.
7    pub actors: Vec<String>,
8    /// Caller label for default-view aggregation; explicit and aggregate views do not coalesce.
9    pub default_caller: Option<String>,
10}
11
12/// Resolve the existing ADR-103 Amendment 5 actor scope without reading storage.
13///
14/// `fleet_readers` must come from the serving runtime's resolved configuration.
15/// Packs retain any additional input-label validation and their response presentation.
16pub fn resolve_event_actor_read_scope(
17    token: &NamespaceToken,
18    fleet_readers: &[String],
19    actor: Option<&str>,
20    all_actors: bool,
21) -> RuntimeResult<EventActorReadScope> {
22    if all_actors && actor.is_some() {
23        return Err(RuntimeError::InvalidInput(
24            "all_actors=true cannot be combined with actor".into(),
25        ));
26    }
27    if all_actors && !fleet_readers.contains(&token.actor().id) {
28        return Err(RuntimeError::InvalidInput(format!(
29            "actor {:?} is not a configured fleet reader",
30            token.actor().id
31        )));
32    }
33    let caller = token.actor().label();
34    if let Some(actor) = actor {
35        let (identity, is_self) = match split_stamped_label(actor) {
36            Some((kind, id)) => (
37                if kind == "actor" { id } else { actor },
38                token.actor().kind == kind && token.actor().id == id,
39            ),
40            None => (actor, actor == caller),
41        };
42        if !is_self && !token.visible_namespace_strs().contains(&identity) {
43            return Err(RuntimeError::InvalidInput(format!(
44                "actor {identity:?} is not visible to this caller"
45            )));
46        }
47    }
48    let default_scope = !all_actors && actor.is_none();
49
50    // A prefixed id has no bare alias: that spelling belongs to another
51    // principal's canonical events. Only default scope coalesces actor keys.
52    let actors = match actor {
53        Some(a) if split_stamped_label(a).is_some() => vec![a.to_string()],
54        Some(a) => vec![a.to_string(), format!("actor:{a}")],
55        None if all_actors => Vec::new(),
56        None if token.actor().kind == "actor" && split_stamped_label(&caller).is_some() => {
57            vec![format!("actor:{caller}")]
58        }
59        None if token.actor().kind == "actor" => {
60            vec![caller.clone(), format!("actor:{caller}")]
61        }
62        None => vec![caller.clone()],
63    };
64    Ok(EventActorReadScope {
65        actors,
66        default_caller: default_scope.then_some(caller),
67    })
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use crate::{ActorRef, Namespace, RUNTIME_STAMPED_ACTOR_KINDS};
74
75    fn token(kind: &str, id: &str, visible: &[&str]) -> NamespaceToken {
76        NamespaceToken::mint_with_visibility(
77            Namespace::local(),
78            visible
79                .iter()
80                .map(|name| Namespace::parse(name).unwrap())
81                .collect(),
82            ActorRef::new(kind, id),
83        )
84    }
85
86    fn invalid(result: RuntimeResult<EventActorReadScope>, expected: &str) {
87        match result {
88            Err(RuntimeError::InvalidInput(message)) => assert_eq!(message, expected),
89            other => panic!("expected InvalidInput {expected:?}, got {other:?}"),
90        }
91    }
92
93    #[test]
94    fn default_scope_keeps_caller_labels_and_only_permitted_aliases() {
95        for (kind, id, labels, caller) in [
96            ("actor", "worker", vec!["worker", "actor:worker"], "worker"),
97            (
98                "actor",
99                "svc:build",
100                vec!["svc:build", "actor:svc:build"],
101                "svc:build",
102            ),
103            ("agent", "worker", vec!["agent:worker"], "agent:worker"),
104            (
105                "anonymous",
106                "local",
107                vec!["anonymous:local"],
108                "anonymous:local",
109            ),
110            (
111                "service",
112                "worker",
113                vec!["service:worker"],
114                "service:worker",
115            ),
116        ] {
117            let scope =
118                resolve_event_actor_read_scope(&token(kind, id, &[]), &[], None, false).unwrap();
119            assert_eq!(scope.actors, labels, "{kind}:{id}");
120            assert_eq!(scope.default_caller.as_deref(), Some(caller));
121        }
122        for kind in RUNTIME_STAMPED_ACTOR_KINDS {
123            let id = format!("{kind}:worker");
124            let scope = resolve_event_actor_read_scope(&token("actor", &id, &[]), &[], None, false)
125                .unwrap();
126            assert_eq!(scope.actors, vec![format!("actor:{id}")]);
127            assert_eq!(scope.default_caller, Some(id));
128        }
129    }
130
131    #[test]
132    fn explicit_self_filters_keep_exact_stamps_and_do_not_coalesce() {
133        for (kind, id, requested, labels) in [
134            ("actor", "worker", "worker", vec!["worker", "actor:worker"]),
135            ("actor", "worker", "actor:worker", vec!["actor:worker"]),
136            ("agent", "worker", "agent:worker", vec!["agent:worker"]),
137            (
138                "anonymous",
139                "local",
140                "anonymous:local",
141                vec!["anonymous:local"],
142            ),
143            (
144                "service",
145                "worker",
146                "service:worker",
147                vec!["service:worker", "actor:service:worker"],
148            ),
149        ] {
150            let scope =
151                resolve_event_actor_read_scope(&token(kind, id, &[]), &[], Some(requested), false)
152                    .unwrap();
153            assert_eq!(scope.actors, labels);
154            assert!(scope.default_caller.is_none());
155        }
156    }
157
158    #[test]
159    fn foreign_visibility_uses_the_documented_exact_identity() {
160        for (requested, identity, labels) in [
161            ("peer", "peer", vec!["peer", "actor:peer"]),
162            ("actor:peer", "peer", vec!["actor:peer"]),
163            ("agent:peer", "agent:peer", vec!["agent:peer"]),
164            (
165                "anonymous:local",
166                "anonymous:local",
167                vec!["anonymous:local"],
168            ),
169            (
170                "service:peer",
171                "service:peer",
172                vec!["service:peer", "actor:service:peer"],
173            ),
174        ] {
175            let scope = resolve_event_actor_read_scope(
176                &token("actor", "worker", &[identity]),
177                &[],
178                Some(requested),
179                false,
180            )
181            .unwrap();
182            assert_eq!(scope.actors, labels);
183            assert!(scope.default_caller.is_none());
184            let descendant = format!("{identity}:child");
185            invalid(
186                resolve_event_actor_read_scope(
187                    &token("actor", "worker", &[&descendant]),
188                    &[],
189                    Some(requested),
190                    false,
191                ),
192                &format!("actor {identity:?} is not visible to this caller"),
193            );
194        }
195    }
196
197    #[test]
198    fn reserved_raw_ids_do_not_gain_collapsed_self_access() {
199        for kind in RUNTIME_STAMPED_ACTOR_KINDS {
200            let id = format!("{kind}:worker");
201            let caller = token("actor", &id, &[]);
202            let canonical = format!("actor:{id}");
203            let own =
204                resolve_event_actor_read_scope(&caller, &[], Some(&canonical), false).unwrap();
205            assert_eq!(own.actors, vec![canonical]);
206            let identity = if *kind == "actor" { "worker" } else { &id };
207            invalid(
208                resolve_event_actor_read_scope(&caller, &[], Some(&id), false),
209                &format!("actor {identity:?} is not visible to this caller"),
210            );
211        }
212    }
213
214    #[test]
215    fn aggregate_requires_exact_fleet_id_and_conflict_wins_first() {
216        let caller = token("actor", "worker", &["peer"]);
217        for readers in [vec![], vec!["actor:worker".into()]] {
218            invalid(
219                resolve_event_actor_read_scope(&caller, &readers, None, true),
220                "actor \"worker\" is not a configured fleet reader",
221            );
222        }
223        let readers = vec!["worker".into()];
224        let all = resolve_event_actor_read_scope(&caller, &readers, None, true).unwrap();
225        assert!(all.actors.is_empty());
226        assert!(all.default_caller.is_none());
227        let default = resolve_event_actor_read_scope(&caller, &readers, None, false).unwrap();
228        assert_eq!(default.actors, vec!["worker", "actor:worker"]);
229        assert_eq!(default.default_caller.as_deref(), Some("worker"));
230        for readers in [vec![], readers] {
231            invalid(
232                resolve_event_actor_read_scope(&caller, &readers, Some(""), true),
233                "all_actors=true cannot be combined with actor",
234            );
235        }
236    }
237
238    #[test]
239    fn shared_scope_does_not_add_telemetry_label_validation_to_brain() {
240        let caller = token("actor", " ", &[]);
241        let scope = resolve_event_actor_read_scope(&caller, &[], Some(" "), false).unwrap();
242        assert_eq!(scope.actors, vec![" ", "actor: "]);
243        assert!(scope.default_caller.is_none());
244    }
245}