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::auth::CallerPrincipal;
47use crate::transactions::{
48    NewTransaction, RecordedPreviewedTransaction, TransactionStore, TransactionStoreError,
49};
50
51pub mod postgres;
52
53/// Column list every `ChainRow` read shares, on both backends. The SQLite
54/// path (`transactions.rs`) maps rows positionally and the Postgres path
55/// (`store/postgres.rs`) maps them by name, but both SELECT this exact list
56/// in this exact order, so the list, the column count, and the mapper order
57/// are one invariant.
58///
59/// This used to be declared twice — once per backend — kept in sync only by
60/// a "Mirrors" doc comment. Adding the caller-identity columns updated the
61/// mapper and one of the two queries, and the miss showed up only as a
62/// runtime "no column found for name: chain_version" from the live-Postgres
63/// test — the unit tests, which never touch this SQL, stayed green. One
64/// declaration means that class of drift is a compile-time impossibility
65/// rather than a live-database surprise (#397).
66///
67/// Parameter placeholders stay per-backend on purpose: SQLite binds `?1`,
68/// Postgres binds `$1`. Only the column list is shared.
69pub(crate) const CHAIN_ROW_COLUMNS: &str =
70    "seq, key_id, transaction_id, request_id, request_hash, \
71     action_name, risk_level, summary, approval_id, warnings_json, \
72     created_at, prev_chain_hash, chain_hash, chain_version, caller_role, event_tip, \
73     caller_principal";
74
75/// Async, polymorphic interface to the audit log. Implemented by
76/// [`SqliteStore`] (rusqlite, blocking under the hood) and
77/// [`postgres::PostgresStore`] (sqlx, native async).
78///
79/// All methods on this trait correspond 1:1 to methods on
80/// [`TransactionStore`] for the SQLite path; the Postgres path implements
81/// the same semantics over a different SQL dialect.
82#[async_trait]
83pub trait AuditStore: Send + Sync + std::fmt::Debug {
84    async fn record(
85        &self,
86        transaction: NewTransaction,
87    ) -> Result<TransactionRecord, TransactionStoreError>;
88
89    async fn record_previewed(
90        &self,
91        transaction: NewTransaction,
92        preview: PreviewEnvelope,
93    ) -> Result<RecordedPreviewedTransaction, TransactionStoreError>;
94
95    async fn get(
96        &self,
97        transaction_id: &str,
98    ) -> Result<Option<TransactionRecord>, TransactionStoreError>;
99
100    async fn get_preview(
101        &self,
102        transaction_id: &str,
103    ) -> Result<Option<PreviewEnvelope>, TransactionStoreError>;
104
105    async fn update_status(
106        &self,
107        transaction_id: &str,
108        new_status: JobState,
109    ) -> Result<(), TransactionStoreError>;
110
111    async fn approve_transaction(
112        &self,
113        transaction_id: &str,
114        approver: CallerPrincipal,
115    ) -> Result<Option<String>, TransactionStoreError>;
116
117    async fn revoke_unconsumed_approval(
118        &self,
119        transaction_id: &str,
120        revoker: CallerPrincipal,
121    ) -> Result<bool, TransactionStoreError>;
122
123    async fn claim_approved_for_execution(
124        &self,
125        transaction_id: &str,
126        receipt_digest: &str,
127        executor: CallerPrincipal,
128    ) -> Result<bool, TransactionStoreError>;
129
130    async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError>;
131
132    /// Reconcile transactions orphaned by a daemon crash or restart.
133    ///
134    /// A row still in `Running` means the daemon was mid-execution of that
135    /// action when it exited. On the next start the in-flight work is orphaned
136    /// and its true outcome is unknown, so each such row is transitioned
137    /// `Running → Failed` (the conservative outcome — the operator must
138    /// re-check) and the transition is recorded in the audit chain. Intended to
139    /// run exactly once at startup, before the daemon begins accepting
140    /// connections, so clients never observe a phantom in-flight row.
141    ///
142    /// Returns the number of transactions reconciled. The default implementation
143    /// is backend-agnostic: it pages through `Running` rows via
144    /// [`list_transactions`](Self::list_transactions) and applies
145    /// [`update_status`](Self::update_status), both of which every backend
146    /// already implements with identical semantics.
147    async fn reconcile_interrupted_running(&self) -> Result<u64, TransactionStoreError> {
148        let mut total = 0u64;
149        loop {
150            let running = self
151                .list_transactions(100, Some("running"), None, None)
152                .await?;
153            if running.is_empty() {
154                break;
155            }
156            let batch = running.len();
157            for tx in running {
158                self.update_status(&tx.transaction_id, JobState::Failed)
159                    .await?;
160            }
161            total += batch as u64;
162            // A short page means we drained every remaining Running row.
163            if batch < 100 {
164                break;
165            }
166        }
167        Ok(total)
168    }
169
170    /// Cancel one still-`Queued` transaction (`Queued → Canceled`). Returns
171    /// `true` iff a queued row was transitioned; a `Running` (in-flight) or
172    /// terminal transaction is never cancelled. See
173    /// [`crate::transactions::TransactionStore::cancel_queued`].
174    async fn cancel_queued(
175        &self,
176        transaction_id: &str,
177        canceller: CallerPrincipal,
178    ) -> Result<bool, TransactionStoreError>;
179
180    async fn list_transactions(
181        &self,
182        limit: u32,
183        status_filter: Option<&str>,
184        action_filter: Option<&str>,
185        since_hours: Option<u32>,
186    ) -> Result<Vec<TransactionRecord>, TransactionStoreError>;
187
188    /// Structured history rows (with `created_at` and typed `risk_level`) for
189    /// programmatic clients. See [`crate::transactions::JobHistoryEntry`].
190    async fn list_history(
191        &self,
192        limit: u32,
193        status_filter: Option<&str>,
194        action_filter: Option<&str>,
195        since_hours: Option<u32>,
196    ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError>;
197
198    async fn fetch_chain_row(
199        &self,
200        transaction_id: &str,
201    ) -> Result<Option<ChainRow>, TransactionStoreError>;
202
203    async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError>;
204
205    /// Every approval event, in seq order. Backs the second chain that records
206    /// grant/consume/revoke — see `audit_chain::AuditEventKind`.
207    async fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError>;
208
209    async fn verify_audit_chain(
210        &self,
211        key: &AuditKey,
212    ) -> Result<VerifyOutcome, TransactionStoreError>;
213
214    /// Walk the approval-event chain.
215    ///
216    /// Provided rather than required: it is the same signature walk over rows
217    /// the backend has already fetched, so a backend that implements
218    /// `fetch_event_rows` correctly cannot get this wrong, and an override
219    /// would be a second place for the two chains' verification to drift.
220    async fn verify_event_chain(
221        &self,
222        key: &AuditKey,
223    ) -> Result<VerifyOutcome, TransactionStoreError> {
224        let rows = self.fetch_event_rows().await?;
225        Ok(crate::audit_chain::verify_event_chain(key, &rows))
226    }
227
228    /// Check that every event tip committed by a transaction row still exists
229    /// in the event chain. See `audit_chain::verify_event_binding`.
230    async fn verify_event_binding(
231        &self,
232    ) -> Result<crate::audit_chain::BindingOutcome, TransactionStoreError> {
233        let tx_rows = self.fetch_chain_rows().await?;
234        let event_rows = self.fetch_event_rows().await?;
235        Ok(crate::audit_chain::verify_event_binding(
236            &tx_rows,
237            &event_rows,
238        ))
239    }
240}
241
242/// Adapter that exposes the existing rusqlite-backed [`TransactionStore`]
243/// through the async [`AuditStore`] trait. Each method runs the underlying
244/// blocking work on `tokio::task::spawn_blocking` so the executor stays
245/// responsive even when the SQLite write takes a millisecond on a slow disk.
246#[derive(Debug, Clone)]
247pub struct SqliteStore {
248    inner: Arc<TransactionStore>,
249}
250
251impl SqliteStore {
252    pub fn new(inner: TransactionStore) -> Self {
253        Self {
254            inner: Arc::new(inner),
255        }
256    }
257
258    /// Borrow the wrapped sync `TransactionStore`. Used by call sites that
259    /// genuinely need the blocking handle (test helpers, the audit-verify
260    /// CLI subcommand which is itself sync over the result).
261    pub fn inner(&self) -> &TransactionStore {
262        &self.inner
263    }
264}
265
266/// Run a blocking closure on the runtime's blocking thread pool.
267///
268/// `spawn_blocking` returns a `JoinHandle` that resolves to the closure's
269/// output; we panic-propagate JoinErrors because they only happen when the
270/// runtime is shutting down — at which point the daemon is also shutting
271/// down and there's no audit log to write to anyway.
272async fn blocking<F, R>(f: F) -> R
273where
274    F: FnOnce() -> R + Send + 'static,
275    R: Send + 'static,
276{
277    tokio::task::spawn_blocking(f)
278        .await
279        .expect("audit-store blocking task panicked")
280}
281
282#[async_trait]
283impl AuditStore for SqliteStore {
284    async fn record(
285        &self,
286        transaction: NewTransaction,
287    ) -> Result<TransactionRecord, TransactionStoreError> {
288        let inner = Arc::clone(&self.inner);
289        blocking(move || inner.record(transaction)).await
290    }
291
292    async fn record_previewed(
293        &self,
294        transaction: NewTransaction,
295        preview: PreviewEnvelope,
296    ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
297        let inner = Arc::clone(&self.inner);
298        blocking(move || inner.record_previewed(transaction, preview)).await
299    }
300
301    async fn get(
302        &self,
303        transaction_id: &str,
304    ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
305        let inner = Arc::clone(&self.inner);
306        let id = transaction_id.to_string();
307        blocking(move || inner.get(&id)).await
308    }
309
310    async fn get_preview(
311        &self,
312        transaction_id: &str,
313    ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
314        let inner = Arc::clone(&self.inner);
315        let id = transaction_id.to_string();
316        blocking(move || inner.get_preview(&id)).await
317    }
318
319    async fn update_status(
320        &self,
321        transaction_id: &str,
322        new_status: JobState,
323    ) -> Result<(), TransactionStoreError> {
324        let inner = Arc::clone(&self.inner);
325        let id = transaction_id.to_string();
326        blocking(move || inner.update_status(&id, new_status)).await
327    }
328
329    async fn approve_transaction(
330        &self,
331        transaction_id: &str,
332        approver: CallerPrincipal,
333    ) -> Result<Option<String>, TransactionStoreError> {
334        let inner = Arc::clone(&self.inner);
335        let id = transaction_id.to_string();
336        blocking(move || inner.approve_transaction(&id, approver)).await
337    }
338
339    async fn revoke_unconsumed_approval(
340        &self,
341        transaction_id: &str,
342        revoker: CallerPrincipal,
343    ) -> Result<bool, TransactionStoreError> {
344        let inner = Arc::clone(&self.inner);
345        let id = transaction_id.to_string();
346        blocking(move || inner.revoke_unconsumed_approval(&id, revoker)).await
347    }
348
349    async fn claim_approved_for_execution(
350        &self,
351        transaction_id: &str,
352        receipt_digest: &str,
353        executor: CallerPrincipal,
354    ) -> Result<bool, TransactionStoreError> {
355        let inner = Arc::clone(&self.inner);
356        let id = transaction_id.to_string();
357        let digest = receipt_digest.to_string();
358        blocking(move || inner.claim_approved_for_execution(&id, &digest, executor)).await
359    }
360
361    async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
362        let inner = Arc::clone(&self.inner);
363        blocking(move || inner.cleanup_stale_queued()).await
364    }
365
366    async fn cancel_queued(
367        &self,
368        transaction_id: &str,
369        canceller: CallerPrincipal,
370    ) -> Result<bool, TransactionStoreError> {
371        let inner = Arc::clone(&self.inner);
372        let id = transaction_id.to_string();
373        blocking(move || inner.cancel_queued(&id, canceller)).await
374    }
375
376    async fn list_transactions(
377        &self,
378        limit: u32,
379        status_filter: Option<&str>,
380        action_filter: Option<&str>,
381        since_hours: Option<u32>,
382    ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
383        let inner = Arc::clone(&self.inner);
384        let status = status_filter.map(str::to_string);
385        let action = action_filter.map(str::to_string);
386        blocking(move || {
387            inner.list_transactions(limit, status.as_deref(), action.as_deref(), since_hours)
388        })
389        .await
390    }
391
392    async fn list_history(
393        &self,
394        limit: u32,
395        status_filter: Option<&str>,
396        action_filter: Option<&str>,
397        since_hours: Option<u32>,
398    ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError> {
399        let inner = Arc::clone(&self.inner);
400        let status = status_filter.map(str::to_string);
401        let action = action_filter.map(str::to_string);
402        blocking(move || {
403            inner.list_history(limit, status.as_deref(), action.as_deref(), since_hours)
404        })
405        .await
406    }
407
408    async fn fetch_chain_row(
409        &self,
410        transaction_id: &str,
411    ) -> Result<Option<ChainRow>, TransactionStoreError> {
412        let inner = Arc::clone(&self.inner);
413        let id = transaction_id.to_string();
414        blocking(move || inner.fetch_chain_row(&id)).await
415    }
416
417    async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
418        let inner = Arc::clone(&self.inner);
419        blocking(move || inner.fetch_chain_rows()).await
420    }
421
422    async fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
423        let inner = Arc::clone(&self.inner);
424        blocking(move || inner.fetch_event_rows()).await
425    }
426
427    async fn verify_audit_chain(
428        &self,
429        key: &AuditKey,
430    ) -> Result<VerifyOutcome, TransactionStoreError> {
431        // `key` is borrowed from the caller; we can't move it into a
432        // `'static` `spawn_blocking` closure. Verifying is a CPU-only signature
433        // walk over rows already fetched, so doing the work on the async
434        // thread (after the blocking fetch) is fine — no I/O risk.
435        let rows = self.fetch_chain_rows().await?;
436        Ok(crate::audit_chain::verify_chain(key, &rows))
437    }
438}
439
440// Re-export the postgres impl so call sites can `use store::PostgresStore`.
441pub use postgres::PostgresStore;
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use crate::audit_chain::AuditKey;
447    use crate::auth::CallerPrincipal;
448    use crate::transactions::NewTransaction;
449    use sysknife_types::{CallerRole, RiskLevel};
450    use tempfile::tempdir;
451
452    fn sqlite_store(path: std::path::PathBuf) -> SqliteStore {
453        let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
454        SqliteStore::new(crate::transactions::TransactionStore::open_with_key(path, key).unwrap())
455    }
456
457    /// `CHAIN_ROW_COLUMNS` is now declared once (store.rs), so there is no
458    /// second copy left to compare — the drift test that justified the hoist
459    /// ran green first, was mutated red on purpose (see #397's PR), and is
460    /// replaced by this pin. The invariant that matters is the one the
461    /// positional mapper (`chain_row_from_sqlite`) and the by-name mapper
462    /// (`row_to_chain_row`) both depend on: 17 columns, in this exact order.
463    /// Adding a column to the chain means updating this list deliberately,
464    /// together with both mappers.
465    #[test]
466    fn chain_row_columns_pinned() {
467        let columns: Vec<&str> = crate::store::CHAIN_ROW_COLUMNS
468            .split(',')
469            .map(str::trim)
470            .collect();
471        assert_eq!(
472            columns,
473            [
474                "seq",
475                "key_id",
476                "transaction_id",
477                "request_id",
478                "request_hash",
479                "action_name",
480                "risk_level",
481                "summary",
482                "approval_id",
483                "warnings_json",
484                "created_at",
485                "prev_chain_hash",
486                "chain_hash",
487                "chain_version",
488                "caller_role",
489                "event_tip",
490                "caller_principal",
491            ],
492            "CHAIN_ROW_COLUMNS changed — both ChainRow mappers read these \
493             columns in this order, so update them in the same change"
494        );
495        assert_eq!(columns.len(), 17, "column count drifted from 17");
496    }
497
498    fn new_tx(request_id: &str) -> NewTransaction {
499        NewTransaction {
500            request_id: request_id.to_string(),
501            request_hash: format!("hash-{request_id}"),
502            action_name: "UpdateSystem".to_string(),
503            risk_level: RiskLevel::High,
504            summary: "Upgrade the system".to_string(),
505            warnings: vec![],
506            caller_role: CallerRole::Dev,
507            caller_principal: CallerPrincipal::Uid(1000),
508        }
509    }
510
511    #[tokio::test]
512    async fn reconcile_interrupted_running_fails_only_running_rows() {
513        let dir = tempdir().unwrap();
514        let store = sqlite_store(dir.path().join("tx.db"));
515
516        // A row left Running by a crash, plus two rows in terminal/other states
517        // that reconciliation must not touch.
518        let running = store.record(new_tx("running")).await.unwrap();
519        store
520            .update_status(&running.transaction_id, JobState::Running)
521            .await
522            .unwrap();
523
524        let queued = store.record(new_tx("queued")).await.unwrap();
525
526        let succeeded = store.record(new_tx("succeeded")).await.unwrap();
527        store
528            .update_status(&succeeded.transaction_id, JobState::Running)
529            .await
530            .unwrap();
531        store
532            .update_status(&succeeded.transaction_id, JobState::Succeeded)
533            .await
534            .unwrap();
535
536        let reconciled = store.reconcile_interrupted_running().await.unwrap();
537        assert_eq!(reconciled, 1, "only the one Running row is reconciled");
538
539        assert_eq!(
540            store
541                .get(&running.transaction_id)
542                .await
543                .unwrap()
544                .unwrap()
545                .status,
546            JobState::Failed,
547            "orphaned Running row must become Failed"
548        );
549        assert_eq!(
550            store
551                .get(&queued.transaction_id)
552                .await
553                .unwrap()
554                .unwrap()
555                .status,
556            JobState::Queued,
557            "Queued row is untouched"
558        );
559        assert_eq!(
560            store
561                .get(&succeeded.transaction_id)
562                .await
563                .unwrap()
564                .unwrap()
565                .status,
566            JobState::Succeeded,
567            "terminal Succeeded row is untouched"
568        );
569
570        // Idempotent: a second pass finds nothing left to reconcile.
571        assert_eq!(store.reconcile_interrupted_running().await.unwrap(), 0);
572    }
573}