Skip to main content

st/collab/
permissions.rs

1//! Permissions - Work by default, deny when necessary
2//!
3//! Philosophy: Enable people, don't block them.
4//! Instead of whitelisting what users CAN do, we only specify
5//! what they CAN'T do (deny patterns).
6
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9
10/// Access level for a collaborator
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)]
12pub enum AccessLevel {
13    /// No access
14    None,
15    /// Can view files and run read-only tools
16    #[default]
17    Read,
18    /// Can edit files in their space
19    Write,
20    /// Can manage other collaborators, settings
21    Admin,
22    /// Project owner - full control
23    Owner,
24}
25
26/// A specific permission grant or deny
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct Permission {
29    /// What action (read, write, execute, admin)
30    pub action: PermissionAction,
31    /// Path pattern (glob) this applies to
32    pub path_pattern: Option<String>,
33    /// Whether this is a grant or deny
34    pub effect: PermissionEffect,
35}
36
37/// Permission action types
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39pub enum PermissionAction {
40    /// Read files
41    Read,
42    /// Write/edit files
43    Write,
44    /// Execute commands
45    Execute,
46    /// Run specific tools
47    Tool(String),
48    /// Administrative actions
49    Admin,
50    /// All actions
51    All,
52}
53
54/// Grant or deny
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
56pub enum PermissionEffect {
57    /// Allow this action (rarely needed - work by default)
58    Allow,
59    /// Deny this action (primary mechanism)
60    Deny,
61}
62
63/// Project access configuration
64#[derive(Debug, Clone, Serialize, Deserialize, Default)]
65pub struct ProjectAccess {
66    /// Default access level for new collaborators
67    pub default_level: AccessLevel,
68
69    /// Specific deny rules (work by default, deny specific things)
70    pub deny_rules: Vec<Permission>,
71
72    /// Paths that are always off-limits (secrets, credentials)
73    pub protected_paths: Vec<String>,
74
75    /// Tools that require explicit approval
76    pub restricted_tools: Vec<String>,
77}
78
79impl ProjectAccess {
80    /// Create with sensible defaults
81    pub fn new() -> Self {
82        ProjectAccess {
83            default_level: AccessLevel::Write,
84            deny_rules: Vec::new(),
85            protected_paths: vec![
86                ".env".to_string(),
87                ".env.*".to_string(),
88                "**/secrets/**".to_string(),
89                "**/*.pem".to_string(),
90                "**/*.key".to_string(),
91                "**/credentials*".to_string(),
92            ],
93            restricted_tools: vec![
94                "execute_command".to_string(), // Needs approval
95            ],
96        }
97    }
98
99    /// Check if a path is accessible
100    pub fn can_access_path(&self, path: &Path, level: &AccessLevel) -> bool {
101        // Owner can access everything
102        if *level == AccessLevel::Owner {
103            return true;
104        }
105
106        let path_str = path.to_string_lossy();
107
108        // Check protected paths
109        for pattern in &self.protected_paths {
110            if Self::matches_glob(pattern, &path_str) {
111                return false;
112            }
113        }
114
115        // Check deny rules
116        for rule in &self.deny_rules {
117            if rule.effect == PermissionEffect::Deny {
118                if let Some(ref pattern) = rule.path_pattern {
119                    if Self::matches_glob(pattern, &path_str) {
120                        return false;
121                    }
122                }
123            }
124        }
125
126        true
127    }
128
129    /// Check if a tool can be used
130    pub fn can_use_tool(&self, tool: &str, level: &AccessLevel) -> bool {
131        // Owner/Admin can use all tools
132        if *level >= AccessLevel::Admin {
133            return true;
134        }
135
136        // Check if tool is restricted
137        !self.restricted_tools.contains(&tool.to_string())
138    }
139
140    /// Simple glob matching (supports * and **)
141    fn matches_glob(pattern: &str, path: &str) -> bool {
142        // Simple implementation - could use glob crate for full support
143        if pattern.contains("**") {
144            // ** matches any path segments
145            let parts: Vec<&str> = pattern.split("**").collect();
146            if parts.len() == 2 {
147                let prefix = parts[0].trim_end_matches('/');
148                let suffix = parts[1].trim_start_matches('/');
149
150                // Empty prefix means match from start
151                let prefix_ok = prefix.is_empty() || path.starts_with(prefix);
152
153                // For suffix, need to handle *.ext patterns
154                let suffix_ok = if suffix.is_empty() {
155                    true
156                } else if suffix.starts_with('*') {
157                    // Handle *.ext pattern in suffix
158                    let ext = suffix.trim_start_matches('*');
159                    path.ends_with(ext)
160                } else {
161                    path.ends_with(suffix)
162                };
163
164                return prefix_ok && suffix_ok;
165            }
166        }
167
168        // Simple * matching
169        if pattern.contains('*') && !pattern.contains("**") {
170            let parts: Vec<&str> = pattern.split('*').collect();
171            if parts.len() == 2 {
172                return path.starts_with(parts[0]) && path.ends_with(parts[1]);
173            }
174        }
175
176        // Exact match
177        pattern == path
178    }
179
180    /// Add a deny rule
181    pub fn deny(&mut self, action: PermissionAction, path_pattern: Option<&str>) {
182        self.deny_rules.push(Permission {
183            action,
184            path_pattern: path_pattern.map(String::from),
185            effect: PermissionEffect::Deny,
186        });
187    }
188
189    /// Protect a path pattern
190    pub fn protect_path(&mut self, pattern: &str) {
191        self.protected_paths.push(pattern.to_string());
192    }
193
194    /// Restrict a tool (require approval)
195    pub fn restrict_tool(&mut self, tool: &str) {
196        if !self.restricted_tools.contains(&tool.to_string()) {
197            self.restricted_tools.push(tool.to_string());
198        }
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn test_default_access() {
208        let access = ProjectAccess::new();
209        assert_eq!(access.default_level, AccessLevel::Write);
210    }
211
212    #[test]
213    fn test_protected_paths() {
214        let access = ProjectAccess::new();
215        let level = AccessLevel::Write;
216
217        // Should deny access to .env files
218        assert!(!access.can_access_path(Path::new(".env"), &level));
219        assert!(!access.can_access_path(Path::new(".env.production"), &level));
220
221        // Should allow normal files
222        assert!(access.can_access_path(Path::new("src/main.rs"), &level));
223    }
224
225    #[test]
226    fn test_owner_bypasses_all() {
227        let access = ProjectAccess::new();
228        let level = AccessLevel::Owner;
229
230        // Owner can access protected paths
231        assert!(access.can_access_path(Path::new(".env"), &level));
232        assert!(access.can_access_path(Path::new("secrets/api.key"), &level));
233    }
234
235    #[test]
236    fn test_glob_matching() {
237        assert!(ProjectAccess::matches_glob("*.rs", "main.rs"));
238        assert!(ProjectAccess::matches_glob("**/*.key", "secrets/api.key"));
239        assert!(ProjectAccess::matches_glob(".env.*", ".env.production"));
240        assert!(!ProjectAccess::matches_glob("*.rs", "main.py"));
241    }
242}