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