Files
quicproquo/crates/quicprochat-server/src/audit.rs
Christian Nennemann a710037dde chore: rename quicproquo → quicprochat in Rust workspace
Rename all crate directories, package names, binary names, proto
package/module paths, ALPN strings, env var prefixes, config filenames,
mDNS service names, and plugin ABI symbols from quicproquo/qpq to
quicprochat/qpc.
2026-03-21 19:14:06 +01:00

233 lines
7.1 KiB
Rust

//! Structured audit log — persistent, machine-readable event journal.
//!
//! Events are serialized as JSON lines and appended to a file or SQL table.
//! Each event carries a correlation `trace_id` for cross-referencing with
//! RPC request traces.
use std::fs::OpenOptions;
use std::io::Write as IoWrite;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use serde::Serialize;
// ── Audit event types ─────────────────────────────────────────────────────
/// Action categories for the audit log.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditAction {
AuthRegister,
AuthLoginSuccess,
AuthLoginFailure,
Enqueue,
BatchEnqueue,
Fetch,
FetchWait,
KeyUpload,
HybridKeyUpload,
BanUser,
UnbanUser,
ReportMessage,
AccountDelete,
DeviceRegister,
DeviceRevoke,
BlobUpload,
RecoveryStore,
RecoveryFetch,
RecoveryDelete,
}
/// Outcome of an audited action.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditOutcome {
Success,
Denied,
Error,
RateLimited,
}
/// A single audit event record.
#[derive(Debug, Clone, Serialize)]
pub struct AuditEvent {
/// ISO-8601 timestamp.
pub timestamp: String,
/// RPC correlation ID.
pub trace_id: String,
/// Hex-encoded actor identity key (truncated for privacy when redact=true).
pub actor: String,
/// The action performed.
pub action: AuditAction,
/// Target identifier (recipient key, username, etc.).
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
/// Outcome of the action.
pub outcome: AuditOutcome,
/// Free-form details.
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
}
// ── Audit logger trait ────────────────────────────────────────────────────
/// Trait for audit log backends.
pub trait AuditLogger: Send + Sync {
fn log(&self, event: AuditEvent);
}
// ── File-backed implementation ───────────────────────────────────────────
/// Appends JSON-line events to a file.
pub struct FileAuditLogger {
path: PathBuf,
file: Mutex<std::fs::File>,
}
impl FileAuditLogger {
/// Open (or create) the audit log file at `path`.
pub fn open(path: &Path) -> Result<Self, std::io::Error> {
let file = OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
Ok(Self {
path: path.to_path_buf(),
file: Mutex::new(file),
})
}
/// Return the path to the audit log file.
pub fn path(&self) -> &Path {
&self.path
}
}
impl AuditLogger for FileAuditLogger {
fn log(&self, event: AuditEvent) {
let Ok(mut line) = serde_json::to_string(&event) else {
tracing::warn!("audit: failed to serialize event");
return;
};
line.push('\n');
let Ok(mut f) = self.file.lock() else {
tracing::warn!("audit: log file lock poisoned");
return;
};
if let Err(e) = f.write_all(line.as_bytes()) {
tracing::warn!(error = %e, "audit: failed to write event");
}
}
}
// ── No-op implementation ─────────────────────────────────────────────────
/// Does nothing. Used when audit logging is disabled.
pub struct NoopAuditLogger;
impl AuditLogger for NoopAuditLogger {
fn log(&self, _event: AuditEvent) {}
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// Format identity key bytes as hex, optionally truncated for privacy.
pub fn format_actor(identity_key: &[u8], redact: bool) -> String {
let full = hex::encode(identity_key);
if redact && full.len() > 12 {
format!("{}...", &full[..12])
} else {
full
}
}
/// Current ISO-8601 UTC timestamp.
pub fn now_iso8601() -> String {
// Use SystemTime to avoid pulling in chrono.
let d = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = d.as_secs();
// Simple UTC formatting: enough for audit logs.
format!("{secs}")
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
#[test]
fn file_audit_logger_writes_json_lines() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("audit.jsonl");
let logger = FileAuditLogger::open(&path).expect("open");
logger.log(AuditEvent {
timestamp: "1709500000".to_string(),
trace_id: "test-trace-001".to_string(),
actor: "abcdef123456".to_string(),
action: AuditAction::Enqueue,
target: Some("recipient-hex".to_string()),
outcome: AuditOutcome::Success,
details: None,
});
logger.log(AuditEvent {
timestamp: "1709500001".to_string(),
trace_id: "test-trace-002".to_string(),
actor: "abcdef123456".to_string(),
action: AuditAction::AuthLoginFailure,
target: None,
outcome: AuditOutcome::Denied,
details: Some("bad password".to_string()),
});
drop(logger);
let mut content = String::new();
std::fs::File::open(&path)
.expect("open for read")
.read_to_string(&mut content)
.expect("read");
let lines: Vec<&str> = content.trim().split('\n').collect();
assert_eq!(lines.len(), 2);
// Verify JSON parses.
let v: serde_json::Value = serde_json::from_str(lines[0]).expect("parse line 0");
assert_eq!(v["action"], "enqueue");
assert_eq!(v["outcome"], "success");
assert_eq!(v["trace_id"], "test-trace-001");
let v: serde_json::Value = serde_json::from_str(lines[1]).expect("parse line 1");
assert_eq!(v["action"], "auth_login_failure");
assert_eq!(v["details"], "bad password");
}
#[test]
fn format_actor_truncates_when_redacted() {
let key = vec![0xAA; 32];
let full = format_actor(&key, false);
assert_eq!(full.len(), 64);
let redacted = format_actor(&key, true);
assert!(redacted.ends_with("..."));
assert_eq!(redacted.len(), 15); // 12 hex chars + "..."
}
#[test]
fn noop_logger_does_not_panic() {
let logger = NoopAuditLogger;
logger.log(AuditEvent {
timestamp: "0".to_string(),
trace_id: "noop".to_string(),
actor: "none".to_string(),
action: AuditAction::Fetch,
target: None,
outcome: AuditOutcome::Success,
details: None,
});
}
}