Skip to main content

a1/
policy.rs

1use crate::cert::DelegationCert;
2use crate::chain::DyoloChain;
3use crate::error::A1Error;
4use crate::intent::Intent;
5
6// ── PolicyViolation ───────────────────────────────────────────────────────────
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
10pub enum PolicyViolation {
11    ChainDepthExceeded { allowed: u8, actual: usize },
12    IntentNotAllowed { intent: String, policy: String },
13    TtlExceedsMaximum { allowed_secs: u64, actual_secs: u64 },
14    SubDelegationForbidden,
15    RequiredExtensionAbsent { key: String },
16    PrincipalNotTrusted,
17}
18
19impl std::fmt::Display for PolicyViolation {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        match self {
22            Self::ChainDepthExceeded { allowed, actual } => {
23                write!(f, "chain depth {actual} exceeds policy maximum {allowed}")
24            }
25            Self::IntentNotAllowed { intent, policy } => {
26                write!(f, "intent '{intent}' not permitted under policy '{policy}'")
27            }
28            Self::TtlExceedsMaximum {
29                allowed_secs,
30                actual_secs,
31            } => write!(
32                f,
33                "cert TTL {actual_secs}s exceeds policy maximum {allowed_secs}s"
34            ),
35            Self::SubDelegationForbidden => {
36                write!(f, "sub-delegation is not permitted under this policy")
37            }
38            Self::RequiredExtensionAbsent { key } => {
39                write!(f, "required extension '{key}' is absent from cert")
40            }
41            Self::PrincipalNotTrusted => {
42                write!(f, "principal public key is not in the trusted set")
43            }
44        }
45    }
46}
47
48impl std::error::Error for PolicyViolation {}
49
50// ── CapabilitySet ─────────────────────────────────────────────────────────────
51
52/// A set of intent action prefixes that an agent is allowed to perform.
53///
54/// A `CapabilitySet` is matched prefix-first: the prefix `"trade."` allows
55/// `"trade.equity"`, `"trade.fx"`, and any other action that starts with
56/// that string. The wildcard `"*"` allows all actions.
57///
58/// # Construction
59///
60/// ```rust
61/// use a1::policy::CapabilitySet;
62///
63/// let caps = CapabilitySet::new()
64///     .allow("trade.equity")
65///     .allow("query.portfolio");
66///
67/// assert!(caps.permits("trade.equity"));
68/// assert!(!caps.permits("trade.crypto"));
69/// ```
70#[derive(Debug, Clone, Default)]
71#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
72pub struct CapabilitySet {
73    prefixes: Vec<String>,
74}
75
76impl CapabilitySet {
77    pub fn new() -> Self {
78        Self::default()
79    }
80
81    pub fn wildcard() -> Self {
82        Self {
83            prefixes: vec!["*".to_owned()],
84        }
85    }
86
87    pub fn allow(mut self, prefix: impl Into<String>) -> Self {
88        self.prefixes.push(prefix.into());
89        self
90    }
91
92    pub fn permits(&self, action: &str) -> bool {
93        self.prefixes.iter().any(|p| {
94            if p == "*" {
95                return true;
96            }
97            if p.ends_with('.') || p.ends_with('*') {
98                let stem = p.trim_end_matches(['*', '.']);
99                action.starts_with(stem)
100            } else {
101                action == p.as_str()
102            }
103        })
104    }
105
106    pub fn is_empty(&self) -> bool {
107        self.prefixes.is_empty()
108    }
109}
110
111// ── DelegationPolicy ─────────────────────────────────────────────────────────
112
113/// Declarative policy governing what a delegation chain is permitted to do.
114///
115/// Attach a `DelegationPolicy` to a [`DyoloChain::authorize_with_policy`]
116/// call to enforce enterprise-grade guardrails on top of the cryptographic
117/// chain validation.
118///
119/// Policies compose: build a base policy and layer environment-specific
120/// restrictions on top without modifying the base.
121///
122/// # Example
123///
124/// ```rust
125/// use a1::policy::{DelegationPolicy, CapabilitySet};
126///
127/// let policy = DelegationPolicy::new("fintech-trading")
128///     .max_chain_depth(3)
129///     .max_ttl_secs(3600)
130///     .capabilities(
131///         CapabilitySet::new()
132///             .allow("trade.equity")
133///             .allow("query.portfolio")
134///     )
135///     .forbid_sub_delegation();
136/// ```
137#[derive(Debug, Clone)]
138#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
139pub struct DelegationPolicy {
140    name: String,
141    max_chain_depth: Option<u8>,
142    max_ttl_secs: Option<u64>,
143    capabilities: Option<CapabilitySet>,
144    allow_sub_delegation: bool,
145    required_extensions: Vec<String>,
146    trusted_principal_pks: Vec<[u8; 32]>,
147}
148
149impl DelegationPolicy {
150    pub fn new(name: impl Into<String>) -> Self {
151        Self {
152            name: name.into(),
153            max_chain_depth: None,
154            max_ttl_secs: None,
155            capabilities: None,
156            allow_sub_delegation: true,
157            required_extensions: Vec::new(),
158            trusted_principal_pks: Vec::new(),
159        }
160    }
161
162    pub fn permissive() -> Self {
163        Self::new("permissive")
164    }
165
166    pub fn max_chain_depth(mut self, depth: u8) -> Self {
167        self.max_chain_depth = Some(depth);
168        self
169    }
170
171    pub fn max_ttl_secs(mut self, secs: u64) -> Self {
172        self.max_ttl_secs = Some(secs);
173        self
174    }
175
176    pub fn capabilities(mut self, caps: CapabilitySet) -> Self {
177        self.capabilities = Some(caps);
178        self
179    }
180
181    pub fn forbid_sub_delegation(mut self) -> Self {
182        self.allow_sub_delegation = false;
183        self
184    }
185
186    pub fn require_extension(mut self, key: impl Into<String>) -> Self {
187        self.required_extensions.push(key.into());
188        self
189    }
190
191    pub fn trust_principal(mut self, pk_bytes: [u8; 32]) -> Self {
192        self.trusted_principal_pks.push(pk_bytes);
193        self
194    }
195
196    pub fn name(&self) -> &str {
197        &self.name
198    }
199
200    pub fn check_chain(&self, chain: &DyoloChain) -> Result<(), PolicyViolation> {
201        if !self.trusted_principal_pks.is_empty() {
202            let pk = chain.principal_pk.as_bytes();
203            let trusted = self.trusted_principal_pks.iter().any(|t| t == pk);
204            if !trusted {
205                return Err(PolicyViolation::PrincipalNotTrusted);
206            }
207        }
208
209        if let Some(max_depth) = self.max_chain_depth {
210            if chain.len() > max_depth as usize {
211                return Err(PolicyViolation::ChainDepthExceeded {
212                    allowed: max_depth,
213                    actual: chain.len(),
214                });
215            }
216        }
217
218        for cert in chain.certs() {
219            self.check_cert(cert)?;
220        }
221
222        Ok(())
223    }
224
225    pub fn check_cert(&self, cert: &DelegationCert) -> Result<(), PolicyViolation> {
226        if let Some(max_ttl) = self.max_ttl_secs {
227            let ttl = cert.expiration_unix.saturating_sub(cert.issued_at);
228            if ttl > max_ttl {
229                return Err(PolicyViolation::TtlExceedsMaximum {
230                    allowed_secs: max_ttl,
231                    actual_secs: ttl,
232                });
233            }
234        }
235
236        if !self.allow_sub_delegation {
237            let has_sub =
238                !cert.scope_proof.subset_intents.is_empty() || !cert.scope_proof.proofs.is_empty();
239            if has_sub && cert.max_depth > 0 {
240                return Err(PolicyViolation::SubDelegationForbidden);
241            }
242        }
243
244        #[cfg(feature = "wire")]
245        {
246            for key in &self.required_extensions {
247                if cert.extensions.get(key).is_none() {
248                    return Err(PolicyViolation::RequiredExtensionAbsent { key: key.clone() });
249                }
250            }
251        }
252        #[cfg(not(feature = "wire"))]
253        {
254            if !self.required_extensions.is_empty() && cert.extensions_hash.is_none() {
255                return Err(PolicyViolation::RequiredExtensionAbsent {
256                    key: "extensions missing (compile with wire feature to inspect)".into(),
257                });
258            }
259        }
260
261        Ok(())
262    }
263
264    pub fn check_intent(&self, intent: &Intent) -> Result<(), PolicyViolation> {
265        if let Some(caps) = &self.capabilities {
266            if !caps.permits(&intent.action) {
267                return Err(PolicyViolation::IntentNotAllowed {
268                    intent: intent.action.clone(),
269                    policy: self.name.clone(),
270                });
271            }
272        }
273        Ok(())
274    }
275}
276
277// ── PolicySet ─────────────────────────────────────────────────────────────────
278
279/// An ordered list of policies evaluated left-to-right.
280///
281/// The first policy that produces a `PolicyViolation` short-circuits
282/// evaluation. All policies must pass for the chain to be accepted.
283///
284/// Use `PolicySet` when a deployment has multiple policy layers —
285/// for example a base org policy composed with a team-specific restriction.
286#[derive(Debug, Default)]
287pub struct PolicySet {
288    policies: Vec<DelegationPolicy>,
289}
290
291impl PolicySet {
292    pub fn new() -> Self {
293        Self::default()
294    }
295
296    #[allow(clippy::should_implement_trait)]
297    pub fn add(mut self, policy: DelegationPolicy) -> Self {
298        self.policies.push(policy);
299        self
300    }
301
302    pub fn check_chain(&self, chain: &DyoloChain) -> Result<(), A1Error> {
303        for policy in &self.policies {
304            policy
305                .check_chain(chain)
306                .map_err(|v| A1Error::PolicyViolation(v.to_string()))?;
307        }
308        Ok(())
309    }
310
311    pub fn check_intent(&self, intent: &Intent) -> Result<(), A1Error> {
312        for policy in &self.policies {
313            policy
314                .check_intent(intent)
315                .map_err(|v| A1Error::PolicyViolation(v.to_string()))?;
316        }
317        Ok(())
318    }
319
320    /// Parse a YAML string into a `PolicySet`.
321    ///
322    /// Requires the `policy-yaml` feature.
323    #[cfg(feature = "policy-yaml")]
324    #[cfg_attr(docsrs, doc(cfg(feature = "policy-yaml")))]
325    pub fn from_yaml(yaml: &str) -> Result<Self, serde_yaml::Error> {
326        #[derive(serde::Deserialize)]
327        #[serde(untagged)]
328        enum PolicyInput {
329            Single(DelegationPolicy),
330            List(Vec<DelegationPolicy>),
331        }
332
333        let input: PolicyInput = serde_yaml::from_str(yaml)?;
334        match input {
335            PolicyInput::Single(p) => Ok(Self { policies: vec![p] }),
336            PolicyInput::List(policies) => Ok(Self { policies }),
337        }
338    }
339
340    /// Parse a JSON string into a `PolicySet`.
341    #[cfg(feature = "serde")]
342    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
343        #[derive(serde::Deserialize)]
344        #[serde(untagged)]
345        enum PolicyInput {
346            Single(DelegationPolicy),
347            List(Vec<DelegationPolicy>),
348        }
349
350        let input: PolicyInput = serde_json::from_str(json)?;
351        match input {
352            PolicyInput::Single(p) => Ok(Self { policies: vec![p] }),
353            PolicyInput::List(policies) => Ok(Self { policies }),
354        }
355    }
356
357    /// Export the policy set to a YAML string for Policy-as-Code pipelines.
358    #[cfg(feature = "policy-yaml")]
359    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
360        serde_yaml::to_string(&self.policies)
361    }
362}
363
364// ── Tests ─────────────────────────────────────────────────────────────────────
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    #[allow(deprecated)]
370    use crate::{
371        cert::CertBuilder,
372        chain::DyoloChain,
373        identity::DyoloIdentity,
374        intent::{intent_hash, IntentTree},
375    };
376
377    #[allow(deprecated)]
378    fn make_chain(depth: usize, ttl: u64) -> DyoloChain {
379        let root = DyoloIdentity::generate();
380        let scope = IntentTree::build(vec![intent_hash("trade.equity", b"")])
381            .unwrap()
382            .root();
383        let now = 1_700_000_000u64;
384        let mut chain = DyoloChain::new(root.verifying_key(), scope);
385        let mut prev = root;
386        for _ in 0..depth {
387            let next = DyoloIdentity::generate();
388            let cert = CertBuilder::new(next.verifying_key(), scope, now, now + ttl).sign(&prev);
389            chain.push(cert);
390            prev = next;
391        }
392        chain
393    }
394
395    #[test]
396    fn depth_policy_enforced() {
397        let chain = make_chain(3, 3600);
398        let policy = DelegationPolicy::new("test").max_chain_depth(2);
399        assert!(policy.check_chain(&chain).is_err());
400        let policy = DelegationPolicy::new("test").max_chain_depth(5);
401        assert!(policy.check_chain(&chain).is_ok());
402    }
403
404    #[test]
405    fn ttl_policy_enforced() {
406        let chain = make_chain(1, 7200);
407        let policy = DelegationPolicy::new("test").max_ttl_secs(3600);
408        assert!(policy.check_chain(&chain).is_err());
409    }
410
411    #[test]
412    fn capability_set_prefix_matching() {
413        let caps = CapabilitySet::new().allow("trade.").allow("query");
414        assert!(caps.permits("trade.equity"));
415        assert!(caps.permits("trade.fx"));
416        assert!(caps.permits("query"));
417        assert!(!caps.permits("admin.delete"));
418    }
419
420    #[test]
421    fn wildcard_permits_all() {
422        let caps = CapabilitySet::wildcard();
423        assert!(caps.permits("anything.at.all"));
424    }
425
426    #[test]
427    fn intent_checked_against_capabilities() {
428        let policy =
429            DelegationPolicy::new("test").capabilities(CapabilitySet::new().allow("trade.equity"));
430        let allowed = Intent::new("trade.equity").unwrap();
431        let forbidden = Intent::new("admin.delete").unwrap();
432        assert!(policy.check_intent(&allowed).is_ok());
433        assert!(policy.check_intent(&forbidden).is_err());
434    }
435}