From 0447284d431cefdb4999bc10aaea8f5a4dc42708 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 2 Jun 2026 07:40:02 +0200 Subject: [PATCH] feat(domain): add audit value types Co-Authored-By: Claude Sonnet 4.6 --- Cargo.toml | 1 + crates/domain/Cargo.toml | 2 + crates/domain/src/audit.rs | 120 +++++++++++++++++++++++++++++++++++++ crates/domain/src/lib.rs | 2 + 4 files changed, 125 insertions(+) create mode 100644 crates/domain/src/audit.rs diff --git a/Cargo.toml b/Cargo.toml index 233f5f8..d82e493 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", uuid = { version = "1", features = ["v4", "serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +time = { version = "0.3.44", features = ["serde"] } clap = { version = "4", features = ["derive", "env"] } utoipa = { version = "5", features = ["uuid"] } anyhow = "1" diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml index 413fd81..d831a62 100644 --- a/crates/domain/Cargo.toml +++ b/crates/domain/Cargo.toml @@ -7,3 +7,5 @@ rust-version.workspace = true [dependencies] uuid.workspace = true serde.workspace = true +serde_json.workspace = true +time.workspace = true diff --git a/crates/domain/src/audit.rs b/crates/domain/src/audit.rs new file mode 100644 index 0000000..5b360a6 --- /dev/null +++ b/crates/domain/src/audit.rs @@ -0,0 +1,120 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use time::OffsetDateTime; +use uuid::Uuid; + +/// What kind of change an audit entry records. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AuditAction { + Created, + Updated, + Deleted, +} + +impl AuditAction { + /// The database/text representation. + pub fn as_str(&self) -> &'static str { + match self { + AuditAction::Created => "created", + AuditAction::Updated => "updated", + AuditAction::Deleted => "deleted", + } + } + + /// Parse from the database/text representation. + pub fn from_db(s: &str) -> Option { + match s { + "created" => Some(AuditAction::Created), + "updated" => Some(AuditAction::Updated), + "deleted" => Some(AuditAction::Deleted), + _ => None, + } + } +} + +/// Who performed the change. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "kind", content = "id")] +pub enum AuditActor { + /// A specific user, referenced by id (a `UserId` newtype arrives with auth). + User(Uuid), + /// The system itself (migrations, automated processes). + System, +} + +/// One field's before/after values within a change. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FieldChange { + /// Field name (catalogue field key or column name). + pub field: String, + /// Value before the change (None when newly set). + pub before: Option, + /// Value after the change (None when cleared). + pub after: Option, +} + +/// An audit event to be recorded. +#[derive(Debug, Clone, PartialEq)] +pub struct NewAuditEvent { + pub actor: AuditActor, + pub action: AuditAction, + pub entity_type: String, + pub entity_id: Uuid, + pub changes: Vec, +} + +/// A recorded audit entry, read back from the log. +#[derive(Debug, Clone, PartialEq)] +pub struct AuditEntry { + /// Monotonic sequence number (insertion order). + pub seq: i64, + /// When it was recorded. + pub at: OffsetDateTime, + pub actor: AuditActor, + pub action: AuditAction, + pub entity_type: String, + pub entity_id: Uuid, + pub changes: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn action_round_trips_via_db_string() { + for a in [ + AuditAction::Created, + AuditAction::Updated, + AuditAction::Deleted, + ] { + assert_eq!(AuditAction::from_db(a.as_str()), Some(a)); + } + assert_eq!(AuditAction::from_db("bogus"), None); + } + + #[test] + fn field_change_serde_round_trip() { + let fc = FieldChange { + field: "name".into(), + before: Some(json!("Vase")), + after: Some(json!("Roman Vase")), + }; + let v = serde_json::to_value(&fc).unwrap(); + assert_eq!(v["field"], "name"); + assert_eq!(v["before"], "Vase"); + assert_eq!(v["after"], "Roman Vase"); + let back: FieldChange = serde_json::from_value(v).unwrap(); + assert_eq!(back, fc); + } + + #[test] + fn actor_is_adjacently_tagged() { + let v = serde_json::to_value(AuditActor::User(Uuid::nil())).unwrap(); + assert_eq!(v["kind"], "user"); + let v2 = serde_json::to_value(AuditActor::System).unwrap(); + assert_eq!(v2["kind"], "system"); + } +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 5972e72..8a20e46 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -1,5 +1,7 @@ //! Core domain types and invariants. No I/O dependencies. +mod audit; mod id; +pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent}; pub use id::OrgId;