Skip to main content

dcp/security/
audit.rs

1//! Structured security audit receipts.
2
3use std::collections::HashMap;
4use std::sync::{Arc, RwLock};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use serde_json::json;
8
9use super::redaction::{sanitize_field_key, sanitize_field_value, sanitize_text};
10
11/// Default maximum number of in-memory security audit receipts retained.
12pub const DEFAULT_MAX_SECURITY_AUDIT_EVENTS: usize = 1024;
13/// Maximum bytes retained for any single audit text field.
14pub const MAX_SECURITY_AUDIT_TEXT_LEN: usize = 256;
15
16fn truncate_audit_text(value: &str) -> String {
17    if value.len() <= MAX_SECURITY_AUDIT_TEXT_LEN {
18        return value.to_string();
19    }
20
21    let mut truncated = String::with_capacity(MAX_SECURITY_AUDIT_TEXT_LEN);
22    for ch in value.chars() {
23        if truncated.len() + ch.len_utf8() > MAX_SECURITY_AUDIT_TEXT_LEN {
24            break;
25        }
26        truncated.push(ch);
27    }
28    truncated
29}
30
31fn sanitize_audit_text(value: &str) -> String {
32    truncate_audit_text(&sanitize_text(value))
33}
34
35fn sanitize_audit_field_key(key: &str) -> String {
36    truncate_audit_text(&sanitize_field_key(key))
37}
38
39fn sanitize_audit_field_value(key: &str, value: &str) -> String {
40    truncate_audit_text(&sanitize_field_value(key, value))
41}
42
43/// Security-relevant event category.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum SecurityAuditAction {
46    /// A request was rejected before normal dispatch.
47    RequestRejected,
48    /// A negotiated capability boundary denied access.
49    CapabilityDenied,
50    /// A parse or schema validation error rejected input.
51    ValidationRejected,
52    /// A replay guard denied a repeated or stale request.
53    ReplayRejected,
54    /// A signature check failed.
55    SignatureRejected,
56    /// A transport-level policy rejected input.
57    TransportRejected,
58    /// Shutdown policy denied new work.
59    ShutdownRejected,
60}
61
62impl SecurityAuditAction {
63    /// Stable receipt action name.
64    pub fn as_str(self) -> &'static str {
65        match self {
66            Self::RequestRejected => "request_rejected",
67            Self::CapabilityDenied => "capability_denied",
68            Self::ValidationRejected => "validation_rejected",
69            Self::ReplayRejected => "replay_rejected",
70            Self::SignatureRejected => "signature_rejected",
71            Self::TransportRejected => "transport_rejected",
72            Self::ShutdownRejected => "shutdown_rejected",
73        }
74    }
75}
76
77/// Structured audit receipt for a security-relevant decision.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct SecurityAuditEvent {
80    /// Event timestamp in Unix milliseconds.
81    pub timestamp: u64,
82    /// Security action category.
83    pub action: SecurityAuditAction,
84    /// Stable reason code.
85    pub reason: String,
86    /// Optional protocol method.
87    pub method: Option<String>,
88    /// Optional request identifier.
89    pub request_id: Option<String>,
90    /// Additional redacted fields.
91    pub fields: HashMap<String, String>,
92}
93
94impl SecurityAuditEvent {
95    /// Create a new audit event.
96    pub fn new(action: SecurityAuditAction, reason: impl Into<String>) -> Self {
97        let timestamp = SystemTime::now()
98            .duration_since(UNIX_EPOCH)
99            .unwrap_or_default()
100            .as_millis() as u64;
101
102        Self {
103            timestamp,
104            action,
105            reason: sanitize_audit_text(&reason.into()),
106            method: None,
107            request_id: None,
108            fields: HashMap::new(),
109        }
110    }
111
112    /// Attach a protocol method.
113    pub fn with_method(mut self, method: impl Into<String>) -> Self {
114        self.method = Some(sanitize_audit_text(&method.into()));
115        self
116    }
117
118    /// Attach a request identifier.
119    pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
120        self.request_id = Some(sanitize_audit_text(&request_id.into()));
121        self
122    }
123
124    /// Attach a redacted field.
125    pub fn with_field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
126        let key = key.into();
127        let value = value.into();
128        let sanitized_key = sanitize_audit_field_key(&key);
129        self.fields
130            .insert(sanitized_key, sanitize_audit_field_value(&key, &value));
131        self
132    }
133
134    fn sanitized_copy(&self) -> Self {
135        let mut fields = HashMap::new();
136        for (key, value) in &self.fields {
137            fields.insert(
138                sanitize_audit_field_key(key),
139                sanitize_audit_field_value(key, value),
140            );
141        }
142
143        Self {
144            timestamp: self.timestamp,
145            action: self.action,
146            reason: sanitize_audit_text(&self.reason),
147            method: self
148                .method
149                .as_ref()
150                .map(|method| sanitize_audit_text(method)),
151            request_id: self
152                .request_id
153                .as_ref()
154                .map(|request_id| sanitize_audit_text(request_id)),
155            fields,
156        }
157    }
158
159    /// Render as JSON for durable receipts.
160    pub fn to_json(&self) -> String {
161        let event = self.sanitized_copy();
162        let mut value = json!({
163            "timestamp": event.timestamp,
164            "action": event.action.as_str(),
165            "reason": event.reason,
166            "fields": event.fields,
167        });
168
169        if let Some(method) = &event.method {
170            value["method"] = json!(method);
171        }
172        if let Some(request_id) = &event.request_id {
173            value["request_id"] = json!(request_id);
174        }
175
176        serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string())
177    }
178}
179
180#[derive(Debug, Default)]
181struct SecurityAuditState {
182    events: Vec<SecurityAuditEvent>,
183    dropped_count: u64,
184}
185
186/// Bounded in-memory audit log used by adapters and tests.
187#[derive(Debug, Clone)]
188pub struct SecurityAuditLog {
189    state: Arc<RwLock<SecurityAuditState>>,
190    max_events: usize,
191}
192
193impl SecurityAuditLog {
194    /// Create an empty audit log.
195    pub fn new() -> Self {
196        Self::with_capacity(DEFAULT_MAX_SECURITY_AUDIT_EVENTS)
197    }
198
199    /// Create an empty audit log with a maximum retained event count.
200    pub fn with_capacity(max_events: usize) -> Self {
201        Self {
202            state: Arc::new(RwLock::new(SecurityAuditState::default())),
203            max_events,
204        }
205    }
206
207    /// Append an event.
208    pub fn record(&self, event: SecurityAuditEvent) {
209        if let Ok(mut state) = self.state.write() {
210            let event = event.sanitized_copy();
211            if self.max_events == 0 {
212                state.dropped_count = state.dropped_count.saturating_add(1);
213                return;
214            }
215
216            if state.events.len() >= self.max_events {
217                state.events.remove(0);
218                state.dropped_count = state.dropped_count.saturating_add(1);
219            }
220            state.events.push(event);
221        }
222    }
223
224    /// Snapshot events.
225    pub fn events(&self) -> Vec<SecurityAuditEvent> {
226        self.state
227            .read()
228            .map(|state| state.events.clone())
229            .unwrap_or_default()
230    }
231
232    /// Number of receipts dropped because the retention bound was reached.
233    pub fn dropped_count(&self) -> u64 {
234        self.state
235            .read()
236            .map(|state| state.dropped_count)
237            .unwrap_or_default()
238    }
239
240    /// Clear events.
241    pub fn clear(&self) {
242        if let Ok(mut state) = self.state.write() {
243            state.events.clear();
244            state.dropped_count = 0;
245        }
246    }
247}
248
249impl Default for SecurityAuditLog {
250    fn default() -> Self {
251        Self::new()
252    }
253}