1use 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#[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#[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#[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
57pub struct PermissionPolicy {
61 config: RwLock<PermissionConfig>,
62 config_path: PathBuf,
63}
64
65impl PermissionPolicy {
66 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 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 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 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 pub fn reload(&self) {
126 let config = Self::load_config(&self.config_path);
127 *self.config.write().unwrap() = config;
128 }
129
130 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 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 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 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 PermissionDecision::Confirm
189 }
190
191 fn match_rule(&self, rule: &PermissionRule, tool_name: &str) -> Option<PermissionDecision> {
193 if self.match_patterns(&rule.deny, tool_name) {
195 return Some(PermissionDecision::Deny);
196 }
197
198 if self.match_patterns(&rule.auto_allow, tool_name) {
200 return Some(PermissionDecision::Allow);
201 }
202
203 if self.match_patterns(&rule.require_confirm, tool_name) {
205 return Some(PermissionDecision::Confirm);
206 }
207
208 None
209 }
210
211 fn match_patterns(&self, patterns: &[String], tool_name: &str) -> bool {
213 patterns.iter().any(|p| self.match_pattern(p, tool_name))
214 }
215
216 fn match_pattern(&self, pattern: &str, tool_name: &str) -> bool {
218 if pattern == tool_name {
220 return true;
221 }
222
223 if pattern == "*" {
225 return true;
226 }
227
228 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 if pattern.starts_with('*') {
238 let suffix = &pattern[1..];
239 if tool_name.ends_with(suffix) {
240 return true;
241 }
242 }
243
244 if pattern.contains('*') {
246 let regex_pattern = format!("^{}$", pattern.replace('*', ".*"));
248 if let Ok(re) = Regex::new(®ex_pattern) {
249 if re.is_match(tool_name) {
250 return true;
251 }
252 }
253 }
254
255 false
256 }
257
258 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 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 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 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 pub fn get_config(&self) -> PermissionConfig {
328 self.config.read().unwrap().clone()
329 }
330
331 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 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 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 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 assert!(policy.match_pattern("xjp_*", "xjp_secret_get"));
408 assert!(!policy.match_pattern("xjp_*", "other_tool"));
409
410 assert!(policy.match_pattern("*_delete", "file_delete"));
412 assert!(!policy.match_pattern("*_delete", "delete_file"));
413
414 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 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 assert_eq!(
463 policy.check_permission("slot-1", "worker", "read_file"),
464 PermissionDecision::Deny
465 );
466
467 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 let decision = policy.check_permission("slot-1", "worker", "new_tool");
480 assert_eq!(decision, PermissionDecision::Confirm); 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 assert_eq!(
518 policy.check_permission("slot-1", "worker", "dangerous_tool"),
519 PermissionDecision::Deny
520 );
521 }
522}