use async_trait::async_trait;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use serde_json::Value;
#[async_trait]
pub(crate) trait AuditExportSink: Send + Sync {
fn name(&self) -> &'static str;
async fn export(&self, topic: &str, envelope: &Value) -> Result<(), String>;
}
pub(crate) struct PostgresAuditLogSink {
pool: sqlx::PgPool,
relation: String,
ensured: AtomicBool,
}
impl PostgresAuditLogSink {
pub(crate) fn new(pool: sqlx::PgPool) -> Self {
Self {
pool,
relation: "udb_system.auth_audit_log".to_string(),
ensured: AtomicBool::new(false),
}
}
pub(crate) async fn ensure_table(&self) -> Result<(), String> {
sqlx::query("CREATE SCHEMA IF NOT EXISTS udb_system")
.execute(&self.pool)
.await
.map_err(|e| format!("ensure udb_system schema failed: {e}"))?;
let sql = format!(
"CREATE TABLE IF NOT EXISTS {rel} ( \
audit_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), \
event_id UUID NOT NULL, \
event_type VARCHAR(200) NOT NULL, \
tenant_id VARCHAR(64) NOT NULL, \
actor VARCHAR(200) NOT NULL DEFAULT '', \
target_resource VARCHAR(400) NOT NULL DEFAULT '', \
operation VARCHAR(80) NOT NULL DEFAULT '', \
outcome VARCHAR(40) NOT NULL DEFAULT '', \
reason_code VARCHAR(200) NOT NULL DEFAULT '', \
correlation_id VARCHAR(120) NOT NULL DEFAULT '', \
trace_id VARCHAR(64) NOT NULL DEFAULT '', \
decision_id VARCHAR(120) NOT NULL DEFAULT '', \
source_ip VARCHAR(64) NOT NULL DEFAULT '', \
redaction_version VARCHAR(16) NOT NULL DEFAULT '', \
envelope JSONB NOT NULL, \
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW() \
)",
rel = self.relation
);
sqlx::query(&sql)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(|e| format!("ensure auth_audit_log table failed: {e}"))
}
}
#[async_trait]
impl AuditExportSink for PostgresAuditLogSink {
fn name(&self) -> &'static str {
"postgres_audit_log"
}
async fn export(&self, topic: &str, envelope: &Value) -> Result<(), String> {
if !self.ensured.load(Ordering::Acquire) {
self.ensure_table().await?;
self.ensured.store(true, Ordering::Release);
}
let s = |k: &str| envelope.get(k).and_then(Value::as_str).unwrap_or("");
let event_id = s("event_id");
let sql = format!(
"INSERT INTO {rel} \
(event_id, event_type, tenant_id, actor, target_resource, operation, outcome, \
reason_code, correlation_id, trace_id, decision_id, source_ip, redaction_version, envelope) \
VALUES ($1::UUID, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::JSONB)",
rel = self.relation
);
sqlx::query(&sql)
.bind(event_id)
.bind(topic)
.bind(s("actor_tenant"))
.bind(s("actor"))
.bind(s("target_resource"))
.bind(s("operation"))
.bind(s("outcome"))
.bind(s("reason_code"))
.bind(s("correlation_id"))
.bind(s("trace_id"))
.bind(s("decision_id"))
.bind(s("source_ip"))
.bind(s("redaction_profile"))
.bind(envelope.to_string())
.execute(&self.pool)
.await
.map(|_| ())
.map_err(|e| format!("auth audit-log insert failed: {e}"))
}
}
pub(crate) enum StdoutTarget {
Stdout,
File(std::path::PathBuf),
}
pub(crate) struct StdoutAuditSink {
target: StdoutTarget,
}
impl StdoutAuditSink {
pub(crate) fn stdout() -> Self {
Self {
target: StdoutTarget::Stdout,
}
}
pub(crate) fn file(path: impl Into<std::path::PathBuf>) -> Self {
Self {
target: StdoutTarget::File(path.into()),
}
}
}
#[async_trait]
impl AuditExportSink for StdoutAuditSink {
fn name(&self) -> &'static str {
"stdout_file"
}
async fn export(&self, _topic: &str, envelope: &Value) -> Result<(), String> {
let line = envelope.to_string();
match &self.target {
StdoutTarget::Stdout => {
println!("{line}");
Ok(())
}
StdoutTarget::File(path) => {
use std::io::Write;
let path = path.clone();
tokio::task::spawn_blocking(move || {
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| format!("open audit file failed: {e}"))?;
writeln!(f, "{line}").map_err(|e| format!("write audit file failed: {e}"))
})
.await
.map_err(|e| format!("audit file task join failed: {e}"))?
}
}
}
}
#[cfg(feature = "http-client")]
pub(crate) struct WebhookAuditSink {
client: reqwest::Client,
url: String,
auth_header: Option<String>,
}
#[cfg(feature = "http-client")]
impl WebhookAuditSink {
pub(crate) fn new(url: impl Into<String>, auth_header: Option<String>) -> Self {
Self {
client: reqwest::Client::new(),
url: url.into(),
auth_header,
}
}
}
#[cfg(feature = "http-client")]
#[async_trait]
impl AuditExportSink for WebhookAuditSink {
fn name(&self) -> &'static str {
"siem_webhook"
}
async fn export(&self, _topic: &str, envelope: &Value) -> Result<(), String> {
let mut req = self
.client
.post(&self.url)
.header("content-type", "application/json")
.body(envelope.to_string());
if let Some(h) = &self.auth_header {
req = req.header("authorization", h);
}
let resp = req
.send()
.await
.map_err(|e| format!("siem webhook post failed: {e}"))?;
if resp.status().is_success() {
Ok(())
} else {
Err(format!("siem webhook returned HTTP {}", resp.status()))
}
}
}
pub(crate) fn export_sinks_from_env(pool: Option<&sqlx::PgPool>) -> Vec<Arc<dyn AuditExportSink>> {
let on = |k: &str| {
std::env::var(k)
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
.unwrap_or(false)
};
let mut sinks: Vec<Arc<dyn AuditExportSink>> = Vec::new();
if on("UDB_AUDIT_EXPORT_POSTGRES") {
if let Some(pool) = pool {
sinks.push(Arc::new(PostgresAuditLogSink::new(pool.clone())));
}
}
if on("UDB_AUDIT_EXPORT_STDOUT") {
sinks.push(Arc::new(StdoutAuditSink::stdout()));
}
if let Ok(path) = std::env::var("UDB_AUDIT_EXPORT_FILE") {
if !path.trim().is_empty() {
sinks.push(Arc::new(StdoutAuditSink::file(path)));
}
}
#[cfg(feature = "http-client")]
if let Ok(url) = std::env::var("UDB_AUDIT_EXPORT_WEBHOOK_URL") {
if !url.trim().is_empty() {
let auth = std::env::var("UDB_AUDIT_EXPORT_WEBHOOK_AUTH")
.ok()
.filter(|s| !s.trim().is_empty());
sinks.push(Arc::new(WebhookAuditSink::new(url, auth)));
}
}
sinks
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[tokio::test]
async fn file_sink_writes_jsonl_line() {
let dir = std::env::temp_dir();
let path = dir.join(format!("udb_audit_test_{}.jsonl", uuid::Uuid::new_v4()));
let sink = StdoutAuditSink::file(&path);
let env = json!({ "event_id": "e1", "event_type": "udb.authz.access.denied.v1" });
sink.export("udb.authz.access.denied.v1", &env)
.await
.expect("file export");
let body = std::fs::read_to_string(&path).expect("read back");
assert!(body.contains("udb.authz.access.denied.v1"));
assert!(body.ends_with('\n'));
let _ = std::fs::remove_file(&path);
}
#[test]
fn env_sinks_default_to_empty() {
let sinks = export_sinks_from_env(None);
assert!(sinks.iter().all(|s| !s.name().is_empty()));
}
}