use std::{sync::Arc, time::SystemTime};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use deadpool_postgres::Pool;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tracing::error;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum AuditError {
#[error("Database operation failed: {0}")]
Database(#[from] deadpool_postgres::PoolError),
#[error("SQL query failed: {0}")]
Sql(#[from] tokio_postgres::Error),
#[error("JSON serialization failed: {0}")]
Serialization(#[from] serde_json::Error),
#[error("Audit export failed: {0}")]
Export(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum AuditLevel {
INFO,
WARN,
ERROR,
}
impl AuditLevel {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::INFO => "INFO",
Self::WARN => "WARN",
Self::ERROR => "ERROR",
}
}
#[must_use]
pub fn parse(s: &str) -> Self {
match s {
"WARN" => Self::WARN,
"ERROR" => Self::ERROR,
_ => Self::INFO, }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub id: Option<i64>,
pub timestamp: DateTime<Utc>,
pub level: AuditLevel,
pub user_id: i64,
pub tenant_id: i64,
pub operation: String,
pub query: String,
pub variables: serde_json::Value,
pub ip_address: String,
pub user_agent: String,
pub error: Option<String>,
pub duration_ms: Option<i32>,
pub previous_hash: Option<String>,
pub integrity_hash: Option<String>,
}
impl AuditEntry {
#[must_use]
pub fn calculate_hash(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(self.user_id.to_string().as_bytes());
hasher.update(self.timestamp.to_rfc3339().as_bytes());
hasher.update(self.operation.as_bytes());
hasher.update(self.query.as_bytes());
hasher.update(self.level.as_str().as_bytes());
if let Some(ref prev) = self.previous_hash {
hasher.update(prev.as_bytes());
}
hex::encode(hasher.finalize())
}
#[must_use]
pub fn verify_integrity(&self) -> bool {
if let Some(ref stored_hash) = self.integrity_hash {
let calculated = self.calculate_hash();
constant_time_eq(stored_hash.as_bytes(), calculated.as_bytes())
} else {
false
}
}
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
use subtle::ConstantTimeEq;
a.ct_eq(b).into()
}
#[async_trait]
pub trait AuditExporter: Send + Sync {
async fn export(&self, entry: &AuditEntry) -> Result<(), AuditError>;
async fn flush(&self) -> Result<(), AuditError>;
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AuditExportConfig {
#[serde(default)]
pub syslog: Option<SyslogExportConfig>,
#[serde(default)]
pub webhook: Option<WebhookExportConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyslogExportConfig {
pub address: String,
#[serde(default = "default_syslog_port")]
pub port: u16,
#[serde(default = "default_syslog_protocol")]
pub protocol: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookExportConfig {
pub url: String,
#[serde(default)]
pub headers: std::collections::HashMap<String, String>,
#[serde(default = "default_batch_size")]
pub batch_size: usize,
#[serde(default = "default_flush_interval_secs")]
pub flush_interval_secs: u64,
}
const fn default_syslog_port() -> u16 {
514
}
fn default_syslog_protocol() -> String {
"udp".to_string()
}
const fn default_batch_size() -> usize {
100
}
const fn default_flush_interval_secs() -> u64 {
30
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AuditStats {
pub total_events: u64,
pub recent_events: u64,
}
#[derive(Clone)]
pub struct AuditLogger {
pool: Arc<Pool>,
exporters: Arc<Vec<Box<dyn AuditExporter>>>,
}
impl std::fmt::Debug for AuditLogger {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuditLogger")
.field("pool", &self.pool)
.field("exporters_count", &self.exporters.len())
.finish()
}
}
impl AuditLogger {
const MAX_AUDIT_FIELD_BYTES: usize = 64 * 1024;
#[must_use]
pub fn new(pool: Arc<Pool>) -> Self {
Self {
pool,
exporters: Arc::new(Vec::new()),
}
}
#[must_use]
pub fn with_exporters(pool: Arc<Pool>, exporters: Vec<Box<dyn AuditExporter>>) -> Self {
Self {
pool,
exporters: Arc::new(exporters),
}
}
pub async fn log(&self, mut entry: AuditEntry) -> Result<i64, AuditError> {
if entry.query.len() > Self::MAX_AUDIT_FIELD_BYTES {
entry.query.truncate(Self::MAX_AUDIT_FIELD_BYTES);
entry.query.push_str("…[truncated]");
}
let vars_serialized = serde_json::to_string(&entry.variables).unwrap_or_default();
if vars_serialized.len() > Self::MAX_AUDIT_FIELD_BYTES {
entry.variables = serde_json::json!({"_truncated": true});
}
let sql = r"
INSERT INTO fraiseql_audit_logs (
timestamp,
level,
user_id,
tenant_id,
operation,
query,
variables,
ip_address,
user_agent,
error,
duration_ms
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id
";
let client = self.pool.get().await?;
let variables_json = serde_json::to_value(&entry.variables)?;
let secs = u64::try_from(entry.timestamp.timestamp()).unwrap_or(0);
let timestamp_system = SystemTime::UNIX_EPOCH
+ std::time::Duration::from_secs(secs)
+ std::time::Duration::from_nanos(u64::from(entry.timestamp.timestamp_subsec_nanos()));
let row = client
.query_one(
sql,
&[
×tamp_system,
&entry.level.as_str(),
&entry.user_id,
&entry.tenant_id,
&entry.operation,
&entry.query,
&variables_json,
&entry.ip_address,
&entry.user_agent,
&entry.error,
&entry.duration_ms,
],
)
.await?;
let id: i64 = row.get(0);
if !self.exporters.is_empty() {
for exporter in self.exporters.iter() {
if let Err(e) = exporter.export(&entry).await {
error!(error = %e, "Audit exporter failed");
}
}
}
Ok(id)
}
pub async fn flush_exporters(&self) -> Result<(), AuditError> {
let mut first_err = None;
for exporter in self.exporters.iter() {
if let Err(e) = exporter.flush().await {
error!(error = %e, "Audit exporter flush failed");
if first_err.is_none() {
first_err = Some(e);
}
}
}
match first_err {
Some(e) => Err(e),
None => Ok(()),
}
}
pub async fn get_recent_logs(
&self,
tenant_id: i64,
level: Option<AuditLevel>,
limit: i64,
) -> Result<Vec<AuditEntry>, AuditError> {
let client = self.pool.get().await?;
let rows = if let Some(lvl) = level {
let sql = r"
SELECT id, timestamp, level, user_id, tenant_id, operation,
query, variables, ip_address, user_agent, error, duration_ms
FROM fraiseql_audit_logs
WHERE tenant_id = $1 AND level = $2
ORDER BY timestamp DESC
LIMIT $3
";
client.query(sql, &[&tenant_id, &lvl.as_str(), &limit]).await?
} else {
let sql = r"
SELECT id, timestamp, level, user_id, tenant_id, operation,
query, variables, ip_address, user_agent, error, duration_ms
FROM fraiseql_audit_logs
WHERE tenant_id = $1
ORDER BY timestamp DESC
LIMIT $2
";
client.query(sql, &[&tenant_id, &limit]).await?
};
let entries: Vec<AuditEntry> = rows
.into_iter()
.map(|row| {
let id: Option<i64> = row.get(0);
let timestamp_system: SystemTime = row.get(1);
let level_str: String = row.get(2);
let user_id: i64 = row.get(3);
let tenant_id: i64 = row.get(4);
let operation: String = row.get(5);
let query: String = row.get(6);
let variables: serde_json::Value = row.get(7);
let ip_address: String = row.get(8);
let user_agent: String = row.get(9);
let error: Option<String> = row.get(10);
let duration_ms: Option<i32> = row.get(11);
let timestamp = DateTime::<Utc>::from(timestamp_system);
AuditEntry {
id,
timestamp,
level: AuditLevel::parse(&level_str),
user_id,
tenant_id,
operation,
query,
variables,
ip_address,
user_agent,
error,
duration_ms,
previous_hash: None,
integrity_hash: None,
}
})
.collect();
Ok(entries)
}
}