Skip to main content

sen_plugin_host/
audit.rs

1//! Audit system for tracking permission events
2//!
3//! Provides a trait-based audit system that framework users can customize
4//! to log permission-related events to their preferred destination.
5
6use chrono::{DateTime, Utc};
7use sen_plugin_api::Capabilities;
8use serde::Serialize;
9use std::collections::VecDeque;
10use std::fmt;
11use std::fs::{File, OpenOptions};
12use std::io::{BufWriter, Write};
13use std::path::{Path, PathBuf};
14use std::sync::{Mutex, RwLock};
15use thiserror::Error;
16
17/// Timestamp type (ISO 8601 string for portability)
18pub type Timestamp = String;
19
20/// Get current timestamp in ISO 8601 format
21fn now_iso8601() -> Timestamp {
22    let now: DateTime<Utc> = Utc::now();
23    now.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
24}
25
26/// Audit event representing a permission-related action
27#[derive(Debug, Clone, Serialize)]
28pub struct AuditEvent {
29    /// Timestamp of the event
30    pub timestamp: Timestamp,
31    /// Type of event
32    pub event_type: AuditEventType,
33    /// Plugin name
34    pub plugin: String,
35    /// Command path (if applicable)
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub command: Option<String>,
38    /// Additional details
39    pub details: AuditDetails,
40}
41
42impl AuditEvent {
43    /// Create a new audit event
44    pub fn new(
45        event_type: AuditEventType,
46        plugin: impl Into<String>,
47        details: AuditDetails,
48    ) -> Self {
49        Self {
50            timestamp: now_iso8601(),
51            event_type,
52            plugin: plugin.into(),
53            command: None,
54            details,
55        }
56    }
57
58    /// Add command path to event
59    pub fn with_command(mut self, command: impl Into<String>) -> Self {
60        self.command = Some(command.into());
61        self
62    }
63}
64
65/// Type of audit event
66#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
67#[serde(rename_all = "snake_case")]
68pub enum AuditEventType {
69    /// Permission was requested
70    PermissionRequested,
71    /// Permission was granted (by user or configuration)
72    PermissionGranted,
73    /// Permission was denied
74    PermissionDenied,
75    /// A capability was used at runtime
76    CapabilityUsed,
77    /// Capability escalation was detected (plugin requests more than before)
78    EscalationDetected,
79    /// Plugin was loaded
80    PluginLoaded,
81    /// Plugin was unloaded
82    PluginUnloaded,
83}
84
85/// Details about the audit event
86#[derive(Debug, Clone, Serialize)]
87#[serde(rename_all = "snake_case", tag = "type")]
88pub enum AuditDetails {
89    /// Permission request/grant/deny details
90    Permission {
91        /// Trust level if granted
92        #[serde(skip_serializing_if = "Option::is_none")]
93        trust_level: Option<TrustLevel>,
94        /// Reason for denial
95        #[serde(skip_serializing_if = "Option::is_none")]
96        reason: Option<String>,
97        /// Capabilities involved
98        capabilities_hash: String,
99    },
100    /// File access details
101    FileAccess { path: PathBuf, mode: AccessMode },
102    /// Environment variable access
103    EnvAccess { variable: String },
104    /// Network access
105    NetworkAccess { host: String, port: Option<u16> },
106    /// Standard I/O access
107    StdioAccess { stream: StdioStream },
108    /// Capability escalation
109    Escalation { old_hash: String, new_hash: String },
110    /// Plugin lifecycle
111    Lifecycle {
112        #[serde(skip_serializing_if = "Option::is_none")]
113        path: Option<PathBuf>,
114        #[serde(skip_serializing_if = "Option::is_none")]
115        version: Option<String>,
116    },
117}
118
119/// Trust level for permission grants
120#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
121#[serde(rename_all = "snake_case")]
122pub enum TrustLevel {
123    /// Trust for this execution only
124    Once,
125    /// Trust for this session
126    Session,
127    /// Trust permanently
128    Permanent,
129}
130
131/// File access mode
132#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
133#[serde(rename_all = "snake_case")]
134pub enum AccessMode {
135    Read,
136    Write,
137    ReadWrite,
138}
139
140/// Standard I/O stream
141#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
142#[serde(rename_all = "snake_case")]
143pub enum StdioStream {
144    Stdin,
145    Stdout,
146    Stderr,
147}
148
149/// Error type for audit operations
150#[derive(Debug, Error)]
151pub enum AuditError {
152    #[error("Failed to write audit log: {0}")]
153    WriteError(#[from] std::io::Error),
154
155    #[error("Failed to serialize audit event: {0}")]
156    SerializationError(#[from] serde_json::Error),
157
158    #[error("Audit sink not available: {0}")]
159    Unavailable(String),
160}
161
162/// Trait for audit event sinks
163///
164/// Framework users implement this trait to customize where audit events are sent.
165///
166/// # Example
167///
168/// ```rust
169/// use sen_plugin_host::audit::{AuditSink, AuditEvent, AuditError};
170///
171/// struct MyCloudAuditSink {
172///     endpoint: String,
173/// }
174///
175/// impl AuditSink for MyCloudAuditSink {
176///     fn record(&self, event: AuditEvent) -> Result<(), AuditError> {
177///         // Send to cloud service
178///         println!("Would send to {}: {:?}", self.endpoint, event);
179///         Ok(())
180///     }
181///
182///     fn flush(&self) -> Result<(), AuditError> {
183///         Ok(())
184///     }
185/// }
186/// ```
187pub trait AuditSink: Send + Sync {
188    /// Record an audit event
189    fn record(&self, event: AuditEvent) -> Result<(), AuditError>;
190
191    /// Flush any buffered events
192    fn flush(&self) -> Result<(), AuditError>;
193
194    /// Check if the sink is healthy/available
195    fn is_healthy(&self) -> bool {
196        true
197    }
198}
199
200// ============================================================================
201// Default Implementations
202// ============================================================================
203
204/// File-based audit sink (JSONL format)
205///
206/// Writes audit events to a file in JSON Lines format (one JSON object per line).
207pub struct FileAuditSink {
208    path: PathBuf,
209    writer: Mutex<BufWriter<File>>,
210}
211
212impl FileAuditSink {
213    /// Create a new file audit sink
214    pub fn new(path: impl AsRef<Path>) -> Result<Self, AuditError> {
215        let path = path.as_ref().to_path_buf();
216
217        // Create parent directories if needed
218        if let Some(parent) = path.parent() {
219            std::fs::create_dir_all(parent)?;
220        }
221
222        let file = OpenOptions::new().create(true).append(true).open(&path)?;
223
224        Ok(Self {
225            path,
226            writer: Mutex::new(BufWriter::new(file)),
227        })
228    }
229
230    /// Get the log file path
231    pub fn path(&self) -> &Path {
232        &self.path
233    }
234}
235
236impl AuditSink for FileAuditSink {
237    fn record(&self, event: AuditEvent) -> Result<(), AuditError> {
238        let json = serde_json::to_string(&event)?;
239        let mut writer = self.writer.lock().expect("FileAuditSink mutex poisoned");
240        writeln!(writer, "{}", json)?;
241        Ok(())
242    }
243
244    fn flush(&self) -> Result<(), AuditError> {
245        let mut writer = self.writer.lock().expect("FileAuditSink mutex poisoned");
246        writer.flush()?;
247        Ok(())
248    }
249
250    fn is_healthy(&self) -> bool {
251        self.path.parent().map(|p| p.exists()).unwrap_or(true)
252    }
253}
254
255impl fmt::Debug for FileAuditSink {
256    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257        f.debug_struct("FileAuditSink")
258            .field("path", &self.path)
259            .finish()
260    }
261}
262
263/// In-memory audit sink for testing
264///
265/// Uses a ring buffer (VecDeque) for O(1) FIFO eviction when capacity is reached.
266pub struct MemoryAuditSink {
267    events: RwLock<VecDeque<AuditEvent>>,
268    max_events: usize,
269}
270
271impl MemoryAuditSink {
272    /// Create a new memory sink with default capacity (1000 events)
273    pub fn new() -> Self {
274        Self::with_capacity(1000)
275    }
276
277    /// Create a new memory sink with specified capacity
278    pub fn with_capacity(max_events: usize) -> Self {
279        Self {
280            events: RwLock::new(VecDeque::with_capacity(max_events.min(1000))),
281            max_events,
282        }
283    }
284
285    /// Get all recorded events
286    pub fn events(&self) -> Vec<AuditEvent> {
287        self.events
288            .read()
289            .expect("MemoryAuditSink RwLock poisoned")
290            .iter()
291            .cloned()
292            .collect()
293    }
294
295    /// Get event count
296    pub fn count(&self) -> usize {
297        self.events
298            .read()
299            .expect("MemoryAuditSink RwLock poisoned")
300            .len()
301    }
302
303    /// Clear all events
304    pub fn clear(&self) {
305        self.events
306            .write()
307            .expect("MemoryAuditSink RwLock poisoned")
308            .clear();
309    }
310
311    /// Find events by type
312    pub fn find_by_type(&self, event_type: AuditEventType) -> Vec<AuditEvent> {
313        self.events
314            .read()
315            .expect("MemoryAuditSink RwLock poisoned")
316            .iter()
317            .filter(|e| e.event_type == event_type)
318            .cloned()
319            .collect()
320    }
321
322    /// Find events by plugin
323    pub fn find_by_plugin(&self, plugin: &str) -> Vec<AuditEvent> {
324        self.events
325            .read()
326            .expect("MemoryAuditSink RwLock poisoned")
327            .iter()
328            .filter(|e| e.plugin == plugin)
329            .cloned()
330            .collect()
331    }
332}
333
334impl Default for MemoryAuditSink {
335    fn default() -> Self {
336        Self::new()
337    }
338}
339
340impl AuditSink for MemoryAuditSink {
341    fn record(&self, event: AuditEvent) -> Result<(), AuditError> {
342        let mut events = self
343            .events
344            .write()
345            .expect("MemoryAuditSink RwLock poisoned");
346        if events.len() >= self.max_events {
347            events.pop_front(); // O(1) FIFO eviction with VecDeque
348        }
349        events.push_back(event);
350        Ok(())
351    }
352
353    fn flush(&self) -> Result<(), AuditError> {
354        Ok(())
355    }
356}
357
358impl fmt::Debug for MemoryAuditSink {
359    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
360        f.debug_struct("MemoryAuditSink")
361            .field("count", &self.count())
362            .field("max_events", &self.max_events)
363            .finish()
364    }
365}
366
367/// Null audit sink (discards all events)
368#[derive(Debug, Default)]
369pub struct NullAuditSink;
370
371impl NullAuditSink {
372    pub fn new() -> Self {
373        Self
374    }
375}
376
377impl AuditSink for NullAuditSink {
378    fn record(&self, _event: AuditEvent) -> Result<(), AuditError> {
379        Ok(())
380    }
381
382    fn flush(&self) -> Result<(), AuditError> {
383        Ok(())
384    }
385}
386
387/// Composite audit sink that writes to multiple sinks
388pub struct CompositeAuditSink {
389    sinks: Vec<Box<dyn AuditSink>>,
390}
391
392impl CompositeAuditSink {
393    pub fn new() -> Self {
394        Self { sinks: Vec::new() }
395    }
396
397    pub fn with_sink(mut self, sink: impl AuditSink + 'static) -> Self {
398        self.sinks.push(Box::new(sink));
399        self
400    }
401}
402
403impl Default for CompositeAuditSink {
404    fn default() -> Self {
405        Self::new()
406    }
407}
408
409impl AuditSink for CompositeAuditSink {
410    fn record(&self, event: AuditEvent) -> Result<(), AuditError> {
411        for sink in &self.sinks {
412            sink.record(event.clone())?;
413        }
414        Ok(())
415    }
416
417    fn flush(&self) -> Result<(), AuditError> {
418        for sink in &self.sinks {
419            sink.flush()?;
420        }
421        Ok(())
422    }
423
424    fn is_healthy(&self) -> bool {
425        self.sinks.iter().all(|s| s.is_healthy())
426    }
427}
428
429impl fmt::Debug for CompositeAuditSink {
430    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
431        f.debug_struct("CompositeAuditSink")
432            .field("sink_count", &self.sinks.len())
433            .finish()
434    }
435}
436
437// ============================================================================
438// Helper functions
439// ============================================================================
440
441/// Create an audit event for permission request
442pub fn permission_requested(plugin: &str, capabilities: &Capabilities) -> AuditEvent {
443    AuditEvent::new(
444        AuditEventType::PermissionRequested,
445        plugin,
446        AuditDetails::Permission {
447            trust_level: None,
448            reason: None,
449            capabilities_hash: capabilities.compute_hash(),
450        },
451    )
452}
453
454/// Create an audit event for permission granted
455pub fn permission_granted(
456    plugin: &str,
457    capabilities: &Capabilities,
458    trust_level: TrustLevel,
459) -> AuditEvent {
460    AuditEvent::new(
461        AuditEventType::PermissionGranted,
462        plugin,
463        AuditDetails::Permission {
464            trust_level: Some(trust_level),
465            reason: None,
466            capabilities_hash: capabilities.compute_hash(),
467        },
468    )
469}
470
471/// Create an audit event for permission denied
472pub fn permission_denied(plugin: &str, capabilities: &Capabilities, reason: &str) -> AuditEvent {
473    AuditEvent::new(
474        AuditEventType::PermissionDenied,
475        plugin,
476        AuditDetails::Permission {
477            trust_level: None,
478            reason: Some(reason.to_string()),
479            capabilities_hash: capabilities.compute_hash(),
480        },
481    )
482}
483
484/// Create an audit event for escalation detection
485pub fn escalation_detected(
486    plugin: &str,
487    old_caps: &Capabilities,
488    new_caps: &Capabilities,
489) -> AuditEvent {
490    AuditEvent::new(
491        AuditEventType::EscalationDetected,
492        plugin,
493        AuditDetails::Escalation {
494            old_hash: old_caps.compute_hash(),
495            new_hash: new_caps.compute_hash(),
496        },
497    )
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use sen_plugin_api::PathPattern;
504
505    #[test]
506    fn test_memory_sink() {
507        let sink = MemoryAuditSink::new();
508        let caps = Capabilities::default().with_fs_read(vec![PathPattern::new("./data")]);
509
510        let event = permission_requested("test-plugin", &caps);
511        sink.record(event).unwrap();
512
513        assert_eq!(sink.count(), 1);
514        let events = sink.find_by_type(AuditEventType::PermissionRequested);
515        assert_eq!(events.len(), 1);
516        assert_eq!(events[0].plugin, "test-plugin");
517    }
518
519    #[test]
520    fn test_memory_sink_eviction() {
521        let sink = MemoryAuditSink::with_capacity(2);
522        let caps = Capabilities::none();
523
524        for i in 0..3 {
525            let event = permission_requested(&format!("plugin-{}", i), &caps);
526            sink.record(event).unwrap();
527        }
528
529        assert_eq!(sink.count(), 2);
530        let events = sink.events();
531        assert_eq!(events[0].plugin, "plugin-1");
532        assert_eq!(events[1].plugin, "plugin-2");
533    }
534
535    #[test]
536    fn test_null_sink() {
537        let sink = NullAuditSink::new();
538        let caps = Capabilities::none();
539
540        let event = permission_requested("test", &caps);
541        assert!(sink.record(event).is_ok());
542        assert!(sink.flush().is_ok());
543    }
544
545    #[test]
546    fn test_composite_sink() {
547        let memory1 = MemoryAuditSink::new();
548        let memory2 = MemoryAuditSink::new();
549        let caps = Capabilities::none();
550
551        // We can't use the composite directly with borrowed sinks,
552        // so test the concept
553        let event = permission_requested("test", &caps);
554        memory1.record(event.clone()).unwrap();
555        memory2.record(event).unwrap();
556
557        assert_eq!(memory1.count(), 1);
558        assert_eq!(memory2.count(), 1);
559    }
560
561    #[test]
562    fn test_event_serialization() {
563        let caps = Capabilities::default().with_fs_read(vec![PathPattern::new("./data")]);
564        let event =
565            permission_granted("test", &caps, TrustLevel::Permanent).with_command("data:export");
566
567        let json = serde_json::to_string(&event).unwrap();
568        assert!(json.contains("permission_granted"));
569        assert!(json.contains("test"));
570        assert!(json.contains("permanent"));
571    }
572
573    #[test]
574    fn test_file_sink() {
575        let dir = tempfile::tempdir().unwrap();
576        let path = dir.path().join("audit.jsonl");
577
578        let sink = FileAuditSink::new(&path).unwrap();
579        let caps = Capabilities::none();
580
581        let event = permission_requested("test", &caps);
582        sink.record(event).unwrap();
583        sink.flush().unwrap();
584
585        let content = std::fs::read_to_string(&path).unwrap();
586        assert!(content.contains("permission_requested"));
587    }
588}