Skip to main content

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}