1use 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
12const DEFAULT_MAX_AUDIT_ENTRIES: usize = 1_000;
14
15#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
27pub enum PermissionScope {
28 Global,
30 Path(String),
32 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 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct PermissionAuditEntry {
81 pub timestamp: chrono::DateTime<chrono::Utc>,
83 pub tool: String,
85 pub action: String,
87 pub resource: Option<String>,
89 pub allowed: bool,
91 pub reason: String,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, Default)]
97struct PermissionState {
98 permissions: Vec<Permission>,
99}
100
101pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 } else {
416 tracing::debug!(
417 tool = %tool,
418 action = %action,
419 resource = ?resource,
420 "Permission check: DENIED (no matching permission)"
421 );
422 }
423
424 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 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 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 let manager = PermissionManager::new();
504 let perms = manager.list().unwrap();
506 assert!(perms.is_empty() || !perms.is_empty()); }
508
509 #[test]
510 fn permission_checks_are_audited() {
511 let manager = PermissionManager::new();
512 manager.clear_audit_log().unwrap();
513
514 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}