use axum::body::Body;
use axum::extract::{Query, State};
use axum::http::Response;
use axum::Extension;
use serde::Deserialize;
use tera::Context;
use super::super::auth;
use super::{inject_op_brand, render, ConsoleState, Paged};
#[derive(Deserialize)]
pub(super) struct AuditQuery {
#[serde(default)]
entity_table: Option<String>,
#[serde(default)]
entity_pk: Option<String>,
#[serde(default)]
operation: Option<String>,
#[serde(default)]
page: Option<i64>,
}
pub(super) async fn audit_list(
State(state): State<ConsoleState>,
Extension(op): Extension<auth::Operator>,
Query(q): Query<AuditQuery>,
) -> Response<Body> {
let filter = crate::audit::AuditFilter {
entity_table: blank_to_none(q.entity_table.as_deref()),
entity_pk: blank_to_none(q.entity_pk.as_deref()),
operation: blank_to_none(q.operation.as_deref()),
source: None,
};
let total = match crate::audit::count(&state.registry, &filter).await {
Ok(n) => n,
Err(e) => {
return super::server_error("could not count the audit log", &e);
}
};
let paged = Paged::from_total(total, q.page);
let entries =
match crate::audit::list(&state.registry, &filter, paged.limit, paged.offset).await {
Ok(e) => e,
Err(e) => {
return super::server_error("could not read the audit log", &e);
}
};
let view: Vec<_> = entries
.iter()
.map(|e| {
serde_json::json!({
"id": e.id,
"occurred_at": e.occurred_at.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
"entity_table": e.entity_table,
"entity_pk": e.entity_pk,
"operation": e.operation,
"source": e.source,
"who": who(&e.source),
"what": what(&e.source),
"changes": serde_json::to_string_pretty(&e.changes)
.unwrap_or_else(|_| e.changes.to_string()),
})
})
.collect();
let mut ctx = Context::new();
inject_op_brand(&mut ctx, &state.op_brand);
ctx.insert("section", "audit");
ctx.insert("operator_username", &op.username);
ctx.insert("entries", &view);
ctx.insert("filter_entity_table", &q.entity_table.unwrap_or_default());
ctx.insert("filter_entity_pk", &q.entity_pk.unwrap_or_default());
ctx.insert("filter_operation", &q.operation.unwrap_or_default());
paged.inject(&mut ctx, "/audit", &filter_suffix(&filter));
render(&state, "op_audit.html", &ctx)
}
fn blank_to_none(v: Option<&str>) -> Option<String> {
v.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
}
fn filter_suffix(filter: &crate::audit::AuditFilter) -> String {
use std::fmt::Write as _;
let mut out = String::new();
for (key, value) in [
("entity_table", filter.entity_table.as_deref()),
("entity_pk", filter.entity_pk.as_deref()),
("operation", filter.operation.as_deref()),
] {
if let Some(v) = value {
let _ = write!(out, "&{key}={}", super::urlencoding_lite(v));
}
}
out
}
fn who(source: &str) -> String {
match source.strip_prefix("operator:") {
Some(rest) => match rest.split_once(':') {
Some((id, _)) => format!("operator {id}"),
None => format!("operator {rest}"),
},
None => source.to_owned(),
}
}
fn what(source: &str) -> String {
source
.strip_prefix("operator:")
.and_then(|rest| rest.split_once(':'))
.map_or_else(|| source.to_owned(), |(_, verb)| verb.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_operator_source_shape_splits_into_who_and_what() {
assert_eq!(who("operator:1:host_add"), "operator 1");
assert_eq!(what("operator:1:host_add"), "host_add");
assert_eq!(what("operator:7:a:b"), "a:b");
}
#[test]
fn a_source_that_is_not_an_operator_is_left_alone() {
assert_eq!(who("system"), "system");
assert_eq!(what("system"), "system");
assert_eq!(who("user:42"), "user:42");
}
#[test]
fn blank_filters_do_not_become_empty_string_matches() {
assert_eq!(blank_to_none(Some(" ")), None);
assert_eq!(blank_to_none(Some("")), None);
assert_eq!(blank_to_none(None), None);
assert_eq!(blank_to_none(Some(" orgs ")).as_deref(), Some("orgs"));
}
#[test]
fn paging_links_keep_the_filters() {
let f = crate::audit::AuditFilter {
entity_table: Some("rustango_orgs".into()),
entity_pk: Some("acme".into()),
operation: None,
source: None,
};
let suffix = filter_suffix(&f);
assert!(suffix.contains("entity_table=rustango_orgs"), "{suffix}");
assert!(suffix.contains("entity_pk=acme"), "{suffix}");
assert!(!suffix.contains("operation="), "unset filters stay out");
}
}