vta_sdk/acl.rs
1//! ACL wire types shared between the VTA and its clients.
2//!
3//! These live here — rather than in `vti-common` alongside the ACL storage and
4//! authorization logic — because the DIDComm and Trust Task bodies in
5//! [`crate::protocols::acl_management`] are part of the wire contract and must
6//! be constructible by clients that never link the server crates. `vti-common`
7//! depends on this crate (never the reverse) and re-exports what it needs, the
8//! same arrangement already used for [`crate::context_path`].
9//!
10//! Authorization over these types stays server-side: `validate_approve_scope_grant`
11//! and friends remain in `vti-common`. Only the shape is shared.
12
13use serde::{Deserialize, Serialize};
14
15/// A DID's authority to **act** — the contexts in which it may make a change.
16/// The sibling axis to [`ApproveScope`], with deliberately identical variants,
17/// an identical [`covers`](ActScope::covers) predicate, and the same
18/// fail-closed [`None`](ActScope::None) default.
19///
20/// # Why this exists
21///
22/// Unlike [`ApproveScope`], this is **not** a stored or wire field. The act
23/// axis is stored as `(role, allowed_contexts)`, where an **empty**
24/// `allowed_contexts` means opposite things depending on the role: unrestricted
25/// for an admin (that *is* how a super-admin is spelled), and *nothing at all*
26/// for every other role. Reading the raw field without the role is therefore a
27/// bug, and has repeatedly been one — a display that called a least-privilege
28/// approver `(unrestricted)`, two `acl list --context` filters that disagreed
29/// on whether an empty list matches every context or none, and a vault scope
30/// gate that granted cross-context reads to an entry authorized nowhere.
31///
32/// This type makes the three cases distinguishable so that decode happens in
33/// one place instead of at every call site. The decode itself is server-side
34/// (`vti_common::acl::act_scope_for`), because it needs `Role`; only the shape
35/// and the predicate are shared, exactly as for [`ApproveScope`].
36///
37/// It lives beside [`ApproveScope`] so the two axes — what a DID may *do* and
38/// what it may *confer* — read as one model rather than an enum and a
39/// convention.
40#[derive(Debug, Clone, Default, PartialEq, Eq)]
41pub enum ActScope {
42 /// Authorized in no context at all (the fail-closed default). The shape of
43 /// a least-privilege approver: acts nowhere, may still confer via its
44 /// [`ApproveScope`].
45 #[default]
46 None,
47 /// Authorized in every context. Combined with the admin role this is a
48 /// super-admin.
49 All,
50 /// Authorized in these contexts and their subtrees, and only these.
51 Contexts(Vec<String>),
52}
53
54impl ActScope {
55 /// Whether a holder of this scope may act in `context_id`.
56 ///
57 /// Segment-aware ancestry, identical to [`ApproveScope::covers`], so
58 /// authority over a parent context covers its whole subtree.
59 pub fn covers(&self, context_id: &str) -> bool {
60 match self {
61 ActScope::None => false,
62 ActScope::All => true,
63 ActScope::Contexts(cs) => cs
64 .iter()
65 .any(|c| crate::context_path::is_ancestor_or_self(c, context_id)),
66 }
67 }
68
69 /// Whether this scope names a context **at or beneath** `context_id` — the
70 /// mirror of [`covers`](ActScope::covers), with the ancestry test's
71 /// arguments swapped.
72 ///
73 /// [`covers`](ActScope::covers) answers "may this entry act *in* the
74 /// context?"; this answers "does this entry hold a grant *inside* the
75 /// context's subtree?". They are different questions and a caller sweeping
76 /// a subtree — to revoke it, or to audit it — wants this one: a grant at
77 /// `acme/eng/team-a` is invisible to `covers("acme/eng")` because it holds
78 /// no authority over `acme/eng`, yet it is precisely what the sweep exists
79 /// to find.
80 ///
81 /// Two deliberate edges:
82 ///
83 /// - [`All`](ActScope::All) is **not** a match. An unrestricted entry names
84 /// no context, so it is not a grant *of* this subtree — it is authority
85 /// everywhere, which [`covers`](ActScope::covers) already reports. A
86 /// subtree sweep that returned it would invite a caller revoking a
87 /// compromised branch to delete its own super-admin. Ask
88 /// [`ContextDirection::Any`] for "everyone whose authority touches this
89 /// subtree", which includes it.
90 /// - A [`Contexts`](ActScope::Contexts) scope matches if **any** named
91 /// context is inside the subtree, even when it also names contexts
92 /// outside it. Such an entry does hold a grant in the subtree, so
93 /// omitting it would under-report; whether to delete the entry or narrow
94 /// its scope is then the caller's decision, made with the entry in hand.
95 pub fn acts_within(&self, context_id: &str) -> bool {
96 match self {
97 ActScope::None | ActScope::All => false,
98 ActScope::Contexts(cs) => cs
99 .iter()
100 .any(|c| crate::context_path::is_ancestor_or_self(context_id, c)),
101 }
102 }
103
104 /// Whether this scope matches `context_id` when read in `direction` — the
105 /// single predicate behind every context-filtered ACL listing.
106 pub fn matches_context(&self, context_id: &str, direction: ContextDirection) -> bool {
107 match direction {
108 ContextDirection::ActingIn => self.covers(context_id),
109 ContextDirection::Subtree => self.acts_within(context_id),
110 ContextDirection::Any => self.covers(context_id) || self.acts_within(context_id),
111 }
112 }
113
114 /// Whether this scope authorizes every context (the super-admin condition
115 /// when paired with the admin role).
116 pub fn is_unrestricted(&self) -> bool {
117 matches!(self, ActScope::All)
118 }
119
120 /// Whether this scope authorizes nothing.
121 pub fn acts_nowhere(&self) -> bool {
122 matches!(self, ActScope::None)
123 }
124
125 /// The contexts named by this scope, or an empty slice for
126 /// [`None`](ActScope::None) / [`All`](ActScope::All) — neither of which is
127 /// expressible as a list.
128 pub fn named_contexts(&self) -> &[String] {
129 match self {
130 ActScope::Contexts(cs) => cs,
131 _ => &[],
132 }
133 }
134}
135
136/// Which way a context filter reads along the context hierarchy.
137///
138/// A context id names a subtree, so "entries relevant to context X" is two
139/// questions, not one, and an answer is only meaningful paired with the
140/// direction being asked:
141///
142/// | direction | question | predicate |
143/// |---|---|---|
144/// | [`ActingIn`](ContextDirection::ActingIn) | who may act **in** X? | scope is X or an ancestor of it |
145/// | [`Subtree`](ContextDirection::Subtree) | who holds a grant **beneath** X? | scope is X or a descendant of it |
146/// | [`Any`](ContextDirection::Any) | whose authority **touches** X's subtree? | either of the above |
147///
148/// Only [`ActingIn`](ContextDirection::ActingIn) existed until now, and a
149/// caller sweeping a subtree to revoke it got back exactly the entries it was
150/// *not* revoking (the ancestors keeping their authority) while every
151/// leaf-scoped grant it existed to cut was silently absent — an incomplete
152/// answer that looks like a complete one (#822).
153///
154/// [`ActingIn`](ContextDirection::ActingIn) is the default, so an omitted
155/// parameter is today's behaviour exactly. An unparseable value is refused
156/// rather than defaulted: guessing which question an operator meant is how the
157/// silent-wrong-answer failure returns.
158#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
159#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
160#[serde(rename_all = "kebab-case")]
161pub enum ContextDirection {
162 /// Entries that may act **in** the context: scoped to it or to an ancestor
163 /// of it. The historical (and default) behaviour of `?context=`.
164 #[default]
165 ActingIn,
166 /// Entries holding a grant **at or beneath** the context. The revocation-
167 /// sweep and delegation-audit direction.
168 Subtree,
169 /// The union — every entry whose act authority touches the context's
170 /// subtree in either direction. The auditor's question.
171 Any,
172}
173
174impl ContextDirection {
175 /// Every direction, in the order the operator-facing help lists them.
176 pub const ALL: [ContextDirection; 3] = [
177 ContextDirection::ActingIn,
178 ContextDirection::Subtree,
179 ContextDirection::Any,
180 ];
181
182 /// The wire spelling — identical to the serde representation, so the CLI,
183 /// the query string, and the Trust Task payload cannot drift apart.
184 pub fn as_str(&self) -> &'static str {
185 match self {
186 ContextDirection::ActingIn => "acting-in",
187 ContextDirection::Subtree => "subtree",
188 ContextDirection::Any => "any",
189 }
190 }
191}
192
193impl std::fmt::Display for ContextDirection {
194 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195 f.write_str(self.as_str())
196 }
197}
198
199impl std::str::FromStr for ContextDirection {
200 type Err = String;
201
202 fn from_str(s: &str) -> Result<Self, Self::Err> {
203 Self::ALL
204 .into_iter()
205 .find(|d| d.as_str() == s)
206 .ok_or_else(|| {
207 let valid = Self::ALL
208 .iter()
209 .map(|d| d.as_str())
210 .collect::<Vec<_>>()
211 .join(", ");
212 format!("invalid context direction '{s}', expected one of: {valid}")
213 })
214 }
215}
216
217impl std::fmt::Display for ActScope {
218 /// Operator-facing rendering. The unrestricted and acts-nowhere cases are
219 /// spelled out rather than both collapsing to `(unrestricted)`, which is
220 /// what made them indistinguishable on ACL displays. The wording matches
221 /// what `vta-cli-common`'s `format_contexts` already prints.
222 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223 match self {
224 ActScope::None => f.write_str("(none — acts nowhere)"),
225 ActScope::All => f.write_str("(unrestricted)"),
226 ActScope::Contexts(cs) => f.write_str(&cs.join(", ")),
227 }
228 }
229}
230
231/// A DID's authority to **confer** access through an approval — task-consent
232/// delegation (`compute_delegated_contexts`) and delegated step-up ratification
233/// (`delegated_any_approver_covers`) — **without** any authority to act.
234///
235/// Read only by those two conferral paths; it never feeds `require_admin` or
236/// `has_context_access`, so an approver can bless a change in a context while
237/// being unable to make one. This is the axis that lets an approver be
238/// least-privilege: `role: Reader`, `allowed_contexts: []` (acts nowhere),
239/// `approve_scope: All` (may authorize anywhere).
240///
241/// Default [`ApproveScope::None`]: an entry confers nothing unless explicitly
242/// granted this — strictly additive and fail-closed. Pre-existing rows omit the
243/// field and deserialise as `None`.
244#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
245#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
246#[serde(rename_all = "snake_case", tag = "kind", content = "contexts")]
247pub enum ApproveScope {
248 /// Confers nothing (the default).
249 #[default]
250 None,
251 /// May confer any context — a cross-context authorizer. Granting this is
252 /// super-admin-only (see `vti_common::acl::validate_approve_scope_grant`).
253 All,
254 /// May confer these contexts (and their subtrees), and only these.
255 Contexts(Vec<String>),
256}
257
258impl ApproveScope {
259 /// Whether an approval by a holder of this scope may confer `context_id`.
260 ///
261 /// Segment-aware ancestry, matching `AuthClaims::has_context_access`, so an
262 /// approver scoped to a parent context covers its whole subtree.
263 pub fn covers(&self, context_id: &str) -> bool {
264 match self {
265 ApproveScope::None => false,
266 ApproveScope::All => true,
267 ApproveScope::Contexts(cs) => cs
268 .iter()
269 .any(|c| crate::context_path::is_ancestor_or_self(c, context_id)),
270 }
271 }
272
273 /// Whether this scope confers nothing.
274 pub fn confers_nothing(&self) -> bool {
275 matches!(self, ApproveScope::None)
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 /// The wire shape is a contract: stored ACL rows and DIDComm bodies both
284 /// carry it, so a change here silently reinterprets existing entries.
285 #[test]
286 fn wire_shape_is_pinned() {
287 let cases = [
288 (ApproveScope::None, r#"{"kind":"none"}"#),
289 (ApproveScope::All, r#"{"kind":"all"}"#),
290 (
291 ApproveScope::Contexts(vec!["a".into(), "b/c".into()]),
292 r#"{"kind":"contexts","contexts":["a","b/c"]}"#,
293 ),
294 ];
295 for (scope, json) in cases {
296 assert_eq!(serde_json::to_string(&scope).unwrap(), json);
297 assert_eq!(
298 serde_json::from_str::<ApproveScope>(json).unwrap(),
299 scope,
300 "round trip for {json}"
301 );
302 }
303 }
304
305 /// Absent ⇒ `None`, so rows written before the field existed stay
306 /// fail-closed rather than deserialising into some conferring shape.
307 #[test]
308 fn absent_defaults_to_conferring_nothing() {
309 assert_eq!(ApproveScope::default(), ApproveScope::None);
310 assert!(ApproveScope::default().confers_nothing());
311 }
312
313 /// The two axes must agree on what a scope covers — same predicate, same
314 /// answers — or "act" and "confer" stop being comparable.
315 #[test]
316 fn act_and_approve_agree_on_coverage() {
317 for ctx in ["acme", "acme/eng", "acme-corp", "other"] {
318 assert_eq!(
319 ActScope::Contexts(vec!["acme".into()]).covers(ctx),
320 ApproveScope::Contexts(vec!["acme".into()]).covers(ctx),
321 "disagreement on {ctx}"
322 );
323 }
324 assert_eq!(ActScope::All.covers("x"), ApproveScope::All.covers("x"));
325 assert_eq!(ActScope::None.covers("x"), ApproveScope::None.covers("x"));
326 }
327
328 /// Fail-closed: an unset act scope authorizes nothing, matching
329 /// `ApproveScope`'s default.
330 #[test]
331 fn act_scope_defaults_to_nothing() {
332 assert_eq!(ActScope::default(), ActScope::None);
333 assert!(ActScope::default().acts_nowhere());
334 assert!(!ActScope::default().is_unrestricted());
335 }
336
337 /// The display defect this type closes: both empty cases rendered
338 /// `(unrestricted)`, so an acts-nowhere entry read as blanket access.
339 #[test]
340 fn display_distinguishes_nothing_from_everything() {
341 assert_eq!(ActScope::All.to_string(), "(unrestricted)");
342 assert_eq!(ActScope::None.to_string(), "(none — acts nowhere)");
343 assert_ne!(ActScope::None.to_string(), ActScope::All.to_string());
344 assert_eq!(
345 ActScope::Contexts(vec!["a".into(), "b".into()]).to_string(),
346 "a, b"
347 );
348 }
349
350 #[test]
351 fn named_contexts_is_empty_for_the_unlistable_variants() {
352 assert!(ActScope::All.named_contexts().is_empty());
353 assert!(ActScope::None.named_contexts().is_empty());
354 assert_eq!(
355 ActScope::Contexts(vec!["a".into()]).named_contexts(),
356 ["a".to_string()]
357 );
358 }
359
360 #[test]
361 fn covers_is_subtree_aware() {
362 let scope = ApproveScope::Contexts(vec!["acme".into()]);
363 assert!(scope.covers("acme"));
364 assert!(scope.covers("acme/eng"));
365 assert!(!scope.covers("acme-corp"), "sibling must not match");
366 assert!(!scope.covers("other"));
367
368 assert!(ApproveScope::All.covers("anything"));
369 assert!(!ApproveScope::None.covers("anything"));
370 }
371
372 // ── ContextDirection ────────────────────────────────────────────
373
374 /// The two directions are mirror images: `covers` walks up from the
375 /// queried context, `acts_within` walks down. The defect was having only
376 /// the first, so the descendant column here was unaskable.
377 #[test]
378 fn the_two_directions_are_mirrors() {
379 let ancestor = ActScope::Contexts(vec!["acme".into()]);
380 let exact = ActScope::Contexts(vec!["acme/eng".into()]);
381 let descendant = ActScope::Contexts(vec!["acme/eng/team-a".into()]);
382 let sibling = ActScope::Contexts(vec!["acme/ops".into()]);
383 let q = "acme/eng";
384
385 // covers: ancestor-or-self of the queried context.
386 assert!(ancestor.covers(q));
387 assert!(exact.covers(q));
388 assert!(!descendant.covers(q), "a leaf holds no authority over q");
389 assert!(!sibling.covers(q));
390
391 // acts_within: at-or-beneath the queried context.
392 assert!(!ancestor.acts_within(q), "a parent grant is not inside q");
393 assert!(exact.acts_within(q), "self is inside its own subtree");
394 assert!(descendant.acts_within(q), "the sweep's whole point");
395 assert!(!sibling.acts_within(q));
396 }
397
398 /// Segment-awareness must hold in the new direction too, or `acme/eng`
399 /// would "contain" `acme/engineering` and a sweep would revoke a
400 /// bystander.
401 #[test]
402 fn acts_within_is_segment_aware() {
403 let scope = ActScope::Contexts(vec!["acme/engineering".into()]);
404 assert!(!scope.acts_within("acme/eng"));
405 assert!(scope.acts_within("acme"));
406 }
407
408 /// An unrestricted entry is authority *everywhere*, not a grant *of* this
409 /// subtree. Surfacing it under `subtree` would put the caller's own
410 /// super-admin on a revocation list; `any` is where it belongs.
411 #[test]
412 fn unrestricted_is_reported_by_acting_in_and_any_but_not_subtree() {
413 let all = ActScope::All;
414 assert!(all.matches_context("acme/eng", ContextDirection::ActingIn));
415 assert!(!all.matches_context("acme/eng", ContextDirection::Subtree));
416 assert!(all.matches_context("acme/eng", ContextDirection::Any));
417 }
418
419 /// Acts-nowhere matches in no direction — the fail-closed edge.
420 #[test]
421 fn acts_nowhere_matches_no_direction() {
422 for d in ContextDirection::ALL {
423 assert!(
424 !ActScope::None.matches_context("acme", d),
425 "acts-nowhere matched {d}"
426 );
427 }
428 }
429
430 /// A scope naming several contexts matches the sweep if *any* of them is
431 /// inside it — under-reporting is the failure mode being fixed.
432 #[test]
433 fn a_partially_inside_scope_is_surfaced() {
434 let scope = ActScope::Contexts(vec!["acme/eng/team-a".into(), "other".into()]);
435 assert!(scope.acts_within("acme/eng"));
436 }
437
438 /// `Any` is exactly the union, and never narrower than either leg.
439 #[test]
440 fn any_is_the_union_of_both_legs() {
441 let scopes = [
442 ActScope::None,
443 ActScope::All,
444 ActScope::Contexts(vec!["acme".into()]),
445 ActScope::Contexts(vec!["acme/eng".into()]),
446 ActScope::Contexts(vec!["acme/eng/team-a".into()]),
447 ActScope::Contexts(vec!["other".into()]),
448 ];
449 for scope in scopes {
450 let acting_in = scope.matches_context("acme/eng", ContextDirection::ActingIn);
451 let subtree = scope.matches_context("acme/eng", ContextDirection::Subtree);
452 assert_eq!(
453 scope.matches_context("acme/eng", ContextDirection::Any),
454 acting_in || subtree,
455 "union broken for {scope:?}"
456 );
457 }
458 }
459
460 /// Omitting the parameter must be today's behaviour, byte for byte.
461 #[test]
462 fn default_direction_is_the_historical_one() {
463 assert_eq!(ContextDirection::default(), ContextDirection::ActingIn);
464 let scope = ActScope::Contexts(vec!["acme".into()]);
465 for ctx in ["acme", "acme/eng", "acme-corp", "other"] {
466 assert_eq!(
467 scope.matches_context(ctx, ContextDirection::default()),
468 scope.covers(ctx),
469 "default direction diverged from `covers` at {ctx}"
470 );
471 }
472 }
473
474 /// One spelling across the query string, the Trust Task payload, and the
475 /// CLI flag — parsed and rendered through the same table.
476 #[test]
477 fn wire_spelling_is_pinned_and_round_trips() {
478 let cases = [
479 (ContextDirection::ActingIn, "acting-in"),
480 (ContextDirection::Subtree, "subtree"),
481 (ContextDirection::Any, "any"),
482 ];
483 for (direction, wire) in cases {
484 assert_eq!(direction.as_str(), wire);
485 assert_eq!(direction.to_string(), wire);
486 assert_eq!(
487 serde_json::to_string(&direction).unwrap(),
488 format!("\"{wire}\"")
489 );
490 assert_eq!(
491 serde_json::from_str::<ContextDirection>(&format!("\"{wire}\"")).unwrap(),
492 direction
493 );
494 assert_eq!(wire.parse::<ContextDirection>().unwrap(), direction);
495 }
496 }
497
498 /// An unknown value is refused, and the error names the valid ones — the
499 /// operator-errors-suggest-the-fix rule. Silently defaulting would
500 /// reintroduce "wrong question, confident answer".
501 #[test]
502 fn an_unknown_direction_is_refused_with_the_valid_set() {
503 let err = "descendants".parse::<ContextDirection>().unwrap_err();
504 assert!(err.contains("descendants"), "{err}");
505 for d in ContextDirection::ALL {
506 assert!(err.contains(d.as_str()), "{err} omits {d}");
507 }
508 assert!(serde_json::from_str::<ContextDirection>("\"ActingIn\"").is_err());
509 }
510}