feat(domain): add audit value types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres",
|
|||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
time = { version = "0.3.44", features = ["serde"] }
|
||||||
clap = { version = "4", features = ["derive", "env"] }
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
utoipa = { version = "5", features = ["uuid"] }
|
utoipa = { version = "5", features = ["uuid"] }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|||||||
@@ -7,3 +7,5 @@ rust-version.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
time.workspace = true
|
||||||
|
|||||||
@@ -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<Self> {
|
||||||
|
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>,
|
||||||
|
/// Value after the change (None when cleared).
|
||||||
|
pub after: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<FieldChange>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<FieldChange>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
//! Core domain types and invariants. No I/O dependencies.
|
//! Core domain types and invariants. No I/O dependencies.
|
||||||
|
|
||||||
|
mod audit;
|
||||||
mod id;
|
mod id;
|
||||||
|
|
||||||
|
pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent};
|
||||||
pub use id::OrgId;
|
pub use id::OrgId;
|
||||||
|
|||||||
Reference in New Issue
Block a user