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