Skip to main content

warrant_core/
engine.rs

1use std::collections::HashMap;
2use std::fmt;
3
4use serde_json::{Map, Value};
5
6use crate::model::ParsedWarrant;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum DenyReason {
10    MissingCapability {
11        capability: String,
12    },
13    ExplicitDeny {
14        capability: String,
15    },
16    InvalidGrantType {
17        capability: String,
18    },
19    MissingScope {
20        capability: String,
21        scope: String,
22    },
23    ScopeMismatch {
24        capability: String,
25        scope: String,
26        expected: String,
27        actual: String,
28    },
29}
30
31impl fmt::Display for DenyReason {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        match self {
34            DenyReason::MissingCapability { capability } => write!(
35                f,
36                "capability \"{capability}\" is not granted; add it under [capabilities] and re-lock the warrant"
37            ),
38            DenyReason::ExplicitDeny { capability } => write!(
39                f,
40                "capability \"{capability}\" is explicitly denied; set it to true (or allow it in a scoped object) and re-lock the warrant"
41            ),
42            DenyReason::InvalidGrantType { capability } => write!(
43                f,
44                "capability \"{capability}\" has an invalid grant type; expected bool or table (for scoped grants)"
45            ),
46            DenyReason::MissingScope { capability, scope } => write!(
47                f,
48                "capability \"{capability}\" requires scope \"{scope}\" in check context; provide that scope value when calling check()"
49            ),
50            DenyReason::ScopeMismatch {
51                capability,
52                scope,
53                expected,
54                actual,
55            } => write!(
56                f,
57                "capability \"{capability}\" is scoped to {scope}={expected} but request used {actual}; change the target or update and re-lock the warrant"
58            ),
59        }
60    }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum Decision {
65    Allow,
66    Deny(DenyReason),
67}
68
69#[derive(Debug, Clone, Default)]
70pub struct CheckContext {
71    values: HashMap<String, Value>,
72}
73
74impl CheckContext {
75    pub fn new() -> Self {
76        Self::default()
77    }
78
79    pub fn with_json(mut self, key: impl Into<String>, value: Value) -> Self {
80        self.values.insert(key.into(), value);
81        self
82    }
83
84    pub fn with_str(self, key: impl Into<String>, value: impl Into<String>) -> Self {
85        self.with_json(key, Value::String(value.into()))
86    }
87
88    pub fn with_strs<I, S>(self, key: impl Into<String>, values: I) -> Self
89    where
90        I: IntoIterator<Item = S>,
91        S: Into<String>,
92    {
93        let array = values
94            .into_iter()
95            .map(|s| Value::String(s.into()))
96            .collect::<Vec<_>>();
97        self.with_json(key, Value::Array(array))
98    }
99
100    pub fn get(&self, key: &str) -> Option<&Value> {
101        self.values.get(key)
102    }
103}
104
105pub fn check(warrant: &ParsedWarrant, capability: &str, ctx: &CheckContext) -> Decision {
106    let Some(grant) = warrant.capabilities.get(capability) else {
107        return Decision::Deny(DenyReason::MissingCapability {
108            capability: capability.to_owned(),
109        });
110    };
111
112    match grant {
113        Value::Bool(true) => Decision::Allow,
114        Value::Bool(false) => Decision::Deny(DenyReason::ExplicitDeny {
115            capability: capability.to_owned(),
116        }),
117        Value::Object(scope) => check_scoped_object(capability, scope, ctx),
118        _ => Decision::Deny(DenyReason::InvalidGrantType {
119            capability: capability.to_owned(),
120        }),
121    }
122}
123
124fn check_scoped_object(
125    capability: &str,
126    scope: &Map<String, Value>,
127    ctx: &CheckContext,
128) -> Decision {
129    if let Some(allow) = scope.get("allow") {
130        match allow {
131            Value::Bool(true) => {}
132            Value::Bool(false) => {
133                return Decision::Deny(DenyReason::ExplicitDeny {
134                    capability: capability.to_owned(),
135                });
136            }
137            _ => {
138                return Decision::Deny(DenyReason::InvalidGrantType {
139                    capability: capability.to_owned(),
140                });
141            }
142        }
143    }
144
145    for (scope_key, expected) in scope {
146        if scope_key == "allow" {
147            continue;
148        }
149        let Some(actual) = ctx.get(scope_key) else {
150            return Decision::Deny(DenyReason::MissingScope {
151                capability: capability.to_owned(),
152                scope: scope_key.clone(),
153            });
154        };
155        if !scope_match(expected, actual) {
156            return Decision::Deny(DenyReason::ScopeMismatch {
157                capability: capability.to_owned(),
158                scope: scope_key.clone(),
159                expected: expected.to_string(),
160                actual: actual.to_string(),
161            });
162        }
163    }
164
165    Decision::Allow
166}
167
168fn scope_match(expected: &Value, actual: &Value) -> bool {
169    match (expected, actual) {
170        (Value::String(pattern), Value::String(value)) => wildcard_match(pattern, value),
171        (Value::Array(expected_list), Value::String(value)) => expected_list
172            .iter()
173            .filter_map(Value::as_str)
174            .any(|pattern| wildcard_match(pattern, value)),
175        // When caller provides multiple values, each value must satisfy policy.
176        (Value::Array(expected_list), Value::Array(actual_list)) => {
177            let expected_patterns: Vec<&str> =
178                expected_list.iter().filter_map(Value::as_str).collect();
179            !expected_patterns.is_empty()
180                && actual_list.iter().filter_map(Value::as_str).all(|value| {
181                    expected_patterns
182                        .iter()
183                        .any(|pattern| wildcard_match(pattern, value))
184                })
185        }
186        _ => expected == actual,
187    }
188}
189
190fn wildcard_match(pattern: &str, value: &str) -> bool {
191    if pattern == "*" {
192        return true;
193    }
194    let pattern = pattern.to_ascii_lowercase();
195    let value = value.to_ascii_lowercase();
196    let mut remainder = value.as_str();
197    let mut first = true;
198
199    for part in pattern.split('*') {
200        if part.is_empty() {
201            continue;
202        }
203        let Some(idx) = remainder.find(part) else {
204            return false;
205        };
206        if first && !pattern.starts_with('*') && idx != 0 {
207            return false;
208        }
209        remainder = &remainder[idx + part.len()..];
210        first = false;
211    }
212
213    if !pattern.ends_with('*') && !remainder.is_empty() {
214        return false;
215    }
216    true
217}
218
219#[cfg(test)]
220mod tests {
221    use serde_json::json;
222
223    use crate::model::{ParsedWarrant, SignatureBlock, WarrantMeta};
224
225    use super::{CheckContext, Decision, DenyReason, check};
226
227    fn sample_warrant(capabilities: serde_json::Value) -> ParsedWarrant {
228        ParsedWarrant::new(
229            WarrantMeta {
230                version: 1,
231                tool: "demo".to_string(),
232                created: "2026-02-16T08:00:00Z".to_string(),
233                issuer: "root@host".to_string(),
234            },
235            capabilities.clone(),
236            SignatureBlock {
237                algorithm: "ed25519".to_string(),
238                public_key_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_string(),
239                value_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
240                    .to_string(),
241            },
242            json!({
243                "warrant": {
244                    "version": 1,
245                    "tool": "demo",
246                    "created": "2026-02-16T08:00:00Z",
247                    "issuer": "root@host"
248                },
249                "capabilities": capabilities
250            }),
251        )
252        .expect("parsed warrant")
253    }
254
255    #[test]
256    fn missing_capability_denied() {
257        let warrant = sample_warrant(json!({"read": true}));
258        assert_eq!(
259            check(&warrant, "send", &CheckContext::new()),
260            Decision::Deny(DenyReason::MissingCapability {
261                capability: "send".to_string()
262            })
263        );
264    }
265
266    #[test]
267    fn bool_allow_and_explicit_deny() {
268        let warrant = sample_warrant(json!({"read": true, "delete": false}));
269        assert_eq!(
270            check(&warrant, "read", &CheckContext::new()),
271            Decision::Allow
272        );
273        assert_eq!(
274            check(&warrant, "delete", &CheckContext::new()),
275            Decision::Deny(DenyReason::ExplicitDeny {
276                capability: "delete".to_string()
277            })
278        );
279    }
280
281    #[test]
282    fn scoped_string_wildcard_match() {
283        let warrant = sample_warrant(json!({
284            "send": { "allow": true, "to_domains": "*@example.com" }
285        }));
286        let allow_ctx = CheckContext::new().with_str("to_domains", "alice@example.com");
287        let deny_ctx = CheckContext::new().with_str("to_domains", "alice@other.com");
288
289        assert_eq!(check(&warrant, "send", &allow_ctx), Decision::Allow);
290        assert!(matches!(
291            check(&warrant, "send", &deny_ctx),
292            Decision::Deny(DenyReason::ScopeMismatch { .. })
293        ));
294    }
295
296    #[test]
297    fn scoped_array_matches_single_and_multi_values() {
298        let warrant = sample_warrant(json!({
299            "send": {
300                "allow": true,
301                "to_domains": ["*@example.com", "*@company.org"]
302            }
303        }));
304
305        let one = CheckContext::new().with_str("to_domains", "alice@example.com");
306        assert_eq!(check(&warrant, "send", &one), Decision::Allow);
307
308        let many_ok =
309            CheckContext::new().with_strs("to_domains", ["alice@example.com", "bob@company.org"]);
310        assert_eq!(check(&warrant, "send", &many_ok), Decision::Allow);
311
312        let many_bad =
313            CheckContext::new().with_strs("to_domains", ["alice@example.com", "mallory@evil.org"]);
314        assert!(matches!(
315            check(&warrant, "send", &many_bad),
316            Decision::Deny(DenyReason::ScopeMismatch { .. })
317        ));
318    }
319
320    #[test]
321    fn missing_scope_is_denied() {
322        let warrant = sample_warrant(json!({
323            "push": { "allow": true, "branches": ["feature/*"] }
324        }));
325        assert_eq!(
326            check(&warrant, "push", &CheckContext::new()),
327            Decision::Deny(DenyReason::MissingScope {
328                capability: "push".to_string(),
329                scope: "branches".to_string()
330            })
331        );
332    }
333
334    #[test]
335    fn deny_reason_messages_are_actionable() {
336        let missing = DenyReason::MissingCapability {
337            capability: "send".to_string(),
338        };
339        assert!(missing.to_string().contains("re-lock the warrant"));
340
341        let mismatch = DenyReason::ScopeMismatch {
342            capability: "push".to_string(),
343            scope: "branches".to_string(),
344            expected: "[\"feature/*\"]".to_string(),
345            actual: "\"main\"".to_string(),
346        };
347        let text = mismatch.to_string();
348        assert!(text.contains("change the target"));
349        assert!(text.contains("branches"));
350    }
351}