Skip to main content

sysknife_daemon/
store.rs

1//! Storage backend abstraction for the audit log.
2//!
3//! Two backends ship in the box:
4//!
5//! - [`SqliteStore`] — `rusqlite`-backed, single-file local database. The
6//!   default for dev/test; explicitly **not recommended** for production
7//!   because the audit log dies with the host (no off-box durability,
8//!   nothing to forward to a SOC).
9//! - [`PostgresStore`] — `sqlx`-backed, native async, TLS via rustls +
10//!   webpki-roots. Wire-compatible with AWS RDS / Aurora, GCP Cloud SQL +
11//!   AlloyDB, Azure Database for PostgreSQL Flexible Server, Supabase,
12//!   Neon, and self-hosted PostgreSQL.
13//!
14//! The dispatcher does not know which backend is active. It calls into
15//! [`AuditStore`] (an async trait) and the runtime resolves to the configured
16//! impl through `state.audit: Arc<dyn AuditStore + Send + Sync>`.
17//!
18//! ## Cloud provider quick reference
19//!
20//! See `docs/storage-cloud.md` for the full table. Summary:
21//!
22//! | Provider             | URL hint                                        | TLS mode      | Statement cache |
23//! |----------------------|-------------------------------------------------|---------------|-----------------|
24//! | AWS RDS / Aurora     | `*.rds.amazonaws.com:5432`                      | `verify-full` | default         |
25//! | GCP Cloud SQL        | via Cloud SQL Auth Proxy on `127.0.0.1`         | `disable`     | default         |
26//! | Azure Flexible       | `*.postgres.database.azure.com:5432`            | `verify-full` | default         |
27//! | Supabase (5432)      | `db.<ref>.supabase.co:5432`                     | `require`     | default         |
28//! | Supabase (pooler)    | `*.pooler.supabase.com:6543`                    | `require`     | **0**           |
29//! | Neon                 | `ep-*.neon.tech`                                | `require`     | default         |
30//! | Self-hosted          | (operator chooses)                              | as needed     | default         |
31
32use async_trait::async_trait;
33use std::sync::Arc;
34use sysknife_types::{JobState, PreviewEnvelope, TransactionRecord};
35
36use crate::audit_chain::{AuditKey, ChainRow, EventRow, VerifyOutcome};
37use crate::transactions::{
38    NewTransaction, RecordedPreviewedTransaction, TransactionStore, TransactionStoreError,
39};
40
41pub mod postgres;
42
43/// Async, polymorphic interface to the audit log. Implemented by
44/// [`SqliteStore`] (rusqlite, blocking under the hood) and
45/// [`postgres::PostgresStore`] (sqlx, native async).
46///
47/// All methods on this trait correspond 1:1 to methods on
48/// [`TransactionStore`] for the SQLite path; the Postgres path implements
49/// the same semantics over a different SQL dialect.
50#[async_trait]
51pub trait AuditStore: Send + Sync + std::fmt::Debug {
52    async fn record(
53        &self,
54        transaction: NewTransaction,
55    ) -> Result<TransactionRecord, TransactionStoreError>;
56
57    async fn record_previewed(
58        &self,
59        transaction: NewTransaction,
60        preview: PreviewEnvelope,
61    ) -> Result<RecordedPreviewedTransaction, TransactionStoreError>;
62
63    async fn get(
64        &self,
65        transaction_id: &str,
66    ) -> Result<Option<TransactionRecord>, TransactionStoreError>;
67
68    async fn get_preview(
69        &self,
70        transaction_id: &str,
71    ) -> Result<Option<PreviewEnvelope>, TransactionStoreError>;
72
73    async fn update_status(
74        &self,
75        transaction_id: &str,
76        new_status: JobState,
77    ) -> Result<(), TransactionStoreError>;
78
79    async fn approve_transaction(
80        &self,
81        transaction_id: &str,
82    ) -> Result<Option<String>, TransactionStoreError>;
83
84    async fn revoke_unconsumed_approval(
85        &self,
86        transaction_id: &str,
87    ) -> Result<bool, TransactionStoreError>;
88
89    async fn claim_approved_for_execution(
90        &self,
91        transaction_id: &str,
92        receipt_digest: &str,
93    ) -> Result<bool, TransactionStoreError>;
94
95    async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError>;
96
97    /// Reconcile transactions orphaned by a daemon crash or restart.
98    ///
99    /// A row still in `Running` means the daemon was mid-execution of that
100    /// action when it exited. On the next start the in-flight work is orphaned
101    /// and its true outcome is unknown, so each such row is transitioned
102    /// `Running → Failed` (the conservative outcome — the operator must
103    /// re-check) and the transition is recorded in the audit chain. Intended to
104    /// run exactly once at startup, before the daemon begins accepting
105    /// connections, so clients never observe a phantom in-flight row.
106    ///
107    /// Returns the number of transactions reconciled. The default implementation
108    /// is backend-agnostic: it pages through `Running` rows via
109    /// [`list_transactions`](Self::list_transactions) and applies
110    /// [`update_status`](Self::update_status), both of which every backend
111    /// already implements with identical semantics.
112    async fn reconcile_interrupted_running(&self) -> Result<u64, TransactionStoreError> {
113        let mut total = 0u64;
114        loop {
115            let running = self
116                .list_transactions(100, Some("running"), None, None)
117                .await?;
118            if running.is_empty() {
119                break;
120            }
121            let batch = running.len();
122            for tx in running {
123                self.update_status(&tx.transaction_id, JobState::Failed)
124                    .await?;
125            }
126            total += batch as u64;
127            // A short page means we drained every remaining Running row.
128            if batch < 100 {
129                break;
130            }
131        }
132        Ok(total)
133    }
134
135    /// Cancel one still-`Queued` transaction (`Queued → Canceled`). Returns
136    /// `true` iff a queued row was transitioned; a `Running` (in-flight) or
137    /// terminal transaction is never cancelled. See
138    /// [`crate::transactions::TransactionStore::cancel_queued`].
139    async fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError>;
140
141    async fn list_transactions(
142        &self,
143        limit: u32,
144        status_filter: Option<&str>,
145        action_filter: Option<&str>,
146        since_hours: Option<u32>,
147    ) -> Result<Vec<TransactionRecord>, TransactionStoreError>;
148
149    /// Structured history rows (with `created_at` and typed `risk_level`) for
150    /// programmatic clients. See [`crate::transactions::JobHistoryEntry`].
151    async fn list_history(
152        &self,
153        limit: u32,
154        status_filter: Option<&str>,
155        action_filter: Option<&str>,
156        since_hours: Option<u32>,
157    ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError>;
158
159    async fn fetch_chain_row(
160        &self,
161        transaction_id: &str,
162    ) -> Result<Option<ChainRow>, TransactionStoreError>;
163
164    async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError>;
165
166    /// Every approval event, in seq order. Backs the second chain that records
167    /// grant/consume/revoke — see `audit_chain::AuditEventKind`.
168    async fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError>;
169
170    async fn verify_audit_chain(
171        &self,
172        key: &AuditKey,
173    ) -> Result<VerifyOutcome, TransactionStoreError>;
174
175    /// Walk the approval-event chain.
176    ///
177    /// Provided rather than required: it is the same signature walk over rows
178    /// the backend has already fetched, so a backend that implements
179    /// `fetch_event_rows` correctly cannot get this wrong, and an override
180    /// would be a second place for the two chains' verification to drift.
181    async fn verify_event_chain(
182        &self,
183        key: &AuditKey,
184    ) -> Result<VerifyOutcome, TransactionStoreError> {
185        let rows = self.fetch_event_rows().await?;
186        Ok(crate::audit_chain::verify_event_chain(key, &rows))
187    }
188
189    /// Check that every event tip committed by a transaction row still exists
190    /// in the event chain. See `audit_chain::verify_event_binding`.
191    async fn verify_event_binding(
192        &self,
193    ) -> Result<crate::audit_chain::BindingOutcome, TransactionStoreError> {
194        let tx_rows = self.fetch_chain_rows().await?;
195        let event_rows = self.fetch_event_rows().await?;
196        Ok(crate::audit_chain::verify_event_binding(
197            &tx_rows,
198            &event_rows,
199        ))
200    }
201}
202
203/// Adapter that exposes the existing rusqlite-backed [`TransactionStore`]
204/// through the async [`AuditStore`] trait. Each method runs the underlying
205/// blocking work on `tokio::task::spawn_blocking` so the executor stays
206/// responsive even when the SQLite write takes a millisecond on a slow disk.
207#[derive(Debug, Clone)]
208pub struct SqliteStore {
209    inner: Arc<TransactionStore>,
210}
211
212impl SqliteStore {
213    pub fn new(inner: TransactionStore) -> Self {
214        Self {
215            inner: Arc::new(inner),
216        }
217    }
218
219    /// Borrow the wrapped sync `TransactionStore`. Used by call sites that
220    /// genuinely need the blocking handle (test helpers, the audit-verify
221    /// CLI subcommand which is itself sync over the result).
222    pub fn inner(&self) -> &TransactionStore {
223        &self.inner
224    }
225}
226
227/// Run a blocking closure on the runtime's blocking thread pool.
228///
229/// `spawn_blocking` returns a `JoinHandle` that resolves to the closure's
230/// output; we panic-propagate JoinErrors because they only happen when the
231/// runtime is shutting down — at which point the daemon is also shutting
232/// down and there's no audit log to write to anyway.
233async fn blocking<F, R>(f: F) -> R
234where
235    F: FnOnce() -> R + Send + 'static,
236    R: Send + 'static,
237{
238    tokio::task::spawn_blocking(f)
239        .await
240        .expect("audit-store blocking task panicked")
241}
242
243#[async_trait]
244impl AuditStore for SqliteStore {
245    async fn record(
246        &self,
247        transaction: NewTransaction,
248    ) -> Result<TransactionRecord, TransactionStoreError> {
249        let inner = Arc::clone(&self.inner);
250        blocking(move || inner.record(transaction)).await
251    }
252
253    async fn record_previewed(
254        &self,
255        transaction: NewTransaction,
256        preview: PreviewEnvelope,
257    ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
258        let inner = Arc::clone(&self.inner);
259        blocking(move || inner.record_previewed(transaction, preview)).await
260    }
261
262    async fn get(
263        &self,
264        transaction_id: &str,
265    ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
266        let inner = Arc::clone(&self.inner);
267        let id = transaction_id.to_string();
268        blocking(move || inner.get(&id)).await
269    }
270
271    async fn get_preview(
272        &self,
273        transaction_id: &str,
274    ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
275        let inner = Arc::clone(&self.inner);
276        let id = transaction_id.to_string();
277        blocking(move || inner.get_preview(&id)).await
278    }
279
280    async fn update_status(
281        &self,
282        transaction_id: &str,
283        new_status: JobState,
284    ) -> Result<(), TransactionStoreError> {
285        let inner = Arc::clone(&self.inner);
286        let id = transaction_id.to_string();
287        blocking(move || inner.update_status(&id, new_status)).await
288    }
289
290    async fn approve_transaction(
291        &self,
292        transaction_id: &str,
293    ) -> Result<Option<String>, TransactionStoreError> {
294        let inner = Arc::clone(&self.inner);
295        let id = transaction_id.to_string();
296        blocking(move || inner.approve_transaction(&id)).await
297    }
298
299    async fn revoke_unconsumed_approval(
300        &self,
301        transaction_id: &str,
302    ) -> Result<bool, TransactionStoreError> {
303        let inner = Arc::clone(&self.inner);
304        let id = transaction_id.to_string();
305        blocking(move || inner.revoke_unconsumed_approval(&id)).await
306    }
307
308    async fn claim_approved_for_execution(
309        &self,
310        transaction_id: &str,
311        receipt_digest: &str,
312    ) -> Result<bool, TransactionStoreError> {
313        let inner = Arc::clone(&self.inner);
314        let id = transaction_id.to_string();
315        let digest = receipt_digest.to_string();
316        blocking(move || inner.claim_approved_for_execution(&id, &digest)).await
317    }
318
319    async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
320        let inner = Arc::clone(&self.inner);
321        blocking(move || inner.cleanup_stale_queued()).await
322    }
323
324    async fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
325        let inner = Arc::clone(&self.inner);
326        let id = transaction_id.to_string();
327        blocking(move || inner.cancel_queued(&id)).await
328    }
329
330    async fn list_transactions(
331        &self,
332        limit: u32,
333        status_filter: Option<&str>,
334        action_filter: Option<&str>,
335        since_hours: Option<u32>,
336    ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
337        let inner = Arc::clone(&self.inner);
338        let status = status_filter.map(str::to_string);
339        let action = action_filter.map(str::to_string);
340        blocking(move || {
341            inner.list_transactions(limit, status.as_deref(), action.as_deref(), since_hours)
342        })
343        .await
344    }
345
346    async fn list_history(
347        &self,
348        limit: u32,
349        status_filter: Option<&str>,
350        action_filter: Option<&str>,
351        since_hours: Option<u32>,
352    ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError> {
353        let inner = Arc::clone(&self.inner);
354        let status = status_filter.map(str::to_string);
355        let action = action_filter.map(str::to_string);
356        blocking(move || {
357            inner.list_history(limit, status.as_deref(), action.as_deref(), since_hours)
358        })
359        .await
360    }
361
362    async fn fetch_chain_row(
363        &self,
364        transaction_id: &str,
365    ) -> Result<Option<ChainRow>, TransactionStoreError> {
366        let inner = Arc::clone(&self.inner);
367        let id = transaction_id.to_string();
368        blocking(move || inner.fetch_chain_row(&id)).await
369    }
370
371    async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
372        let inner = Arc::clone(&self.inner);
373        blocking(move || inner.fetch_chain_rows()).await
374    }
375
376    async fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
377        let inner = Arc::clone(&self.inner);
378        blocking(move || inner.fetch_event_rows()).await
379    }
380
381    async fn verify_audit_chain(
382        &self,
383        key: &AuditKey,
384    ) -> Result<VerifyOutcome, TransactionStoreError> {
385        // `key` is borrowed from the caller; we can't move it into a
386        // `'static` `spawn_blocking` closure. Verifying is a CPU-only signature
387        // walk over rows already fetched, so doing the work on the async
388        // thread (after the blocking fetch) is fine — no I/O risk.
389        let rows = self.fetch_chain_rows().await?;
390        Ok(crate::audit_chain::verify_chain(key, &rows))
391    }
392}
393
394// Re-export the postgres impl so call sites can `use store::PostgresStore`.
395pub use postgres::PostgresStore;
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use crate::audit_chain::AuditKey;
401    use crate::transactions::NewTransaction;
402    use sysknife_types::{CallerRole, RiskLevel};
403    use tempfile::tempdir;
404
405    fn sqlite_store(path: std::path::PathBuf) -> SqliteStore {
406        let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
407        SqliteStore::new(crate::transactions::TransactionStore::open_with_key(path, key).unwrap())
408    }
409
410    fn new_tx(request_id: &str) -> NewTransaction {
411        NewTransaction {
412            request_id: request_id.to_string(),
413            request_hash: format!("hash-{request_id}"),
414            action_name: "UpdateSystem".to_string(),
415            risk_level: RiskLevel::High,
416            summary: "Upgrade the system".to_string(),
417            warnings: vec![],
418            caller_role: CallerRole::Dev,
419        }
420    }
421
422    #[tokio::test]
423    async fn reconcile_interrupted_running_fails_only_running_rows() {
424        let dir = tempdir().unwrap();
425        let store = sqlite_store(dir.path().join("tx.db"));
426
427        // A row left Running by a crash, plus two rows in terminal/other states
428        // that reconciliation must not touch.
429        let running = store.record(new_tx("running")).await.unwrap();
430        store
431            .update_status(&running.transaction_id, JobState::Running)
432            .await
433            .unwrap();
434
435        let queued = store.record(new_tx("queued")).await.unwrap();
436
437        let succeeded = store.record(new_tx("succeeded")).await.unwrap();
438        store
439            .update_status(&succeeded.transaction_id, JobState::Running)
440            .await
441            .unwrap();
442        store
443            .update_status(&succeeded.transaction_id, JobState::Succeeded)
444            .await
445            .unwrap();
446
447        let reconciled = store.reconcile_interrupted_running().await.unwrap();
448        assert_eq!(reconciled, 1, "only the one Running row is reconciled");
449
450        assert_eq!(
451            store
452                .get(&running.transaction_id)
453                .await
454                .unwrap()
455                .unwrap()
456                .status,
457            JobState::Failed,
458            "orphaned Running row must become Failed"
459        );
460        assert_eq!(
461            store
462                .get(&queued.transaction_id)
463                .await
464                .unwrap()
465                .unwrap()
466                .status,
467            JobState::Queued,
468            "Queued row is untouched"
469        );
470        assert_eq!(
471            store
472                .get(&succeeded.transaction_id)
473                .await
474                .unwrap()
475                .unwrap()
476                .status,
477            JobState::Succeeded,
478            "terminal Succeeded row is untouched"
479        );
480
481        // Idempotent: a second pass finds nothing left to reconcile.
482        assert_eq!(store.reconcile_interrupted_running().await.unwrap(), 0);
483    }
484}