Skip to main content

cairn_mod/audit/
append.rs

1//! Audit-log append helpers (#39, v1.3).
2//!
3//! Two public entry points + one shared connection-level helper. All
4//! three route through [`crate::audit::hash::compute_audit_row_hash`]
5//! so the chain has a single canonical hash implementation.
6//!
7//! ```text
8//!   in-process call sites with their own tx        →  append_in_tx (writer-internal,
9//!                                                                   flag_reporter)
10//!   in-process callers without an existing tx      →  WriterHandle::append_audit
11//!                                                     (retention_sweep)
12//!   cross-process CLI callers (no writer task)     →  append_via_pool (publish,
13//!                                                                      unpublish)
14//! ```
15//!
16//! All paths read `prev_hash` as the latest non-NULL `audit_log.row_hash`
17//! at write time, falling back to [`crate::audit::hash::GENESIS_PREV_HASH`]
18//! when the table is empty or contains only pre-v1.3 rows.
19//!
20//! The chain-integrity invariant is enforced at the SQLite write-lock
21//! layer. [`append_via_pool`] explicitly issues `BEGIN IMMEDIATE` so the
22//! latest-row read + INSERT pair is atomic against any concurrent
23//! appender — including a `cairn serve` writer task running in another
24//! process. [`append_in_tx`] inherits the lock from its caller's already-
25//! open transaction.
26
27use sqlx::sqlite::SqliteConnection;
28use sqlx::{Pool, Sqlite, Transaction};
29
30use super::hash::{
31    AuditRowForHashing, GENESIS_PREV_HASH, compute_audit_row_hash, parse_stored_hash,
32};
33use crate::error::{Error, Result};
34
35/// Owned audit-row payload used as input to the append helpers.
36/// Mirrors [`crate::audit::hash::AuditRowForHashing`] but holds owned
37/// data so it can travel across futures + writer-task message boundaries.
38#[derive(Debug, Clone)]
39pub struct AuditRowForAppend {
40    /// Internal wall-clock epoch-ms.
41    pub created_at: i64,
42    /// Audit-action discriminator.
43    pub action: String,
44    /// Actor DID.
45    pub actor_did: String,
46    /// Optional target identifier.
47    pub target: Option<String>,
48    /// Optional CID pin on the target.
49    pub target_cid: Option<String>,
50    /// `"success"` or `"failure"`.
51    pub outcome: String,
52    /// Optional structured-JSON or free-text payload.
53    pub reason: Option<String>,
54}
55
56impl AuditRowForAppend {
57    /// Borrowed view for hashing — no ownership transfer.
58    fn as_hashing(&self) -> AuditRowForHashing<'_> {
59        AuditRowForHashing {
60            created_at: self.created_at,
61            action: &self.action,
62            actor_did: &self.actor_did,
63            target: self.target.as_deref(),
64            target_cid: self.target_cid.as_deref(),
65            outcome: &self.outcome,
66            reason: self.reason.as_deref(),
67        }
68    }
69}
70
71/// Append an audit row inside an existing transaction. Used by callers
72/// that need to combine the audit row with other writes in the same
73/// commit — writer-internal handlers (apply / negate / resolve_report)
74/// and `flag_reporter` (suppression update + audit row atomically).
75///
76/// The transaction itself is the serialization point: SQLite's
77/// `BEGIN IMMEDIATE` (or any write-acquiring tx) holds the database
78/// write lock, so the latest-row-hash read + INSERT pair is atomic
79/// against any concurrent appender.
80///
81/// Returns the inserted `audit_log.id`.
82pub async fn append_in_tx(
83    tx: &mut Transaction<'_, Sqlite>,
84    row: &AuditRowForAppend,
85) -> Result<i64> {
86    perform_append(tx, row).await
87}
88
89/// Append an audit row in its own transaction. Used by cross-process
90/// CLI callers that don't have a writer task in their address space —
91/// `cairn publish-service-record` and `cairn unpublish-service-record`
92/// run as one-shot CLIs and write to the same SQLite file as a running
93/// `cairn serve`, so the SQLite write lock is what serializes them
94/// against the writer's appends.
95///
96/// `BEGIN IMMEDIATE` is the load-bearing primitive: it acquires the
97/// write lock at transaction-start instead of first-write-statement,
98/// closing the read-then-modify race that would otherwise let two
99/// processes both observe the same `prev_hash` and write rows that
100/// fork the chain. sqlx 0.8's `Pool::begin` issues `BEGIN DEFERRED`
101/// with no override hook, so we reach for `Pool::acquire` + raw SQL.
102pub async fn append_via_pool(pool: &Pool<Sqlite>, row: &AuditRowForAppend) -> Result<i64> {
103    let mut conn = pool
104        .acquire()
105        .await
106        .map_err(|e| Error::Signing(format!("audit acquire: {e}")))?;
107    sqlx::query("BEGIN IMMEDIATE")
108        .execute(&mut *conn)
109        .await
110        .map_err(|e| Error::Signing(format!("audit begin: {e}")))?;
111
112    match perform_append(&mut conn, row).await {
113        Ok(id) => {
114            sqlx::query("COMMIT")
115                .execute(&mut *conn)
116                .await
117                .map_err(|e| Error::Signing(format!("audit commit: {e}")))?;
118            Ok(id)
119        }
120        Err(e) => {
121            // Best-effort rollback — if the rollback itself fails the
122            // connection is in a degraded state but the original error
123            // is what the caller cares about.
124            let _ = sqlx::query("ROLLBACK").execute(&mut *conn).await;
125            Err(e)
126        }
127    }
128}
129
130/// Connection-level append: reads `prev_hash`, computes `row_hash`,
131/// INSERTs the row. The caller is responsible for transactional
132/// framing (either inheriting one via [`append_in_tx`] or issuing
133/// `BEGIN IMMEDIATE` via [`append_via_pool`]).
134async fn perform_append(conn: &mut SqliteConnection, row: &AuditRowForAppend) -> Result<i64> {
135    let prev_hash = read_latest_row_hash(&mut *conn).await?;
136    let row_hash = compute_audit_row_hash(&prev_hash, &row.as_hashing())?;
137
138    let prev_hash_slice: &[u8] = &prev_hash;
139    let row_hash_slice: &[u8] = &row_hash;
140    let id = sqlx::query_scalar!(
141        "INSERT INTO audit_log
142             (created_at, action, actor_did, target, target_cid, outcome, reason,
143              prev_hash, row_hash)
144         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
145         RETURNING id",
146        row.created_at,
147        row.action,
148        row.actor_did,
149        row.target,
150        row.target_cid,
151        row.outcome,
152        row.reason,
153        prev_hash_slice,
154        row_hash_slice,
155    )
156    .fetch_one(&mut *conn)
157    .await
158    .map_err(|e| Error::Signing(format!("audit append: {e}")))?;
159    Ok(id)
160}
161
162/// Read the latest stored `audit_log.row_hash`, skipping pre-v1.3 rows
163/// that have NULL hashes. Returns [`GENESIS_PREV_HASH`] when no
164/// non-NULL row_hash exists.
165///
166/// This produces the trust-horizon semantic: the v1.3 chain is rooted
167/// at GENESIS regardless of whether `cairn audit-rebuild` has run.
168async fn read_latest_row_hash(conn: &mut SqliteConnection) -> Result<[u8; 32]> {
169    let row = sqlx::query!(
170        "SELECT row_hash FROM audit_log
171         WHERE row_hash IS NOT NULL
172         ORDER BY id DESC LIMIT 1"
173    )
174    .fetch_optional(&mut *conn)
175    .await
176    .map_err(|e| Error::Signing(format!("audit prev_hash read: {e}")))?;
177    match row.and_then(|r| r.row_hash) {
178        Some(bytes) => parse_stored_hash(&bytes),
179        None => Ok(GENESIS_PREV_HASH),
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::storage;
187    use tempfile::tempdir;
188
189    async fn fresh_pool() -> Pool<Sqlite> {
190        let dir = tempdir().unwrap();
191        let path = dir.path().join("audit-test.db");
192        let pool = storage::open(&path).await.unwrap();
193        // Hold the tempdir for the pool's lifetime via Box::leak —
194        // the pool's connections reference the on-disk file. Test-only
195        // pattern; production paths own their TempDir explicitly.
196        Box::leak(Box::new(dir));
197        pool
198    }
199
200    fn sample_row(action: &str, actor_did: &str) -> AuditRowForAppend {
201        AuditRowForAppend {
202            created_at: 1_776_902_400_000,
203            action: action.into(),
204            actor_did: actor_did.into(),
205            target: Some("at://did:plc:target/col/r".into()),
206            target_cid: None,
207            outcome: "success".into(),
208            reason: None,
209        }
210    }
211
212    #[tokio::test]
213    async fn first_append_uses_genesis_prev_hash() {
214        let pool = fresh_pool().await;
215        let id = append_via_pool(&pool, &sample_row("label_applied", "did:plc:m1"))
216            .await
217            .unwrap();
218
219        let stored = sqlx::query!(
220            "SELECT prev_hash, row_hash FROM audit_log WHERE id = ?1",
221            id
222        )
223        .fetch_one(&pool)
224        .await
225        .unwrap();
226
227        let prev: &[u8] = stored.prev_hash.as_deref().expect("prev_hash present");
228        assert_eq!(prev, GENESIS_PREV_HASH);
229        assert!(stored.row_hash.is_some());
230    }
231
232    #[tokio::test]
233    async fn second_append_chains_to_first_row_hash() {
234        let pool = fresh_pool().await;
235        let id1 = append_via_pool(&pool, &sample_row("label_applied", "did:plc:m1"))
236            .await
237            .unwrap();
238        let id2 = append_via_pool(&pool, &sample_row("label_negated", "did:plc:m1"))
239            .await
240            .unwrap();
241
242        let row1_hash: Vec<u8> = sqlx::query_scalar!(
243            r#"SELECT row_hash AS "row_hash!" FROM audit_log WHERE id = ?1"#,
244            id1
245        )
246        .fetch_one(&pool)
247        .await
248        .unwrap();
249        let row2_prev: Vec<u8> = sqlx::query_scalar!(
250            r#"SELECT prev_hash AS "prev_hash!" FROM audit_log WHERE id = ?1"#,
251            id2
252        )
253        .fetch_one(&pool)
254        .await
255        .unwrap();
256        assert_eq!(
257            row1_hash, row2_prev,
258            "row 2's prev_hash must match row 1's row_hash"
259        );
260    }
261
262    #[tokio::test]
263    async fn pre_v13_null_rows_are_skipped_for_prev_hash_lookup() {
264        let pool = fresh_pool().await;
265        // Manually insert a pre-v1.3 row (NULL hashes), then append
266        // a v1.3 row and confirm the v1.3 row's prev_hash is GENESIS,
267        // not the NULL value of the previous row.
268        sqlx::query!(
269            "INSERT INTO audit_log (created_at, action, actor_did, outcome)
270             VALUES (?1, ?2, ?3, ?4)",
271            1_i64,
272            "label_applied",
273            "did:plc:m1",
274            "success"
275        )
276        .execute(&pool)
277        .await
278        .unwrap();
279
280        let v13_id = append_via_pool(&pool, &sample_row("label_negated", "did:plc:m1"))
281            .await
282            .unwrap();
283
284        let prev: Option<Vec<u8>> =
285            sqlx::query_scalar!("SELECT prev_hash FROM audit_log WHERE id = ?1", v13_id)
286                .fetch_one(&pool)
287                .await
288                .unwrap();
289        let prev_bytes = prev.expect("prev_hash present on v1.3 row");
290        assert_eq!(
291            prev_bytes.as_slice(),
292            GENESIS_PREV_HASH,
293            "v1.3 row must use GENESIS_PREV_HASH when latest row has NULL row_hash"
294        );
295    }
296
297    #[tokio::test]
298    async fn append_in_tx_and_append_via_pool_produce_same_row_hash() {
299        // Pin the no-drift contract from #39's design: both append
300        // paths must compute the same row_hash for the same row
301        // content + prev_hash. If this test ever fails, the hash
302        // implementation has split between the two paths.
303        let pool_a = fresh_pool().await;
304        let pool_b = fresh_pool().await;
305        let row = sample_row("label_applied", "did:plc:m1");
306
307        // Path 1: append_via_pool on its own.
308        let id_a = append_via_pool(&pool_a, &row).await.unwrap();
309        let hash_a: Vec<u8> = sqlx::query_scalar!(
310            r#"SELECT row_hash AS "row_hash!" FROM audit_log WHERE id = ?1"#,
311            id_a
312        )
313        .fetch_one(&pool_a)
314        .await
315        .unwrap();
316
317        // Path 2: append_in_tx via an explicit BEGIN IMMEDIATE on a
318        // raw connection (matches the production tx pattern that
319        // writer-internal callers and flag_reporter exercise).
320        let mut tx = pool_b.begin().await.unwrap();
321        let id_b = append_in_tx(&mut tx, &row).await.unwrap();
322        tx.commit().await.unwrap();
323        let hash_b: Vec<u8> = sqlx::query_scalar!(
324            r#"SELECT row_hash AS "row_hash!" FROM audit_log WHERE id = ?1"#,
325            id_b
326        )
327        .fetch_one(&pool_b)
328        .await
329        .unwrap();
330
331        assert_eq!(
332            hash_a, hash_b,
333            "append_via_pool and append_in_tx must agree on row_hash for identical input"
334        );
335    }
336}