Skip to main content

csaf_models/
audit_log.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne
3
4//! Audit log model for CSAF CRUD operations.
5//!
6//! Every create, update, and delete is logged with an ISO 8601 timestamp.
7
8use rusqlite::{Connection, params};
9use serde::{Deserialize, Serialize};
10
11/// An audit log entry recording a CSAF operation.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct AuditLogEntry {
14    /// Database ID.
15    pub id: i64,
16    /// Timestamp (ISO 8601 UTC).
17    pub timestamp: String,
18    /// Action performed.
19    pub action: String,
20    /// CSAF tracking ID.
21    pub tracking_id: String,
22    /// User ID who performed the action (if any).
23    pub user_id: Option<i64>,
24    /// Additional details (JSON string).
25    pub details: Option<String>,
26}
27
28/// Valid audit log actions. Mirror the `valid_action` CHECK constraint
29/// in `crates/csaf-models/src/db.rs` — any addition here must be mirrored
30/// there or the insert will fail at runtime.
31pub const VALID_ACTIONS: &[&str] = &[
32    "create",
33    "update",
34    "delete",
35    "import",
36    "export",
37    "settings_reset",
38];
39
40/// Record an audit log entry.
41///
42/// # Errors
43///
44/// Returns an error if the insert fails.
45pub fn record(
46    conn: &Connection,
47    action: &str,
48    tracking_id: &str,
49    user_id: Option<i64>,
50    details: Option<&str>,
51) -> Result<i64, rusqlite::Error> {
52    let timestamp = chrono::Utc::now()
53        .format("%Y-%m-%dT%H:%M:%S%.3fZ")
54        .to_string();
55
56    conn.execute(
57        "INSERT INTO audit_log (timestamp, action, tracking_id, user_id, details) \
58         VALUES (?1, ?2, ?3, ?4, ?5)",
59        params![timestamp, action, tracking_id, user_id, details],
60    )?;
61
62    Ok(conn.last_insert_rowid())
63}
64
65/// List audit log entries with optional filters.
66///
67/// # Errors
68///
69/// Returns a database error if the query fails.
70pub fn list(
71    conn: &Connection,
72    tracking_id: Option<&str>,
73    limit: usize,
74    offset: usize,
75) -> Result<Vec<AuditLogEntry>, rusqlite::Error> {
76    let (sql, has_filter) = if tracking_id.is_some() {
77        (
78            "SELECT id, timestamp, action, tracking_id, user_id, details \
79             FROM audit_log WHERE tracking_id = ?1 \
80             ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3",
81            true,
82        )
83    } else {
84        (
85            "SELECT id, timestamp, action, tracking_id, user_id, details \
86             FROM audit_log ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2",
87            false,
88        )
89    };
90
91    let mut stmt = conn.prepare(sql)?;
92
93    // `usize` → `i64` is safe at our scales (limits and offsets are
94    // user-bounded and never exceed `i64::MAX`) but we still go
95    // through `try_from` so the cast can never wrap on 64-bit targets.
96    let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
97    let offset_i64 = i64::try_from(offset).unwrap_or(i64::MAX);
98    let rows = if has_filter {
99        stmt.query_map(
100            params![tracking_id.unwrap_or_default(), limit_i64, offset_i64],
101            map_row,
102        )?
103    } else {
104        stmt.query_map(params![limit_i64, offset_i64], map_row)?
105    };
106
107    rows.collect()
108}
109
110/// Count total audit log entries with optional filter.
111///
112/// # Errors
113///
114/// Returns a database error if the query fails.
115pub fn count(conn: &Connection, tracking_id: Option<&str>) -> Result<usize, rusqlite::Error> {
116    // SQLite `COUNT(*)` is always non-negative (sign loss is safe) and
117    // fits in `usize` on every platform we ship to. We still go through
118    // `try_from` + saturate so the cast can never truncate on 32-bit
119    // targets.
120    let to_usize = |n: i64| usize::try_from(n.max(0)).unwrap_or(usize::MAX);
121    tracking_id.map_or_else(
122        || {
123            conn.query_row("SELECT COUNT(*) FROM audit_log", [], |row| {
124                row.get::<_, i64>(0).map(to_usize)
125            })
126        },
127        |tid| {
128            conn.query_row(
129                "SELECT COUNT(*) FROM audit_log WHERE tracking_id = ?1",
130                params![tid],
131                |row| row.get::<_, i64>(0).map(to_usize),
132            )
133        },
134    )
135}
136
137/// Map a database row to an `AuditLogEntry`.
138fn map_row(row: &rusqlite::Row<'_>) -> Result<AuditLogEntry, rusqlite::Error> {
139    Ok(AuditLogEntry {
140        id: row.get("id")?,
141        timestamp: row.get("timestamp")?,
142        action: row.get("action")?,
143        tracking_id: row.get("tracking_id")?,
144        user_id: row.get("user_id")?,
145        details: row.get("details")?,
146    })
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::db::DbPool;
153
154    #[test]
155    fn test_record_and_list() {
156        let pool = DbPool::open_in_memory().expect("DB open failed");
157        pool.with_conn(|conn| {
158            record(
159                conn,
160                "create",
161                "ndaal-sa-2026-001",
162                None,
163                Some(r#"{"source":"manual"}"#),
164            )?;
165            record(conn, "update", "ndaal-sa-2026-001", None, None)?;
166            record(conn, "delete", "ndaal-sa-2026-002", None, None)?;
167
168            let all = list(conn, None, 100, 0)?;
169            assert_eq!(all.len(), 3);
170
171            let filtered = list(conn, Some("ndaal-sa-2026-001"), 100, 0)?;
172            assert_eq!(filtered.len(), 2);
173
174            let total = count(conn, None)?;
175            assert_eq!(total, 3);
176
177            let filtered_count = count(conn, Some("ndaal-sa-2026-001"))?;
178            assert_eq!(filtered_count, 2);
179
180            Ok(())
181        })
182        .expect("DB operation failed");
183    }
184
185    #[test]
186    fn test_iso_8601_timestamp() {
187        let pool = DbPool::open_in_memory().expect("DB open failed");
188        pool.with_conn(|conn| {
189            record(conn, "create", "test-id", None, None)?;
190            let entries = list(conn, None, 1, 0)?;
191            let entry = &entries[0];
192
193            // Verify ISO 8601 format with milliseconds
194            assert!(
195                entry.timestamp.contains('T'),
196                "Timestamp should contain 'T': {}",
197                entry.timestamp
198            );
199            assert!(
200                entry.timestamp.ends_with('Z'),
201                "Timestamp should end with 'Z': {}",
202                entry.timestamp
203            );
204
205            Ok(())
206        })
207        .expect("DB operation failed");
208    }
209}