Skip to main content

faucet_cli/serve/handlers/
audit.rs

1//! `GET /v1/audit` — read the control-plane audit log (RBAC, #205). The route
2//! requires the `AuditRead` permission (admin-only), enforced by the auth
3//! middleware, so reaching this handler already means the caller is authorized.
4
5use crate::serve::error::ServeError;
6use crate::serve::history::{AuditEntry, AuditFilter};
7use crate::serve::state::ServerState;
8use axum::Json;
9use axum::extract::{Query, State};
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13const DEFAULT_LIMIT: usize = 100;
14const MAX_LIMIT: usize = 1000;
15
16/// `GET /v1/audit` query string.
17#[derive(Debug, Deserialize)]
18pub struct AuditQuery {
19    pub principal: Option<String>,
20    pub action: Option<String>,
21    pub since: Option<String>,
22    pub until: Option<String>,
23    pub limit: Option<usize>,
24}
25
26/// `GET /v1/audit` response body.
27#[derive(Debug, Serialize)]
28pub struct AuditListResponse {
29    pub entries: Vec<AuditEntry>,
30}
31
32/// Parse an RFC3339 query timestamp, restoring the `+` that form-encoding turns
33/// into a space (so explicit offsets like `+05:30` survive).
34fn parse_ts(raw: &str, field: &str) -> Result<DateTime<Utc>, ServeError> {
35    DateTime::parse_from_rfc3339(&raw.replace(' ', "+"))
36        .map(|d| d.to_utc())
37        .map_err(|e| ServeError::BadConfig(format!("invalid `{field}` timestamp: {e}")))
38}
39
40/// `GET /v1/audit` → 200.
41pub async fn list_audit(
42    State(state): State<ServerState>,
43    Query(query): Query<AuditQuery>,
44) -> Result<Json<AuditListResponse>, ServeError> {
45    let filter = AuditFilter {
46        principal: query.principal,
47        action: query.action,
48        since: query
49            .since
50            .as_deref()
51            .map(|s| parse_ts(s, "since"))
52            .transpose()?,
53        until: query
54            .until
55            .as_deref()
56            .map(|s| parse_ts(s, "until"))
57            .transpose()?,
58        limit: query.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT),
59    };
60    let entries = state
61        .history()
62        .list_audit(&filter)
63        .await
64        .map_err(|e| ServeError::Internal(e.to_string()))?;
65    Ok(Json(AuditListResponse { entries }))
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn parse_ts_accepts_z_and_offset() {
74        assert!(parse_ts("2026-01-01T00:00:00Z", "since").is_ok());
75        // Space in place of '+' (form-encoded offset) is restored.
76        assert!(parse_ts("2026-01-01T00:00:00 05:30", "since").is_ok());
77        assert!(parse_ts("not-a-date", "since").is_err());
78    }
79}