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