1use std::io::{BufWriter, Write};
38use std::path::{Path, PathBuf};
39use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
40use std::sync::{mpsc, Arc, Mutex};
41use std::thread::{self, JoinHandle};
42use std::time::{Duration, Instant};
43
44use crate::crypto::{os_random, sha256};
45use crate::json::{Map, Value as JsonValue};
46use reddb_types::encoding::base64_encode;
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum AuditAuthSource {
58 ApiKey,
59 Session,
60 Password,
61 Oauth,
62 ClientCert,
63 Anonymous,
64 System,
65}
66
67impl AuditAuthSource {
68 pub fn as_str(self) -> &'static str {
69 match self {
70 Self::ApiKey => "api_key",
71 Self::Session => "session",
72 Self::Password => "password",
73 Self::Oauth => "oauth",
74 Self::ClientCert => "client_cert",
75 Self::Anonymous => "anonymous",
76 Self::System => "system",
77 }
78 }
79
80 pub fn parse(s: &str) -> Option<Self> {
81 Some(match s {
82 "api_key" => Self::ApiKey,
83 "session" => Self::Session,
84 "password" => Self::Password,
85 "oauth" => Self::Oauth,
86 "client_cert" => Self::ClientCert,
87 "anonymous" => Self::Anonymous,
88 "system" => Self::System,
89 _ => return None,
90 })
91 }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum Outcome {
97 Success,
98 Denied,
99 Error,
100}
101
102impl Outcome {
103 pub fn as_str(self) -> &'static str {
104 match self {
105 Self::Success => "success",
106 Self::Denied => "denied",
107 Self::Error => "error",
108 }
109 }
110
111 pub fn parse(s: &str) -> Option<Self> {
112 Some(match s {
113 "success" | "ok" => Self::Success,
114 "denied" | "deny" => Self::Denied,
115 "error" | "err" => Self::Error,
116 _ => return None,
117 })
118 }
119}
120
121#[derive(Debug, Clone)]
123pub struct AuditEvent {
124 pub ts: u128,
125 pub event_id: String,
126 pub principal: Option<String>,
127 pub source: AuditAuthSource,
128 pub tenant: Option<String>,
129 pub action: String,
130 pub resource: Option<String>,
131 pub outcome: Outcome,
132 pub detail: JsonValue,
133 pub remote_addr: Option<String>,
134 pub correlation_id: Option<String>,
135}
136
137impl AuditEvent {
138 pub fn builder(action: impl Into<String>) -> AuditEventBuilder {
141 AuditEventBuilder {
142 inner: AuditEvent {
143 ts: crate::utils::now_unix_millis() as u128,
144 event_id: new_event_id(),
145 principal: None,
146 source: AuditAuthSource::System,
147 tenant: None,
148 action: action.into(),
149 resource: None,
150 outcome: Outcome::Success,
151 detail: JsonValue::Null,
152 remote_addr: None,
153 correlation_id: None,
154 },
155 }
156 }
157
158 pub fn from_legacy(
162 action: &str,
163 principal: &str,
164 target: &str,
165 result: &str,
166 details: JsonValue,
167 ) -> Self {
168 let outcome = if result == "ok" {
169 Outcome::Success
170 } else if result.starts_with("err") {
171 Outcome::Error
172 } else if result.starts_with("denied") || result.starts_with("deny") {
173 Outcome::Denied
174 } else {
175 Outcome::Success
176 };
177 let mut detail = details;
178 if !result.is_empty() && result != "ok" {
179 let mut obj = match detail {
182 JsonValue::Object(map) => map,
183 JsonValue::Null => Map::new(),
184 other => {
185 let mut m = Map::new();
186 m.insert("legacy".to_string(), other);
187 m
188 }
189 };
190 obj.entry("result_text".to_string())
191 .or_insert(JsonValue::String(result.to_string()));
192 detail = JsonValue::Object(obj);
193 }
194 Self {
195 ts: crate::utils::now_unix_millis() as u128,
196 event_id: new_event_id(),
197 principal: if principal.is_empty() || principal == "system" {
198 None
199 } else {
200 Some(principal.to_string())
201 },
202 source: if principal == "system" {
203 AuditAuthSource::System
204 } else if principal.is_empty() {
205 AuditAuthSource::Anonymous
206 } else {
207 AuditAuthSource::Password
208 },
209 tenant: None,
210 action: action.to_string(),
211 resource: if target.is_empty() {
212 None
213 } else {
214 Some(target.to_string())
215 },
216 outcome,
217 detail,
218 remote_addr: None,
219 correlation_id: None,
220 }
221 }
222
223 pub fn to_json_line(&self, prev_hash: Option<&str>) -> String {
226 let mut object = Map::new();
227 object.insert("ts".to_string(), JsonValue::Number(self.ts as f64));
228 object.insert(
229 "ts_iso".to_string(),
230 JsonValue::String(format_iso8601(self.ts as u64)),
231 );
232 object.insert(
233 "event_id".to_string(),
234 JsonValue::String(self.event_id.clone()),
235 );
236 if let Some(p) = &self.principal {
237 object.insert("principal".to_string(), JsonValue::String(p.clone()));
238 }
239 object.insert(
240 "source".to_string(),
241 JsonValue::String(self.source.as_str().to_string()),
242 );
243 if let Some(t) = &self.tenant {
244 object.insert("tenant".to_string(), JsonValue::String(t.clone()));
245 }
246 object.insert("action".to_string(), JsonValue::String(self.action.clone()));
247 if let Some(r) = &self.resource {
248 object.insert("resource".to_string(), JsonValue::String(r.clone()));
249 }
250 object.insert(
251 "outcome".to_string(),
252 JsonValue::String(self.outcome.as_str().to_string()),
253 );
254 if !matches!(self.detail, JsonValue::Null) {
255 object.insert("detail".to_string(), self.detail.clone());
256 }
257 if let Some(ip) = &self.remote_addr {
258 object.insert("remote_addr".to_string(), JsonValue::String(ip.clone()));
259 }
260 if let Some(cid) = &self.correlation_id {
261 object.insert("correlation_id".to_string(), JsonValue::String(cid.clone()));
262 }
263 if let Some(h) = prev_hash {
264 object.insert("prev_hash".to_string(), JsonValue::String(h.to_string()));
265 }
266 JsonValue::Object(object).to_string_compact()
267 }
268
269 pub fn parse_line(line: &str) -> Option<Self> {
273 let v: JsonValue = crate::json::from_str(line).ok()?;
274 let action = v.get("action")?.as_str()?.to_string();
275 let outcome_s = v.get("outcome").and_then(|n| n.as_str()).unwrap_or("");
276 let outcome = Outcome::parse(outcome_s)?;
277 let ts = v.get("ts").and_then(|n| n.as_f64()).unwrap_or(0.0) as u128;
278 let event_id = v
279 .get("event_id")
280 .and_then(|n| n.as_str())
281 .unwrap_or("")
282 .to_string();
283 let source = v
284 .get("source")
285 .and_then(|n| n.as_str())
286 .and_then(AuditAuthSource::parse)
287 .unwrap_or(AuditAuthSource::System);
288 Some(Self {
289 ts,
290 event_id,
291 principal: v
292 .get("principal")
293 .and_then(|n| n.as_str())
294 .map(|s| s.to_string()),
295 source,
296 tenant: v
297 .get("tenant")
298 .and_then(|n| n.as_str())
299 .map(|s| s.to_string()),
300 action,
301 resource: v
302 .get("resource")
303 .and_then(|n| n.as_str())
304 .map(|s| s.to_string()),
305 outcome,
306 detail: v.get("detail").cloned().unwrap_or(JsonValue::Null),
307 remote_addr: v
308 .get("remote_addr")
309 .and_then(|n| n.as_str())
310 .map(|s| s.to_string()),
311 correlation_id: v
312 .get("correlation_id")
313 .and_then(|n| n.as_str())
314 .map(|s| s.to_string()),
315 })
316 }
317}
318
319pub struct AuditEventBuilder {
321 inner: AuditEvent,
322}
323
324impl AuditEventBuilder {
325 pub fn principal(mut self, principal: impl Into<String>) -> Self {
326 self.inner.principal = Some(principal.into());
327 self
328 }
329
330 pub fn principal_opt(mut self, principal: Option<String>) -> Self {
331 self.inner.principal = principal;
332 self
333 }
334
335 pub fn source(mut self, source: AuditAuthSource) -> Self {
336 self.inner.source = source;
337 self
338 }
339
340 pub fn tenant(mut self, tenant: impl Into<String>) -> Self {
341 self.inner.tenant = Some(tenant.into());
342 self
343 }
344
345 pub fn resource(mut self, resource: impl Into<String>) -> Self {
346 self.inner.resource = Some(resource.into());
347 self
348 }
349
350 pub fn outcome(mut self, outcome: Outcome) -> Self {
351 self.inner.outcome = outcome;
352 self
353 }
354
355 pub fn detail(mut self, detail: JsonValue) -> Self {
356 self.inner.detail = detail;
357 self
358 }
359
360 pub fn field(mut self, field: AuditField) -> Self {
364 let mut obj = match std::mem::replace(&mut self.inner.detail, JsonValue::Null) {
365 JsonValue::Object(map) => map,
366 JsonValue::Null => Map::new(),
367 other => {
368 let mut m = Map::new();
371 m.insert("legacy_detail".to_string(), other);
372 m
373 }
374 };
375 obj.insert(field.name.to_string(), field.value.into_json_value());
376 self.inner.detail = JsonValue::Object(obj);
377 self
378 }
379
380 pub fn fields(mut self, fields: impl IntoIterator<Item = AuditField>) -> Self {
382 for f in fields {
383 self = self.field(f);
384 }
385 self
386 }
387
388 pub fn remote_addr(mut self, addr: impl Into<String>) -> Self {
389 self.inner.remote_addr = Some(addr.into());
390 self
391 }
392
393 pub fn correlation_id(mut self, cid: impl Into<String>) -> Self {
394 self.inner.correlation_id = Some(cid.into());
395 self
396 }
397
398 pub fn build(self) -> AuditEvent {
399 self.inner
400 }
401}
402
403#[derive(Debug, Clone)]
417pub enum AuditValue {
418 String(String),
419 Bytes(Vec<u8>),
423 Number(i64),
424 Bool(bool),
425 Null,
426}
427
428impl AuditValue {
429 fn into_json_value(self) -> JsonValue {
430 match self {
431 AuditValue::String(s) => JsonValue::String(s),
432 AuditValue::Bytes(bytes) => JsonValue::String(base64_encode(&bytes)),
433 AuditValue::Number(n) => JsonValue::Number(n as f64),
434 AuditValue::Bool(b) => JsonValue::Bool(b),
435 AuditValue::Null => JsonValue::Null,
436 }
437 }
438}
439
440impl From<String> for AuditValue {
441 fn from(value: String) -> Self {
442 AuditValue::String(value)
443 }
444}
445
446impl From<&str> for AuditValue {
447 fn from(value: &str) -> Self {
448 AuditValue::String(value.to_string())
449 }
450}
451
452impl From<&String> for AuditValue {
453 fn from(value: &String) -> Self {
454 AuditValue::String(value.clone())
455 }
456}
457
458impl From<Vec<u8>> for AuditValue {
459 fn from(value: Vec<u8>) -> Self {
460 AuditValue::Bytes(value)
461 }
462}
463
464impl From<&[u8]> for AuditValue {
465 fn from(value: &[u8]) -> Self {
466 AuditValue::Bytes(value.to_vec())
467 }
468}
469
470impl From<i64> for AuditValue {
471 fn from(value: i64) -> Self {
472 AuditValue::Number(value)
473 }
474}
475
476impl From<u64> for AuditValue {
477 fn from(value: u64) -> Self {
478 AuditValue::Number(if value > i64::MAX as u64 {
481 i64::MAX
482 } else {
483 value as i64
484 })
485 }
486}
487
488impl From<u32> for AuditValue {
489 fn from(value: u32) -> Self {
490 AuditValue::Number(value as i64)
491 }
492}
493
494impl From<usize> for AuditValue {
495 fn from(value: usize) -> Self {
496 AuditValue::Number(if value > i64::MAX as usize {
497 i64::MAX
498 } else {
499 value as i64
500 })
501 }
502}
503
504impl From<bool> for AuditValue {
505 fn from(value: bool) -> Self {
506 AuditValue::Bool(value)
507 }
508}
509
510impl<T: Into<AuditValue>> From<Option<T>> for AuditValue {
511 fn from(value: Option<T>) -> Self {
512 match value {
513 Some(v) => v.into(),
514 None => AuditValue::Null,
515 }
516 }
517}
518
519#[derive(Debug, Clone)]
524pub struct AuditField {
525 name: &'static str,
526 value: AuditValue,
527}
528
529impl AuditField {
530 pub fn name(&self) -> &'static str {
531 self.name
532 }
533 pub fn value(&self) -> &AuditValue {
534 &self.value
535 }
536}
537
538pub struct AuditFieldEscaper;
554
555impl AuditFieldEscaper {
556 pub fn field(name: &'static str, value: impl Into<AuditValue>) -> AuditField {
561 AuditField {
562 name,
563 value: value.into(),
564 }
565 }
566}
567
568fn new_event_id() -> String {
575 const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
576 let now_ms = crate::utils::now_unix_millis();
577 let mut rand_bytes = [0u8; 10];
578 let _ = os_random::fill_bytes(&mut rand_bytes);
579
580 let mut out = String::with_capacity(26);
581 for i in (0..10).rev() {
583 let shift = (i as u32) * 5;
584 let idx = ((now_ms >> shift) & 0x1f) as usize;
585 out.push(ALPHABET[idx] as char);
586 }
587 let mut acc: u128 = 0;
589 for &b in &rand_bytes {
590 acc = (acc << 8) | (b as u128);
591 }
592 for i in (0..16).rev() {
593 let shift = (i as u32) * 5;
594 let idx = ((acc >> shift) & 0x1f) as usize;
595 out.push(ALPHABET[idx] as char);
596 }
597 out
598}
599
600#[derive(Debug, Clone, Copy, PartialEq, Eq)]
605enum FsyncMode {
606 Off,
608 Every,
610 Periodic,
613}
614
615impl FsyncMode {
616 fn from_env() -> Self {
617 match std::env::var("RED_AUDIT_FSYNC")
618 .unwrap_or_default()
619 .to_ascii_lowercase()
620 .as_str()
621 {
622 "every" | "strong" | "on" => Self::Every,
623 "off" | "none" => Self::Off,
624 _ => Self::Periodic,
625 }
626 }
627}
628
629#[allow(clippy::large_enum_variant)]
634enum WriterMsg {
635 Event(AuditEvent),
636 Flush(mpsc::Sender<()>),
637 Shutdown,
638}
639
640#[derive(Debug)]
645pub struct AuditLogger {
646 path: PathBuf,
647 tx: Mutex<Option<mpsc::SyncSender<WriterMsg>>>,
648 fallback_lock: Mutex<()>,
652 last_hash: Arc<Mutex<Option<String>>>,
656 max_bytes: u64,
657 fsync_mode: FsyncMode,
658 stream_url: Option<String>,
659 writer_alive: Arc<AtomicBool>,
661 pending: Arc<AtomicU64>,
662 handle: Mutex<Option<JoinHandle<()>>>,
663}
664
665impl AuditLogger {
666 pub fn for_data_path(data_path: &Path) -> Self {
669 Self::with_path(reddb_file::layout::legacy_audit_log_path(data_path))
670 }
671
672 pub fn for_destination(
679 dest: &crate::storage::layout::LogDestination,
680 fallback_data_path: &Path,
681 ) -> Self {
682 use crate::storage::layout::LogDestination;
683 match dest {
684 LogDestination::File(path) => Self::with_path(path.clone()),
685 LogDestination::Stderr => Self::for_data_path(fallback_data_path),
686 LogDestination::Syslog => {
687 tracing::warn!(
688 target: "reddb::audit",
689 "audit LogDestination::Syslog requested; sink not implemented, falling back to file next to data path"
690 );
691 Self::for_data_path(fallback_data_path)
692 }
693 }
694 }
695
696 pub fn with_path(path: PathBuf) -> Self {
698 let max_bytes = std::env::var("RED_AUDIT_MAX_BYTES")
699 .ok()
700 .and_then(|v| v.parse::<u64>().ok())
701 .unwrap_or(64 * 1024 * 1024);
702 let fsync_mode = FsyncMode::from_env();
703 let stream_url = std::env::var("RED_AUDIT_STREAM_URL")
704 .ok()
705 .filter(|s| !s.is_empty());
706 Self::with_settings(path, max_bytes, fsync_mode, stream_url)
707 }
708
709 pub fn with_max_bytes(path: PathBuf, max_bytes: u64) -> Self {
712 Self::with_settings(path, max_bytes, FsyncMode::Periodic, None)
713 }
714
715 fn with_settings(
716 path: PathBuf,
717 max_bytes: u64,
718 fsync_mode: FsyncMode,
719 stream_url: Option<String>,
720 ) -> Self {
721 let writer_alive = Arc::new(AtomicBool::new(false));
722 let pending = Arc::new(AtomicU64::new(0));
723 let mut seed: Option<String> = None;
726 if let Ok(body) = std::fs::read_to_string(&path) {
727 if let Some(line) = body.lines().last() {
728 seed = Some(crate::utils::to_hex(&sha256::sha256(line.as_bytes())));
729 }
730 }
731 let last_hash = Arc::new(Mutex::new(seed));
732 let logger = Self {
733 path,
734 tx: Mutex::new(None),
735 fallback_lock: Mutex::new(()),
736 last_hash,
737 max_bytes,
738 fsync_mode,
739 stream_url,
740 writer_alive: Arc::clone(&writer_alive),
741 pending: Arc::clone(&pending),
742 handle: Mutex::new(None),
743 };
744 logger.start_writer_thread();
745 logger
746 }
747
748 pub fn path(&self) -> &Path {
749 &self.path
750 }
751
752 pub fn max_bytes(&self) -> u64 {
754 self.max_bytes
755 }
756
757 fn start_writer_thread(&self) {
758 let (tx, rx) = mpsc::sync_channel::<WriterMsg>(4096);
759 *self.tx.lock().unwrap_or_else(|e| e.into_inner()) = Some(tx);
760 let path = self.path.clone();
761 let max_bytes = self.max_bytes;
762 let fsync_mode = self.fsync_mode;
763 let stream_url = self.stream_url.clone();
764 let writer_alive = Arc::clone(&self.writer_alive);
765 let pending = Arc::clone(&self.pending);
766 let last_hash = Arc::clone(&self.last_hash);
767 writer_alive.store(true, Ordering::SeqCst);
768 let handle = thread::Builder::new()
769 .name("reddb-audit-writer".to_string())
770 .spawn(move || {
771 writer_loop(
772 rx,
773 path,
774 max_bytes,
775 fsync_mode,
776 stream_url,
777 writer_alive,
778 pending,
779 last_hash,
780 );
781 })
782 .expect("spawn audit writer thread");
783 *self.handle.lock().unwrap_or_else(|e| e.into_inner()) = Some(handle);
784 }
785
786 pub fn current_hash(&self) -> Option<String> {
788 self.last_hash
789 .lock()
790 .unwrap_or_else(|e| e.into_inner())
791 .clone()
792 }
793
794 pub fn wait_idle(&self, timeout: Duration) -> bool {
797 let deadline = Instant::now() + timeout;
798 let tx_guard = self.tx.lock().unwrap_or_else(|e| e.into_inner());
799 if let Some(tx) = tx_guard.as_ref() {
800 let (back_tx, back_rx) = mpsc::channel();
801 if tx.send(WriterMsg::Flush(back_tx)).is_err() {
802 return false;
803 }
804 drop(tx_guard);
805 let remaining = deadline.saturating_duration_since(Instant::now());
806 return back_rx.recv_timeout(remaining).is_ok();
807 }
808 false
809 }
810
811 pub fn record(
814 &self,
815 action: &str,
816 principal: &str,
817 target: &str,
818 result: &str,
819 details: JsonValue,
820 ) {
821 let event = AuditEvent::from_legacy(action, principal, target, result, details);
822 self.record_event(event);
823 }
824
825 pub fn record_event(&self, event: AuditEvent) {
829 let tx_guard = self.tx.lock().unwrap_or_else(|e| e.into_inner());
830 let recovered_event: AuditEvent;
831 if let Some(tx) = tx_guard.as_ref() {
832 self.pending.fetch_add(1, Ordering::SeqCst);
833 match tx.try_send(WriterMsg::Event(event)) {
834 Ok(()) => return,
835 Err(mpsc::TrySendError::Full(msg)) => {
836 self.pending.fetch_sub(1, Ordering::SeqCst);
837 tracing::warn!(
838 target: "reddb::audit",
839 "audit channel saturated; falling back to sync write"
840 );
841 recovered_event = match msg {
842 WriterMsg::Event(ev) => ev,
843 _ => return,
844 };
845 }
846 Err(mpsc::TrySendError::Disconnected(msg)) => {
847 self.pending.fetch_sub(1, Ordering::SeqCst);
848 recovered_event = match msg {
849 WriterMsg::Event(ev) => ev,
850 _ => return,
851 };
852 }
853 }
854 } else {
855 recovered_event = event;
856 }
857 drop(tx_guard);
858 self.write_direct(recovered_event);
859 }
860
861 fn write_direct(&self, event: AuditEvent) {
864 let _g = self.fallback_lock.lock().unwrap_or_else(|e| e.into_inner());
865 let prev = self
866 .last_hash
867 .lock()
868 .unwrap_or_else(|e| e.into_inner())
869 .clone();
870 let line = event.to_json_line(prev.as_deref());
871 if let Err(err) = append_line_with_rotation(&self.path, &line, self.max_bytes) {
872 tracing::warn!(
873 target: "reddb::audit",
874 error = %err,
875 path = %self.path.display(),
876 "direct audit append failed"
877 );
878 return;
879 }
880 let new_hash = crate::utils::to_hex(&sha256::sha256(line.as_bytes()));
881 if let Ok(mut g) = self.last_hash.lock() {
882 *g = Some(new_hash);
883 }
884 if let Some(url) = &self.stream_url {
885 stream_post(url, &line);
886 }
887 tracing::info!(target: "reddb::audit", "{line}");
888 }
889}
890
891impl Drop for AuditLogger {
892 fn drop(&mut self) {
893 let mut tx_guard = self.tx.lock().unwrap_or_else(|e| e.into_inner());
894 if let Some(tx) = tx_guard.take() {
895 let _ = tx.send(WriterMsg::Shutdown);
896 }
897 drop(tx_guard);
898 if let Some(handle) = self.handle.lock().unwrap_or_else(|e| e.into_inner()).take() {
899 let _ = handle.join();
900 }
901 }
902}
903
904#[allow(clippy::too_many_arguments)]
909fn writer_loop(
910 rx: mpsc::Receiver<WriterMsg>,
911 path: PathBuf,
912 max_bytes: u64,
913 fsync_mode: FsyncMode,
914 stream_url: Option<String>,
915 writer_alive: Arc<AtomicBool>,
916 pending: Arc<AtomicU64>,
917 last_hash: Arc<Mutex<Option<String>>>,
918) {
919 if let Some(parent) = path.parent() {
920 if !parent.as_os_str().is_empty() {
921 let _ = std::fs::create_dir_all(parent);
922 }
923 }
924
925 let mut writer = match open_active(&path) {
926 Ok(w) => w,
927 Err(err) => {
928 tracing::error!(target: "reddb::audit", error = %err, "audit writer init failed");
929 writer_alive.store(false, Ordering::SeqCst);
930 return;
931 }
932 };
933 let mut bytes_written: u64 = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
937
938 let periodic_interval = Duration::from_millis(250);
939 let mut last_flush = Instant::now();
940 let mut buffered_since_fsync: u64 = 0;
941
942 loop {
943 let recv_timeout = match fsync_mode {
946 FsyncMode::Periodic => periodic_interval,
947 FsyncMode::Every | FsyncMode::Off => Duration::from_secs(1),
948 };
949 match rx.recv_timeout(recv_timeout) {
950 Ok(WriterMsg::Event(event)) => {
951 let prev = last_hash.lock().unwrap_or_else(|e| e.into_inner()).clone();
952 let line = event.to_json_line(prev.as_deref());
953
954 let line_bytes = line.len() as u64 + 1; if let Err(err) = write_line(&mut writer, &line) {
956 tracing::warn!(
957 target: "reddb::audit",
958 error = %err,
959 "audit write failed; reopening"
960 );
961 if let Ok(w2) = open_active(&path) {
962 writer = w2;
963 let _ = write_line(&mut writer, &line);
964 }
965 }
966 bytes_written = bytes_written.saturating_add(line_bytes);
967 let new_hash = crate::utils::to_hex(&sha256::sha256(line.as_bytes()));
968 if let Ok(mut g) = last_hash.lock() {
969 *g = Some(new_hash);
970 }
971 if let Some(url) = &stream_url {
972 stream_post(url, &line);
973 }
974 tracing::info!(target: "reddb::audit", "{line}");
975 pending.fetch_sub(1, Ordering::SeqCst);
976 buffered_since_fsync += 1;
977
978 match fsync_mode {
979 FsyncMode::Every => {
980 let _ = writer.flush();
981 let _ = writer.get_ref().sync_data();
982 buffered_since_fsync = 0;
983 }
984 FsyncMode::Periodic => {
985 if last_flush.elapsed() >= periodic_interval {
986 let _ = writer.flush();
987 let _ = writer.get_ref().sync_data();
988 last_flush = Instant::now();
989 buffered_since_fsync = 0;
990 }
991 }
992 FsyncMode::Off => {}
993 }
994
995 if bytes_written >= max_bytes {
998 let _ = writer.flush();
999 let _ = writer.get_ref().sync_data();
1000 if let Err(err) = rotate(&path) {
1001 tracing::warn!(
1002 target: "reddb::audit",
1003 error = %err,
1004 "audit rotation failed; continuing in-place"
1005 );
1006 }
1007 match open_active(&path) {
1008 Ok(w2) => writer = w2,
1009 Err(err) => {
1010 tracing::error!(
1011 target: "reddb::audit",
1012 error = %err,
1013 "audit reopen failed after rotate"
1014 );
1015 break;
1016 }
1017 }
1018 last_flush = Instant::now();
1019 buffered_since_fsync = 0;
1020 bytes_written = 0;
1021 }
1022 }
1023 Ok(WriterMsg::Flush(ack)) => {
1024 let _ = writer.flush();
1025 let _ = writer.get_ref().sync_data();
1026 last_flush = Instant::now();
1027 buffered_since_fsync = 0;
1028 let _ = ack.send(());
1032 }
1033 Ok(WriterMsg::Shutdown) => {
1034 let _ = writer.flush();
1035 let _ = writer.get_ref().sync_data();
1036 break;
1037 }
1038 Err(mpsc::RecvTimeoutError::Timeout) => {
1039 if buffered_since_fsync > 0 {
1040 let _ = writer.flush();
1041 let _ = writer.get_ref().sync_data();
1042 last_flush = Instant::now();
1043 buffered_since_fsync = 0;
1044 }
1045 }
1046 Err(mpsc::RecvTimeoutError::Disconnected) => {
1047 let _ = writer.flush();
1048 let _ = writer.get_ref().sync_data();
1049 break;
1050 }
1051 }
1052 }
1053
1054 writer_alive.store(false, Ordering::SeqCst);
1055}
1056
1057fn open_active(path: &Path) -> std::io::Result<BufWriter<std::fs::File>> {
1058 if let Some(parent) = path.parent() {
1059 if !parent.as_os_str().is_empty() {
1060 std::fs::create_dir_all(parent)?;
1061 }
1062 }
1063 let f = std::fs::OpenOptions::new()
1064 .create(true)
1065 .append(true)
1066 .open(path)?;
1067 Ok(BufWriter::new(f))
1068}
1069
1070fn write_line(writer: &mut BufWriter<std::fs::File>, line: &str) -> std::io::Result<()> {
1071 writer.write_all(line.as_bytes())?;
1072 writer.write_all(b"\n")?;
1073 Ok(())
1074}
1075
1076fn append_line_with_rotation(path: &Path, line: &str, max_bytes: u64) -> std::io::Result<()> {
1077 if let Some(parent) = path.parent() {
1078 if !parent.as_os_str().is_empty() {
1079 std::fs::create_dir_all(parent)?;
1080 }
1081 }
1082 let mut file = std::fs::OpenOptions::new()
1083 .create(true)
1084 .append(true)
1085 .open(path)?;
1086 file.write_all(line.as_bytes())?;
1087 file.write_all(b"\n")?;
1088 file.sync_data()?;
1089 drop(file);
1090 if let Ok(meta) = std::fs::metadata(path) {
1091 if meta.len() >= max_bytes {
1092 let _ = rotate(path);
1093 }
1094 }
1095 Ok(())
1096}
1097
1098fn rotate(active: &Path) -> std::io::Result<()> {
1106 let ts = crate::utils::now_unix_nanos();
1107 let rotation = reddb_file::rotate_audit_log(active, ts)?;
1108 if let Some(err) = rotation.compression_error {
1109 tracing::warn!(
1110 target: "reddb::audit",
1111 error = %err,
1112 "audit rotation: zstd compress failed; leaving plaintext"
1113 );
1114 }
1115 Ok(())
1116}
1117
1118fn stream_post(url: &str, line: &str) {
1123 let url = url.to_string();
1124 let line = line.to_string();
1125 let _ = thread::Builder::new()
1129 .name("reddb-audit-siem".to_string())
1130 .spawn(move || {
1131 let agent: ureq::Agent = ureq::Agent::config_builder()
1132 .timeout_connect(Some(Duration::from_secs(2)))
1133 .timeout_send_request(Some(Duration::from_secs(3)))
1134 .timeout_recv_response(Some(Duration::from_secs(3)))
1135 .http_status_as_error(false)
1136 .build()
1137 .into();
1138 let _ = agent
1139 .post(&url)
1140 .header("content-type", "application/x-ndjson")
1141 .send(line.as_bytes());
1142 });
1143}
1144
1145fn format_iso8601(ms_since_epoch: u64) -> String {
1150 let secs = ms_since_epoch / 1000;
1151 let ms = ms_since_epoch % 1000;
1152 let days = secs / 86_400;
1153 let rem = secs % 86_400;
1154 let (y, mo, d) = civil_from_days(days as i64);
1155 let h = rem / 3600;
1156 let mi = (rem % 3600) / 60;
1157 let s = rem % 60;
1158 format!(
1159 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
1160 y, mo, d, h, mi, s, ms
1161 )
1162}
1163
1164fn civil_from_days(z: i64) -> (i64, u32, u32) {
1165 let z = z + 719_468;
1166 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1167 let doe = (z - era * 146_097) as u64;
1168 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
1169 let y = (yoe as i64) + era * 400;
1170 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1171 let mp = (5 * doy + 2) / 153;
1172 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
1173 let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
1174 (if m <= 2 { y + 1 } else { y }, m, d)
1175}
1176
1177#[cfg(test)]
1182mod tests {
1183 use super::*;
1184
1185 fn temp_data_path(tag: &str) -> PathBuf {
1186 let mut p = std::env::temp_dir();
1187 p.push(format!(
1188 "reddb-audit-{}-{}-{}",
1189 tag,
1190 std::process::id(),
1191 crate::utils::now_unix_nanos()
1192 ));
1193 std::fs::create_dir_all(&p).unwrap();
1194 p.push("data.rdb");
1195 p
1196 }
1197
1198 fn drain(logger: &AuditLogger) {
1199 assert!(logger.wait_idle(Duration::from_secs(2)));
1200 }
1201
1202 #[test]
1203 fn record_writes_one_line_per_call() {
1204 let data = temp_data_path("one-line");
1205 let logger = AuditLogger::for_data_path(&data);
1206 logger.record(
1207 "admin/readonly",
1208 "operator",
1209 "instance",
1210 "ok",
1211 JsonValue::Null,
1212 );
1213 drain(&logger);
1214 let body = std::fs::read_to_string(logger.path()).unwrap();
1215 let lines: Vec<&str> = body.lines().collect();
1216 assert_eq!(lines.len(), 1);
1217 assert!(lines[0].contains("\"action\":\"admin/readonly\""));
1218 assert!(lines[0].contains("\"outcome\":\"success\""));
1219 }
1220
1221 #[test]
1222 fn record_appends_across_calls() {
1223 let data = temp_data_path("append");
1224 let logger = AuditLogger::for_data_path(&data);
1225 logger.record("admin/drain", "op", "instance", "ok", JsonValue::Null);
1226 logger.record("admin/shutdown", "op", "instance", "ok", JsonValue::Null);
1227 drain(&logger);
1228 let lines = std::fs::read_to_string(logger.path()).unwrap();
1229 assert_eq!(lines.lines().count(), 2);
1230 }
1231
1232 #[test]
1233 fn record_event_emits_full_schema() {
1234 let data = temp_data_path("schema");
1235 let logger = AuditLogger::for_data_path(&data);
1236 let mut detail = Map::new();
1237 detail.insert("ms".to_string(), JsonValue::Number(412.0));
1238 let ev = AuditEvent::builder("admin/shutdown")
1239 .principal("alice@acme")
1240 .source(AuditAuthSource::Session)
1241 .tenant("acme")
1242 .resource("instance")
1243 .outcome(Outcome::Success)
1244 .detail(JsonValue::Object(detail))
1245 .remote_addr("203.0.113.5")
1246 .correlation_id("req-42")
1247 .build();
1248 logger.record_event(ev);
1249 drain(&logger);
1250 let body = std::fs::read_to_string(logger.path()).unwrap();
1251 assert!(body.contains("\"action\":\"admin/shutdown\""));
1252 assert!(body.contains("\"principal\":\"alice@acme\""));
1253 assert!(body.contains("\"tenant\":\"acme\""));
1254 assert!(body.contains("\"source\":\"session\""));
1255 assert!(body.contains("\"correlation_id\":\"req-42\""));
1256 assert!(body.contains("\"remote_addr\":\"203.0.113.5\""));
1257 assert!(body.contains("\"event_id\":\""));
1258 assert!(body.contains("\"prev_hash\":") || body.lines().count() == 1);
1259 }
1260
1261 #[test]
1262 fn hash_chain_links_every_event() {
1263 let data = temp_data_path("chain");
1264 let logger = AuditLogger::for_data_path(&data);
1265 for i in 0..5 {
1266 logger.record_event(
1267 AuditEvent::builder(format!("test/event/{i}"))
1268 .principal("tester")
1269 .build(),
1270 );
1271 }
1272 drain(&logger);
1273 let body = std::fs::read_to_string(logger.path()).unwrap();
1274 let lines: Vec<&str> = body.lines().collect();
1275 assert_eq!(lines.len(), 5);
1276 let mut prev: Option<String> = None;
1277 for (idx, line) in lines.iter().enumerate() {
1278 let parsed: JsonValue = crate::json::from_str(line).unwrap();
1279 let stored_prev = parsed
1280 .get("prev_hash")
1281 .and_then(|v| v.as_str())
1282 .map(|s| s.to_string());
1283 assert_eq!(stored_prev, prev, "line {idx} prev_hash mismatch");
1284 prev = Some(crate::utils::to_hex(&sha256::sha256(line.as_bytes())));
1285 }
1286 }
1287
1288 #[test]
1289 fn legacy_record_back_compat_maps_outcomes() {
1290 let data = temp_data_path("legacy");
1291 let logger = AuditLogger::for_data_path(&data);
1292 logger.record(
1293 "admin/restore",
1294 "operator",
1295 "instance",
1296 "err: disk full",
1297 JsonValue::Null,
1298 );
1299 drain(&logger);
1300 let body = std::fs::read_to_string(logger.path()).unwrap();
1301 assert!(body.contains("\"outcome\":\"error\""));
1302 assert!(body.contains("\"result_text\":\"err: disk full\""));
1303 }
1304
1305 #[test]
1306 fn iso8601_formats_known_epoch() {
1307 assert_eq!(
1308 format_iso8601(1_709_210_096_789),
1309 "2024-02-29T12:34:56.789Z"
1310 );
1311 }
1312
1313 #[test]
1314 fn rotation_at_threshold() {
1315 let data = temp_data_path("rotate");
1316 let parent = data.parent().unwrap().to_path_buf();
1317 let logger = AuditLogger::with_max_bytes(parent.join(".audit.log"), 1024);
1318 for i in 0..30 {
1319 logger.record_event(
1320 AuditEvent::builder(format!("test/rotate/{i}"))
1321 .principal("rotator")
1322 .detail(JsonValue::String(
1323 "lorem ipsum dolor sit amet consectetur padding padding padding"
1324 .to_string(),
1325 ))
1326 .build(),
1327 );
1328 }
1329 drain(&logger);
1330 let parent = logger.path().parent().unwrap();
1331 let rotated: Vec<_> = std::fs::read_dir(parent)
1332 .unwrap()
1333 .filter_map(|e| e.ok())
1334 .filter(|e| {
1335 e.file_name()
1336 .to_str()
1337 .map(|n| n.starts_with(".audit.log.") && n.ends_with(".zst"))
1338 .unwrap_or(false)
1339 })
1340 .collect();
1341 assert!(
1342 !rotated.is_empty(),
1343 "expected at least one rotated .zst file"
1344 );
1345 }
1346
1347 #[test]
1348 fn parse_line_round_trips() {
1349 let ev = AuditEvent::builder("auth/login.ok")
1350 .principal("alice")
1351 .source(AuditAuthSource::Password)
1352 .tenant("acme")
1353 .outcome(Outcome::Success)
1354 .build();
1355 let line = ev.to_json_line(None);
1356 let parsed = AuditEvent::parse_line(&line).expect("round-trip parse");
1357 assert_eq!(parsed.action, "auth/login.ok");
1358 assert_eq!(parsed.principal.as_deref(), Some("alice"));
1359 assert_eq!(parsed.tenant.as_deref(), Some("acme"));
1360 assert_eq!(parsed.outcome, Outcome::Success);
1361 assert_eq!(parsed.source, AuditAuthSource::Password);
1362 }
1363
1364 #[test]
1365 fn event_id_is_lexicographically_sortable_by_time() {
1366 let a = new_event_id();
1367 std::thread::sleep(Duration::from_millis(2));
1368 let b = new_event_id();
1369 assert!(a < b, "event_id ordering broken: {a} >= {b}");
1370 }
1371
1372 #[test]
1377 fn audit_field_escaper_typed_string() {
1378 let f = AuditFieldEscaper::field("collection", "users");
1379 assert_eq!(f.name(), "collection");
1380 match f.value() {
1381 AuditValue::String(s) => assert_eq!(s, "users"),
1382 other => panic!("expected String, got {:?}", other),
1383 }
1384 }
1385
1386 #[test]
1387 fn audit_field_escaper_bytes_emit_base64() {
1388 let bytes = vec![0xDEu8, 0xAD, 0xBE, 0xEF];
1389 let f = AuditFieldEscaper::field("blob", bytes);
1390 let ev = AuditEvent::builder("test/bytes").field(f).build();
1391 let line = ev.to_json_line(None);
1392 assert!(
1394 line.contains("\"blob\":\"3q2+7w==\""),
1395 "expected base64 emission: {line}"
1396 );
1397 }
1398
1399 #[test]
1400 fn audit_field_escaper_number_bool_null() {
1401 let ev = AuditEvent::builder("test/types")
1402 .field(AuditFieldEscaper::field("count", 42i64))
1403 .field(AuditFieldEscaper::field("ok", true))
1404 .field(AuditFieldEscaper::field("missing", AuditValue::Null))
1405 .build();
1406 let line = ev.to_json_line(None);
1407 assert!(line.contains("\"count\":42"));
1408 assert!(line.contains("\"ok\":true"));
1409 assert!(line.contains("\"missing\":null"));
1410 }
1411
1412 #[test]
1413 fn audit_field_escaper_adversarial_corpus_preserves_structure() {
1414 let cases: &[(&str, &str)] = &[
1420 ("crlf", "line1\r\nline2"),
1421 ("nul", "before\0after"),
1422 ("quote", "she said \"hi\""),
1423 ("semicolon", "a;b;c"),
1424 ("json_in_json", r#"{"injected":"yes"}"#),
1425 ("low_ctrl", "\x01\x02\x03\x07\x1f"),
1426 ("backslash", "C:\\path\\file"),
1427 ("mixed", "name=\"x\"\n\\path\t\x01end"),
1428 ("empty", ""),
1429 ];
1436 let mut survivors = 0usize;
1437 for (label, payload) in cases {
1438 let f = AuditFieldEscaper::field("user_input", *payload);
1439 let ev = AuditEvent::builder(format!("test/adv/{label}"))
1440 .principal("attacker")
1441 .field(f)
1442 .build();
1443 let line = ev.to_json_line(None);
1444 assert!(
1446 !line.contains('\n'),
1447 "{label}: embedded newline in JSONL row: {line:?}"
1448 );
1449 let parsed: JsonValue = crate::json::from_str(&line)
1451 .unwrap_or_else(|err| panic!("{label}: line did not parse: {err} :: {line:?}"));
1452 let detail = parsed.get("detail").expect("detail present");
1453 let recovered = detail.get("user_input").and_then(|v| v.as_str()).unwrap();
1454 assert_eq!(
1455 recovered, *payload,
1456 "{label}: round-trip mismatch: {recovered:?} != {payload:?}"
1457 );
1458 survivors += 1;
1459 }
1460 assert_eq!(
1461 survivors,
1462 cases.len(),
1463 "adversarial corpus survival rate: {survivors}/{}",
1464 cases.len()
1465 );
1466 }
1467
1468 #[test]
1469 fn audit_emission_emits_one_line_per_call_through_guard() {
1470 let data = temp_data_path("guard-emission");
1471 let logger = AuditLogger::for_data_path(&data);
1472 let attacker = "users\";DROP\r\n{\"x\":1}\0";
1474 logger.record_event(
1475 AuditEvent::builder("admin/scan")
1476 .principal("evil")
1477 .field(AuditFieldEscaper::field("collection", attacker))
1478 .build(),
1479 );
1480 drain(&logger);
1481 let body = std::fs::read_to_string(logger.path()).unwrap();
1482 let lines: Vec<&str> = body.lines().collect();
1483 assert_eq!(lines.len(), 1, "guard must emit exactly one JSONL row");
1484 let parsed: JsonValue = crate::json::from_str(lines[0]).unwrap();
1486 let recovered = parsed
1487 .get("detail")
1488 .and_then(|d| d.get("collection"))
1489 .and_then(|v| v.as_str())
1490 .unwrap();
1491 assert_eq!(recovered, attacker);
1492 }
1493
1494 #[test]
1495 fn audit_field_escaper_no_format_macro_in_value_path() {
1496 let _ = AuditFieldEscaper::field("name", "value"); }
1506
1507 #[test]
1508 fn audit_field_escaper_chains_via_builder_fields() {
1509 let ev = AuditEvent::builder("test/multi")
1510 .fields([
1511 AuditFieldEscaper::field("a", "x"),
1512 AuditFieldEscaper::field("b", 7i64),
1513 AuditFieldEscaper::field("c", true),
1514 ])
1515 .build();
1516 let line = ev.to_json_line(None);
1517 let parsed: JsonValue = crate::json::from_str(&line).unwrap();
1518 let d = parsed.get("detail").unwrap();
1519 assert_eq!(d.get("a").and_then(|v| v.as_str()), Some("x"));
1520 assert_eq!(d.get("b").and_then(|v| v.as_i64()), Some(7));
1521 assert_eq!(d.get("c").and_then(|v| v.as_bool()), Some(true));
1522 }
1523
1524 proptest::proptest! {
1525 #[test]
1537 fn prop_audit_field_round_trips_arbitrary_strings(
1538 payload in proptest::string::string_regex("[\\x00-\\x7f]{0,128}").unwrap()
1539 ) {
1540 let f = AuditFieldEscaper::field("p", payload.as_str());
1541 let ev = AuditEvent::builder("prop/test").field(f).build();
1542 let line = ev.to_json_line(None);
1543 proptest::prop_assert!(!line.contains('\n'));
1545 let parsed: JsonValue = crate::json::from_str(&line)
1546 .expect("emission must always parse");
1547 let recovered = parsed
1548 .get("detail")
1549 .and_then(|d| d.get("p"))
1550 .and_then(|v| v.as_str())
1551 .unwrap();
1552 proptest::prop_assert_eq!(recovered, payload.as_str());
1553 }
1554
1555 #[test]
1558 fn prop_audit_field_bytes_base64_round_trip(
1559 bytes in proptest::collection::vec(proptest::bits::u8::ANY, 0..64)
1560 ) {
1561 let f = AuditFieldEscaper::field("b", bytes.clone());
1562 let ev = AuditEvent::builder("prop/bytes").field(f).build();
1563 let line = ev.to_json_line(None);
1564 proptest::prop_assert!(!line.contains('\n'));
1565 let parsed: JsonValue = crate::json::from_str(&line).unwrap();
1566 let recovered_b64 = parsed
1567 .get("detail")
1568 .and_then(|d| d.get("b"))
1569 .and_then(|v| v.as_str())
1570 .unwrap()
1571 .to_string();
1572 proptest::prop_assert_eq!(recovered_b64, base64_encode(&bytes));
1573 }
1574 }
1575}