Skip to main content

quantrs2_device/security/
audit.rs

1//! Structured JSON audit logging for quantum job operations.
2//!
3//! Every cloud API interaction (circuit submission, result fetch, job cancel)
4//! can be recorded to an audit trail for compliance and debugging.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::PathBuf;
9use std::sync::{Arc, Mutex};
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
11
12/// Type of quantum operation being audited
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[non_exhaustive]
15pub enum OperationType {
16    /// Submit a quantum circuit for execution
17    CircuitSubmit,
18    /// Fetch results of a completed job
19    ResultFetch,
20    /// Query available backends
21    BackendQuery,
22    /// Authenticate with a cloud provider
23    Authentication,
24    /// Cancel a running or queued job
25    JobCancel,
26    /// Fetch device calibration data
27    CalibrationFetch,
28    /// Check status of a job
29    StatusCheck,
30}
31
32impl std::fmt::Display for OperationType {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        let s = match self {
35            OperationType::CircuitSubmit => "CircuitSubmit",
36            OperationType::ResultFetch => "ResultFetch",
37            OperationType::BackendQuery => "BackendQuery",
38            OperationType::Authentication => "Authentication",
39            OperationType::JobCancel => "JobCancel",
40            OperationType::CalibrationFetch => "CalibrationFetch",
41            OperationType::StatusCheck => "StatusCheck",
42        };
43        f.write_str(s)
44    }
45}
46
47/// A single audit log entry
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct AuditEvent {
50    /// Unique event ID (timestamp + random suffix)
51    pub id: String,
52    /// Unix timestamp in milliseconds
53    pub timestamp_ms: u64,
54    /// Operation type
55    pub operation: OperationType,
56    /// Backend/provider name (e.g. "ibm_nairobi", "aws_sv1")
57    pub backend_id: Option<String>,
58    /// FNV-1a hex digest of the circuit string (for correlation without exposing circuit)
59    pub circuit_hash: Option<String>,
60    /// User or service identifier
61    pub user_id: Option<String>,
62    /// Whether the operation succeeded
63    pub success: bool,
64    /// Duration of the operation in milliseconds
65    pub duration_ms: Option<u64>,
66    /// Error description (if success == false)
67    pub error: Option<String>,
68    /// Additional key-value metadata
69    pub metadata: HashMap<String, String>,
70}
71
72impl AuditEvent {
73    /// Create a new audit event with current timestamp
74    pub fn new(operation: OperationType, success: bool) -> Self {
75        let timestamp_ms = SystemTime::now()
76            .duration_since(UNIX_EPOCH)
77            .unwrap_or(Duration::ZERO)
78            .as_millis() as u64;
79
80        let id = format!("{}-{}", timestamp_ms, fastrand::u64(..));
81
82        Self {
83            id,
84            timestamp_ms,
85            operation,
86            backend_id: None,
87            circuit_hash: None,
88            user_id: None,
89            success,
90            duration_ms: None,
91            error: None,
92            metadata: HashMap::new(),
93        }
94    }
95
96    /// Set the backend identifier
97    pub fn with_backend(mut self, backend: impl Into<String>) -> Self {
98        self.backend_id = Some(backend.into());
99        self
100    }
101
102    /// Set the circuit hash for correlation
103    pub fn with_circuit_hash(mut self, hash: impl Into<String>) -> Self {
104        self.circuit_hash = Some(hash.into());
105        self
106    }
107
108    /// Set the user or service identifier
109    pub fn with_user(mut self, user_id: impl Into<String>) -> Self {
110        self.user_id = Some(user_id.into());
111        self
112    }
113
114    /// Set the operation duration in milliseconds
115    pub fn with_duration_ms(mut self, ms: u64) -> Self {
116        self.duration_ms = Some(ms);
117        self
118    }
119
120    /// Set an error description
121    pub fn with_error(mut self, error: impl Into<String>) -> Self {
122        self.error = Some(error.into());
123        self
124    }
125
126    /// Add arbitrary metadata key-value pair
127    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
128        self.metadata.insert(key.into(), value.into());
129        self
130    }
131}
132
133/// Compute an FNV-1a hex string for circuit correlation.
134///
135/// Note: not cryptographically secure — suitable for correlation/deduplication only.
136pub fn circuit_hash(circuit: &str) -> String {
137    let mut h: u64 = 0xcbf29ce484222325; // FNV-1a offset basis
138    for byte in circuit.bytes() {
139        h ^= byte as u64;
140        h = h.wrapping_mul(0x100000001b3); // FNV prime
141    }
142    format!("{:016x}", h)
143}
144
145/// Errors from audit logging
146#[derive(Debug)]
147#[non_exhaustive]
148pub enum AuditError {
149    /// Underlying I/O failure
150    IoError(std::io::Error),
151    /// JSON serialization failure
152    SerializationError(String),
153}
154
155impl std::fmt::Display for AuditError {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        match self {
158            AuditError::IoError(e) => write!(f, "audit IO error: {}", e),
159            AuditError::SerializationError(s) => write!(f, "audit serialization error: {}", s),
160        }
161    }
162}
163
164impl std::error::Error for AuditError {
165    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
166        match self {
167            AuditError::IoError(e) => Some(e),
168            AuditError::SerializationError(_) => None,
169        }
170    }
171}
172
173impl From<std::io::Error> for AuditError {
174    fn from(e: std::io::Error) -> Self {
175        AuditError::IoError(e)
176    }
177}
178
179/// Trait for audit log sinks
180pub trait AuditLogger: Send + Sync {
181    /// Record an audit event
182    fn log(&self, event: AuditEvent) -> Result<(), AuditError>;
183    /// Flush any buffered events
184    fn flush(&self) -> Result<(), AuditError>;
185    /// Return all events — only `InMemoryAuditLogger` returns data; others return empty vec
186    fn events(&self) -> Vec<AuditEvent> {
187        vec![]
188    }
189}
190
191/// Appends newline-delimited JSON audit events to a file
192pub struct FileAuditLogger {
193    path: PathBuf,
194    file: Arc<Mutex<std::fs::File>>,
195}
196
197impl FileAuditLogger {
198    /// Open (or create) the audit log file at the given path.
199    pub fn new(path: impl Into<PathBuf>) -> Result<Self, AuditError> {
200        let path = path.into();
201        let file = std::fs::OpenOptions::new()
202            .create(true)
203            .append(true)
204            .open(&path)
205            .map_err(AuditError::IoError)?;
206        Ok(Self {
207            path,
208            file: Arc::new(Mutex::new(file)),
209        })
210    }
211
212    /// Return the path of the underlying log file.
213    pub fn path(&self) -> &std::path::Path {
214        &self.path
215    }
216}
217
218impl AuditLogger for FileAuditLogger {
219    fn log(&self, event: AuditEvent) -> Result<(), AuditError> {
220        use std::io::Write;
221        let json = serde_json::to_string(&event)
222            .map_err(|e| AuditError::SerializationError(e.to_string()))?;
223        let mut f = self.file.lock().unwrap_or_else(|e| e.into_inner());
224        writeln!(f, "{}", json).map_err(AuditError::IoError)
225    }
226
227    fn flush(&self) -> Result<(), AuditError> {
228        use std::io::Write;
229        let mut f = self.file.lock().unwrap_or_else(|e| e.into_inner());
230        f.flush().map_err(AuditError::IoError)
231    }
232}
233
234/// In-memory audit logger for testing and in-process inspection
235#[derive(Default)]
236pub struct InMemoryAuditLogger {
237    stored: Arc<Mutex<Vec<AuditEvent>>>,
238}
239
240impl InMemoryAuditLogger {
241    /// Create a new empty in-memory logger
242    pub fn new() -> Self {
243        Self::default()
244    }
245
246    /// Return the number of recorded events
247    pub fn event_count(&self) -> usize {
248        self.stored.lock().unwrap_or_else(|e| e.into_inner()).len()
249    }
250}
251
252impl AuditLogger for InMemoryAuditLogger {
253    fn log(&self, event: AuditEvent) -> Result<(), AuditError> {
254        self.stored
255            .lock()
256            .unwrap_or_else(|e| e.into_inner())
257            .push(event);
258        Ok(())
259    }
260
261    fn flush(&self) -> Result<(), AuditError> {
262        Ok(())
263    }
264
265    fn events(&self) -> Vec<AuditEvent> {
266        self.stored
267            .lock()
268            .unwrap_or_else(|e| e.into_inner())
269            .clone()
270    }
271}
272
273/// Discards all audit events — for use when auditing is explicitly disabled
274pub struct NullAuditLogger;
275
276impl AuditLogger for NullAuditLogger {
277    fn log(&self, _event: AuditEvent) -> Result<(), AuditError> {
278        Ok(())
279    }
280
281    fn flush(&self) -> Result<(), AuditError> {
282        Ok(())
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use std::env;
290
291    #[test]
292    fn test_in_memory_logger() {
293        let logger = InMemoryAuditLogger::new();
294        for _ in 0..100 {
295            let event = AuditEvent::new(OperationType::CircuitSubmit, true)
296                .with_backend("test_backend")
297                .with_duration_ms(42);
298            logger.log(event).expect("log should succeed");
299        }
300        assert_eq!(logger.event_count(), 100);
301    }
302
303    #[test]
304    fn test_audit_event_json_roundtrip() {
305        let event = AuditEvent::new(OperationType::ResultFetch, false)
306            .with_backend("ibm_nairobi")
307            .with_error("timeout");
308
309        let json = serde_json::to_string(&event).expect("serialization should succeed");
310        let parsed: AuditEvent =
311            serde_json::from_str(&json).expect("deserialization should succeed");
312        assert_eq!(parsed.backend_id, Some("ibm_nairobi".to_string()));
313        assert!(!parsed.success);
314        assert_eq!(parsed.error, Some("timeout".to_string()));
315    }
316
317    #[test]
318    fn test_in_memory_logger_events() {
319        let logger = InMemoryAuditLogger::new();
320        let event = AuditEvent::new(OperationType::BackendQuery, true)
321            .with_user("test-user")
322            .with_metadata("region", "us-east-1");
323        logger.log(event).expect("log should succeed");
324
325        let events = logger.events();
326        assert_eq!(events.len(), 1);
327        assert_eq!(events[0].user_id, Some("test-user".to_string()));
328        assert_eq!(
329            events[0].metadata.get("region"),
330            Some(&"us-east-1".to_string())
331        );
332    }
333
334    #[test]
335    fn test_file_audit_logger() {
336        let dir = env::temp_dir();
337        let path = dir.join(format!("quantrs_audit_test_{}.jsonl", fastrand::u64(..)));
338        let logger = FileAuditLogger::new(&path).expect("file logger creation should succeed");
339
340        let event = AuditEvent::new(OperationType::Authentication, true);
341        logger.log(event).expect("log should succeed");
342        logger.flush().expect("flush should succeed");
343
344        assert_eq!(logger.path(), path.as_path());
345
346        let content = std::fs::read_to_string(&path).expect("should read audit file");
347        assert!(content.contains("Authentication"));
348
349        let _ = std::fs::remove_file(&path);
350    }
351
352    #[test]
353    fn test_file_audit_logger_multiple_entries() {
354        let dir = env::temp_dir();
355        let path = dir.join(format!("quantrs_audit_multi_{}.jsonl", fastrand::u64(..)));
356        let logger = FileAuditLogger::new(&path).expect("file logger creation should succeed");
357
358        for op in [
359            OperationType::CircuitSubmit,
360            OperationType::ResultFetch,
361            OperationType::JobCancel,
362        ] {
363            let event = AuditEvent::new(op, true);
364            logger.log(event).expect("log should succeed");
365        }
366        logger.flush().expect("flush should succeed");
367
368        let content = std::fs::read_to_string(&path).expect("should read audit file");
369        let lines: Vec<&str> = content.lines().collect();
370        assert_eq!(lines.len(), 3);
371
372        let _ = std::fs::remove_file(&path);
373    }
374
375    #[test]
376    fn test_null_logger() {
377        let logger = NullAuditLogger;
378        let event = AuditEvent::new(OperationType::StatusCheck, true);
379        logger
380            .log(event)
381            .expect("null logger should always succeed");
382        logger.flush().expect("null flush should always succeed");
383        assert!(logger.events().is_empty());
384    }
385
386    #[test]
387    fn test_circuit_hash_deterministic() {
388        let circuit = "H q[0]; CNOT q[0],q[1];";
389        let h1 = circuit_hash(circuit);
390        let h2 = circuit_hash(circuit);
391        assert_eq!(h1, h2);
392        assert_eq!(h1.len(), 16);
393    }
394
395    #[test]
396    fn test_circuit_hash_different_for_different_inputs() {
397        let h1 = circuit_hash("H q[0];");
398        let h2 = circuit_hash("X q[0];");
399        assert_ne!(h1, h2);
400    }
401
402    #[test]
403    fn test_operation_type_display() {
404        assert_eq!(OperationType::CircuitSubmit.to_string(), "CircuitSubmit");
405        assert_eq!(OperationType::Authentication.to_string(), "Authentication");
406    }
407
408    #[test]
409    fn test_audit_event_builder_chain() {
410        let event = AuditEvent::new(OperationType::CalibrationFetch, false)
411            .with_backend("ibm_lagos")
412            .with_circuit_hash("abcdef0123456789")
413            .with_user("service-account")
414            .with_duration_ms(250)
415            .with_error("backend unavailable")
416            .with_metadata("attempt", "3")
417            .with_metadata("region", "eu-west");
418
419        assert_eq!(event.backend_id, Some("ibm_lagos".to_string()));
420        assert_eq!(event.circuit_hash, Some("abcdef0123456789".to_string()));
421        assert_eq!(event.user_id, Some("service-account".to_string()));
422        assert_eq!(event.duration_ms, Some(250));
423        assert!(!event.success);
424        assert_eq!(event.metadata.get("attempt"), Some(&"3".to_string()));
425    }
426}