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 authorizes every context (the super-admin condition
70 /// when paired with the admin role).
71 pub fn is_unrestricted(&self) -> bool {
72 matches!(self, ActScope::All)
73 }
74
75 /// Whether this scope authorizes nothing.
76 pub fn acts_nowhere(&self) -> bool {
77 matches!(self, ActScope::None)
78 }
79
80 /// The contexts named by this scope, or an empty slice for
81 /// [`None`](ActScope::None) / [`All`](ActScope::All) — neither of which is
82 /// expressible as a list.
83 pub fn named_contexts(&self) -> &[String] {
84 match self {
85 ActScope::Contexts(cs) => cs,
86 _ => &[],
87 }
88 }
89}
90
91impl std::fmt::Display for ActScope {
92 /// Operator-facing rendering. The unrestricted and acts-nowhere cases are
93 /// spelled out rather than both collapsing to `(unrestricted)`, which is
94 /// what made them indistinguishable on ACL displays. The wording matches
95 /// what `vta-cli-common`'s `format_contexts` already prints.
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 match self {
98 ActScope::None => f.write_str("(none — acts nowhere)"),
99 ActScope::All => f.write_str("(unrestricted)"),
100 ActScope::Contexts(cs) => f.write_str(&cs.join(", ")),
101 }
102 }
103}
104
105/// A DID's authority to **confer** access through an approval — task-consent
106/// delegation (`compute_delegated_contexts`) and delegated step-up ratification
107/// (`delegated_any_approver_covers`) — **without** any authority to act.
108///
109/// Read only by those two conferral paths; it never feeds `require_admin` or
110/// `has_context_access`, so an approver can bless a change in a context while
111/// being unable to make one. This is the axis that lets an approver be
112/// least-privilege: `role: Reader`, `allowed_contexts: []` (acts nowhere),
113/// `approve_scope: All` (may authorize anywhere).
114///
115/// Default [`ApproveScope::None`]: an entry confers nothing unless explicitly
116/// granted this — strictly additive and fail-closed. Pre-existing rows omit the
117/// field and deserialise as `None`.
118#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
119#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
120#[serde(rename_all = "snake_case", tag = "kind", content = "contexts")]
121pub enum ApproveScope {
122 /// Confers nothing (the default).
123 #[default]
124 None,
125 /// May confer any context — a cross-context authorizer. Granting this is
126 /// super-admin-only (see `vti_common::acl::validate_approve_scope_grant`).
127 All,
128 /// May confer these contexts (and their subtrees), and only these.
129 Contexts(Vec<String>),
130}
131
132impl ApproveScope {
133 /// Whether an approval by a holder of this scope may confer `context_id`.
134 ///
135 /// Segment-aware ancestry, matching `AuthClaims::has_context_access`, so an
136 /// approver scoped to a parent context covers its whole subtree.
137 pub fn covers(&self, context_id: &str) -> bool {
138 match self {
139 ApproveScope::None => false,
140 ApproveScope::All => true,
141 ApproveScope::Contexts(cs) => cs
142 .iter()
143 .any(|c| crate::context_path::is_ancestor_or_self(c, context_id)),
144 }
145 }
146
147 /// Whether this scope confers nothing.
148 pub fn confers_nothing(&self) -> bool {
149 matches!(self, ApproveScope::None)
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 /// The wire shape is a contract: stored ACL rows and DIDComm bodies both
158 /// carry it, so a change here silently reinterprets existing entries.
159 #[test]
160 fn wire_shape_is_pinned() {
161 let cases = [
162 (ApproveScope::None, r#"{"kind":"none"}"#),
163 (ApproveScope::All, r#"{"kind":"all"}"#),
164 (
165 ApproveScope::Contexts(vec!["a".into(), "b/c".into()]),
166 r#"{"kind":"contexts","contexts":["a","b/c"]}"#,
167 ),
168 ];
169 for (scope, json) in cases {
170 assert_eq!(serde_json::to_string(&scope).unwrap(), json);
171 assert_eq!(
172 serde_json::from_str::<ApproveScope>(json).unwrap(),
173 scope,
174 "round trip for {json}"
175 );
176 }
177 }
178
179 /// Absent ⇒ `None`, so rows written before the field existed stay
180 /// fail-closed rather than deserialising into some conferring shape.
181 #[test]
182 fn absent_defaults_to_conferring_nothing() {
183 assert_eq!(ApproveScope::default(), ApproveScope::None);
184 assert!(ApproveScope::default().confers_nothing());
185 }
186
187 /// The two axes must agree on what a scope covers — same predicate, same
188 /// answers — or "act" and "confer" stop being comparable.
189 #[test]
190 fn act_and_approve_agree_on_coverage() {
191 for ctx in ["acme", "acme/eng", "acme-corp", "other"] {
192 assert_eq!(
193 ActScope::Contexts(vec!["acme".into()]).covers(ctx),
194 ApproveScope::Contexts(vec!["acme".into()]).covers(ctx),
195 "disagreement on {ctx}"
196 );
197 }
198 assert_eq!(ActScope::All.covers("x"), ApproveScope::All.covers("x"));
199 assert_eq!(ActScope::None.covers("x"), ApproveScope::None.covers("x"));
200 }
201
202 /// Fail-closed: an unset act scope authorizes nothing, matching
203 /// `ApproveScope`'s default.
204 #[test]
205 fn act_scope_defaults_to_nothing() {
206 assert_eq!(ActScope::default(), ActScope::None);
207 assert!(ActScope::default().acts_nowhere());
208 assert!(!ActScope::default().is_unrestricted());
209 }
210
211 /// The display defect this type closes: both empty cases rendered
212 /// `(unrestricted)`, so an acts-nowhere entry read as blanket access.
213 #[test]
214 fn display_distinguishes_nothing_from_everything() {
215 assert_eq!(ActScope::All.to_string(), "(unrestricted)");
216 assert_eq!(ActScope::None.to_string(), "(none — acts nowhere)");
217 assert_ne!(ActScope::None.to_string(), ActScope::All.to_string());
218 assert_eq!(
219 ActScope::Contexts(vec!["a".into(), "b".into()]).to_string(),
220 "a, b"
221 );
222 }
223
224 #[test]
225 fn named_contexts_is_empty_for_the_unlistable_variants() {
226 assert!(ActScope::All.named_contexts().is_empty());
227 assert!(ActScope::None.named_contexts().is_empty());
228 assert_eq!(
229 ActScope::Contexts(vec!["a".into()]).named_contexts(),
230 ["a".to_string()]
231 );
232 }
233
234 #[test]
235 fn covers_is_subtree_aware() {
236 let scope = ApproveScope::Contexts(vec!["acme".into()]);
237 assert!(scope.covers("acme"));
238 assert!(scope.covers("acme/eng"));
239 assert!(!scope.covers("acme-corp"), "sibling must not match");
240 assert!(!scope.covers("other"));
241
242 assert!(ApproveScope::All.covers("anything"));
243 assert!(!ApproveScope::None.covers("anything"));
244 }
245}