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