feat(admin): audit-log viewer
Surface the admin_audit table (written by every mutating admin action but previously unreadable) as a new /admin/audit tab. New repo::admin_audit::list (LEFT JOIN users for the actor's username, filter by action/target_kind/actor/ since, at DESC via admin_audit_at_idx) behind GET /v1/admin/audit, mirroring the crawler history endpoint's paged/clamped idiom. Frontend: a self-contained AuditTable (target-kind + window + action filters, expandable JSON payload, AbortController-cancelled fetches, loading/error/empty states) and shared fmtAgo() in format.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
70
backend/src/api/admin/audit.rs
Normal file
70
backend/src/api/admin/audit.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
//! GET /admin/audit — paginated, filterable admin-action audit log.
|
||||
//!
|
||||
//! A pure DB read over the `admin_audit` table joined to the actor's
|
||||
//! username. Drives the "Audit" admin tab: who did what, when. Every
|
||||
//! mutating admin action writes a row here; this is the only reader.
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::app::AppState;
|
||||
use crate::auth::extractor::RequireAdmin;
|
||||
use crate::error::AppResult;
|
||||
use crate::repo;
|
||||
use crate::repo::admin_audit::{AuditEntryRow, AuditFilter};
|
||||
|
||||
// Reuse the analysis window helper so `days` clamps identically everywhere.
|
||||
use crate::api::admin::analysis::window_since;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/admin/audit", get(list_audit))
|
||||
}
|
||||
|
||||
fn default_limit() -> i64 {
|
||||
50
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct AuditParams {
|
||||
#[serde(default)]
|
||||
action: Option<String>,
|
||||
#[serde(default)]
|
||||
target_kind: Option<String>,
|
||||
#[serde(default)]
|
||||
actor_user_id: Option<Uuid>,
|
||||
#[serde(default)]
|
||||
days: i64,
|
||||
#[serde(default = "default_limit")]
|
||||
limit: i64,
|
||||
#[serde(default)]
|
||||
offset: i64,
|
||||
}
|
||||
|
||||
async fn list_audit(
|
||||
State(state): State<AppState>,
|
||||
_admin: RequireAdmin,
|
||||
Query(params): Query<AuditParams>,
|
||||
) -> AppResult<Json<crate::api::pagination::PagedResponse<AuditEntryRow>>> {
|
||||
let limit = params.limit.clamp(1, 200);
|
||||
let offset = params.offset.max(0);
|
||||
let action = params.action.filter(|s| !s.trim().is_empty());
|
||||
let target_kind = params.target_kind.filter(|s| !s.trim().is_empty());
|
||||
let (items, total) = repo::admin_audit::list(
|
||||
&state.db,
|
||||
AuditFilter {
|
||||
action: action.as_deref(),
|
||||
target_kind: target_kind.as_deref(),
|
||||
actor_user_id: params.actor_user_id,
|
||||
since: window_since(params.days),
|
||||
},
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(crate::api::pagination::PagedResponse::with_total(
|
||||
items, limit, offset, total,
|
||||
)))
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
//! `crate::auth::extractor::RequireAdmin`).
|
||||
|
||||
pub mod analysis;
|
||||
pub mod audit;
|
||||
pub mod crawler;
|
||||
pub mod mangas;
|
||||
pub mod overview;
|
||||
@@ -26,6 +27,7 @@ pub fn routes() -> Router<AppState> {
|
||||
.merge(storage::routes())
|
||||
.merge(system::routes())
|
||||
.merge(overview::routes())
|
||||
.merge(audit::routes())
|
||||
.merge(crawler::routes())
|
||||
.merge(analysis::routes())
|
||||
.merge(settings::routes())
|
||||
|
||||
@@ -1,14 +1,98 @@
|
||||
//! Admin-action audit log writes.
|
||||
//! Admin-action audit log writes + the admin-facing read query.
|
||||
//!
|
||||
//! Insert is always called from inside the same transaction as the
|
||||
//! action it audits — the executor parameter is `PgExecutor` so the
|
||||
//! caller passes `&mut *tx` directly.
|
||||
//! caller passes `&mut *tx` directly. [`list`] backs the audit-log viewer.
|
||||
|
||||
use sqlx::PgExecutor;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use sqlx::{FromRow, PgExecutor, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppResult;
|
||||
|
||||
/// One audit row joined to the actor's current username (NULL when the
|
||||
/// actor account was deleted — `actor_user_id` is `ON DELETE SET NULL`).
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct AuditEntryRow {
|
||||
pub id: Uuid,
|
||||
pub actor_user_id: Option<Uuid>,
|
||||
pub actor_username: Option<String>,
|
||||
pub action: String,
|
||||
pub target_kind: String,
|
||||
pub target_id: Option<Uuid>,
|
||||
pub payload: serde_json::Value,
|
||||
pub at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Optional filters for [`list`]. `None`/absent widens the scope; an
|
||||
/// empty-string action/target_kind is treated as absent by the caller.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct AuditFilter<'a> {
|
||||
pub action: Option<&'a str>,
|
||||
pub target_kind: Option<&'a str>,
|
||||
pub actor_user_id: Option<Uuid>,
|
||||
pub since: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Paginated, newest-first audit log. Returns the page slice plus the
|
||||
/// filtered total. Ordering by `at DESC` uses `admin_audit_at_idx`.
|
||||
pub async fn list(
|
||||
pool: &PgPool,
|
||||
filter: AuditFilter<'_>,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> AppResult<(Vec<AuditEntryRow>, i64)> {
|
||||
let items = sqlx::query_as::<_, AuditEntryRow>(
|
||||
r#"
|
||||
SELECT
|
||||
a.id,
|
||||
a.actor_user_id,
|
||||
u.username AS actor_username,
|
||||
a.action,
|
||||
a.target_kind,
|
||||
a.target_id,
|
||||
a.payload,
|
||||
a.at
|
||||
FROM admin_audit a
|
||||
LEFT JOIN users u ON u.id = a.actor_user_id
|
||||
WHERE ($1::text IS NULL OR a.action = $1)
|
||||
AND ($2::text IS NULL OR a.target_kind = $2)
|
||||
AND ($3::uuid IS NULL OR a.actor_user_id = $3)
|
||||
AND ($4::timestamptz IS NULL OR a.at >= $4)
|
||||
ORDER BY a.at DESC
|
||||
LIMIT $5 OFFSET $6
|
||||
"#,
|
||||
)
|
||||
.bind(filter.action)
|
||||
.bind(filter.target_kind)
|
||||
.bind(filter.actor_user_id)
|
||||
.bind(filter.since)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let (total,): (i64,) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT COUNT(*)
|
||||
FROM admin_audit a
|
||||
WHERE ($1::text IS NULL OR a.action = $1)
|
||||
AND ($2::text IS NULL OR a.target_kind = $2)
|
||||
AND ($3::uuid IS NULL OR a.actor_user_id = $3)
|
||||
AND ($4::timestamptz IS NULL OR a.at >= $4)
|
||||
"#,
|
||||
)
|
||||
.bind(filter.action)
|
||||
.bind(filter.target_kind)
|
||||
.bind(filter.actor_user_id)
|
||||
.bind(filter.since)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok((items, total))
|
||||
}
|
||||
|
||||
pub async fn insert<'e, E: PgExecutor<'e>>(
|
||||
executor: E,
|
||||
actor_user_id: Uuid,
|
||||
|
||||
175
backend/tests/api_admin_audit.rs
Normal file
175
backend/tests/api_admin_audit.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
//! Integration tests for the admin audit-log viewer: the `repo::admin_audit`
|
||||
//! list query (filters, username join, NULL actor) and the
|
||||
//! `GET /v1/admin/audit` endpoint (admin-gating, shape, filter params).
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::Router;
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use common::{body_json, get_with_cookie, harness, register_user};
|
||||
use mangalord::repo::admin_audit::{self, AuditFilter};
|
||||
|
||||
async fn seed_admin(pool: &PgPool, app: &Router) -> (Uuid, String) {
|
||||
let (username, cookie) = register_user(app).await;
|
||||
let u = mangalord::repo::user::find_by_username(pool, &username)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
mangalord::repo::user::set_is_admin_unchecked(pool, u.id, true)
|
||||
.await
|
||||
.unwrap();
|
||||
(u.id, cookie)
|
||||
}
|
||||
|
||||
/// Insert an audit row with an explicit `at` so ordering/since are testable.
|
||||
async fn insert_audit(
|
||||
pool: &PgPool,
|
||||
actor: Option<Uuid>,
|
||||
action: &str,
|
||||
target_kind: &str,
|
||||
at: &str,
|
||||
) {
|
||||
sqlx::query(
|
||||
"INSERT INTO admin_audit (actor_user_id, action, target_kind, target_id, payload, at) \
|
||||
VALUES ($1, $2, $3, NULL, $4, $5::timestamptz)",
|
||||
)
|
||||
.bind(actor)
|
||||
.bind(action)
|
||||
.bind(target_kind)
|
||||
.bind(json!({ "k": action }))
|
||||
.bind(at)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_joins_username_and_orders_newest_first(pool: PgPool) {
|
||||
let h = harness(pool.clone());
|
||||
let (admin_id, _cookie) = seed_admin(&pool, &h.app).await;
|
||||
insert_audit(&pool, Some(admin_id), "crawler_run", "crawler", "2026-06-01T00:00:00Z").await;
|
||||
insert_audit(&pool, None, "manga_resync", "manga", "2026-06-02T00:00:00Z").await;
|
||||
|
||||
let (items, total) = admin_audit::list(&pool, AuditFilter::default(), 50, 0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(total, 2);
|
||||
// Newest first.
|
||||
assert_eq!(items[0].action, "manga_resync");
|
||||
assert_eq!(items[1].action, "crawler_run");
|
||||
// Username join: the admin actor resolves; the NULL actor stays None.
|
||||
assert_eq!(items[1].actor_user_id, Some(admin_id));
|
||||
assert!(items[1].actor_username.is_some());
|
||||
assert_eq!(items[0].actor_user_id, None);
|
||||
assert_eq!(items[0].actor_username, None);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_filters_by_action_target_kind_actor_and_since(pool: PgPool) {
|
||||
let h = harness(pool.clone());
|
||||
let (admin_id, _cookie) = seed_admin(&pool, &h.app).await;
|
||||
insert_audit(&pool, Some(admin_id), "crawler_run", "crawler", "2026-05-01T00:00:00Z").await;
|
||||
insert_audit(&pool, Some(admin_id), "manga_resync", "manga", "2026-06-10T00:00:00Z").await;
|
||||
insert_audit(&pool, None, "crawler_run", "crawler", "2026-06-11T00:00:00Z").await;
|
||||
|
||||
// Filter by action.
|
||||
let (items, total) = admin_audit::list(
|
||||
&pool,
|
||||
AuditFilter { action: Some("crawler_run"), ..Default::default() },
|
||||
50,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(total, 2);
|
||||
assert!(items.iter().all(|r| r.action == "crawler_run"));
|
||||
|
||||
// Filter by target_kind.
|
||||
let (_items, total) = admin_audit::list(
|
||||
&pool,
|
||||
AuditFilter { target_kind: Some("manga"), ..Default::default() },
|
||||
50,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(total, 1);
|
||||
|
||||
// Filter by actor.
|
||||
let (_items, total) = admin_audit::list(
|
||||
&pool,
|
||||
AuditFilter { actor_user_id: Some(admin_id), ..Default::default() },
|
||||
50,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(total, 2);
|
||||
|
||||
// Filter by since (only the two June rows).
|
||||
let since = chrono::DateTime::parse_from_rfc3339("2026-06-01T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&chrono::Utc);
|
||||
let (_items, total) = admin_audit::list(
|
||||
&pool,
|
||||
AuditFilter { since: Some(since), ..Default::default() },
|
||||
50,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(total, 2);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn endpoint_requires_admin(pool: PgPool) {
|
||||
let h = harness(pool.clone());
|
||||
let (_u, cookie) = register_user(&h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(get_with_cookie("/api/v1/admin/audit", &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn endpoint_returns_paged_shape_and_honors_filter(pool: PgPool) {
|
||||
let h = harness(pool.clone());
|
||||
let (admin_id, cookie) = seed_admin(&pool, &h.app).await;
|
||||
insert_audit(&pool, Some(admin_id), "crawler_run", "crawler", "2026-06-01T00:00:00Z").await;
|
||||
insert_audit(&pool, Some(admin_id), "manga_resync", "manga", "2026-06-02T00:00:00Z").await;
|
||||
|
||||
// Unfiltered: both rows + paged envelope.
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(get_with_cookie("/api/v1/admin/audit", &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(body["page"]["total"], 2);
|
||||
assert_eq!(body["items"].as_array().unwrap().len(), 2);
|
||||
assert!(body["items"][0]["actor_username"].is_string());
|
||||
|
||||
// Filter by action.
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(get_with_cookie(
|
||||
"/api/v1/admin/audit?action=manga_resync",
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(body["page"]["total"], 1);
|
||||
assert_eq!(body["items"][0]["action"], "manga_resync");
|
||||
assert_eq!(body["items"][0]["target_kind"], "manga");
|
||||
}
|
||||
Reference in New Issue
Block a user