use std::collections::HashMap;
use axum::extract::{Form, Query};
use axum::response::{Html, IntoResponse, Redirect, Response};
use serde_json::Value;
use super::errors::AdminError;
use super::helpers::{chrome_context, ListQuery};
use super::render;
use super::templates::render_with_chrome;
use super::urls::AppState;
use super::views::{fetch_facet_counts, fetch_facet_value_total, FacetRow};
use crate::audit::AuditPerm;
const AUDIT_PAGE_SIZE: i64 = 50;
pub(crate) const DETAIL_ROWS: i64 = 20;
const AUDIT_FACET_TRUNCATE: usize = 15;
const AUDIT_FACET_SHOW_ALL_CAP: usize = 200;
pub(crate) struct AuditReader<'a> {
pool: &'a crate::sql::Pool,
tables: Option<Vec<String>>,
}
impl AuditReader<'_> {
pub(crate) async fn list(
&self,
filter: &crate::audit::AuditFilter,
page_size: i64,
offset: i64,
) -> Result<Vec<crate::audit::AuditEntry>, sqlx::Error> {
crate::audit::list_in(self.pool, filter, self.tables.as_deref(), page_size, offset).await
}
pub(crate) async fn count(
&self,
filter: &crate::audit::AuditFilter,
) -> Result<i64, sqlx::Error> {
crate::audit::count_in(self.pool, filter, self.tables.as_deref()).await
}
pub(crate) async fn recent_for_entity(
&self,
table: &str,
pk: &str,
limit: i64,
) -> Result<Vec<crate::audit::AuditEntry>, sqlx::Error> {
let filter = crate::audit::AuditFilter {
entity_table: Some(table.to_owned()),
entity_pk: Some(pk.to_owned()),
..Default::default()
};
crate::audit::list_in(self.pool, &filter, None, limit, 0).await
}
pub(crate) async fn facet(
&self,
column: &str,
limit: usize,
active: Option<&str>,
) -> Result<(Vec<FacetRow>, usize), AdminError> {
use crate::core::{Filter, Model as _, Op, SelectQuery, SqlValue, WhereExpr};
let schema = crate::audit::AuditLog::SCHEMA;
let (Some(field), Some(table_field)) = (schema.field(column), schema.field("entity_table"))
else {
return Ok((Vec::new(), 0));
};
if self.tables.as_ref().is_some_and(Vec::is_empty) {
return Ok((Vec::new(), 0));
}
let scope = |extra: Option<Filter>| {
let tables = self.tables.as_ref().map(|t| {
let list = t.iter().cloned().map(SqlValue::String).collect();
Filter::new(table_field.column, Op::In, SqlValue::List(list))
});
SelectQuery {
where_clause: WhereExpr::and_predicates(tables.into_iter().chain(extra).collect()),
..SelectQuery::new(schema)
}
};
let mut rows =
fetch_facet_counts(self.pool, scope(None), field, false, Some(limit + 1)).await?;
let mut total = rows.len();
if total > limit {
total = fetch_facet_value_total(self.pool, scope(None), field).await?;
if let Some(v) = active.filter(|v| rows.iter().all(|r| r.raw != *v)) {
let own = Filter::new(field.column, Op::Eq, SqlValue::String(v.to_owned()));
let extra =
fetch_facet_counts(self.pool, scope(Some(own)), field, false, Some(1)).await?;
let extra: Vec<FacetRow> = extra
.into_iter()
.filter(|e| rows.iter().all(|r| r.raw != e.raw))
.collect();
rows.extend(extra);
}
}
Ok((rows, total))
}
}
impl AppState {
pub(crate) fn audit_reader(&self) -> Option<AuditReader<'_>> {
let tables = match &self.config.user_perms {
None => None,
Some(perms) if AuditPerm::View.granted_by(perms) => Some(
perms
.iter()
.filter_map(|c| c.strip_suffix(".view"))
.filter(|t| self.is_visible(t) && !row_limited(t))
.map(str::to_owned)
.collect(),
),
Some(_) => return None,
};
Some(AuditReader {
pool: &self.pool,
tables,
})
}
pub(crate) fn can_clean_audit(&self) -> bool {
self.config
.user_perms
.as_ref()
.is_none_or(|p| AuditPerm::Delete.granted_by(p))
}
}
fn row_limited(table: &str) -> bool {
!super::queryset_hooks::for_table(table).is_empty()
|| super::object_permissions::has_hook(table, "view")
}
fn audit_path(state: &AppState) -> String {
format!("{}{}", state.config.admin_prefix, state.config.audit_url)
}
fn audit_forbidden(action: &'static str) -> AdminError {
AdminError::Forbidden {
table: "rustango_audit_log".to_owned(),
action,
}
}
pub(crate) async fn audit_log_view(
Query(params): Query<HashMap<String, String>>,
state: AppState,
) -> Result<Html<String>, AdminError> {
let reader = state
.audit_reader()
.ok_or_else(|| audit_forbidden("view"))?;
let page = crate::list_params::parse_page(¶ms);
let offset = crate::list_params::page_offset(page, AUDIT_PAGE_SIZE);
let filter = crate::audit::AuditFilter {
entity_table: params
.get("entity_table")
.filter(|s| !s.is_empty())
.cloned(),
entity_pk: params.get("entity_pk").filter(|s| !s.is_empty()).cloned(),
operation: params.get("operation").filter(|s| !s.is_empty()).cloned(),
source: params.get("source").filter(|s| !s.is_empty()).cloned(),
};
let mut active_field_filters: Vec<(&'static str, String)> = Vec::new();
for (col, val) in [
("entity_table", &filter.entity_table),
("entity_pk", &filter.entity_pk),
("operation", &filter.operation),
("source", &filter.source),
] {
if let Some(v) = val.as_deref().filter(|s| !s.is_empty()) {
active_field_filters.push((col, v.to_owned()));
}
}
let mut feed_query = ListQuery::new(audit_path(&state));
for (k, v) in &active_field_filters {
feed_query.push(*k, v.clone());
}
let total = reader.count(&filter).await.unwrap_or(0);
let entries = reader
.list(&filter, AUDIT_PAGE_SIZE, offset)
.await
.unwrap_or_default();
let mut facets_ctx: Vec<Value> = Vec::new();
let show_all_facet = params.get("facet_show_all").map(String::as_str);
for col in ["entity_table", "operation", "source"] {
let active_value = active_field_filters
.iter()
.find(|(k, _)| *k == col)
.map(|(_, v)| v.as_str());
let show_all = show_all_facet == Some(col);
let cap = if show_all {
AUDIT_FACET_SHOW_ALL_CAP
} else {
AUDIT_FACET_TRUNCATE
};
let (facet_rows, total_values) = reader
.facet(col, cap, active_value)
.await
.unwrap_or_default();
let mut values: Vec<Value> = facet_rows
.iter()
.map(|FacetRow { raw, count, .. }| {
let is_active = active_value.map(|v| v == raw).unwrap_or(false);
let mut toggle = feed_query.without(&[col]);
if !is_active {
toggle = toggle.with(col, raw.clone());
}
let url = toggle.url();
serde_json::json!({
"raw": raw.clone(),
"display": render::escape(raw),
"count": count,
"active": is_active,
"toggle_url": url,
})
})
.collect();
if values.len() > cap {
let (active, rest): (Vec<Value>, Vec<Value>) = values
.into_iter()
.partition(|v| v.get("active").and_then(Value::as_bool).unwrap_or(false));
let room = cap.saturating_sub(active.len());
values = active;
values.extend(rest.into_iter().take(room));
}
let more_count = total_values.saturating_sub(values.len());
let show_all_url = (more_count > 0 && !show_all)
.then(|| feed_query.clone().with("facet_show_all", col).url());
facets_ctx.push(serde_json::json!({
"field": col,
"values": values,
"more_count": more_count,
"show_all_url": show_all_url,
}));
}
let last_page = if total == 0 {
1
} else {
((total - 1) / AUDIT_PAGE_SIZE) + 1
};
let entries_ctx: Vec<Value> = entries
.iter()
.map(|e| {
let (action_name, cleaned) = split_action_marker(&e.changes);
let admin_prefix = state.config.admin_prefix.as_str();
serde_json::json!({
"id": e.id,
"entity_table": e.entity_table,
"entity_pk": e.entity_pk,
"detail_url": format!(
"{admin_prefix}/{}/{}",
url_encode_q(&e.entity_table),
url_encode_q(&e.entity_pk)
),
"operation": e.operation,
"action_name": action_name,
"source": e.source,
"changes": serde_json::to_string_pretty(&cleaned).unwrap_or_default(),
"occurred_at": e.occurred_at.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
})
})
.collect();
let pager_extras = feed_query.suffix();
let active_filters_ctx: Vec<Value> = active_field_filters
.iter()
.map(|(k, v)| serde_json::json!({ "key": k, "value": v }))
.collect();
let mut ctx = serde_json::json!({
"total": total,
"plural": if total == 1 { "" } else { "s" },
"entries": entries_ctx,
"facets": facets_ctx,
"active_filters": active_filters_ctx,
"page": page,
"last_page": last_page,
"pager_extras": pager_extras,
"can_clean_audit": state.can_clean_audit(),
});
Ok(Html(render_with_chrome(
"audit_log.html",
&mut ctx,
chrome_context(&state, Some("__audit")),
)))
}
pub(crate) async fn audit_cleanup_submit(
state: AppState,
Form(form): Form<HashMap<String, String>>,
) -> Result<Response, AdminError> {
if !state.can_clean_audit() {
return Err(audit_forbidden("delete"));
}
let mode = form.get("mode").map(String::as_str).unwrap_or("older_than");
let (_removed, changes) = match mode {
"keep_last" => {
let keep: i64 = form
.get("keep")
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(50)
.max(0);
let removed = crate::audit::cleanup_keep_last_n_pool(&state.pool, keep)
.await
.unwrap_or_else(|e| {
tracing::warn!(target: "rustango::admin::audit",
error = %e, "cleanup_keep_last_n_pool failed");
0
});
(
removed,
serde_json::json!({
"__action": "audit_cleanup",
"mode": "keep_last",
"keep": keep,
"removed": removed,
}),
)
}
_ => {
let days: i64 = form
.get("days")
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(90)
.max(0);
let removed = crate::audit::cleanup_older_than_pool(&state.pool, days)
.await
.unwrap_or_else(|e| {
tracing::warn!(target: "rustango::admin::audit",
error = %e, "cleanup_older_than_pool failed");
0
});
(
removed,
serde_json::json!({
"__action": "audit_cleanup",
"mode": "older_than",
"cutoff_days": days,
"removed": removed,
}),
)
}
};
let entry = crate::audit::PendingEntry {
entity_table: "rustango_audit_log",
entity_pk: "*".into(),
operation: crate::audit::AuditOp::Delete,
source: crate::audit::current_source(),
changes,
};
if let Err(e) = crate::audit::emit_one_pool(&state.pool, &entry).await {
tracing::warn!(target: "rustango::admin::audit",
error = %e, "audit_cleanup self-audit emit failed");
}
Ok(Redirect::to(&audit_path(&state)).into_response())
}
pub(crate) fn admin_audit_diff_entry(
model: &'static crate::core::ModelSchema,
pk_str: &str,
row: &serde_json::Value,
form: &HashMap<String, String>,
) -> Option<crate::audit::PendingEntry> {
let row = &mask_secrets(model, &super::helpers::admin_config_or_default(model), row);
let before_pairs: Vec<(&str, Value)> = model
.scalar_fields()
.filter(|f| {
model
.audit_track
.map_or(true, |names| names.is_empty() || names.contains(&f.name))
})
.map(|f| (f.name, render::read_value_as_json_from_json(row, f)))
.collect();
let after_pairs: Vec<(&str, Value)> = model
.scalar_fields()
.filter(|f| {
model
.audit_track
.map_or(true, |names| names.is_empty() || names.contains(&f.name))
})
.map(|f| {
let v = match form.get(f.name) {
Some(s) => render::coerce_form_to_json(f, s),
None => render::read_value_as_json_from_json(row, f),
};
(f.name, v)
})
.collect();
crate::audit::PendingEntry::update_diff(
model.table,
pk_str.to_owned(),
&before_pairs,
&after_pairs,
)
}
const SECRET_CHANGED: &str = "[changed]";
const SECRET_SET: &str = "[set]";
pub(super) fn audit_form(
model: &'static crate::core::ModelSchema,
admin_cfg: &crate::core::AdminConfig,
form: &HashMap<String, String>,
written: &[(&'static str, crate::core::SqlValue)],
) -> HashMap<String, String> {
let is_written = |name: &str| {
model
.field(name)
.is_some_and(|f| written.iter().any(|(c, _)| *c == f.column))
};
let mut out: HashMap<String, String> = form
.iter()
.filter(|(k, _)| !super::helpers::is_secret_field(admin_cfg, k) && is_written(k))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
for (column, value) in written {
let Some(f) = model.scalar_fields().find(|f| f.column == *column) else {
continue;
};
if super::helpers::is_secret_field(admin_cfg, f.name) {
out.insert(f.name.to_owned(), SECRET_CHANGED.to_owned());
} else if let crate::core::SqlValue::DateTime(at) = value {
out.insert(f.name.to_owned(), at.to_rfc3339());
} else if !out.contains_key(f.name) {
let shown = match value {
crate::core::SqlValue::Null => String::new(),
v => v.to_display_string(),
};
out.insert(f.name.to_owned(), shown);
}
}
out
}
fn mask_secrets(
model: &'static crate::core::ModelSchema,
admin_cfg: &crate::core::AdminConfig,
row: &Value,
) -> Value {
let mut row = row.clone();
for f in model.scalar_fields() {
if super::helpers::is_secret_field(admin_cfg, f.name) {
if let Some(v) = row.get_mut(f.name).filter(|v| !v.is_null()) {
*v = Value::String(SECRET_SET.to_owned());
}
}
}
row
}
pub(crate) async fn emit_best_effort(state: &AppState, entry: &crate::audit::PendingEntry) {
if let Err(e) = crate::audit::emit_one_pool(&state.pool, entry).await {
tracing::warn!(
target: "rustango::admin::audit",
error = %e,
entity_table = %entry.entity_table,
entity_pk = %entry.entity_pk,
"admin audit emit failed (data write already committed)",
);
}
}
pub(crate) async fn emit_many_best_effort(
state: &AppState,
entries: &[crate::audit::PendingEntry],
action: &str,
) {
let Some(first) = entries.first() else {
return;
};
if let Err(e) = crate::audit::emit_many_pool(&state.pool, entries).await {
tracing::warn!(
target: "rustango::admin::audit",
error = %e,
entity_table = %first.entity_table,
action = %action,
count = entries.len(),
"admin bulk-action audit emit failed",
);
}
}
pub(crate) fn admin_audit_entry(
model: &'static crate::core::ModelSchema,
pk_str: &str,
op: crate::audit::AuditOp,
form: &HashMap<String, String>,
) -> crate::audit::PendingEntry {
let pairs: Vec<(&str, Value)> = model
.scalar_fields()
.filter(|f| {
model
.audit_track
.map_or(true, |names| names.is_empty() || names.contains(&f.name))
})
.filter_map(|f| {
form.get(f.name)
.map(|v| (f.name, render::coerce_form_to_json(f, v)))
})
.collect();
crate::audit::PendingEntry {
entity_table: model.table,
entity_pk: pk_str.to_owned(),
operation: op,
source: crate::audit::current_source(),
changes: crate::audit::snapshot_changes(&pairs),
}
}
pub(crate) fn admin_row_snapshot_entry(
model: &'static crate::core::ModelSchema,
pk_str: String,
op: crate::audit::AuditOp,
row: &Value,
action: Option<&str>,
) -> crate::audit::PendingEntry {
let cfg = super::helpers::admin_config_or_default(model);
let row = mask_secrets(model, &cfg, row);
let mut pairs: Vec<(&str, Value)> = model
.scalar_fields()
.map(|f| (f.name, render::read_value_as_json_from_json(&row, f)))
.collect();
if let Some(name) = action {
pairs.push(("__action", Value::String(name.to_owned())));
}
crate::audit::PendingEntry {
entity_table: model.table,
entity_pk: pk_str,
operation: op,
source: crate::audit::current_source(),
changes: crate::audit::snapshot_changes(&pairs),
}
}
pub(crate) fn split_action_marker(changes: &Value) -> (Option<String>, Value) {
if let Value::Object(map) = changes {
if let Some(Value::String(name)) = map.get("__action") {
let mut cleaned = map.clone();
cleaned.remove("__action");
return (Some(name.clone()), Value::Object(cleaned));
}
}
(None, changes.clone())
}
use crate::url_codec::url_encode as url_encode_q;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_audit_form_holds_only_written_fields() {
use crate::core::Model as _;
let model = crate::admin::user::AdminUser::SCHEMA;
let form: HashMap<String, String> = [
("username", "alice"),
("sessions_revoked_at", "2020-01-01T00:00"),
]
.into_iter()
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect();
let written = vec![("username", crate::core::SqlValue::String("alice".into()))];
let out = audit_form(model, &crate::core::AdminConfig::DEFAULT, &form, &written);
assert_eq!(out.get("username").map(String::as_str), Some("alice"));
assert!(!out.contains_key("sessions_revoked_at"), "{out:?}");
}
#[test]
fn the_audit_form_masks_a_password_without_the_widget() {
use crate::core::Model as _;
let model = crate::admin::user::AdminUser::SCHEMA;
let form: HashMap<String, String> = [("password_hash", "typed-plaintext")]
.into_iter()
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect();
let written = vec![(
"password_hash",
crate::core::SqlValue::String("$argon2id$h".into()),
)];
let out = audit_form(model, &crate::core::AdminConfig::DEFAULT, &form, &written);
assert_eq!(
out.get("password_hash").map(String::as_str),
Some(SECRET_CHANGED)
);
}
}