use chrono::Utc;
use serde_json::Value;
use uuid::Uuid;
use crate::action::Action;
use crate::audit::{Audit, NewAudit};
use crate::backend::{AuditQuery, Backend, Order};
use crate::changes::{AuditedChanges, FILTERED, ValueMap};
use crate::config::AuditOptions;
use crate::context::{self, AuditContext};
use crate::error::{AuditError, Result};
use crate::id::AuditId;
pub(crate) struct Meta<'a> {
pub auditable_type: &'a str,
pub auditable_id: AuditId,
pub primary_key: &'a str,
pub inheritance_column: Option<&'a str>,
pub options: &'a AuditOptions,
pub associated: Option<(String, AuditId)>,
pub instance_enabled: bool,
}
pub(crate) fn meta_for<'a, T: crate::auditable::Auditable>(
record: &T,
options: &'a AuditOptions,
) -> Meta<'a> {
Meta {
auditable_type: T::auditable_type(),
auditable_id: record.auditable_id(),
primary_key: T::primary_key(),
inheritance_column: T::inheritance_column(),
options,
associated: record.audit_associated(),
instance_enabled: record.audit_if() && !record.audit_unless(),
}
}
fn is_blank(comment: &Option<String>) -> bool {
comment
.as_ref()
.map(|s| s.trim().is_empty())
.unwrap_or(true)
}
fn apply_masks(changes: &mut AuditedChanges, options: &AuditOptions) {
if !options.redacted.is_empty() {
changes.mask(&options.redacted, &options.redaction_value);
}
if !options.encrypted.is_empty() {
changes.mask(&options.encrypted, &Value::String(FILTERED.to_string()));
}
}
pub(crate) async fn create(
backend: &dyn Backend,
meta: &Meta<'_>,
all_attrs: ValueMap,
comment: Option<String>,
) -> Result<Option<Audit>> {
if !meta.options.audits_action(Action::Create) {
return Ok(None);
}
let ctx = context::current_context();
let enabled =
context::type_auditing_enabled(meta.auditable_type, &ctx) && meta.instance_enabled;
let mut changes = AuditedChanges::snapshot(meta.options.filter_attributes(
&all_attrs,
meta.primary_key,
meta.inheritance_column,
));
apply_masks(&mut changes, meta.options);
if enabled && meta.options.comment_required && !changes.is_empty() && is_blank(&comment) {
return Err(AuditError::CommentRequired {
action: Action::Create,
});
}
if !enabled {
return Ok(None);
}
finish(backend, meta, Action::Create, changes, comment, &ctx)
.await
.map(Some)
}
pub(crate) async fn update(
backend: &dyn Backend,
meta: &Meta<'_>,
old_attrs: ValueMap,
new_attrs: ValueMap,
comment: Option<String>,
) -> Result<Option<Audit>> {
if !meta.options.audits_action(Action::Update) {
return Ok(None);
}
let ctx = context::current_context();
let enabled =
context::type_auditing_enabled(meta.auditable_type, &ctx) && meta.instance_enabled;
let filtered_old =
meta.options
.filter_attributes(&old_attrs, meta.primary_key, meta.inheritance_column);
let filtered_new =
meta.options
.filter_attributes(&new_attrs, meta.primary_key, meta.inheritance_column);
let mut changes = AuditedChanges::diff(&filtered_old, &filtered_new);
apply_masks(&mut changes, meta.options);
let changed = !changes.is_empty();
if enabled && meta.options.comment_required && changed && is_blank(&comment) {
return Err(AuditError::CommentRequired {
action: Action::Update,
});
}
if !enabled {
return Ok(None);
}
let skip = !changed && (is_blank(&comment) || !meta.options.update_with_comment_only);
if skip {
return Ok(None);
}
finish(backend, meta, Action::Update, changes, comment, &ctx)
.await
.map(Some)
}
pub(crate) async fn destroy(
backend: &dyn Backend,
meta: &Meta<'_>,
all_attrs: ValueMap,
comment: Option<String>,
) -> Result<Option<Audit>> {
if !meta.options.audits_action(Action::Destroy) {
return Ok(None);
}
let ctx = context::current_context();
let enabled =
context::type_auditing_enabled(meta.auditable_type, &ctx) && meta.instance_enabled;
let mut changes = AuditedChanges::snapshot(meta.options.filter_attributes(
&all_attrs,
meta.primary_key,
meta.inheritance_column,
));
apply_masks(&mut changes, meta.options);
if enabled && meta.options.comment_required && is_blank(&comment) {
return Err(AuditError::CommentRequired {
action: Action::Destroy,
});
}
if !enabled {
return Ok(None);
}
finish(backend, meta, Action::Destroy, changes, comment, &ctx)
.await
.map(Some)
}
async fn finish(
backend: &dyn Backend,
meta: &Meta<'_>,
action: Action,
changes: AuditedChanges,
comment: Option<String>,
ctx: &AuditContext,
) -> Result<Audit> {
let (user_id, user_type, username) = match &ctx.user {
Some(actor) => actor.clone().into_columns(),
None => (None, None, None),
};
let request_uuid = ctx
.request_uuid
.clone()
.or_else(|| Some(Uuid::new_v4().to_string()));
let (associated_type, associated_id) = match &meta.associated {
Some((t, i)) => (Some(t.clone()), Some(i.clone())),
None => (None, None),
};
let new_audit = NewAudit {
auditable_type: meta.auditable_type.to_string(),
auditable_id: meta.auditable_id.clone(),
associated_type,
associated_id,
user_type,
user_id,
username,
action,
audited_changes: changes,
comment,
remote_address: ctx.remote_address.clone(),
request_uuid,
created_at: Utc::now(),
};
let audit = backend.insert(new_audit).await?;
if action != Action::Create {
maybe_combine(backend, meta).await?;
}
Ok(audit)
}
async fn maybe_combine(backend: &dyn Backend, meta: &Meta<'_>) -> Result<()> {
let Some(max) = meta.options.effective_max_audits() else {
return Ok(());
};
let count = backend
.count_for_auditable(meta.auditable_type, &meta.auditable_id)
.await?;
let extra = count - max as i64;
if extra <= 0 {
return Ok(());
}
let query = AuditQuery {
order: Order::VersionAsc,
limit: Some(extra + 1),
..Default::default()
};
let group = backend
.audits_for_auditable(meta.auditable_type, &meta.auditable_id, &query)
.await?;
if group.len() < 2 {
return Ok(());
}
let target = group.last().expect("group is non-empty");
let mut merged = AuditedChanges::empty();
for a in &group {
merged.merge_in(&a.audited_changes);
}
let combined_comment = format!(
"{}\nThis audit is the result of multiple audits being combined.",
target.comment.as_deref().unwrap_or("")
);
let older_ids: Vec<i64> = group
.iter()
.filter(|a| a.version < target.version)
.map(|a| a.id)
.collect();
backend
.combine(target.id, &merged, Some(&combined_comment), &older_ids)
.await
}