1use crate::{AuthContext, models::Role};
7use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, HashSet};
9use thiserror::Error;
10use tracing::debug;
11
12#[derive(Debug, Error)]
14pub enum PermissionError {
15 #[error("Access denied: {0}")]
16 AccessDenied(String),
17
18 #[error("Permission not found: {0}")]
19 NotFound(String),
20
21 #[error("Invalid permission format: {0}")]
22 InvalidFormat(String),
23
24 #[error("Role configuration error: {0}")]
25 RoleConfig(String),
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
30pub enum McpPermission {
31 UseTool(String),
33
34 UseResource(String),
36
37 UseToolCategory(String),
39
40 UseResourceCategory(String),
42
43 UsePrompt(String),
45
46 Subscribe(String),
48
49 Complete,
51
52 SetLogLevel,
54
55 Admin(String),
57
58 Custom(String),
60}
61
62impl McpPermission {
63 pub fn tool(name: &str) -> Self {
65 Self::UseTool(name.to_string())
66 }
67
68 pub fn resource(uri: &str) -> Self {
70 Self::UseResource(uri.to_string())
71 }
72
73 pub fn tool_category(category: &str) -> Self {
75 Self::UseToolCategory(category.to_string())
76 }
77
78 pub fn resource_category(category: &str) -> Self {
80 Self::UseResourceCategory(category.to_string())
81 }
82
83 pub fn to_string(&self) -> String {
85 match self {
86 Self::UseTool(name) => format!("tool:{}", name),
87 Self::UseResource(uri) => format!("resource:{}", uri),
88 Self::UseToolCategory(cat) => format!("tool_category:{}", cat),
89 Self::UseResourceCategory(cat) => format!("resource_category:{}", cat),
90 Self::UsePrompt(name) => format!("prompt:{}", name),
91 Self::Subscribe(resource) => format!("subscribe:{}", resource),
92 Self::Complete => "complete".to_string(),
93 Self::SetLogLevel => "set_log_level".to_string(),
94 Self::Admin(action) => format!("admin:{}", action),
95 Self::Custom(perm) => format!("custom:{}", perm),
96 }
97 }
98
99 pub fn from_string(s: &str) -> Result<Self, PermissionError> {
101 let parts: Vec<&str> = s.splitn(2, ':').collect();
102 match parts.as_slice() {
103 ["tool", name] => Ok(Self::UseTool(name.to_string())),
104 ["resource", uri] => Ok(Self::UseResource(uri.to_string())),
105 ["tool_category", cat] => Ok(Self::UseToolCategory(cat.to_string())),
106 ["resource_category", cat] => Ok(Self::UseResourceCategory(cat.to_string())),
107 ["prompt", name] => Ok(Self::UsePrompt(name.to_string())),
108 ["subscribe", resource] => Ok(Self::Subscribe(resource.to_string())),
109 ["complete"] => Ok(Self::Complete),
110 ["set_log_level"] => Ok(Self::SetLogLevel),
111 ["admin", action] => Ok(Self::Admin(action.to_string())),
112 ["custom", perm] => Ok(Self::Custom(perm.to_string())),
113 _ => Err(PermissionError::InvalidFormat(format!(
114 "Invalid permission format: {}",
115 s
116 ))),
117 }
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub enum PermissionAction {
124 Allow,
125 Deny,
126}
127
128impl Default for PermissionAction {
129 fn default() -> Self {
130 Self::Deny
131 }
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct PermissionRule {
137 pub permission: McpPermission,
139
140 pub roles: Vec<Role>,
142
143 pub action: PermissionAction,
145
146 pub conditions: Option<HashMap<String, String>>,
148}
149
150impl PermissionRule {
151 pub fn allow(permission: McpPermission, roles: Vec<Role>) -> Self {
153 Self {
154 permission,
155 roles,
156 action: PermissionAction::Allow,
157 conditions: None,
158 }
159 }
160
161 pub fn deny(permission: McpPermission, roles: Vec<Role>) -> Self {
163 Self {
164 permission,
165 roles,
166 action: PermissionAction::Deny,
167 conditions: None,
168 }
169 }
170
171 pub fn applies_to_role(&self, role: &Role) -> bool {
173 self.roles.contains(role)
174 }
175}
176
177#[derive(Debug, Clone, Default, Serialize, Deserialize)]
179pub struct ToolPermissionConfig {
180 pub default_action: PermissionAction,
182
183 pub tool_permissions: HashMap<String, Vec<Role>>,
185
186 pub category_permissions: HashMap<String, Vec<Role>>,
188
189 pub admin_only_tools: HashSet<String>,
191
192 pub read_only_tools: HashSet<String>,
194}
195
196#[derive(Debug, Clone, Default, Serialize, Deserialize)]
198pub struct ResourcePermissionConfig {
199 pub default_action: PermissionAction,
201
202 pub resource_permissions: HashMap<String, Vec<Role>>,
204
205 pub category_permissions: HashMap<String, Vec<Role>>,
207
208 pub admin_only_resources: HashSet<String>,
210
211 pub public_resources: HashSet<String>,
213}
214
215#[derive(Debug, Clone, Default, Serialize, Deserialize)]
217pub struct PermissionConfig {
218 pub tools: ToolPermissionConfig,
220
221 pub resources: ResourcePermissionConfig,
223
224 pub custom_rules: Vec<PermissionRule>,
226
227 pub strict_mode: bool,
229
230 pub default_action: PermissionAction,
232}
233
234impl PermissionConfig {
235 pub fn permissive() -> Self {
237 Self {
238 tools: ToolPermissionConfig {
239 default_action: PermissionAction::Allow,
240 ..Default::default()
241 },
242 resources: ResourcePermissionConfig {
243 default_action: PermissionAction::Allow,
244 ..Default::default()
245 },
246 strict_mode: false,
247 default_action: PermissionAction::Allow,
248 ..Default::default()
249 }
250 }
251
252 pub fn restrictive() -> Self {
254 Self {
255 tools: ToolPermissionConfig {
256 default_action: PermissionAction::Deny,
257 ..Default::default()
258 },
259 resources: ResourcePermissionConfig {
260 default_action: PermissionAction::Deny,
261 ..Default::default()
262 },
263 strict_mode: true,
264 default_action: PermissionAction::Deny,
265 ..Default::default()
266 }
267 }
268
269 pub fn production() -> Self {
271 let mut config = Self::restrictive();
272
273 config.tools.read_only_tools.extend([
275 "ping".to_string(),
276 "health_check".to_string(),
277 "get_status".to_string(),
278 "list_devices".to_string(),
279 ]);
280
281 config.resources.public_resources.extend([
283 "system://status".to_string(),
284 "system://health".to_string(),
285 "system://version".to_string(),
286 ]);
287
288 config
289 }
290
291 pub fn allow_role_tool(mut self, role: Role, tool: &str) -> Self {
293 self.tools
294 .tool_permissions
295 .entry(tool.to_string())
296 .or_insert_with(Vec::new)
297 .push(role);
298 self
299 }
300
301 pub fn allow_role_resource(mut self, role: Role, resource: &str) -> Self {
303 self.resources
304 .resource_permissions
305 .entry(resource.to_string())
306 .or_insert_with(Vec::new)
307 .push(role);
308 self
309 }
310
311 pub fn deny_role_resource(mut self, role: Role, resource: &str) -> Self {
313 let permission_rule =
314 PermissionRule::deny(McpPermission::UseResource(resource.to_string()), vec![role]);
315 self.custom_rules.push(permission_rule);
316 self
317 }
318}
319
320pub struct McpPermissionChecker {
322 config: PermissionConfig,
323}
324
325impl McpPermissionChecker {
326 pub fn new(config: PermissionConfig) -> Self {
328 Self { config }
329 }
330
331 pub fn can_use_tool(&self, auth_context: &AuthContext, tool_name: &str) -> bool {
333 debug!(
334 "Checking tool permission: {} for roles: {:?}",
335 tool_name, auth_context.roles
336 );
337
338 for rule in &self.config.custom_rules {
340 if let McpPermission::UseTool(rule_tool) = &rule.permission {
341 if rule_tool == tool_name {
342 for role in &auth_context.roles {
343 if rule.applies_to_role(role) {
344 match rule.action {
345 PermissionAction::Allow => return true,
346 PermissionAction::Deny => return false,
347 }
348 }
349 }
350 }
351 }
352 }
353
354 if self.config.tools.admin_only_tools.contains(tool_name) {
356 return auth_context.roles.contains(&Role::Admin);
357 }
358
359 if self.config.tools.read_only_tools.contains(tool_name) {
361 return auth_context
362 .roles
363 .iter()
364 .any(|role| matches!(role, Role::Admin | Role::Operator | Role::Monitor));
365 }
366
367 if let Some(allowed_roles) = self.config.tools.tool_permissions.get(tool_name) {
369 return auth_context
370 .roles
371 .iter()
372 .any(|role| allowed_roles.contains(role));
373 }
374
375 if let Some(category) = self.extract_tool_category(tool_name) {
377 if let Some(allowed_roles) = self.config.tools.category_permissions.get(&category) {
378 return auth_context
379 .roles
380 .iter()
381 .any(|role| allowed_roles.contains(role));
382 }
383 }
384
385 match self.config.tools.default_action {
387 PermissionAction::Allow => true,
388 PermissionAction::Deny => false,
389 }
390 }
391
392 pub fn can_access_resource(&self, auth_context: &AuthContext, resource_uri: &str) -> bool {
394 debug!(
395 "Checking resource permission: {} for roles: {:?}",
396 resource_uri, auth_context.roles
397 );
398
399 for rule in &self.config.custom_rules {
401 if let McpPermission::UseResource(rule_resource) = &rule.permission {
402 if self.matches_resource_pattern(rule_resource, resource_uri) {
403 for role in &auth_context.roles {
404 if rule.applies_to_role(role) {
405 match rule.action {
406 PermissionAction::Allow => return true,
407 PermissionAction::Deny => return false,
408 }
409 }
410 }
411 }
412 }
413 }
414
415 if self
417 .config
418 .resources
419 .public_resources
420 .contains(resource_uri)
421 {
422 return true;
423 }
424
425 if self
427 .config
428 .resources
429 .admin_only_resources
430 .contains(resource_uri)
431 {
432 return auth_context.roles.contains(&Role::Admin);
433 }
434
435 for (pattern, allowed_roles) in &self.config.resources.resource_permissions {
437 if self.matches_resource_pattern(pattern, resource_uri) {
438 return auth_context
439 .roles
440 .iter()
441 .any(|role| allowed_roles.contains(role));
442 }
443 }
444
445 if let Some(category) = self.extract_resource_category(resource_uri) {
447 if let Some(allowed_roles) = self.config.resources.category_permissions.get(&category) {
448 return auth_context
449 .roles
450 .iter()
451 .any(|role| allowed_roles.contains(role));
452 }
453 }
454
455 match self.config.resources.default_action {
457 PermissionAction::Allow => true,
458 PermissionAction::Deny => false,
459 }
460 }
461
462 pub fn can_use_prompt(&self, auth_context: &AuthContext, prompt_name: &str) -> bool {
464 self.can_use_tool(auth_context, prompt_name)
466 }
467
468 pub fn can_subscribe(&self, auth_context: &AuthContext, resource_uri: &str) -> bool {
470 if !self.can_access_resource(auth_context, resource_uri) {
472 return false;
473 }
474
475 for rule in &self.config.custom_rules {
477 if let McpPermission::Subscribe(rule_resource) = &rule.permission {
478 if self.matches_resource_pattern(rule_resource, resource_uri) {
479 for role in &auth_context.roles {
480 if rule.applies_to_role(role) {
481 match rule.action {
482 PermissionAction::Allow => return true,
483 PermissionAction::Deny => return false,
484 }
485 }
486 }
487 }
488 }
489 }
490
491 true
493 }
494
495 pub fn can_use_method(&self, auth_context: &AuthContext, method: &str) -> bool {
497 match method {
498 "tools/call" => {
499 true
501 }
502 "resources/read" | "resources/list" => {
503 true
505 }
506 "resources/subscribe" | "resources/unsubscribe" => {
507 auth_context
509 .roles
510 .iter()
511 .any(|role| matches!(role, Role::Admin | Role::Operator))
512 }
513 "completion/complete" => {
514 for rule in &self.config.custom_rules {
516 if matches!(rule.permission, McpPermission::Complete) {
517 for role in &auth_context.roles {
518 if rule.applies_to_role(role) {
519 return matches!(rule.action, PermissionAction::Allow);
520 }
521 }
522 }
523 }
524 auth_context
526 .roles
527 .iter()
528 .any(|role| matches!(role, Role::Admin | Role::Operator))
529 }
530 "logging/setLevel" => {
531 auth_context.roles.contains(&Role::Admin)
533 }
534 "initialize" | "ping" => {
535 true
537 }
538 _ => {
539 matches!(self.config.default_action, PermissionAction::Allow)
541 }
542 }
543 }
544
545 fn extract_tool_category(&self, tool_name: &str) -> Option<String> {
547 if tool_name.starts_with("control_") {
549 Some("control".to_string())
550 } else if tool_name.starts_with("get_") || tool_name.starts_with("list_") {
551 Some("read".to_string())
552 } else if tool_name.starts_with("set_") || tool_name.starts_with("update_") {
553 Some("write".to_string())
554 } else if tool_name.contains("_lights") || tool_name.contains("lighting") {
555 Some("lighting".to_string())
556 } else if tool_name.contains("_climate") || tool_name.contains("temperature") {
557 Some("climate".to_string())
558 } else if tool_name.contains("_security") || tool_name.contains("alarm") {
559 Some("security".to_string())
560 } else if tool_name.contains("_audio") || tool_name.contains("volume") {
561 Some("audio".to_string())
562 } else {
563 None
564 }
565 }
566
567 fn extract_resource_category(&self, resource_uri: &str) -> Option<String> {
569 if let Some(scheme_pos) = resource_uri.find("://") {
571 let after_scheme = &resource_uri[scheme_pos + 3..];
572 if let Some(slash_pos) = after_scheme.find('/') {
573 Some(after_scheme[..slash_pos].to_string())
574 } else {
575 Some(after_scheme.to_string())
576 }
577 } else {
578 None
579 }
580 }
581
582 fn matches_resource_pattern(&self, pattern: &str, uri: &str) -> bool {
584 if pattern.ends_with('*') {
585 let prefix = &pattern[..pattern.len() - 1];
586 uri.starts_with(prefix)
587 } else {
588 pattern == uri
589 }
590 }
591
592 pub fn validate_config(&self) -> Result<(), PermissionError> {
594 for rule in &self.config.custom_rules {
596 if rule.roles.is_empty() {
597 return Err(PermissionError::RoleConfig(
598 "Permission rule must specify at least one role".to_string(),
599 ));
600 }
601 }
602
603 Ok(())
604 }
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610
611 #[test]
612 fn test_permission_string_conversion() {
613 let perm = McpPermission::tool("control_device");
614 assert_eq!(perm.to_string(), "tool:control_device");
615
616 let parsed = McpPermission::from_string("tool:control_device").unwrap();
617 assert_eq!(perm, parsed);
618 }
619
620 #[test]
621 fn test_permission_rule_creation() {
622 let rule = PermissionRule::allow(
623 McpPermission::tool("test_tool"),
624 vec![Role::Admin, Role::Operator],
625 );
626
627 assert!(rule.applies_to_role(&Role::Admin));
628 assert!(rule.applies_to_role(&Role::Operator));
629 assert!(!rule.applies_to_role(&Role::Monitor));
630 assert_eq!(rule.action, PermissionAction::Allow);
631 }
632
633 #[test]
634 fn test_tool_category_extraction() {
635 let checker = McpPermissionChecker::new(PermissionConfig::default());
636
637 assert_eq!(
638 checker.extract_tool_category("control_lights"),
639 Some("control".to_string())
640 );
641 assert_eq!(
642 checker.extract_tool_category("get_status"),
643 Some("read".to_string())
644 );
645 assert_eq!(
646 checker.extract_tool_category("set_temperature"),
647 Some("write".to_string())
648 );
649 assert_eq!(
650 checker.extract_tool_category("lighting_control"),
651 Some("lighting".to_string())
652 );
653 }
654
655 #[test]
656 fn test_resource_category_extraction() {
657 let checker = McpPermissionChecker::new(PermissionConfig::default());
658
659 assert_eq!(
660 checker.extract_resource_category("loxone://devices/all"),
661 Some("devices".to_string())
662 );
663 assert_eq!(
664 checker.extract_resource_category("system://status"),
665 Some("status".to_string())
666 );
667 }
668
669 #[test]
670 fn test_resource_pattern_matching() {
671 let checker = McpPermissionChecker::new(PermissionConfig::default());
672
673 assert!(checker.matches_resource_pattern("loxone://admin/*", "loxone://admin/keys"));
674 assert!(checker.matches_resource_pattern("system://status", "system://status"));
675 assert!(!checker.matches_resource_pattern("loxone://admin/*", "loxone://devices/all"));
676 }
677
678 #[test]
679 fn test_permission_config_builder() {
680 let config = PermissionConfig::production()
681 .allow_role_tool(Role::Operator, "control_device")
682 .allow_role_resource(Role::Monitor, "system://status")
683 .deny_role_resource(Role::Monitor, "loxone://admin/*");
684
685 assert!(
686 config
687 .tools
688 .tool_permissions
689 .get("control_device")
690 .unwrap()
691 .contains(&Role::Operator)
692 );
693 assert!(
694 config
695 .resources
696 .resource_permissions
697 .get("system://status")
698 .unwrap()
699 .contains(&Role::Monitor)
700 );
701 assert_eq!(config.custom_rules.len(), 1);
702 }
703}