Skip to main content

vta_sdk/
context_policy.rs

1//! Per-context policy: fine-grained, field-wise constraints on what a
2//! context-scoped actor may do, enforced VTA-side. This is the primitive that
3//! makes enterprise separation-of-duty expressible beyond coarse capabilities —
4//! see `docs/05-design-notes/enterprise-fleet-management.md`.
5//!
6//! ## Resolution model — field-wise intersection (additive-narrow)
7//!
8//! A context inherits the constraints of all its ancestors. The *effective*
9//! policy for a context is the field-wise intersection of every policy on the
10//! path root→leaf ([`ContextPolicy::resolve`]). Each field resolves
11//! independently:
12//!
13//! * **Allow-list fields** (`Option<BTreeSet<String>>`): `None` = inherit (no
14//!   constraint at this level); `Some(set)` = only these are allowed.
15//!   Intersecting two `Some` sets keeps only members common to both, so a child
16//!   can *narrow* an ancestor's allow-list but never widen it — the result is
17//!   always a subset of every constraining level.
18//! * **`export_allowed`**: logical AND down the chain — once any ancestor
19//!   disables export, no descendant can re-enable it.
20//! * **`quotas`**: per-operation-class daily ceilings; the effective ceiling is
21//!   the minimum across the chain (a level may add a ceiling for a class an
22//!   ancestor left unbounded).
23//!
24//! Widening is therefore *structurally impossible*: enforcement always resolves
25//! the full ancestor chain, so a permissive policy written at a child level is
26//! clamped by its ancestors regardless of who wrote it. This mirrors the
27//! relaxing-override-ignored property of the auth step-up policy, and means the
28//! CRUD layer only has to gate *who may write a policy at a level*, not whether
29//! a write could escalate — it can't.
30//!
31//! Every field is "absent = inherit / unrestricted", so a context with no policy
32//! (or [`ContextPolicy::unrestricted`]) imposes no constraints — preserving the
33//! behaviour of VTAs that predate this type.
34
35use serde::{Deserialize, Serialize};
36use std::collections::{BTreeMap, BTreeSet};
37
38/// Per-context policy. See the module docs for the resolution model.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
41pub struct ContextPolicy {
42    /// Verifier DIDs an actor in this context may present to. `None` = any.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub trusted_verifiers: Option<BTreeSet<String>>,
45    /// Credential `type`s an actor may present. `None` = any.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub presentable_types: Option<BTreeSet<String>>,
48    /// Key ids the signing oracle may be invoked on. `None` = any.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub signable_keys: Option<BTreeSet<String>>,
51    /// Whether sealed-transfer export is permitted. Defaults to `true`
52    /// (unrestricted) when absent from the wire.
53    #[serde(default = "default_true")]
54    pub export_allowed: bool,
55    /// Per-operation-class daily ceilings. `None` = no quota.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub quotas: Option<Quotas>,
58}
59
60fn default_true() -> bool {
61    true
62}
63
64/// Per-operation-class daily ceilings (e.g. `"sign" -> 1000`).
65#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
66#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
67pub struct Quotas {
68    /// operation-class -> maximum invocations per day.
69    pub per_day: BTreeMap<String, u64>,
70}
71
72impl Default for ContextPolicy {
73    fn default() -> Self {
74        Self::unrestricted()
75    }
76}
77
78impl ContextPolicy {
79    /// A policy that imposes no constraints — the implicit policy of any context
80    /// without one, and the identity element for [`intersect`](Self::intersect).
81    pub fn unrestricted() -> Self {
82        Self {
83            trusted_verifiers: None,
84            presentable_types: None,
85            signable_keys: None,
86            export_allowed: true,
87            quotas: None,
88        }
89    }
90
91    /// Field-wise intersection of `self` (ancestor) with `child`. The result is
92    /// at least as strict as both inputs; allow-list fields become the set
93    /// intersection, `export_allowed` the logical AND, quotas the per-class
94    /// minimum.
95    #[must_use]
96    pub fn intersect(&self, child: &ContextPolicy) -> ContextPolicy {
97        ContextPolicy {
98            trusted_verifiers: narrow_set(&self.trusted_verifiers, &child.trusted_verifiers),
99            presentable_types: narrow_set(&self.presentable_types, &child.presentable_types),
100            signable_keys: narrow_set(&self.signable_keys, &child.signable_keys),
101            export_allowed: self.export_allowed && child.export_allowed,
102            quotas: narrow_quotas(&self.quotas, &child.quotas),
103        }
104    }
105
106    /// Resolve the effective policy for a context from its ancestor chain,
107    /// ordered root→leaf. An empty chain resolves to
108    /// [`unrestricted`](Self::unrestricted).
109    pub fn resolve<'a, I>(chain: I) -> ContextPolicy
110    where
111        I: IntoIterator<Item = &'a ContextPolicy>,
112    {
113        chain
114            .into_iter()
115            .fold(ContextPolicy::unrestricted(), |acc, p| acc.intersect(p))
116    }
117
118    // --- enforcement gates (absent allow-list = allow) ----------------------
119
120    /// Whether a credential may be presented to `verifier_did`.
121    pub fn allows_verifier(&self, verifier_did: &str) -> bool {
122        self.trusted_verifiers
123            .as_ref()
124            .is_none_or(|s| s.contains(verifier_did))
125    }
126
127    /// Whether a credential of `credential_type` may be presented.
128    pub fn allows_presentable_type(&self, credential_type: &str) -> bool {
129        self.presentable_types
130            .as_ref()
131            .is_none_or(|s| s.contains(credential_type))
132    }
133
134    /// Whether the signing oracle may be invoked on `key_id`.
135    pub fn allows_signing_key(&self, key_id: &str) -> bool {
136        self.signable_keys
137            .as_ref()
138            .is_none_or(|s| s.contains(key_id))
139    }
140
141    /// Whether sealed-transfer export is permitted.
142    pub fn allows_export(&self) -> bool {
143        self.export_allowed
144    }
145
146    /// The daily ceiling for `operation_class`, if any.
147    pub fn quota_for(&self, operation_class: &str) -> Option<u64> {
148        self.quotas
149            .as_ref()
150            .and_then(|q| q.per_day.get(operation_class).copied())
151    }
152}
153
154/// Intersect two optional allow-lists. `None` = no constraint at that level, so
155/// the other level's constraint (if any) carries through unchanged; two
156/// constraints intersect.
157fn narrow_set(
158    ancestor: &Option<BTreeSet<String>>,
159    child: &Option<BTreeSet<String>>,
160) -> Option<BTreeSet<String>> {
161    match (ancestor, child) {
162        (None, None) => None,
163        (Some(s), None) | (None, Some(s)) => Some(s.clone()),
164        (Some(a), Some(c)) => Some(a.intersection(c).cloned().collect()),
165    }
166}
167
168/// Merge two optional quota maps: union of operation classes, taking the
169/// stricter (minimum) ceiling where both constrain the same class.
170fn narrow_quotas(ancestor: &Option<Quotas>, child: &Option<Quotas>) -> Option<Quotas> {
171    match (ancestor, child) {
172        (None, None) => None,
173        (Some(q), None) | (None, Some(q)) => Some(q.clone()),
174        (Some(a), Some(c)) => {
175            let mut per_day = a.per_day.clone();
176            for (op, &limit) in &c.per_day {
177                per_day
178                    .entry(op.clone())
179                    .and_modify(|existing| *existing = (*existing).min(limit))
180                    .or_insert(limit);
181            }
182            Some(Quotas { per_day })
183        }
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    fn set(items: &[&str]) -> BTreeSet<String> {
192        items.iter().map(|s| s.to_string()).collect()
193    }
194
195    #[test]
196    fn unrestricted_allows_everything() {
197        let p = ContextPolicy::unrestricted();
198        assert!(p.allows_verifier("did:key:anything"));
199        assert!(p.allows_presentable_type("AnyCredential"));
200        assert!(p.allows_signing_key("key-1"));
201        assert!(p.allows_export());
202        assert_eq!(p.quota_for("sign"), None);
203    }
204
205    #[test]
206    fn allow_list_present_is_membership() {
207        let p = ContextPolicy {
208            trusted_verifiers: Some(set(&["did:key:trusted"])),
209            ..ContextPolicy::unrestricted()
210        };
211        assert!(p.allows_verifier("did:key:trusted"));
212        assert!(!p.allows_verifier("did:key:other"));
213    }
214
215    #[test]
216    fn intersect_inherits_when_child_unset() {
217        let parent = ContextPolicy {
218            signable_keys: Some(set(&["k1", "k2"])),
219            ..ContextPolicy::unrestricted()
220        };
221        let eff = parent.intersect(&ContextPolicy::unrestricted());
222        assert_eq!(eff.signable_keys, Some(set(&["k1", "k2"])));
223    }
224
225    #[test]
226    fn intersect_adds_child_constraint() {
227        let child = ContextPolicy {
228            signable_keys: Some(set(&["k1"])),
229            ..ContextPolicy::unrestricted()
230        };
231        let eff = ContextPolicy::unrestricted().intersect(&child);
232        assert_eq!(eff.signable_keys, Some(set(&["k1"])));
233    }
234
235    #[test]
236    fn intersect_drops_unauthorized_and_cannot_widen() {
237        let parent = ContextPolicy {
238            signable_keys: Some(set(&["k1", "k2"])),
239            ..ContextPolicy::unrestricted()
240        };
241        // Child tries to allow k3 (never granted by the parent) plus k2.
242        let child = ContextPolicy {
243            signable_keys: Some(set(&["k2", "k3"])),
244            ..ContextPolicy::unrestricted()
245        };
246        let eff = parent.intersect(&child);
247        assert_eq!(eff.signable_keys, Some(set(&["k2"])));
248        assert!(!eff.allows_signing_key("k3")); // widening dropped
249        assert!(eff.allows_signing_key("k2"));
250        assert!(!eff.allows_signing_key("k1")); // child narrowed it away
251    }
252
253    #[test]
254    fn export_is_logical_and_and_cannot_be_re_enabled() {
255        let on = ContextPolicy::unrestricted();
256        let off = ContextPolicy {
257            export_allowed: false,
258            ..ContextPolicy::unrestricted()
259        };
260        assert!(on.intersect(&on).allows_export());
261        assert!(!on.intersect(&off).allows_export());
262        assert!(!off.intersect(&on).allows_export()); // descendant cannot re-enable
263    }
264
265    #[test]
266    fn quotas_take_min_and_union() {
267        let parent = ContextPolicy {
268            quotas: Some(Quotas {
269                per_day: [("sign".to_string(), 1000), ("release".to_string(), 10)].into(),
270            }),
271            ..ContextPolicy::unrestricted()
272        };
273        let child = ContextPolicy {
274            quotas: Some(Quotas {
275                per_day: [("sign".to_string(), 50), ("proxy".to_string(), 5)].into(),
276            }),
277            ..ContextPolicy::unrestricted()
278        };
279        let eff = parent.intersect(&child);
280        assert_eq!(eff.quota_for("sign"), Some(50)); // min of 1000, 50
281        assert_eq!(eff.quota_for("release"), Some(10)); // inherited from parent
282        assert_eq!(eff.quota_for("proxy"), Some(5)); // added by child
283    }
284
285    #[test]
286    fn resolve_empty_chain_is_unrestricted() {
287        assert_eq!(
288            ContextPolicy::resolve(std::iter::empty()),
289            ContextPolicy::unrestricted()
290        );
291    }
292
293    #[test]
294    fn resolve_chain_narrows_monotonically() {
295        let root = ContextPolicy {
296            presentable_types: Some(set(&["A", "B", "C"])),
297            ..ContextPolicy::unrestricted()
298        };
299        let mid = ContextPolicy {
300            presentable_types: Some(set(&["B", "C"])),
301            export_allowed: false,
302            ..ContextPolicy::unrestricted()
303        };
304        let leaf = ContextPolicy {
305            presentable_types: Some(set(&["C", "D"])),
306            ..ContextPolicy::unrestricted()
307        };
308        let eff = ContextPolicy::resolve([&root, &mid, &leaf]);
309        assert_eq!(eff.presentable_types, Some(set(&["C"]))); // D never authorized upstream
310        assert!(!eff.allows_export()); // mid disabled; leaf can't re-enable
311    }
312
313    #[test]
314    fn intersect_is_subset_of_each_input() {
315        let a = ContextPolicy {
316            signable_keys: Some(set(&["k1", "k2", "k3"])),
317            ..ContextPolicy::unrestricted()
318        };
319        let b = ContextPolicy {
320            signable_keys: Some(set(&["k2", "k3", "k4"])),
321            ..ContextPolicy::unrestricted()
322        };
323        let r = a.intersect(&b).signable_keys.unwrap();
324        assert!(r.is_subset(&set(&["k1", "k2", "k3"])));
325        assert!(r.is_subset(&set(&["k2", "k3", "k4"])));
326    }
327
328    #[test]
329    fn serde_empty_is_unrestricted_and_roundtrips() {
330        // Backward-compat: an empty object (no policy fields) = unrestricted.
331        let p: ContextPolicy = serde_json::from_str("{}").unwrap();
332        assert_eq!(p, ContextPolicy::unrestricted());
333
334        let full = ContextPolicy {
335            trusted_verifiers: Some(set(&["did:key:v"])),
336            presentable_types: None,
337            signable_keys: Some(set(&["k1"])),
338            export_allowed: false,
339            quotas: Some(Quotas {
340                per_day: [("sign".to_string(), 10)].into(),
341            }),
342        };
343        let back: ContextPolicy =
344            serde_json::from_str(&serde_json::to_string(&full).unwrap()).unwrap();
345        assert_eq!(full, back);
346    }
347}