blueprint_engine_core/
permissions.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
4#[serde(rename_all = "lowercase")]
5pub enum Policy {
6    Allow,
7    #[default]
8    Deny,
9    Ask,
10}
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum PermissionCheck {
14    Allow,
15    Deny,
16    Ask,
17}
18
19#[derive(Debug, Clone, Deserialize, Serialize, Default)]
20pub struct Permissions {
21    #[serde(default)]
22    pub policy: Policy,
23
24    #[serde(default)]
25    pub allow: Vec<String>,
26
27    #[serde(default)]
28    pub ask: Vec<String>,
29
30    #[serde(default)]
31    pub deny: Vec<String>,
32}
33
34impl Permissions {
35    pub fn none() -> Self {
36        Self {
37            policy: Policy::Deny,
38            allow: vec![],
39            ask: vec![],
40            deny: vec![],
41        }
42    }
43
44    pub fn all() -> Self {
45        Self {
46            policy: Policy::Allow,
47            allow: vec![],
48            ask: vec![],
49            deny: vec![],
50        }
51    }
52
53    pub fn ask_all() -> Self {
54        Self {
55            policy: Policy::Ask,
56            allow: vec![],
57            ask: vec![],
58            deny: vec![],
59        }
60    }
61
62    pub fn check(&self, operation: &str, resource: Option<&str>) -> PermissionCheck {
63        // Priority: deny > ask > allow > policy
64        if self.matches_any(&self.deny, operation, resource) {
65            return PermissionCheck::Deny;
66        }
67
68        if self.matches_any(&self.ask, operation, resource) {
69            return PermissionCheck::Ask;
70        }
71
72        if self.matches_any(&self.allow, operation, resource) {
73            return PermissionCheck::Allow;
74        }
75
76        match self.policy {
77            Policy::Allow => PermissionCheck::Allow,
78            Policy::Deny => PermissionCheck::Deny,
79            Policy::Ask => PermissionCheck::Ask,
80        }
81    }
82
83    fn matches_any(&self, rules: &[String], operation: &str, resource: Option<&str>) -> bool {
84        for rule in rules {
85            if self.matches_rule(rule, operation, resource) {
86                return true;
87            }
88        }
89        false
90    }
91
92    fn matches_rule(&self, rule: &str, operation: &str, resource: Option<&str>) -> bool {
93        if let Some((rule_op, rule_pattern)) = rule.split_once(':') {
94            if !self.matches_operation(rule_op, operation) {
95                return false;
96            }
97            match resource {
98                Some(res) => self.matches_pattern(rule_pattern, res),
99                None => rule_pattern == "*",
100            }
101        } else {
102            self.matches_operation(rule, operation) && resource.is_none()
103        }
104    }
105
106    fn matches_operation(&self, rule_op: &str, operation: &str) -> bool {
107        if rule_op == "*" {
108            return true;
109        }
110        if rule_op.ends_with(".*") {
111            let prefix = &rule_op[..rule_op.len() - 1];
112            return operation.starts_with(prefix);
113        }
114        rule_op == operation
115    }
116
117    fn matches_pattern(&self, pattern: &str, value: &str) -> bool {
118        if pattern == "*" {
119            return true;
120        }
121
122        if pattern.starts_with("*.") {
123            let suffix = &pattern[1..];
124            let host = extract_host(value);
125            return host.ends_with(suffix) || host == &pattern[2..];
126        }
127
128        if pattern.contains('*') {
129            if let Ok(glob) = glob::Pattern::new(pattern) {
130                if glob.matches(value) {
131                    return true;
132                }
133            }
134            let prefix = pattern.trim_end_matches('*');
135            if !prefix.is_empty() && value.starts_with(prefix) {
136                return true;
137            }
138        }
139
140        if is_url(value) {
141            let host = extract_host(value);
142            return host == pattern;
143        }
144
145        pattern == value
146    }
147
148    pub fn check_fs_read(&self, path: &str) -> PermissionCheck {
149        self.check("fs.read", Some(path))
150    }
151
152    pub fn check_fs_write(&self, path: &str) -> PermissionCheck {
153        self.check("fs.write", Some(path))
154    }
155
156    pub fn check_fs_delete(&self, path: &str) -> PermissionCheck {
157        self.check("fs.delete", Some(path))
158    }
159
160    pub fn check_http(&self, url: &str) -> PermissionCheck {
161        self.check("net.http", Some(url))
162    }
163
164    pub fn check_ws(&self, url: &str) -> PermissionCheck {
165        self.check("net.ws", Some(url))
166    }
167
168    pub fn check_process_run(&self, binary: &str) -> PermissionCheck {
169        let bin_name = std::path::Path::new(binary)
170            .file_name()
171            .and_then(|s| s.to_str())
172            .unwrap_or(binary);
173
174        let check = self.check("process.run", Some(binary));
175        if matches!(check, PermissionCheck::Allow) {
176            return check;
177        }
178        if binary != bin_name {
179            let name_check = self.check("process.run", Some(bin_name));
180            if matches!(name_check, PermissionCheck::Allow) {
181                return name_check;
182            }
183        }
184        check
185    }
186
187    pub fn check_process_shell(&self) -> PermissionCheck {
188        self.check("process.shell", None)
189    }
190
191    pub fn check_env_read(&self, var: &str) -> PermissionCheck {
192        self.check("env.read", Some(var))
193    }
194
195    pub fn check_env_write(&self) -> PermissionCheck {
196        self.check("env.write", None)
197    }
198}
199
200fn is_url(s: &str) -> bool {
201    s.starts_with("http://")
202        || s.starts_with("https://")
203        || s.starts_with("ws://")
204        || s.starts_with("wss://")
205}
206
207fn extract_host(url: &str) -> &str {
208    let without_scheme = url
209        .strip_prefix("https://")
210        .or_else(|| url.strip_prefix("http://"))
211        .or_else(|| url.strip_prefix("wss://"))
212        .or_else(|| url.strip_prefix("ws://"))
213        .unwrap_or(url);
214
215    without_scheme
216        .split('/')
217        .next()
218        .unwrap_or(without_scheme)
219        .split(':')
220        .next()
221        .unwrap_or(without_scheme)
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn test_policy_default_deny() {
230        let perms = Permissions::none();
231        assert_eq!(perms.check_fs_read("/etc/passwd"), PermissionCheck::Deny);
232        assert_eq!(
233            perms.check_http("https://example.com"),
234            PermissionCheck::Deny
235        );
236        assert_eq!(perms.check_process_shell(), PermissionCheck::Deny);
237    }
238
239    #[test]
240    fn test_policy_allow_all() {
241        let perms = Permissions::all();
242        assert_eq!(perms.check_fs_read("/etc/passwd"), PermissionCheck::Allow);
243        assert_eq!(
244            perms.check_http("https://example.com"),
245            PermissionCheck::Allow
246        );
247        assert_eq!(perms.check_process_shell(), PermissionCheck::Allow);
248    }
249
250    #[test]
251    fn test_policy_ask_all() {
252        let perms = Permissions::ask_all();
253        assert_eq!(perms.check_fs_read("/etc/passwd"), PermissionCheck::Ask);
254        assert_eq!(
255            perms.check_http("https://example.com"),
256            PermissionCheck::Ask
257        );
258        assert_eq!(perms.check_process_shell(), PermissionCheck::Ask);
259    }
260
261    #[test]
262    fn test_allow_patterns() {
263        let perms = Permissions {
264            policy: Policy::Deny,
265            allow: vec![
266                "fs.read:./data/*".to_string(),
267                "fs.read:/tmp/*".to_string(),
268                "net.http:api.github.com".to_string(),
269                "net.http:*.internal.corp".to_string(),
270                "process.run:git".to_string(),
271                "process.run:jq".to_string(),
272                "env.read:HOME".to_string(),
273            ],
274            ask: vec![],
275            deny: vec![],
276        };
277
278        assert_eq!(
279            perms.check_fs_read("./data/file.json"),
280            PermissionCheck::Allow
281        );
282        assert_eq!(perms.check_fs_read("/tmp/test"), PermissionCheck::Allow);
283        assert_eq!(perms.check_fs_read("/etc/passwd"), PermissionCheck::Deny);
284
285        assert_eq!(
286            perms.check_http("https://api.github.com/repos"),
287            PermissionCheck::Allow
288        );
289        assert_eq!(
290            perms.check_http("https://foo.internal.corp/api"),
291            PermissionCheck::Allow
292        );
293        assert_eq!(perms.check_http("https://evil.com"), PermissionCheck::Deny);
294
295        assert_eq!(perms.check_process_run("git"), PermissionCheck::Allow);
296        assert_eq!(
297            perms.check_process_run("/usr/bin/git"),
298            PermissionCheck::Allow
299        );
300        assert_eq!(perms.check_process_run("rm"), PermissionCheck::Deny);
301
302        assert_eq!(perms.check_env_read("HOME"), PermissionCheck::Allow);
303        assert_eq!(perms.check_env_read("SECRET"), PermissionCheck::Deny);
304    }
305
306    #[test]
307    fn test_ask_patterns() {
308        let perms = Permissions {
309            policy: Policy::Deny,
310            allow: vec!["fs.read:./config/*".to_string()],
311            ask: vec!["fs.read:*".to_string(), "net.http:*".to_string()],
312            deny: vec!["process.shell".to_string()],
313        };
314
315        assert_eq!(
316            perms.check_fs_read("./config/settings.json"),
317            PermissionCheck::Allow
318        );
319        assert_eq!(perms.check_fs_read("/etc/passwd"), PermissionCheck::Ask);
320        assert_eq!(
321            perms.check_http("https://example.com"),
322            PermissionCheck::Ask
323        );
324        assert_eq!(perms.check_process_shell(), PermissionCheck::Deny);
325        assert_eq!(perms.check_process_run("git"), PermissionCheck::Deny);
326    }
327
328    #[test]
329    fn test_priority_deny_over_ask_over_allow() {
330        // Test: deny > ask > allow
331        let perms = Permissions {
332            policy: Policy::Allow,
333            allow: vec!["fs.read:*".to_string()],
334            ask: vec!["fs.read:/home/*".to_string()],
335            deny: vec!["fs.read:/etc/*".to_string()],
336        };
337
338        // allow matches but nothing higher priority
339        assert_eq!(perms.check_fs_read("./data/file"), PermissionCheck::Allow);
340        // ask overrides allow
341        assert_eq!(perms.check_fs_read("/home/user/file"), PermissionCheck::Ask);
342        // deny overrides both ask and allow
343        assert_eq!(perms.check_fs_read("/etc/passwd"), PermissionCheck::Deny);
344    }
345
346    #[test]
347    fn test_ask_overrides_allow() {
348        let perms = Permissions {
349            policy: Policy::Deny,
350            allow: vec!["net.http:*".to_string()],
351            ask: vec!["net.http:*.dangerous.com".to_string()],
352            deny: vec![],
353        };
354
355        assert_eq!(perms.check_http("https://safe.com"), PermissionCheck::Allow);
356        assert_eq!(
357            perms.check_http("https://foo.dangerous.com"),
358            PermissionCheck::Ask
359        );
360    }
361
362    #[test]
363    fn test_wildcard_operation() {
364        let perms = Permissions {
365            policy: Policy::Deny,
366            allow: vec!["fs.*:./workspace/*".to_string()],
367            ask: vec![],
368            deny: vec![],
369        };
370
371        assert_eq!(
372            perms.check_fs_read("./workspace/file"),
373            PermissionCheck::Allow
374        );
375        assert_eq!(
376            perms.check_fs_write("./workspace/file"),
377            PermissionCheck::Allow
378        );
379        assert_eq!(
380            perms.check_fs_delete("./workspace/file"),
381            PermissionCheck::Allow
382        );
383        assert_eq!(perms.check_fs_read("/etc/passwd"), PermissionCheck::Deny);
384    }
385
386    #[test]
387    fn test_deserialize_permissions() {
388        let json = r#"{
389            "policy": "deny",
390            "allow": [
391                "fs.read:./data/*",
392                "net.http:api.github.com",
393                "process.run:git"
394            ],
395            "ask": [
396                "net.http:*"
397            ],
398            "deny": [
399                "process.shell"
400            ]
401        }"#;
402
403        let perms: Permissions = serde_json::from_str(json).unwrap();
404
405        assert_eq!(perms.policy, Policy::Deny);
406        assert_eq!(perms.check_fs_read("./data/test"), PermissionCheck::Allow);
407        assert_eq!(
408            perms.check_http("https://api.github.com"),
409            PermissionCheck::Allow
410        );
411        assert_eq!(perms.check_http("https://other.com"), PermissionCheck::Ask);
412        assert_eq!(perms.check_process_shell(), PermissionCheck::Deny);
413    }
414
415    #[test]
416    fn test_extract_host() {
417        assert_eq!(
418            extract_host("https://api.example.com/v1"),
419            "api.example.com"
420        );
421        assert_eq!(extract_host("http://localhost:8080/path"), "localhost");
422        assert_eq!(
423            extract_host("wss://stream.example.com"),
424            "stream.example.com"
425        );
426    }
427}