1use sea_orm::{ActiveModelTrait, ActiveValue::Set, ConnectionTrait, DbErr, EntityTrait};
11use serde_json::Value as JsonValue;
12use uuid::Uuid;
13
14use crate::actor::AuditActor;
15use crate::entity;
16use crate::error::AuditError;
17use crate::target::AuditTarget;
18
19pub type AuditEntry = entity::Model;
24
25impl AuditEntry {
26 pub fn record(action: impl Into<String>) -> AuditEntryBuilder {
46 AuditEntryBuilder {
47 action: action.into(),
48 actor: AuditActor::System,
49 target: None,
50 before: None,
51 after: None,
52 reason: None,
53 correlation_id: None,
54 tenant_id: None,
55 }
56 }
57}
58
59#[derive(Debug)]
64pub struct AuditEntryBuilder {
65 action: String,
66 actor: AuditActor,
67 target: Option<AuditTarget>,
68 before: Option<JsonValue>,
69 after: Option<JsonValue>,
70 reason: Option<String>,
71 correlation_id: Option<Uuid>,
72 tenant_id: Option<String>,
73}
74
75impl AuditEntryBuilder {
76 pub fn actor(mut self, actor: AuditActor) -> Self {
78 self.actor = actor;
79 self
80 }
81
82 pub fn target(mut self, target: AuditTarget) -> Self {
86 self.target = Some(target);
87 self
88 }
89
90 pub fn before(mut self, before: JsonValue) -> Self {
92 self.before = Some(before);
93 self
94 }
95
96 pub fn after(mut self, after: JsonValue) -> Self {
98 self.after = Some(after);
99 self
100 }
101
102 pub fn reason(mut self, reason: impl Into<String>) -> Self {
104 self.reason = Some(reason.into());
105 self
106 }
107
108 pub fn correlation(mut self, correlation_id: Uuid) -> Self {
111 self.correlation_id = Some(correlation_id);
112 self
113 }
114
115 pub fn tenant(mut self, tenant_id: impl Into<String>) -> Self {
118 self.tenant_id = Some(tenant_id.into());
119 self
120 }
121
122 pub async fn write<C: ConnectionTrait>(self, conn: &C) -> Result<AuditEntry, AuditError> {
132 if self.action.is_empty() {
133 return Err(AuditError::MissingAction);
134 }
135
136 if self.target.is_none() {
137 tracing::warn!(
138 action = %self.action,
139 "audit entry written without a target — history_for_target will not find this entry"
140 );
141 }
142
143 let new_id = Uuid::new_v4();
144 let (target_kind, target_id) = match self.target {
145 Some(t) => (Some(t.kind), Some(t.id)),
146 None => (None, None),
147 };
148
149 let actor_kind = self.actor.kind().to_string();
150 let actor_id = self.actor.id().map(|s| s.to_string());
151
152 let active = entity::ActiveModel {
153 id: Set(new_id),
154 tenant_id: Set(self.tenant_id),
155 actor_kind: Set(actor_kind),
156 actor_id: Set(actor_id),
157 action: Set(self.action),
158 target_kind: Set(target_kind),
159 target_id: Set(target_id),
160 before: Set(self.before),
161 after: Set(self.after),
162 reason: Set(self.reason),
163 correlation_id: Set(self.correlation_id),
164 created_at: sea_orm::ActiveValue::NotSet,
167 };
168
169 active.insert(conn).await?;
170
171 let persisted = entity::Entity::find_by_id(new_id)
175 .one(conn)
176 .await?
177 .ok_or_else(|| {
178 AuditError::Db(DbErr::RecordNotFound(
179 "audit_log: row vanished after INSERT".to_string(),
180 ))
181 })?;
182
183 Ok(persisted)
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use chrono::NaiveDateTime;
191 use sea_orm::{Database, DatabaseConnection};
192 use sea_orm_migration::prelude::*;
193 use serde_json::json;
194
195 struct TestMigrator;
196
197 #[async_trait::async_trait]
198 impl MigratorTrait for TestMigrator {
199 fn migrations() -> Vec<Box<dyn sea_orm_migration::MigrationTrait>> {
200 vec![Box::new(crate::migration::Migration)]
201 }
202 }
203
204 async fn fresh_db() -> DatabaseConnection {
205 let conn = Database::connect("sqlite::memory:")
206 .await
207 .expect("connect sqlite::memory:");
208 TestMigrator::up(&conn, None).await.expect("run migration");
209 conn
210 }
211
212 #[tokio::test]
213 async fn happy_path() {
214 let conn = fresh_db().await;
215
216 let entry = AuditEntry::record("inventory.stock.adjust")
217 .actor(AuditActor::User("u_42".into()))
218 .target(AuditTarget::new("inventory.unit", "abc"))
219 .before(json!({ "quantity": 5 }))
220 .after(json!({ "quantity": 4 }))
221 .reason("order_committed")
222 .write(&conn)
223 .await
224 .expect("write happy_path");
225
226 assert_ne!(entry.id, Uuid::nil(), "id should be a fresh UUIDv4");
227 assert_ne!(
228 entry.created_at,
229 NaiveDateTime::default(),
230 "created_at should be DB-stamped"
231 );
232 assert_eq!(entry.action, "inventory.stock.adjust");
233 assert_eq!(entry.actor_kind, "user");
234 assert_eq!(entry.actor_id, Some("u_42".to_string()));
235 assert_eq!(entry.target_kind, Some("inventory.unit".to_string()));
236 assert_eq!(entry.target_id, Some("abc".to_string()));
237 assert_eq!(entry.before, Some(json!({ "quantity": 5 })));
238 assert_eq!(entry.after, Some(json!({ "quantity": 4 })));
239 assert_eq!(entry.reason, Some("order_committed".to_string()));
240 }
241
242 #[tokio::test]
243 async fn missing_action() {
244 let conn = fresh_db().await;
245
246 let err = AuditEntry::record("")
247 .actor(AuditActor::System)
248 .target(AuditTarget::new("inventory.unit", "abc"))
249 .write(&conn)
250 .await
251 .expect_err("empty action must fail");
252
253 assert!(matches!(err, AuditError::MissingAction));
254 assert_eq!(err.to_string(), "audit: action is required");
255 }
256
257 #[tokio::test]
258 async fn missing_target_writes() {
259 let conn = fresh_db().await;
260
261 let entry = AuditEntry::record("user.password_reset_requested")
266 .actor(AuditActor::User("u_42".into()))
267 .write(&conn)
268 .await
269 .expect("write without target should succeed");
270
271 assert_eq!(entry.target_kind, None);
272 assert_eq!(entry.target_id, None);
273 assert_eq!(entry.action, "user.password_reset_requested");
274 }
275
276 #[tokio::test]
277 async fn json_roundtrip() {
278 let conn = fresh_db().await;
279
280 let complex = json!({
281 "quantity": 42,
282 "status": "reserved",
283 "tags": ["urgent", "vip"],
284 "nested": { "depth": 2, "items": [1, 2, 3] }
285 });
286
287 let entry = AuditEntry::record("inventory.unit.updated")
288 .target(AuditTarget::new("inventory.unit", "abc"))
289 .after(complex.clone())
290 .write(&conn)
291 .await
292 .expect("write json_roundtrip");
293
294 let read_back = entity::Entity::find_by_id(entry.id)
296 .one(&conn)
297 .await
298 .expect("re-fetch")
299 .expect("entry exists");
300
301 assert_eq!(read_back.after, Some(complex));
302 }
303
304 #[tokio::test]
305 async fn actor_null_id() {
306 let conn = fresh_db().await;
307
308 let sys_entry = AuditEntry::record("system.cleanup")
310 .actor(AuditActor::System)
311 .target(AuditTarget::new("system.task", "cleanup"))
312 .write(&conn)
313 .await
314 .expect("write system");
315 assert_eq!(sys_entry.actor_kind, "system");
316 assert_eq!(sys_entry.actor_id, None);
317
318 let anon_entry = AuditEntry::record("public.page_view")
320 .actor(AuditActor::Anonymous)
321 .target(AuditTarget::new("page", "/about"))
322 .write(&conn)
323 .await
324 .expect("write anonymous");
325 assert_eq!(anon_entry.actor_kind, "anonymous");
326 assert_eq!(anon_entry.actor_id, None);
327
328 let api_entry = AuditEntry::record("api.token.refresh")
330 .actor(AuditActor::ApiClient("oauth_client_xyz".into()))
331 .target(AuditTarget::new("api.client", "oauth_client_xyz"))
332 .write(&conn)
333 .await
334 .expect("write api_client");
335 assert_eq!(api_entry.actor_kind, "api_client");
336 assert_eq!(api_entry.actor_id, Some("oauth_client_xyz".to_string()));
337 }
338}