use sea_orm::{ActiveModelTrait, ActiveValue::Set, ConnectionTrait, DbErr, EntityTrait};
use serde_json::Value as JsonValue;
use uuid::Uuid;
use crate::actor::AuditActor;
use crate::entity;
use crate::error::AuditError;
use crate::target::AuditTarget;
pub type AuditEntry = entity::Model;
impl AuditEntry {
pub fn record(action: impl Into<String>) -> AuditEntryBuilder {
AuditEntryBuilder {
action: action.into(),
actor: AuditActor::System,
target: None,
before: None,
after: None,
reason: None,
correlation_id: None,
tenant_id: None,
}
}
}
#[derive(Debug)]
pub struct AuditEntryBuilder {
action: String,
actor: AuditActor,
target: Option<AuditTarget>,
before: Option<JsonValue>,
after: Option<JsonValue>,
reason: Option<String>,
correlation_id: Option<Uuid>,
tenant_id: Option<String>,
}
impl AuditEntryBuilder {
pub fn actor(mut self, actor: AuditActor) -> Self {
self.actor = actor;
self
}
pub fn target(mut self, target: AuditTarget) -> Self {
self.target = Some(target);
self
}
pub fn before(mut self, before: JsonValue) -> Self {
self.before = Some(before);
self
}
pub fn after(mut self, after: JsonValue) -> Self {
self.after = Some(after);
self
}
pub fn reason(mut self, reason: impl Into<String>) -> Self {
self.reason = Some(reason.into());
self
}
pub fn correlation(mut self, correlation_id: Uuid) -> Self {
self.correlation_id = Some(correlation_id);
self
}
pub fn tenant(mut self, tenant_id: impl Into<String>) -> Self {
self.tenant_id = Some(tenant_id.into());
self
}
pub async fn write<C: ConnectionTrait>(self, conn: &C) -> Result<AuditEntry, AuditError> {
if self.action.is_empty() {
return Err(AuditError::MissingAction);
}
if self.target.is_none() {
tracing::warn!(
action = %self.action,
"audit entry written without a target — history_for_target will not find this entry"
);
}
let new_id = Uuid::new_v4();
let (target_kind, target_id) = match self.target {
Some(t) => (Some(t.kind), Some(t.id)),
None => (None, None),
};
let actor_kind = self.actor.kind().to_string();
let actor_id = self.actor.id().map(|s| s.to_string());
let active = entity::ActiveModel {
id: Set(new_id),
tenant_id: Set(self.tenant_id),
actor_kind: Set(actor_kind),
actor_id: Set(actor_id),
action: Set(self.action),
target_kind: Set(target_kind),
target_id: Set(target_id),
before: Set(self.before),
after: Set(self.after),
reason: Set(self.reason),
correlation_id: Set(self.correlation_id),
created_at: sea_orm::ActiveValue::NotSet,
};
active.insert(conn).await?;
let persisted = entity::Entity::find_by_id(new_id)
.one(conn)
.await?
.ok_or_else(|| {
AuditError::Db(DbErr::RecordNotFound(
"audit_log: row vanished after INSERT".to_string(),
))
})?;
Ok(persisted)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDateTime;
use sea_orm::{Database, DatabaseConnection};
use sea_orm_migration::prelude::*;
use serde_json::json;
struct TestMigrator;
#[async_trait::async_trait]
impl MigratorTrait for TestMigrator {
fn migrations() -> Vec<Box<dyn sea_orm_migration::MigrationTrait>> {
vec![Box::new(crate::migration::Migration)]
}
}
async fn fresh_db() -> DatabaseConnection {
let conn = Database::connect("sqlite::memory:")
.await
.expect("connect sqlite::memory:");
TestMigrator::up(&conn, None).await.expect("run migration");
conn
}
#[tokio::test]
async fn happy_path() {
let conn = fresh_db().await;
let entry = AuditEntry::record("inventory.stock.adjust")
.actor(AuditActor::User("u_42".into()))
.target(AuditTarget::new("inventory.unit", "abc"))
.before(json!({ "quantity": 5 }))
.after(json!({ "quantity": 4 }))
.reason("order_committed")
.write(&conn)
.await
.expect("write happy_path");
assert_ne!(entry.id, Uuid::nil(), "id should be a fresh UUIDv4");
assert_ne!(
entry.created_at,
NaiveDateTime::default(),
"created_at should be DB-stamped"
);
assert_eq!(entry.action, "inventory.stock.adjust");
assert_eq!(entry.actor_kind, "user");
assert_eq!(entry.actor_id, Some("u_42".to_string()));
assert_eq!(entry.target_kind, Some("inventory.unit".to_string()));
assert_eq!(entry.target_id, Some("abc".to_string()));
assert_eq!(entry.before, Some(json!({ "quantity": 5 })));
assert_eq!(entry.after, Some(json!({ "quantity": 4 })));
assert_eq!(entry.reason, Some("order_committed".to_string()));
}
#[tokio::test]
async fn missing_action() {
let conn = fresh_db().await;
let err = AuditEntry::record("")
.actor(AuditActor::System)
.target(AuditTarget::new("inventory.unit", "abc"))
.write(&conn)
.await
.expect_err("empty action must fail");
assert!(matches!(err, AuditError::MissingAction));
assert_eq!(err.to_string(), "audit: action is required");
}
#[tokio::test]
async fn missing_target_writes() {
let conn = fresh_db().await;
let entry = AuditEntry::record("user.password_reset_requested")
.actor(AuditActor::User("u_42".into()))
.write(&conn)
.await
.expect("write without target should succeed");
assert_eq!(entry.target_kind, None);
assert_eq!(entry.target_id, None);
assert_eq!(entry.action, "user.password_reset_requested");
}
#[tokio::test]
async fn json_roundtrip() {
let conn = fresh_db().await;
let complex = json!({
"quantity": 42,
"status": "reserved",
"tags": ["urgent", "vip"],
"nested": { "depth": 2, "items": [1, 2, 3] }
});
let entry = AuditEntry::record("inventory.unit.updated")
.target(AuditTarget::new("inventory.unit", "abc"))
.after(complex.clone())
.write(&conn)
.await
.expect("write json_roundtrip");
let read_back = entity::Entity::find_by_id(entry.id)
.one(&conn)
.await
.expect("re-fetch")
.expect("entry exists");
assert_eq!(read_back.after, Some(complex));
}
#[tokio::test]
async fn actor_null_id() {
let conn = fresh_db().await;
let sys_entry = AuditEntry::record("system.cleanup")
.actor(AuditActor::System)
.target(AuditTarget::new("system.task", "cleanup"))
.write(&conn)
.await
.expect("write system");
assert_eq!(sys_entry.actor_kind, "system");
assert_eq!(sys_entry.actor_id, None);
let anon_entry = AuditEntry::record("public.page_view")
.actor(AuditActor::Anonymous)
.target(AuditTarget::new("page", "/about"))
.write(&conn)
.await
.expect("write anonymous");
assert_eq!(anon_entry.actor_kind, "anonymous");
assert_eq!(anon_entry.actor_id, None);
let api_entry = AuditEntry::record("api.token.refresh")
.actor(AuditActor::ApiClient("oauth_client_xyz".into()))
.target(AuditTarget::new("api.client", "oauth_client_xyz"))
.write(&conn)
.await
.expect("write api_client");
assert_eq!(api_entry.actor_kind, "api_client");
assert_eq!(api_entry.actor_id, Some("oauth_client_xyz".to_string()));
}
}