Skip to main content

auths_policy/
expr.rs

1//! Serializable policy expression AST.
2//!
3//! This is the **wire format**. All identifiers are strings.
4//! Must be compiled to [`CompiledPolicy`](crate::compiled::CompiledPolicy) before evaluation.
5//! Compilation validates and canonicalizes all string fields.
6//!
7//! # Intentional Limitations
8//!
9//! - No closures, no function pointers, no IO
10//! - No `Value` type for open-ended JSON queries — scope predicates are first-class
11//! - Recursion bounded at compile time via depth check
12
13use serde::{Deserialize, Serialize};
14
15/// Serializable policy expression.
16///
17/// This is the **wire format** stored in JSON/TOML files. All identifiers are strings.
18/// Must be compiled to [`CompiledPolicy`](crate::compiled::CompiledPolicy) before evaluation.
19/// Compilation validates and canonicalizes all string fields.
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21#[serde(tag = "op", content = "args")]
22#[non_exhaustive]
23pub enum Expr {
24    // ── Combinators ──────────────────────────────────────────────────
25    /// Always allow.
26    True,
27    /// Always deny.
28    False,
29    /// All children must evaluate to Allow.
30    And(Vec<Expr>),
31    /// At least one child must evaluate to Allow.
32    Or(Vec<Expr>),
33    /// Invert the child's outcome.
34    Not(Box<Expr>),
35
36    // ── Capability ───────────────────────────────────────────────────
37    /// Subject must have this capability.
38    HasCapability(String),
39    /// Subject must have all listed capabilities.
40    HasAllCapabilities(Vec<String>),
41    /// Subject must have at least one of the listed capabilities.
42    HasAnyCapability(Vec<String>),
43
44    // ── Identity ─────────────────────────────────────────────────────
45    /// Issuer DID must match exactly.
46    IssuerIs(String),
47    /// Issuer DID must be in the set.
48    IssuerIn(Vec<String>),
49    /// Subject DID must match exactly.
50    SubjectIs(String),
51    /// Attestation must be delegated by this DID.
52    DelegatedBy(String),
53
54    // ── Lifecycle ────────────────────────────────────────────────────
55    /// Attestation must not be revoked.
56    NotRevoked,
57    /// Attestation must not be expired.
58    NotExpired,
59    /// Attestation must have at least this many seconds remaining.
60    ExpiresAfter(i64),
61    /// Attestation must have been issued within this many seconds.
62    IssuedWithin(i64),
63
64    // ── Role ─────────────────────────────────────────────────────────
65    /// Subject's role must match exactly.
66    RoleIs(String),
67    /// Subject's role must be in the set.
68    RoleIn(Vec<String>),
69
70    // ── Scope (typed, first-class) ───────────────────────────────────
71    /// Repository must match exactly.
72    RepoIs(String),
73    /// Repository must be in the set.
74    RepoIn(Vec<String>),
75    /// Git ref must match the glob pattern.
76    RefMatches(String),
77    /// All paths must match at least one of the glob patterns.
78    PathAllowed(Vec<String>),
79    /// Environment must match exactly.
80    EnvIs(String),
81    /// Environment must be in the set.
82    EnvIn(Vec<String>),
83
84    // ── Workload Claims ──────────────────────────────────────────────
85    /// Workload issuer DID must match exactly.
86    WorkloadIssuerIs(String),
87    /// Workload claim must equal the expected value.
88    WorkloadClaimEquals {
89        /// Claim key (alphanumeric + underscore only).
90        key: String,
91        /// Expected value.
92        value: String,
93    },
94
95    // ── Signer Type ──────────────────────────────────────────────────
96    /// Signer must be an AI agent.
97    IsAgent,
98    /// Signer must be a human.
99    IsHuman,
100    /// Signer must be a workload (CI/CD).
101    IsWorkload,
102
103    // ── Chain ────────────────────────────────────────────────────────
104    /// Delegation chain depth must not exceed this value.
105    MaxChainDepth(u32),
106
107    // ── Escape Hatch (constrained) ───────────────────────────────────
108    /// Match a flat string attribute. Keys must be alphanumeric+underscore,
109    /// max 64 chars. No dot-paths. No nested JSON. No `Value` type.
110    AttrEquals {
111        /// Attribute key (alphanumeric + underscore only).
112        key: String,
113        /// Expected value.
114        value: String,
115    },
116    /// Attribute must be one of the values.
117    AttrIn {
118        /// Attribute key (alphanumeric + underscore only).
119        key: String,
120        /// Allowed values.
121        values: Vec<String>,
122    },
123
124    // ── Assurance Level ────────────────────────────────────────────
125    /// Assurance level must be at least this level (uses `Ord` comparison).
126    MinAssurance(String),
127    /// Assurance level must match exactly.
128    AssuranceLevelIs(String),
129
130    // ── Approval Gate ─────────────────────────────────────────────
131    /// Approval gate: if inner evaluates to Allow, return RequiresApproval instead.
132    /// Transparent to Deny/Indeterminate — those pass through unchanged.
133    ApprovalGate {
134        /// The inner expression to evaluate.
135        inner: Box<Expr>,
136        /// DIDs of allowed approvers (validated at compile time).
137        approvers: Vec<String>,
138        /// Approval request TTL in seconds (default 300 = 5 minutes).
139        ttl_seconds: u64,
140        /// Approval scope: "identity" (default), "scoped", or "full".
141        scope: Option<String>,
142    },
143}
144
145impl Expr {
146    /// Create an And expression from multiple conditions.
147    pub fn and(conditions: impl IntoIterator<Item = Expr>) -> Self {
148        Expr::And(conditions.into_iter().collect())
149    }
150
151    /// Create an Or expression from multiple conditions.
152    pub fn or(conditions: impl IntoIterator<Item = Expr>) -> Self {
153        Expr::Or(conditions.into_iter().collect())
154    }
155
156    /// Create a Not expression (negation).
157    pub fn negate(expr: Expr) -> Self {
158        Expr::Not(Box::new(expr))
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn serde_true() {
168        let expr = Expr::True;
169        let json = serde_json::to_string(&expr).unwrap();
170        assert_eq!(json, r#"{"op":"True"}"#);
171        let parsed: Expr = serde_json::from_str(&json).unwrap();
172        assert_eq!(parsed, expr);
173    }
174
175    #[test]
176    fn serde_has_capability() {
177        let expr = Expr::HasCapability("sign_commit".into());
178        let json = serde_json::to_string(&expr).unwrap();
179        assert!(json.contains(r#""op":"HasCapability""#));
180        let parsed: Expr = serde_json::from_str(&json).unwrap();
181        assert_eq!(parsed, expr);
182    }
183
184    #[test]
185    fn serde_and() {
186        let expr = Expr::And(vec![Expr::NotRevoked, Expr::NotExpired]);
187        let json = serde_json::to_string(&expr).unwrap();
188        assert!(json.contains(r#""op":"And""#));
189        let parsed: Expr = serde_json::from_str(&json).unwrap();
190        assert_eq!(parsed, expr);
191    }
192
193    #[test]
194    fn serde_not() {
195        let expr = Expr::Not(Box::new(Expr::HasCapability("admin".into())));
196        let json = serde_json::to_string(&expr).unwrap();
197        assert!(json.contains(r#""op":"Not""#));
198        let parsed: Expr = serde_json::from_str(&json).unwrap();
199        assert_eq!(parsed, expr);
200    }
201
202    #[test]
203    fn serde_issuer_in() {
204        let expr = Expr::IssuerIn(vec!["did:keri:E1".into(), "did:keri:E2".into()]);
205        let json = serde_json::to_string(&expr).unwrap();
206        let parsed: Expr = serde_json::from_str(&json).unwrap();
207        assert_eq!(parsed, expr);
208    }
209
210    #[test]
211    fn serde_ref_matches() {
212        let expr = Expr::RefMatches("refs/heads/*".into());
213        let json = serde_json::to_string(&expr).unwrap();
214        let parsed: Expr = serde_json::from_str(&json).unwrap();
215        assert_eq!(parsed, expr);
216    }
217
218    #[test]
219    fn serde_workload_claim_equals() {
220        let expr = Expr::WorkloadClaimEquals {
221            key: "repo".into(),
222            value: "my-org/my-repo".into(),
223        };
224        let json = serde_json::to_string(&expr).unwrap();
225        assert!(json.contains("WorkloadClaimEquals"));
226        let parsed: Expr = serde_json::from_str(&json).unwrap();
227        assert_eq!(parsed, expr);
228    }
229
230    #[test]
231    fn serde_attr_equals() {
232        let expr = Expr::AttrEquals {
233            key: "team".into(),
234            value: "platform".into(),
235        };
236        let json = serde_json::to_string(&expr).unwrap();
237        let parsed: Expr = serde_json::from_str(&json).unwrap();
238        assert_eq!(parsed, expr);
239    }
240
241    #[test]
242    fn serde_attr_in() {
243        let expr = Expr::AttrIn {
244            key: "team".into(),
245            values: vec!["platform".into(), "security".into()],
246        };
247        let json = serde_json::to_string(&expr).unwrap();
248        let parsed: Expr = serde_json::from_str(&json).unwrap();
249        assert_eq!(parsed, expr);
250    }
251
252    #[test]
253    fn serde_complex_nested() {
254        let expr = Expr::And(vec![
255            Expr::NotRevoked,
256            Expr::NotExpired,
257            Expr::Or(vec![
258                Expr::HasCapability("admin".into()),
259                Expr::And(vec![
260                    Expr::HasCapability("write".into()),
261                    Expr::RepoIs("my-org/my-repo".into()),
262                ]),
263            ]),
264        ]);
265        let json = serde_json::to_string(&expr).unwrap();
266        let parsed: Expr = serde_json::from_str(&json).unwrap();
267        assert_eq!(parsed, expr);
268    }
269
270    #[test]
271    fn helper_and() {
272        let expr = Expr::and([Expr::True, Expr::False]);
273        match expr {
274            Expr::And(children) => assert_eq!(children.len(), 2),
275            _ => panic!("expected And"),
276        }
277    }
278
279    #[test]
280    fn helper_or() {
281        let expr = Expr::or([Expr::True, Expr::False]);
282        match expr {
283            Expr::Or(children) => assert_eq!(children.len(), 2),
284            _ => panic!("expected Or"),
285        }
286    }
287
288    #[test]
289    fn helper_negate() {
290        let expr = Expr::negate(Expr::True);
291        match expr {
292            Expr::Not(inner) => assert_eq!(*inner, Expr::True),
293            _ => panic!("expected Not"),
294        }
295    }
296
297    #[test]
298    fn serde_all_variants() {
299        // Test that all variants can be serialized and deserialized
300        let variants = vec![
301            Expr::True,
302            Expr::False,
303            Expr::And(vec![]),
304            Expr::Or(vec![]),
305            Expr::Not(Box::new(Expr::True)),
306            Expr::HasCapability("cap".into()),
307            Expr::HasAllCapabilities(vec!["a".into(), "b".into()]),
308            Expr::HasAnyCapability(vec!["a".into(), "b".into()]),
309            Expr::IssuerIs("did:keri:E1".into()),
310            Expr::IssuerIn(vec!["did:keri:E1".into()]),
311            Expr::SubjectIs("did:keri:E1".into()),
312            Expr::DelegatedBy("did:keri:E1".into()),
313            Expr::NotRevoked,
314            Expr::NotExpired,
315            Expr::ExpiresAfter(3600),
316            Expr::IssuedWithin(86400),
317            Expr::RoleIs("admin".into()),
318            Expr::RoleIn(vec!["admin".into(), "user".into()]),
319            Expr::RepoIs("org/repo".into()),
320            Expr::RepoIn(vec!["org/repo".into()]),
321            Expr::RefMatches("refs/heads/*".into()),
322            Expr::PathAllowed(vec!["src/**".into()]),
323            Expr::EnvIs("production".into()),
324            Expr::EnvIn(vec!["staging".into(), "production".into()]),
325            Expr::WorkloadIssuerIs("did:keri:E1".into()),
326            Expr::WorkloadClaimEquals {
327                key: "k".into(),
328                value: "v".into(),
329            },
330            Expr::IsAgent,
331            Expr::IsHuman,
332            Expr::IsWorkload,
333            Expr::MaxChainDepth(3),
334            Expr::AttrEquals {
335                key: "k".into(),
336                value: "v".into(),
337            },
338            Expr::AttrIn {
339                key: "k".into(),
340                values: vec!["v1".into(), "v2".into()],
341            },
342            Expr::MinAssurance("authenticated".into()),
343            Expr::AssuranceLevelIs("sovereign".into()),
344            Expr::ApprovalGate {
345                inner: Box::new(Expr::HasCapability("deploy".into())),
346                approvers: vec!["did:keri:EHuman123".into()],
347                ttl_seconds: 300,
348                scope: Some("identity".into()),
349            },
350        ];
351
352        for expr in variants {
353            let json = serde_json::to_string(&expr).unwrap();
354            let parsed: Expr = serde_json::from_str(&json).unwrap();
355            assert_eq!(parsed, expr, "roundtrip failed for {:?}", expr);
356        }
357    }
358}