use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ContextPolicy {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trusted_verifiers: Option<BTreeSet<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub presentable_types: Option<BTreeSet<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signable_keys: Option<BTreeSet<String>>,
#[serde(default = "default_true")]
pub export_allowed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quotas: Option<Quotas>,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct Quotas {
pub per_day: BTreeMap<String, u64>,
}
impl Default for ContextPolicy {
fn default() -> Self {
Self::unrestricted()
}
}
impl ContextPolicy {
pub fn unrestricted() -> Self {
Self {
trusted_verifiers: None,
presentable_types: None,
signable_keys: None,
export_allowed: true,
quotas: None,
}
}
#[must_use]
pub fn intersect(&self, child: &ContextPolicy) -> ContextPolicy {
ContextPolicy {
trusted_verifiers: narrow_set(&self.trusted_verifiers, &child.trusted_verifiers),
presentable_types: narrow_set(&self.presentable_types, &child.presentable_types),
signable_keys: narrow_set(&self.signable_keys, &child.signable_keys),
export_allowed: self.export_allowed && child.export_allowed,
quotas: narrow_quotas(&self.quotas, &child.quotas),
}
}
pub fn resolve<'a, I>(chain: I) -> ContextPolicy
where
I: IntoIterator<Item = &'a ContextPolicy>,
{
chain
.into_iter()
.fold(ContextPolicy::unrestricted(), |acc, p| acc.intersect(p))
}
pub fn allows_verifier(&self, verifier_did: &str) -> bool {
self.trusted_verifiers
.as_ref()
.is_none_or(|s| s.contains(verifier_did))
}
pub fn allows_presentable_type(&self, credential_type: &str) -> bool {
self.presentable_types
.as_ref()
.is_none_or(|s| s.contains(credential_type))
}
pub fn allows_signing_key(&self, key_id: &str) -> bool {
self.signable_keys
.as_ref()
.is_none_or(|s| s.contains(key_id))
}
pub fn allows_export(&self) -> bool {
self.export_allowed
}
pub fn quota_for(&self, operation_class: &str) -> Option<u64> {
self.quotas
.as_ref()
.and_then(|q| q.per_day.get(operation_class).copied())
}
}
fn narrow_set(
ancestor: &Option<BTreeSet<String>>,
child: &Option<BTreeSet<String>>,
) -> Option<BTreeSet<String>> {
match (ancestor, child) {
(None, None) => None,
(Some(s), None) | (None, Some(s)) => Some(s.clone()),
(Some(a), Some(c)) => Some(a.intersection(c).cloned().collect()),
}
}
fn narrow_quotas(ancestor: &Option<Quotas>, child: &Option<Quotas>) -> Option<Quotas> {
match (ancestor, child) {
(None, None) => None,
(Some(q), None) | (None, Some(q)) => Some(q.clone()),
(Some(a), Some(c)) => {
let mut per_day = a.per_day.clone();
for (op, &limit) in &c.per_day {
per_day
.entry(op.clone())
.and_modify(|existing| *existing = (*existing).min(limit))
.or_insert(limit);
}
Some(Quotas { per_day })
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn set(items: &[&str]) -> BTreeSet<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn unrestricted_allows_everything() {
let p = ContextPolicy::unrestricted();
assert!(p.allows_verifier("did:key:anything"));
assert!(p.allows_presentable_type("AnyCredential"));
assert!(p.allows_signing_key("key-1"));
assert!(p.allows_export());
assert_eq!(p.quota_for("sign"), None);
}
#[test]
fn allow_list_present_is_membership() {
let p = ContextPolicy {
trusted_verifiers: Some(set(&["did:key:trusted"])),
..ContextPolicy::unrestricted()
};
assert!(p.allows_verifier("did:key:trusted"));
assert!(!p.allows_verifier("did:key:other"));
}
#[test]
fn intersect_inherits_when_child_unset() {
let parent = ContextPolicy {
signable_keys: Some(set(&["k1", "k2"])),
..ContextPolicy::unrestricted()
};
let eff = parent.intersect(&ContextPolicy::unrestricted());
assert_eq!(eff.signable_keys, Some(set(&["k1", "k2"])));
}
#[test]
fn intersect_adds_child_constraint() {
let child = ContextPolicy {
signable_keys: Some(set(&["k1"])),
..ContextPolicy::unrestricted()
};
let eff = ContextPolicy::unrestricted().intersect(&child);
assert_eq!(eff.signable_keys, Some(set(&["k1"])));
}
#[test]
fn intersect_drops_unauthorized_and_cannot_widen() {
let parent = ContextPolicy {
signable_keys: Some(set(&["k1", "k2"])),
..ContextPolicy::unrestricted()
};
let child = ContextPolicy {
signable_keys: Some(set(&["k2", "k3"])),
..ContextPolicy::unrestricted()
};
let eff = parent.intersect(&child);
assert_eq!(eff.signable_keys, Some(set(&["k2"])));
assert!(!eff.allows_signing_key("k3")); assert!(eff.allows_signing_key("k2"));
assert!(!eff.allows_signing_key("k1")); }
#[test]
fn export_is_logical_and_and_cannot_be_re_enabled() {
let on = ContextPolicy::unrestricted();
let off = ContextPolicy {
export_allowed: false,
..ContextPolicy::unrestricted()
};
assert!(on.intersect(&on).allows_export());
assert!(!on.intersect(&off).allows_export());
assert!(!off.intersect(&on).allows_export()); }
#[test]
fn quotas_take_min_and_union() {
let parent = ContextPolicy {
quotas: Some(Quotas {
per_day: [("sign".to_string(), 1000), ("release".to_string(), 10)].into(),
}),
..ContextPolicy::unrestricted()
};
let child = ContextPolicy {
quotas: Some(Quotas {
per_day: [("sign".to_string(), 50), ("proxy".to_string(), 5)].into(),
}),
..ContextPolicy::unrestricted()
};
let eff = parent.intersect(&child);
assert_eq!(eff.quota_for("sign"), Some(50)); assert_eq!(eff.quota_for("release"), Some(10)); assert_eq!(eff.quota_for("proxy"), Some(5)); }
#[test]
fn resolve_empty_chain_is_unrestricted() {
assert_eq!(
ContextPolicy::resolve(std::iter::empty()),
ContextPolicy::unrestricted()
);
}
#[test]
fn resolve_chain_narrows_monotonically() {
let root = ContextPolicy {
presentable_types: Some(set(&["A", "B", "C"])),
..ContextPolicy::unrestricted()
};
let mid = ContextPolicy {
presentable_types: Some(set(&["B", "C"])),
export_allowed: false,
..ContextPolicy::unrestricted()
};
let leaf = ContextPolicy {
presentable_types: Some(set(&["C", "D"])),
..ContextPolicy::unrestricted()
};
let eff = ContextPolicy::resolve([&root, &mid, &leaf]);
assert_eq!(eff.presentable_types, Some(set(&["C"]))); assert!(!eff.allows_export()); }
#[test]
fn intersect_is_subset_of_each_input() {
let a = ContextPolicy {
signable_keys: Some(set(&["k1", "k2", "k3"])),
..ContextPolicy::unrestricted()
};
let b = ContextPolicy {
signable_keys: Some(set(&["k2", "k3", "k4"])),
..ContextPolicy::unrestricted()
};
let r = a.intersect(&b).signable_keys.unwrap();
assert!(r.is_subset(&set(&["k1", "k2", "k3"])));
assert!(r.is_subset(&set(&["k2", "k3", "k4"])));
}
#[test]
fn serde_empty_is_unrestricted_and_roundtrips() {
let p: ContextPolicy = serde_json::from_str("{}").unwrap();
assert_eq!(p, ContextPolicy::unrestricted());
let full = ContextPolicy {
trusted_verifiers: Some(set(&["did:key:v"])),
presentable_types: None,
signable_keys: Some(set(&["k1"])),
export_allowed: false,
quotas: Some(Quotas {
per_day: [("sign".to_string(), 10)].into(),
}),
};
let back: ContextPolicy =
serde_json::from_str(&serde_json::to_string(&full).unwrap()).unwrap();
assert_eq!(full, back);
}
}