1use serde::{Deserialize, Serialize};
36use std::collections::{BTreeMap, BTreeSet};
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
41pub struct ContextPolicy {
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub trusted_verifiers: Option<BTreeSet<String>>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub presentable_types: Option<BTreeSet<String>>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub signable_keys: Option<BTreeSet<String>>,
51 #[serde(default = "default_true")]
54 pub export_allowed: bool,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub quotas: Option<Quotas>,
58}
59
60fn default_true() -> bool {
61 true
62}
63
64#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
66#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
67pub struct Quotas {
68 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 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 #[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 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 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 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 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 pub fn allows_export(&self) -> bool {
143 self.export_allowed
144 }
145
146 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
154fn 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
168fn 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 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")); assert!(eff.allows_signing_key("k2"));
250 assert!(!eff.allows_signing_key("k1")); }
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()); }
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)); assert_eq!(eff.quota_for("release"), Some(10)); assert_eq!(eff.quota_for("proxy"), Some(5)); }
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"]))); assert!(!eff.allows_export()); }
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 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}