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 (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}