use crate::cache::SharedCache;
use crate::security::types::{
deserialize_audit_logs, serialize_audit_logs, AuditLog, AuditResult, AuthContext, AuthMetadata,
};
use log::warn;
use once_cell::sync::Lazy;
use std::sync::Arc;
use std::time::Duration;
use uuid::Uuid;
pub(crate) struct AuditLogBatch {
user_id: String,
#[allow(dead_code)] log: AuditLog,
}
static JWT_PATTERN: Lazy<regex::Regex> = Lazy::new(|| {
regex::Regex::new(r#"eyJ[A-Za-z0-9\-_]+\.eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+"#).unwrap()
});
static SECRET_PATTERN: Lazy<regex::Regex> = Lazy::new(|| {
regex::Regex::new(r#"(?i)(password|secret|token|key|auth|bearer)\s*[:=]\s*[^,\s}\]]{1,100}"#)
.unwrap()
});
static PATH_PATTERN: Lazy<regex::Regex> =
Lazy::new(|| regex::Regex::new(r#"/[a-zA-Z0-9/_.-]+\.(pem|key|crt|p12|jks)"#).unwrap());
static API_KEY_PATTERN: Lazy<regex::Regex> = Lazy::new(|| {
regex::Regex::new(r#"(?i)(api[_-]?key|apikey)\s*[:=]\s*['\"]?[A-Za-z0-9]{20,}['\"]?"#).unwrap()
});
static CREDIT_CARD_PATTERN: Lazy<regex::Regex> =
Lazy::new(|| regex::Regex::new(r#"\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b"#).unwrap());
static SSN_PATTERN: Lazy<regex::Regex> =
Lazy::new(|| regex::Regex::new(r#"\b\d{3}[-\s]?\d{2}[-\s]?\d{4}\b"#).unwrap());
fn sanitize_error_message(message: &str) -> String {
let mut result = message.to_string();
result = JWT_PATTERN
.replace_all(&result, "[REDACTED_JWT]")
.to_string();
result = SECRET_PATTERN
.replace_all(&result, |caps: ®ex::Captures| {
format!("{}={}", &caps[1], "[REDACTED]")
})
.to_string();
result = API_KEY_PATTERN
.replace_all(&result, "[REDACTED_API_KEY]")
.to_string();
result = CREDIT_CARD_PATTERN
.replace_all(&result, "[REDACTED_CREDIT_CARD]")
.to_string();
result = SSN_PATTERN
.replace_all(&result, "[REDACTED_SSN]")
.to_string();
result = PATH_PATTERN
.replace_all(&result, "[REDACTED_PATH]")
.to_string();
const MAX_SANITIZED_LENGTH: usize = 500;
if result.len() > MAX_SANITIZED_LENGTH {
result.truncate(MAX_SANITIZED_LENGTH);
result.push_str("...[TRUNCATED]");
}
result
}
#[derive(Clone)]
pub struct AppAuditLogger {
logs: SharedCache,
max_logs_per_user: usize,
semaphore: Arc<tokio::sync::Semaphore>,
queue_sender: Arc<tokio::sync::mpsc::Sender<AuditLogBatch>>,
fallback_logs: SharedCache,
dropped_log_count: Arc<std::sync::atomic::AtomicU64>,
total_log_count: Arc<std::sync::atomic::AtomicU64>,
}
impl Default for AppAuditLogger {
fn default() -> Self {
Self::new()
}
}
impl AppAuditLogger {
pub fn builder() -> AppAuditLoggerBuilder {
AppAuditLoggerBuilder::new()
}
pub fn new() -> Self {
Self::with_limit(1000)
}
pub fn with_limit(max_logs: usize) -> Self {
let (queue_sender, mut queue_receiver) = tokio::sync::mpsc::channel::<AuditLogBatch>(1000);
let logs: SharedCache = Arc::new(crate::cache::DashMapCache::new());
let fallback_logs: SharedCache = Arc::new(crate::cache::DashMapCache::new());
let logs_clone = logs.clone();
let fallback_logs_clone = fallback_logs.clone();
let max_logs_clone = max_logs;
tokio::spawn(async move {
while let Some(batch) = queue_receiver.recv().await {
let key = &batch.user_id;
if let Some(fallback_data) = fallback_logs_clone.get(key) {
let fallback: Vec<AuditLog> = deserialize_audit_logs(&fallback_data);
if !fallback.is_empty() {
let data = logs_clone.get(key);
let mut logs_vec: Vec<AuditLog> = data
.as_ref()
.map(|d| deserialize_audit_logs(d))
.unwrap_or_default();
logs_vec.extend(fallback);
if logs_vec.len() > max_logs_clone {
logs_vec.truncate(max_logs_clone);
}
logs_clone.set(key, serialize_audit_logs(&logs_vec));
fallback_logs_clone.delete(key);
}
}
}
});
Self {
logs,
max_logs_per_user: max_logs,
semaphore: Arc::new(tokio::sync::Semaphore::new(100)), queue_sender: Arc::new(queue_sender),
fallback_logs,
dropped_log_count: Arc::new(std::sync::atomic::AtomicU64::new(0)),
total_log_count: Arc::new(std::sync::atomic::AtomicU64::new(0)),
}
}
pub async fn log(
&self,
context: &AuthContext,
action: impl Into<String>,
resource: impl Into<String>,
success: bool,
message: Option<String>,
) {
let permit = match tokio::time::timeout(
Duration::from_secs(1),
self.semaphore.clone().acquire_owned(),
)
.await
{
Ok(Ok(permit)) => permit,
Ok(Err(_)) | Err(_) => {
return;
}
};
let mut log = AuditLog {
id: Uuid::new_v4().to_string(),
timestamp: chrono::Utc::now().timestamp(),
user_id: context.user_id.clone(),
action: action.into(),
resource: resource.into(),
result: if success {
AuditResult::Success
} else {
AuditResult::Failure {
message: sanitize_error_message(
&message.unwrap_or_else(|| "Unknown error".to_string()),
),
}
},
metadata: context.metadata.clone(),
signature: None, };
if let Ok(signing_key_str) = std::env::var("SDFORGE_AUDIT_SIGNING_KEY") {
if !signing_key_str.is_empty() {
log.generate_signature(signing_key_str.as_bytes());
} else {
warn!("⚠️ WARNING: SDFORGE_AUDIT_SIGNING_KEY is empty. Audit logs will not be signed.");
}
} else {
warn!(
"⚠️ WARNING: SDFORGE_AUDIT_SIGNING_KEY not set. Audit logs will not be signed.\n For production, set this environment variable to a secure random value (min 32 bytes).\n Example: export SDFORGE_AUDIT_SIGNING_KEY=$(openssl rand -hex 32)"
);
}
let user_id = context
.user_id
.clone()
.unwrap_or_else(|| "anonymous".to_string());
let key = &user_id;
let data = self.logs.get(key);
let mut logs_vec: Vec<AuditLog> = data
.as_ref()
.map(|d| deserialize_audit_logs(d))
.unwrap_or_default();
logs_vec.push(log.clone());
if logs_vec.len() > self.max_logs_per_user {
logs_vec.truncate(self.max_logs_per_user);
}
let bytes = serialize_audit_logs(&logs_vec);
self.logs.set(key, bytes);
self.total_log_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if let Some(fallback_data) = self.fallback_logs.get(key) {
let fallback: Vec<AuditLog> = deserialize_audit_logs(&fallback_data);
if !fallback.is_empty() {
let mut merged = logs_vec;
merged.extend(fallback);
if merged.len() > self.max_logs_per_user {
merged.truncate(self.max_logs_per_user);
}
self.logs.set(key, serialize_audit_logs(&merged));
self.fallback_logs.delete(key);
}
}
let sender = self.queue_sender.clone();
let log_batch = AuditLogBatch {
user_id: user_id.clone(),
log,
};
match sender.try_send(log_batch) {
Ok(()) => {
}
Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
}
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
}
}
drop(permit);
}
pub fn get_logs(&self, user_id: &str) -> Vec<AuditLog> {
let primary = self
.logs
.get(user_id)
.map(|data| deserialize_audit_logs(&data))
.unwrap_or_default();
let fallback = self
.fallback_logs
.get(user_id)
.map(|data| deserialize_audit_logs(&data))
.unwrap_or_default();
let mut all_logs = primary;
for log in fallback {
if !all_logs.iter().any(|l| l.id == log.id) {
all_logs.push(log);
}
}
all_logs.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
all_logs
}
pub fn clear_logs(&self, user_id: &str) {
self.logs.delete(user_id);
}
pub async fn log_key_rotation(
&self,
_key_id: &str,
_old_version: &str,
_new_version: &str,
success: bool,
message: Option<String>,
) {
let context = AuthContext {
user_id: Some("system".to_string()),
permissions: vec![], metadata: AuthMetadata {
client_ip: None,
user_agent: None,
request_id: format!("rotation_{}", chrono::Utc::now().timestamp()),
timestamp: chrono::Utc::now().timestamp(),
},
};
self.log(&context, "key_rotation", "api_key", success, message)
.await;
}
pub fn total_log_count(&self) -> usize {
self.total_log_count
.load(std::sync::atomic::Ordering::Relaxed) as usize
}
pub fn dropped_log_count(&self) -> u64 {
self.dropped_log_count
.load(std::sync::atomic::Ordering::SeqCst)
}
}
impl crate::security::traits::AuditLogger for AppAuditLogger {
fn log(&self, log: AuditLog) {
let context = AuthContext {
user_id: log.user_id.clone(),
permissions: vec![],
metadata: log.metadata.clone(),
};
let result = matches!(log.result, AuditResult::Success);
let message = match &log.result {
AuditResult::Success => None,
AuditResult::Failure { message } => Some(message.clone()),
};
let action = log.action.clone();
let resource = log.resource.clone();
let logs = self.logs.clone();
let max_logs_per_user = self.max_logs_per_user;
let semaphore = self.semaphore.clone();
let queue_sender = self.queue_sender.clone();
let fallback_logs = self.fallback_logs.clone();
let dropped_log_count = self.dropped_log_count.clone();
match tokio::runtime::Handle::try_current() {
Ok(handle) => {
handle.spawn(async move {
let logger = AppAuditLogger {
logs,
max_logs_per_user,
semaphore,
queue_sender,
fallback_logs,
dropped_log_count,
total_log_count: Arc::new(std::sync::atomic::AtomicU64::new(0)),
};
logger
.log(&context, action, resource, result, message)
.await;
});
}
Err(_) => {
dropped_log_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
warn!(
"[AuditLogger] WARNING: no tokio runtime available, audit log dropped for action={}"
, action);
}
}
}
}
pub struct AppAuditLoggerBuilder {
max_logs_per_user: usize,
max_concurrent_ops: usize,
queue_size: usize,
}
impl Default for AppAuditLoggerBuilder {
fn default() -> Self {
Self::new()
}
}
impl AppAuditLoggerBuilder {
pub fn new() -> Self {
Self {
max_logs_per_user: 1000,
max_concurrent_ops: 100,
queue_size: 1000,
}
}
pub fn max_logs_per_user(mut self, max_logs: usize) -> Self {
self.max_logs_per_user = max_logs;
self
}
pub fn max_concurrent_ops(mut self, max_concurrent: usize) -> Self {
self.max_concurrent_ops = max_concurrent;
self
}
pub fn queue_size(mut self, queue_size: usize) -> Self {
self.queue_size = queue_size;
self
}
pub fn build(self) -> AppAuditLogger {
let (queue_sender, mut queue_receiver) =
tokio::sync::mpsc::channel::<AuditLogBatch>(self.queue_size);
let logs: SharedCache = Arc::new(crate::cache::DashMapCache::new());
let fallback_logs: SharedCache = Arc::new(crate::cache::DashMapCache::new());
let logs_clone = logs.clone();
let fallback_logs_clone = fallback_logs.clone();
let max_logs_clone = self.max_logs_per_user;
tokio::spawn(async move {
while let Some(batch) = queue_receiver.recv().await {
let key = &batch.user_id;
if let Some(fallback_data) = fallback_logs_clone.get(key) {
let fallback: Vec<AuditLog> = deserialize_audit_logs(&fallback_data);
if !fallback.is_empty() {
let data = logs_clone.get(key);
let mut logs_vec: Vec<AuditLog> = data
.as_ref()
.map(|d| deserialize_audit_logs(d))
.unwrap_or_default();
logs_vec.extend(fallback);
if logs_vec.len() > max_logs_clone {
logs_vec.truncate(max_logs_clone);
}
logs_clone.set(key, serialize_audit_logs(&logs_vec));
fallback_logs_clone.delete(key);
}
}
}
});
AppAuditLogger {
logs,
max_logs_per_user: self.max_logs_per_user,
semaphore: Arc::new(tokio::sync::Semaphore::new(self.max_concurrent_ops)),
queue_sender: Arc::new(queue_sender),
fallback_logs,
dropped_log_count: Arc::new(std::sync::atomic::AtomicU64::new(0)),
total_log_count: Arc::new(std::sync::atomic::AtomicU64::new(0)),
}
}
}
#[cfg(test)]
mod tests;