Skip to main content

kipuka/routes/admin/
audit.rs

1//! Audit-trail query endpoint for the admin API.
2//!
3//! NIAP CA PP FAU_SAR.1 (audit review): authorised administrators — both
4//! operators and auditors — may read the audit trail.  This endpoint is
5//! **read-only**; it never mutates the trail, so it is available to the
6//! [`AdminRole::Auditor`](super::AdminRole::Auditor) role as well as
7//! operators.
8//!
9//! Audit-record *integrity verification* (hash-chain checking) is a separate
10//! concern tracked on its own branch; this endpoint only lists stored events.
11
12use std::sync::Arc;
13
14use axum::Json;
15use axum::extract::{Query, State};
16use axum::http::StatusCode;
17use axum::response::{IntoResponse, Response};
18use serde::{Deserialize, Serialize};
19
20use super::AdminAuth;
21use crate::state::AppState;
22
23/// Query parameters for audit-trail listing.
24#[derive(Deserialize)]
25pub struct ListAuditQuery {
26    /// Filter by event type (stored `event_type` string, e.g. `auth.failure`).
27    pub event_type: Option<String>,
28
29    /// Filter by actor (the identity that triggered the event).
30    pub actor: Option<String>,
31
32    /// Maximum number of results to return (capped at 1000).
33    #[serde(default = "default_limit")]
34    pub limit: u32,
35
36    /// Offset for pagination.
37    #[serde(default)]
38    pub offset: u32,
39}
40
41fn default_limit() -> u32 {
42    50
43}
44
45/// A single audit event as returned to the client.
46#[derive(Serialize, sqlx::FromRow)]
47pub struct AuditEventSummary {
48    /// Monotonic event identifier.
49    pub id: i64,
50
51    /// Event timestamp (RFC 3339).
52    pub timestamp: String,
53
54    /// Event type string (e.g. `cert.revoke`, `auth.failure`).
55    pub event_type: String,
56
57    /// Identity that triggered the event, if recorded.
58    pub actor: Option<String>,
59
60    /// Target of the event (e.g. certificate serial), if recorded.
61    pub target: Option<String>,
62
63    /// Free-form JSON detail payload, if recorded.
64    pub detail_json: Option<String>,
65
66    /// Source IP address, if recorded.
67    pub source_ip: Option<String>,
68}
69
70/// `GET /admin/audit` — list audit-trail events (read-only).
71///
72/// Available to both operators and auditors (FAU_SAR.1).  Supports filtering
73/// by `event_type` and `actor`, and offset/limit pagination.  Results are
74/// ordered newest-first.
75pub async fn list_audit_events(
76    admin: AdminAuth,
77    Query(query): Query<ListAuditQuery>,
78    State(state): State<Arc<AppState>>,
79) -> Response {
80    // Read-only: no `require_operator()` — auditors are permitted here.
81    tracing::debug!(
82        identity = %admin.identity,
83        event_type = ?query.event_type,
84        actor = ?query.actor,
85        limit = query.limit,
86        offset = query.offset,
87        "listing audit events"
88    );
89
90    let limit = query.limit.min(1000);
91
92    let events = match list_audit_from_db(
93        &state.db_ro,
94        query.event_type.as_deref(),
95        query.actor.as_deref(),
96        limit,
97        query.offset,
98    )
99    .await
100    {
101        Ok(e) => e,
102        Err(e) => {
103            tracing::error!(error = %e, "failed to list audit events from database");
104            return (
105                StatusCode::INTERNAL_SERVER_ERROR,
106                Json(serde_json::json!({
107                    "error": "database_error",
108                    "detail": "failed to query audit trail"
109                })),
110            )
111                .into_response();
112        }
113    };
114
115    // Record the review itself — reading the audit trail is a security-relevant
116    // administrative action (FAU_SAR.1 / accountability).
117    state
118        .record_audit_event_with_actor(
119            "admin_audit_review",
120            &admin.identity,
121            &format!(
122                "count={}, filter_event_type={:?}, filter_actor={:?}",
123                events.len(),
124                query.event_type,
125                query.actor
126            ),
127        )
128        .await;
129
130    (StatusCode::OK, Json(events)).into_response()
131}
132
133/// Query the audit trail from the database with optional filters.
134async fn list_audit_from_db(
135    db: &sqlx::AnyPool,
136    event_type: Option<&str>,
137    actor: Option<&str>,
138    limit: u32,
139    offset: u32,
140) -> Result<Vec<AuditEventSummary>, String> {
141    // Build the WHERE clause dynamically using `?` placeholders (rewritten for
142    // PostgreSQL by `pg_sql_dynamic`).
143    let mut conditions = Vec::new();
144    if event_type.is_some() {
145        conditions.push("event_type = ?");
146    }
147    if actor.is_some() {
148        conditions.push("actor = ?");
149    }
150
151    let where_clause = if conditions.is_empty() {
152        String::new()
153    } else {
154        format!("WHERE {}", conditions.join(" AND "))
155    };
156
157    let sql = crate::db::pg_sql_dynamic(format!(
158        "SELECT id, timestamp, event_type, actor, target, detail_json, source_ip \
159         FROM audit_events {where_clause} \
160         ORDER BY id DESC \
161         LIMIT ? OFFSET ?"
162    ));
163
164    let mut q = sqlx::query_as::<_, AuditEventSummary>(sqlx::AssertSqlSafe(sql));
165    if let Some(et) = event_type {
166        q = q.bind(et.to_string());
167    }
168    if let Some(ac) = actor {
169        q = q.bind(ac.to_string());
170    }
171    q = q.bind(limit as i64).bind(offset as i64);
172
173    q.fetch_all(db)
174        .await
175        .map_err(|e| format!("audit query failed: {e}"))
176}