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.
233 lines
7.1 KiB
Rust
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,
|
|
});
|
|
}
|
|
}
|