Skip to main content

missiond_core/core/
permission.rs

1//! Permission Policy - Tool permission management
2//!
3//! Supports permission configuration by role/slot:
4//! - auto_allow: Automatically approved tools
5//! - require_confirm: Tools requiring confirmation
6//! - deny: Denied tools
7
8use anyhow::Result;
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::fs;
13use std::path::{Path, PathBuf};
14use std::sync::RwLock;
15use tracing::{debug, error, info};
16
17/// Permission decision
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum PermissionDecision {
20    Allow,
21    Confirm,
22    Deny,
23}
24
25impl PermissionDecision {
26    pub fn as_str(&self) -> &'static str {
27        match self {
28            PermissionDecision::Allow => "allow",
29            PermissionDecision::Confirm => "confirm",
30            PermissionDecision::Deny => "deny",
31        }
32    }
33}
34
35/// Permission rule for a specific scope
36#[derive(Debug, Clone, Default, Serialize, Deserialize)]
37pub struct PermissionRule {
38    #[serde(default)]
39    pub auto_allow: Vec<String>,
40    #[serde(default)]
41    pub require_confirm: Vec<String>,
42    #[serde(default)]
43    pub deny: Vec<String>,
44}
45
46/// Permission configuration
47#[derive(Debug, Clone, Default, Serialize, Deserialize)]
48pub struct PermissionConfig {
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub default: Option<PermissionRule>,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub roles: Option<HashMap<String, PermissionRule>>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub slots: Option<HashMap<String, PermissionRule>>,
55}
56
57/// Permission Policy
58///
59/// Manages tool permission checking for slots and roles.
60pub struct PermissionPolicy {
61    config: RwLock<PermissionConfig>,
62    config_path: PathBuf,
63}
64
65impl PermissionPolicy {
66    /// Create a new PermissionPolicy from a config file
67    pub fn new<P: AsRef<Path>>(config_path: P) -> Self {
68        let config_path = config_path.as_ref().to_path_buf();
69        let config = Self::load_config(&config_path);
70
71        Self {
72            config: RwLock::new(config),
73            config_path,
74        }
75    }
76
77    /// Load configuration from file
78    fn load_config(path: &Path) -> PermissionConfig {
79        if !path.exists() {
80            info!(path = ?path, "No permission config found, using defaults");
81            return Self::default_config();
82        }
83
84        match fs::read_to_string(path) {
85            Ok(content) => match serde_yaml::from_str(&content) {
86                Ok(config) => {
87                    info!(path = ?path, "Permission config loaded");
88                    config
89                }
90                Err(e) => {
91                    error!(error = %e, path = ?path, "Failed to parse permission config");
92                    Self::default_config()
93                }
94            },
95            Err(e) => {
96                error!(error = %e, path = ?path, "Failed to read permission config");
97                Self::default_config()
98            }
99        }
100    }
101
102    /// Get default configuration
103    fn default_config() -> PermissionConfig {
104        PermissionConfig {
105            default: Some(PermissionRule {
106                auto_allow: vec![],
107                require_confirm: vec!["*".to_string()],
108                deny: vec![],
109            }),
110            roles: None,
111            slots: None,
112        }
113    }
114
115    /// Save configuration to file
116    pub fn save_config(&self) -> Result<()> {
117        let config = self.config.read().unwrap();
118        let content = serde_yaml::to_string(&*config)?;
119        fs::write(&self.config_path, content)?;
120        info!(path = ?self.config_path, "Permission config saved");
121        Ok(())
122    }
123
124    /// Reload configuration from file
125    pub fn reload(&self) {
126        let config = Self::load_config(&self.config_path);
127        *self.config.write().unwrap() = config;
128    }
129
130    /// Check permission for a tool
131    ///
132    /// Priority: slot > role > default
133    pub fn check_permission(
134        &self,
135        slot_id: &str,
136        role: &str,
137        tool_name: &str,
138    ) -> PermissionDecision {
139        let config = self.config.read().unwrap();
140
141        // Check slot level
142        if let Some(slots) = &config.slots {
143            if let Some(rule) = slots.get(slot_id) {
144                if let Some(decision) = self.match_rule(rule, tool_name) {
145                    debug!(
146                        slot_id = %slot_id,
147                        tool_name = %tool_name,
148                        decision = ?decision,
149                        level = "slot",
150                        "Permission matched"
151                    );
152                    return decision;
153                }
154            }
155        }
156
157        // Check role level
158        if let Some(roles) = &config.roles {
159            if let Some(rule) = roles.get(role) {
160                if let Some(decision) = self.match_rule(rule, tool_name) {
161                    debug!(
162                        slot_id = %slot_id,
163                        role = %role,
164                        tool_name = %tool_name,
165                        decision = ?decision,
166                        level = "role",
167                        "Permission matched"
168                    );
169                    return decision;
170                }
171            }
172        }
173
174        // Check default level
175        if let Some(rule) = &config.default {
176            if let Some(decision) = self.match_rule(rule, tool_name) {
177                debug!(
178                    tool_name = %tool_name,
179                    decision = ?decision,
180                    level = "default",
181                    "Permission matched"
182                );
183                return decision;
184            }
185        }
186
187        // Default to confirm
188        PermissionDecision::Confirm
189    }
190
191    /// Match a rule against a tool name
192    fn match_rule(&self, rule: &PermissionRule, tool_name: &str) -> Option<PermissionDecision> {
193        // Check deny (highest priority)
194        if self.match_patterns(&rule.deny, tool_name) {
195            return Some(PermissionDecision::Deny);
196        }
197
198        // Check auto_allow
199        if self.match_patterns(&rule.auto_allow, tool_name) {
200            return Some(PermissionDecision::Allow);
201        }
202
203        // Check require_confirm
204        if self.match_patterns(&rule.require_confirm, tool_name) {
205            return Some(PermissionDecision::Confirm);
206        }
207
208        None
209    }
210
211    /// Match patterns against a tool name
212    fn match_patterns(&self, patterns: &[String], tool_name: &str) -> bool {
213        patterns.iter().any(|p| self.match_pattern(p, tool_name))
214    }
215
216    /// Match a single pattern (supports wildcards)
217    fn match_pattern(&self, pattern: &str, tool_name: &str) -> bool {
218        // Exact match
219        if pattern == tool_name {
220            return true;
221        }
222
223        // Wildcard *
224        if pattern == "*" {
225            return true;
226        }
227
228        // Prefix wildcard: xjp_secret_*
229        if pattern.ends_with('*') {
230            let prefix = &pattern[..pattern.len() - 1];
231            if tool_name.starts_with(prefix) {
232                return true;
233            }
234        }
235
236        // Suffix wildcard: *_delete
237        if pattern.starts_with('*') {
238            let suffix = &pattern[1..];
239            if tool_name.ends_with(suffix) {
240                return true;
241            }
242        }
243
244        // Middle wildcard: xjp_*_get
245        if pattern.contains('*') {
246            // Convert pattern to regex
247            let regex_pattern = format!("^{}$", pattern.replace('*', ".*"));
248            if let Ok(re) = Regex::new(&regex_pattern) {
249                if re.is_match(tool_name) {
250                    return true;
251                }
252            }
253        }
254
255        false
256    }
257
258    // ============ Configuration modification API ============
259
260    /// Set role rule
261    pub fn set_role_rule(&self, role: &str, rule: PermissionRule) {
262        let mut config = self.config.write().unwrap();
263        if config.roles.is_none() {
264            config.roles = Some(HashMap::new());
265        }
266        config.roles.as_mut().unwrap().insert(role.to_string(), rule);
267        drop(config);
268        let _ = self.save_config();
269    }
270
271    /// Set slot rule
272    pub fn set_slot_rule(&self, slot_id: &str, rule: PermissionRule) {
273        let mut config = self.config.write().unwrap();
274        if config.slots.is_none() {
275            config.slots = Some(HashMap::new());
276        }
277        config
278            .slots
279            .as_mut()
280            .unwrap()
281            .insert(slot_id.to_string(), rule);
282        drop(config);
283        let _ = self.save_config();
284    }
285
286    /// Add to role's auto_allow
287    pub fn add_role_auto_allow(&self, role: &str, pattern: &str) {
288        let mut config = self.config.write().unwrap();
289        if config.roles.is_none() {
290            config.roles = Some(HashMap::new());
291        }
292
293        let roles = config.roles.as_mut().unwrap();
294        if !roles.contains_key(role) {
295            roles.insert(role.to_string(), PermissionRule::default());
296        }
297
298        let rule = roles.get_mut(role).unwrap();
299        if !rule.auto_allow.contains(&pattern.to_string()) {
300            rule.auto_allow.push(pattern.to_string());
301        }
302        drop(config);
303        let _ = self.save_config();
304    }
305
306    /// Add to slot's auto_allow
307    pub fn add_slot_auto_allow(&self, slot_id: &str, pattern: &str) {
308        let mut config = self.config.write().unwrap();
309        if config.slots.is_none() {
310            config.slots = Some(HashMap::new());
311        }
312
313        let slots = config.slots.as_mut().unwrap();
314        if !slots.contains_key(slot_id) {
315            slots.insert(slot_id.to_string(), PermissionRule::default());
316        }
317
318        let rule = slots.get_mut(slot_id).unwrap();
319        if !rule.auto_allow.contains(&pattern.to_string()) {
320            rule.auto_allow.push(pattern.to_string());
321        }
322        drop(config);
323        let _ = self.save_config();
324    }
325
326    /// Get the full configuration
327    pub fn get_config(&self) -> PermissionConfig {
328        self.config.read().unwrap().clone()
329    }
330
331    /// Get role rule
332    pub fn get_role_rule(&self, role: &str) -> Option<PermissionRule> {
333        let config = self.config.read().unwrap();
334        config.roles.as_ref()?.get(role).cloned()
335    }
336
337    /// Get slot rule
338    pub fn get_slot_rule(&self, slot_id: &str) -> Option<PermissionRule> {
339        let config = self.config.read().unwrap();
340        config.slots.as_ref()?.get(slot_id).cloned()
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use tempfile::tempdir;
348
349    fn create_test_policy() -> PermissionPolicy {
350        let dir = tempdir().unwrap();
351        let config_path = dir.path().join("permissions.yaml");
352        PermissionPolicy::new(&config_path)
353    }
354
355    #[test]
356    fn test_default_config() {
357        let policy = create_test_policy();
358
359        // Default should require confirmation for everything
360        let decision = policy.check_permission("slot-1", "worker", "some_tool");
361        assert_eq!(decision, PermissionDecision::Confirm);
362    }
363
364    #[test]
365    fn test_exact_match() {
366        let dir = tempdir().unwrap();
367        let config_path = dir.path().join("permissions.yaml");
368
369        // Create config with exact match
370        let config = PermissionConfig {
371            default: None,
372            roles: Some({
373                let mut m = HashMap::new();
374                m.insert(
375                    "worker".to_string(),
376                    PermissionRule {
377                        auto_allow: vec!["read_file".to_string()],
378                        require_confirm: vec![],
379                        deny: vec!["delete_file".to_string()],
380                    },
381                );
382                m
383            }),
384            slots: None,
385        };
386
387        let content = serde_yaml::to_string(&config).unwrap();
388        fs::write(&config_path, content).unwrap();
389
390        let policy = PermissionPolicy::new(&config_path);
391
392        assert_eq!(
393            policy.check_permission("slot-1", "worker", "read_file"),
394            PermissionDecision::Allow
395        );
396        assert_eq!(
397            policy.check_permission("slot-1", "worker", "delete_file"),
398            PermissionDecision::Deny
399        );
400    }
401
402    #[test]
403    fn test_wildcard_patterns() {
404        let policy = create_test_policy();
405
406        // Test prefix wildcard
407        assert!(policy.match_pattern("xjp_*", "xjp_secret_get"));
408        assert!(!policy.match_pattern("xjp_*", "other_tool"));
409
410        // Test suffix wildcard
411        assert!(policy.match_pattern("*_delete", "file_delete"));
412        assert!(!policy.match_pattern("*_delete", "delete_file"));
413
414        // Test middle wildcard
415        assert!(policy.match_pattern("xjp_*_get", "xjp_secret_get"));
416        assert!(policy.match_pattern("xjp_*_get", "xjp_user_info_get"));
417        assert!(!policy.match_pattern("xjp_*_get", "xjp_secret_set"));
418
419        // Test universal wildcard
420        assert!(policy.match_pattern("*", "any_tool"));
421    }
422
423    #[test]
424    fn test_slot_overrides_role() {
425        let dir = tempdir().unwrap();
426        let config_path = dir.path().join("permissions.yaml");
427
428        let config = PermissionConfig {
429            default: None,
430            roles: Some({
431                let mut m = HashMap::new();
432                m.insert(
433                    "worker".to_string(),
434                    PermissionRule {
435                        auto_allow: vec!["read_file".to_string()],
436                        require_confirm: vec![],
437                        deny: vec![],
438                    },
439                );
440                m
441            }),
442            slots: Some({
443                let mut m = HashMap::new();
444                m.insert(
445                    "slot-1".to_string(),
446                    PermissionRule {
447                        auto_allow: vec![],
448                        require_confirm: vec![],
449                        deny: vec!["read_file".to_string()],
450                    },
451                );
452                m
453            }),
454        };
455
456        let content = serde_yaml::to_string(&config).unwrap();
457        fs::write(&config_path, content).unwrap();
458
459        let policy = PermissionPolicy::new(&config_path);
460
461        // Slot rule denies, even though role allows
462        assert_eq!(
463            policy.check_permission("slot-1", "worker", "read_file"),
464            PermissionDecision::Deny
465        );
466
467        // Different slot uses role rule
468        assert_eq!(
469            policy.check_permission("slot-2", "worker", "read_file"),
470            PermissionDecision::Allow
471        );
472    }
473
474    #[test]
475    fn test_add_auto_allow() {
476        let policy = create_test_policy();
477
478        // Initially not allowed
479        let decision = policy.check_permission("slot-1", "worker", "new_tool");
480        assert_eq!(decision, PermissionDecision::Confirm); // Falls back to default
481
482        // Add to role auto_allow
483        policy.add_role_auto_allow("worker", "new_tool");
484
485        let decision = policy.check_permission("slot-1", "worker", "new_tool");
486        assert_eq!(decision, PermissionDecision::Allow);
487    }
488
489    #[test]
490    fn test_deny_has_highest_priority() {
491        let dir = tempdir().unwrap();
492        let config_path = dir.path().join("permissions.yaml");
493
494        let config = PermissionConfig {
495            default: None,
496            roles: Some({
497                let mut m = HashMap::new();
498                m.insert(
499                    "worker".to_string(),
500                    PermissionRule {
501                        auto_allow: vec!["dangerous_tool".to_string()],
502                        require_confirm: vec![],
503                        deny: vec!["dangerous_tool".to_string()],
504                    },
505                );
506                m
507            }),
508            slots: None,
509        };
510
511        let content = serde_yaml::to_string(&config).unwrap();
512        fs::write(&config_path, content).unwrap();
513
514        let policy = PermissionPolicy::new(&config_path);
515
516        // Deny takes priority over auto_allow
517        assert_eq!(
518            policy.check_permission("slot-1", "worker", "dangerous_tool"),
519            PermissionDecision::Deny
520        );
521    }
522}