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)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn test_json_audit_logger_does_not_panic() {
145        let logger = JsonAuditLogger;
146        let event = AuditEvent {
147            timestamp: Utc::now(),
148            request_id: "req-001".to_string(),
149            actor: "user@example.com".to_string(),
150            action: "generate_data".to_string(),
151            resource: "/api/stream/start".to_string(),
152            outcome: AuditOutcome::Success,
153            tenant_id: Some("tenant-1".to_string()),
154            ip_address: Some("192.168.1.1".to_string()),
155            user_agent: Some("datasynth-cli/0.5.0".to_string()),
156        };
157        // Should not panic even without a tracing subscriber installed.
158        logger.log_event(&event);
159    }
160
161    #[test]
162    fn test_noop_audit_logger() {
163        let logger = NoopAuditLogger;
164        let event = AuditEvent {
165            timestamp: Utc::now(),
166            request_id: "req-002".to_string(),
167            actor: "anonymous".to_string(),
168            action: "view_metrics".to_string(),
169            resource: "/metrics".to_string(),
170            outcome: AuditOutcome::Denied,
171            tenant_id: None,
172            ip_address: None,
173            user_agent: None,
174        };
175        // Should be a no-op.
176        logger.log_event(&event);
177    }
178
179    #[test]
180    fn test_audit_event_serialization_roundtrip() {
181        let event = AuditEvent {
182            timestamp: Utc::now(),
183            request_id: "req-003".to_string(),
184            actor: "admin-key-ab12".to_string(),
185            action: "manage_config".to_string(),
186            resource: "/api/config".to_string(),
187            outcome: AuditOutcome::Error,
188            tenant_id: None,
189            ip_address: Some("10.0.0.1".to_string()),
190            user_agent: None,
191        };
192
193        let json = serde_json::to_string(&event).expect("should serialize");
194        let deserialized: AuditEvent = serde_json::from_str(&json).expect("should deserialize");
195
196        assert_eq!(deserialized.request_id, "req-003");
197        assert_eq!(deserialized.actor, "admin-key-ab12");
198        assert_eq!(deserialized.action, "manage_config");
199        assert_eq!(deserialized.outcome, AuditOutcome::Error);
200        assert!(deserialized.tenant_id.is_none());
201        assert_eq!(deserialized.ip_address, Some("10.0.0.1".to_string()));
202        assert!(deserialized.user_agent.is_none());
203    }
204
205    #[test]
206    fn test_audit_config_defaults() {
207        let config = AuditConfig::default();
208        assert!(!config.enabled);
209        assert!(config.log_to_stdout);
210    }
211
212    #[test]
213    fn test_audit_outcome_serialization() {
214        assert_eq!(
215            serde_json::to_string(&AuditOutcome::Success).unwrap(),
216            "\"success\""
217        );
218        assert_eq!(
219            serde_json::to_string(&AuditOutcome::Denied).unwrap(),
220            "\"denied\""
221        );
222        assert_eq!(
223            serde_json::to_string(&AuditOutcome::Error).unwrap(),
224            "\"error\""
225        );
226    }
227}