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. /// /// Note: after a JSON round-trip, `Some(Value::Null)` is indistinguishable from /// `None`. Use `None` to mean "no value"; do not encode an absent value as /// `Some(Value::Null)`. #[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_serde_round_trips() { for actor in [AuditActor::User(Uuid::nil()), AuditActor::System] { let v = serde_json::to_value(actor).unwrap(); let back: AuditActor = serde_json::from_value(v).unwrap(); assert_eq!(back, actor); } assert_eq!( serde_json::to_value(AuditActor::User(Uuid::nil())).unwrap()["kind"], "user" ); assert_eq!( serde_json::to_value(AuditActor::System).unwrap()["kind"], "system" ); } #[test] fn action_serde_matches_as_str() { for a in [ AuditAction::Created, AuditAction::Updated, AuditAction::Deleted, ] { assert_eq!( serde_json::to_value(a).unwrap(), serde_json::Value::String(a.as_str().to_owned()) ); } } }