1use crate::audit_chain::{
2 self, AuditEventKind, AuditKey, ChainContent, ChainIdentity, ChainRow, EventContent, EventRow,
3 VerifyOutcome, CURRENT_KEY_ID,
4};
5use crate::audit_watermark::emit_chain_tip_watermark;
6use crate::auth::CallerPrincipal;
7use rusqlite::{params, Connection, TransactionBehavior};
8use serde::{de::DeserializeOwned, Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use subtle::ConstantTimeEq;
12use sysknife_types::{CallerRole, JobState, PreviewEnvelope, RiskLevel, TransactionRecord};
13use uuid::Uuid;
14
15pub(crate) const APPROVAL_RECEIPT_TTL_MINUTES: i64 = 15;
26
27#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
35pub struct JobHistoryEntry {
36 pub transaction_id: String,
37 pub action_name: String,
38 pub risk_level: RiskLevel,
39 pub status: JobState,
40 pub summary: String,
41 pub created_at: String,
42}
43
44#[derive(Clone, Debug, PartialEq, Eq)]
50pub struct NewTransaction {
51 pub request_id: String,
52 pub request_hash: String,
53 pub action_name: String,
54 pub risk_level: RiskLevel,
55 pub summary: String,
75 pub warnings: Vec<String>,
76 pub caller_role: CallerRole,
85 pub caller_principal: CallerPrincipal,
93}
94
95struct SqliteMigration {
104 version: i64,
105 name: &'static str,
106 sql: &'static str,
107}
108
109const SQLITE_MIGRATIONS: &[SqliteMigration] = &[
127 SqliteMigration {
128 version: 1,
129 name: "initial_audit_schema",
130 sql: r#"
131 CREATE TABLE IF NOT EXISTS transactions (
132 transaction_id TEXT PRIMARY KEY,
133 request_id TEXT NOT NULL,
134 request_hash TEXT NOT NULL,
135 action_name TEXT NOT NULL,
136 risk_level TEXT NOT NULL,
137 status TEXT NOT NULL,
138 approval_id TEXT,
139 summary TEXT NOT NULL,
140 warnings_json TEXT NOT NULL,
141 created_at TEXT NOT NULL DEFAULT (datetime('now')),
142 seq INTEGER NOT NULL UNIQUE,
143 key_id TEXT NOT NULL,
144 chain_hash TEXT NOT NULL,
145 prev_chain_hash TEXT NOT NULL DEFAULT ''
146 );
147
148 CREATE TABLE IF NOT EXISTS transaction_previews (
149 transaction_id TEXT PRIMARY KEY,
150 preview_json TEXT NOT NULL
151 );
152
153 CREATE TABLE IF NOT EXISTS transaction_approvals (
154 transaction_id TEXT PRIMARY KEY,
155 receipt_digest TEXT NOT NULL,
156 approved_at TEXT NOT NULL DEFAULT (datetime('now')),
157 consumed_at TEXT
158 );
159
160 CREATE INDEX IF NOT EXISTS transactions_seq_idx ON transactions(seq);
161 "#,
162 },
163 SqliteMigration {
172 version: 2,
173 name: "caller_identity_and_approval_events",
174 sql: r#"
175 ALTER TABLE transactions ADD COLUMN chain_version INTEGER NOT NULL DEFAULT 1;
176 ALTER TABLE transactions ADD COLUMN caller_role TEXT;
177 ALTER TABLE transactions ADD COLUMN event_tip TEXT;
178
179 CREATE TABLE IF NOT EXISTS audit_events (
180 seq INTEGER PRIMARY KEY,
181 key_id TEXT NOT NULL,
182 kind TEXT NOT NULL,
183 transaction_id TEXT NOT NULL,
184 receipt_digest TEXT NOT NULL,
185 created_at TEXT NOT NULL,
186 chain_hash TEXT NOT NULL,
187 prev_chain_hash TEXT NOT NULL DEFAULT ''
188 );
189
190 CREATE INDEX IF NOT EXISTS audit_events_transaction_idx
191 ON audit_events(transaction_id);
192 "#,
193 },
194 SqliteMigration {
199 version: 3,
200 name: "caller_principal",
201 sql: r#"
202 ALTER TABLE transactions ADD COLUMN caller_principal TEXT;
203 "#,
204 },
205];
206
207const CHAIN_ROW_COLUMNS: &str = "seq, key_id, transaction_id, request_id, request_hash, \
214 action_name, risk_level, summary, approval_id, warnings_json, \
215 created_at, prev_chain_hash, chain_hash, chain_version, caller_role, event_tip, \
216 caller_principal";
217
218fn chain_row_from_sqlite(row: &rusqlite::Row<'_>) -> rusqlite::Result<ChainRow> {
219 Ok(ChainRow {
220 seq: row.get::<_, i64>(0)? as u64,
221 key_id: row.get(1)?,
222 transaction_id: row.get(2)?,
223 request_id: row.get(3)?,
224 request_hash: row.get(4)?,
225 action_name: row.get(5)?,
226 risk_level: deserialize_field(&row.get::<_, String>(6)?).map_err(|e| {
227 rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(e))
228 })?,
229 summary: row.get(7)?,
230 approval_id: row.get(8)?,
231 warnings_json: row.get(9)?,
232 created_at: row.get(10)?,
233 prev_chain_hash: row.get(11)?,
234 chain_hash: row.get(12)?,
235 chain_version: row.get::<_, i64>(13)? as u32,
236 caller_role: row.get(14)?,
237 event_tip: row.get(15)?,
238 caller_principal: row.get(16)?,
239 })
240}
241
242fn event_row_from_sqlite(row: &rusqlite::Row<'_>) -> rusqlite::Result<EventRow> {
243 Ok(EventRow {
244 seq: row.get::<_, i64>(0)? as u64,
245 key_id: row.get(1)?,
246 kind: row.get(2)?,
247 transaction_id: row.get(3)?,
248 receipt_digest: row.get(4)?,
249 created_at: row.get(5)?,
250 prev_chain_hash: row.get(6)?,
251 chain_hash: row.get(7)?,
252 })
253}
254
255#[derive(Clone, Debug)]
256pub struct TransactionStore {
257 path: PathBuf,
258 audit_key: Option<Arc<AuditKey>>,
261}
262
263#[derive(Clone, Debug, PartialEq)]
264pub struct RecordedPreviewedTransaction {
265 pub transaction: TransactionRecord,
266 pub preview: PreviewEnvelope,
267}
268
269struct InsertedTransaction {
270 record: TransactionRecord,
271 seq: u64,
272 chain_hash: String,
273}
274
275#[derive(Debug, thiserror::Error)]
276pub enum TransactionStoreError {
277 #[error("io error: {0}")]
278 Io(#[from] std::io::Error),
279
280 #[error("sqlite error: {0}")]
281 Sqlite(#[from] rusqlite::Error),
282
283 #[error("json error: {0}")]
284 Json(#[from] serde_json::Error),
285
286 #[error("database invariant violation: {0}")]
287 DatabaseInvariant(String),
288
289 #[error("transaction not found: {0}")]
290 NotFound(String),
291
292 #[error("invalid transition from {from:?} to {to:?}")]
293 InvalidTransition { from: JobState, to: JobState },
294
295 #[error("audit chain misconfiguration: {0}")]
296 AuditChainMissing(&'static str),
297
298 #[error("transaction {0} status changed concurrently; retry")]
306 ConcurrentStatusChange(String),
307}
308
309impl TransactionStore {
310 pub fn open_read_only(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
314 let path = path.as_ref().to_path_buf();
315 if let Some(parent) = path.parent() {
316 ensure_private_dir(parent)?;
317 }
318
319 let store = Self {
320 path,
321 audit_key: None,
322 };
323 store.initialize()?;
324 Ok(store)
325 }
326
327 pub fn open(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
334 let db_path = path.as_ref();
335 let key_path = std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
336 .map(PathBuf::from)
337 .unwrap_or_else(|_| {
338 db_path
339 .parent()
340 .unwrap_or_else(|| Path::new("."))
341 .join("audit-key")
342 });
343 let key = AuditKey::load_or_generate(&key_path).map_err(|e| {
344 TransactionStoreError::Io(std::io::Error::new(
345 std::io::ErrorKind::InvalidData,
346 format!("audit key load failed: {e}"),
347 ))
348 })?;
349 Self::open_with_key(path, Arc::new(key))
350 }
351
352 pub fn open_with_key(
355 path: impl AsRef<Path>,
356 audit_key: Arc<AuditKey>,
357 ) -> Result<Self, TransactionStoreError> {
358 let path = path.as_ref().to_path_buf();
359 if let Some(parent) = path.parent() {
360 ensure_private_dir(parent)?;
361 }
362
363 let store = Self {
364 path,
365 audit_key: Some(audit_key),
366 };
367 store.initialize()?;
368 restrict_db_file_mode(&store.path);
369 Ok(store)
370 }
371
372 pub fn record(
373 &self,
374 transaction: NewTransaction,
375 ) -> Result<TransactionRecord, TransactionStoreError> {
376 let key = self
377 .audit_key
378 .as_ref()
379 .ok_or(TransactionStoreError::AuditChainMissing(
380 "this TransactionStore was opened read-only; cannot record",
381 ))?;
382 let mut conn = self.connection()?;
383 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
388 let transaction_id = Uuid::new_v4().to_string();
389 let inserted = Self::insert_transaction(&tx, key, &transaction_id, transaction)?;
390 tx.commit()?;
391 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
392 Ok(inserted.record)
393 }
394
395 pub fn record_previewed(
396 &self,
397 transaction: NewTransaction,
398 preview: PreviewEnvelope,
399 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
400 let key = self
401 .audit_key
402 .as_ref()
403 .ok_or(TransactionStoreError::AuditChainMissing(
404 "this TransactionStore was opened read-only; cannot record",
405 ))?;
406 let mut conn = self.connection()?;
407 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
412 let transaction_id = Uuid::new_v4().to_string();
413 let inserted = Self::insert_transaction(&tx, key, &transaction_id, transaction)?;
414 Self::insert_preview(&tx, &inserted.record.transaction_id, &preview)?;
415 tx.commit()?;
416 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
417
418 Ok(RecordedPreviewedTransaction {
419 transaction: inserted.record,
420 preview,
421 })
422 }
423
424 pub fn get(
425 &self,
426 transaction_id: &str,
427 ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
428 let conn = self.connection()?;
429 let mut stmt = conn.prepare(
430 "SELECT
431 transaction_id,
432 request_id,
433 request_hash,
434 action_name,
435 risk_level,
436 status,
437 approval_id,
438 summary,
439 warnings_json
440 FROM transactions
441 WHERE transaction_id = ?1",
442 )?;
443 let mut rows = stmt.query(params![transaction_id])?;
444 if let Some(row) = rows.next()? {
445 Ok(Some(row_to_record(row)?))
446 } else {
447 Ok(None)
448 }
449 }
450
451 pub fn get_preview(
452 &self,
453 transaction_id: &str,
454 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
455 let conn = self.connection()?;
456 let mut stmt = conn.prepare(
457 "SELECT preview_json
458 FROM transaction_previews
459 WHERE transaction_id = ?1",
460 )?;
461 let mut rows = stmt.query(params![transaction_id])?;
462 if let Some(row) = rows.next()? {
463 let preview_json: String = row.get(0)?;
464 Ok(Some(serde_json::from_str(&preview_json)?))
465 } else {
466 Ok(None)
467 }
468 }
469
470 pub fn update_status(
471 &self,
472 transaction_id: &str,
473 new_status: JobState,
474 ) -> Result<(), TransactionStoreError> {
475 let conn = self.connection()?;
476
477 let current_status: String = conn
479 .query_row(
480 "SELECT status FROM transactions WHERE transaction_id = ?1",
481 params![transaction_id],
482 |row| row.get(0),
483 )
484 .map_err(|e| match e {
485 rusqlite::Error::QueryReturnedNoRows => {
486 TransactionStoreError::NotFound(transaction_id.to_string())
487 }
488 other => TransactionStoreError::Sqlite(other),
489 })?;
490
491 let current: JobState = deserialize_field(¤t_status)?;
492 if !crate::jobs::allowed_transition(¤t, &new_status) {
493 return Err(TransactionStoreError::InvalidTransition {
494 from: current,
495 to: new_status,
496 });
497 }
498
499 let rows_affected = conn.execute(
511 "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2 AND status = ?3",
512 params![
513 serialize_field(&new_status)?,
514 transaction_id,
515 current_status
516 ],
517 )?;
518 if rows_affected == 0 {
519 return Err(TransactionStoreError::ConcurrentStatusChange(
520 transaction_id.to_string(),
521 ));
522 }
523 Ok(())
524 }
525
526 pub fn approve_transaction(
528 &self,
529 transaction_id: &str,
530 ) -> Result<Option<String>, TransactionStoreError> {
531 let key = self
532 .audit_key
533 .as_ref()
534 .ok_or(TransactionStoreError::AuditChainMissing(
535 "this TransactionStore was opened read-only; cannot approve",
536 ))?;
537 let Some(record) = self.get(transaction_id)? else {
538 return Ok(None);
539 };
540 let receipt = key.approval_receipt(transaction_id, &record.request_hash);
541 let receipt_digest = audit_chain::approval_receipt_digest(&receipt);
542 let Some(committed_digest) = record.approval_id.as_deref() else {
543 return Err(TransactionStoreError::DatabaseInvariant(format!(
544 "transaction {transaction_id} has no signed approval commitment"
545 )));
546 };
547 if !bool::from(receipt_digest.as_bytes().ct_eq(committed_digest.as_bytes())) {
548 return Err(TransactionStoreError::DatabaseInvariant(format!(
549 "transaction {transaction_id} approval commitment does not match its signed preview"
550 )));
551 }
552
553 let mut conn = self.connection()?;
556 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
557 let queued_json = serialize_field(&JobState::Queued)?;
558 let rows_affected = tx.execute(
559 &format!(
560 "INSERT INTO transaction_approvals (transaction_id, receipt_digest) \
561 SELECT transaction_id, ?1 FROM transactions \
562 WHERE transaction_id = ?2 \
563 AND status = ?3 \
564 AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
565 AND NOT EXISTS ( \
566 SELECT 1 FROM transaction_approvals WHERE transaction_id = ?2 \
567 )"
568 ),
569 params![receipt_digest, transaction_id, queued_json],
570 )?;
571 if rows_affected > 0 {
572 Self::append_event(
573 &tx,
574 key,
575 AuditEventKind::ApprovalGranted,
576 transaction_id,
577 &receipt_digest,
578 )?;
579 }
580 tx.commit()?;
581 Ok((rows_affected > 0).then_some(receipt))
582 }
583
584 pub fn revoke_unconsumed_approval(
587 &self,
588 transaction_id: &str,
589 ) -> Result<bool, TransactionStoreError> {
590 let key = self
591 .audit_key
592 .as_ref()
593 .ok_or(TransactionStoreError::AuditChainMissing(
594 "this TransactionStore was opened read-only; cannot revoke",
595 ))?;
596 let mut conn = self.connection()?;
597 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
598 let digest: Option<String> = tx
601 .query_row(
602 "SELECT receipt_digest FROM transaction_approvals \
603 WHERE transaction_id = ?1 AND consumed_at IS NULL",
604 params![transaction_id],
605 |row| row.get(0),
606 )
607 .map(Some)
608 .or_else(|e| match e {
609 rusqlite::Error::QueryReturnedNoRows => Ok(None),
610 other => Err(other),
611 })?;
612 let rows_affected = tx.execute(
613 "DELETE FROM transaction_approvals \
614 WHERE transaction_id = ?1 AND consumed_at IS NULL",
615 params![transaction_id],
616 )?;
617 if rows_affected > 0 {
618 let digest = digest.ok_or_else(|| {
619 TransactionStoreError::DatabaseInvariant(format!(
620 "revoked an approval for {transaction_id} that had no receipt digest"
621 ))
622 })?;
623 Self::append_event(
624 &tx,
625 key,
626 AuditEventKind::ApprovalRevoked,
627 transaction_id,
628 &digest,
629 )?;
630 }
631 tx.commit()?;
632 Ok(rows_affected > 0)
633 }
634
635 pub fn claim_approved_for_execution(
637 &self,
638 transaction_id: &str,
639 receipt_digest: &str,
640 ) -> Result<bool, TransactionStoreError> {
641 let key = self
642 .audit_key
643 .as_ref()
644 .ok_or(TransactionStoreError::AuditChainMissing(
645 "this TransactionStore was opened read-only; cannot claim",
646 ))?;
647 let mut conn = self.connection()?;
648 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
649 let queued_json = serialize_field(&JobState::Queued)?;
650 let running_json = serialize_field(&JobState::Running)?;
651 let rows_affected = tx.execute(
652 &format!(
653 "UPDATE transactions SET status = ?1 \
654 WHERE transaction_id = ?2 \
655 AND status = ?3 \
656 AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
657 AND EXISTS ( \
658 SELECT 1 FROM transaction_approvals \
659 WHERE transaction_id = ?2 \
660 AND receipt_digest = ?4 \
661 AND consumed_at IS NULL \
662 )"
663 ),
664 params![running_json, transaction_id, queued_json, receipt_digest],
665 )?;
666 if rows_affected > 0 {
667 tx.execute(
668 "UPDATE transaction_approvals \
669 SET consumed_at = datetime('now') \
670 WHERE transaction_id = ?1 AND consumed_at IS NULL",
671 params![transaction_id],
672 )?;
673 Self::append_event(
674 &tx,
675 key,
676 AuditEventKind::ApprovalConsumed,
677 transaction_id,
678 receipt_digest,
679 )?;
680 }
681 tx.commit()?;
682 Ok(rows_affected > 0)
683 }
684
685 pub fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
700 let conn = self.connection()?;
701 let canceled_json = serialize_field(&JobState::Canceled)?;
702 let queued_json = serialize_field(&JobState::Queued)?;
703 let rows_affected = conn.execute(
704 &format!(
705 "UPDATE transactions SET status = ?1 \
706 WHERE status = ?2 \
707 AND julianday(created_at) <= julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes')"
708 ),
709 params![canceled_json, queued_json],
710 )?;
711 Ok(rows_affected as u64)
712 }
713
714 pub fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
723 let conn = self.connection()?;
724 let canceled_json = serialize_field(&JobState::Canceled)?;
725 let queued_json = serialize_field(&JobState::Queued)?;
726 let rows_affected = conn.execute(
727 "UPDATE transactions SET status = ?1 \
728 WHERE transaction_id = ?2 AND status = ?3",
729 params![canceled_json, transaction_id, queued_json],
730 )?;
731 Ok(rows_affected > 0)
732 }
733
734 pub fn list_transactions(
743 &self,
744 limit: u32,
745 status_filter: Option<&str>,
746 action_filter: Option<&str>,
747 since_hours: Option<u32>,
748 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
749 let conn = self.connection()?;
750 let (filter_sql, param_values) =
751 Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
752 let sql = format!(
753 "SELECT transaction_id, request_id, request_hash, action_name, \
754 risk_level, status, approval_id, summary, warnings_json \
755 FROM transactions WHERE 1=1{filter_sql}"
756 );
757 let params_ref: Vec<&dyn rusqlite::types::ToSql> =
758 param_values.iter().map(|b| b.as_ref()).collect();
759
760 let mut stmt = conn.prepare(&sql)?;
761 let rows = stmt.query_map(params_ref.as_slice(), |row| Ok(row_to_record(row)))?;
762
763 let mut results = Vec::new();
764 for row in rows {
765 results.push(row??);
766 }
767 Ok(results)
768 }
769
770 pub fn list_history(
775 &self,
776 limit: u32,
777 status_filter: Option<&str>,
778 action_filter: Option<&str>,
779 since_hours: Option<u32>,
780 ) -> Result<Vec<JobHistoryEntry>, TransactionStoreError> {
781 let conn = self.connection()?;
782 let (filter_sql, param_values) =
783 Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
784 let sql = format!(
785 "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
786 FROM transactions WHERE 1=1{filter_sql}"
787 );
788 let params_ref: Vec<&dyn rusqlite::types::ToSql> =
789 param_values.iter().map(|b| b.as_ref()).collect();
790
791 let mut stmt = conn.prepare(&sql)?;
792 let rows = stmt.query_map(params_ref.as_slice(), |row| {
793 Ok((|| {
794 Ok::<JobHistoryEntry, TransactionStoreError>(JobHistoryEntry {
795 transaction_id: row.get(0)?,
796 action_name: row.get(1)?,
797 risk_level: deserialize_field(&row.get::<_, String>(2)?)?,
798 status: deserialize_field(&row.get::<_, String>(3)?)?,
799 summary: row.get(4)?,
800 created_at: row.get(5)?,
801 })
802 })())
803 })?;
804
805 let mut results = Vec::new();
806 for row in rows {
807 results.push(row??);
808 }
809 Ok(results)
810 }
811
812 fn build_history_filter(
817 limit: u32,
818 status_filter: Option<&str>,
819 action_filter: Option<&str>,
820 since_hours: Option<u32>,
821 ) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), TransactionStoreError> {
822 let mut sql = String::new();
823 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
824
825 if let Some(status) = status_filter {
826 let validated: JobState = deserialize_field(&format!("\"{status}\""))?;
830 let status_json = serialize_field(&validated)?;
831 sql.push_str(" AND status = ?");
832 param_values.push(Box::new(status_json));
833 }
834
835 if let Some(action) = action_filter {
836 sql.push_str(" AND action_name = ?");
837 param_values.push(Box::new(action.to_string()));
838 }
839
840 if let Some(hours) = since_hours {
841 sql.push_str(" AND julianday(created_at) > julianday('now', '-' || ? || ' hours')");
842 param_values.push(Box::new(hours));
843 }
844
845 sql.push_str(" ORDER BY seq DESC LIMIT ?");
846 param_values.push(Box::new(limit.min(100)));
847
848 Ok((sql, param_values))
849 }
850
851 fn connection(&self) -> Result<Connection, TransactionStoreError> {
852 let conn = Connection::open(&self.path)?;
853 conn.pragma_update(None, "journal_mode", "WAL")?;
864 conn.pragma_update(None, "synchronous", "NORMAL")?;
865 conn.pragma_update(None, "foreign_keys", "ON")?;
866 conn.busy_timeout(std::time::Duration::from_secs(5))?;
867 Ok(conn)
868 }
869
870 fn initialize(&self) -> Result<(), TransactionStoreError> {
871 let mut conn = self.connection()?;
872 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
873 tx.execute_batch(
874 "CREATE TABLE IF NOT EXISTS schema_migrations (\
875 version INTEGER PRIMARY KEY,\
876 name TEXT NOT NULL,\
877 applied_at TEXT NOT NULL DEFAULT (datetime('now'))\
878 );",
879 )?;
880 let current: i64 = tx.query_row(
881 "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
882 [],
883 |row| row.get(0),
884 )?;
885 let latest = SQLITE_MIGRATIONS.last().map_or(0, |m| m.version);
886 if current > latest {
887 return Err(TransactionStoreError::DatabaseInvariant(format!(
888 "sqlite schema version {current} is newer than this binary supports ({latest})"
889 )));
890 }
891 for migration in SQLITE_MIGRATIONS.iter().filter(|m| m.version > current) {
892 tx.execute_batch(migration.sql)?;
893 tx.execute(
894 "INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)",
895 params![migration.version, migration.name],
896 )?;
897 }
898 tx.commit()?;
899 Ok(())
900 }
901
902 pub fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
904 let conn = self.connection()?;
905 let mut stmt = conn.prepare(&format!(
906 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions ORDER BY seq ASC"
907 ))?;
908 let rows = stmt.query_map([], chain_row_from_sqlite)?;
909 let mut out = Vec::new();
910 for row in rows {
911 out.push(row?);
912 }
913 Ok(out)
914 }
915
916 pub fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
918 let conn = self.connection()?;
919 let mut stmt = conn.prepare(
920 "SELECT seq, key_id, kind, transaction_id, receipt_digest, \
921 created_at, prev_chain_hash, chain_hash \
922 FROM audit_events ORDER BY seq ASC",
923 )?;
924 let rows = stmt.query_map([], event_row_from_sqlite)?;
925 let mut out = Vec::new();
926 for row in rows {
927 out.push(row?);
928 }
929 Ok(out)
930 }
931
932 pub fn verify_audit_chain(
934 &self,
935 key: &AuditKey,
936 ) -> Result<VerifyOutcome, TransactionStoreError> {
937 let rows = self.fetch_chain_rows()?;
938 Ok(audit_chain::verify_chain(key, &rows))
939 }
940
941 pub fn verify_event_chain(
943 &self,
944 key: &AuditKey,
945 ) -> Result<VerifyOutcome, TransactionStoreError> {
946 let rows = self.fetch_event_rows()?;
947 Ok(audit_chain::verify_event_chain(key, &rows))
948 }
949
950 pub fn verify_event_binding(
953 &self,
954 ) -> Result<audit_chain::BindingOutcome, TransactionStoreError> {
955 let tx_rows = self.fetch_chain_rows()?;
956 let event_rows = self.fetch_event_rows()?;
957 Ok(audit_chain::verify_event_binding(&tx_rows, &event_rows))
958 }
959
960 pub fn verify_audit_chain_with_pubkey(
963 &self,
964 verifying_key_hex: &str,
965 ) -> Result<VerifyOutcome, TransactionStoreError> {
966 let rows = self.fetch_chain_rows()?;
967 Ok(audit_chain::verify_chain_with_pubkey(
968 verifying_key_hex,
969 &rows,
970 ))
971 }
972
973 pub fn fetch_chain_row(
976 &self,
977 transaction_id: &str,
978 ) -> Result<Option<ChainRow>, TransactionStoreError> {
979 let conn = self.connection()?;
980 let mut stmt = conn.prepare(&format!(
981 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions WHERE transaction_id = ?1"
982 ))?;
983 let mut rows = stmt.query(params![transaction_id])?;
984 if let Some(row) = rows.next()? {
985 Ok(Some(chain_row_from_sqlite(row)?))
986 } else {
987 Ok(None)
988 }
989 }
990
991 fn next_seq_and_prev_hash(conn: &Connection) -> Result<(u64, String), TransactionStoreError> {
995 let prev: Option<(i64, String)> = conn
996 .query_row(
997 "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1",
998 [],
999 |row| Ok((row.get(0)?, row.get(1)?)),
1000 )
1001 .map(Some)
1002 .or_else(|e| match e {
1003 rusqlite::Error::QueryReturnedNoRows => Ok(None),
1004 other => Err(other),
1005 })?;
1006 Ok(match prev {
1007 Some((seq, hash)) => ((seq as u64) + 1, hash),
1008 None => (1, String::new()),
1009 })
1010 }
1011
1012 fn event_chain_tip(conn: &Connection) -> Result<Option<String>, TransactionStoreError> {
1015 conn.query_row(
1016 "SELECT chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1",
1017 [],
1018 |row| row.get(0),
1019 )
1020 .map(Some)
1021 .or_else(|e| match e {
1022 rusqlite::Error::QueryReturnedNoRows => Ok(None),
1023 other => Err(other.into()),
1024 })
1025 }
1026
1027 fn append_event(
1032 conn: &Connection,
1033 key: &AuditKey,
1034 kind: AuditEventKind,
1035 transaction_id: &str,
1036 receipt_digest: &str,
1037 ) -> Result<(), TransactionStoreError> {
1038 let prev_chain_hash = Self::event_chain_tip(conn)?.unwrap_or_default();
1039 let seq: i64 = conn.query_row(
1040 "SELECT COALESCE(MAX(seq), 0) + 1 FROM audit_events",
1041 [],
1042 |row| row.get(0),
1043 )?;
1044 let created_at: String =
1045 conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
1046 row.get(0)
1047 })?;
1048 let key_id = CURRENT_KEY_ID.to_string();
1049 let chain_hash = key.event_hash(
1050 &EventContent {
1051 seq: seq as u64,
1052 key_id: &key_id,
1053 kind,
1054 transaction_id,
1055 receipt_digest,
1056 created_at: &created_at,
1057 },
1058 &prev_chain_hash,
1059 );
1060 conn.execute(
1061 "INSERT INTO audit_events (
1062 seq, key_id, kind, transaction_id, receipt_digest,
1063 created_at, chain_hash, prev_chain_hash
1064 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1065 params![
1066 seq,
1067 key_id,
1068 kind.as_str(),
1069 transaction_id,
1070 receipt_digest,
1071 created_at,
1072 chain_hash,
1073 prev_chain_hash,
1074 ],
1075 )?;
1076 Ok(())
1077 }
1078
1079 fn insert_transaction(
1080 conn: &Connection,
1081 key: &AuditKey,
1082 transaction_id: &str,
1083 transaction: NewTransaction,
1084 ) -> Result<InsertedTransaction, TransactionStoreError> {
1085 let request_id = transaction.request_id;
1086 let request_hash = transaction.request_hash;
1087 let action_name = transaction.action_name;
1088 let risk_level = transaction.risk_level;
1089 let status = JobState::Queued;
1091 let approval_id = Some(key.approval_commitment(transaction_id, &request_hash));
1098 let summary = transaction.summary;
1099 let warnings = transaction.warnings;
1100 let warnings_json = serde_json::to_string(&warnings)?;
1101
1102 let (seq, prev_chain_hash) = Self::next_seq_and_prev_hash(conn)?;
1105
1106 let created_at: String =
1110 conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
1111 row.get(0)
1112 })?;
1113
1114 let key_id = CURRENT_KEY_ID.to_string();
1115 let event_tip = Self::event_chain_tip(conn)?.unwrap_or_default();
1119 let caller_role = transaction.caller_role.as_str();
1120 let caller_principal = transaction.caller_principal.as_signed_str();
1121 let identity = ChainIdentity::V3 {
1125 caller_role,
1126 event_tip: &event_tip,
1127 caller_principal: &caller_principal,
1128 };
1129 let chain_hash = key.chain_hash(
1130 &ChainContent {
1131 seq,
1132 key_id: &key_id,
1133 transaction_id,
1134 request_id: &request_id,
1135 request_hash: &request_hash,
1136 action_name: &action_name,
1137 risk_level,
1138 summary: &summary,
1139 approval_id: approval_id.as_deref(),
1140 warnings_json: &warnings_json,
1141 created_at: &created_at,
1142 identity,
1143 },
1144 &prev_chain_hash,
1145 );
1146
1147 let record = TransactionRecord {
1148 transaction_id: transaction_id.to_string(),
1149 request_id: request_id.clone(),
1150 request_hash: request_hash.clone(),
1151 action_name: action_name.clone(),
1152 risk_level,
1153 status,
1154 approval_id: approval_id.clone(),
1155 summary: summary.clone(),
1156 warnings: warnings.clone(),
1157 };
1158
1159 conn.execute(
1160 "INSERT INTO transactions (
1161 transaction_id,
1162 request_id,
1163 request_hash,
1164 action_name,
1165 risk_level,
1166 status,
1167 approval_id,
1168 summary,
1169 warnings_json,
1170 created_at,
1171 seq,
1172 key_id,
1173 chain_hash,
1174 prev_chain_hash,
1175 chain_version,
1176 caller_role,
1177 event_tip,
1178 caller_principal
1179 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
1180 params![
1181 transaction_id,
1182 request_id,
1183 request_hash,
1184 action_name,
1185 serialize_field(&risk_level)?,
1186 serialize_field(&status)?,
1187 approval_id,
1188 summary,
1189 warnings_json,
1190 created_at,
1191 seq as i64,
1192 key_id,
1193 chain_hash,
1194 prev_chain_hash,
1195 identity.version() as i64,
1196 caller_role,
1197 event_tip,
1198 caller_principal,
1199 ],
1200 )?;
1201
1202 Ok(InsertedTransaction {
1203 record,
1204 seq,
1205 chain_hash,
1206 })
1207 }
1208
1209 fn insert_preview(
1210 conn: &Connection,
1211 transaction_id: &str,
1212 preview: &PreviewEnvelope,
1213 ) -> Result<(), TransactionStoreError> {
1214 conn.execute(
1215 "INSERT INTO transaction_previews (transaction_id, preview_json)
1216 VALUES (?1, ?2)",
1217 params![transaction_id, serde_json::to_string(preview)?],
1218 )?;
1219 Ok(())
1220 }
1221}
1222
1223fn ensure_private_dir(dir: &Path) -> std::io::Result<()> {
1230 use std::os::unix::fs::DirBuilderExt;
1231 if dir.exists() {
1232 return Ok(());
1233 }
1234 std::fs::DirBuilder::new()
1235 .recursive(true)
1236 .mode(0o700)
1237 .create(dir)
1238}
1239
1240fn restrict_db_file_mode(db_path: &Path) {
1254 use std::os::unix::fs::PermissionsExt;
1255
1256 let sidecars = [
1260 db_path.to_path_buf(),
1261 with_suffix(db_path, "-wal"),
1262 with_suffix(db_path, "-shm"),
1263 ];
1264 for path in sidecars {
1265 let Ok(metadata) = std::fs::metadata(&path) else {
1266 continue; };
1268 let mode = metadata.permissions().mode() & 0o777;
1269 if mode & 0o077 == 0 {
1270 continue; }
1272 if let Err(err) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) {
1273 eprintln!(
1274 "[sysknife-daemon] WARNING: {} is mode {mode:o} and could not be \
1275 narrowed to 0600 ({err}); it is readable by anyone who can reach \
1276 it. The parent directory should be 0700 — check it.",
1277 path.display()
1278 );
1279 }
1280 }
1281}
1282
1283fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
1286 let mut name = path.as_os_str().to_os_string();
1287 name.push(suffix);
1288 PathBuf::from(name)
1289}
1290
1291fn row_to_record(row: &rusqlite::Row) -> Result<TransactionRecord, TransactionStoreError> {
1292 Ok(TransactionRecord {
1293 transaction_id: row.get(0)?,
1294 request_id: row.get(1)?,
1295 request_hash: row.get(2)?,
1296 action_name: row.get(3)?,
1297 risk_level: deserialize_field(&row.get::<_, String>(4)?)?,
1298 status: deserialize_field(&row.get::<_, String>(5)?)?,
1299 approval_id: row.get(6)?,
1300 summary: row.get(7)?,
1301 warnings: serde_json::from_str(&row.get::<_, String>(8)?)?,
1302 })
1303}
1304
1305fn serialize_field<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
1306 serde_json::to_string(value)
1307}
1308
1309fn deserialize_field<T: DeserializeOwned>(value: &str) -> Result<T, serde_json::Error> {
1310 serde_json::from_str(value)
1311}
1312
1313#[cfg(test)]
1314mod tests {
1315 use super::*;
1316 use crate::audit_chain::CHAIN_VERSION_CURRENT;
1317 use crate::audit_chain::CHAIN_VERSION_LEGACY;
1318 use std::os::unix::fs::PermissionsExt;
1319 use tempfile::tempdir;
1320
1321 fn test_store(path: impl AsRef<Path>) -> TransactionStore {
1325 let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
1326 TransactionStore::open_with_key(path, key).unwrap()
1327 }
1328
1329 fn legacy_v1_database(path: &Path, key: &AuditKey) -> String {
1336 let conn = Connection::open(path).unwrap();
1337 conn.execute_batch(
1338 "CREATE TABLE schema_migrations (
1339 version INTEGER PRIMARY KEY,
1340 name TEXT NOT NULL,
1341 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1342 );",
1343 )
1344 .unwrap();
1345 conn.execute_batch(SQLITE_MIGRATIONS[0].sql).unwrap();
1346 conn.execute(
1347 "INSERT INTO schema_migrations (version, name) VALUES (1, ?1)",
1348 params![SQLITE_MIGRATIONS[0].name],
1349 )
1350 .unwrap();
1351
1352 let transaction_id = "tx-legacy";
1353 let created_at = "2026-04-24T12:00:00.000Z";
1354 let chain_hash = key.chain_hash(
1355 &ChainContent {
1356 seq: 1,
1357 key_id: CURRENT_KEY_ID,
1358 transaction_id,
1359 request_id: "req-legacy",
1360 request_hash: "hash-legacy",
1361 action_name: "UpdateSystem",
1362 risk_level: RiskLevel::High,
1363 summary: "Upgrade the system",
1364 approval_id: None,
1365 warnings_json: "[]",
1366 created_at,
1367 identity: ChainIdentity::LegacyV1,
1368 },
1369 "",
1370 );
1371 conn.execute(
1372 "INSERT INTO transactions (
1373 transaction_id, request_id, request_hash, action_name, risk_level,
1374 status, approval_id, summary, warnings_json, created_at,
1375 seq, key_id, chain_hash, prev_chain_hash
1376 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, 1, ?10, ?11, '')",
1377 params![
1378 transaction_id,
1379 "req-legacy",
1380 "hash-legacy",
1381 "UpdateSystem",
1382 serialize_field(&RiskLevel::High).unwrap(),
1383 serialize_field(&JobState::Queued).unwrap(),
1384 "Upgrade the system",
1385 "[]",
1386 created_at,
1387 CURRENT_KEY_ID,
1388 chain_hash,
1389 ],
1390 )
1391 .unwrap();
1392 transaction_id.to_string()
1393 }
1394
1395 fn v2_database(path: &Path, key: &AuditKey) -> String {
1402 let conn = Connection::open(path).unwrap();
1403 conn.execute_batch(
1404 "CREATE TABLE schema_migrations (
1405 version INTEGER PRIMARY KEY,
1406 name TEXT NOT NULL,
1407 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1408 );",
1409 )
1410 .unwrap();
1411 for migration in &SQLITE_MIGRATIONS[0..=1] {
1412 conn.execute_batch(migration.sql).unwrap();
1413 conn.execute(
1414 "INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)",
1415 params![migration.version, migration.name],
1416 )
1417 .unwrap();
1418 }
1419
1420 let transaction_id = "tx-v2";
1421 let created_at = "2026-07-01T12:00:00.000Z";
1422 let chain_hash = key.chain_hash(
1423 &ChainContent {
1424 seq: 1,
1425 key_id: CURRENT_KEY_ID,
1426 transaction_id,
1427 request_id: "req-v2",
1428 request_hash: "hash-v2",
1429 action_name: "UpdateSystem",
1430 risk_level: RiskLevel::High,
1431 summary: "Upgrade the system",
1432 approval_id: None,
1433 warnings_json: "[]",
1434 created_at,
1435 identity: ChainIdentity::V2 {
1436 caller_role: "admin",
1437 event_tip: "",
1438 },
1439 },
1440 "",
1441 );
1442 conn.execute(
1443 "INSERT INTO transactions (
1444 transaction_id, request_id, request_hash, action_name, risk_level,
1445 status, approval_id, summary, warnings_json, created_at,
1446 seq, key_id, chain_hash, prev_chain_hash,
1447 chain_version, caller_role, event_tip
1448 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, 1, ?10, ?11, '', 2, 'admin', '')",
1449 params![
1450 transaction_id,
1451 "req-v2",
1452 "hash-v2",
1453 "UpdateSystem",
1454 serialize_field(&RiskLevel::High).unwrap(),
1455 serialize_field(&JobState::Queued).unwrap(),
1456 "Upgrade the system",
1457 "[]",
1458 created_at,
1459 CURRENT_KEY_ID,
1460 chain_hash,
1461 ],
1462 )
1463 .unwrap();
1464 transaction_id.to_string()
1465 }
1466
1467 #[test]
1475 fn a_v2_database_keeps_verifying_and_accepts_v3_rows() {
1476 let dir = tempdir().unwrap();
1477 let db_path = dir.path().join("tx.db");
1478 let key = AuditKey::from_bytes(vec![0x42; 32]);
1479 let old_transaction = v2_database(&db_path, &key);
1480
1481 let store = test_store(&db_path);
1482
1483 let version: i64 = store
1484 .connection()
1485 .unwrap()
1486 .query_row("SELECT MAX(version) FROM schema_migrations", [], |row| {
1487 row.get(0)
1488 })
1489 .unwrap();
1490 assert_eq!(version, 3, "opening a v2 database must apply migration 3");
1491
1492 let rows = store.fetch_chain_rows().unwrap();
1493 assert_eq!(rows.len(), 1);
1494 assert_eq!(rows[0].transaction_id, old_transaction);
1495 assert_eq!(rows[0].chain_version, audit_chain::CHAIN_VERSION_V2);
1496 assert_eq!(
1497 rows[0].caller_principal, None,
1498 "migration 3 must not invent a principal for a row signed without one"
1499 );
1500 assert!(matches!(
1501 store.verify_audit_chain(&key).unwrap(),
1502 audit_chain::VerifyOutcome::Intact { rows_checked: 1 }
1503 ));
1504
1505 let mut fresh = queued_transaction();
1507 fresh.caller_principal = CallerPrincipal::Uid(4242);
1508 store.record(fresh).unwrap();
1509
1510 let rows = store.fetch_chain_rows().unwrap();
1511 assert_eq!(rows.len(), 2);
1512 assert_eq!(rows[1].chain_version, audit_chain::CHAIN_VERSION_CURRENT);
1513 assert_eq!(rows[1].caller_principal.as_deref(), Some("uid:4242"));
1514 assert_eq!(
1515 rows[1].prev_chain_hash, rows[0].chain_hash,
1516 "the v3 row must chain onto the v2 row, not start a new chain"
1517 );
1518 assert!(matches!(
1519 store.verify_audit_chain(&key).unwrap(),
1520 audit_chain::VerifyOutcome::Intact { rows_checked: 2 }
1521 ));
1522
1523 let census = audit_chain::AttributionCensus::of(&rows);
1531 assert_eq!(census.rows(), 2);
1532 assert_eq!(census.named(), 1, "only the v3 row names uid:4242");
1533 assert_eq!(
1534 census.not_recorded(),
1535 1,
1536 "the v2 row's encoding never signed a principal"
1537 );
1538 assert_eq!(census.attribution_failed(), 0);
1539 assert_eq!(census.unattested(), 0);
1540 assert_eq!(census.unnamed(), 1);
1541 }
1542
1543 #[test]
1544 fn a_database_written_before_the_migration_still_verifies_after_upgrading() {
1545 let dir = tempdir().unwrap();
1550 let db_path = dir.path().join("tx.db");
1551 let key = AuditKey::from_bytes(vec![0x42; 32]);
1552 legacy_v1_database(&db_path, &key);
1553
1554 let store = test_store(&db_path);
1555 assert_eq!(
1556 store.verify_audit_chain(&key).unwrap(),
1557 VerifyOutcome::Intact { rows_checked: 1 }
1558 );
1559 let rows = store.fetch_chain_rows().unwrap();
1560 assert_eq!(rows[0].chain_version, CHAIN_VERSION_LEGACY);
1561 assert_eq!(rows[0].caller_role, None);
1562 }
1563
1564 #[test]
1565 fn rows_appended_after_the_migration_chain_onto_legacy_rows() {
1566 let dir = tempdir().unwrap();
1570 let db_path = dir.path().join("tx.db");
1571 let key = AuditKey::from_bytes(vec![0x42; 32]);
1572 legacy_v1_database(&db_path, &key);
1573
1574 let store = test_store(&db_path);
1575 store.record(queued_transaction()).unwrap();
1576
1577 let rows = store.fetch_chain_rows().unwrap();
1578 assert_eq!(rows.len(), 2);
1579 assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
1580 assert_eq!(rows[1].chain_version, CHAIN_VERSION_CURRENT);
1581 assert_eq!(
1582 store.verify_audit_chain(&key).unwrap(),
1583 VerifyOutcome::Intact { rows_checked: 2 }
1584 );
1585 }
1586
1587 #[test]
1588 fn a_schema_newer_than_this_binary_is_refused() {
1589 let dir = tempdir().unwrap();
1590 let db_path = dir.path().join("tx.db");
1591 let key = AuditKey::from_bytes(vec![0x42; 32]);
1592 legacy_v1_database(&db_path, &key);
1593 let conn = Connection::open(&db_path).unwrap();
1594 conn.execute(
1595 "INSERT INTO schema_migrations (version, name) VALUES (99, 'from the future')",
1596 [],
1597 )
1598 .unwrap();
1599 drop(conn);
1600
1601 let err = TransactionStore::open_with_key(&db_path, Arc::new(key)).unwrap_err();
1602 assert!(
1603 matches!(err, TransactionStoreError::DatabaseInvariant(ref m) if m.contains("99")),
1604 "expected a refusal naming the unsupported version, got {err:?}"
1605 );
1606 }
1607
1608 #[test]
1611 fn the_signed_row_records_which_role_asked() {
1612 let dir = tempdir().unwrap();
1613 let store = test_store(dir.path().join("tx.db"));
1614 let mut tx = queued_transaction();
1615 tx.caller_role = CallerRole::Admin;
1616 store.record(tx).unwrap();
1617
1618 let rows = store.fetch_chain_rows().unwrap();
1619 assert_eq!(rows[0].caller_role.as_deref(), Some("admin"));
1620 }
1621
1622 #[test]
1623 fn approving_consuming_and_revoking_each_append_a_chained_event() {
1624 let dir = tempdir().unwrap();
1625 let key = AuditKey::from_bytes(vec![0x42; 32]);
1626 let store = test_store(dir.path().join("tx.db"));
1627
1628 let a = store.record(queued_transaction()).unwrap();
1630 let receipt = store
1631 .approve_transaction(&a.transaction_id)
1632 .unwrap()
1633 .expect("queued transaction approves");
1634 let digest = audit_chain::approval_receipt_digest(&receipt);
1635 assert!(store
1636 .claim_approved_for_execution(&a.transaction_id, &digest)
1637 .unwrap());
1638
1639 let b = store.record(queued_transaction()).unwrap();
1641 store.approve_transaction(&b.transaction_id).unwrap();
1642 assert!(store.revoke_unconsumed_approval(&b.transaction_id).unwrap());
1643
1644 let events = store.fetch_event_rows().unwrap();
1645 let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect();
1646 assert_eq!(
1647 kinds,
1648 vec![
1649 "approval_granted",
1650 "approval_consumed",
1651 "approval_granted",
1652 "approval_revoked",
1653 ]
1654 );
1655 assert_eq!(
1656 store.verify_event_chain(&key).unwrap(),
1657 VerifyOutcome::Intact { rows_checked: 4 }
1658 );
1659 }
1660
1661 #[test]
1662 fn a_failed_approval_appends_no_event() {
1663 let dir = tempdir().unwrap();
1667 let store = test_store(dir.path().join("tx.db"));
1668 let tx = store.record(queued_transaction()).unwrap();
1669 store.approve_transaction(&tx.transaction_id).unwrap();
1670 store.approve_transaction(&tx.transaction_id).unwrap();
1671 assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
1672
1673 assert!(!store.revoke_unconsumed_approval("no-such-tx").unwrap());
1675 assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
1676 }
1677
1678 #[test]
1679 fn deleting_the_record_that_an_approval_happened_no_longer_goes_unnoticed() {
1680 let dir = tempdir().unwrap();
1686 let db_path = dir.path().join("tx.db");
1687 let key = AuditKey::from_bytes(vec![0x42; 32]);
1688 let store = test_store(&db_path);
1689
1690 let a = store.record(queued_transaction()).unwrap();
1691 store.approve_transaction(&a.transaction_id).unwrap();
1692 store.record(queued_transaction()).unwrap();
1695
1696 assert_eq!(
1697 store.verify_event_chain(&key).unwrap(),
1698 VerifyOutcome::Intact { rows_checked: 1 }
1699 );
1700 assert_eq!(
1701 store.verify_event_binding().unwrap(),
1702 audit_chain::BindingOutcome::Consistent {
1703 bindings_checked: 1
1704 }
1705 );
1706
1707 let conn = Connection::open(&db_path).unwrap();
1709 conn.execute("DELETE FROM transaction_approvals", [])
1710 .unwrap();
1711 conn.execute("DELETE FROM audit_events", []).unwrap();
1712 drop(conn);
1713
1714 assert_eq!(
1718 store.verify_event_chain(&key).unwrap(),
1719 VerifyOutcome::Intact { rows_checked: 0 }
1720 );
1721 match store.verify_event_binding().unwrap() {
1722 audit_chain::BindingOutcome::MissingEvent {
1723 transaction_seq, ..
1724 } => assert_eq!(transaction_seq, 2),
1725 other => panic!("expected MissingEvent, got {other:?}"),
1726 }
1727 }
1728
1729 #[test]
1730 fn a_read_only_store_cannot_revoke_or_claim() {
1731 let dir = tempdir().unwrap();
1734 let db_path = dir.path().join("tx.db");
1735 let store = test_store(&db_path);
1736 store.record(queued_transaction()).unwrap();
1737
1738 let read_only = TransactionStore::open_read_only(&db_path).unwrap();
1739 assert!(matches!(
1740 read_only.revoke_unconsumed_approval("tx"),
1741 Err(TransactionStoreError::AuditChainMissing(_))
1742 ));
1743 assert!(matches!(
1744 read_only.claim_approved_for_execution("tx", "digest"),
1745 Err(TransactionStoreError::AuditChainMissing(_))
1746 ));
1747 }
1748
1749 #[test]
1756 fn record_persists_the_caller_principal_under_the_current_encoding() {
1757 let dir = tempdir().unwrap();
1758 let store = test_store(dir.path().join("tx.db"));
1759 let mut tx = queued_transaction();
1760 tx.caller_principal = CallerPrincipal::Uid(4242);
1761 store.record(tx).unwrap();
1762
1763 let conn = store.connection().unwrap();
1764 let (version, role, principal): (i64, Option<String>, Option<String>) = conn
1765 .query_row(
1766 "SELECT chain_version, caller_role, caller_principal FROM transactions",
1767 [],
1768 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1769 )
1770 .unwrap();
1771
1772 assert_eq!(version, i64::from(audit_chain::CHAIN_VERSION_CURRENT));
1773 assert_eq!(role.as_deref(), Some("dev"));
1774 assert_eq!(
1775 principal.as_deref(),
1776 Some("uid:4242"),
1777 "the row must name the account the dispatcher resolved"
1778 );
1779
1780 let key = AuditKey::from_bytes(vec![0x42; 32]);
1782 assert!(matches!(
1783 store.verify_audit_chain(&key).unwrap(),
1784 audit_chain::VerifyOutcome::Intact { .. }
1785 ));
1786 }
1787
1788 #[test]
1789 fn record_writes_audit_chain_columns() {
1790 let dir = tempdir().unwrap();
1791 let store = test_store(dir.path().join("tx.db"));
1792 let _record = store.record(queued_transaction()).unwrap();
1793
1794 let conn = store.connection().unwrap();
1795 let (seq, key_id, chain_hash, prev): (i64, String, String, String) = conn
1796 .query_row(
1797 "SELECT seq, key_id, chain_hash, prev_chain_hash FROM transactions",
1798 [],
1799 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1800 )
1801 .unwrap();
1802 assert_eq!(seq, 1, "first row gets seq=1");
1803 assert_eq!(key_id, audit_chain::CURRENT_KEY_ID);
1804 assert_eq!(prev, "", "first row has empty prev_chain_hash");
1805 assert_eq!(
1806 chain_hash.len(),
1807 audit_chain::HASH_HEX_LEN,
1808 "chain_hash is a hex-encoded Ed25519 signature"
1809 );
1810 }
1811
1812 #[test]
1813 fn sequential_records_produce_chained_hashes() {
1814 let dir = tempdir().unwrap();
1815 let store = test_store(dir.path().join("tx.db"));
1816 store.record(queued_transaction()).unwrap();
1817 store.record(queued_transaction()).unwrap();
1818 store.record(queued_transaction()).unwrap();
1819
1820 let rows = store.fetch_chain_rows().unwrap();
1821 assert_eq!(rows.len(), 3);
1822 assert_eq!(rows[0].seq, 1);
1823 assert_eq!(rows[1].seq, 2);
1824 assert_eq!(rows[2].seq, 3);
1825 assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
1827 assert_eq!(rows[2].prev_chain_hash, rows[1].chain_hash);
1828 }
1829
1830 #[test]
1842 fn concurrent_record_keeps_chain_intact_and_seqs_contiguous() {
1843 const WORKERS: usize = 8;
1844 const RECORDS_PER_WORKER: usize = 10;
1845 const TOTAL: usize = WORKERS * RECORDS_PER_WORKER;
1846
1847 let dir = tempdir().unwrap();
1848 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
1849
1850 let mut handles = Vec::with_capacity(WORKERS);
1851 for w in 0..WORKERS {
1852 let store = std::sync::Arc::clone(&store);
1853 handles.push(std::thread::spawn(move || {
1854 for r in 0..RECORDS_PER_WORKER {
1855 let tx = NewTransaction {
1856 request_id: format!("worker-{w}-record-{r}"),
1857 request_hash: format!("hash-{w}-{r}"),
1858 action_name: "GetSystemState".to_string(),
1859 risk_level: RiskLevel::Low,
1860 summary: format!("worker {w} record {r}"),
1861 warnings: vec![],
1862 caller_role: CallerRole::Dev,
1863 caller_principal: CallerPrincipal::Uid(3000 + w as u32),
1867 };
1868 store
1869 .record(tx)
1870 .expect("record must succeed under contention");
1871 }
1872 }));
1873 }
1874 for h in handles {
1875 h.join().expect("worker thread did not panic");
1876 }
1877
1878 let key = AuditKey::from_bytes(vec![0x42; 32]);
1880 let outcome = store.verify_audit_chain(&key).unwrap();
1881 match outcome {
1882 VerifyOutcome::Intact { rows_checked } => {
1883 assert_eq!(
1884 rows_checked, TOTAL as u64,
1885 "expected {TOTAL} rows checked, got {rows_checked}"
1886 );
1887 }
1888 other => panic!("chain must be Intact under concurrent writes; got {other:?}"),
1889 }
1890
1891 {
1895 let conn = store.connection().unwrap();
1896 let mut stmt = conn
1897 .prepare("SELECT summary, caller_principal FROM transactions")
1898 .unwrap();
1899 let pairs: Vec<(String, Option<String>)> = stmt
1900 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
1901 .unwrap()
1902 .map(|r| r.unwrap())
1903 .collect();
1904 assert_eq!(pairs.len(), TOTAL);
1905 for (summary, principal) in pairs {
1906 let worker: u32 = summary
1907 .split_whitespace()
1908 .nth(1)
1909 .and_then(|w| w.parse().ok())
1910 .unwrap_or_else(|| panic!("unexpected summary {summary:?}"));
1911 assert_eq!(
1912 principal.as_deref(),
1913 Some(format!("uid:{}", 3000 + worker).as_str()),
1914 "row {summary:?} was attributed to the wrong account"
1915 );
1916 }
1917 }
1918
1919 let conn = store.connection().unwrap();
1921 let mut stmt = conn
1922 .prepare("SELECT seq FROM transactions ORDER BY seq ASC")
1923 .unwrap();
1924 let seqs: Vec<i64> = stmt
1925 .query_map([], |row| row.get::<_, i64>(0))
1926 .unwrap()
1927 .map(|r| r.unwrap())
1928 .collect();
1929 assert_eq!(seqs.len(), TOTAL, "row count mismatch");
1930 for (i, s) in seqs.iter().enumerate() {
1931 assert_eq!(
1932 *s,
1933 (i as i64) + 1,
1934 "seq column must be contiguous 1..={TOTAL}; saw {s} at position {i}"
1935 );
1936 }
1937 }
1938
1939 #[test]
1940 fn verify_audit_chain_intact_after_inserts() {
1941 let dir = tempdir().unwrap();
1942 let store = test_store(dir.path().join("tx.db"));
1943 for _ in 0..3 {
1944 store.record(queued_transaction()).unwrap();
1945 }
1946 let key = AuditKey::from_bytes(vec![0x42; 32]);
1947 let outcome = store.verify_audit_chain(&key).unwrap();
1948 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
1949 }
1950
1951 #[test]
1952 fn verify_audit_chain_with_pubkey_intact_after_inserts() {
1953 let dir = tempdir().unwrap();
1954 let store = test_store(dir.path().join("tx.db"));
1955 for _ in 0..3 {
1956 store.record(queued_transaction()).unwrap();
1957 }
1958 let key = AuditKey::from_bytes(vec![0x42; 32]);
1960 let outcome = store
1961 .verify_audit_chain_with_pubkey(&key.verifying_key_hex())
1962 .unwrap();
1963 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
1964 }
1965
1966 #[test]
1967 fn verify_audit_chain_with_wrong_pubkey_is_broken() {
1968 let dir = tempdir().unwrap();
1969 let store = test_store(dir.path().join("tx.db"));
1970 store.record(queued_transaction()).unwrap();
1971 let other = AuditKey::from_bytes(vec![0x99; 32]);
1973 let outcome = store
1974 .verify_audit_chain_with_pubkey(&other.verifying_key_hex())
1975 .unwrap();
1976 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
1977 }
1978
1979 #[test]
1980 fn verify_detects_tampered_summary() {
1981 let dir = tempdir().unwrap();
1982 let store = test_store(dir.path().join("tx.db"));
1983 let tx = store.record(queued_transaction()).unwrap();
1984
1985 let conn = store.connection().unwrap();
1988 conn.execute(
1989 "UPDATE transactions SET summary = ?1 WHERE transaction_id = ?2",
1990 params!["EVIL CHANGE", tx.transaction_id],
1991 )
1992 .unwrap();
1993
1994 let key = AuditKey::from_bytes(vec![0x42; 32]);
1995 let outcome = store.verify_audit_chain(&key).unwrap();
1996 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
1997 }
1998
1999 #[test]
2000 fn status_update_does_not_break_chain() {
2001 let dir = tempdir().unwrap();
2003 let store = test_store(dir.path().join("tx.db"));
2004 let tx = store.record(queued_transaction()).unwrap();
2005 store
2006 .update_status(&tx.transaction_id, JobState::Running)
2007 .unwrap();
2008 store
2009 .update_status(&tx.transaction_id, JobState::Succeeded)
2010 .unwrap();
2011
2012 let key = AuditKey::from_bytes(vec![0x42; 32]);
2013 let outcome = store.verify_audit_chain(&key).unwrap();
2014 assert!(
2015 matches!(outcome, VerifyOutcome::Intact { rows_checked: 1 }),
2016 "status mutation must not break the chain (status not in chain content): {outcome:?}"
2017 );
2018 }
2019
2020 #[test]
2021 fn open_read_only_rejects_record() {
2022 let dir = tempdir().unwrap();
2023 let key_path = dir.path().join("audit-key");
2024 std::fs::write(&key_path, vec![0x42; 32]).unwrap();
2025 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
2026
2027 let store = TransactionStore::open_read_only(dir.path().join("tx.db")).unwrap();
2028 let result = store.record(queued_transaction());
2029 assert!(matches!(
2030 result,
2031 Err(TransactionStoreError::AuditChainMissing(_))
2032 ));
2033 }
2034
2035 #[test]
2036 fn ensure_private_dir_creates_with_0700_mode() {
2037 let root = tempdir().unwrap();
2038 let target = root.path().join("a/b/c");
2039 ensure_private_dir(&target).unwrap();
2040 assert!(target.is_dir());
2041 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
2042 assert_eq!(mode, 0o700, "leaf dir must be 0o700, got {mode:o}");
2043 }
2044
2045 #[test]
2053 fn the_audit_database_is_created_owner_only() {
2054 let dir = tempdir().unwrap();
2055 let db = dir.path().join("daemon.sqlite");
2056 let store = TransactionStore::open(&db).unwrap();
2057 store
2059 .record(NewTransaction {
2060 request_id: "mode-probe".to_string(),
2061 request_hash: "hash-mode-probe".to_string(),
2062 action_name: "GetSystemState".to_string(),
2063 risk_level: RiskLevel::Low,
2064 summary: "probe".to_string(),
2065 warnings: vec![],
2066 caller_role: CallerRole::Dev,
2067 caller_principal: CallerPrincipal::Uid(3000),
2068 })
2069 .unwrap();
2070
2071 for path in [db.clone(), db.with_extension("sqlite-wal")] {
2072 if !path.exists() {
2073 continue;
2074 }
2075 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2076 assert_eq!(
2077 mode,
2078 0o600,
2079 "{} must be owner-only, got {mode:o}",
2080 path.display()
2081 );
2082 }
2083 }
2084
2085 #[test]
2086 fn ensure_private_dir_is_idempotent_and_does_not_widen_existing_mode() {
2087 let root = tempdir().unwrap();
2088 let target = root.path().join("preexisting");
2089 std::fs::create_dir(&target).unwrap();
2090 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
2091 ensure_private_dir(&target).unwrap();
2092 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
2094 assert_eq!(mode, 0o755);
2095 }
2096
2097 #[test]
2098 fn open_creates_parent_with_private_mode() {
2099 let root = tempdir().unwrap();
2100 let db_path = root.path().join("nested/dirs/daemon.sqlite");
2101 let _store = test_store(&db_path);
2102 let parent = db_path.parent().unwrap();
2103 let mode = std::fs::metadata(parent).unwrap().permissions().mode() & 0o777;
2104 assert_eq!(mode, 0o700);
2105 }
2106
2107 fn queued_transaction() -> NewTransaction {
2108 NewTransaction {
2109 request_id: "req-1".to_string(),
2110 request_hash: "hash-abc".to_string(),
2111 action_name: "UpdateSystem".to_string(),
2112 risk_level: RiskLevel::High,
2113 summary: "Upgrade the system".to_string(),
2114 warnings: vec![],
2115 caller_role: CallerRole::Dev,
2116 caller_principal: CallerPrincipal::Uid(1000),
2117 }
2118 }
2119
2120 #[test]
2121 fn update_status_transitions_queued_to_running() {
2122 let dir = tempdir().unwrap();
2123 let store = test_store(dir.path().join("tx.db"));
2124 let tx = store.record(queued_transaction()).unwrap();
2125
2126 store
2127 .update_status(&tx.transaction_id, JobState::Running)
2128 .unwrap();
2129
2130 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2131 assert_eq!(updated.status, JobState::Running);
2132 }
2133
2134 #[test]
2135 fn update_status_transitions_running_to_succeeded() {
2136 let dir = tempdir().unwrap();
2137 let store = test_store(dir.path().join("tx.db"));
2138 let tx = store.record(queued_transaction()).unwrap();
2139
2140 store
2141 .update_status(&tx.transaction_id, JobState::Running)
2142 .unwrap();
2143 store
2144 .update_status(&tx.transaction_id, JobState::Succeeded)
2145 .unwrap();
2146
2147 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2148 assert_eq!(updated.status, JobState::Succeeded);
2149 }
2150
2151 #[test]
2152 fn update_status_for_unknown_id_returns_not_found() {
2153 let dir = tempdir().unwrap();
2154 let store = test_store(dir.path().join("tx.db"));
2155
2156 let result = store.update_status("does-not-exist", JobState::Running);
2157 assert!(
2158 matches!(result, Err(TransactionStoreError::NotFound(ref id)) if id == "does-not-exist"),
2159 "expected NotFound, got: {result:?}"
2160 );
2161 }
2162
2163 #[test]
2164 fn update_status_leaves_other_fields_intact() {
2165 let dir = tempdir().unwrap();
2166 let store = test_store(dir.path().join("tx.db"));
2167 let tx = store.record(queued_transaction()).unwrap();
2168
2169 store
2170 .update_status(&tx.transaction_id, JobState::Running)
2171 .unwrap();
2172 store
2173 .update_status(&tx.transaction_id, JobState::Failed)
2174 .unwrap();
2175
2176 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2177 assert_eq!(updated.action_name, "UpdateSystem");
2178 assert_eq!(updated.risk_level, RiskLevel::High);
2179 assert_eq!(updated.status, JobState::Failed);
2180 }
2181
2182 #[test]
2191 fn update_status_is_atomic_under_concurrent_conflicting_transitions() {
2192 let dir = tempdir().unwrap();
2193 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
2194 let tx = store.record(queued_transaction()).unwrap();
2195 store
2196 .update_status(&tx.transaction_id, JobState::Running)
2197 .unwrap();
2198
2199 let store_a = std::sync::Arc::clone(&store);
2200 let id_a = tx.transaction_id.clone();
2201 let succeed = std::thread::spawn(move || store_a.update_status(&id_a, JobState::Succeeded));
2202
2203 let store_b = std::sync::Arc::clone(&store);
2204 let id_b = tx.transaction_id.clone();
2205 let fail = std::thread::spawn(move || store_b.update_status(&id_b, JobState::Failed));
2206
2207 let succeed_result = succeed.join().unwrap();
2208 let fail_result = fail.join().unwrap();
2209
2210 let oks = [succeed_result.is_ok(), fail_result.is_ok()]
2212 .into_iter()
2213 .filter(|ok| *ok)
2214 .count();
2215 assert_eq!(
2216 oks, 1,
2217 "exactly one concurrent transition should succeed: succeed={succeed_result:?} \
2218 fail={fail_result:?}"
2219 );
2220
2221 let loser = if succeed_result.is_ok() {
2237 &fail_result
2238 } else {
2239 &succeed_result
2240 };
2241 match loser {
2242 Err(TransactionStoreError::ConcurrentStatusChange(id)) => {
2243 assert_eq!(id, &tx.transaction_id);
2244 }
2245 Err(TransactionStoreError::InvalidTransition { from, to }) => {
2246 let winner = if succeed_result.is_ok() {
2248 JobState::Succeeded
2249 } else {
2250 JobState::Failed
2251 };
2252 assert_eq!(
2253 *from, winner,
2254 "the loser must have observed the winner's committed status"
2255 );
2256 assert_ne!(*to, winner, "the loser must be the other transition");
2257 }
2258 other => panic!(
2259 "the losing transition must be refused with either \
2260 ConcurrentStatusChange or InvalidTransition, got: {other:?}"
2261 ),
2262 }
2263
2264 let final_status = store.get(&tx.transaction_id).unwrap().unwrap().status;
2267 if succeed_result.is_ok() {
2268 assert_eq!(final_status, JobState::Succeeded);
2269 } else {
2270 assert_eq!(final_status, JobState::Failed);
2271 }
2272 }
2273
2274 #[test]
2275 fn revoke_unconsumed_approval_removes_an_unused_receipt() {
2276 let dir = tempdir().unwrap();
2279 let store = test_store(dir.path().join("tx.db"));
2280 let tx = store.record(queued_transaction()).unwrap();
2281 let receipt = store
2282 .approve_transaction(&tx.transaction_id)
2283 .unwrap()
2284 .expect("approved");
2285 let digest = audit_chain::approval_receipt_digest(&receipt);
2286
2287 assert!(
2288 store
2289 .revoke_unconsumed_approval(&tx.transaction_id)
2290 .unwrap(),
2291 "an unconsumed approval must be revocable"
2292 );
2293 assert!(
2294 !store
2295 .claim_approved_for_execution(&tx.transaction_id, &digest)
2296 .unwrap(),
2297 "a revoked receipt must no longer be claimable"
2298 );
2299 }
2300
2301 #[test]
2302 fn revoke_cannot_retract_an_approval_that_was_already_used() {
2303 let dir = tempdir().unwrap();
2307 let store = test_store(dir.path().join("tx.db"));
2308 let tx = store.record(queued_transaction()).unwrap();
2309 let receipt = store
2310 .approve_transaction(&tx.transaction_id)
2311 .unwrap()
2312 .expect("approved");
2313 let digest = audit_chain::approval_receipt_digest(&receipt);
2314 assert!(store
2315 .claim_approved_for_execution(&tx.transaction_id, &digest)
2316 .unwrap());
2317
2318 assert!(
2319 !store
2320 .revoke_unconsumed_approval(&tx.transaction_id)
2321 .unwrap(),
2322 "a consumed approval must not be revocable"
2323 );
2324 assert_eq!(
2325 store.get(&tx.transaction_id).unwrap().unwrap().status,
2326 JobState::Running,
2327 "a failed revocation must not disturb the running transaction"
2328 );
2329 }
2330
2331 #[test]
2332 fn approve_refuses_a_transaction_that_is_no_longer_queued() {
2333 let dir = tempdir().unwrap();
2337 let store = test_store(dir.path().join("tx.db"));
2338 let tx = store.record(queued_transaction()).unwrap();
2339 let receipt = store
2340 .approve_transaction(&tx.transaction_id)
2341 .unwrap()
2342 .expect("approved");
2343 let digest = audit_chain::approval_receipt_digest(&receipt);
2344 assert!(store
2345 .claim_approved_for_execution(&tx.transaction_id, &digest)
2346 .unwrap());
2347
2348 assert!(
2350 store
2351 .approve_transaction(&tx.transaction_id)
2352 .unwrap()
2353 .is_none(),
2354 "a Running transaction must not be approvable"
2355 );
2356
2357 store
2359 .update_status(&tx.transaction_id, JobState::Succeeded)
2360 .unwrap();
2361 assert!(
2362 store
2363 .approve_transaction(&tx.transaction_id)
2364 .unwrap()
2365 .is_none(),
2366 "a completed transaction must not be approvable"
2367 );
2368 }
2369
2370 #[test]
2371 fn only_one_of_two_concurrent_claims_can_execute() {
2372 let dir = tempdir().unwrap();
2377 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
2378 let tx = store.record(queued_transaction()).unwrap();
2379 let receipt = store
2380 .approve_transaction(&tx.transaction_id)
2381 .unwrap()
2382 .expect("approved");
2383 let digest = audit_chain::approval_receipt_digest(&receipt);
2384
2385 let mut handles = Vec::new();
2386 for _ in 0..2 {
2387 let store = std::sync::Arc::clone(&store);
2388 let id = tx.transaction_id.clone();
2389 let digest = digest.clone();
2390 handles.push(std::thread::spawn(move || {
2391 store.claim_approved_for_execution(&id, &digest)
2392 }));
2393 }
2394 let claims: Vec<bool> = handles
2395 .into_iter()
2396 .map(|h| h.join().unwrap().unwrap_or(false))
2397 .collect();
2398
2399 assert_eq!(
2400 claims.iter().filter(|c| **c).count(),
2401 1,
2402 "exactly one claim may win: {claims:?}"
2403 );
2404 assert_eq!(
2405 store.get(&tx.transaction_id).unwrap().unwrap().status,
2406 JobState::Running
2407 );
2408 }
2409
2410 #[test]
2411 fn cancel_queued_cancels_a_queued_transaction_once() {
2412 let dir = tempdir().unwrap();
2413 let store = test_store(dir.path().join("tx.db"));
2414 let tx = store.record(queued_transaction()).unwrap();
2415
2416 assert!(
2417 store.cancel_queued(&tx.transaction_id).unwrap(),
2418 "a queued transaction is cancelable"
2419 );
2420 assert_eq!(
2421 store.get(&tx.transaction_id).unwrap().unwrap().status,
2422 JobState::Canceled
2423 );
2424 assert!(
2425 !store.cancel_queued(&tx.transaction_id).unwrap(),
2426 "an already-canceled transaction is not cancelable again"
2427 );
2428 assert!(
2429 !store.cancel_queued("no-such-transaction").unwrap(),
2430 "a missing transaction is not cancelable"
2431 );
2432 }
2433
2434 #[test]
2435 fn cancel_queued_refuses_a_running_transaction() {
2436 let dir = tempdir().unwrap();
2439 let store = test_store(dir.path().join("tx.db"));
2440 let tx = store.record(queued_transaction()).unwrap();
2441 let receipt = store
2442 .approve_transaction(&tx.transaction_id)
2443 .unwrap()
2444 .expect("approved");
2445 let digest = audit_chain::approval_receipt_digest(&receipt);
2446 assert!(store
2447 .claim_approved_for_execution(&tx.transaction_id, &digest)
2448 .unwrap());
2449
2450 assert!(
2451 !store.cancel_queued(&tx.transaction_id).unwrap(),
2452 "a running transaction must not be cancelable"
2453 );
2454 assert_eq!(
2455 store.get(&tx.transaction_id).unwrap().unwrap().status,
2456 JobState::Running,
2457 "cancel must not disturb a running transaction"
2458 );
2459 }
2460
2461 #[test]
2462 fn approved_receipt_is_required_and_consumed_once() {
2463 let dir = tempdir().unwrap();
2464 let store = test_store(dir.path().join("tx.db"));
2465 let tx = store.record(queued_transaction()).unwrap();
2466
2467 assert!(
2468 !store
2469 .claim_approved_for_execution(&tx.transaction_id, "digest-a")
2470 .unwrap(),
2471 "an unapproved preview must not execute"
2472 );
2473 let receipt = store
2474 .approve_transaction(&tx.transaction_id)
2475 .unwrap()
2476 .expect("first approval must succeed");
2477 let digest = audit_chain::approval_receipt_digest(&receipt);
2478 assert_eq!(tx.approval_id.as_deref(), Some(digest.as_str()));
2479 assert!(
2480 store
2481 .approve_transaction(&tx.transaction_id)
2482 .unwrap()
2483 .is_none(),
2484 "approval is immutable once issued"
2485 );
2486 assert!(
2487 !store
2488 .claim_approved_for_execution(&tx.transaction_id, "wrong-digest")
2489 .unwrap(),
2490 "a forged receipt must not execute"
2491 );
2492 assert!(
2493 store
2494 .claim_approved_for_execution(&tx.transaction_id, &digest)
2495 .unwrap(),
2496 "the exact approved receipt must execute"
2497 );
2498 assert!(
2499 !store
2500 .claim_approved_for_execution(&tx.transaction_id, &digest)
2501 .unwrap(),
2502 "the receipt must be one-time"
2503 );
2504 }
2505
2506 #[test]
2507 fn approval_commitment_is_covered_by_the_signed_chain() {
2508 let dir = tempdir().unwrap();
2509 let store = test_store(dir.path().join("tx.db"));
2510 let tx = store.record(queued_transaction()).unwrap();
2511 assert!(tx.approval_id.is_some());
2512
2513 store
2514 .connection()
2515 .unwrap()
2516 .execute(
2517 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
2518 params![tx.transaction_id],
2519 )
2520 .unwrap();
2521
2522 let key = AuditKey::from_bytes(vec![0x42; 32]);
2523 assert!(matches!(
2524 store.verify_audit_chain(&key).unwrap(),
2525 VerifyOutcome::Broken { .. }
2526 ));
2527 }
2528
2529 #[test]
2530 fn approve_rejects_a_forged_commitment_at_runtime() {
2531 let dir = tempdir().unwrap();
2535 let store = test_store(dir.path().join("tx.db"));
2536 let tx = store.record(queued_transaction()).unwrap();
2537
2538 store
2539 .connection()
2540 .unwrap()
2541 .execute(
2542 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
2543 params![tx.transaction_id],
2544 )
2545 .unwrap();
2546
2547 let err = store
2548 .approve_transaction(&tx.transaction_id)
2549 .expect_err("a forged commitment must be rejected, not approved");
2550 assert!(
2551 matches!(err, TransactionStoreError::DatabaseInvariant(_)),
2552 "forged commitment must surface as a DatabaseInvariant, got {err:?}"
2553 );
2554 }
2555
2556 #[test]
2557 fn stale_iso_timestamp_cannot_be_approved() {
2558 let dir = tempdir().unwrap();
2559 let store = test_store(dir.path().join("tx.db"));
2560 let tx = store.record(queued_transaction()).unwrap();
2561 let conn = store.connection().unwrap();
2562 conn.execute(
2563 "UPDATE transactions \
2564 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2565 WHERE transaction_id = ?1",
2566 params![tx.transaction_id],
2567 )
2568 .unwrap();
2569
2570 assert!(
2571 store
2572 .approve_transaction(&tx.transaction_id)
2573 .unwrap()
2574 .is_none(),
2575 "a production-format timestamp outside the TTL must not be approved"
2576 );
2577 }
2578
2579 #[test]
2580 fn stale_iso_timestamp_cannot_be_claimed_for_execution() {
2581 let dir = tempdir().unwrap();
2586 let store = test_store(dir.path().join("tx.db"));
2587 let tx = store.record(queued_transaction()).unwrap();
2588 let receipt = store
2589 .approve_transaction(&tx.transaction_id)
2590 .unwrap()
2591 .expect("a fresh approval succeeds");
2592 let digest = audit_chain::approval_receipt_digest(&receipt);
2593
2594 let conn = store.connection().unwrap();
2596 conn.execute(
2597 "UPDATE transactions \
2598 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2599 WHERE transaction_id = ?1",
2600 params![tx.transaction_id],
2601 )
2602 .unwrap();
2603
2604 assert!(
2605 !store
2606 .claim_approved_for_execution(&tx.transaction_id, &digest)
2607 .unwrap(),
2608 "an approval aged past the TTL must not be claimable at execute time"
2609 );
2610 assert_eq!(
2611 store.get(&tx.transaction_id).unwrap().unwrap().status,
2612 JobState::Queued,
2613 "a TTL-expired claim must leave the transaction Queued, never Running"
2614 );
2615 }
2616
2617 #[test]
2618 fn cleanup_stale_queued_cancels_old_records() {
2619 let dir = tempdir().unwrap();
2620 let store = test_store(dir.path().join("tx.db"));
2621
2622 let fresh = store.record(queued_transaction()).unwrap();
2624 let stale = store.record(queued_transaction()).unwrap();
2625
2626 let conn = store.connection().unwrap();
2628 conn.execute(
2629 "UPDATE transactions \
2630 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2631 WHERE transaction_id = ?1",
2632 params![stale.transaction_id],
2633 )
2634 .unwrap();
2635
2636 let canceled = store.cleanup_stale_queued().unwrap();
2637 assert_eq!(canceled, 1, "only the stale record should be canceled");
2638
2639 let stale_record = store.get(&stale.transaction_id).unwrap().unwrap();
2641 assert_eq!(stale_record.status, JobState::Canceled);
2642
2643 let fresh_record = store.get(&fresh.transaction_id).unwrap().unwrap();
2645 assert_eq!(fresh_record.status, JobState::Queued);
2646 }
2647
2648 #[test]
2651 fn update_status_rejects_queued_to_succeeded() {
2652 let dir = tempdir().unwrap();
2653 let store = test_store(dir.path().join("tx.db"));
2654 let tx = store.record(queued_transaction()).unwrap();
2655
2656 let result = store.update_status(&tx.transaction_id, JobState::Succeeded);
2657 assert!(
2658 matches!(
2659 result,
2660 Err(TransactionStoreError::InvalidTransition {
2661 from: JobState::Queued,
2662 to: JobState::Succeeded,
2663 })
2664 ),
2665 "Queued -> Succeeded must be rejected (must go through Running first): {result:?}"
2666 );
2667 }
2668
2669 #[test]
2670 fn update_status_rejects_succeeded_to_running() {
2671 let dir = tempdir().unwrap();
2672 let store = test_store(dir.path().join("tx.db"));
2673 let tx = store.record(queued_transaction()).unwrap();
2674
2675 store
2676 .update_status(&tx.transaction_id, JobState::Running)
2677 .unwrap();
2678 store
2679 .update_status(&tx.transaction_id, JobState::Succeeded)
2680 .unwrap();
2681
2682 let result = store.update_status(&tx.transaction_id, JobState::Running);
2683 assert!(
2684 matches!(
2685 result,
2686 Err(TransactionStoreError::InvalidTransition {
2687 from: JobState::Succeeded,
2688 to: JobState::Running,
2689 })
2690 ),
2691 "Succeeded -> Running must be rejected (terminal state): {result:?}"
2692 );
2693 }
2694
2695 #[test]
2696 fn update_status_accepts_running_to_failed() {
2697 let dir = tempdir().unwrap();
2698 let store = test_store(dir.path().join("tx.db"));
2699 let tx = store.record(queued_transaction()).unwrap();
2700
2701 store
2702 .update_status(&tx.transaction_id, JobState::Running)
2703 .unwrap();
2704 store
2705 .update_status(&tx.transaction_id, JobState::Failed)
2706 .unwrap();
2707
2708 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2709 assert_eq!(updated.status, JobState::Failed);
2710 }
2711
2712 #[test]
2713 fn update_status_accepts_running_to_rolled_back() {
2714 let dir = tempdir().unwrap();
2715 let store = test_store(dir.path().join("tx.db"));
2716 let tx = store.record(queued_transaction()).unwrap();
2717
2718 store
2719 .update_status(&tx.transaction_id, JobState::Running)
2720 .unwrap();
2721 store
2722 .update_status(&tx.transaction_id, JobState::RolledBack)
2723 .unwrap();
2724
2725 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2726 assert_eq!(updated.status, JobState::RolledBack);
2727 }
2728
2729 #[test]
2732 fn list_transactions_returns_empty_for_fresh_store() {
2733 let dir = tempdir().unwrap();
2734 let store = test_store(dir.path().join("tx.db"));
2735 let results = store.list_transactions(10, None, None, None).unwrap();
2736 assert!(results.is_empty());
2737 }
2738
2739 #[test]
2740 fn list_transactions_returns_all_records_ordered_by_newest_first() {
2741 let dir = tempdir().unwrap();
2742 let store = test_store(dir.path().join("tx.db"));
2743 store.record(queued_transaction()).unwrap();
2744
2745 let mut second = queued_transaction();
2746 second.action_name = "GetDiskUsage".to_string();
2747 second.risk_level = RiskLevel::Low;
2748 store.record(second).unwrap();
2749
2750 let results = store.list_transactions(10, None, None, None).unwrap();
2751 assert_eq!(results.len(), 2);
2752 assert_eq!(results[0].action_name, "GetDiskUsage");
2754 assert_eq!(results[1].action_name, "UpdateSystem");
2755 }
2756
2757 #[test]
2758 fn list_history_populates_created_at_and_risk_level() {
2759 let dir = tempdir().unwrap();
2760 let store = test_store(dir.path().join("tx.db"));
2761 store.record(queued_transaction()).unwrap();
2762
2763 let entries = store.list_history(10, None, None, None).unwrap();
2764 assert_eq!(entries.len(), 1);
2765 let entry = &entries[0];
2766 assert_eq!(entry.action_name, "UpdateSystem");
2767 assert_eq!(entry.risk_level, RiskLevel::High);
2768 assert_eq!(entry.status, JobState::Queued);
2769 assert!(
2770 !entry.created_at.is_empty(),
2771 "created_at must be populated from the stored row, not left blank"
2772 );
2773 }
2774
2775 #[test]
2776 fn list_history_applies_the_same_filters_as_list_transactions() {
2777 let dir = tempdir().unwrap();
2778 let store = test_store(dir.path().join("tx.db"));
2779 store.record(queued_transaction()).unwrap();
2780 let mut low = queued_transaction();
2781 low.action_name = "GetDiskUsage".to_string();
2782 low.risk_level = RiskLevel::Low;
2783 store.record(low).unwrap();
2784
2785 let only = store
2786 .list_history(10, None, Some("GetDiskUsage"), None)
2787 .unwrap();
2788 assert_eq!(only.len(), 1);
2789 assert_eq!(only[0].action_name, "GetDiskUsage");
2790 }
2791
2792 #[test]
2793 fn list_transactions_respects_limit() {
2794 let dir = tempdir().unwrap();
2795 let store = test_store(dir.path().join("tx.db"));
2796 for _ in 0..5 {
2797 store.record(queued_transaction()).unwrap();
2798 }
2799 let results = store.list_transactions(3, None, None, None).unwrap();
2800 assert_eq!(results.len(), 3);
2801 }
2802
2803 #[test]
2804 fn list_transactions_filters_by_status() {
2805 let dir = tempdir().unwrap();
2806 let store = test_store(dir.path().join("tx.db"));
2807 let tx = store.record(queued_transaction()).unwrap();
2808 store
2809 .update_status(&tx.transaction_id, JobState::Running)
2810 .unwrap();
2811 store
2812 .update_status(&tx.transaction_id, JobState::Succeeded)
2813 .unwrap();
2814
2815 store.record(queued_transaction()).unwrap();
2817
2818 let succeeded = store
2819 .list_transactions(10, Some("succeeded"), None, None)
2820 .unwrap();
2821 assert_eq!(succeeded.len(), 1);
2822 assert_eq!(succeeded[0].status, JobState::Succeeded);
2823
2824 let queued = store
2825 .list_transactions(10, Some("queued"), None, None)
2826 .unwrap();
2827 assert_eq!(queued.len(), 1);
2828 assert_eq!(queued[0].status, JobState::Queued);
2829 }
2830
2831 #[test]
2832 fn list_transactions_filters_by_action_name() {
2833 let dir = tempdir().unwrap();
2834 let store = test_store(dir.path().join("tx.db"));
2835 store.record(queued_transaction()).unwrap(); let mut disk = queued_transaction();
2838 disk.action_name = "GetDiskUsage".to_string();
2839 store.record(disk).unwrap();
2840
2841 let results = store
2842 .list_transactions(10, None, Some("GetDiskUsage"), None)
2843 .unwrap();
2844 assert_eq!(results.len(), 1);
2845 assert_eq!(results[0].action_name, "GetDiskUsage");
2846 }
2847
2848 #[test]
2849 fn list_transactions_filters_by_since_hours() {
2850 let dir = tempdir().unwrap();
2851 let store = test_store(dir.path().join("tx.db"));
2852
2853 let old = store.record(queued_transaction()).unwrap();
2855 let conn = store.connection().unwrap();
2856 conn.execute(
2857 "UPDATE transactions \
2858 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-48 hours') \
2859 WHERE transaction_id = ?1",
2860 params![old.transaction_id],
2861 )
2862 .unwrap();
2863
2864 store.record(queued_transaction()).unwrap();
2866
2867 let results = store.list_transactions(10, None, None, Some(24)).unwrap();
2869 assert_eq!(results.len(), 1);
2870
2871 let results = store.list_transactions(10, None, None, Some(72)).unwrap();
2873 assert_eq!(results.len(), 2);
2874 }
2875
2876 #[test]
2877 fn list_transactions_rejects_invalid_status_filter() {
2878 let dir = tempdir().unwrap();
2879 let store = test_store(dir.path().join("tx.db"));
2880 store.record(queued_transaction()).unwrap();
2881 let result = store.list_transactions(10, Some("bogus"), None, None);
2882 assert!(result.is_err(), "invalid status filter should return error");
2883 }
2884
2885 #[test]
2893 fn record_emits_one_watermark_per_entry() {
2894 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2895 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2896
2897 let dir = tempdir().unwrap();
2898 let store = test_store(dir.path().join("tx.db"));
2899 store.record(queued_transaction()).unwrap();
2900
2901 let calls = crate::audit_watermark::take_watermarks(&sink);
2902 assert_eq!(calls.len(), 1, "expected exactly 1 watermark per record()");
2903 }
2904
2905 #[test]
2907 fn record_previewed_emits_one_watermark() {
2908 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2909 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2910
2911 let dir = tempdir().unwrap();
2912 let store = test_store(dir.path().join("tx.db"));
2913 let preview = PreviewEnvelope {
2914 summary: "Upgrade the system".to_string(),
2915 risk_level: RiskLevel::High,
2916 current_state: serde_json::Value::Null,
2917 proposed_change: serde_json::Value::Null,
2918 expected_side_effects: vec![],
2919 reboot_required: false,
2920 rollback_available: false,
2921 warnings: vec![],
2922 request_hash: sysknife_types::RequestHash::from("hash-abc".to_string()),
2923 };
2924 store
2925 .record_previewed(queued_transaction(), preview)
2926 .unwrap();
2927
2928 let calls = crate::audit_watermark::take_watermarks(&sink);
2929 assert_eq!(
2930 calls.len(),
2931 1,
2932 "expected exactly 1 watermark per record_previewed()"
2933 );
2934 }
2935
2936 #[test]
2938 fn watermark_seq_and_hash_match_chain_row() {
2939 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2940 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2941
2942 let dir = tempdir().unwrap();
2943 let store = test_store(dir.path().join("tx.db"));
2944 store.record(queued_transaction()).unwrap();
2945
2946 let rows = store.fetch_chain_rows().unwrap();
2947 assert_eq!(rows.len(), 1);
2948 let row = &rows[0];
2949
2950 let calls = crate::audit_watermark::take_watermarks(&sink);
2951 assert_eq!(calls.len(), 1);
2952 assert_eq!(calls[0].seq, row.seq, "watermark seq must match chain row");
2953 assert_eq!(
2954 calls[0].chain_hash_hex, row.chain_hash,
2955 "watermark chain_hash_hex must match stored chain_hash"
2956 );
2957 }
2958
2959 #[test]
2961 fn multiple_records_produce_one_watermark_each() {
2962 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2963 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2964
2965 let dir = tempdir().unwrap();
2966 let store = test_store(dir.path().join("tx.db"));
2967 for _ in 0..3 {
2968 store.record(queued_transaction()).unwrap();
2969 }
2970
2971 let calls = crate::audit_watermark::take_watermarks(&sink);
2972 assert_eq!(calls.len(), 3, "one watermark per record call");
2973 assert_eq!(calls[0].seq, 1);
2974 assert_eq!(calls[1].seq, 2);
2975 assert_eq!(calls[2].seq, 3);
2976 }
2977
2978 #[test]
2992 fn no_watermark_emitted_before_any_record() {
2993 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2994 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2995
2996 let dir = tempdir().unwrap();
2997 let _store = test_store(dir.path().join("tx.db"));
2998
2999 let calls = crate::audit_watermark::take_watermarks(&sink);
3001 assert!(
3002 calls.is_empty(),
3003 "no watermark must be emitted without a record() call"
3004 );
3005 }
3006}