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