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