Skip to main content

auths_policy/
compiled.rs

1//! Compiled policy expression — validated, canonical, ready to evaluate.
2//!
3//! This is what `evaluate` actually runs against. Every string has been validated
4//! and canonicalized. Constructed only via [`compile`](crate::compile::compile).
5
6use serde::{Deserialize, Serialize};
7
8use crate::types::{AssuranceLevel, CanonicalCapability, CanonicalDid, ValidatedGlob};
9
10/// Compiled policy expression — validated, canonical, ready to evaluate.
11///
12/// Constructed only via [`compile`](crate::compile::compile). Cannot be built directly.
13/// All string fields have been parsed into canonical typed forms.
14#[derive(Debug, Clone, PartialEq)]
15#[non_exhaustive]
16pub enum CompiledExpr {
17    /// Always allow.
18    True,
19    /// Always deny.
20    False,
21    /// All children must evaluate to Allow.
22    And(Vec<CompiledExpr>),
23    /// At least one child must evaluate to Allow.
24    Or(Vec<CompiledExpr>),
25    /// Invert the child's outcome.
26    Not(Box<CompiledExpr>),
27
28    /// Subject must have this capability.
29    HasCapability(CanonicalCapability),
30    /// Subject must have all listed capabilities.
31    HasAllCapabilities(Vec<CanonicalCapability>),
32    /// Subject must have at least one of the listed capabilities.
33    HasAnyCapability(Vec<CanonicalCapability>),
34
35    /// Issuer DID must match exactly.
36    IssuerIs(CanonicalDid),
37    /// Issuer DID must be in the set.
38    IssuerIn(Vec<CanonicalDid>),
39    /// Subject DID must match exactly.
40    SubjectIs(CanonicalDid),
41    /// Attestation must be delegated by this DID.
42    DelegatedBy(CanonicalDid),
43
44    /// Attestation must not be revoked.
45    NotRevoked,
46    /// Attestation must not be expired.
47    NotExpired,
48    /// Attestation must have at least this many seconds remaining.
49    ExpiresAfter(i64),
50    /// Attestation must have been issued within this many seconds.
51    IssuedWithin(i64),
52
53    /// Subject's role must match exactly.
54    RoleIs(String),
55    /// Subject's role must be in the set.
56    RoleIn(Vec<String>),
57
58    /// Repository must match exactly.
59    RepoIs(String),
60    /// Repository must be in the set.
61    RepoIn(Vec<String>),
62    /// Git ref must match the glob pattern.
63    RefMatches(ValidatedGlob),
64    /// All paths must match at least one of the glob patterns.
65    PathAllowed(Vec<ValidatedGlob>),
66    /// Environment must match exactly.
67    EnvIs(String),
68    /// Environment must be in the set.
69    EnvIn(Vec<String>),
70
71    /// Workload issuer DID must match exactly.
72    WorkloadIssuerIs(CanonicalDid),
73    /// Workload claim must equal the expected value.
74    WorkloadClaimEquals {
75        /// Claim key.
76        key: String,
77        /// Expected value.
78        value: String,
79    },
80
81    /// Signer must be an AI agent.
82    IsAgent,
83    /// Signer must be a human.
84    IsHuman,
85    /// Signer must be a workload (CI/CD).
86    IsWorkload,
87
88    /// Delegation chain depth must not exceed this value.
89    MaxChainDepth(u32),
90
91    /// Match a flat string attribute.
92    AttrEquals {
93        /// Attribute key.
94        key: String,
95        /// Expected value.
96        value: String,
97    },
98    /// Attribute must be one of the values.
99    AttrIn {
100        /// Attribute key.
101        key: String,
102        /// Allowed values.
103        values: Vec<String>,
104    },
105
106    /// Assurance level must be at least this level (uses `Ord` comparison).
107    MinAssurance(AssuranceLevel),
108    /// Assurance level must match exactly.
109    AssuranceLevelIs(AssuranceLevel),
110
111    /// Approval gate: if inner evaluates to Allow, return RequiresApproval.
112    ApprovalGate {
113        /// The compiled inner expression.
114        inner: Box<CompiledExpr>,
115        /// Validated DIDs of allowed approvers.
116        approvers: Vec<CanonicalDid>,
117        /// Approval request TTL in seconds.
118        ttl_seconds: u64,
119        /// Approval scope controlling hash binding.
120        scope: ApprovalScope,
121    },
122}
123
124/// Controls which EvalContext fields are included in the approval request hash.
125#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(rename_all = "snake_case")]
127pub enum ApprovalScope {
128    /// Hash: issuer + subject + capabilities (approve the agent for the action).
129    #[default]
130    Identity,
131    /// Hash: issuer + subject + capabilities + repo + environment.
132    Scoped,
133    /// Hash: all context fields (approve the exact request).
134    Full,
135}
136
137/// A compiled policy — validated, immutable, ready for repeated evaluation.
138///
139/// Stores the compiled expression plus metadata for audit/pinning.
140#[derive(Debug, Clone)]
141pub struct CompiledPolicy {
142    expr: CompiledExpr,
143    source_hash: [u8; 32],
144}
145
146impl CompiledPolicy {
147    /// Create a new compiled policy (internal use only).
148    ///
149    /// This is `pub(crate)` to ensure policies can only be created via `compile()`.
150    pub(crate) fn new(expr: CompiledExpr, source_hash: [u8; 32]) -> Self {
151        Self { expr, source_hash }
152    }
153
154    /// Returns the compiled expression.
155    pub fn expr(&self) -> &CompiledExpr {
156        &self.expr
157    }
158
159    /// Blake3 hash of the original `Expr` JSON used to compile this policy.
160    /// Included in decision evidence for audit pinning.
161    pub fn source_hash(&self) -> &[u8; 32] {
162        &self.source_hash
163    }
164
165    /// Return a human-readable summary of the policy's requirements.
166    pub fn describe(&self) -> String {
167        describe_expr(&self.expr, 0)
168    }
169}
170
171fn describe_expr(expr: &CompiledExpr, depth: usize) -> String {
172    let indent = "  ".repeat(depth);
173    match expr {
174        CompiledExpr::True => format!("{indent}always allow"),
175        CompiledExpr::False => format!("{indent}always deny"),
176        CompiledExpr::And(children) => {
177            let parts: Vec<String> = children
178                .iter()
179                .map(|c| describe_expr(c, depth + 1))
180                .collect();
181            format!("{indent}ALL of:\n{}", parts.join("\n"))
182        }
183        CompiledExpr::Or(children) => {
184            let parts: Vec<String> = children
185                .iter()
186                .map(|c| describe_expr(c, depth + 1))
187                .collect();
188            format!("{indent}ANY of:\n{}", parts.join("\n"))
189        }
190        CompiledExpr::Not(inner) => format!("{indent}NOT:\n{}", describe_expr(inner, depth + 1)),
191        CompiledExpr::HasCapability(c) => format!("{indent}require capability: {c}"),
192        CompiledExpr::HasAllCapabilities(caps) => {
193            let names: Vec<String> = caps.iter().map(|c| c.to_string()).collect();
194            format!("{indent}require all capabilities: [{}]", names.join(", "))
195        }
196        CompiledExpr::HasAnyCapability(caps) => {
197            let names: Vec<String> = caps.iter().map(|c| c.to_string()).collect();
198            format!("{indent}require any capability: [{}]", names.join(", "))
199        }
200        CompiledExpr::IssuerIs(d) => format!("{indent}issuer must be: {d}"),
201        CompiledExpr::IssuerIn(ds) => {
202            let names: Vec<String> = ds.iter().map(|d| d.to_string()).collect();
203            format!("{indent}issuer in: [{}]", names.join(", "))
204        }
205        CompiledExpr::SubjectIs(d) => format!("{indent}subject must be: {d}"),
206        CompiledExpr::DelegatedBy(d) => format!("{indent}delegated by: {d}"),
207        CompiledExpr::NotRevoked => format!("{indent}not revoked"),
208        CompiledExpr::NotExpired => format!("{indent}not expired"),
209        CompiledExpr::ExpiresAfter(s) => format!("{indent}expires after {s}s"),
210        CompiledExpr::IssuedWithin(s) => format!("{indent}issued within {s}s"),
211        CompiledExpr::RoleIs(r) => format!("{indent}role must be: {r}"),
212        CompiledExpr::RoleIn(rs) => format!("{indent}role in: [{}]", rs.join(", ")),
213        CompiledExpr::RepoIs(r) => format!("{indent}repo must be: {r}"),
214        CompiledExpr::RepoIn(rs) => format!("{indent}repo in: [{}]", rs.join(", ")),
215        CompiledExpr::RefMatches(g) => format!("{indent}ref matches: {g}"),
216        CompiledExpr::PathAllowed(gs) => {
217            let names: Vec<String> = gs.iter().map(|g| g.to_string()).collect();
218            format!("{indent}paths allowed: [{}]", names.join(", "))
219        }
220        CompiledExpr::EnvIs(e) => format!("{indent}env must be: {e}"),
221        CompiledExpr::EnvIn(es) => format!("{indent}env in: [{}]", es.join(", ")),
222        CompiledExpr::WorkloadIssuerIs(d) => format!("{indent}workload issuer: {d}"),
223        CompiledExpr::WorkloadClaimEquals { key, value } => {
224            format!("{indent}workload claim {key} = {value}")
225        }
226        CompiledExpr::IsAgent => format!("{indent}signer is agent"),
227        CompiledExpr::IsHuman => format!("{indent}signer is human"),
228        CompiledExpr::IsWorkload => format!("{indent}signer is workload"),
229        CompiledExpr::MaxChainDepth(d) => format!("{indent}max chain depth: {d}"),
230        CompiledExpr::AttrEquals { key, value } => format!("{indent}attr {key} = {value}"),
231        CompiledExpr::AttrIn { key, values } => {
232            format!("{indent}attr {key} in: [{}]", values.join(", "))
233        }
234        CompiledExpr::MinAssurance(level) => {
235            format!("{indent}min assurance: {}", level.label())
236        }
237        CompiledExpr::AssuranceLevelIs(level) => {
238            format!("{indent}assurance must be: {}", level.label())
239        }
240        CompiledExpr::ApprovalGate {
241            approvers,
242            ttl_seconds,
243            ..
244        } => {
245            let names: Vec<String> = approvers.iter().map(|d| d.to_string()).collect();
246            format!(
247                "{indent}requires approval from [{}] (TTL: {ttl_seconds}s)",
248                names.join(", ")
249            )
250        }
251    }
252}
253
254impl PartialEq for CompiledPolicy {
255    fn eq(&self, other: &Self) -> bool {
256        self.expr == other.expr && self.source_hash == other.source_hash
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::types::{CanonicalCapability, CanonicalDid, ValidatedGlob};
264
265    #[test]
266    fn compiled_expr_true() {
267        let expr = CompiledExpr::True;
268        assert!(matches!(expr, CompiledExpr::True));
269    }
270
271    #[test]
272    fn compiled_expr_and() {
273        let expr = CompiledExpr::And(vec![CompiledExpr::True, CompiledExpr::False]);
274        match expr {
275            CompiledExpr::And(children) => assert_eq!(children.len(), 2),
276            _ => panic!("expected And"),
277        }
278    }
279
280    #[test]
281    fn compiled_expr_has_capability() {
282        let cap = CanonicalCapability::parse("sign_commit").unwrap();
283        let expr = CompiledExpr::HasCapability(cap.clone());
284        match expr {
285            CompiledExpr::HasCapability(c) => assert_eq!(c, cap),
286            _ => panic!("expected HasCapability"),
287        }
288    }
289
290    #[test]
291    fn compiled_expr_issuer_is() {
292        let did = CanonicalDid::parse("did:keri:EOrg123").unwrap();
293        let expr = CompiledExpr::IssuerIs(did.clone());
294        match expr {
295            CompiledExpr::IssuerIs(d) => assert_eq!(d, did),
296            _ => panic!("expected IssuerIs"),
297        }
298    }
299
300    #[test]
301    fn compiled_expr_ref_matches() {
302        let glob = ValidatedGlob::parse("refs/heads/*").unwrap();
303        let expr = CompiledExpr::RefMatches(glob.clone());
304        match expr {
305            CompiledExpr::RefMatches(g) => assert_eq!(g, glob),
306            _ => panic!("expected RefMatches"),
307        }
308    }
309
310    #[test]
311    fn compiled_policy_accessors() {
312        let expr = CompiledExpr::True;
313        let hash = [42u8; 32];
314        let policy = CompiledPolicy::new(expr.clone(), hash);
315
316        assert_eq!(*policy.expr(), expr);
317        assert_eq!(*policy.source_hash(), hash);
318    }
319
320    #[test]
321    fn compiled_policy_equality() {
322        let expr1 = CompiledExpr::True;
323        let expr2 = CompiledExpr::True;
324        let hash = [42u8; 32];
325
326        let policy1 = CompiledPolicy::new(expr1, hash);
327        let policy2 = CompiledPolicy::new(expr2, hash);
328
329        assert_eq!(policy1, policy2);
330    }
331
332    #[test]
333    fn compiled_policy_inequality_different_hash() {
334        let expr = CompiledExpr::True;
335        let hash1 = [42u8; 32];
336        let hash2 = [43u8; 32];
337
338        let policy1 = CompiledPolicy::new(expr.clone(), hash1);
339        let policy2 = CompiledPolicy::new(expr, hash2);
340
341        assert_ne!(policy1, policy2);
342    }
343}