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 HistorySince, 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: Option<HistorySince>,
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 match since {
864 Some(HistorySince::Hours(_)) => {
865 sql.push_str(&format!(
866 " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
867 ));
868 idx += 1;
869 }
870 Some(HistorySince::Cutoff(_)) => {
871 sql.push_str(&format!(
872 " AND created_at::timestamptz >= ${idx}::timestamptz"
873 ));
874 idx += 1;
875 }
876 None => {}
877 }
878 sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
879
880 let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
881 if let Some(s) = &validated_status {
882 q = q.bind(s);
883 }
884 if let Some(a) = action_filter {
885 q = q.bind(a);
886 }
887 match since {
888 Some(HistorySince::Hours(h)) => q = q.bind(h.to_string()),
889 Some(HistorySince::Cutoff(at)) => q = q.bind(HistorySince::cutoff_rfc3339(&at)),
890 None => {}
891 }
892 q = q.bind(limit);
893
894 let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
895 rows.into_iter().map(row_to_record).collect()
896 }
897
898 async fn list_history(
899 &self,
900 limit: u32,
901 status_filter: Option<&str>,
902 action_filter: Option<&str>,
903 since: Option<HistorySince>,
904 ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError> {
905 let limit = limit.min(100) as i64;
908 let validated_status: Option<String> = status_filter
909 .map(|s| -> Result<String, TransactionStoreError> {
910 let parsed: JobState = deserialize(&format!("\"{s}\""))?;
911 serialize(&parsed)
912 })
913 .transpose()?;
914
915 let mut sql = String::from(
916 "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
917 FROM transactions WHERE TRUE",
918 );
919 let mut idx = 1;
920 if validated_status.is_some() {
921 sql.push_str(&format!(" AND status = ${idx}"));
922 idx += 1;
923 }
924 if action_filter.is_some() {
925 sql.push_str(&format!(" AND action_name = ${idx}"));
926 idx += 1;
927 }
928 match since {
929 Some(HistorySince::Hours(_)) => {
930 sql.push_str(&format!(
931 " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
932 ));
933 idx += 1;
934 }
935 Some(HistorySince::Cutoff(_)) => {
936 sql.push_str(&format!(
937 " AND created_at::timestamptz >= ${idx}::timestamptz"
938 ));
939 idx += 1;
940 }
941 None => {}
942 }
943 sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
944
945 let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
946 if let Some(s) = &validated_status {
947 q = q.bind(s);
948 }
949 if let Some(a) = action_filter {
950 q = q.bind(a);
951 }
952 match since {
953 Some(HistorySince::Hours(h)) => q = q.bind(h.to_string()),
954 Some(HistorySince::Cutoff(at)) => q = q.bind(HistorySince::cutoff_rfc3339(&at)),
955 None => {}
956 }
957 q = q.bind(limit);
958
959 let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
960 rows.into_iter().map(row_to_history_entry).collect()
961 }
962
963 async fn fetch_chain_row(
964 &self,
965 transaction_id: &str,
966 ) -> Result<Option<ChainRow>, TransactionStoreError> {
967 let row = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(format!(
968 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions WHERE transaction_id = $1"
969 )))
970 .bind(transaction_id)
971 .fetch_optional(&self.pool)
972 .await
973 .map_err(map_sqlx_err)?;
974 row.map(row_to_chain_row).transpose()
975 }
976
977 async fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
978 fetch_event_rows_from_pool(&self.pool).await
979 }
980
981 async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
982 fetch_chain_rows_from_pool(&self.pool).await
983 }
984
985 async fn verify_audit_chain(
986 &self,
987 key: &AuditKey,
988 ) -> Result<VerifyOutcome, TransactionStoreError> {
989 let rows = self.fetch_chain_rows().await?;
990 Ok(crate::audit_chain::verify_chain(key, &rows))
991 }
992}
993
994async fn fetch_chain_rows_from_pool(pool: &PgPool) -> Result<Vec<ChainRow>, TransactionStoreError> {
995 let rows = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(format!(
996 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions ORDER BY seq ASC"
997 )))
998 .fetch_all(pool)
999 .await
1000 .map_err(map_sqlx_err)?;
1001 rows.into_iter().map(row_to_chain_row).collect()
1002}
1003
1004async fn audit_events_table_absent(pool: &PgPool) -> Result<bool, TransactionStoreError> {
1011 let present: Option<String> =
1012 sqlx_core::query_scalar::query_scalar("SELECT to_regclass('audit_events')::text")
1013 .fetch_one(pool)
1014 .await
1015 .map_err(map_sqlx_err)?;
1016 Ok(present.is_none())
1017}
1018
1019async fn fetch_event_rows_from_pool(pool: &PgPool) -> Result<Vec<EventRow>, TransactionStoreError> {
1021 let rows = sqlx_core::query::query(
1022 "SELECT seq, key_id, kind, transaction_id, receipt_digest, \
1023 created_at, prev_chain_hash, chain_hash, chain_version, caller_principal \
1024 FROM audit_events ORDER BY seq ASC",
1025 )
1026 .fetch_all(pool)
1027 .await
1028 .map_err(map_sqlx_err)?;
1029 rows.into_iter().map(row_to_event_row).collect()
1030}
1031
1032async fn append_event(
1038 tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
1039 key: &AuditKey,
1040 kind: AuditEventKind,
1041 transaction_id: &str,
1042 receipt_digest: &str,
1043 identity: EventIdentity<'_>,
1044) -> Result<(), TransactionStoreError> {
1045 let prev: Option<(i64, String)> = sqlx_core::query_as::query_as(
1046 "SELECT seq, chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1 FOR UPDATE",
1047 )
1048 .fetch_optional(&mut **tx)
1049 .await
1050 .map_err(map_sqlx_err)?;
1051 let (seq, prev_chain_hash) = match prev {
1052 Some((s, h)) => ((s as u64) + 1, h),
1053 None => (1, String::new()),
1054 };
1055 let created_at = now_iso();
1056 let key_id = CURRENT_KEY_ID.to_string();
1057 let content = EventContent {
1061 seq,
1062 key_id: &key_id,
1063 kind,
1064 transaction_id,
1065 receipt_digest,
1066 created_at: &created_at,
1067 identity,
1068 };
1069 let chain_hash = key.event_hash(&content, &prev_chain_hash);
1070 let stored_principal = match identity {
1071 EventIdentity::V2 { caller_principal } => Some(caller_principal),
1072 EventIdentity::LegacyV1 => None,
1073 };
1074 sqlx_core::query::query(
1075 "INSERT INTO audit_events ( \
1076 seq, key_id, kind, transaction_id, receipt_digest, \
1077 created_at, chain_hash, prev_chain_hash, chain_version, caller_principal \
1078 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
1079 )
1080 .bind(seq as i64)
1081 .bind(&key_id)
1082 .bind(kind.as_str())
1083 .bind(transaction_id)
1084 .bind(receipt_digest)
1085 .bind(&created_at)
1086 .bind(&chain_hash)
1087 .bind(&prev_chain_hash)
1088 .bind(identity.version() as i64)
1089 .bind(stored_principal)
1090 .execute(&mut **tx)
1091 .await
1092 .map_err(map_sqlx_err)?;
1093 Ok(())
1094}
1095
1096async fn insert_transaction(
1101 tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
1102 key: &AuditKey,
1103 transaction_id: &str,
1104 transaction: NewTransaction,
1105) -> Result<InsertedTransaction, TransactionStoreError> {
1106 let warnings_json = serialize(&transaction.warnings)?;
1107 let status = JobState::Queued;
1108 let created_at = now_iso();
1109 let key_id = CURRENT_KEY_ID.to_string();
1110 let approval_id = Some(key.approval_commitment(transaction_id, &transaction.request_hash));
1113
1114 let prev: Option<(i64, String)> = sqlx_core::query_as::query_as(
1120 "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1 FOR UPDATE",
1121 )
1122 .fetch_optional(&mut **tx)
1123 .await
1124 .map_err(map_sqlx_err)?;
1125 let (seq, prev_chain_hash) = match prev {
1126 Some((s, h)) => ((s as u64) + 1, h),
1127 None => (1, String::new()),
1128 };
1129
1130 let event_tip: String = sqlx_core::query_scalar::query_scalar(
1133 "SELECT COALESCE((SELECT chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1), '')",
1134 )
1135 .fetch_one(&mut **tx)
1136 .await
1137 .map_err(map_sqlx_err)?;
1138 let caller_role = transaction.caller_role.as_str();
1139 let caller_principal = transaction.caller_principal.as_signed_str();
1140
1141 let identity = ChainIdentity::V4 {
1144 caller_role,
1145 event_tip: &event_tip,
1146 caller_principal: &caller_principal,
1147 };
1148 let chain_hash = key.chain_hash(
1149 &ChainContent {
1150 seq,
1151 key_id: &key_id,
1152 transaction_id,
1153 request_id: &transaction.request_id,
1154 request_hash: &transaction.request_hash,
1155 action_name: &transaction.action_name,
1156 risk_level: transaction.risk_level,
1157 summary: &transaction.summary,
1158 approval_id: approval_id.as_deref(),
1159 warnings_json: &warnings_json,
1160 created_at: &created_at,
1161 identity,
1162 },
1163 &prev_chain_hash,
1164 );
1165
1166 sqlx_core::query::query(
1167 "INSERT INTO transactions ( \
1168 transaction_id, request_id, request_hash, action_name, risk_level, \
1169 status, approval_id, summary, warnings_json, created_at, \
1170 seq, key_id, chain_hash, prev_chain_hash, \
1171 chain_version, caller_role, event_tip, caller_principal \
1172 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)",
1173 )
1174 .bind(transaction_id)
1175 .bind(&transaction.request_id)
1176 .bind(&transaction.request_hash)
1177 .bind(&transaction.action_name)
1178 .bind(serialize(&transaction.risk_level)?)
1179 .bind(serialize(&status)?)
1180 .bind(&approval_id)
1181 .bind(&transaction.summary)
1182 .bind(&warnings_json)
1183 .bind(&created_at)
1184 .bind(seq as i64)
1185 .bind(&key_id)
1186 .bind(&chain_hash)
1187 .bind(&prev_chain_hash)
1188 .bind(i64::from(identity.version()))
1189 .bind(caller_role)
1190 .bind(&event_tip)
1191 .bind(&caller_principal)
1192 .execute(&mut **tx)
1193 .await
1194 .map_err(map_sqlx_err)?;
1195
1196 Ok(InsertedTransaction {
1197 record: TransactionRecord {
1198 transaction_id: transaction_id.to_string(),
1199 request_id: transaction.request_id,
1200 request_hash: transaction.request_hash,
1201 action_name: transaction.action_name,
1202 risk_level: transaction.risk_level,
1203 status,
1204 approval_id,
1205 summary: transaction.summary,
1206 warnings: transaction.warnings,
1207 },
1208 seq,
1209 chain_hash,
1210 })
1211}
1212
1213fn row_to_history_entry(
1218 row: sqlx_postgres::PgRow,
1219) -> Result<crate::transactions::JobHistoryEntry, TransactionStoreError> {
1220 Ok(crate::transactions::JobHistoryEntry {
1221 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1222 action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
1223 risk_level: deserialize(
1224 &row.try_get::<String, _>("risk_level")
1225 .map_err(map_sqlx_err)?,
1226 )?,
1227 status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
1228 summary: row.try_get("summary").map_err(map_sqlx_err)?,
1229 created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1230 })
1231}
1232
1233fn row_to_record(row: sqlx_postgres::PgRow) -> Result<TransactionRecord, TransactionStoreError> {
1234 Ok(TransactionRecord {
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 status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
1244 approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
1245 summary: row.try_get("summary").map_err(map_sqlx_err)?,
1246 warnings: deserialize(
1247 &row.try_get::<String, _>("warnings_json")
1248 .map_err(map_sqlx_err)?,
1249 )?,
1250 })
1251}
1252
1253fn row_to_chain_row(row: sqlx_postgres::PgRow) -> Result<ChainRow, TransactionStoreError> {
1254 Ok(ChainRow {
1255 seq: row.try_get::<i64, _>("seq").map_err(map_sqlx_err)? as u64,
1256 key_id: row.try_get("key_id").map_err(map_sqlx_err)?,
1257 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1258 request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
1259 request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
1260 action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
1261 risk_level: deserialize(
1262 &row.try_get::<String, _>("risk_level")
1263 .map_err(map_sqlx_err)?,
1264 )?,
1265 summary: row.try_get("summary").map_err(map_sqlx_err)?,
1266 approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
1267 warnings_json: row.try_get("warnings_json").map_err(map_sqlx_err)?,
1268 created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1269 prev_chain_hash: row.try_get("prev_chain_hash").map_err(map_sqlx_err)?,
1270 chain_hash: row.try_get("chain_hash").map_err(map_sqlx_err)?,
1271 chain_version: row
1272 .try_get::<i64, _>("chain_version")
1273 .map_err(map_sqlx_err)? as u32,
1274 caller_role: row.try_get("caller_role").map_err(map_sqlx_err)?,
1275 event_tip: row.try_get("event_tip").map_err(map_sqlx_err)?,
1276 caller_principal: row.try_get("caller_principal").map_err(map_sqlx_err)?,
1277 })
1278}
1279
1280fn row_to_event_row(row: sqlx_postgres::PgRow) -> Result<EventRow, TransactionStoreError> {
1281 Ok(EventRow {
1282 seq: row.try_get::<i64, _>("seq").map_err(map_sqlx_err)? as u64,
1283 key_id: row.try_get("key_id").map_err(map_sqlx_err)?,
1284 kind: row.try_get("kind").map_err(map_sqlx_err)?,
1285 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1286 receipt_digest: row.try_get("receipt_digest").map_err(map_sqlx_err)?,
1287 created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1288 prev_chain_hash: row.try_get("prev_chain_hash").map_err(map_sqlx_err)?,
1289 chain_hash: row.try_get("chain_hash").map_err(map_sqlx_err)?,
1290 chain_version: row
1291 .try_get::<i64, _>("chain_version")
1292 .map_err(map_sqlx_err)? as u32,
1293 caller_principal: row.try_get("caller_principal").map_err(map_sqlx_err)?,
1294 })
1295}
1296
1297#[cfg(test)]
1298mod tests {
1299 use super::*;
1300
1301 #[test]
1304 fn default_config_keeps_statement_cache_enabled() {
1305 let c = PostgresConfig::default();
1306 assert!(c.statement_cache_capacity > 0);
1307 assert!(c.max_connections >= 4);
1308 assert!(c.acquire_timeout >= Duration::from_secs(5));
1309 }
1310
1311 #[test]
1312 fn url_parsing_accepts_standard_postgres_url() {
1313 let opts = PgConnectOptions::from_str(
1314 "postgres://user:pass@host.example.com:5432/audit?sslmode=require",
1315 )
1316 .expect("standard URL parses");
1317 let _ = opts.statement_cache_capacity(0);
1319 }
1320
1321 #[test]
1322 fn now_iso_produces_a_parseable_rfc3339_timestamp() {
1323 let ts = now_iso();
1330 let parsed = chrono::DateTime::parse_from_rfc3339(&ts);
1331 assert!(
1332 parsed.is_ok(),
1333 "now_iso() must emit a parseable RFC 3339 timestamp, got {ts:?} ({parsed:?})"
1334 );
1335 assert_eq!(
1336 ts.len(),
1337 "2026-07-27T12:34:56.789Z".len(),
1338 "timestamp shape changed unexpectedly: {ts}"
1339 );
1340 }
1341
1342 #[test]
1343 fn now_iso_matches_the_sqlite_backends_timestamp_shape() {
1344 let ts = now_iso();
1350 let (date, rest) = ts.split_once('T').expect("timestamp has a T separator");
1351 assert_eq!(date.len(), "2026-07-27".len(), "date part: {ts}");
1352 assert!(rest.ends_with('Z'), "must be UTC with a Z suffix: {ts}");
1353 let time = rest.trim_end_matches('Z');
1354 let fields: Vec<&str> = time.split(':').collect();
1355 assert_eq!(fields.len(), 3, "expected HH:MM:SS.mmm, got {time:?}");
1356 assert!(
1357 fields[2].contains('.'),
1358 "seconds field must carry fractional milliseconds: {time:?}"
1359 );
1360 let (secs, millis) = fields[2].split_once('.').expect("checked above");
1361 assert_eq!(secs.len(), 2, "seconds must be two digits: {time:?}");
1362 assert_eq!(millis.len(), 3, "expected millisecond precision: {time:?}");
1363 }
1364
1365 #[test]
1366 fn schema_decode_errors_are_classified_as_invariant_failures() {
1367 let error = map_sqlx_err(sqlx_core::Error::ColumnNotFound("chain_hash".to_string()));
1368 assert!(matches!(error, TransactionStoreError::DatabaseInvariant(_)));
1369 }
1370}