gestura_core_tools/
permissions.rs

1//! Permission management for tool access
2//!
3//! Provides permission management with structured output.
4
5use crate::error::{AppError, Result};
6use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, HashSet};
8use std::fs;
9use std::path::PathBuf;
10use std::sync::RwLock;
11
12/// Default maximum number of audit log entries retained in memory.
13const DEFAULT_MAX_AUDIT_ENTRIES: usize = 1_000;
14
15/// A permission grant
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Permission {
18    pub tool: String,
19    pub action: String,
20    pub scope: PermissionScope,
21    pub granted_at: chrono::DateTime<chrono::Utc>,
22    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
23}
24
25/// Scope of a permission
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
27pub enum PermissionScope {
28    /// Permission applies to all resources
29    Global,
30    /// Permission applies to a specific path pattern
31    Path(String),
32    /// Permission applies to a specific command pattern
33    Command(String),
34}
35
36impl std::fmt::Display for PermissionScope {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            Self::Global => write!(f, "global"),
40            Self::Path(p) => write!(f, "{}", p),
41            Self::Command(c) => write!(f, "{}", c),
42        }
43    }
44}
45
46impl std::str::FromStr for PermissionScope {
47    type Err = std::convert::Infallible;
48
49    /// Parse a permission scope from a string.
50    ///
51    /// - Empty or `"global"` → [`Global`](PermissionScope::Global)
52    /// - Starts with `/` → [`Path`](PermissionScope::Path)
53    /// - Otherwise → [`Command`](PermissionScope::Command)
54    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
55        let s = s.trim();
56        if s.is_empty() || s.eq_ignore_ascii_case("global") {
57            Ok(Self::Global)
58        } else if s.starts_with('/') {
59            Ok(Self::Path(s.to_string()))
60        } else {
61            Ok(Self::Command(s.to_string()))
62        }
63    }
64}
65
66/// Permission check result
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct PermissionCheck {
69    pub tool: String,
70    pub action: String,
71    pub allowed: bool,
72    pub reason: String,
73}
74
75/// Audit log entry for a permission check.
76///
77/// This is an in-memory, best-effort trail intended for observability and
78/// debugging (e.g. showing why a tool action was allowed or denied).
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct PermissionAuditEntry {
81    /// When the check occurred.
82    pub timestamp: chrono::DateTime<chrono::Utc>,
83    /// Tool name (e.g. "file", "shell").
84    pub tool: String,
85    /// Action name (e.g. "read", "write").
86    pub action: String,
87    /// Optional resource being checked (e.g. a path or command).
88    pub resource: Option<String>,
89    /// Whether the check was allowed.
90    pub allowed: bool,
91    /// Human-readable reason for the decision.
92    pub reason: String,
93}
94
95/// Persisted permission state
96#[derive(Debug, Clone, Serialize, Deserialize, Default)]
97struct PermissionState {
98    permissions: Vec<Permission>,
99}
100
101/// Permission management service
102pub struct PermissionManager {
103    permissions: RwLock<HashMap<String, HashSet<Permission>>>,
104    config_path: PathBuf,
105    audit_log: RwLock<Vec<PermissionAuditEntry>>,
106    max_audit_entries: usize,
107}
108
109impl Permission {
110    fn key(&self) -> String {
111        format!("{}:{}", self.tool, self.action)
112    }
113}
114
115impl std::hash::Hash for Permission {
116    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
117        self.tool.hash(state);
118        self.action.hash(state);
119        self.scope.hash(state);
120    }
121}
122
123impl PartialEq for Permission {
124    fn eq(&self, other: &Self) -> bool {
125        self.tool == other.tool && self.action == other.action && self.scope == other.scope
126    }
127}
128
129impl Eq for Permission {}
130
131impl Default for PermissionManager {
132    fn default() -> Self {
133        Self::new()
134    }
135}
136
137impl PermissionManager {
138    /// Create a new permission manager.
139    ///
140    /// Loads persisted permissions from `~/.config/gestura/permissions.json` (or
141    /// the platform equivalent). The audit log is kept in-memory only.
142    pub fn new() -> Self {
143        let config_path = dirs::config_dir()
144            .unwrap_or_else(|| PathBuf::from("."))
145            .join("gestura")
146            .join("permissions.json");
147
148        Self::from_config_path(config_path)
149    }
150
151    /// Create a permission manager backed by an explicit config path.
152    ///
153    /// This is useful for tests and isolated runtimes that must avoid reading or
154    /// writing the default user-scoped permissions file.
155    pub fn from_config_path(config_path: PathBuf) -> Self {
156        tracing::debug!(
157            config_path = ?config_path,
158            "Initializing PermissionManager"
159        );
160
161        let manager = Self {
162            permissions: RwLock::new(HashMap::new()),
163            config_path,
164            audit_log: RwLock::new(Vec::new()),
165            max_audit_entries: DEFAULT_MAX_AUDIT_ENTRIES,
166        };
167        let _ = manager.load();
168        manager
169    }
170
171    /// Return a snapshot of the permission audit log.
172    pub fn audit_log(&self) -> Result<Vec<PermissionAuditEntry>> {
173        let log = self
174            .audit_log
175            .read()
176            .map_err(|e| AppError::Io(std::io::Error::other(format!("Lock error: {e}"))))?;
177        Ok(log.clone())
178    }
179
180    /// Clear the permission audit log.
181    ///
182    /// Returns the number of entries removed.
183    pub fn clear_audit_log(&self) -> Result<usize> {
184        let mut log = self
185            .audit_log
186            .write()
187            .map_err(|e| AppError::Io(std::io::Error::other(format!("Lock error: {e}"))))?;
188        let removed = log.len();
189        log.clear();
190        Ok(removed)
191    }
192
193    /// Record an audit entry, trimming the log to the configured maximum.
194    fn push_audit_entry(&self, entry: PermissionAuditEntry) {
195        let Ok(mut log) = self.audit_log.write() else {
196            return;
197        };
198
199        log.push(entry);
200
201        // Trim oldest entries if we exceed capacity.
202        if self.max_audit_entries > 0 && log.len() > self.max_audit_entries {
203            let overflow = log.len() - self.max_audit_entries;
204            log.drain(0..overflow);
205        }
206    }
207
208    /// Load permissions from disk
209    fn load(&self) -> Result<()> {
210        tracing::debug!(
211            config_path = ?self.config_path,
212            exists = self.config_path.exists(),
213            "Loading permissions from disk"
214        );
215
216        if self.config_path.exists() {
217            let content = fs::read_to_string(&self.config_path)?;
218            let state: PermissionState = serde_json::from_str(&content).map_err(AppError::Json)?;
219
220            let mut perms = self
221                .permissions
222                .write()
223                .map_err(|e| AppError::Io(std::io::Error::other(format!("Lock error: {e}"))))?;
224
225            let count = state.permissions.len();
226            for perm in state.permissions {
227                perms.entry(perm.key()).or_default().insert(perm);
228            }
229
230            tracing::debug!(
231                config_path = ?self.config_path,
232                permissions_loaded = count,
233                "Permissions loaded successfully"
234            );
235        } else {
236            tracing::debug!(
237                config_path = ?self.config_path,
238                "No permissions file found, starting with empty permissions"
239            );
240        }
241        Ok(())
242    }
243
244    /// Save permissions to disk
245    fn save(&self) -> Result<()> {
246        let perms = self
247            .permissions
248            .read()
249            .map_err(|e| AppError::Io(std::io::Error::other(format!("Lock error: {e}"))))?;
250
251        let all_perms: Vec<Permission> = perms.values().flatten().cloned().collect();
252        let state = PermissionState {
253            permissions: all_perms,
254        };
255
256        if let Some(parent) = self.config_path.parent() {
257            fs::create_dir_all(parent)?;
258        }
259
260        let content = serde_json::to_string_pretty(&state).map_err(AppError::Json)?;
261        fs::write(&self.config_path, &content)?;
262
263        tracing::debug!(
264            config_path = ?self.config_path,
265            permissions_saved = state.permissions.len(),
266            "Permissions saved to disk"
267        );
268        Ok(())
269    }
270
271    /// List all permissions
272    pub fn list(&self) -> Result<Vec<Permission>> {
273        let perms = self
274            .permissions
275            .read()
276            .map_err(|e| AppError::Io(std::io::Error::other(format!("Lock error: {e}"))))?;
277        Ok(perms.values().flatten().cloned().collect())
278    }
279
280    /// Grant a permission
281    pub fn grant(
282        &self,
283        tool: &str,
284        action: &str,
285        scope: PermissionScope,
286        ttl_secs: Option<u64>,
287    ) -> Result<Permission> {
288        tracing::debug!(
289            tool = %tool,
290            action = %action,
291            scope = ?scope,
292            ttl_secs = ?ttl_secs,
293            "Granting permission"
294        );
295
296        let perm = Permission {
297            tool: tool.to_string(),
298            action: action.to_string(),
299            scope,
300            granted_at: chrono::Utc::now(),
301            expires_at: ttl_secs.map(|s| chrono::Utc::now() + chrono::Duration::seconds(s as i64)),
302        };
303
304        let mut perms = self
305            .permissions
306            .write()
307            .map_err(|e| AppError::Io(std::io::Error::other(format!("Lock error: {e}"))))?;
308        perms.entry(perm.key()).or_default().insert(perm.clone());
309        drop(perms);
310        self.save()?;
311
312        tracing::info!(
313            tool = %tool,
314            action = %action,
315            expires_at = ?perm.expires_at,
316            "Permission granted successfully"
317        );
318        Ok(perm)
319    }
320
321    /// Revoke a permission
322    pub fn revoke(&self, tool: &str, action: &str) -> Result<usize> {
323        tracing::debug!(
324            tool = %tool,
325            action = %action,
326            "Revoking permission"
327        );
328
329        let key = format!("{tool}:{action}");
330        let mut perms = self
331            .permissions
332            .write()
333            .map_err(|e| AppError::Io(std::io::Error::other(format!("Lock error: {e}"))))?;
334        let removed = perms.remove(&key).map(|s| s.len()).unwrap_or(0);
335        drop(perms);
336        self.save()?;
337
338        tracing::info!(
339            tool = %tool,
340            action = %action,
341            removed_count = removed,
342            "Permission revoked"
343        );
344        Ok(removed)
345    }
346
347    /// Check if an action is permitted
348    pub fn check(
349        &self,
350        tool: &str,
351        action: &str,
352        resource: Option<&str>,
353    ) -> Result<PermissionCheck> {
354        tracing::debug!(
355            tool = %tool,
356            action = %action,
357            resource = ?resource,
358            "Checking permission"
359        );
360
361        let perms = self
362            .permissions
363            .read()
364            .map_err(|e| AppError::Io(std::io::Error::other(format!("Lock error: {e}"))))?;
365
366        let key = format!("{tool}:{action}");
367        let now = chrono::Utc::now();
368
369        let mut allowed = false;
370        let mut reason = "No matching permission found".to_string();
371
372        if let Some(perm_set) = perms.get(&key) {
373            for perm in perm_set {
374                // Check expiry
375                if let Some(expires) = perm.expires_at
376                    && expires < now
377                {
378                    tracing::debug!(
379                        tool = %tool,
380                        action = %action,
381                        expires_at = ?expires,
382                        "Permission expired, skipping"
383                    );
384                    continue;
385                }
386
387                // Check scope
388                let matches = match &perm.scope {
389                    PermissionScope::Global => true,
390                    PermissionScope::Path(pattern) => {
391                        resource.map(|r| r.starts_with(pattern)).unwrap_or(false)
392                    }
393                    PermissionScope::Command(pattern) => {
394                        resource.map(|r| r.contains(pattern)).unwrap_or(false)
395                    }
396                };
397
398                if matches {
399                    tracing::debug!(
400                        tool = %tool,
401                        action = %action,
402                        resource = ?resource,
403                        scope = ?perm.scope,
404                        "Permission check: ALLOWED"
405                    );
406                    allowed = true;
407                    reason = "Permission granted".to_string();
408                    break;
409                }
410            }
411        }
412
413        if allowed {
414            // already logged as allowed above
415        } else {
416            tracing::debug!(
417                tool = %tool,
418                action = %action,
419                resource = ?resource,
420                "Permission check: DENIED (no matching permission)"
421            );
422        }
423
424        // Best-effort audit trail (in-memory).
425        self.push_audit_entry(PermissionAuditEntry {
426            timestamp: chrono::Utc::now(),
427            tool: tool.to_string(),
428            action: action.to_string(),
429            resource: resource.map(|r| r.to_string()),
430            allowed,
431            reason: reason.clone(),
432        });
433
434        Ok(PermissionCheck {
435            tool: tool.to_string(),
436            action: action.to_string(),
437            allowed,
438            reason,
439        })
440    }
441
442    /// Reset all permissions
443    pub fn reset(&self) -> Result<usize> {
444        let mut perms = self
445            .permissions
446            .write()
447            .map_err(|e| AppError::Io(std::io::Error::other(format!("Lock error: {e}"))))?;
448        let count = perms.values().map(|s| s.len()).sum();
449        perms.clear();
450        drop(perms);
451
452        // Delete the file
453        if self.config_path.exists() {
454            fs::remove_file(&self.config_path)?;
455        }
456
457        Ok(count)
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn test_permission_scope_variants() {
467        let global = PermissionScope::Global;
468        let path = PermissionScope::Path("/home/*".to_string());
469        let cmd = PermissionScope::Command("echo *".to_string());
470
471        assert!(matches!(global, PermissionScope::Global));
472        assert!(matches!(path, PermissionScope::Path(_)));
473        assert!(matches!(cmd, PermissionScope::Command(_)));
474    }
475
476    #[test]
477    fn test_permission_struct() {
478        let perm = Permission {
479            tool: "file".to_string(),
480            action: "read".to_string(),
481            scope: PermissionScope::Global,
482            granted_at: chrono::Utc::now(),
483            expires_at: None,
484        };
485        assert_eq!(perm.tool, "file");
486        assert_eq!(perm.action, "read");
487    }
488
489    #[test]
490    fn test_permission_check_struct() {
491        let check = PermissionCheck {
492            tool: "file".to_string(),
493            action: "read".to_string(),
494            allowed: true,
495            reason: "Granted".to_string(),
496        };
497        assert!(check.allowed);
498    }
499
500    #[test]
501    fn test_permission_manager_new() {
502        // Just test that we can create a manager
503        let manager = PermissionManager::new();
504        // List should work even if empty
505        let perms = manager.list().unwrap();
506        assert!(perms.is_empty() || !perms.is_empty()); // Just check it doesn't panic
507    }
508
509    #[test]
510    fn permission_checks_are_audited() {
511        let manager = PermissionManager::new();
512        manager.clear_audit_log().unwrap();
513
514        // Any check (allowed or denied) should produce an audit entry.
515        let _ = manager
516            .check("file", "read", Some("/tmp/test.txt"))
517            .unwrap();
518
519        let log = manager.audit_log().unwrap();
520        assert_eq!(log.len(), 1);
521        assert_eq!(log[0].tool, "file");
522        assert_eq!(log[0].action, "read");
523        assert_eq!(log[0].resource.as_deref(), Some("/tmp/test.txt"));
524    }
525}