Skip to main content

confium_pki/delegation/
constraint.rs

1//! Delegation constraints.
2//!
3//! Constraints narrow the scope of a delegation. A child cert may
4//! only exercise its delegated authority within all constraints
5//! imposed by the parent.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10/// A constraint on delegated authority.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(tag = "kind", rename_all = "snake_case")]
13pub enum Constraint {
14    /// Bound to a specific model identifier (CNML Manufacturer Model Cert pattern).
15    ModelBound {
16        /// The model identifier.
17        model_id: String,
18    },
19    /// Bound to a name pattern (e.g., "*.example.com").
20    NameBound {
21        /// The name pattern (glob).
22        name_pattern: String,
23    },
24    /// Time-bounded delegation.
25    TimeBound {
26        /// When the delegation becomes valid.
27        not_before: DateTime<Utc>,
28        /// When the delegation expires.
29        not_after: DateTime<Utc>,
30    },
31    /// Bounded by total issuance count.
32    CountBound {
33        /// Maximum number of artifacts the child may issue.
34        max_issuances: u32,
35    },
36    /// Bounded by geographic region.
37    GeographicBound {
38        /// Permitted regions.
39        regions: Vec<String>,
40    },
41    /// Bounded by subject-matter.
42    SubjectBound {
43        /// Permitted subjects (e.g., "metrology", "pharma").
44        subjects: Vec<String>,
45    },
46}
47
48impl Constraint {
49    /// Check whether `value` satisfies this constraint.
50    /// `value` is the actual scope value extracted from the proposed
51    /// child artifact (cert, document, etc.).
52    pub fn satisfies(&self, value: &ScopeValue) -> bool {
53        match (self, value) {
54            (Constraint::ModelBound { model_id }, ScopeValue::ModelId(actual)) => {
55                model_id == actual
56            }
57            (Constraint::NameBound { name_pattern }, ScopeValue::Name(actual)) => {
58                glob_match(name_pattern, actual)
59            }
60            (
61                Constraint::TimeBound {
62                    not_before,
63                    not_after,
64                },
65                ScopeValue::Time(when),
66            ) => when >= not_before && when <= not_after,
67            (Constraint::CountBound { max_issuances }, ScopeValue::Count(used)) => {
68                *used <= *max_issuances
69            }
70            (Constraint::GeographicBound { regions }, ScopeValue::Region(actual)) => {
71                regions.iter().any(|r| r == actual)
72            }
73            (Constraint::SubjectBound { subjects }, ScopeValue::Subject(actual)) => {
74                subjects.iter().any(|s| s == actual)
75            }
76            _ => false,
77        }
78    }
79}
80
81/// A scope value extracted from a child artifact, checked against constraints.
82#[derive(Debug, Clone)]
83pub enum ScopeValue<'a> {
84    /// Model identifier.
85    ModelId(&'a str),
86    /// DNS-style name.
87    Name(&'a str),
88    /// Timestamp.
89    Time(DateTime<Utc>),
90    /// Count of already-issued artifacts.
91    Count(u32),
92    /// Geographic region.
93    Region(&'a str),
94    /// Subject area.
95    Subject(&'a str),
96}
97
98/// Simple glob matcher: `*` matches any chars, `?` matches one char.
99fn glob_match(pattern: &str, value: &str) -> bool {
100    fn helper(p: &[u8], v: &[u8]) -> bool {
101        match (p.first(), v.first()) {
102            (Some(b'*'), _) => helper(&p[1..], v) || (!v.is_empty() && helper(p, &v[1..])),
103            (Some(b'?'), Some(_)) => helper(&p[1..], &v[1..]),
104            (Some(pc), Some(vc)) if pc == vc => helper(&p[1..], &v[1..]),
105            (None, None) => true,
106            _ => false,
107        }
108    }
109    helper(pattern.as_bytes(), value.as_bytes())
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn model_bound_matches() {
118        let c = Constraint::ModelBound {
119            model_id: "FM-2026-A".into(),
120        };
121        assert!(c.satisfies(&ScopeValue::ModelId("FM-2026-A")));
122        assert!(!c.satisfies(&ScopeValue::ModelId("FM-2026-B")));
123    }
124
125    #[test]
126    fn name_bound_glob_matches() {
127        let c = Constraint::NameBound {
128            name_pattern: "*.example.com".into(),
129        };
130        assert!(c.satisfies(&ScopeValue::Name("www.example.com")));
131        assert!(c.satisfies(&ScopeValue::Name("api.example.com")));
132        assert!(!c.satisfies(&ScopeValue::Name("example.com")));
133        assert!(!c.satisfies(&ScopeValue::Name("evil.org")));
134    }
135
136    #[test]
137    fn geographic_bound_matches() {
138        let c = Constraint::GeographicBound {
139            regions: vec!["europe".into(), "americas".into()],
140        };
141        assert!(c.satisfies(&ScopeValue::Region("europe")));
142        assert!(c.satisfies(&ScopeValue::Region("americas")));
143        assert!(!c.satisfies(&ScopeValue::Region("asia-pacific")));
144    }
145}