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, 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    async fn verify_audit_chain(
167        &self,
168        key: &AuditKey,
169    ) -> Result<VerifyOutcome, TransactionStoreError>;
170}
171
172/// Adapter that exposes the existing rusqlite-backed [`TransactionStore`]
173/// through the async [`AuditStore`] trait. Each method runs the underlying
174/// blocking work on `tokio::task::spawn_blocking` so the executor stays
175/// responsive even when the SQLite write takes a millisecond on a slow disk.
176#[derive(Debug, Clone)]
177pub struct SqliteStore {
178    inner: Arc<TransactionStore>,
179}
180
181impl SqliteStore {
182    pub fn new(inner: TransactionStore) -> Self {
183        Self {
184            inner: Arc::new(inner),
185        }
186    }
187
188    /// Borrow the wrapped sync `TransactionStore`. Used by call sites that
189    /// genuinely need the blocking handle (test helpers, the audit-verify
190    /// CLI subcommand which is itself sync over the result).
191    pub fn inner(&self) -> &TransactionStore {
192        &self.inner
193    }
194}
195
196/// Run a blocking closure on the runtime's blocking thread pool.
197///
198/// `spawn_blocking` returns a `JoinHandle` that resolves to the closure's
199/// output; we panic-propagate JoinErrors because they only happen when the
200/// runtime is shutting down — at which point the daemon is also shutting
201/// down and there's no audit log to write to anyway.
202async fn blocking<F, R>(f: F) -> R
203where
204    F: FnOnce() -> R + Send + 'static,
205    R: Send + 'static,
206{
207    tokio::task::spawn_blocking(f)
208        .await
209        .expect("audit-store blocking task panicked")
210}
211
212#[async_trait]
213impl AuditStore for SqliteStore {
214    async fn record(
215        &self,
216        transaction: NewTransaction,
217    ) -> Result<TransactionRecord, TransactionStoreError> {
218        let inner = Arc::clone(&self.inner);
219        blocking(move || inner.record(transaction)).await
220    }
221
222    async fn record_previewed(
223        &self,
224        transaction: NewTransaction,
225        preview: PreviewEnvelope,
226    ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
227        let inner = Arc::clone(&self.inner);
228        blocking(move || inner.record_previewed(transaction, preview)).await
229    }
230
231    async fn get(
232        &self,
233        transaction_id: &str,
234    ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
235        let inner = Arc::clone(&self.inner);
236        let id = transaction_id.to_string();
237        blocking(move || inner.get(&id)).await
238    }
239
240    async fn get_preview(
241        &self,
242        transaction_id: &str,
243    ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
244        let inner = Arc::clone(&self.inner);
245        let id = transaction_id.to_string();
246        blocking(move || inner.get_preview(&id)).await
247    }
248
249    async fn update_status(
250        &self,
251        transaction_id: &str,
252        new_status: JobState,
253    ) -> Result<(), TransactionStoreError> {
254        let inner = Arc::clone(&self.inner);
255        let id = transaction_id.to_string();
256        blocking(move || inner.update_status(&id, new_status)).await
257    }
258
259    async fn approve_transaction(
260        &self,
261        transaction_id: &str,
262    ) -> Result<Option<String>, TransactionStoreError> {
263        let inner = Arc::clone(&self.inner);
264        let id = transaction_id.to_string();
265        blocking(move || inner.approve_transaction(&id)).await
266    }
267
268    async fn revoke_unconsumed_approval(
269        &self,
270        transaction_id: &str,
271    ) -> Result<bool, TransactionStoreError> {
272        let inner = Arc::clone(&self.inner);
273        let id = transaction_id.to_string();
274        blocking(move || inner.revoke_unconsumed_approval(&id)).await
275    }
276
277    async fn claim_approved_for_execution(
278        &self,
279        transaction_id: &str,
280        receipt_digest: &str,
281    ) -> Result<bool, TransactionStoreError> {
282        let inner = Arc::clone(&self.inner);
283        let id = transaction_id.to_string();
284        let digest = receipt_digest.to_string();
285        blocking(move || inner.claim_approved_for_execution(&id, &digest)).await
286    }
287
288    async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
289        let inner = Arc::clone(&self.inner);
290        blocking(move || inner.cleanup_stale_queued()).await
291    }
292
293    async fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
294        let inner = Arc::clone(&self.inner);
295        let id = transaction_id.to_string();
296        blocking(move || inner.cancel_queued(&id)).await
297    }
298
299    async fn list_transactions(
300        &self,
301        limit: u32,
302        status_filter: Option<&str>,
303        action_filter: Option<&str>,
304        since_hours: Option<u32>,
305    ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
306        let inner = Arc::clone(&self.inner);
307        let status = status_filter.map(str::to_string);
308        let action = action_filter.map(str::to_string);
309        blocking(move || {
310            inner.list_transactions(limit, status.as_deref(), action.as_deref(), since_hours)
311        })
312        .await
313    }
314
315    async fn list_history(
316        &self,
317        limit: u32,
318        status_filter: Option<&str>,
319        action_filter: Option<&str>,
320        since_hours: Option<u32>,
321    ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError> {
322        let inner = Arc::clone(&self.inner);
323        let status = status_filter.map(str::to_string);
324        let action = action_filter.map(str::to_string);
325        blocking(move || {
326            inner.list_history(limit, status.as_deref(), action.as_deref(), since_hours)
327        })
328        .await
329    }
330
331    async fn fetch_chain_row(
332        &self,
333        transaction_id: &str,
334    ) -> Result<Option<ChainRow>, TransactionStoreError> {
335        let inner = Arc::clone(&self.inner);
336        let id = transaction_id.to_string();
337        blocking(move || inner.fetch_chain_row(&id)).await
338    }
339
340    async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
341        let inner = Arc::clone(&self.inner);
342        blocking(move || inner.fetch_chain_rows()).await
343    }
344
345    async fn verify_audit_chain(
346        &self,
347        key: &AuditKey,
348    ) -> Result<VerifyOutcome, TransactionStoreError> {
349        // `key` is borrowed from the caller; we can't move it into a
350        // `'static` `spawn_blocking` closure. Verifying is a CPU-only signature
351        // walk over rows already fetched, so doing the work on the async
352        // thread (after the blocking fetch) is fine — no I/O risk.
353        let rows = self.fetch_chain_rows().await?;
354        Ok(crate::audit_chain::verify_chain(key, &rows))
355    }
356}
357
358// Re-export the postgres impl so call sites can `use store::PostgresStore`.
359pub use postgres::PostgresStore;
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::audit_chain::AuditKey;
365    use crate::transactions::NewTransaction;
366    use sysknife_types::RiskLevel;
367    use tempfile::tempdir;
368
369    fn sqlite_store(path: std::path::PathBuf) -> SqliteStore {
370        let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
371        SqliteStore::new(crate::transactions::TransactionStore::open_with_key(path, key).unwrap())
372    }
373
374    fn new_tx(request_id: &str) -> NewTransaction {
375        NewTransaction {
376            request_id: request_id.to_string(),
377            request_hash: format!("hash-{request_id}"),
378            action_name: "UpdateSystem".to_string(),
379            risk_level: RiskLevel::High,
380            approval_id: None,
381            summary: "Upgrade the system".to_string(),
382            warnings: vec![],
383        }
384    }
385
386    #[tokio::test]
387    async fn reconcile_interrupted_running_fails_only_running_rows() {
388        let dir = tempdir().unwrap();
389        let store = sqlite_store(dir.path().join("tx.db"));
390
391        // A row left Running by a crash, plus two rows in terminal/other states
392        // that reconciliation must not touch.
393        let running = store.record(new_tx("running")).await.unwrap();
394        store
395            .update_status(&running.transaction_id, JobState::Running)
396            .await
397            .unwrap();
398
399        let queued = store.record(new_tx("queued")).await.unwrap();
400
401        let succeeded = store.record(new_tx("succeeded")).await.unwrap();
402        store
403            .update_status(&succeeded.transaction_id, JobState::Running)
404            .await
405            .unwrap();
406        store
407            .update_status(&succeeded.transaction_id, JobState::Succeeded)
408            .await
409            .unwrap();
410
411        let reconciled = store.reconcile_interrupted_running().await.unwrap();
412        assert_eq!(reconciled, 1, "only the one Running row is reconciled");
413
414        assert_eq!(
415            store
416                .get(&running.transaction_id)
417                .await
418                .unwrap()
419                .unwrap()
420                .status,
421            JobState::Failed,
422            "orphaned Running row must become Failed"
423        );
424        assert_eq!(
425            store
426                .get(&queued.transaction_id)
427                .await
428                .unwrap()
429                .unwrap()
430                .status,
431            JobState::Queued,
432            "Queued row is untouched"
433        );
434        assert_eq!(
435            store
436                .get(&succeeded.transaction_id)
437                .await
438                .unwrap()
439                .unwrap()
440                .status,
441            JobState::Succeeded,
442            "terminal Succeeded row is untouched"
443        );
444
445        // Idempotent: a second pass finds nothing left to reconcile.
446        assert_eq!(store.reconcile_interrupted_running().await.unwrap(), 0);
447    }
448}