1use async_trait::async_trait;
30use sqlx_core::row::Row;
31use sqlx_postgres::{PgConnectOptions, PgPool, PgPoolOptions};
32use std::str::FromStr;
33use std::time::Duration;
34use subtle::ConstantTimeEq;
35use sysknife_types::{JobState, PreviewEnvelope, TransactionRecord};
36use uuid::Uuid;
37
38use crate::audit_chain::{
39 AttributionCensus, AuditEventKind, AuditKey, AuditVerification, BindingOutcome, ChainContent,
40 ChainIdentity, ChainRow, EventContent, EventIdentity, EventRow, VerifyOutcome, CURRENT_KEY_ID,
41};
42use crate::audit_watermark::emit_chain_tip_watermark;
43use crate::auth::CallerPrincipal;
44use crate::store::AuditStore;
45use crate::transactions::{
46 NewTransaction, RecordedPreviewedTransaction, TransactionStoreError,
47 APPROVAL_RECEIPT_TTL_MINUTES,
48};
49
50const MIGRATION_LOCK_ID: i64 = 0x5359_534b_4e49_4645;
51
52use crate::store::CHAIN_ROW_COLUMNS;
61
62struct Migration {
63 version: i64,
64 name: &'static str,
65 statements: &'static [&'static str],
66}
67
68const MIGRATIONS: &[Migration] = &[Migration {
69 version: 1,
70 name: "initial_audit_schema",
71 statements: &[
72 r#"
73 CREATE TABLE IF NOT EXISTS transactions (
74 transaction_id TEXT PRIMARY KEY,
75 request_id TEXT NOT NULL,
76 request_hash TEXT NOT NULL,
77 action_name TEXT NOT NULL,
78 risk_level TEXT NOT NULL,
79 status TEXT NOT NULL,
80 approval_id TEXT,
81 summary TEXT NOT NULL,
82 warnings_json TEXT NOT NULL,
83 created_at TEXT NOT NULL,
84 seq BIGINT NOT NULL UNIQUE,
85 key_id TEXT NOT NULL,
86 chain_hash TEXT NOT NULL,
87 prev_chain_hash TEXT NOT NULL DEFAULT ''
88 )
89 "#,
90 r#"
91 CREATE TABLE IF NOT EXISTS transaction_approvals (
92 transaction_id TEXT PRIMARY KEY,
93 receipt_digest TEXT NOT NULL,
94 approved_at TEXT NOT NULL,
95 consumed_at TEXT
96 )
97 "#,
98 r#"
99 CREATE TABLE IF NOT EXISTS transaction_previews (
100 transaction_id TEXT PRIMARY KEY,
101 preview_json TEXT NOT NULL
102 )
103 "#,
104 "CREATE INDEX IF NOT EXISTS transactions_seq_idx ON transactions(seq)",
105 ],
106},
107 Migration {
111 version: 2,
112 name: "caller_identity_and_approval_events",
113 statements: &[
114 "ALTER TABLE transactions ADD COLUMN IF NOT EXISTS chain_version BIGINT NOT NULL DEFAULT 1",
115 "ALTER TABLE transactions ADD COLUMN IF NOT EXISTS caller_role TEXT",
116 "ALTER TABLE transactions ADD COLUMN IF NOT EXISTS event_tip TEXT",
117 r#"
118 CREATE TABLE IF NOT EXISTS audit_events (
119 seq BIGINT PRIMARY KEY,
120 key_id TEXT NOT NULL,
121 kind TEXT NOT NULL,
122 transaction_id TEXT NOT NULL,
123 receipt_digest TEXT NOT NULL,
124 created_at TEXT NOT NULL,
125 chain_hash TEXT NOT NULL,
126 prev_chain_hash TEXT NOT NULL DEFAULT ''
127 )
128 "#,
129 "CREATE INDEX IF NOT EXISTS audit_events_transaction_idx ON audit_events(transaction_id)",
130 ],
131 },
132 Migration {
136 version: 3,
137 name: "caller_principal",
138 statements: &["ALTER TABLE transactions ADD COLUMN IF NOT EXISTS caller_principal TEXT"],
139 },
140 Migration {
146 version: 4,
147 name: "event_approver_identity",
148 statements: &[
149 "ALTER TABLE audit_events ADD COLUMN IF NOT EXISTS chain_version BIGINT NOT NULL DEFAULT 1",
150 "ALTER TABLE audit_events ADD COLUMN IF NOT EXISTS caller_principal TEXT",
151 ],
152 },
153];
154
155#[derive(Clone, Debug)]
158pub struct PostgresConfig {
159 pub url: String,
161 pub max_connections: u32,
164 pub acquire_timeout: Duration,
167 pub statement_cache_capacity: usize,
171}
172
173impl Default for PostgresConfig {
174 fn default() -> Self {
175 Self {
176 url: String::new(),
177 max_connections: 8,
178 acquire_timeout: Duration::from_secs(10),
179 statement_cache_capacity: 100,
180 }
181 }
182}
183
184#[derive(Debug, Clone)]
186pub struct PostgresStore {
187 pool: PgPool,
188 audit_key: std::sync::Arc<AuditKey>,
189}
190
191struct InsertedTransaction {
192 record: TransactionRecord,
193 seq: u64,
194 chain_hash: String,
195}
196
197impl PostgresStore {
198 async fn connect_pool(config: &PostgresConfig) -> Result<PgPool, TransactionStoreError> {
199 let mut connect_opts = PgConnectOptions::from_str(&config.url).map_err(|e| {
200 TransactionStoreError::Io(std::io::Error::new(
201 std::io::ErrorKind::InvalidInput,
202 format!("invalid postgres URL: {e}"),
203 ))
204 })?;
205 crate::pg_tls::require_tls_for_remote(&connect_opts).map_err(|msg| {
211 TransactionStoreError::Io(std::io::Error::new(
212 std::io::ErrorKind::PermissionDenied,
213 msg,
214 ))
215 })?;
216 connect_opts = connect_opts.statement_cache_capacity(config.statement_cache_capacity);
217 PgPoolOptions::new()
218 .max_connections(config.max_connections)
219 .acquire_timeout(config.acquire_timeout)
220 .connect_with(connect_opts)
221 .await
222 .map_err(map_sqlx_err)
223 }
224
225 pub async fn connect(
228 config: &PostgresConfig,
229 audit_key: std::sync::Arc<AuditKey>,
230 ) -> Result<Self, TransactionStoreError> {
231 let pool = Self::connect_pool(config).await?;
232
233 let store = Self { pool, audit_key };
234 store.initialize().await?;
235 Ok(store)
236 }
237
238 pub async fn verify_all_with_pubkey(
242 config: &PostgresConfig,
243 verifying_key_hex: &str,
244 ) -> Result<AuditVerification, TransactionStoreError> {
245 let pool = Self::connect_pool(config).await?;
246 let tx_rows = fetch_chain_rows_from_pool(&pool).await?;
247 let event_rows = match fetch_event_rows_from_pool(&pool).await {
248 Ok(rows) => rows,
249 Err(err) => {
250 match audit_events_table_absent(&pool).await {
267 Ok(true) => Vec::new(),
268 Ok(false) => {
269 return Ok(Self::unreadable_events(
270 verifying_key_hex,
271 &tx_rows,
272 format!("could not read the approval-event table: {err}"),
273 ))
274 }
275 Err(probe_err) => {
276 return Ok(Self::unreadable_events(
277 verifying_key_hex,
278 &tx_rows,
279 format!(
280 "could not read the approval-event table ({err}), and could not \
281 determine whether it exists ({probe_err})"
282 ),
283 ))
284 }
285 }
286 }
287 };
288 Ok(crate::audit_chain::verify_all_with_pubkey(
289 verifying_key_hex,
290 &tx_rows,
291 &event_rows,
292 ))
293 }
294
295 fn unreadable_events(
300 verifying_key_hex: &str,
301 tx_rows: &[ChainRow],
302 reason: String,
303 ) -> AuditVerification {
304 AuditVerification {
305 chain: crate::audit_chain::verify_chain_with_pubkey(verifying_key_hex, tx_rows),
306 events: VerifyOutcome::CannotVerify { reason },
307 binding: BindingOutcome::NotChecked,
308 attribution: Some(AttributionCensus::of(tx_rows)),
309 status: None,
310 }
311 }
312
313 pub async fn read_chain_rows(
317 config: &PostgresConfig,
318 ) -> Result<Vec<ChainRow>, TransactionStoreError> {
319 let pool = Self::connect_pool(config).await?;
320 fetch_chain_rows_from_pool(&pool).await
321 }
322
323 pub async fn verify_all(
325 &self,
326 key: &AuditKey,
327 ) -> Result<AuditVerification, TransactionStoreError> {
328 let tx_rows = fetch_chain_rows_from_pool(&self.pool).await?;
329 let event_rows = fetch_event_rows_from_pool(&self.pool).await?;
330 Ok(crate::audit_chain::verify_all(key, &tx_rows, &event_rows))
331 }
332
333 async fn initialize(&self) -> Result<(), TransactionStoreError> {
336 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
337 sqlx_core::query::query("SELECT pg_advisory_xact_lock($1)")
338 .bind(MIGRATION_LOCK_ID)
339 .execute(&mut *tx)
340 .await
341 .map_err(map_sqlx_err)?;
342
343 sqlx_core::query::query(
344 r#"
345 CREATE TABLE IF NOT EXISTS schema_migrations (
346 version BIGINT PRIMARY KEY,
347 name TEXT NOT NULL,
348 applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
349 )
350 "#,
351 )
352 .execute(&mut *tx)
353 .await
354 .map_err(map_sqlx_err)?;
355
356 let current: i64 = sqlx_core::query_scalar::query_scalar(
357 "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
358 )
359 .fetch_one(&mut *tx)
360 .await
361 .map_err(map_sqlx_err)?;
362 let latest = MIGRATIONS.last().map_or(0, |migration| migration.version);
363 if current > latest {
364 return Err(TransactionStoreError::DatabaseInvariant(format!(
365 "postgres schema version {current} is newer than this binary supports ({latest})"
366 )));
367 }
368
369 for migration in MIGRATIONS
370 .iter()
371 .filter(|migration| migration.version > current)
372 {
373 for statement in migration.statements {
376 sqlx_core::query::query(*statement)
377 .execute(&mut *tx)
378 .await
379 .map_err(map_sqlx_err)?;
380 }
381 sqlx_core::query::query(
382 "INSERT INTO schema_migrations (version, name) VALUES ($1, $2)",
383 )
384 .bind(migration.version)
385 .bind(migration.name)
386 .execute(&mut *tx)
387 .await
388 .map_err(map_sqlx_err)?;
389 }
390
391 tx.commit().await.map_err(map_sqlx_err)
392 }
393
394 async fn revoke_unconsumed_approval_in_tx(
395 tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
396 key: &AuditKey,
397 transaction_id: &str,
398 revoker_signed: &str,
399 ) -> Result<bool, TransactionStoreError> {
400 let digest: Option<String> = sqlx_core::query_scalar::query_scalar(
403 "SELECT receipt_digest FROM transaction_approvals \
404 WHERE transaction_id = $1 AND consumed_at IS NULL",
405 )
406 .bind(transaction_id)
407 .fetch_optional(&mut **tx)
408 .await
409 .map_err(map_sqlx_err)?;
410 let result = sqlx_core::query::query(
411 "DELETE FROM transaction_approvals \
412 WHERE transaction_id = $1 AND consumed_at IS NULL",
413 )
414 .bind(transaction_id)
415 .execute(&mut **tx)
416 .await
417 .map_err(map_sqlx_err)?;
418 if result.rows_affected() > 0 {
419 let digest = digest.ok_or_else(|| {
420 TransactionStoreError::DatabaseInvariant(format!(
421 "revoked an approval for {transaction_id} that had no receipt digest"
422 ))
423 })?;
424 append_event(
425 tx,
426 key,
427 AuditEventKind::ApprovalRevoked,
428 transaction_id,
429 &digest,
430 EventIdentity::V2 {
431 caller_principal: revoker_signed,
432 },
433 )
434 .await?;
435 }
436 Ok(result.rows_affected() > 0)
437 }
438}
439
440fn map_sqlx_err(e: sqlx_core::Error) -> TransactionStoreError {
445 let message = format!("postgres: {e}");
446 let invariant = match &e {
447 sqlx_core::Error::Database(db) => db
448 .code()
449 .is_some_and(|code| code.starts_with("22") || code.starts_with("23")),
450 sqlx_core::Error::TypeNotFound { .. }
451 | sqlx_core::Error::ColumnIndexOutOfBounds { .. }
452 | sqlx_core::Error::ColumnNotFound(_)
453 | sqlx_core::Error::ColumnDecode { .. }
454 | sqlx_core::Error::Decode(_) => true,
455 _ => false,
456 };
457 if invariant {
458 TransactionStoreError::DatabaseInvariant(message)
459 } else {
460 TransactionStoreError::Io(std::io::Error::other(message))
461 }
462}
463
464fn serialize<T: serde::Serialize>(v: &T) -> Result<String, TransactionStoreError> {
465 serde_json::to_string(v).map_err(TransactionStoreError::Json)
466}
467
468fn deserialize<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, TransactionStoreError> {
469 serde_json::from_str(s).map_err(TransactionStoreError::Json)
470}
471
472fn now_iso() -> String {
480 chrono::Utc::now()
481 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
482 .to_string()
483}
484
485#[async_trait]
490impl AuditStore for PostgresStore {
491 async fn record(
492 &self,
493 transaction: NewTransaction,
494 ) -> Result<TransactionRecord, TransactionStoreError> {
495 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
496 let transaction_id = Uuid::new_v4().to_string();
497 let inserted =
498 insert_transaction(&mut tx, &self.audit_key, &transaction_id, transaction).await?;
499 tx.commit().await.map_err(map_sqlx_err)?;
500 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
501 Ok(inserted.record)
502 }
503
504 async fn record_previewed(
505 &self,
506 transaction: NewTransaction,
507 preview: PreviewEnvelope,
508 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
509 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
510 let transaction_id = Uuid::new_v4().to_string();
511 let inserted =
512 insert_transaction(&mut tx, &self.audit_key, &transaction_id, transaction).await?;
513
514 sqlx_core::query::query(
515 "INSERT INTO transaction_previews (transaction_id, preview_json) VALUES ($1, $2)",
516 )
517 .bind(&inserted.record.transaction_id)
518 .bind(serialize(&preview)?)
519 .execute(&mut *tx)
520 .await
521 .map_err(map_sqlx_err)?;
522
523 tx.commit().await.map_err(map_sqlx_err)?;
524 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
525 Ok(RecordedPreviewedTransaction {
526 transaction: inserted.record,
527 preview,
528 })
529 }
530
531 async fn get(
532 &self,
533 transaction_id: &str,
534 ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
535 let row = sqlx_core::query::query(
536 "SELECT transaction_id, request_id, request_hash, action_name, risk_level, \
537 status, approval_id, summary, warnings_json \
538 FROM transactions WHERE transaction_id = $1",
539 )
540 .bind(transaction_id)
541 .fetch_optional(&self.pool)
542 .await
543 .map_err(map_sqlx_err)?;
544 row.map(row_to_record).transpose()
545 }
546
547 async fn get_preview(
548 &self,
549 transaction_id: &str,
550 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
551 let row = sqlx_core::query::query(
552 "SELECT preview_json FROM transaction_previews WHERE transaction_id = $1",
553 )
554 .bind(transaction_id)
555 .fetch_optional(&self.pool)
556 .await
557 .map_err(map_sqlx_err)?;
558 match row {
559 Some(r) => {
560 let s: String = r.try_get("preview_json").map_err(map_sqlx_err)?;
561 Ok(Some(deserialize(&s)?))
562 }
563 None => Ok(None),
564 }
565 }
566
567 async fn update_status(
568 &self,
569 transaction_id: &str,
570 new_status: JobState,
571 ) -> Result<(), TransactionStoreError> {
572 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
581 let current_str: Option<String> = sqlx_core::query_scalar::query_scalar(
582 "SELECT status FROM transactions WHERE transaction_id = $1 FOR UPDATE",
583 )
584 .bind(transaction_id)
585 .fetch_optional(&mut *tx)
586 .await
587 .map_err(map_sqlx_err)?;
588 let current_str = current_str
589 .ok_or_else(|| TransactionStoreError::NotFound(transaction_id.to_string()))?;
590 let current: JobState = deserialize(¤t_str)?;
591 if !crate::jobs::allowed_transition(¤t, &new_status) {
592 return Err(TransactionStoreError::InvalidTransition {
593 from: current,
594 to: new_status,
595 });
596 }
597 sqlx_core::query::query("UPDATE transactions SET status = $1 WHERE transaction_id = $2")
598 .bind(serialize(&new_status)?)
599 .bind(transaction_id)
600 .execute(&mut *tx)
601 .await
602 .map_err(map_sqlx_err)?;
603 tx.commit().await.map_err(map_sqlx_err)?;
604 Ok(())
605 }
606
607 async fn approve_transaction(
608 &self,
609 transaction_id: &str,
610 approver: CallerPrincipal,
611 ) -> Result<Option<String>, TransactionStoreError> {
612 let approver_signed = approver.as_signed_str();
615 let row = sqlx_core::query::query(
616 "SELECT request_hash, approval_id FROM transactions WHERE transaction_id = $1",
617 )
618 .bind(transaction_id)
619 .fetch_optional(&self.pool)
620 .await
621 .map_err(map_sqlx_err)?;
622 let Some(row) = row else {
623 return Ok(None);
624 };
625 let request_hash: String = row.try_get("request_hash").map_err(map_sqlx_err)?;
626 let committed_digest: Option<String> = row.try_get("approval_id").map_err(map_sqlx_err)?;
627 let receipt = self
628 .audit_key
629 .approval_receipt(transaction_id, &request_hash);
630 let receipt_digest = crate::audit_chain::approval_receipt_digest(&receipt);
631 let commitment_matches = committed_digest.as_deref().is_some_and(|committed| {
632 bool::from(receipt_digest.as_bytes().ct_eq(committed.as_bytes()))
633 });
634 if !commitment_matches {
635 return Err(TransactionStoreError::DatabaseInvariant(format!(
636 "transaction {transaction_id} approval commitment does not match its signed preview"
637 )));
638 }
639 let queued = serialize(&JobState::Queued)?;
640 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
643 let result = sqlx_core::query::query(
644 sqlx_core::sql_str::AssertSqlSafe(format!(
645 "INSERT INTO transaction_approvals \
646 (transaction_id, receipt_digest, approved_at) \
647 SELECT transaction_id, $1, $2 FROM transactions \
648 WHERE transaction_id = $3 \
649 AND status = $4 \
650 AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
651 ON CONFLICT (transaction_id) DO NOTHING"
652 )),
653 )
654 .bind(&receipt_digest)
655 .bind(now_iso())
656 .bind(transaction_id)
657 .bind(&queued)
658 .execute(&mut *tx)
659 .await
660 .map_err(map_sqlx_err)?;
661 if result.rows_affected() > 0 {
662 append_event(
663 &mut tx,
664 &self.audit_key,
665 AuditEventKind::ApprovalGranted,
666 transaction_id,
667 &receipt_digest,
668 EventIdentity::V2 {
669 caller_principal: &approver_signed,
670 },
671 )
672 .await?;
673 }
674 tx.commit().await.map_err(map_sqlx_err)?;
675 Ok((result.rows_affected() > 0).then_some(receipt))
676 }
677
678 async fn revoke_unconsumed_approval(
679 &self,
680 transaction_id: &str,
681 revoker: CallerPrincipal,
682 ) -> Result<bool, TransactionStoreError> {
683 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
684 let revoked = Self::revoke_unconsumed_approval_in_tx(
685 &mut tx,
686 &self.audit_key,
687 transaction_id,
688 &revoker.as_signed_str(),
689 )
690 .await?;
691 tx.commit().await.map_err(map_sqlx_err)?;
692 Ok(revoked)
693 }
694
695 async fn claim_approved_for_execution(
696 &self,
697 transaction_id: &str,
698 receipt_digest: &str,
699 executor: CallerPrincipal,
700 ) -> Result<bool, TransactionStoreError> {
701 let queued = serialize(&JobState::Queued)?;
702 let running = serialize(&JobState::Running)?;
703 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
704 let result = sqlx_core::query::query(
705 sqlx_core::sql_str::AssertSqlSafe(format!(
706 "UPDATE transactions SET status = $1 \
707 WHERE transaction_id = $2 \
708 AND status = $3 \
709 AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
710 AND EXISTS ( \
711 SELECT 1 FROM transaction_approvals \
712 WHERE transaction_id = $2 \
713 AND receipt_digest = $4 \
714 AND consumed_at IS NULL \
715 )"
716 )),
717 )
718 .bind(&running)
719 .bind(transaction_id)
720 .bind(&queued)
721 .bind(receipt_digest)
722 .execute(&mut *tx)
723 .await
724 .map_err(map_sqlx_err)?;
725 if result.rows_affected() > 0 {
726 sqlx_core::query::query(
727 "UPDATE transaction_approvals SET consumed_at = $1 \
728 WHERE transaction_id = $2 AND consumed_at IS NULL",
729 )
730 .bind(now_iso())
731 .bind(transaction_id)
732 .execute(&mut *tx)
733 .await
734 .map_err(map_sqlx_err)?;
735 append_event(
736 &mut tx,
737 &self.audit_key,
738 AuditEventKind::ApprovalConsumed,
739 transaction_id,
740 receipt_digest,
741 EventIdentity::V2 {
742 caller_principal: &executor.as_signed_str(),
743 },
744 )
745 .await?;
746 }
747 tx.commit().await.map_err(map_sqlx_err)?;
748 Ok(result.rows_affected() > 0)
749 }
750
751 async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
752 let queued = serialize(&JobState::Queued)?;
753 let canceled = serialize(&JobState::Canceled)?;
754 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
755 let stale_ids: Vec<String> = sqlx_core::query_scalar::query_scalar(
756 sqlx_core::sql_str::AssertSqlSafe(format!(
757 "SELECT transaction_id FROM transactions \
758 WHERE status = $1 \
759 AND created_at::timestamptz <= now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes'"
760 )),
761 )
762 .bind(&queued)
763 .fetch_all(&mut *tx)
764 .await
765 .map_err(map_sqlx_err)?;
766 let mut canceled_count = 0;
767 for transaction_id in stale_ids {
768 let result = sqlx_core::query::query(
769 "UPDATE transactions SET status = $1 \
770 WHERE transaction_id = $2 AND status = $3",
771 )
772 .bind(&canceled)
773 .bind(&transaction_id)
774 .bind(&queued)
775 .execute(&mut *tx)
776 .await
777 .map_err(map_sqlx_err)?;
778 if result.rows_affected() > 0 {
779 Self::revoke_unconsumed_approval_in_tx(
783 &mut tx,
784 &self.audit_key,
785 &transaction_id,
786 &CallerPrincipal::Unattributed.as_signed_str(),
787 )
788 .await?;
789 canceled_count += result.rows_affected();
790 }
791 }
792 tx.commit().await.map_err(map_sqlx_err)?;
793 Ok(canceled_count)
794 }
795
796 async fn cancel_queued(
797 &self,
798 transaction_id: &str,
799 canceller: CallerPrincipal,
800 ) -> Result<bool, TransactionStoreError> {
801 let queued = serialize(&JobState::Queued)?;
804 let canceled = serialize(&JobState::Canceled)?;
805 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
806 let result = sqlx_core::query::query(
807 "UPDATE transactions SET status = $1 \
808 WHERE transaction_id = $2 AND status = $3",
809 )
810 .bind(&canceled)
811 .bind(transaction_id)
812 .bind(&queued)
813 .execute(&mut *tx)
814 .await
815 .map_err(map_sqlx_err)?;
816 if result.rows_affected() > 0 {
817 Self::revoke_unconsumed_approval_in_tx(
820 &mut tx,
821 &self.audit_key,
822 transaction_id,
823 &canceller.as_signed_str(),
824 )
825 .await?;
826 }
827 tx.commit().await.map_err(map_sqlx_err)?;
828 Ok(result.rows_affected() > 0)
829 }
830
831 async fn list_transactions(
832 &self,
833 limit: u32,
834 status_filter: Option<&str>,
835 action_filter: Option<&str>,
836 since_hours: Option<u32>,
837 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
838 let limit = limit.min(100) as i64;
839
840 let validated_status: Option<String> = status_filter
843 .map(|s| -> Result<String, TransactionStoreError> {
844 let parsed: JobState = deserialize(&format!("\"{s}\""))?;
845 serialize(&parsed)
846 })
847 .transpose()?;
848
849 let mut sql = String::from(
850 "SELECT transaction_id, request_id, request_hash, action_name, risk_level, \
851 status, approval_id, summary, warnings_json \
852 FROM transactions WHERE TRUE",
853 );
854 let mut idx = 1;
855 if validated_status.is_some() {
856 sql.push_str(&format!(" AND status = ${idx}"));
857 idx += 1;
858 }
859 if action_filter.is_some() {
860 sql.push_str(&format!(" AND action_name = ${idx}"));
861 idx += 1;
862 }
863 if since_hours.is_some() {
864 sql.push_str(&format!(
865 " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
866 ));
867 idx += 1;
868 }
869 sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
870
871 let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
872 if let Some(s) = &validated_status {
873 q = q.bind(s);
874 }
875 if let Some(a) = action_filter {
876 q = q.bind(a);
877 }
878 if let Some(h) = since_hours {
879 q = q.bind(h.to_string());
880 }
881 q = q.bind(limit);
882
883 let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
884 rows.into_iter().map(row_to_record).collect()
885 }
886
887 async fn list_history(
888 &self,
889 limit: u32,
890 status_filter: Option<&str>,
891 action_filter: Option<&str>,
892 since_hours: Option<u32>,
893 ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError> {
894 let limit = limit.min(100) as i64;
897 let validated_status: Option<String> = status_filter
898 .map(|s| -> Result<String, TransactionStoreError> {
899 let parsed: JobState = deserialize(&format!("\"{s}\""))?;
900 serialize(&parsed)
901 })
902 .transpose()?;
903
904 let mut sql = String::from(
905 "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
906 FROM transactions WHERE TRUE",
907 );
908 let mut idx = 1;
909 if validated_status.is_some() {
910 sql.push_str(&format!(" AND status = ${idx}"));
911 idx += 1;
912 }
913 if action_filter.is_some() {
914 sql.push_str(&format!(" AND action_name = ${idx}"));
915 idx += 1;
916 }
917 if since_hours.is_some() {
918 sql.push_str(&format!(
919 " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
920 ));
921 idx += 1;
922 }
923 sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
924
925 let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
926 if let Some(s) = &validated_status {
927 q = q.bind(s);
928 }
929 if let Some(a) = action_filter {
930 q = q.bind(a);
931 }
932 if let Some(h) = since_hours {
933 q = q.bind(h.to_string());
934 }
935 q = q.bind(limit);
936
937 let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
938 rows.into_iter().map(row_to_history_entry).collect()
939 }
940
941 async fn fetch_chain_row(
942 &self,
943 transaction_id: &str,
944 ) -> Result<Option<ChainRow>, TransactionStoreError> {
945 let row = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(format!(
946 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions WHERE transaction_id = $1"
947 )))
948 .bind(transaction_id)
949 .fetch_optional(&self.pool)
950 .await
951 .map_err(map_sqlx_err)?;
952 row.map(row_to_chain_row).transpose()
953 }
954
955 async fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
956 fetch_event_rows_from_pool(&self.pool).await
957 }
958
959 async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
960 fetch_chain_rows_from_pool(&self.pool).await
961 }
962
963 async fn verify_audit_chain(
964 &self,
965 key: &AuditKey,
966 ) -> Result<VerifyOutcome, TransactionStoreError> {
967 let rows = self.fetch_chain_rows().await?;
968 Ok(crate::audit_chain::verify_chain(key, &rows))
969 }
970}
971
972async fn fetch_chain_rows_from_pool(pool: &PgPool) -> Result<Vec<ChainRow>, TransactionStoreError> {
973 let rows = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(format!(
974 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions ORDER BY seq ASC"
975 )))
976 .fetch_all(pool)
977 .await
978 .map_err(map_sqlx_err)?;
979 rows.into_iter().map(row_to_chain_row).collect()
980}
981
982async fn audit_events_table_absent(pool: &PgPool) -> Result<bool, TransactionStoreError> {
989 let present: Option<String> =
990 sqlx_core::query_scalar::query_scalar("SELECT to_regclass('audit_events')::text")
991 .fetch_one(pool)
992 .await
993 .map_err(map_sqlx_err)?;
994 Ok(present.is_none())
995}
996
997async fn fetch_event_rows_from_pool(pool: &PgPool) -> Result<Vec<EventRow>, TransactionStoreError> {
999 let rows = sqlx_core::query::query(
1000 "SELECT seq, key_id, kind, transaction_id, receipt_digest, \
1001 created_at, prev_chain_hash, chain_hash, chain_version, caller_principal \
1002 FROM audit_events ORDER BY seq ASC",
1003 )
1004 .fetch_all(pool)
1005 .await
1006 .map_err(map_sqlx_err)?;
1007 rows.into_iter().map(row_to_event_row).collect()
1008}
1009
1010async fn append_event(
1016 tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
1017 key: &AuditKey,
1018 kind: AuditEventKind,
1019 transaction_id: &str,
1020 receipt_digest: &str,
1021 identity: EventIdentity<'_>,
1022) -> Result<(), TransactionStoreError> {
1023 let prev: Option<(i64, String)> = sqlx_core::query_as::query_as(
1024 "SELECT seq, chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1 FOR UPDATE",
1025 )
1026 .fetch_optional(&mut **tx)
1027 .await
1028 .map_err(map_sqlx_err)?;
1029 let (seq, prev_chain_hash) = match prev {
1030 Some((s, h)) => ((s as u64) + 1, h),
1031 None => (1, String::new()),
1032 };
1033 let created_at = now_iso();
1034 let key_id = CURRENT_KEY_ID.to_string();
1035 let content = EventContent {
1039 seq,
1040 key_id: &key_id,
1041 kind,
1042 transaction_id,
1043 receipt_digest,
1044 created_at: &created_at,
1045 identity,
1046 };
1047 let chain_hash = key.event_hash(&content, &prev_chain_hash);
1048 let stored_principal = match identity {
1049 EventIdentity::V2 { caller_principal } => Some(caller_principal),
1050 EventIdentity::LegacyV1 => None,
1051 };
1052 sqlx_core::query::query(
1053 "INSERT INTO audit_events ( \
1054 seq, key_id, kind, transaction_id, receipt_digest, \
1055 created_at, chain_hash, prev_chain_hash, chain_version, caller_principal \
1056 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
1057 )
1058 .bind(seq as i64)
1059 .bind(&key_id)
1060 .bind(kind.as_str())
1061 .bind(transaction_id)
1062 .bind(receipt_digest)
1063 .bind(&created_at)
1064 .bind(&chain_hash)
1065 .bind(&prev_chain_hash)
1066 .bind(identity.version() as i64)
1067 .bind(stored_principal)
1068 .execute(&mut **tx)
1069 .await
1070 .map_err(map_sqlx_err)?;
1071 Ok(())
1072}
1073
1074async fn insert_transaction(
1079 tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
1080 key: &AuditKey,
1081 transaction_id: &str,
1082 transaction: NewTransaction,
1083) -> Result<InsertedTransaction, TransactionStoreError> {
1084 let warnings_json = serialize(&transaction.warnings)?;
1085 let status = JobState::Queued;
1086 let created_at = now_iso();
1087 let key_id = CURRENT_KEY_ID.to_string();
1088 let approval_id = Some(key.approval_commitment(transaction_id, &transaction.request_hash));
1091
1092 let prev: Option<(i64, String)> = sqlx_core::query_as::query_as(
1098 "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1 FOR UPDATE",
1099 )
1100 .fetch_optional(&mut **tx)
1101 .await
1102 .map_err(map_sqlx_err)?;
1103 let (seq, prev_chain_hash) = match prev {
1104 Some((s, h)) => ((s as u64) + 1, h),
1105 None => (1, String::new()),
1106 };
1107
1108 let event_tip: String = sqlx_core::query_scalar::query_scalar(
1111 "SELECT COALESCE((SELECT chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1), '')",
1112 )
1113 .fetch_one(&mut **tx)
1114 .await
1115 .map_err(map_sqlx_err)?;
1116 let caller_role = transaction.caller_role.as_str();
1117 let caller_principal = transaction.caller_principal.as_signed_str();
1118
1119 let identity = ChainIdentity::V3 {
1122 caller_role,
1123 event_tip: &event_tip,
1124 caller_principal: &caller_principal,
1125 };
1126 let chain_hash = key.chain_hash(
1127 &ChainContent {
1128 seq,
1129 key_id: &key_id,
1130 transaction_id,
1131 request_id: &transaction.request_id,
1132 request_hash: &transaction.request_hash,
1133 action_name: &transaction.action_name,
1134 risk_level: transaction.risk_level,
1135 summary: &transaction.summary,
1136 approval_id: approval_id.as_deref(),
1137 warnings_json: &warnings_json,
1138 created_at: &created_at,
1139 identity,
1140 },
1141 &prev_chain_hash,
1142 );
1143
1144 sqlx_core::query::query(
1145 "INSERT INTO transactions ( \
1146 transaction_id, request_id, request_hash, action_name, risk_level, \
1147 status, approval_id, summary, warnings_json, created_at, \
1148 seq, key_id, chain_hash, prev_chain_hash, \
1149 chain_version, caller_role, event_tip, caller_principal \
1150 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)",
1151 )
1152 .bind(transaction_id)
1153 .bind(&transaction.request_id)
1154 .bind(&transaction.request_hash)
1155 .bind(&transaction.action_name)
1156 .bind(serialize(&transaction.risk_level)?)
1157 .bind(serialize(&status)?)
1158 .bind(&approval_id)
1159 .bind(&transaction.summary)
1160 .bind(&warnings_json)
1161 .bind(&created_at)
1162 .bind(seq as i64)
1163 .bind(&key_id)
1164 .bind(&chain_hash)
1165 .bind(&prev_chain_hash)
1166 .bind(i64::from(identity.version()))
1167 .bind(caller_role)
1168 .bind(&event_tip)
1169 .bind(&caller_principal)
1170 .execute(&mut **tx)
1171 .await
1172 .map_err(map_sqlx_err)?;
1173
1174 Ok(InsertedTransaction {
1175 record: TransactionRecord {
1176 transaction_id: transaction_id.to_string(),
1177 request_id: transaction.request_id,
1178 request_hash: transaction.request_hash,
1179 action_name: transaction.action_name,
1180 risk_level: transaction.risk_level,
1181 status,
1182 approval_id,
1183 summary: transaction.summary,
1184 warnings: transaction.warnings,
1185 },
1186 seq,
1187 chain_hash,
1188 })
1189}
1190
1191fn row_to_history_entry(
1196 row: sqlx_postgres::PgRow,
1197) -> Result<crate::transactions::JobHistoryEntry, TransactionStoreError> {
1198 Ok(crate::transactions::JobHistoryEntry {
1199 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1200 action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
1201 risk_level: deserialize(
1202 &row.try_get::<String, _>("risk_level")
1203 .map_err(map_sqlx_err)?,
1204 )?,
1205 status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
1206 summary: row.try_get("summary").map_err(map_sqlx_err)?,
1207 created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1208 })
1209}
1210
1211fn row_to_record(row: sqlx_postgres::PgRow) -> Result<TransactionRecord, TransactionStoreError> {
1212 Ok(TransactionRecord {
1213 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1214 request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
1215 request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
1216 action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
1217 risk_level: deserialize(
1218 &row.try_get::<String, _>("risk_level")
1219 .map_err(map_sqlx_err)?,
1220 )?,
1221 status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
1222 approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
1223 summary: row.try_get("summary").map_err(map_sqlx_err)?,
1224 warnings: deserialize(
1225 &row.try_get::<String, _>("warnings_json")
1226 .map_err(map_sqlx_err)?,
1227 )?,
1228 })
1229}
1230
1231fn row_to_chain_row(row: sqlx_postgres::PgRow) -> Result<ChainRow, TransactionStoreError> {
1232 Ok(ChainRow {
1233 seq: row.try_get::<i64, _>("seq").map_err(map_sqlx_err)? as u64,
1234 key_id: row.try_get("key_id").map_err(map_sqlx_err)?,
1235 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1236 request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
1237 request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
1238 action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
1239 risk_level: deserialize(
1240 &row.try_get::<String, _>("risk_level")
1241 .map_err(map_sqlx_err)?,
1242 )?,
1243 summary: row.try_get("summary").map_err(map_sqlx_err)?,
1244 approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
1245 warnings_json: row.try_get("warnings_json").map_err(map_sqlx_err)?,
1246 created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1247 prev_chain_hash: row.try_get("prev_chain_hash").map_err(map_sqlx_err)?,
1248 chain_hash: row.try_get("chain_hash").map_err(map_sqlx_err)?,
1249 chain_version: row
1250 .try_get::<i64, _>("chain_version")
1251 .map_err(map_sqlx_err)? as u32,
1252 caller_role: row.try_get("caller_role").map_err(map_sqlx_err)?,
1253 event_tip: row.try_get("event_tip").map_err(map_sqlx_err)?,
1254 caller_principal: row.try_get("caller_principal").map_err(map_sqlx_err)?,
1255 })
1256}
1257
1258fn row_to_event_row(row: sqlx_postgres::PgRow) -> Result<EventRow, TransactionStoreError> {
1259 Ok(EventRow {
1260 seq: row.try_get::<i64, _>("seq").map_err(map_sqlx_err)? as u64,
1261 key_id: row.try_get("key_id").map_err(map_sqlx_err)?,
1262 kind: row.try_get("kind").map_err(map_sqlx_err)?,
1263 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1264 receipt_digest: row.try_get("receipt_digest").map_err(map_sqlx_err)?,
1265 created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1266 prev_chain_hash: row.try_get("prev_chain_hash").map_err(map_sqlx_err)?,
1267 chain_hash: row.try_get("chain_hash").map_err(map_sqlx_err)?,
1268 chain_version: row
1269 .try_get::<i64, _>("chain_version")
1270 .map_err(map_sqlx_err)? as u32,
1271 caller_principal: row.try_get("caller_principal").map_err(map_sqlx_err)?,
1272 })
1273}
1274
1275#[cfg(test)]
1276mod tests {
1277 use super::*;
1278
1279 #[test]
1282 fn default_config_keeps_statement_cache_enabled() {
1283 let c = PostgresConfig::default();
1284 assert!(c.statement_cache_capacity > 0);
1285 assert!(c.max_connections >= 4);
1286 assert!(c.acquire_timeout >= Duration::from_secs(5));
1287 }
1288
1289 #[test]
1290 fn url_parsing_accepts_standard_postgres_url() {
1291 let opts = PgConnectOptions::from_str(
1292 "postgres://user:pass@host.example.com:5432/audit?sslmode=require",
1293 )
1294 .expect("standard URL parses");
1295 let _ = opts.statement_cache_capacity(0);
1297 }
1298
1299 #[test]
1300 fn now_iso_produces_a_parseable_rfc3339_timestamp() {
1301 let ts = now_iso();
1308 let parsed = chrono::DateTime::parse_from_rfc3339(&ts);
1309 assert!(
1310 parsed.is_ok(),
1311 "now_iso() must emit a parseable RFC 3339 timestamp, got {ts:?} ({parsed:?})"
1312 );
1313 assert_eq!(
1314 ts.len(),
1315 "2026-07-27T12:34:56.789Z".len(),
1316 "timestamp shape changed unexpectedly: {ts}"
1317 );
1318 }
1319
1320 #[test]
1321 fn now_iso_matches_the_sqlite_backends_timestamp_shape() {
1322 let ts = now_iso();
1328 let (date, rest) = ts.split_once('T').expect("timestamp has a T separator");
1329 assert_eq!(date.len(), "2026-07-27".len(), "date part: {ts}");
1330 assert!(rest.ends_with('Z'), "must be UTC with a Z suffix: {ts}");
1331 let time = rest.trim_end_matches('Z');
1332 let fields: Vec<&str> = time.split(':').collect();
1333 assert_eq!(fields.len(), 3, "expected HH:MM:SS.mmm, got {time:?}");
1334 assert!(
1335 fields[2].contains('.'),
1336 "seconds field must carry fractional milliseconds: {time:?}"
1337 );
1338 let (secs, millis) = fields[2].split_once('.').expect("checked above");
1339 assert_eq!(secs.len(), 2, "seconds must be two digits: {time:?}");
1340 assert_eq!(millis.len(), 3, "expected millisecond precision: {time:?}");
1341 }
1342
1343 #[test]
1344 fn schema_decode_errors_are_classified_as_invariant_failures() {
1345 let error = map_sqlx_err(sqlx_core::Error::ColumnNotFound("chain_hash".to_string()));
1346 assert!(matches!(error, TransactionStoreError::DatabaseInvariant(_)));
1347 }
1348}