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 verify_all(
302 &self,
303 key: &AuditKey,
304 ) -> Result<AuditVerification, TransactionStoreError> {
305 let tx_rows = fetch_chain_rows_from_pool(&self.pool).await?;
306 let event_rows = fetch_event_rows_from_pool(&self.pool).await?;
307 Ok(crate::audit_chain::verify_all(key, &tx_rows, &event_rows))
308 }
309
310 async fn initialize(&self) -> Result<(), TransactionStoreError> {
313 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
314 sqlx_core::query::query("SELECT pg_advisory_xact_lock($1)")
315 .bind(MIGRATION_LOCK_ID)
316 .execute(&mut *tx)
317 .await
318 .map_err(map_sqlx_err)?;
319
320 sqlx_core::query::query(
321 r#"
322 CREATE TABLE IF NOT EXISTS schema_migrations (
323 version BIGINT PRIMARY KEY,
324 name TEXT NOT NULL,
325 applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
326 )
327 "#,
328 )
329 .execute(&mut *tx)
330 .await
331 .map_err(map_sqlx_err)?;
332
333 let current: i64 = sqlx_core::query_scalar::query_scalar(
334 "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
335 )
336 .fetch_one(&mut *tx)
337 .await
338 .map_err(map_sqlx_err)?;
339 let latest = MIGRATIONS.last().map_or(0, |migration| migration.version);
340 if current > latest {
341 return Err(TransactionStoreError::DatabaseInvariant(format!(
342 "postgres schema version {current} is newer than this binary supports ({latest})"
343 )));
344 }
345
346 for migration in MIGRATIONS
347 .iter()
348 .filter(|migration| migration.version > current)
349 {
350 for statement in migration.statements {
353 sqlx_core::query::query(*statement)
354 .execute(&mut *tx)
355 .await
356 .map_err(map_sqlx_err)?;
357 }
358 sqlx_core::query::query(
359 "INSERT INTO schema_migrations (version, name) VALUES ($1, $2)",
360 )
361 .bind(migration.version)
362 .bind(migration.name)
363 .execute(&mut *tx)
364 .await
365 .map_err(map_sqlx_err)?;
366 }
367
368 tx.commit().await.map_err(map_sqlx_err)
369 }
370}
371
372fn map_sqlx_err(e: sqlx_core::Error) -> TransactionStoreError {
377 let message = format!("postgres: {e}");
378 let invariant = match &e {
379 sqlx_core::Error::Database(db) => db
380 .code()
381 .is_some_and(|code| code.starts_with("22") || code.starts_with("23")),
382 sqlx_core::Error::TypeNotFound { .. }
383 | sqlx_core::Error::ColumnIndexOutOfBounds { .. }
384 | sqlx_core::Error::ColumnNotFound(_)
385 | sqlx_core::Error::ColumnDecode { .. }
386 | sqlx_core::Error::Decode(_) => true,
387 _ => false,
388 };
389 if invariant {
390 TransactionStoreError::DatabaseInvariant(message)
391 } else {
392 TransactionStoreError::Io(std::io::Error::other(message))
393 }
394}
395
396fn serialize<T: serde::Serialize>(v: &T) -> Result<String, TransactionStoreError> {
397 serde_json::to_string(v).map_err(TransactionStoreError::Json)
398}
399
400fn deserialize<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, TransactionStoreError> {
401 serde_json::from_str(s).map_err(TransactionStoreError::Json)
402}
403
404fn now_iso() -> String {
412 chrono::Utc::now()
413 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
414 .to_string()
415}
416
417#[async_trait]
422impl AuditStore for PostgresStore {
423 async fn record(
424 &self,
425 transaction: NewTransaction,
426 ) -> Result<TransactionRecord, TransactionStoreError> {
427 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
428 let transaction_id = Uuid::new_v4().to_string();
429 let inserted =
430 insert_transaction(&mut tx, &self.audit_key, &transaction_id, transaction).await?;
431 tx.commit().await.map_err(map_sqlx_err)?;
432 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
433 Ok(inserted.record)
434 }
435
436 async fn record_previewed(
437 &self,
438 transaction: NewTransaction,
439 preview: PreviewEnvelope,
440 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
441 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
442 let transaction_id = Uuid::new_v4().to_string();
443 let inserted =
444 insert_transaction(&mut tx, &self.audit_key, &transaction_id, transaction).await?;
445
446 sqlx_core::query::query(
447 "INSERT INTO transaction_previews (transaction_id, preview_json) VALUES ($1, $2)",
448 )
449 .bind(&inserted.record.transaction_id)
450 .bind(serialize(&preview)?)
451 .execute(&mut *tx)
452 .await
453 .map_err(map_sqlx_err)?;
454
455 tx.commit().await.map_err(map_sqlx_err)?;
456 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
457 Ok(RecordedPreviewedTransaction {
458 transaction: inserted.record,
459 preview,
460 })
461 }
462
463 async fn get(
464 &self,
465 transaction_id: &str,
466 ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
467 let row = sqlx_core::query::query(
468 "SELECT transaction_id, request_id, request_hash, action_name, risk_level, \
469 status, approval_id, summary, warnings_json \
470 FROM transactions WHERE transaction_id = $1",
471 )
472 .bind(transaction_id)
473 .fetch_optional(&self.pool)
474 .await
475 .map_err(map_sqlx_err)?;
476 row.map(row_to_record).transpose()
477 }
478
479 async fn get_preview(
480 &self,
481 transaction_id: &str,
482 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
483 let row = sqlx_core::query::query(
484 "SELECT preview_json FROM transaction_previews WHERE transaction_id = $1",
485 )
486 .bind(transaction_id)
487 .fetch_optional(&self.pool)
488 .await
489 .map_err(map_sqlx_err)?;
490 match row {
491 Some(r) => {
492 let s: String = r.try_get("preview_json").map_err(map_sqlx_err)?;
493 Ok(Some(deserialize(&s)?))
494 }
495 None => Ok(None),
496 }
497 }
498
499 async fn update_status(
500 &self,
501 transaction_id: &str,
502 new_status: JobState,
503 ) -> Result<(), TransactionStoreError> {
504 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
513 let current_str: Option<String> = sqlx_core::query_scalar::query_scalar(
514 "SELECT status FROM transactions WHERE transaction_id = $1 FOR UPDATE",
515 )
516 .bind(transaction_id)
517 .fetch_optional(&mut *tx)
518 .await
519 .map_err(map_sqlx_err)?;
520 let current_str = current_str
521 .ok_or_else(|| TransactionStoreError::NotFound(transaction_id.to_string()))?;
522 let current: JobState = deserialize(¤t_str)?;
523 if !crate::jobs::allowed_transition(¤t, &new_status) {
524 return Err(TransactionStoreError::InvalidTransition {
525 from: current,
526 to: new_status,
527 });
528 }
529 sqlx_core::query::query("UPDATE transactions SET status = $1 WHERE transaction_id = $2")
530 .bind(serialize(&new_status)?)
531 .bind(transaction_id)
532 .execute(&mut *tx)
533 .await
534 .map_err(map_sqlx_err)?;
535 tx.commit().await.map_err(map_sqlx_err)?;
536 Ok(())
537 }
538
539 async fn approve_transaction(
540 &self,
541 transaction_id: &str,
542 ) -> Result<Option<String>, TransactionStoreError> {
543 let row = sqlx_core::query::query(
544 "SELECT request_hash, approval_id FROM transactions WHERE transaction_id = $1",
545 )
546 .bind(transaction_id)
547 .fetch_optional(&self.pool)
548 .await
549 .map_err(map_sqlx_err)?;
550 let Some(row) = row else {
551 return Ok(None);
552 };
553 let request_hash: String = row.try_get("request_hash").map_err(map_sqlx_err)?;
554 let committed_digest: Option<String> = row.try_get("approval_id").map_err(map_sqlx_err)?;
555 let receipt = self
556 .audit_key
557 .approval_receipt(transaction_id, &request_hash);
558 let receipt_digest = crate::audit_chain::approval_receipt_digest(&receipt);
559 let commitment_matches = committed_digest.as_deref().is_some_and(|committed| {
560 bool::from(receipt_digest.as_bytes().ct_eq(committed.as_bytes()))
561 });
562 if !commitment_matches {
563 return Err(TransactionStoreError::DatabaseInvariant(format!(
564 "transaction {transaction_id} approval commitment does not match its signed preview"
565 )));
566 }
567 let queued = serialize(&JobState::Queued)?;
568 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
571 let result = sqlx_core::query::query(
572 sqlx_core::sql_str::AssertSqlSafe(format!(
573 "INSERT INTO transaction_approvals \
574 (transaction_id, receipt_digest, approved_at) \
575 SELECT transaction_id, $1, $2 FROM transactions \
576 WHERE transaction_id = $3 \
577 AND status = $4 \
578 AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
579 ON CONFLICT (transaction_id) DO NOTHING"
580 )),
581 )
582 .bind(&receipt_digest)
583 .bind(now_iso())
584 .bind(transaction_id)
585 .bind(&queued)
586 .execute(&mut *tx)
587 .await
588 .map_err(map_sqlx_err)?;
589 if result.rows_affected() > 0 {
590 append_event(
591 &mut tx,
592 &self.audit_key,
593 AuditEventKind::ApprovalGranted,
594 transaction_id,
595 &receipt_digest,
596 )
597 .await?;
598 }
599 tx.commit().await.map_err(map_sqlx_err)?;
600 Ok((result.rows_affected() > 0).then_some(receipt))
601 }
602
603 async fn revoke_unconsumed_approval(
604 &self,
605 transaction_id: &str,
606 ) -> Result<bool, TransactionStoreError> {
607 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
608 let digest: Option<String> = sqlx_core::query_scalar::query_scalar(
611 "SELECT receipt_digest FROM transaction_approvals \
612 WHERE transaction_id = $1 AND consumed_at IS NULL",
613 )
614 .bind(transaction_id)
615 .fetch_optional(&mut *tx)
616 .await
617 .map_err(map_sqlx_err)?;
618 let result = sqlx_core::query::query(
619 "DELETE FROM transaction_approvals \
620 WHERE transaction_id = $1 AND consumed_at IS NULL",
621 )
622 .bind(transaction_id)
623 .execute(&mut *tx)
624 .await
625 .map_err(map_sqlx_err)?;
626 if result.rows_affected() > 0 {
627 let digest = digest.ok_or_else(|| {
628 TransactionStoreError::DatabaseInvariant(format!(
629 "revoked an approval for {transaction_id} that had no receipt digest"
630 ))
631 })?;
632 append_event(
633 &mut tx,
634 &self.audit_key,
635 AuditEventKind::ApprovalRevoked,
636 transaction_id,
637 &digest,
638 )
639 .await?;
640 }
641 tx.commit().await.map_err(map_sqlx_err)?;
642 Ok(result.rows_affected() > 0)
643 }
644
645 async fn claim_approved_for_execution(
646 &self,
647 transaction_id: &str,
648 receipt_digest: &str,
649 ) -> Result<bool, TransactionStoreError> {
650 let queued = serialize(&JobState::Queued)?;
651 let running = serialize(&JobState::Running)?;
652 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
653 let result = sqlx_core::query::query(
654 sqlx_core::sql_str::AssertSqlSafe(format!(
655 "UPDATE transactions SET status = $1 \
656 WHERE transaction_id = $2 \
657 AND status = $3 \
658 AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
659 AND EXISTS ( \
660 SELECT 1 FROM transaction_approvals \
661 WHERE transaction_id = $2 \
662 AND receipt_digest = $4 \
663 AND consumed_at IS NULL \
664 )"
665 )),
666 )
667 .bind(&running)
668 .bind(transaction_id)
669 .bind(&queued)
670 .bind(receipt_digest)
671 .execute(&mut *tx)
672 .await
673 .map_err(map_sqlx_err)?;
674 if result.rows_affected() > 0 {
675 sqlx_core::query::query(
676 "UPDATE transaction_approvals SET consumed_at = $1 \
677 WHERE transaction_id = $2 AND consumed_at IS NULL",
678 )
679 .bind(now_iso())
680 .bind(transaction_id)
681 .execute(&mut *tx)
682 .await
683 .map_err(map_sqlx_err)?;
684 append_event(
685 &mut tx,
686 &self.audit_key,
687 AuditEventKind::ApprovalConsumed,
688 transaction_id,
689 receipt_digest,
690 )
691 .await?;
692 }
693 tx.commit().await.map_err(map_sqlx_err)?;
694 Ok(result.rows_affected() > 0)
695 }
696
697 async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
698 let queued = serialize(&JobState::Queued)?;
699 let canceled = serialize(&JobState::Canceled)?;
700 let result = sqlx_core::query::query(
701 sqlx_core::sql_str::AssertSqlSafe(format!(
702 "UPDATE transactions SET status = $1 \
703 WHERE status = $2 \
704 AND created_at::timestamptz <= now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes'"
705 )),
706 )
707 .bind(&canceled)
708 .bind(&queued)
709 .execute(&self.pool)
710 .await
711 .map_err(map_sqlx_err)?;
712 Ok(result.rows_affected())
713 }
714
715 async fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
716 let queued = serialize(&JobState::Queued)?;
719 let canceled = serialize(&JobState::Canceled)?;
720 let result = sqlx_core::query::query(
721 "UPDATE transactions SET status = $1 \
722 WHERE transaction_id = $2 AND status = $3",
723 )
724 .bind(&canceled)
725 .bind(transaction_id)
726 .bind(&queued)
727 .execute(&self.pool)
728 .await
729 .map_err(map_sqlx_err)?;
730 Ok(result.rows_affected() > 0)
731 }
732
733 async fn list_transactions(
734 &self,
735 limit: u32,
736 status_filter: Option<&str>,
737 action_filter: Option<&str>,
738 since_hours: Option<u32>,
739 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
740 let limit = limit.min(100) as i64;
741
742 let validated_status: Option<String> = status_filter
745 .map(|s| -> Result<String, TransactionStoreError> {
746 let parsed: JobState = deserialize(&format!("\"{s}\""))?;
747 serialize(&parsed)
748 })
749 .transpose()?;
750
751 let mut sql = String::from(
752 "SELECT transaction_id, request_id, request_hash, action_name, risk_level, \
753 status, approval_id, summary, warnings_json \
754 FROM transactions WHERE TRUE",
755 );
756 let mut idx = 1;
757 if validated_status.is_some() {
758 sql.push_str(&format!(" AND status = ${idx}"));
759 idx += 1;
760 }
761 if action_filter.is_some() {
762 sql.push_str(&format!(" AND action_name = ${idx}"));
763 idx += 1;
764 }
765 if since_hours.is_some() {
766 sql.push_str(&format!(
767 " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
768 ));
769 idx += 1;
770 }
771 sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
772
773 let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
774 if let Some(s) = &validated_status {
775 q = q.bind(s);
776 }
777 if let Some(a) = action_filter {
778 q = q.bind(a);
779 }
780 if let Some(h) = since_hours {
781 q = q.bind(h.to_string());
782 }
783 q = q.bind(limit);
784
785 let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
786 rows.into_iter().map(row_to_record).collect()
787 }
788
789 async fn list_history(
790 &self,
791 limit: u32,
792 status_filter: Option<&str>,
793 action_filter: Option<&str>,
794 since_hours: Option<u32>,
795 ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError> {
796 let limit = limit.min(100) as i64;
799 let validated_status: Option<String> = status_filter
800 .map(|s| -> Result<String, TransactionStoreError> {
801 let parsed: JobState = deserialize(&format!("\"{s}\""))?;
802 serialize(&parsed)
803 })
804 .transpose()?;
805
806 let mut sql = String::from(
807 "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
808 FROM transactions WHERE TRUE",
809 );
810 let mut idx = 1;
811 if validated_status.is_some() {
812 sql.push_str(&format!(" AND status = ${idx}"));
813 idx += 1;
814 }
815 if action_filter.is_some() {
816 sql.push_str(&format!(" AND action_name = ${idx}"));
817 idx += 1;
818 }
819 if since_hours.is_some() {
820 sql.push_str(&format!(
821 " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
822 ));
823 idx += 1;
824 }
825 sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
826
827 let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
828 if let Some(s) = &validated_status {
829 q = q.bind(s);
830 }
831 if let Some(a) = action_filter {
832 q = q.bind(a);
833 }
834 if let Some(h) = since_hours {
835 q = q.bind(h.to_string());
836 }
837 q = q.bind(limit);
838
839 let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
840 rows.into_iter().map(row_to_history_entry).collect()
841 }
842
843 async fn fetch_chain_row(
844 &self,
845 transaction_id: &str,
846 ) -> Result<Option<ChainRow>, TransactionStoreError> {
847 let row = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(format!(
848 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions WHERE transaction_id = $1"
849 )))
850 .bind(transaction_id)
851 .fetch_optional(&self.pool)
852 .await
853 .map_err(map_sqlx_err)?;
854 row.map(row_to_chain_row).transpose()
855 }
856
857 async fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
858 fetch_event_rows_from_pool(&self.pool).await
859 }
860
861 async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
862 fetch_chain_rows_from_pool(&self.pool).await
863 }
864
865 async fn verify_audit_chain(
866 &self,
867 key: &AuditKey,
868 ) -> Result<VerifyOutcome, TransactionStoreError> {
869 let rows = self.fetch_chain_rows().await?;
870 Ok(crate::audit_chain::verify_chain(key, &rows))
871 }
872}
873
874async fn fetch_chain_rows_from_pool(pool: &PgPool) -> Result<Vec<ChainRow>, TransactionStoreError> {
875 let rows = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(format!(
876 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions ORDER BY seq ASC"
877 )))
878 .fetch_all(pool)
879 .await
880 .map_err(map_sqlx_err)?;
881 rows.into_iter().map(row_to_chain_row).collect()
882}
883
884async fn audit_events_table_absent(pool: &PgPool) -> Result<bool, TransactionStoreError> {
891 let present: Option<String> =
892 sqlx_core::query_scalar::query_scalar("SELECT to_regclass('audit_events')::text")
893 .fetch_one(pool)
894 .await
895 .map_err(map_sqlx_err)?;
896 Ok(present.is_none())
897}
898
899async fn fetch_event_rows_from_pool(pool: &PgPool) -> Result<Vec<EventRow>, TransactionStoreError> {
901 let rows = sqlx_core::query::query(
902 "SELECT seq, key_id, kind, transaction_id, receipt_digest, \
903 created_at, prev_chain_hash, chain_hash \
904 FROM audit_events ORDER BY seq ASC",
905 )
906 .fetch_all(pool)
907 .await
908 .map_err(map_sqlx_err)?;
909 rows.into_iter().map(row_to_event_row).collect()
910}
911
912async fn append_event(
918 tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
919 key: &AuditKey,
920 kind: AuditEventKind,
921 transaction_id: &str,
922 receipt_digest: &str,
923) -> Result<(), TransactionStoreError> {
924 let prev: Option<(i64, String)> = sqlx_core::query_as::query_as(
925 "SELECT seq, chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1 FOR UPDATE",
926 )
927 .fetch_optional(&mut **tx)
928 .await
929 .map_err(map_sqlx_err)?;
930 let (seq, prev_chain_hash) = match prev {
931 Some((s, h)) => ((s as u64) + 1, h),
932 None => (1, String::new()),
933 };
934 let created_at = now_iso();
935 let key_id = CURRENT_KEY_ID.to_string();
936 let chain_hash = key.event_hash(
937 &EventContent {
938 seq,
939 key_id: &key_id,
940 kind,
941 transaction_id,
942 receipt_digest,
943 created_at: &created_at,
944 },
945 &prev_chain_hash,
946 );
947 sqlx_core::query::query(
948 "INSERT INTO audit_events ( \
949 seq, key_id, kind, transaction_id, receipt_digest, \
950 created_at, chain_hash, prev_chain_hash \
951 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
952 )
953 .bind(seq as i64)
954 .bind(&key_id)
955 .bind(kind.as_str())
956 .bind(transaction_id)
957 .bind(receipt_digest)
958 .bind(&created_at)
959 .bind(&chain_hash)
960 .bind(&prev_chain_hash)
961 .execute(&mut **tx)
962 .await
963 .map_err(map_sqlx_err)?;
964 Ok(())
965}
966
967async fn insert_transaction(
972 tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
973 key: &AuditKey,
974 transaction_id: &str,
975 transaction: NewTransaction,
976) -> Result<InsertedTransaction, TransactionStoreError> {
977 let warnings_json = serialize(&transaction.warnings)?;
978 let status = JobState::Queued;
979 let created_at = now_iso();
980 let key_id = CURRENT_KEY_ID.to_string();
981 let approval_id = Some(key.approval_commitment(transaction_id, &transaction.request_hash));
984
985 let prev: Option<(i64, String)> = sqlx_core::query_as::query_as(
991 "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1 FOR UPDATE",
992 )
993 .fetch_optional(&mut **tx)
994 .await
995 .map_err(map_sqlx_err)?;
996 let (seq, prev_chain_hash) = match prev {
997 Some((s, h)) => ((s as u64) + 1, h),
998 None => (1, String::new()),
999 };
1000
1001 let event_tip: String = sqlx_core::query_scalar::query_scalar(
1004 "SELECT COALESCE((SELECT chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1), '')",
1005 )
1006 .fetch_one(&mut **tx)
1007 .await
1008 .map_err(map_sqlx_err)?;
1009 let caller_role = transaction.caller_role.as_str();
1010 let caller_principal = transaction.caller_principal.as_signed_str();
1011
1012 let identity = ChainIdentity::V3 {
1015 caller_role,
1016 event_tip: &event_tip,
1017 caller_principal: &caller_principal,
1018 };
1019 let chain_hash = key.chain_hash(
1020 &ChainContent {
1021 seq,
1022 key_id: &key_id,
1023 transaction_id,
1024 request_id: &transaction.request_id,
1025 request_hash: &transaction.request_hash,
1026 action_name: &transaction.action_name,
1027 risk_level: transaction.risk_level,
1028 summary: &transaction.summary,
1029 approval_id: approval_id.as_deref(),
1030 warnings_json: &warnings_json,
1031 created_at: &created_at,
1032 identity,
1033 },
1034 &prev_chain_hash,
1035 );
1036
1037 sqlx_core::query::query(
1038 "INSERT INTO transactions ( \
1039 transaction_id, request_id, request_hash, action_name, risk_level, \
1040 status, approval_id, summary, warnings_json, created_at, \
1041 seq, key_id, chain_hash, prev_chain_hash, \
1042 chain_version, caller_role, event_tip, caller_principal \
1043 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)",
1044 )
1045 .bind(transaction_id)
1046 .bind(&transaction.request_id)
1047 .bind(&transaction.request_hash)
1048 .bind(&transaction.action_name)
1049 .bind(serialize(&transaction.risk_level)?)
1050 .bind(serialize(&status)?)
1051 .bind(&approval_id)
1052 .bind(&transaction.summary)
1053 .bind(&warnings_json)
1054 .bind(&created_at)
1055 .bind(seq as i64)
1056 .bind(&key_id)
1057 .bind(&chain_hash)
1058 .bind(&prev_chain_hash)
1059 .bind(i64::from(identity.version()))
1060 .bind(caller_role)
1061 .bind(&event_tip)
1062 .bind(&caller_principal)
1063 .execute(&mut **tx)
1064 .await
1065 .map_err(map_sqlx_err)?;
1066
1067 Ok(InsertedTransaction {
1068 record: TransactionRecord {
1069 transaction_id: transaction_id.to_string(),
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 status,
1075 approval_id,
1076 summary: transaction.summary,
1077 warnings: transaction.warnings,
1078 },
1079 seq,
1080 chain_hash,
1081 })
1082}
1083
1084fn row_to_history_entry(
1089 row: sqlx_postgres::PgRow,
1090) -> Result<crate::transactions::JobHistoryEntry, TransactionStoreError> {
1091 Ok(crate::transactions::JobHistoryEntry {
1092 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1093 action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
1094 risk_level: deserialize(
1095 &row.try_get::<String, _>("risk_level")
1096 .map_err(map_sqlx_err)?,
1097 )?,
1098 status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
1099 summary: row.try_get("summary").map_err(map_sqlx_err)?,
1100 created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1101 })
1102}
1103
1104fn row_to_record(row: sqlx_postgres::PgRow) -> Result<TransactionRecord, TransactionStoreError> {
1105 Ok(TransactionRecord {
1106 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1107 request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
1108 request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
1109 action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
1110 risk_level: deserialize(
1111 &row.try_get::<String, _>("risk_level")
1112 .map_err(map_sqlx_err)?,
1113 )?,
1114 status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
1115 approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
1116 summary: row.try_get("summary").map_err(map_sqlx_err)?,
1117 warnings: deserialize(
1118 &row.try_get::<String, _>("warnings_json")
1119 .map_err(map_sqlx_err)?,
1120 )?,
1121 })
1122}
1123
1124fn row_to_chain_row(row: sqlx_postgres::PgRow) -> Result<ChainRow, TransactionStoreError> {
1125 Ok(ChainRow {
1126 seq: row.try_get::<i64, _>("seq").map_err(map_sqlx_err)? as u64,
1127 key_id: row.try_get("key_id").map_err(map_sqlx_err)?,
1128 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1129 request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
1130 request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
1131 action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
1132 risk_level: deserialize(
1133 &row.try_get::<String, _>("risk_level")
1134 .map_err(map_sqlx_err)?,
1135 )?,
1136 summary: row.try_get("summary").map_err(map_sqlx_err)?,
1137 approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
1138 warnings_json: row.try_get("warnings_json").map_err(map_sqlx_err)?,
1139 created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1140 prev_chain_hash: row.try_get("prev_chain_hash").map_err(map_sqlx_err)?,
1141 chain_hash: row.try_get("chain_hash").map_err(map_sqlx_err)?,
1142 chain_version: row
1143 .try_get::<i64, _>("chain_version")
1144 .map_err(map_sqlx_err)? as u32,
1145 caller_role: row.try_get("caller_role").map_err(map_sqlx_err)?,
1146 event_tip: row.try_get("event_tip").map_err(map_sqlx_err)?,
1147 caller_principal: row.try_get("caller_principal").map_err(map_sqlx_err)?,
1148 })
1149}
1150
1151fn row_to_event_row(row: sqlx_postgres::PgRow) -> Result<EventRow, TransactionStoreError> {
1152 Ok(EventRow {
1153 seq: row.try_get::<i64, _>("seq").map_err(map_sqlx_err)? as u64,
1154 key_id: row.try_get("key_id").map_err(map_sqlx_err)?,
1155 kind: row.try_get("kind").map_err(map_sqlx_err)?,
1156 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1157 receipt_digest: row.try_get("receipt_digest").map_err(map_sqlx_err)?,
1158 created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1159 prev_chain_hash: row.try_get("prev_chain_hash").map_err(map_sqlx_err)?,
1160 chain_hash: row.try_get("chain_hash").map_err(map_sqlx_err)?,
1161 })
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166 use super::*;
1167
1168 #[test]
1171 fn default_config_keeps_statement_cache_enabled() {
1172 let c = PostgresConfig::default();
1173 assert!(c.statement_cache_capacity > 0);
1174 assert!(c.max_connections >= 4);
1175 assert!(c.acquire_timeout >= Duration::from_secs(5));
1176 }
1177
1178 #[test]
1179 fn url_parsing_accepts_standard_postgres_url() {
1180 let opts = PgConnectOptions::from_str(
1181 "postgres://user:pass@host.example.com:5432/audit?sslmode=require",
1182 )
1183 .expect("standard URL parses");
1184 let _ = opts.statement_cache_capacity(0);
1186 }
1187
1188 #[test]
1189 fn now_iso_produces_a_parseable_rfc3339_timestamp() {
1190 let ts = now_iso();
1197 let parsed = chrono::DateTime::parse_from_rfc3339(&ts);
1198 assert!(
1199 parsed.is_ok(),
1200 "now_iso() must emit a parseable RFC 3339 timestamp, got {ts:?} ({parsed:?})"
1201 );
1202 assert_eq!(
1203 ts.len(),
1204 "2026-07-27T12:34:56.789Z".len(),
1205 "timestamp shape changed unexpectedly: {ts}"
1206 );
1207 }
1208
1209 #[test]
1210 fn now_iso_matches_the_sqlite_backends_timestamp_shape() {
1211 let ts = now_iso();
1217 let (date, rest) = ts.split_once('T').expect("timestamp has a T separator");
1218 assert_eq!(date.len(), "2026-07-27".len(), "date part: {ts}");
1219 assert!(rest.ends_with('Z'), "must be UTC with a Z suffix: {ts}");
1220 let time = rest.trim_end_matches('Z');
1221 let fields: Vec<&str> = time.split(':').collect();
1222 assert_eq!(fields.len(), 3, "expected HH:MM:SS.mmm, got {time:?}");
1223 assert!(
1224 fields[2].contains('.'),
1225 "seconds field must carry fractional milliseconds: {time:?}"
1226 );
1227 let (secs, millis) = fields[2].split_once('.').expect("checked above");
1228 assert_eq!(secs.len(), 2, "seconds must be two digits: {time:?}");
1229 assert_eq!(millis.len(), 3, "expected millisecond precision: {time:?}");
1230 }
1231
1232 #[test]
1233 fn schema_decode_errors_are_classified_as_invariant_failures() {
1234 let error = map_sqlx_err(sqlx_core::Error::ColumnNotFound("chain_hash".to_string()));
1235 assert!(matches!(error, TransactionStoreError::DatabaseInvariant(_)));
1236 }
1237}