pulseengine_mcp_auth/permissions/
mcp_permissions.rs

1//! MCP Permission System
2//!
3//! This module provides comprehensive permission management for MCP tools,
4//! resources, and custom operations with role-based access control.
5
6use crate::{models::Role, AuthContext};
7use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, HashSet};
9use thiserror::Error;
10use tracing::debug;
11
12/// Errors that can occur during permission checking
13#[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/// MCP-specific permission types
29#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
30pub enum McpPermission {
31    /// Permission to use a specific tool
32    UseTool(String),
33
34    /// Permission to access a specific resource
35    UseResource(String),
36
37    /// Permission to use tools in a category
38    UseToolCategory(String),
39
40    /// Permission to access resources in a category
41    UseResourceCategory(String),
42
43    /// Permission to use prompts
44    UsePrompt(String),
45
46    /// Permission to subscribe to resources
47    Subscribe(String),
48
49    /// Permission to perform completion operations
50    Complete,
51
52    /// Permission to change log levels
53    SetLogLevel,
54
55    /// Administrative permissions
56    Admin(String),
57
58    /// Custom permission
59    Custom(String),
60}
61
62impl McpPermission {
63    /// Create a tool permission from a tool name
64    pub fn tool(name: &str) -> Self {
65        Self::UseTool(name.to_string())
66    }
67
68    /// Create a resource permission from a resource URI
69    pub fn resource(uri: &str) -> Self {
70        Self::UseResource(uri.to_string())
71    }
72
73    /// Create a tool category permission
74    pub fn tool_category(category: &str) -> Self {
75        Self::UseToolCategory(category.to_string())
76    }
77
78    /// Create a resource category permission
79    pub fn resource_category(category: &str) -> Self {
80        Self::UseResourceCategory(category.to_string())
81    }
82
83    /// Get a string representation of the permission
84    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    /// Parse a permission from a string
100    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/// Permission action (allow or deny)
122#[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/// Permission rule that defines access for specific roles
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct PermissionRule {
137    /// The permission this rule applies to
138    pub permission: McpPermission,
139
140    /// Roles this rule applies to
141    pub roles: Vec<Role>,
142
143    /// Action to take (allow or deny)
144    pub action: PermissionAction,
145
146    /// Optional conditions (for future expansion)
147    pub conditions: Option<HashMap<String, String>>,
148}
149
150impl PermissionRule {
151    /// Create a new allow rule
152    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    /// Create a new deny rule
162    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    /// Check if this rule applies to a given role
172    pub fn applies_to_role(&self, role: &Role) -> bool {
173        self.roles.contains(role)
174    }
175}
176
177/// Configuration for tool permissions
178#[derive(Debug, Clone, Default, Serialize, Deserialize)]
179pub struct ToolPermissionConfig {
180    /// Default permission for tools (allow or deny)
181    pub default_action: PermissionAction,
182
183    /// Specific tool permissions
184    pub tool_permissions: HashMap<String, Vec<Role>>,
185
186    /// Tool category permissions
187    pub category_permissions: HashMap<String, Vec<Role>>,
188
189    /// Tools that require admin access
190    pub admin_only_tools: HashSet<String>,
191
192    /// Tools that are read-only (allowed for monitor role)
193    pub read_only_tools: HashSet<String>,
194}
195
196/// Configuration for resource permissions
197#[derive(Debug, Clone, Default, Serialize, Deserialize)]
198pub struct ResourcePermissionConfig {
199    /// Default permission for resources (allow or deny)
200    pub default_action: PermissionAction,
201
202    /// Specific resource permissions by URI pattern
203    pub resource_permissions: HashMap<String, Vec<Role>>,
204
205    /// Resource category permissions
206    pub category_permissions: HashMap<String, Vec<Role>>,
207
208    /// Resources that require admin access
209    pub admin_only_resources: HashSet<String>,
210
211    /// Resources that are always public
212    pub public_resources: HashSet<String>,
213}
214
215/// Main permission configuration
216#[derive(Debug, Clone, Default, Serialize, Deserialize)]
217pub struct PermissionConfig {
218    /// Tool permission configuration
219    pub tools: ToolPermissionConfig,
220
221    /// Resource permission configuration
222    pub resources: ResourcePermissionConfig,
223
224    /// Custom permission rules
225    pub custom_rules: Vec<PermissionRule>,
226
227    /// Enable strict permission checking
228    pub strict_mode: bool,
229
230    /// Default action when no rule matches
231    pub default_action: PermissionAction,
232}
233
234impl PermissionConfig {
235    /// Create a permissive configuration (allows most operations)
236    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    /// Create a restrictive configuration (denies by default)
253    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    /// Create a standard production configuration
270    pub fn production() -> Self {
271        let mut config = Self::restrictive();
272
273        // Allow common read-only operations for Monitor role
274        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        // Allow public resources
282        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    /// Builder pattern for adding tool permissions
292    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    /// Builder pattern for adding resource permissions
302    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    /// Builder pattern for denying resource access
312    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
320/// MCP Permission Checker
321pub struct McpPermissionChecker {
322    config: PermissionConfig,
323}
324
325impl McpPermissionChecker {
326    /// Create a new permission checker
327    pub fn new(config: PermissionConfig) -> Self {
328        Self { config }
329    }
330
331    /// Check if a user can use a specific tool
332    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        // Check custom rules first
339        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        // Check if tool requires admin access
355        if self.config.tools.admin_only_tools.contains(tool_name) {
356            return auth_context.roles.contains(&Role::Admin);
357        }
358
359        // Check if tool is read-only (monitor role allowed)
360        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        // Check specific tool permissions
368        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        // Check tool category permissions
376        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        // Fall back to default action
386        match self.config.tools.default_action {
387            PermissionAction::Allow => true,
388            PermissionAction::Deny => false,
389        }
390    }
391
392    /// Check if a user can access a specific resource
393    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        // Check custom rules first
400        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        // Check if resource is public
416        if self
417            .config
418            .resources
419            .public_resources
420            .contains(resource_uri)
421        {
422            return true;
423        }
424
425        // Check if resource requires admin access
426        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        // Check specific resource permissions
436        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        // Check resource category permissions
446        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        // Fall back to default action
456        match self.config.resources.default_action {
457            PermissionAction::Allow => true,
458            PermissionAction::Deny => false,
459        }
460    }
461
462    /// Check if a user can use a specific prompt
463    pub fn can_use_prompt(&self, auth_context: &AuthContext, prompt_name: &str) -> bool {
464        // For now, prompts follow the same rules as tools
465        self.can_use_tool(auth_context, prompt_name)
466    }
467
468    /// Check if a user can subscribe to a resource
469    pub fn can_subscribe(&self, auth_context: &AuthContext, resource_uri: &str) -> bool {
470        // Subscription requires both resource access and subscription permission
471        if !self.can_access_resource(auth_context, resource_uri) {
472            return false;
473        }
474
475        // Check for subscription-specific rules
476        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        // Default: if you can access the resource, you can subscribe
492        true
493    }
494
495    /// Check method-level permissions
496    pub fn can_use_method(&self, auth_context: &AuthContext, method: &str) -> bool {
497        match method {
498            "tools/call" => {
499                // Will be checked per-tool in can_use_tool
500                true
501            }
502            "resources/read" | "resources/list" => {
503                // Will be checked per-resource in can_access_resource
504                true
505            }
506            "resources/subscribe" | "resources/unsubscribe" => {
507                // Subscription requires at least operator role
508                auth_context
509                    .roles
510                    .iter()
511                    .any(|role| matches!(role, Role::Admin | Role::Operator))
512            }
513            "completion/complete" => {
514                // Custom rules for completion
515                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                // Default: allow for admin and operator
525                auth_context
526                    .roles
527                    .iter()
528                    .any(|role| matches!(role, Role::Admin | Role::Operator))
529            }
530            "logging/setLevel" => {
531                // Only admin can change log levels
532                auth_context.roles.contains(&Role::Admin)
533            }
534            "initialize" | "ping" => {
535                // Always allowed
536                true
537            }
538            _ => {
539                // Unknown method - use default action
540                matches!(self.config.default_action, PermissionAction::Allow)
541            }
542        }
543    }
544
545    /// Extract tool category from tool name
546    fn extract_tool_category(&self, tool_name: &str) -> Option<String> {
547        // Common patterns for tool categorization
548        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    /// Extract resource category from URI
568    fn extract_resource_category(&self, resource_uri: &str) -> Option<String> {
569        // Parse scheme://category/... pattern
570        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    /// Check if a resource pattern matches a URI
583    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    /// Validate permission configuration
593    pub fn validate_config(&self) -> Result<(), PermissionError> {
594        // Check for conflicting rules
595        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!(config
686            .tools
687            .tool_permissions
688            .get("control_device")
689            .unwrap()
690            .contains(&Role::Operator));
691        assert!(config
692            .resources
693            .resource_permissions
694            .get("system://status")
695            .unwrap()
696            .contains(&Role::Monitor));
697        assert_eq!(config.custom_rules.len(), 1);
698    }
699}