Skip to main content

auths_policy/
types.rs

1//! Canonical types for policy expressions.
2//!
3//! Every string that crosses the policy boundary gets validated and canonicalised
4//! at compile time. These types ensure that invalid data cannot reach the evaluator.
5
6use std::fmt;
7
8use serde::{Deserialize, Serialize};
9
10// CanonicalDid and AssuranceLevel live in auths-verifier (Layer 1) so all shared types are co-located.
11pub use auths_verifier::types::{AssuranceLevel, AssuranceLevelParseError, CanonicalDid};
12
13/// Re-export DidParseError from auths-verifier for backwards compatibility.
14pub type DidParseError = auths_verifier::DidParseError;
15
16/// A validated capability identifier.
17///
18/// Enforces the same rules as `Capability::validate_custom`:
19/// alphanumeric + colon/hyphen/underscore, max 64 chars. Stored in canonical
20/// lowercase form.
21#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
22#[serde(try_from = "String", into = "String")]
23pub struct CanonicalCapability(String);
24
25/// Error returned when parsing a capability fails.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct CapabilityParseError(pub String);
28
29impl std::fmt::Display for CapabilityParseError {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        write!(f, "{}", self.0)
32    }
33}
34
35impl std::error::Error for CapabilityParseError {}
36
37impl CanonicalCapability {
38    /// Parse and validate a capability string into canonical form.
39    ///
40    /// # Errors
41    ///
42    /// Returns an error if the capability:
43    /// - Is empty or exceeds 64 characters
44    /// - Contains characters other than alphanumeric, colon, hyphen, or underscore
45    pub fn parse(raw: &str) -> Result<Self, CapabilityParseError> {
46        let trimmed = raw.trim();
47        if trimmed.is_empty() || trimmed.len() > 64 {
48            return Err(CapabilityParseError(format!(
49                "capability must be 1-64 chars, got {}",
50                trimmed.len()
51            )));
52        }
53        if !trimmed
54            .chars()
55            .all(|c| c.is_alphanumeric() || c == ':' || c == '-' || c == '_')
56        {
57            return Err(CapabilityParseError(format!(
58                "invalid chars in capability: '{}'",
59                trimmed
60            )));
61        }
62        // Canonical: lowercase
63        Ok(Self(trimmed.to_lowercase()))
64    }
65
66    /// Returns the canonical capability as a string slice.
67    pub fn as_str(&self) -> &str {
68        &self.0
69    }
70}
71
72impl TryFrom<String> for CanonicalCapability {
73    type Error = CapabilityParseError;
74    fn try_from(s: String) -> Result<Self, Self::Error> {
75        Self::parse(&s)
76    }
77}
78
79impl From<CanonicalCapability> for String {
80    fn from(c: CanonicalCapability) -> Self {
81        c.0
82    }
83}
84
85impl fmt::Display for CanonicalCapability {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        f.write_str(&self.0)
88    }
89}
90
91/// The type of entity that produced a signature.
92///
93/// Used to distinguish human, AI agent, and workload (CI/CD) signers
94/// in policy evaluation.
95#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
96#[non_exhaustive]
97pub enum SignerType {
98    /// A human user.
99    Human,
100    /// An autonomous AI agent.
101    Agent,
102    /// A CI/CD workload or service identity.
103    Workload,
104}
105
106/// A validated glob pattern for path/ref matching.
107///
108/// Restricted to:
109/// - ASCII printable characters only
110/// - Max 256 chars
111/// - Wildcards: `*` (single segment), `**` (multi-segment)
112/// - No `..` path traversal
113/// - Segments separated by `/`
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(try_from = "String", into = "String")]
116pub struct ValidatedGlob(String);
117
118/// Error returned when parsing a glob pattern fails.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct GlobParseError(pub String);
121
122impl std::fmt::Display for GlobParseError {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        write!(f, "{}", self.0)
125    }
126}
127
128impl std::error::Error for GlobParseError {}
129
130impl ValidatedGlob {
131    /// Parse and validate a glob pattern into canonical form.
132    ///
133    /// # Errors
134    ///
135    /// Returns an error if the glob:
136    /// - Is empty or exceeds 256 characters
137    /// - Contains non-ASCII or control characters
138    /// - Contains path traversal (`..`)
139    pub fn parse(raw: &str) -> Result<Self, GlobParseError> {
140        let trimmed = raw.trim();
141        if trimmed.is_empty() || trimmed.len() > 256 {
142            return Err(GlobParseError(format!(
143                "glob must be 1-256 chars, got {}",
144                trimmed.len()
145            )));
146        }
147        if !trimmed.chars().all(|c| c.is_ascii() && !c.is_control()) {
148            return Err(GlobParseError(
149                "glob contains non-ASCII or control chars".into(),
150            ));
151        }
152        if trimmed.contains("..") {
153            return Err(GlobParseError("glob contains path traversal (..)".into()));
154        }
155        // Normalise consecutive slashes
156        let normalised: String = trimmed
157            .split('/')
158            .filter(|s| !s.is_empty())
159            .collect::<Vec<_>>()
160            .join("/");
161        Ok(Self(normalised))
162    }
163
164    /// Returns the normalised glob pattern as a string slice.
165    pub fn as_str(&self) -> &str {
166        &self.0
167    }
168}
169
170impl TryFrom<String> for ValidatedGlob {
171    type Error = GlobParseError;
172    fn try_from(s: String) -> Result<Self, Self::Error> {
173        Self::parse(&s)
174    }
175}
176
177impl From<ValidatedGlob> for String {
178    fn from(g: ValidatedGlob) -> Self {
179        g.0
180    }
181}
182
183impl fmt::Display for ValidatedGlob {
184    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
185        f.write_str(&self.0)
186    }
187}
188
189/// Evaluates quorum requirements across multiple signers.
190///
191/// This operates at a higher level than `Expr` (which evaluates a single
192/// `EvalContext`). A `QuorumPolicy` aggregates the results of per-signer
193/// policy evaluations and enforces typed signer count thresholds.
194///
195/// Usage:
196/// ```ignore
197/// let quorum = QuorumPolicy {
198///     required_humans: 1,
199///     required_agents: 1,
200///     required_total: 2,
201///     base_expression: Expr::And(vec![Expr::NotRevoked, Expr::NotExpired]),
202/// };
203/// let approved = quorum.evaluate(&contexts, |expr, ctx| evaluator(expr, ctx));
204/// ```
205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct QuorumPolicy {
207    /// Minimum number of human signers required.
208    pub required_humans: u32,
209    /// Minimum number of agent signers required.
210    pub required_agents: u32,
211    /// Minimum total signers required (human + agent + workload).
212    pub required_total: u32,
213    /// Each signer must also pass this expression.
214    pub base_expression: crate::expr::Expr,
215}
216
217impl QuorumPolicy {
218    /// Evaluate the quorum against a set of signer contexts.
219    ///
220    /// Args:
221    /// * `contexts`: The evaluation contexts for each signer.
222    /// * `eval_fn`: A function that evaluates the base expression against a context.
223    ///   Returns `true` if the signer passes the base policy.
224    pub fn evaluate<F>(&self, contexts: &[crate::context::EvalContext], eval_fn: F) -> bool
225    where
226        F: Fn(&crate::expr::Expr, &crate::context::EvalContext) -> bool,
227    {
228        let mut human_count: u32 = 0;
229        let mut agent_count: u32 = 0;
230        let mut total_count: u32 = 0;
231
232        for ctx in contexts {
233            if eval_fn(&self.base_expression, ctx) {
234                total_count += 1;
235                match ctx.signer_type {
236                    Some(SignerType::Human) => human_count += 1,
237                    Some(SignerType::Agent) => agent_count += 1,
238                    Some(SignerType::Workload) | None => {}
239                }
240            }
241        }
242
243        human_count >= self.required_humans
244            && agent_count >= self.required_agents
245            && total_count >= self.required_total
246    }
247}
248
249#[cfg(test)]
250#[allow(clippy::disallowed_methods)]
251mod tests {
252    use super::*;
253
254    mod canonical_did {
255        use super::*;
256
257        #[test]
258        fn parses_valid_did() {
259            let did = CanonicalDid::parse("did:keri:EOrg123").unwrap();
260            assert_eq!(did.as_str(), "did:keri:EOrg123");
261        }
262
263        #[test]
264        fn lowercases_method() {
265            let did = CanonicalDid::parse("did:KERI:EOrg123").unwrap();
266            assert_eq!(did.as_str(), "did:keri:EOrg123");
267        }
268
269        #[test]
270        fn preserves_id_case() {
271            let did = CanonicalDid::parse("did:key:zABC123XYZ").unwrap();
272            assert_eq!(did.as_str(), "did:key:zABC123XYZ");
273        }
274
275        #[test]
276        fn trims_whitespace() {
277            let did = CanonicalDid::parse("  did:keri:EOrg123  ").unwrap();
278            assert_eq!(did.as_str(), "did:keri:EOrg123");
279        }
280
281        #[test]
282        fn rejects_empty() {
283            assert!(CanonicalDid::parse("").is_err());
284            assert!(CanonicalDid::parse("   ").is_err());
285        }
286
287        #[test]
288        fn rejects_missing_parts() {
289            assert!(CanonicalDid::parse("did").is_err());
290            assert!(CanonicalDid::parse("did:keri").is_err());
291            assert!(CanonicalDid::parse("did::id").is_err());
292            assert!(CanonicalDid::parse("did:keri:").is_err());
293        }
294
295        #[test]
296        fn rejects_wrong_prefix() {
297            assert!(CanonicalDid::parse("uri:keri:id").is_err());
298        }
299
300        #[test]
301        fn rejects_control_chars() {
302            assert!(CanonicalDid::parse("did:keri:id\x00").is_err());
303            assert!(CanonicalDid::parse("did:keri:id\n").is_err());
304        }
305
306        #[test]
307        fn serde_roundtrip() {
308            let did = CanonicalDid::parse("did:keri:EOrg123").unwrap();
309            let json = serde_json::to_string(&did).unwrap();
310            let parsed: CanonicalDid = serde_json::from_str(&json).unwrap();
311            assert_eq!(did, parsed);
312        }
313    }
314
315    mod canonical_capability {
316        use super::*;
317
318        #[test]
319        fn parses_valid_capability() {
320            let cap = CanonicalCapability::parse("sign_commit").unwrap();
321            assert_eq!(cap.as_str(), "sign_commit");
322        }
323
324        #[test]
325        fn lowercases() {
326            let cap = CanonicalCapability::parse("Sign_Commit").unwrap();
327            assert_eq!(cap.as_str(), "sign_commit");
328        }
329
330        #[test]
331        fn allows_colons_and_hyphens() {
332            let cap = CanonicalCapability::parse("repo:read-write").unwrap();
333            assert_eq!(cap.as_str(), "repo:read-write");
334        }
335
336        #[test]
337        fn trims_whitespace() {
338            let cap = CanonicalCapability::parse("  sign_commit  ").unwrap();
339            assert_eq!(cap.as_str(), "sign_commit");
340        }
341
342        #[test]
343        fn rejects_empty() {
344            assert!(CanonicalCapability::parse("").is_err());
345        }
346
347        #[test]
348        fn rejects_too_long() {
349            let long = "a".repeat(65);
350            assert!(CanonicalCapability::parse(&long).is_err());
351        }
352
353        #[test]
354        fn accepts_max_length() {
355            let max = "a".repeat(64);
356            assert!(CanonicalCapability::parse(&max).is_ok());
357        }
358
359        #[test]
360        fn rejects_invalid_chars() {
361            assert!(CanonicalCapability::parse("sign commit").is_err()); // space
362            assert!(CanonicalCapability::parse("sign.commit").is_err()); // dot
363            assert!(CanonicalCapability::parse("sign/commit").is_err()); // slash
364        }
365
366        #[test]
367        fn serde_roundtrip() {
368            let cap = CanonicalCapability::parse("sign_commit").unwrap();
369            let json = serde_json::to_string(&cap).unwrap();
370            let parsed: CanonicalCapability = serde_json::from_str(&json).unwrap();
371            assert_eq!(cap, parsed);
372        }
373    }
374
375    mod validated_glob {
376        use super::*;
377
378        #[test]
379        fn parses_simple_path() {
380            let glob = ValidatedGlob::parse("refs/heads/main").unwrap();
381            assert_eq!(glob.as_str(), "refs/heads/main");
382        }
383
384        #[test]
385        fn parses_wildcards() {
386            let glob = ValidatedGlob::parse("refs/heads/*").unwrap();
387            assert_eq!(glob.as_str(), "refs/heads/*");
388
389            let glob = ValidatedGlob::parse("refs/**/main").unwrap();
390            assert_eq!(glob.as_str(), "refs/**/main");
391        }
392
393        #[test]
394        fn normalises_consecutive_slashes() {
395            let glob = ValidatedGlob::parse("refs//heads///main").unwrap();
396            assert_eq!(glob.as_str(), "refs/heads/main");
397        }
398
399        #[test]
400        fn strips_leading_trailing_slashes() {
401            let glob = ValidatedGlob::parse("/refs/heads/main/").unwrap();
402            assert_eq!(glob.as_str(), "refs/heads/main");
403        }
404
405        #[test]
406        fn trims_whitespace() {
407            let glob = ValidatedGlob::parse("  refs/heads/main  ").unwrap();
408            assert_eq!(glob.as_str(), "refs/heads/main");
409        }
410
411        #[test]
412        fn rejects_empty() {
413            assert!(ValidatedGlob::parse("").is_err());
414        }
415
416        #[test]
417        fn rejects_too_long() {
418            let long = "a/".repeat(129); // 258 chars
419            assert!(ValidatedGlob::parse(&long).is_err());
420        }
421
422        #[test]
423        fn rejects_path_traversal() {
424            assert!(ValidatedGlob::parse("refs/../secrets").is_err());
425            assert!(ValidatedGlob::parse("..").is_err());
426            assert!(ValidatedGlob::parse("foo/..").is_err());
427        }
428
429        #[test]
430        fn rejects_non_ascii() {
431            assert!(ValidatedGlob::parse("refs/héads/main").is_err());
432        }
433
434        #[test]
435        fn rejects_control_chars() {
436            assert!(ValidatedGlob::parse("refs/heads/main\x00").is_err());
437        }
438
439        #[test]
440        fn serde_roundtrip() {
441            let glob = ValidatedGlob::parse("refs/heads/*").unwrap();
442            let json = serde_json::to_string(&glob).unwrap();
443            let parsed: ValidatedGlob = serde_json::from_str(&json).unwrap();
444            assert_eq!(glob, parsed);
445        }
446    }
447
448    mod signer_type {
449        use super::*;
450
451        #[test]
452        fn serde_roundtrip() {
453            for st in [SignerType::Human, SignerType::Agent, SignerType::Workload] {
454                let json = serde_json::to_string(&st).unwrap();
455                let parsed: SignerType = serde_json::from_str(&json).unwrap();
456                assert_eq!(st, parsed);
457            }
458        }
459
460        #[test]
461        fn equality() {
462            assert_eq!(SignerType::Human, SignerType::Human);
463            assert_ne!(SignerType::Human, SignerType::Agent);
464            assert_ne!(SignerType::Agent, SignerType::Workload);
465        }
466    }
467
468    mod quorum_policy {
469        use super::*;
470        use crate::context::EvalContext;
471        use crate::expr::Expr;
472        use chrono::Utc;
473
474        fn did(s: &str) -> CanonicalDid {
475            CanonicalDid::parse(s).unwrap()
476        }
477
478        fn make_ctx(signer_type: SignerType) -> EvalContext {
479            EvalContext::new(Utc::now(), did("did:keri:issuer"), did("did:keri:subject"))
480                .signer_type(signer_type)
481        }
482
483        fn always_pass(_expr: &Expr, _ctx: &EvalContext) -> bool {
484            true
485        }
486
487        fn always_fail(_expr: &Expr, _ctx: &EvalContext) -> bool {
488            false
489        }
490
491        #[test]
492        fn quorum_met_with_mixed_signers() {
493            let quorum = QuorumPolicy {
494                required_humans: 1,
495                required_agents: 1,
496                required_total: 2,
497                base_expression: Expr::True,
498            };
499            let contexts = vec![make_ctx(SignerType::Human), make_ctx(SignerType::Agent)];
500            assert!(quorum.evaluate(&contexts, always_pass));
501        }
502
503        #[test]
504        fn quorum_not_met_missing_human() {
505            let quorum = QuorumPolicy {
506                required_humans: 1,
507                required_agents: 1,
508                required_total: 2,
509                base_expression: Expr::True,
510            };
511            let contexts = vec![make_ctx(SignerType::Agent), make_ctx(SignerType::Agent)];
512            assert!(!quorum.evaluate(&contexts, always_pass));
513        }
514
515        #[test]
516        fn quorum_not_met_base_expression_fails() {
517            let quorum = QuorumPolicy {
518                required_humans: 1,
519                required_agents: 0,
520                required_total: 1,
521                base_expression: Expr::True,
522            };
523            let contexts = vec![make_ctx(SignerType::Human)];
524            assert!(!quorum.evaluate(&contexts, always_fail));
525        }
526
527        #[test]
528        fn quorum_empty_contexts() {
529            let quorum = QuorumPolicy {
530                required_humans: 0,
531                required_agents: 0,
532                required_total: 0,
533                base_expression: Expr::True,
534            };
535            assert!(quorum.evaluate(&[], always_pass));
536        }
537
538        #[test]
539        fn quorum_total_threshold() {
540            let quorum = QuorumPolicy {
541                required_humans: 0,
542                required_agents: 0,
543                required_total: 3,
544                base_expression: Expr::True,
545            };
546            let contexts = vec![make_ctx(SignerType::Human), make_ctx(SignerType::Agent)];
547            assert!(!quorum.evaluate(&contexts, always_pass));
548
549            let contexts = vec![
550                make_ctx(SignerType::Human),
551                make_ctx(SignerType::Agent),
552                make_ctx(SignerType::Workload),
553            ];
554            assert!(quorum.evaluate(&contexts, always_pass));
555        }
556
557        #[test]
558        fn serde_roundtrip() {
559            let quorum = QuorumPolicy {
560                required_humans: 1,
561                required_agents: 1,
562                required_total: 2,
563                base_expression: Expr::And(vec![Expr::NotRevoked, Expr::NotExpired]),
564            };
565            let json = serde_json::to_string(&quorum).unwrap();
566            let parsed: QuorumPolicy = serde_json::from_str(&json).unwrap();
567            assert_eq!(quorum, parsed);
568        }
569    }
570}