Skip to main content

datasynth_server/rest/
audit.rs

1//! Structured audit logging for the REST API.
2//!
3//! Provides a trait-based audit logging system that records security-relevant
4//! events (authentication, authorization, data access) in a structured JSON
5//! format suitable for SIEM ingestion.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10// ===========================================================================
11// Audit event types
12// ===========================================================================
13
14/// Outcome of an audited action.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum AuditOutcome {
18    /// The action completed successfully.
19    Success,
20    /// The action was denied (e.g., insufficient permissions).
21    Denied,
22    /// The action failed due to an internal error.
23    Error,
24}
25
26/// A single audit event capturing who did what, when, and whether it succeeded.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct AuditEvent {
29    /// When the event occurred (UTC).
30    pub timestamp: DateTime<Utc>,
31    /// Unique request identifier for correlation.
32    pub request_id: String,
33    /// Identity of the actor (user ID, API key hash prefix, or "anonymous").
34    pub actor: String,
35    /// The action that was attempted (e.g., "generate_data", "view_config").
36    pub action: String,
37    /// The resource that was acted upon (e.g., "/api/stream/start", "job:abc123").
38    pub resource: String,
39    /// Whether the action succeeded, was denied, or errored.
40    pub outcome: AuditOutcome,
41    /// Tenant identifier for multi-tenant deployments.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub tenant_id: Option<String>,
44    /// Source IP address of the request.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub ip_address: Option<String>,
47    /// User-Agent header value.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub user_agent: Option<String>,
50}
51
52// ===========================================================================
53// Audit logger trait and implementations
54// ===========================================================================
55
56/// Trait for audit event sinks.
57///
58/// Implementations may log to stdout, files, external services, etc.
59pub trait AuditLogger: Send + Sync {
60    /// Record an audit event.
61    fn log_event(&self, event: &AuditEvent);
62}
63
64/// Logs audit events as JSON via the `tracing` crate.
65///
66/// Events are emitted at `INFO` level with a structured `audit_event` field,
67/// making them easy to filter and forward in log aggregation pipelines.
68pub struct JsonAuditLogger;
69
70impl AuditLogger for JsonAuditLogger {
71    fn log_event(&self, event: &AuditEvent) {
72        // Serialize to a JSON string; fall back to debug format on failure.
73        match serde_json::to_string(event) {
74            Ok(json) => {
75                tracing::info!(
76                    audit_event = %json,
77                    actor = %event.actor,
78                    action = %event.action,
79                    outcome = ?event.outcome,
80                    "audit"
81                );
82            }
83            Err(e) => {
84                tracing::warn!(
85                    error = %e,
86                    actor = %event.actor,
87                    action = %event.action,
88                    "Failed to serialize audit event"
89                );
90            }
91        }
92    }
93}
94
95/// A no-op logger that silently discards events.
96///
97/// Used when audit logging is disabled to avoid runtime overhead.
98pub struct NoopAuditLogger;
99
100impl AuditLogger for NoopAuditLogger {
101    fn log_event(&self, _event: &AuditEvent) {
102        // Intentionally empty.
103    }
104}
105
106// ===========================================================================
107// Configuration
108// ===========================================================================
109
110/// Configuration for the audit logging subsystem.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct AuditConfig {
113    /// Whether audit logging is enabled.
114    #[serde(default)]
115    pub enabled: bool,
116
117    /// Whether to log audit events to stdout (via tracing).
118    #[serde(default = "default_true")]
119    pub log_to_stdout: bool,
120}
121
122fn default_true() -> bool {
123    true
124}
125
126impl Default for AuditConfig {
127    fn default() -> Self {
128        Self {
129            enabled: false,
130            log_to_stdout: true,
131        }
132    }
133}
134
135// ===========================================================================
136// Tests
137// ===========================================================================
138
139#[cfg(test)]
140#[allow(clippy::unwrap_used)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn test_json_audit_logger_does_not_panic() {
146        let logger = JsonAuditLogger;
147        let event = AuditEvent {
148            timestamp: Utc::now(),
149            request_id: "req-001".to_string(),
150            actor: "user@example.com".to_string(),
151            action: "generate_data".to_string(),
152            resource: "/api/stream/start".to_string(),
153            outcome: AuditOutcome::Success,
154            tenant_id: Some("tenant-1".to_string()),
155            ip_address: Some("192.168.1.1".to_string()),
156            user_agent: Some("datasynth-cli/0.5.0".to_string()),
157        };
158        // Should not panic even without a tracing subscriber installed.
159        logger.log_event(&event);
160    }
161
162    #[test]
163    fn test_noop_audit_logger() {
164        let logger = NoopAuditLogger;
165        let event = AuditEvent {
166            timestamp: Utc::now(),
167            request_id: "req-002".to_string(),
168            actor: "anonymous".to_string(),
169            action: "view_metrics".to_string(),
170            resource: "/metrics".to_string(),
171            outcome: AuditOutcome::Denied,
172            tenant_id: None,
173            ip_address: None,
174            user_agent: None,
175        };
176        // Should be a no-op.
177        logger.log_event(&event);
178    }
179
180    #[test]
181    fn test_audit_event_serialization_roundtrip() {
182        let event = AuditEvent {
183            timestamp: Utc::now(),
184            request_id: "req-003".to_string(),
185            actor: "admin-key-ab12".to_string(),
186            action: "manage_config".to_string(),
187            resource: "/api/config".to_string(),
188            outcome: AuditOutcome::Error,
189            tenant_id: None,
190            ip_address: Some("10.0.0.1".to_string()),
191            user_agent: None,
192        };
193
194        let json = serde_json::to_string(&event).expect("should serialize");
195        let deserialized: AuditEvent = serde_json::from_str(&json).expect("should deserialize");
196
197        assert_eq!(deserialized.request_id, "req-003");
198        assert_eq!(deserialized.actor, "admin-key-ab12");
199        assert_eq!(deserialized.action, "manage_config");
200        assert_eq!(deserialized.outcome, AuditOutcome::Error);
201        assert!(deserialized.tenant_id.is_none());
202        assert_eq!(deserialized.ip_address, Some("10.0.0.1".to_string()));
203        assert!(deserialized.user_agent.is_none());
204    }
205
206    #[test]
207    fn test_audit_config_defaults() {
208        let config = AuditConfig::default();
209        assert!(!config.enabled);
210        assert!(config.log_to_stdout);
211    }
212
213    #[test]
214    fn test_audit_outcome_serialization() {
215        assert_eq!(
216            serde_json::to_string(&AuditOutcome::Success).unwrap(),
217            "\"success\""
218        );
219        assert_eq!(
220            serde_json::to_string(&AuditOutcome::Denied).unwrap(),
221            "\"denied\""
222        );
223        assert_eq!(
224            serde_json::to_string(&AuditOutcome::Error).unwrap(),
225            "\"error\""
226        );
227    }
228}