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 AuditEventKind, AuditKey, AuditVerification, ChainContent, ChainIdentity, ChainRow,
40 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 connect_opts = connect_opts.statement_cache_capacity(config.statement_cache_capacity);
194 PgPoolOptions::new()
195 .max_connections(config.max_connections)
196 .acquire_timeout(config.acquire_timeout)
197 .connect_with(connect_opts)
198 .await
199 .map_err(map_sqlx_err)
200 }
201
202 pub async fn connect(
205 config: &PostgresConfig,
206 audit_key: std::sync::Arc<AuditKey>,
207 ) -> Result<Self, TransactionStoreError> {
208 let pool = Self::connect_pool(config).await?;
209
210 let store = Self { pool, audit_key };
211 store.initialize().await?;
212 Ok(store)
213 }
214
215 pub async fn verify_all_with_pubkey(
219 config: &PostgresConfig,
220 verifying_key_hex: &str,
221 ) -> Result<AuditVerification, TransactionStoreError> {
222 let pool = Self::connect_pool(config).await?;
223 let tx_rows = fetch_chain_rows_from_pool(&pool).await?;
224 let event_rows = fetch_event_rows_from_pool(&pool).await.unwrap_or_default();
228 Ok(crate::audit_chain::verify_all_with_pubkey(
229 verifying_key_hex,
230 &tx_rows,
231 &event_rows,
232 ))
233 }
234
235 pub async fn verify_all(
237 &self,
238 key: &AuditKey,
239 ) -> Result<AuditVerification, TransactionStoreError> {
240 let tx_rows = fetch_chain_rows_from_pool(&self.pool).await?;
241 let event_rows = fetch_event_rows_from_pool(&self.pool).await?;
242 Ok(crate::audit_chain::verify_all(key, &tx_rows, &event_rows))
243 }
244
245 async fn initialize(&self) -> Result<(), TransactionStoreError> {
248 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
249 sqlx_core::query::query("SELECT pg_advisory_xact_lock($1)")
250 .bind(MIGRATION_LOCK_ID)
251 .execute(&mut *tx)
252 .await
253 .map_err(map_sqlx_err)?;
254
255 sqlx_core::query::query(
256 r#"
257 CREATE TABLE IF NOT EXISTS schema_migrations (
258 version BIGINT PRIMARY KEY,
259 name TEXT NOT NULL,
260 applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
261 )
262 "#,
263 )
264 .execute(&mut *tx)
265 .await
266 .map_err(map_sqlx_err)?;
267
268 let current: i64 = sqlx_core::query_scalar::query_scalar(
269 "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
270 )
271 .fetch_one(&mut *tx)
272 .await
273 .map_err(map_sqlx_err)?;
274 let latest = MIGRATIONS.last().map_or(0, |migration| migration.version);
275 if current > latest {
276 return Err(TransactionStoreError::DatabaseInvariant(format!(
277 "postgres schema version {current} is newer than this binary supports ({latest})"
278 )));
279 }
280
281 for migration in MIGRATIONS
282 .iter()
283 .filter(|migration| migration.version > current)
284 {
285 for statement in migration.statements {
288 sqlx_core::query::query(*statement)
289 .execute(&mut *tx)
290 .await
291 .map_err(map_sqlx_err)?;
292 }
293 sqlx_core::query::query(
294 "INSERT INTO schema_migrations (version, name) VALUES ($1, $2)",
295 )
296 .bind(migration.version)
297 .bind(migration.name)
298 .execute(&mut *tx)
299 .await
300 .map_err(map_sqlx_err)?;
301 }
302
303 tx.commit().await.map_err(map_sqlx_err)
304 }
305}
306
307fn map_sqlx_err(e: sqlx_core::Error) -> TransactionStoreError {
312 let message = format!("postgres: {e}");
313 let invariant = match &e {
314 sqlx_core::Error::Database(db) => db
315 .code()
316 .is_some_and(|code| code.starts_with("22") || code.starts_with("23")),
317 sqlx_core::Error::TypeNotFound { .. }
318 | sqlx_core::Error::ColumnIndexOutOfBounds { .. }
319 | sqlx_core::Error::ColumnNotFound(_)
320 | sqlx_core::Error::ColumnDecode { .. }
321 | sqlx_core::Error::Decode(_) => true,
322 _ => false,
323 };
324 if invariant {
325 TransactionStoreError::DatabaseInvariant(message)
326 } else {
327 TransactionStoreError::Io(std::io::Error::other(message))
328 }
329}
330
331fn serialize<T: serde::Serialize>(v: &T) -> Result<String, TransactionStoreError> {
332 serde_json::to_string(v).map_err(TransactionStoreError::Json)
333}
334
335fn deserialize<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, TransactionStoreError> {
336 serde_json::from_str(s).map_err(TransactionStoreError::Json)
337}
338
339fn now_iso() -> String {
347 chrono::Utc::now()
348 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
349 .to_string()
350}
351
352#[async_trait]
357impl AuditStore for PostgresStore {
358 async fn record(
359 &self,
360 transaction: NewTransaction,
361 ) -> Result<TransactionRecord, TransactionStoreError> {
362 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
363 let transaction_id = Uuid::new_v4().to_string();
364 let inserted =
365 insert_transaction(&mut tx, &self.audit_key, &transaction_id, transaction).await?;
366 tx.commit().await.map_err(map_sqlx_err)?;
367 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
368 Ok(inserted.record)
369 }
370
371 async fn record_previewed(
372 &self,
373 transaction: NewTransaction,
374 preview: PreviewEnvelope,
375 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
376 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
377 let transaction_id = Uuid::new_v4().to_string();
378 let inserted =
379 insert_transaction(&mut tx, &self.audit_key, &transaction_id, transaction).await?;
380
381 sqlx_core::query::query(
382 "INSERT INTO transaction_previews (transaction_id, preview_json) VALUES ($1, $2)",
383 )
384 .bind(&inserted.record.transaction_id)
385 .bind(serialize(&preview)?)
386 .execute(&mut *tx)
387 .await
388 .map_err(map_sqlx_err)?;
389
390 tx.commit().await.map_err(map_sqlx_err)?;
391 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
392 Ok(RecordedPreviewedTransaction {
393 transaction: inserted.record,
394 preview,
395 })
396 }
397
398 async fn get(
399 &self,
400 transaction_id: &str,
401 ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
402 let row = sqlx_core::query::query(
403 "SELECT transaction_id, request_id, request_hash, action_name, risk_level, \
404 status, approval_id, summary, warnings_json \
405 FROM transactions WHERE transaction_id = $1",
406 )
407 .bind(transaction_id)
408 .fetch_optional(&self.pool)
409 .await
410 .map_err(map_sqlx_err)?;
411 row.map(row_to_record).transpose()
412 }
413
414 async fn get_preview(
415 &self,
416 transaction_id: &str,
417 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
418 let row = sqlx_core::query::query(
419 "SELECT preview_json FROM transaction_previews WHERE transaction_id = $1",
420 )
421 .bind(transaction_id)
422 .fetch_optional(&self.pool)
423 .await
424 .map_err(map_sqlx_err)?;
425 match row {
426 Some(r) => {
427 let s: String = r.try_get("preview_json").map_err(map_sqlx_err)?;
428 Ok(Some(deserialize(&s)?))
429 }
430 None => Ok(None),
431 }
432 }
433
434 async fn update_status(
435 &self,
436 transaction_id: &str,
437 new_status: JobState,
438 ) -> Result<(), TransactionStoreError> {
439 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
448 let current_str: Option<String> = sqlx_core::query_scalar::query_scalar(
449 "SELECT status FROM transactions WHERE transaction_id = $1 FOR UPDATE",
450 )
451 .bind(transaction_id)
452 .fetch_optional(&mut *tx)
453 .await
454 .map_err(map_sqlx_err)?;
455 let current_str = current_str
456 .ok_or_else(|| TransactionStoreError::NotFound(transaction_id.to_string()))?;
457 let current: JobState = deserialize(¤t_str)?;
458 if !crate::jobs::allowed_transition(¤t, &new_status) {
459 return Err(TransactionStoreError::InvalidTransition {
460 from: current,
461 to: new_status,
462 });
463 }
464 sqlx_core::query::query("UPDATE transactions SET status = $1 WHERE transaction_id = $2")
465 .bind(serialize(&new_status)?)
466 .bind(transaction_id)
467 .execute(&mut *tx)
468 .await
469 .map_err(map_sqlx_err)?;
470 tx.commit().await.map_err(map_sqlx_err)?;
471 Ok(())
472 }
473
474 async fn approve_transaction(
475 &self,
476 transaction_id: &str,
477 ) -> Result<Option<String>, TransactionStoreError> {
478 let row = sqlx_core::query::query(
479 "SELECT request_hash, approval_id FROM transactions WHERE transaction_id = $1",
480 )
481 .bind(transaction_id)
482 .fetch_optional(&self.pool)
483 .await
484 .map_err(map_sqlx_err)?;
485 let Some(row) = row else {
486 return Ok(None);
487 };
488 let request_hash: String = row.try_get("request_hash").map_err(map_sqlx_err)?;
489 let committed_digest: Option<String> = row.try_get("approval_id").map_err(map_sqlx_err)?;
490 let receipt = self
491 .audit_key
492 .approval_receipt(transaction_id, &request_hash);
493 let receipt_digest = crate::audit_chain::approval_receipt_digest(&receipt);
494 let commitment_matches = committed_digest.as_deref().is_some_and(|committed| {
495 bool::from(receipt_digest.as_bytes().ct_eq(committed.as_bytes()))
496 });
497 if !commitment_matches {
498 return Err(TransactionStoreError::DatabaseInvariant(format!(
499 "transaction {transaction_id} approval commitment does not match its signed preview"
500 )));
501 }
502 let queued = serialize(&JobState::Queued)?;
503 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
506 let result = sqlx_core::query::query(
507 sqlx_core::sql_str::AssertSqlSafe(format!(
508 "INSERT INTO transaction_approvals \
509 (transaction_id, receipt_digest, approved_at) \
510 SELECT transaction_id, $1, $2 FROM transactions \
511 WHERE transaction_id = $3 \
512 AND status = $4 \
513 AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
514 ON CONFLICT (transaction_id) DO NOTHING"
515 )),
516 )
517 .bind(&receipt_digest)
518 .bind(now_iso())
519 .bind(transaction_id)
520 .bind(&queued)
521 .execute(&mut *tx)
522 .await
523 .map_err(map_sqlx_err)?;
524 if result.rows_affected() > 0 {
525 append_event(
526 &mut tx,
527 &self.audit_key,
528 AuditEventKind::ApprovalGranted,
529 transaction_id,
530 &receipt_digest,
531 )
532 .await?;
533 }
534 tx.commit().await.map_err(map_sqlx_err)?;
535 Ok((result.rows_affected() > 0).then_some(receipt))
536 }
537
538 async fn revoke_unconsumed_approval(
539 &self,
540 transaction_id: &str,
541 ) -> Result<bool, TransactionStoreError> {
542 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
543 let digest: Option<String> = sqlx_core::query_scalar::query_scalar(
546 "SELECT receipt_digest FROM transaction_approvals \
547 WHERE transaction_id = $1 AND consumed_at IS NULL",
548 )
549 .bind(transaction_id)
550 .fetch_optional(&mut *tx)
551 .await
552 .map_err(map_sqlx_err)?;
553 let result = sqlx_core::query::query(
554 "DELETE FROM transaction_approvals \
555 WHERE transaction_id = $1 AND consumed_at IS NULL",
556 )
557 .bind(transaction_id)
558 .execute(&mut *tx)
559 .await
560 .map_err(map_sqlx_err)?;
561 if result.rows_affected() > 0 {
562 let digest = digest.ok_or_else(|| {
563 TransactionStoreError::DatabaseInvariant(format!(
564 "revoked an approval for {transaction_id} that had no receipt digest"
565 ))
566 })?;
567 append_event(
568 &mut tx,
569 &self.audit_key,
570 AuditEventKind::ApprovalRevoked,
571 transaction_id,
572 &digest,
573 )
574 .await?;
575 }
576 tx.commit().await.map_err(map_sqlx_err)?;
577 Ok(result.rows_affected() > 0)
578 }
579
580 async fn claim_approved_for_execution(
581 &self,
582 transaction_id: &str,
583 receipt_digest: &str,
584 ) -> Result<bool, TransactionStoreError> {
585 let queued = serialize(&JobState::Queued)?;
586 let running = serialize(&JobState::Running)?;
587 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
588 let result = sqlx_core::query::query(
589 sqlx_core::sql_str::AssertSqlSafe(format!(
590 "UPDATE transactions SET status = $1 \
591 WHERE transaction_id = $2 \
592 AND status = $3 \
593 AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
594 AND EXISTS ( \
595 SELECT 1 FROM transaction_approvals \
596 WHERE transaction_id = $2 \
597 AND receipt_digest = $4 \
598 AND consumed_at IS NULL \
599 )"
600 )),
601 )
602 .bind(&running)
603 .bind(transaction_id)
604 .bind(&queued)
605 .bind(receipt_digest)
606 .execute(&mut *tx)
607 .await
608 .map_err(map_sqlx_err)?;
609 if result.rows_affected() > 0 {
610 sqlx_core::query::query(
611 "UPDATE transaction_approvals SET consumed_at = $1 \
612 WHERE transaction_id = $2 AND consumed_at IS NULL",
613 )
614 .bind(now_iso())
615 .bind(transaction_id)
616 .execute(&mut *tx)
617 .await
618 .map_err(map_sqlx_err)?;
619 append_event(
620 &mut tx,
621 &self.audit_key,
622 AuditEventKind::ApprovalConsumed,
623 transaction_id,
624 receipt_digest,
625 )
626 .await?;
627 }
628 tx.commit().await.map_err(map_sqlx_err)?;
629 Ok(result.rows_affected() > 0)
630 }
631
632 async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
633 let queued = serialize(&JobState::Queued)?;
634 let canceled = serialize(&JobState::Canceled)?;
635 let result = sqlx_core::query::query(
636 sqlx_core::sql_str::AssertSqlSafe(format!(
637 "UPDATE transactions SET status = $1 \
638 WHERE status = $2 \
639 AND created_at::timestamptz <= now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes'"
640 )),
641 )
642 .bind(&canceled)
643 .bind(&queued)
644 .execute(&self.pool)
645 .await
646 .map_err(map_sqlx_err)?;
647 Ok(result.rows_affected())
648 }
649
650 async fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
651 let queued = serialize(&JobState::Queued)?;
654 let canceled = serialize(&JobState::Canceled)?;
655 let result = sqlx_core::query::query(
656 "UPDATE transactions SET status = $1 \
657 WHERE transaction_id = $2 AND status = $3",
658 )
659 .bind(&canceled)
660 .bind(transaction_id)
661 .bind(&queued)
662 .execute(&self.pool)
663 .await
664 .map_err(map_sqlx_err)?;
665 Ok(result.rows_affected() > 0)
666 }
667
668 async fn list_transactions(
669 &self,
670 limit: u32,
671 status_filter: Option<&str>,
672 action_filter: Option<&str>,
673 since_hours: Option<u32>,
674 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
675 let limit = limit.min(100) as i64;
676
677 let validated_status: Option<String> = status_filter
680 .map(|s| -> Result<String, TransactionStoreError> {
681 let parsed: JobState = deserialize(&format!("\"{s}\""))?;
682 serialize(&parsed)
683 })
684 .transpose()?;
685
686 let mut sql = String::from(
687 "SELECT transaction_id, request_id, request_hash, action_name, risk_level, \
688 status, approval_id, summary, warnings_json \
689 FROM transactions WHERE TRUE",
690 );
691 let mut idx = 1;
692 if validated_status.is_some() {
693 sql.push_str(&format!(" AND status = ${idx}"));
694 idx += 1;
695 }
696 if action_filter.is_some() {
697 sql.push_str(&format!(" AND action_name = ${idx}"));
698 idx += 1;
699 }
700 if since_hours.is_some() {
701 sql.push_str(&format!(
702 " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
703 ));
704 idx += 1;
705 }
706 sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
707
708 let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
709 if let Some(s) = &validated_status {
710 q = q.bind(s);
711 }
712 if let Some(a) = action_filter {
713 q = q.bind(a);
714 }
715 if let Some(h) = since_hours {
716 q = q.bind(h.to_string());
717 }
718 q = q.bind(limit);
719
720 let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
721 rows.into_iter().map(row_to_record).collect()
722 }
723
724 async fn list_history(
725 &self,
726 limit: u32,
727 status_filter: Option<&str>,
728 action_filter: Option<&str>,
729 since_hours: Option<u32>,
730 ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError> {
731 let limit = limit.min(100) as i64;
734 let validated_status: Option<String> = status_filter
735 .map(|s| -> Result<String, TransactionStoreError> {
736 let parsed: JobState = deserialize(&format!("\"{s}\""))?;
737 serialize(&parsed)
738 })
739 .transpose()?;
740
741 let mut sql = String::from(
742 "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
743 FROM transactions WHERE TRUE",
744 );
745 let mut idx = 1;
746 if validated_status.is_some() {
747 sql.push_str(&format!(" AND status = ${idx}"));
748 idx += 1;
749 }
750 if action_filter.is_some() {
751 sql.push_str(&format!(" AND action_name = ${idx}"));
752 idx += 1;
753 }
754 if since_hours.is_some() {
755 sql.push_str(&format!(
756 " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
757 ));
758 idx += 1;
759 }
760 sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
761
762 let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
763 if let Some(s) = &validated_status {
764 q = q.bind(s);
765 }
766 if let Some(a) = action_filter {
767 q = q.bind(a);
768 }
769 if let Some(h) = since_hours {
770 q = q.bind(h.to_string());
771 }
772 q = q.bind(limit);
773
774 let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
775 rows.into_iter().map(row_to_history_entry).collect()
776 }
777
778 async fn fetch_chain_row(
779 &self,
780 transaction_id: &str,
781 ) -> Result<Option<ChainRow>, TransactionStoreError> {
782 let row = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(format!(
783 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions WHERE transaction_id = $1"
784 )))
785 .bind(transaction_id)
786 .fetch_optional(&self.pool)
787 .await
788 .map_err(map_sqlx_err)?;
789 row.map(row_to_chain_row).transpose()
790 }
791
792 async fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
793 fetch_event_rows_from_pool(&self.pool).await
794 }
795
796 async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
797 fetch_chain_rows_from_pool(&self.pool).await
798 }
799
800 async fn verify_audit_chain(
801 &self,
802 key: &AuditKey,
803 ) -> Result<VerifyOutcome, TransactionStoreError> {
804 let rows = self.fetch_chain_rows().await?;
805 Ok(crate::audit_chain::verify_chain(key, &rows))
806 }
807}
808
809async fn fetch_chain_rows_from_pool(pool: &PgPool) -> Result<Vec<ChainRow>, TransactionStoreError> {
810 let rows = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(format!(
811 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions ORDER BY seq ASC"
812 )))
813 .fetch_all(pool)
814 .await
815 .map_err(map_sqlx_err)?;
816 rows.into_iter().map(row_to_chain_row).collect()
817}
818
819async fn fetch_event_rows_from_pool(pool: &PgPool) -> Result<Vec<EventRow>, TransactionStoreError> {
821 let rows = sqlx_core::query::query(
822 "SELECT seq, key_id, kind, transaction_id, receipt_digest, \
823 created_at, prev_chain_hash, chain_hash \
824 FROM audit_events ORDER BY seq ASC",
825 )
826 .fetch_all(pool)
827 .await
828 .map_err(map_sqlx_err)?;
829 rows.into_iter().map(row_to_event_row).collect()
830}
831
832async fn append_event(
838 tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
839 key: &AuditKey,
840 kind: AuditEventKind,
841 transaction_id: &str,
842 receipt_digest: &str,
843) -> Result<(), TransactionStoreError> {
844 let prev: Option<(i64, String)> = sqlx_core::query_as::query_as(
845 "SELECT seq, chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1 FOR UPDATE",
846 )
847 .fetch_optional(&mut **tx)
848 .await
849 .map_err(map_sqlx_err)?;
850 let (seq, prev_chain_hash) = match prev {
851 Some((s, h)) => ((s as u64) + 1, h),
852 None => (1, String::new()),
853 };
854 let created_at = now_iso();
855 let key_id = CURRENT_KEY_ID.to_string();
856 let chain_hash = key.event_hash(
857 &EventContent {
858 seq,
859 key_id: &key_id,
860 kind,
861 transaction_id,
862 receipt_digest,
863 created_at: &created_at,
864 },
865 &prev_chain_hash,
866 );
867 sqlx_core::query::query(
868 "INSERT INTO audit_events ( \
869 seq, key_id, kind, transaction_id, receipt_digest, \
870 created_at, chain_hash, prev_chain_hash \
871 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
872 )
873 .bind(seq as i64)
874 .bind(&key_id)
875 .bind(kind.as_str())
876 .bind(transaction_id)
877 .bind(receipt_digest)
878 .bind(&created_at)
879 .bind(&chain_hash)
880 .bind(&prev_chain_hash)
881 .execute(&mut **tx)
882 .await
883 .map_err(map_sqlx_err)?;
884 Ok(())
885}
886
887async fn insert_transaction(
892 tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
893 key: &AuditKey,
894 transaction_id: &str,
895 transaction: NewTransaction,
896) -> Result<InsertedTransaction, TransactionStoreError> {
897 let warnings_json = serialize(&transaction.warnings)?;
898 let status = JobState::Queued;
899 let created_at = now_iso();
900 let key_id = CURRENT_KEY_ID.to_string();
901 let approval_id = Some(key.approval_commitment(transaction_id, &transaction.request_hash));
904
905 let prev: Option<(i64, String)> = sqlx_core::query_as::query_as(
911 "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1 FOR UPDATE",
912 )
913 .fetch_optional(&mut **tx)
914 .await
915 .map_err(map_sqlx_err)?;
916 let (seq, prev_chain_hash) = match prev {
917 Some((s, h)) => ((s as u64) + 1, h),
918 None => (1, String::new()),
919 };
920
921 let event_tip: String = sqlx_core::query_scalar::query_scalar(
924 "SELECT COALESCE((SELECT chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1), '')",
925 )
926 .fetch_one(&mut **tx)
927 .await
928 .map_err(map_sqlx_err)?;
929 let caller_role = transaction.caller_role.as_str();
930 let caller_principal = transaction.caller_principal.as_signed_str();
931
932 let identity = ChainIdentity::V3 {
935 caller_role,
936 event_tip: &event_tip,
937 caller_principal: &caller_principal,
938 };
939 let chain_hash = key.chain_hash(
940 &ChainContent {
941 seq,
942 key_id: &key_id,
943 transaction_id,
944 request_id: &transaction.request_id,
945 request_hash: &transaction.request_hash,
946 action_name: &transaction.action_name,
947 risk_level: transaction.risk_level,
948 summary: &transaction.summary,
949 approval_id: approval_id.as_deref(),
950 warnings_json: &warnings_json,
951 created_at: &created_at,
952 identity,
953 },
954 &prev_chain_hash,
955 );
956
957 sqlx_core::query::query(
958 "INSERT INTO transactions ( \
959 transaction_id, request_id, request_hash, action_name, risk_level, \
960 status, approval_id, summary, warnings_json, created_at, \
961 seq, key_id, chain_hash, prev_chain_hash, \
962 chain_version, caller_role, event_tip, caller_principal \
963 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)",
964 )
965 .bind(transaction_id)
966 .bind(&transaction.request_id)
967 .bind(&transaction.request_hash)
968 .bind(&transaction.action_name)
969 .bind(serialize(&transaction.risk_level)?)
970 .bind(serialize(&status)?)
971 .bind(&approval_id)
972 .bind(&transaction.summary)
973 .bind(&warnings_json)
974 .bind(&created_at)
975 .bind(seq as i64)
976 .bind(&key_id)
977 .bind(&chain_hash)
978 .bind(&prev_chain_hash)
979 .bind(i64::from(identity.version()))
980 .bind(caller_role)
981 .bind(&event_tip)
982 .bind(&caller_principal)
983 .execute(&mut **tx)
984 .await
985 .map_err(map_sqlx_err)?;
986
987 Ok(InsertedTransaction {
988 record: TransactionRecord {
989 transaction_id: transaction_id.to_string(),
990 request_id: transaction.request_id,
991 request_hash: transaction.request_hash,
992 action_name: transaction.action_name,
993 risk_level: transaction.risk_level,
994 status,
995 approval_id,
996 summary: transaction.summary,
997 warnings: transaction.warnings,
998 },
999 seq,
1000 chain_hash,
1001 })
1002}
1003
1004fn row_to_history_entry(
1009 row: sqlx_postgres::PgRow,
1010) -> Result<crate::transactions::JobHistoryEntry, TransactionStoreError> {
1011 Ok(crate::transactions::JobHistoryEntry {
1012 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1013 action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
1014 risk_level: deserialize(
1015 &row.try_get::<String, _>("risk_level")
1016 .map_err(map_sqlx_err)?,
1017 )?,
1018 status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
1019 summary: row.try_get("summary").map_err(map_sqlx_err)?,
1020 created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1021 })
1022}
1023
1024fn row_to_record(row: sqlx_postgres::PgRow) -> Result<TransactionRecord, TransactionStoreError> {
1025 Ok(TransactionRecord {
1026 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1027 request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
1028 request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
1029 action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
1030 risk_level: deserialize(
1031 &row.try_get::<String, _>("risk_level")
1032 .map_err(map_sqlx_err)?,
1033 )?,
1034 status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
1035 approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
1036 summary: row.try_get("summary").map_err(map_sqlx_err)?,
1037 warnings: deserialize(
1038 &row.try_get::<String, _>("warnings_json")
1039 .map_err(map_sqlx_err)?,
1040 )?,
1041 })
1042}
1043
1044fn row_to_chain_row(row: sqlx_postgres::PgRow) -> Result<ChainRow, TransactionStoreError> {
1045 Ok(ChainRow {
1046 seq: row.try_get::<i64, _>("seq").map_err(map_sqlx_err)? as u64,
1047 key_id: row.try_get("key_id").map_err(map_sqlx_err)?,
1048 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1049 request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
1050 request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
1051 action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
1052 risk_level: deserialize(
1053 &row.try_get::<String, _>("risk_level")
1054 .map_err(map_sqlx_err)?,
1055 )?,
1056 summary: row.try_get("summary").map_err(map_sqlx_err)?,
1057 approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
1058 warnings_json: row.try_get("warnings_json").map_err(map_sqlx_err)?,
1059 created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1060 prev_chain_hash: row.try_get("prev_chain_hash").map_err(map_sqlx_err)?,
1061 chain_hash: row.try_get("chain_hash").map_err(map_sqlx_err)?,
1062 chain_version: row
1063 .try_get::<i64, _>("chain_version")
1064 .map_err(map_sqlx_err)? as u32,
1065 caller_role: row.try_get("caller_role").map_err(map_sqlx_err)?,
1066 event_tip: row.try_get("event_tip").map_err(map_sqlx_err)?,
1067 caller_principal: row.try_get("caller_principal").map_err(map_sqlx_err)?,
1068 })
1069}
1070
1071fn row_to_event_row(row: sqlx_postgres::PgRow) -> Result<EventRow, TransactionStoreError> {
1072 Ok(EventRow {
1073 seq: row.try_get::<i64, _>("seq").map_err(map_sqlx_err)? as u64,
1074 key_id: row.try_get("key_id").map_err(map_sqlx_err)?,
1075 kind: row.try_get("kind").map_err(map_sqlx_err)?,
1076 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
1077 receipt_digest: row.try_get("receipt_digest").map_err(map_sqlx_err)?,
1078 created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
1079 prev_chain_hash: row.try_get("prev_chain_hash").map_err(map_sqlx_err)?,
1080 chain_hash: row.try_get("chain_hash").map_err(map_sqlx_err)?,
1081 })
1082}
1083
1084#[cfg(test)]
1085mod tests {
1086 use super::*;
1087
1088 #[test]
1091 fn default_config_keeps_statement_cache_enabled() {
1092 let c = PostgresConfig::default();
1093 assert!(c.statement_cache_capacity > 0);
1094 assert!(c.max_connections >= 4);
1095 assert!(c.acquire_timeout >= Duration::from_secs(5));
1096 }
1097
1098 #[test]
1099 fn url_parsing_accepts_standard_postgres_url() {
1100 let opts = PgConnectOptions::from_str(
1101 "postgres://user:pass@host.example.com:5432/audit?sslmode=require",
1102 )
1103 .expect("standard URL parses");
1104 let _ = opts.statement_cache_capacity(0);
1106 }
1107
1108 #[test]
1109 fn now_iso_produces_a_parseable_rfc3339_timestamp() {
1110 let ts = now_iso();
1117 let parsed = chrono::DateTime::parse_from_rfc3339(&ts);
1118 assert!(
1119 parsed.is_ok(),
1120 "now_iso() must emit a parseable RFC 3339 timestamp, got {ts:?} ({parsed:?})"
1121 );
1122 assert_eq!(
1123 ts.len(),
1124 "2026-07-27T12:34:56.789Z".len(),
1125 "timestamp shape changed unexpectedly: {ts}"
1126 );
1127 }
1128
1129 #[test]
1130 fn now_iso_matches_the_sqlite_backends_timestamp_shape() {
1131 let ts = now_iso();
1137 let (date, rest) = ts.split_once('T').expect("timestamp has a T separator");
1138 assert_eq!(date.len(), "2026-07-27".len(), "date part: {ts}");
1139 assert!(rest.ends_with('Z'), "must be UTC with a Z suffix: {ts}");
1140 let time = rest.trim_end_matches('Z');
1141 let fields: Vec<&str> = time.split(':').collect();
1142 assert_eq!(fields.len(), 3, "expected HH:MM:SS.mmm, got {time:?}");
1143 assert!(
1144 fields[2].contains('.'),
1145 "seconds field must carry fractional milliseconds: {time:?}"
1146 );
1147 let (secs, millis) = fields[2].split_once('.').expect("checked above");
1148 assert_eq!(secs.len(), 2, "seconds must be two digits: {time:?}");
1149 assert_eq!(millis.len(), 3, "expected millisecond precision: {time:?}");
1150 }
1151
1152 #[test]
1153 fn schema_decode_errors_are_classified_as_invariant_failures() {
1154 let error = map_sqlx_err(sqlx_core::Error::ColumnNotFound("chain_hash".to_string()));
1155 assert!(matches!(error, TransactionStoreError::DatabaseInvariant(_)));
1156 }
1157}