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
255fn query_optional<T>(
261 conn: &Connection,
262 sql: &str,
263 params: impl rusqlite::Params,
264 f: impl FnOnce(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
265) -> rusqlite::Result<Option<T>> {
266 match conn.query_row(sql, params, f) {
267 Ok(v) => Ok(Some(v)),
268 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
269 Err(e) => Err(e),
270 }
271}
272
273#[derive(Clone, Debug)]
274pub struct TransactionStore {
275 path: PathBuf,
276 audit_key: Option<Arc<AuditKey>>,
279}
280
281#[derive(Clone, Debug, PartialEq)]
282pub struct RecordedPreviewedTransaction {
283 pub transaction: TransactionRecord,
284 pub preview: PreviewEnvelope,
285}
286
287struct InsertedTransaction {
288 record: TransactionRecord,
289 seq: u64,
290 chain_hash: String,
291}
292
293#[derive(Debug, thiserror::Error)]
294pub enum TransactionStoreError {
295 #[error("io error: {0}")]
296 Io(#[from] std::io::Error),
297
298 #[error("sqlite error: {0}")]
299 Sqlite(#[from] rusqlite::Error),
300
301 #[error("json error: {0}")]
302 Json(#[from] serde_json::Error),
303
304 #[error("database invariant violation: {0}")]
305 DatabaseInvariant(String),
306
307 #[error("transaction not found: {0}")]
308 NotFound(String),
309
310 #[error("invalid transition from {from:?} to {to:?}")]
311 InvalidTransition { from: JobState, to: JobState },
312
313 #[error("audit chain misconfiguration: {0}")]
314 AuditChainMissing(&'static str),
315
316 #[error("transaction {0} status changed concurrently; retry")]
324 ConcurrentStatusChange(String),
325}
326
327impl TransactionStore {
328 pub fn open_read_only(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
332 let path = path.as_ref().to_path_buf();
333 if let Some(parent) = path.parent() {
334 ensure_private_dir(parent)?;
335 }
336
337 let store = Self {
338 path,
339 audit_key: None,
340 };
341 store.initialize()?;
342 Ok(store)
343 }
344
345 pub fn open(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
352 let db_path = path.as_ref();
353 let key_path = std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
354 .map(PathBuf::from)
355 .unwrap_or_else(|_| {
356 db_path
357 .parent()
358 .unwrap_or_else(|| Path::new("."))
359 .join("audit-key")
360 });
361 let key = AuditKey::load_or_generate(&key_path).map_err(|e| {
362 TransactionStoreError::Io(std::io::Error::new(
363 std::io::ErrorKind::InvalidData,
364 format!("audit key load failed: {e}"),
365 ))
366 })?;
367 Self::open_with_key(path, Arc::new(key))
368 }
369
370 pub fn open_with_key(
373 path: impl AsRef<Path>,
374 audit_key: Arc<AuditKey>,
375 ) -> Result<Self, TransactionStoreError> {
376 let path = path.as_ref().to_path_buf();
377 if let Some(parent) = path.parent() {
378 ensure_private_dir(parent)?;
379 }
380
381 let store = Self {
382 path,
383 audit_key: Some(audit_key),
384 };
385 store.initialize()?;
386 restrict_db_file_mode(&store.path);
387 Ok(store)
388 }
389
390 fn record_with(
399 &self,
400 transaction: NewTransaction,
401 in_tx: impl FnOnce(&Connection, &InsertedTransaction) -> Result<(), TransactionStoreError>,
402 ) -> Result<InsertedTransaction, TransactionStoreError> {
403 let key = self
404 .audit_key
405 .as_ref()
406 .ok_or(TransactionStoreError::AuditChainMissing(
407 "this TransactionStore was opened read-only; cannot record",
408 ))?;
409 let mut conn = self.connection()?;
410 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
415 let transaction_id = Uuid::new_v4().to_string();
416 let inserted = Self::insert_transaction(&tx, key, &transaction_id, transaction)?;
417 in_tx(&tx, &inserted)?;
418 tx.commit()?;
419 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
420 Ok(inserted)
421 }
422
423 pub fn record(
424 &self,
425 transaction: NewTransaction,
426 ) -> Result<TransactionRecord, TransactionStoreError> {
427 let inserted = self.record_with(transaction, |_, _| Ok(()))?;
428 Ok(inserted.record)
429 }
430
431 pub fn record_previewed(
432 &self,
433 transaction: NewTransaction,
434 preview: PreviewEnvelope,
435 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
436 let inserted = self.record_with(transaction, |conn, inserted| {
437 Self::insert_preview(conn, &inserted.record.transaction_id, &preview)
438 })?;
439 Ok(RecordedPreviewedTransaction {
440 transaction: inserted.record,
441 preview,
442 })
443 }
444
445 pub fn get(
446 &self,
447 transaction_id: &str,
448 ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
449 let conn = self.connection()?;
450 let mut stmt = conn.prepare(
451 "SELECT
452 transaction_id,
453 request_id,
454 request_hash,
455 action_name,
456 risk_level,
457 status,
458 approval_id,
459 summary,
460 warnings_json
461 FROM transactions
462 WHERE transaction_id = ?1",
463 )?;
464 let mut rows = stmt.query(params![transaction_id])?;
465 if let Some(row) = rows.next()? {
466 Ok(Some(row_to_record(row)?))
467 } else {
468 Ok(None)
469 }
470 }
471
472 pub fn get_preview(
473 &self,
474 transaction_id: &str,
475 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
476 let conn = self.connection()?;
477 let mut stmt = conn.prepare(
478 "SELECT preview_json
479 FROM transaction_previews
480 WHERE transaction_id = ?1",
481 )?;
482 let mut rows = stmt.query(params![transaction_id])?;
483 if let Some(row) = rows.next()? {
484 let preview_json: String = row.get(0)?;
485 Ok(Some(serde_json::from_str(&preview_json)?))
486 } else {
487 Ok(None)
488 }
489 }
490
491 pub fn update_status(
492 &self,
493 transaction_id: &str,
494 new_status: JobState,
495 ) -> Result<(), TransactionStoreError> {
496 let conn = self.connection()?;
497
498 let current_status: String = conn
500 .query_row(
501 "SELECT status FROM transactions WHERE transaction_id = ?1",
502 params![transaction_id],
503 |row| row.get(0),
504 )
505 .map_err(|e| match e {
506 rusqlite::Error::QueryReturnedNoRows => {
507 TransactionStoreError::NotFound(transaction_id.to_string())
508 }
509 other => TransactionStoreError::Sqlite(other),
510 })?;
511
512 let current: JobState = deserialize_field(¤t_status)?;
513 if !crate::jobs::allowed_transition(¤t, &new_status) {
514 return Err(TransactionStoreError::InvalidTransition {
515 from: current,
516 to: new_status,
517 });
518 }
519
520 let rows_affected = conn.execute(
532 "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2 AND status = ?3",
533 params![
534 serialize_field(&new_status)?,
535 transaction_id,
536 current_status
537 ],
538 )?;
539 if rows_affected == 0 {
540 return Err(TransactionStoreError::ConcurrentStatusChange(
541 transaction_id.to_string(),
542 ));
543 }
544 Ok(())
545 }
546
547 pub fn approve_transaction(
549 &self,
550 transaction_id: &str,
551 ) -> Result<Option<String>, TransactionStoreError> {
552 let key = self
553 .audit_key
554 .as_ref()
555 .ok_or(TransactionStoreError::AuditChainMissing(
556 "this TransactionStore was opened read-only; cannot approve",
557 ))?;
558 let Some(record) = self.get(transaction_id)? else {
559 return Ok(None);
560 };
561 let receipt = key.approval_receipt(transaction_id, &record.request_hash);
562 let receipt_digest = audit_chain::approval_receipt_digest(&receipt);
563 let Some(committed_digest) = record.approval_id.as_deref() else {
564 return Err(TransactionStoreError::DatabaseInvariant(format!(
565 "transaction {transaction_id} has no signed approval commitment"
566 )));
567 };
568 if !bool::from(receipt_digest.as_bytes().ct_eq(committed_digest.as_bytes())) {
569 return Err(TransactionStoreError::DatabaseInvariant(format!(
570 "transaction {transaction_id} approval commitment does not match its signed preview"
571 )));
572 }
573
574 let mut conn = self.connection()?;
577 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
578 let queued_json = serialize_field(&JobState::Queued)?;
579 let rows_affected = tx.execute(
580 &format!(
581 "INSERT INTO transaction_approvals (transaction_id, receipt_digest) \
582 SELECT transaction_id, ?1 FROM transactions \
583 WHERE transaction_id = ?2 \
584 AND status = ?3 \
585 AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
586 AND NOT EXISTS ( \
587 SELECT 1 FROM transaction_approvals WHERE transaction_id = ?2 \
588 )"
589 ),
590 params![receipt_digest, transaction_id, queued_json],
591 )?;
592 if rows_affected > 0 {
593 Self::append_event(
594 &tx,
595 key,
596 AuditEventKind::ApprovalGranted,
597 transaction_id,
598 &receipt_digest,
599 )?;
600 }
601 tx.commit()?;
602 Ok((rows_affected > 0).then_some(receipt))
603 }
604
605 pub fn revoke_unconsumed_approval(
608 &self,
609 transaction_id: &str,
610 ) -> Result<bool, TransactionStoreError> {
611 let key = self
612 .audit_key
613 .as_ref()
614 .ok_or(TransactionStoreError::AuditChainMissing(
615 "this TransactionStore was opened read-only; cannot revoke",
616 ))?;
617 let mut conn = self.connection()?;
618 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
619 let digest: Option<String> = query_optional(
622 &tx,
623 "SELECT receipt_digest FROM transaction_approvals \
624 WHERE transaction_id = ?1 AND consumed_at IS NULL",
625 params![transaction_id],
626 |row| row.get(0),
627 )?;
628 let rows_affected = tx.execute(
629 "DELETE FROM transaction_approvals \
630 WHERE transaction_id = ?1 AND consumed_at IS NULL",
631 params![transaction_id],
632 )?;
633 if rows_affected > 0 {
634 let digest = digest.ok_or_else(|| {
635 TransactionStoreError::DatabaseInvariant(format!(
636 "revoked an approval for {transaction_id} that had no receipt digest"
637 ))
638 })?;
639 Self::append_event(
640 &tx,
641 key,
642 AuditEventKind::ApprovalRevoked,
643 transaction_id,
644 &digest,
645 )?;
646 }
647 tx.commit()?;
648 Ok(rows_affected > 0)
649 }
650
651 pub fn claim_approved_for_execution(
653 &self,
654 transaction_id: &str,
655 receipt_digest: &str,
656 ) -> Result<bool, TransactionStoreError> {
657 let key = self
658 .audit_key
659 .as_ref()
660 .ok_or(TransactionStoreError::AuditChainMissing(
661 "this TransactionStore was opened read-only; cannot claim",
662 ))?;
663 let mut conn = self.connection()?;
664 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
665 let queued_json = serialize_field(&JobState::Queued)?;
666 let running_json = serialize_field(&JobState::Running)?;
667 let rows_affected = tx.execute(
668 &format!(
669 "UPDATE transactions SET status = ?1 \
670 WHERE transaction_id = ?2 \
671 AND status = ?3 \
672 AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
673 AND EXISTS ( \
674 SELECT 1 FROM transaction_approvals \
675 WHERE transaction_id = ?2 \
676 AND receipt_digest = ?4 \
677 AND consumed_at IS NULL \
678 )"
679 ),
680 params![running_json, transaction_id, queued_json, receipt_digest],
681 )?;
682 if rows_affected > 0 {
683 tx.execute(
684 "UPDATE transaction_approvals \
685 SET consumed_at = datetime('now') \
686 WHERE transaction_id = ?1 AND consumed_at IS NULL",
687 params![transaction_id],
688 )?;
689 Self::append_event(
690 &tx,
691 key,
692 AuditEventKind::ApprovalConsumed,
693 transaction_id,
694 receipt_digest,
695 )?;
696 }
697 tx.commit()?;
698 Ok(rows_affected > 0)
699 }
700
701 pub fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
716 let conn = self.connection()?;
717 let canceled_json = serialize_field(&JobState::Canceled)?;
718 let queued_json = serialize_field(&JobState::Queued)?;
719 let rows_affected = conn.execute(
720 &format!(
721 "UPDATE transactions SET status = ?1 \
722 WHERE status = ?2 \
723 AND julianday(created_at) <= julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes')"
724 ),
725 params![canceled_json, queued_json],
726 )?;
727 Ok(rows_affected as u64)
728 }
729
730 pub fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
739 let conn = self.connection()?;
740 let canceled_json = serialize_field(&JobState::Canceled)?;
741 let queued_json = serialize_field(&JobState::Queued)?;
742 let rows_affected = conn.execute(
743 "UPDATE transactions SET status = ?1 \
744 WHERE transaction_id = ?2 AND status = ?3",
745 params![canceled_json, transaction_id, queued_json],
746 )?;
747 Ok(rows_affected > 0)
748 }
749
750 pub fn list_transactions(
759 &self,
760 limit: u32,
761 status_filter: Option<&str>,
762 action_filter: Option<&str>,
763 since_hours: Option<u32>,
764 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
765 let conn = self.connection()?;
766 let (filter_sql, param_values) =
767 Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
768 let sql = format!(
769 "SELECT transaction_id, request_id, request_hash, action_name, \
770 risk_level, status, approval_id, summary, warnings_json \
771 FROM transactions WHERE 1=1{filter_sql}"
772 );
773 let params_ref: Vec<&dyn rusqlite::types::ToSql> =
774 param_values.iter().map(|b| b.as_ref()).collect();
775
776 let mut stmt = conn.prepare(&sql)?;
777 let rows = stmt.query_map(params_ref.as_slice(), |row| Ok(row_to_record(row)))?;
778
779 let mut results = Vec::new();
780 for row in rows {
781 results.push(row??);
782 }
783 Ok(results)
784 }
785
786 pub fn list_history(
791 &self,
792 limit: u32,
793 status_filter: Option<&str>,
794 action_filter: Option<&str>,
795 since_hours: Option<u32>,
796 ) -> Result<Vec<JobHistoryEntry>, TransactionStoreError> {
797 let conn = self.connection()?;
798 let (filter_sql, param_values) =
799 Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
800 let sql = format!(
801 "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
802 FROM transactions WHERE 1=1{filter_sql}"
803 );
804 let params_ref: Vec<&dyn rusqlite::types::ToSql> =
805 param_values.iter().map(|b| b.as_ref()).collect();
806
807 let mut stmt = conn.prepare(&sql)?;
808 let rows = stmt.query_map(params_ref.as_slice(), |row| {
809 Ok((|| {
810 Ok::<JobHistoryEntry, TransactionStoreError>(JobHistoryEntry {
811 transaction_id: row.get(0)?,
812 action_name: row.get(1)?,
813 risk_level: deserialize_field(&row.get::<_, String>(2)?)?,
814 status: deserialize_field(&row.get::<_, String>(3)?)?,
815 summary: row.get(4)?,
816 created_at: row.get(5)?,
817 })
818 })())
819 })?;
820
821 let mut results = Vec::new();
822 for row in rows {
823 results.push(row??);
824 }
825 Ok(results)
826 }
827
828 fn build_history_filter(
833 limit: u32,
834 status_filter: Option<&str>,
835 action_filter: Option<&str>,
836 since_hours: Option<u32>,
837 ) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), TransactionStoreError> {
838 let mut sql = String::new();
839 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
840
841 if let Some(status) = status_filter {
842 let validated: JobState = deserialize_field(&format!("\"{status}\""))?;
846 let status_json = serialize_field(&validated)?;
847 sql.push_str(" AND status = ?");
848 param_values.push(Box::new(status_json));
849 }
850
851 if let Some(action) = action_filter {
852 sql.push_str(" AND action_name = ?");
853 param_values.push(Box::new(action.to_string()));
854 }
855
856 if let Some(hours) = since_hours {
857 sql.push_str(" AND julianday(created_at) > julianday('now', '-' || ? || ' hours')");
858 param_values.push(Box::new(hours));
859 }
860
861 sql.push_str(" ORDER BY seq DESC LIMIT ?");
862 param_values.push(Box::new(limit.min(100)));
863
864 Ok((sql, param_values))
865 }
866
867 fn connection(&self) -> Result<Connection, TransactionStoreError> {
868 let conn = Connection::open(&self.path)?;
869 conn.pragma_update(None, "journal_mode", "WAL")?;
880 conn.pragma_update(None, "synchronous", "NORMAL")?;
881 conn.pragma_update(None, "foreign_keys", "ON")?;
882 conn.busy_timeout(std::time::Duration::from_secs(5))?;
883 Ok(conn)
884 }
885
886 fn initialize(&self) -> Result<(), TransactionStoreError> {
887 let mut conn = self.connection()?;
888 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
889 tx.execute_batch(
890 "CREATE TABLE IF NOT EXISTS schema_migrations (\
891 version INTEGER PRIMARY KEY,\
892 name TEXT NOT NULL,\
893 applied_at TEXT NOT NULL DEFAULT (datetime('now'))\
894 );",
895 )?;
896 let current: i64 = tx.query_row(
897 "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
898 [],
899 |row| row.get(0),
900 )?;
901 let latest = SQLITE_MIGRATIONS.last().map_or(0, |m| m.version);
902 if current > latest {
903 return Err(TransactionStoreError::DatabaseInvariant(format!(
904 "sqlite schema version {current} is newer than this binary supports ({latest})"
905 )));
906 }
907 for migration in SQLITE_MIGRATIONS.iter().filter(|m| m.version > current) {
908 tx.execute_batch(migration.sql)?;
909 tx.execute(
910 "INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)",
911 params![migration.version, migration.name],
912 )?;
913 }
914 tx.commit()?;
915 Ok(())
916 }
917
918 pub fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
920 let conn = self.connection()?;
921 let mut stmt = conn.prepare(&format!(
922 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions ORDER BY seq ASC"
923 ))?;
924 let rows = stmt.query_map([], chain_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 fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
934 let conn = self.connection()?;
935 let mut stmt = conn.prepare(
936 "SELECT seq, key_id, kind, transaction_id, receipt_digest, \
937 created_at, prev_chain_hash, chain_hash \
938 FROM audit_events ORDER BY seq ASC",
939 )?;
940 let rows = stmt.query_map([], event_row_from_sqlite)?;
941 let mut out = Vec::new();
942 for row in rows {
943 out.push(row?);
944 }
945 Ok(out)
946 }
947
948 pub fn verify_audit_chain(
950 &self,
951 key: &AuditKey,
952 ) -> Result<VerifyOutcome, TransactionStoreError> {
953 let rows = self.fetch_chain_rows()?;
954 Ok(audit_chain::verify_chain(key, &rows))
955 }
956
957 pub fn verify_event_chain(
959 &self,
960 key: &AuditKey,
961 ) -> Result<VerifyOutcome, TransactionStoreError> {
962 let rows = self.fetch_event_rows()?;
963 Ok(audit_chain::verify_event_chain(key, &rows))
964 }
965
966 pub fn verify_event_binding(
969 &self,
970 ) -> Result<audit_chain::BindingOutcome, TransactionStoreError> {
971 let tx_rows = self.fetch_chain_rows()?;
972 let event_rows = self.fetch_event_rows()?;
973 Ok(audit_chain::verify_event_binding(&tx_rows, &event_rows))
974 }
975
976 pub fn verify_audit_chain_with_pubkey(
979 &self,
980 verifying_key_hex: &str,
981 ) -> Result<VerifyOutcome, TransactionStoreError> {
982 let rows = self.fetch_chain_rows()?;
983 Ok(audit_chain::verify_chain_with_pubkey(
984 verifying_key_hex,
985 &rows,
986 ))
987 }
988
989 pub fn fetch_chain_row(
992 &self,
993 transaction_id: &str,
994 ) -> Result<Option<ChainRow>, TransactionStoreError> {
995 let conn = self.connection()?;
996 let mut stmt = conn.prepare(&format!(
997 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions WHERE transaction_id = ?1"
998 ))?;
999 let mut rows = stmt.query(params![transaction_id])?;
1000 if let Some(row) = rows.next()? {
1001 Ok(Some(chain_row_from_sqlite(row)?))
1002 } else {
1003 Ok(None)
1004 }
1005 }
1006
1007 fn next_seq_and_prev_hash(conn: &Connection) -> Result<(u64, String), TransactionStoreError> {
1011 let prev: Option<(i64, String)> = query_optional(
1012 conn,
1013 "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1",
1014 [],
1015 |row| Ok((row.get(0)?, row.get(1)?)),
1016 )?;
1017 Ok(match prev {
1018 Some((seq, hash)) => ((seq as u64) + 1, hash),
1019 None => (1, String::new()),
1020 })
1021 }
1022
1023 fn event_chain_tip(conn: &Connection) -> Result<Option<String>, TransactionStoreError> {
1026 Ok(query_optional(
1027 conn,
1028 "SELECT chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1",
1029 [],
1030 |row| row.get(0),
1031 )?)
1032 }
1033
1034 fn append_event(
1039 conn: &Connection,
1040 key: &AuditKey,
1041 kind: AuditEventKind,
1042 transaction_id: &str,
1043 receipt_digest: &str,
1044 ) -> Result<(), TransactionStoreError> {
1045 let prev_chain_hash = Self::event_chain_tip(conn)?.unwrap_or_default();
1046 let seq: i64 = conn.query_row(
1047 "SELECT COALESCE(MAX(seq), 0) + 1 FROM audit_events",
1048 [],
1049 |row| row.get(0),
1050 )?;
1051 let created_at: String =
1052 conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
1053 row.get(0)
1054 })?;
1055 let key_id = CURRENT_KEY_ID.to_string();
1056 let chain_hash = key.event_hash(
1057 &EventContent {
1058 seq: seq as u64,
1059 key_id: &key_id,
1060 kind,
1061 transaction_id,
1062 receipt_digest,
1063 created_at: &created_at,
1064 },
1065 &prev_chain_hash,
1066 );
1067 conn.execute(
1068 "INSERT INTO audit_events (
1069 seq, key_id, kind, transaction_id, receipt_digest,
1070 created_at, chain_hash, prev_chain_hash
1071 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1072 params![
1073 seq,
1074 key_id,
1075 kind.as_str(),
1076 transaction_id,
1077 receipt_digest,
1078 created_at,
1079 chain_hash,
1080 prev_chain_hash,
1081 ],
1082 )?;
1083 Ok(())
1084 }
1085
1086 fn insert_transaction(
1087 conn: &Connection,
1088 key: &AuditKey,
1089 transaction_id: &str,
1090 transaction: NewTransaction,
1091 ) -> Result<InsertedTransaction, TransactionStoreError> {
1092 let request_id = transaction.request_id;
1093 let request_hash = transaction.request_hash;
1094 let action_name = transaction.action_name;
1095 let risk_level = transaction.risk_level;
1096 let status = JobState::Queued;
1098 let approval_id = Some(key.approval_commitment(transaction_id, &request_hash));
1105 let summary = transaction.summary;
1106 let warnings = transaction.warnings;
1107 let warnings_json = serde_json::to_string(&warnings)?;
1108
1109 let (seq, prev_chain_hash) = Self::next_seq_and_prev_hash(conn)?;
1112
1113 let created_at: String =
1117 conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
1118 row.get(0)
1119 })?;
1120
1121 let key_id = CURRENT_KEY_ID.to_string();
1122 let event_tip = Self::event_chain_tip(conn)?.unwrap_or_default();
1126 let caller_role = transaction.caller_role.as_str();
1127 let caller_principal = transaction.caller_principal.as_signed_str();
1128 let identity = ChainIdentity::V3 {
1132 caller_role,
1133 event_tip: &event_tip,
1134 caller_principal: &caller_principal,
1135 };
1136 let chain_hash = key.chain_hash(
1137 &ChainContent {
1138 seq,
1139 key_id: &key_id,
1140 transaction_id,
1141 request_id: &request_id,
1142 request_hash: &request_hash,
1143 action_name: &action_name,
1144 risk_level,
1145 summary: &summary,
1146 approval_id: approval_id.as_deref(),
1147 warnings_json: &warnings_json,
1148 created_at: &created_at,
1149 identity,
1150 },
1151 &prev_chain_hash,
1152 );
1153
1154 let record = TransactionRecord {
1155 transaction_id: transaction_id.to_string(),
1156 request_id: request_id.clone(),
1157 request_hash: request_hash.clone(),
1158 action_name: action_name.clone(),
1159 risk_level,
1160 status,
1161 approval_id: approval_id.clone(),
1162 summary: summary.clone(),
1163 warnings: warnings.clone(),
1164 };
1165
1166 conn.execute(
1167 "INSERT INTO transactions (
1168 transaction_id,
1169 request_id,
1170 request_hash,
1171 action_name,
1172 risk_level,
1173 status,
1174 approval_id,
1175 summary,
1176 warnings_json,
1177 created_at,
1178 seq,
1179 key_id,
1180 chain_hash,
1181 prev_chain_hash,
1182 chain_version,
1183 caller_role,
1184 event_tip,
1185 caller_principal
1186 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
1187 params![
1188 transaction_id,
1189 request_id,
1190 request_hash,
1191 action_name,
1192 serialize_field(&risk_level)?,
1193 serialize_field(&status)?,
1194 approval_id,
1195 summary,
1196 warnings_json,
1197 created_at,
1198 seq as i64,
1199 key_id,
1200 chain_hash,
1201 prev_chain_hash,
1202 identity.version() as i64,
1203 caller_role,
1204 event_tip,
1205 caller_principal,
1206 ],
1207 )?;
1208
1209 Ok(InsertedTransaction {
1210 record,
1211 seq,
1212 chain_hash,
1213 })
1214 }
1215
1216 fn insert_preview(
1217 conn: &Connection,
1218 transaction_id: &str,
1219 preview: &PreviewEnvelope,
1220 ) -> Result<(), TransactionStoreError> {
1221 conn.execute(
1222 "INSERT INTO transaction_previews (transaction_id, preview_json)
1223 VALUES (?1, ?2)",
1224 params![transaction_id, serde_json::to_string(preview)?],
1225 )?;
1226 Ok(())
1227 }
1228}
1229
1230fn ensure_private_dir(dir: &Path) -> std::io::Result<()> {
1237 use std::os::unix::fs::DirBuilderExt;
1238 if dir.exists() {
1239 return Ok(());
1240 }
1241 std::fs::DirBuilder::new()
1242 .recursive(true)
1243 .mode(0o700)
1244 .create(dir)
1245}
1246
1247fn restrict_db_file_mode(db_path: &Path) {
1261 use std::os::unix::fs::PermissionsExt;
1262
1263 let sidecars = [
1267 db_path.to_path_buf(),
1268 with_suffix(db_path, "-wal"),
1269 with_suffix(db_path, "-shm"),
1270 ];
1271 for path in sidecars {
1272 let Ok(metadata) = std::fs::metadata(&path) else {
1273 continue; };
1275 let mode = metadata.permissions().mode() & 0o777;
1276 if mode & 0o077 == 0 {
1277 continue; }
1279 if let Err(err) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) {
1280 eprintln!(
1281 "[sysknife-daemon] WARNING: {} is mode {mode:o} and could not be \
1282 narrowed to 0600 ({err}); it is readable by anyone who can reach \
1283 it. The parent directory should be 0700 — check it.",
1284 path.display()
1285 );
1286 }
1287 }
1288}
1289
1290fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
1293 let mut name = path.as_os_str().to_os_string();
1294 name.push(suffix);
1295 PathBuf::from(name)
1296}
1297
1298fn row_to_record(row: &rusqlite::Row) -> Result<TransactionRecord, TransactionStoreError> {
1299 Ok(TransactionRecord {
1300 transaction_id: row.get(0)?,
1301 request_id: row.get(1)?,
1302 request_hash: row.get(2)?,
1303 action_name: row.get(3)?,
1304 risk_level: deserialize_field(&row.get::<_, String>(4)?)?,
1305 status: deserialize_field(&row.get::<_, String>(5)?)?,
1306 approval_id: row.get(6)?,
1307 summary: row.get(7)?,
1308 warnings: serde_json::from_str(&row.get::<_, String>(8)?)?,
1309 })
1310}
1311
1312fn serialize_field<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
1313 serde_json::to_string(value)
1314}
1315
1316fn deserialize_field<T: DeserializeOwned>(value: &str) -> Result<T, serde_json::Error> {
1317 serde_json::from_str(value)
1318}
1319
1320#[cfg(test)]
1321mod tests {
1322 use super::*;
1323 use crate::audit_chain::CHAIN_VERSION_CURRENT;
1324 use crate::audit_chain::CHAIN_VERSION_LEGACY;
1325 use std::os::unix::fs::PermissionsExt;
1326 use tempfile::tempdir;
1327
1328 fn test_store(path: impl AsRef<Path>) -> TransactionStore {
1332 let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
1333 TransactionStore::open_with_key(path, key).unwrap()
1334 }
1335
1336 fn legacy_v1_database(path: &Path, key: &AuditKey) -> String {
1343 let conn = Connection::open(path).unwrap();
1344 conn.execute_batch(
1345 "CREATE TABLE schema_migrations (
1346 version INTEGER PRIMARY KEY,
1347 name TEXT NOT NULL,
1348 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1349 );",
1350 )
1351 .unwrap();
1352 conn.execute_batch(SQLITE_MIGRATIONS[0].sql).unwrap();
1353 conn.execute(
1354 "INSERT INTO schema_migrations (version, name) VALUES (1, ?1)",
1355 params![SQLITE_MIGRATIONS[0].name],
1356 )
1357 .unwrap();
1358
1359 let transaction_id = "tx-legacy";
1360 let created_at = "2026-04-24T12:00:00.000Z";
1361 let chain_hash = key.chain_hash(
1362 &ChainContent {
1363 seq: 1,
1364 key_id: CURRENT_KEY_ID,
1365 transaction_id,
1366 request_id: "req-legacy",
1367 request_hash: "hash-legacy",
1368 action_name: "UpdateSystem",
1369 risk_level: RiskLevel::High,
1370 summary: "Upgrade the system",
1371 approval_id: None,
1372 warnings_json: "[]",
1373 created_at,
1374 identity: ChainIdentity::LegacyV1,
1375 },
1376 "",
1377 );
1378 conn.execute(
1379 "INSERT INTO transactions (
1380 transaction_id, request_id, request_hash, action_name, risk_level,
1381 status, approval_id, summary, warnings_json, created_at,
1382 seq, key_id, chain_hash, prev_chain_hash
1383 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, 1, ?10, ?11, '')",
1384 params![
1385 transaction_id,
1386 "req-legacy",
1387 "hash-legacy",
1388 "UpdateSystem",
1389 serialize_field(&RiskLevel::High).unwrap(),
1390 serialize_field(&JobState::Queued).unwrap(),
1391 "Upgrade the system",
1392 "[]",
1393 created_at,
1394 CURRENT_KEY_ID,
1395 chain_hash,
1396 ],
1397 )
1398 .unwrap();
1399 transaction_id.to_string()
1400 }
1401
1402 fn v2_database(path: &Path, key: &AuditKey) -> String {
1409 let conn = Connection::open(path).unwrap();
1410 conn.execute_batch(
1411 "CREATE TABLE schema_migrations (
1412 version INTEGER PRIMARY KEY,
1413 name TEXT NOT NULL,
1414 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1415 );",
1416 )
1417 .unwrap();
1418 for migration in &SQLITE_MIGRATIONS[0..=1] {
1419 conn.execute_batch(migration.sql).unwrap();
1420 conn.execute(
1421 "INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)",
1422 params![migration.version, migration.name],
1423 )
1424 .unwrap();
1425 }
1426
1427 let transaction_id = "tx-v2";
1428 let created_at = "2026-07-01T12:00:00.000Z";
1429 let chain_hash = key.chain_hash(
1430 &ChainContent {
1431 seq: 1,
1432 key_id: CURRENT_KEY_ID,
1433 transaction_id,
1434 request_id: "req-v2",
1435 request_hash: "hash-v2",
1436 action_name: "UpdateSystem",
1437 risk_level: RiskLevel::High,
1438 summary: "Upgrade the system",
1439 approval_id: None,
1440 warnings_json: "[]",
1441 created_at,
1442 identity: ChainIdentity::V2 {
1443 caller_role: "admin",
1444 event_tip: "",
1445 },
1446 },
1447 "",
1448 );
1449 conn.execute(
1450 "INSERT INTO transactions (
1451 transaction_id, request_id, request_hash, action_name, risk_level,
1452 status, approval_id, summary, warnings_json, created_at,
1453 seq, key_id, chain_hash, prev_chain_hash,
1454 chain_version, caller_role, event_tip
1455 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, 1, ?10, ?11, '', 2, 'admin', '')",
1456 params![
1457 transaction_id,
1458 "req-v2",
1459 "hash-v2",
1460 "UpdateSystem",
1461 serialize_field(&RiskLevel::High).unwrap(),
1462 serialize_field(&JobState::Queued).unwrap(),
1463 "Upgrade the system",
1464 "[]",
1465 created_at,
1466 CURRENT_KEY_ID,
1467 chain_hash,
1468 ],
1469 )
1470 .unwrap();
1471 transaction_id.to_string()
1472 }
1473
1474 #[test]
1482 fn a_v2_database_keeps_verifying_and_accepts_v3_rows() {
1483 let dir = tempdir().unwrap();
1484 let db_path = dir.path().join("tx.db");
1485 let key = AuditKey::from_bytes(vec![0x42; 32]);
1486 let old_transaction = v2_database(&db_path, &key);
1487
1488 let store = test_store(&db_path);
1489
1490 let version: i64 = store
1491 .connection()
1492 .unwrap()
1493 .query_row("SELECT MAX(version) FROM schema_migrations", [], |row| {
1494 row.get(0)
1495 })
1496 .unwrap();
1497 assert_eq!(version, 3, "opening a v2 database must apply migration 3");
1498
1499 let rows = store.fetch_chain_rows().unwrap();
1500 assert_eq!(rows.len(), 1);
1501 assert_eq!(rows[0].transaction_id, old_transaction);
1502 assert_eq!(rows[0].chain_version, audit_chain::CHAIN_VERSION_V2);
1503 assert_eq!(
1504 rows[0].caller_principal, None,
1505 "migration 3 must not invent a principal for a row signed without one"
1506 );
1507 assert!(matches!(
1508 store.verify_audit_chain(&key).unwrap(),
1509 audit_chain::VerifyOutcome::Intact { rows_checked: 1 }
1510 ));
1511
1512 let mut fresh = queued_transaction();
1514 fresh.caller_principal = CallerPrincipal::Uid(4242);
1515 store.record(fresh).unwrap();
1516
1517 let rows = store.fetch_chain_rows().unwrap();
1518 assert_eq!(rows.len(), 2);
1519 assert_eq!(rows[1].chain_version, audit_chain::CHAIN_VERSION_CURRENT);
1520 assert_eq!(rows[1].caller_principal.as_deref(), Some("uid:4242"));
1521 assert_eq!(
1522 rows[1].prev_chain_hash, rows[0].chain_hash,
1523 "the v3 row must chain onto the v2 row, not start a new chain"
1524 );
1525 assert!(matches!(
1526 store.verify_audit_chain(&key).unwrap(),
1527 audit_chain::VerifyOutcome::Intact { rows_checked: 2 }
1528 ));
1529
1530 let census = audit_chain::AttributionCensus::of(&rows);
1538 assert_eq!(census.rows(), 2);
1539 assert_eq!(census.named(), 1, "only the v3 row names uid:4242");
1540 assert_eq!(
1541 census.not_recorded(),
1542 1,
1543 "the v2 row's encoding never signed a principal"
1544 );
1545 assert_eq!(census.attribution_failed(), 0);
1546 assert_eq!(census.unattested(), 0);
1547 assert_eq!(census.unnamed(), 1);
1548 }
1549
1550 #[test]
1551 fn a_database_written_before_the_migration_still_verifies_after_upgrading() {
1552 let dir = tempdir().unwrap();
1557 let db_path = dir.path().join("tx.db");
1558 let key = AuditKey::from_bytes(vec![0x42; 32]);
1559 legacy_v1_database(&db_path, &key);
1560
1561 let store = test_store(&db_path);
1562 assert_eq!(
1563 store.verify_audit_chain(&key).unwrap(),
1564 VerifyOutcome::Intact { rows_checked: 1 }
1565 );
1566 let rows = store.fetch_chain_rows().unwrap();
1567 assert_eq!(rows[0].chain_version, CHAIN_VERSION_LEGACY);
1568 assert_eq!(rows[0].caller_role, None);
1569 }
1570
1571 #[test]
1572 fn rows_appended_after_the_migration_chain_onto_legacy_rows() {
1573 let dir = tempdir().unwrap();
1577 let db_path = dir.path().join("tx.db");
1578 let key = AuditKey::from_bytes(vec![0x42; 32]);
1579 legacy_v1_database(&db_path, &key);
1580
1581 let store = test_store(&db_path);
1582 store.record(queued_transaction()).unwrap();
1583
1584 let rows = store.fetch_chain_rows().unwrap();
1585 assert_eq!(rows.len(), 2);
1586 assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
1587 assert_eq!(rows[1].chain_version, CHAIN_VERSION_CURRENT);
1588 assert_eq!(
1589 store.verify_audit_chain(&key).unwrap(),
1590 VerifyOutcome::Intact { rows_checked: 2 }
1591 );
1592 }
1593
1594 #[test]
1595 fn a_schema_newer_than_this_binary_is_refused() {
1596 let dir = tempdir().unwrap();
1597 let db_path = dir.path().join("tx.db");
1598 let key = AuditKey::from_bytes(vec![0x42; 32]);
1599 legacy_v1_database(&db_path, &key);
1600 let conn = Connection::open(&db_path).unwrap();
1601 conn.execute(
1602 "INSERT INTO schema_migrations (version, name) VALUES (99, 'from the future')",
1603 [],
1604 )
1605 .unwrap();
1606 drop(conn);
1607
1608 let err = TransactionStore::open_with_key(&db_path, Arc::new(key)).unwrap_err();
1609 assert!(
1610 matches!(err, TransactionStoreError::DatabaseInvariant(ref m) if m.contains("99")),
1611 "expected a refusal naming the unsupported version, got {err:?}"
1612 );
1613 }
1614
1615 #[test]
1618 fn the_signed_row_records_which_role_asked() {
1619 let dir = tempdir().unwrap();
1620 let store = test_store(dir.path().join("tx.db"));
1621 let mut tx = queued_transaction();
1622 tx.caller_role = CallerRole::Admin;
1623 store.record(tx).unwrap();
1624
1625 let rows = store.fetch_chain_rows().unwrap();
1626 assert_eq!(rows[0].caller_role.as_deref(), Some("admin"));
1627 }
1628
1629 #[test]
1630 fn approving_consuming_and_revoking_each_append_a_chained_event() {
1631 let dir = tempdir().unwrap();
1632 let key = AuditKey::from_bytes(vec![0x42; 32]);
1633 let store = test_store(dir.path().join("tx.db"));
1634
1635 let a = store.record(queued_transaction()).unwrap();
1637 let receipt = store
1638 .approve_transaction(&a.transaction_id)
1639 .unwrap()
1640 .expect("queued transaction approves");
1641 let digest = audit_chain::approval_receipt_digest(&receipt);
1642 assert!(store
1643 .claim_approved_for_execution(&a.transaction_id, &digest)
1644 .unwrap());
1645
1646 let b = store.record(queued_transaction()).unwrap();
1648 store.approve_transaction(&b.transaction_id).unwrap();
1649 assert!(store.revoke_unconsumed_approval(&b.transaction_id).unwrap());
1650
1651 let events = store.fetch_event_rows().unwrap();
1652 let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect();
1653 assert_eq!(
1654 kinds,
1655 vec![
1656 "approval_granted",
1657 "approval_consumed",
1658 "approval_granted",
1659 "approval_revoked",
1660 ]
1661 );
1662 assert_eq!(
1663 store.verify_event_chain(&key).unwrap(),
1664 VerifyOutcome::Intact { rows_checked: 4 }
1665 );
1666 }
1667
1668 #[test]
1669 fn a_failed_approval_appends_no_event() {
1670 let dir = tempdir().unwrap();
1674 let store = test_store(dir.path().join("tx.db"));
1675 let tx = store.record(queued_transaction()).unwrap();
1676 store.approve_transaction(&tx.transaction_id).unwrap();
1677 store.approve_transaction(&tx.transaction_id).unwrap();
1678 assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
1679
1680 assert!(!store.revoke_unconsumed_approval("no-such-tx").unwrap());
1682 assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
1683 }
1684
1685 #[test]
1686 fn deleting_the_record_that_an_approval_happened_no_longer_goes_unnoticed() {
1687 let dir = tempdir().unwrap();
1693 let db_path = dir.path().join("tx.db");
1694 let key = AuditKey::from_bytes(vec![0x42; 32]);
1695 let store = test_store(&db_path);
1696
1697 let a = store.record(queued_transaction()).unwrap();
1698 store.approve_transaction(&a.transaction_id).unwrap();
1699 store.record(queued_transaction()).unwrap();
1702
1703 assert_eq!(
1704 store.verify_event_chain(&key).unwrap(),
1705 VerifyOutcome::Intact { rows_checked: 1 }
1706 );
1707 assert_eq!(
1708 store.verify_event_binding().unwrap(),
1709 audit_chain::BindingOutcome::Consistent {
1710 bindings_checked: 1
1711 }
1712 );
1713
1714 let conn = Connection::open(&db_path).unwrap();
1716 conn.execute("DELETE FROM transaction_approvals", [])
1717 .unwrap();
1718 conn.execute("DELETE FROM audit_events", []).unwrap();
1719 drop(conn);
1720
1721 assert_eq!(
1725 store.verify_event_chain(&key).unwrap(),
1726 VerifyOutcome::Intact { rows_checked: 0 }
1727 );
1728 match store.verify_event_binding().unwrap() {
1729 audit_chain::BindingOutcome::MissingEvent {
1730 transaction_seq, ..
1731 } => assert_eq!(transaction_seq, 2),
1732 other => panic!("expected MissingEvent, got {other:?}"),
1733 }
1734 }
1735
1736 #[test]
1737 fn a_read_only_store_cannot_revoke_or_claim() {
1738 let dir = tempdir().unwrap();
1741 let db_path = dir.path().join("tx.db");
1742 let store = test_store(&db_path);
1743 store.record(queued_transaction()).unwrap();
1744
1745 let read_only = TransactionStore::open_read_only(&db_path).unwrap();
1746 assert!(matches!(
1747 read_only.revoke_unconsumed_approval("tx"),
1748 Err(TransactionStoreError::AuditChainMissing(_))
1749 ));
1750 assert!(matches!(
1751 read_only.claim_approved_for_execution("tx", "digest"),
1752 Err(TransactionStoreError::AuditChainMissing(_))
1753 ));
1754 }
1755
1756 #[test]
1763 fn record_persists_the_caller_principal_under_the_current_encoding() {
1764 let dir = tempdir().unwrap();
1765 let store = test_store(dir.path().join("tx.db"));
1766 let mut tx = queued_transaction();
1767 tx.caller_principal = CallerPrincipal::Uid(4242);
1768 store.record(tx).unwrap();
1769
1770 let conn = store.connection().unwrap();
1771 let (version, role, principal): (i64, Option<String>, Option<String>) = conn
1772 .query_row(
1773 "SELECT chain_version, caller_role, caller_principal FROM transactions",
1774 [],
1775 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1776 )
1777 .unwrap();
1778
1779 assert_eq!(version, i64::from(audit_chain::CHAIN_VERSION_CURRENT));
1780 assert_eq!(role.as_deref(), Some("dev"));
1781 assert_eq!(
1782 principal.as_deref(),
1783 Some("uid:4242"),
1784 "the row must name the account the dispatcher resolved"
1785 );
1786
1787 let key = AuditKey::from_bytes(vec![0x42; 32]);
1789 assert!(matches!(
1790 store.verify_audit_chain(&key).unwrap(),
1791 audit_chain::VerifyOutcome::Intact { .. }
1792 ));
1793 }
1794
1795 #[test]
1796 fn record_writes_audit_chain_columns() {
1797 let dir = tempdir().unwrap();
1798 let store = test_store(dir.path().join("tx.db"));
1799 let _record = store.record(queued_transaction()).unwrap();
1800
1801 let conn = store.connection().unwrap();
1802 let (seq, key_id, chain_hash, prev): (i64, String, String, String) = conn
1803 .query_row(
1804 "SELECT seq, key_id, chain_hash, prev_chain_hash FROM transactions",
1805 [],
1806 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1807 )
1808 .unwrap();
1809 assert_eq!(seq, 1, "first row gets seq=1");
1810 assert_eq!(key_id, audit_chain::CURRENT_KEY_ID);
1811 assert_eq!(prev, "", "first row has empty prev_chain_hash");
1812 assert_eq!(
1813 chain_hash.len(),
1814 audit_chain::HASH_HEX_LEN,
1815 "chain_hash is a hex-encoded Ed25519 signature"
1816 );
1817 }
1818
1819 #[test]
1820 fn sequential_records_produce_chained_hashes() {
1821 let dir = tempdir().unwrap();
1822 let store = test_store(dir.path().join("tx.db"));
1823 store.record(queued_transaction()).unwrap();
1824 store.record(queued_transaction()).unwrap();
1825 store.record(queued_transaction()).unwrap();
1826
1827 let rows = store.fetch_chain_rows().unwrap();
1828 assert_eq!(rows.len(), 3);
1829 assert_eq!(rows[0].seq, 1);
1830 assert_eq!(rows[1].seq, 2);
1831 assert_eq!(rows[2].seq, 3);
1832 assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
1834 assert_eq!(rows[2].prev_chain_hash, rows[1].chain_hash);
1835 }
1836
1837 #[test]
1849 fn concurrent_record_keeps_chain_intact_and_seqs_contiguous() {
1850 const WORKERS: usize = 8;
1851 const RECORDS_PER_WORKER: usize = 10;
1852 const TOTAL: usize = WORKERS * RECORDS_PER_WORKER;
1853
1854 let dir = tempdir().unwrap();
1855 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
1856
1857 let mut handles = Vec::with_capacity(WORKERS);
1858 for w in 0..WORKERS {
1859 let store = std::sync::Arc::clone(&store);
1860 handles.push(std::thread::spawn(move || {
1861 for r in 0..RECORDS_PER_WORKER {
1862 let tx = NewTransaction {
1863 request_id: format!("worker-{w}-record-{r}"),
1864 request_hash: format!("hash-{w}-{r}"),
1865 action_name: "GetSystemState".to_string(),
1866 risk_level: RiskLevel::Low,
1867 summary: format!("worker {w} record {r}"),
1868 warnings: vec![],
1869 caller_role: CallerRole::Dev,
1870 caller_principal: CallerPrincipal::Uid(3000 + w as u32),
1874 };
1875 store
1876 .record(tx)
1877 .expect("record must succeed under contention");
1878 }
1879 }));
1880 }
1881 for h in handles {
1882 h.join().expect("worker thread did not panic");
1883 }
1884
1885 let key = AuditKey::from_bytes(vec![0x42; 32]);
1887 let outcome = store.verify_audit_chain(&key).unwrap();
1888 match outcome {
1889 VerifyOutcome::Intact { rows_checked } => {
1890 assert_eq!(
1891 rows_checked, TOTAL as u64,
1892 "expected {TOTAL} rows checked, got {rows_checked}"
1893 );
1894 }
1895 other => panic!("chain must be Intact under concurrent writes; got {other:?}"),
1896 }
1897
1898 {
1902 let conn = store.connection().unwrap();
1903 let mut stmt = conn
1904 .prepare("SELECT summary, caller_principal FROM transactions")
1905 .unwrap();
1906 let pairs: Vec<(String, Option<String>)> = stmt
1907 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
1908 .unwrap()
1909 .map(|r| r.unwrap())
1910 .collect();
1911 assert_eq!(pairs.len(), TOTAL);
1912 for (summary, principal) in pairs {
1913 let worker: u32 = summary
1914 .split_whitespace()
1915 .nth(1)
1916 .and_then(|w| w.parse().ok())
1917 .unwrap_or_else(|| panic!("unexpected summary {summary:?}"));
1918 assert_eq!(
1919 principal.as_deref(),
1920 Some(format!("uid:{}", 3000 + worker).as_str()),
1921 "row {summary:?} was attributed to the wrong account"
1922 );
1923 }
1924 }
1925
1926 let conn = store.connection().unwrap();
1928 let mut stmt = conn
1929 .prepare("SELECT seq FROM transactions ORDER BY seq ASC")
1930 .unwrap();
1931 let seqs: Vec<i64> = stmt
1932 .query_map([], |row| row.get::<_, i64>(0))
1933 .unwrap()
1934 .map(|r| r.unwrap())
1935 .collect();
1936 assert_eq!(seqs.len(), TOTAL, "row count mismatch");
1937 for (i, s) in seqs.iter().enumerate() {
1938 assert_eq!(
1939 *s,
1940 (i as i64) + 1,
1941 "seq column must be contiguous 1..={TOTAL}; saw {s} at position {i}"
1942 );
1943 }
1944 }
1945
1946 #[test]
1947 fn verify_audit_chain_intact_after_inserts() {
1948 let dir = tempdir().unwrap();
1949 let store = test_store(dir.path().join("tx.db"));
1950 for _ in 0..3 {
1951 store.record(queued_transaction()).unwrap();
1952 }
1953 let key = AuditKey::from_bytes(vec![0x42; 32]);
1954 let outcome = store.verify_audit_chain(&key).unwrap();
1955 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
1956 }
1957
1958 #[test]
1959 fn verify_audit_chain_with_pubkey_intact_after_inserts() {
1960 let dir = tempdir().unwrap();
1961 let store = test_store(dir.path().join("tx.db"));
1962 for _ in 0..3 {
1963 store.record(queued_transaction()).unwrap();
1964 }
1965 let key = AuditKey::from_bytes(vec![0x42; 32]);
1967 let outcome = store
1968 .verify_audit_chain_with_pubkey(&key.verifying_key_hex())
1969 .unwrap();
1970 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
1971 }
1972
1973 #[test]
1974 fn verify_audit_chain_with_wrong_pubkey_is_broken() {
1975 let dir = tempdir().unwrap();
1976 let store = test_store(dir.path().join("tx.db"));
1977 store.record(queued_transaction()).unwrap();
1978 let other = AuditKey::from_bytes(vec![0x99; 32]);
1980 let outcome = store
1981 .verify_audit_chain_with_pubkey(&other.verifying_key_hex())
1982 .unwrap();
1983 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
1984 }
1985
1986 #[test]
1987 fn verify_detects_tampered_summary() {
1988 let dir = tempdir().unwrap();
1989 let store = test_store(dir.path().join("tx.db"));
1990 let tx = store.record(queued_transaction()).unwrap();
1991
1992 let conn = store.connection().unwrap();
1995 conn.execute(
1996 "UPDATE transactions SET summary = ?1 WHERE transaction_id = ?2",
1997 params!["EVIL CHANGE", tx.transaction_id],
1998 )
1999 .unwrap();
2000
2001 let key = AuditKey::from_bytes(vec![0x42; 32]);
2002 let outcome = store.verify_audit_chain(&key).unwrap();
2003 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
2004 }
2005
2006 #[test]
2007 fn status_update_does_not_break_chain() {
2008 let dir = tempdir().unwrap();
2010 let store = test_store(dir.path().join("tx.db"));
2011 let tx = store.record(queued_transaction()).unwrap();
2012 store
2013 .update_status(&tx.transaction_id, JobState::Running)
2014 .unwrap();
2015 store
2016 .update_status(&tx.transaction_id, JobState::Succeeded)
2017 .unwrap();
2018
2019 let key = AuditKey::from_bytes(vec![0x42; 32]);
2020 let outcome = store.verify_audit_chain(&key).unwrap();
2021 assert!(
2022 matches!(outcome, VerifyOutcome::Intact { rows_checked: 1 }),
2023 "status mutation must not break the chain (status not in chain content): {outcome:?}"
2024 );
2025 }
2026
2027 #[test]
2028 fn open_read_only_rejects_record() {
2029 let dir = tempdir().unwrap();
2030 let key_path = dir.path().join("audit-key");
2031 std::fs::write(&key_path, vec![0x42; 32]).unwrap();
2032 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
2033
2034 let store = TransactionStore::open_read_only(dir.path().join("tx.db")).unwrap();
2035 let result = store.record(queued_transaction());
2036 assert!(matches!(
2037 result,
2038 Err(TransactionStoreError::AuditChainMissing(_))
2039 ));
2040 }
2041
2042 #[test]
2043 fn ensure_private_dir_creates_with_0700_mode() {
2044 let root = tempdir().unwrap();
2045 let target = root.path().join("a/b/c");
2046 ensure_private_dir(&target).unwrap();
2047 assert!(target.is_dir());
2048 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
2049 assert_eq!(mode, 0o700, "leaf dir must be 0o700, got {mode:o}");
2050 }
2051
2052 #[test]
2060 fn the_audit_database_is_created_owner_only() {
2061 let dir = tempdir().unwrap();
2062 let db = dir.path().join("daemon.sqlite");
2063 let store = TransactionStore::open(&db).unwrap();
2064 store
2066 .record(NewTransaction {
2067 request_id: "mode-probe".to_string(),
2068 request_hash: "hash-mode-probe".to_string(),
2069 action_name: "GetSystemState".to_string(),
2070 risk_level: RiskLevel::Low,
2071 summary: "probe".to_string(),
2072 warnings: vec![],
2073 caller_role: CallerRole::Dev,
2074 caller_principal: CallerPrincipal::Uid(3000),
2075 })
2076 .unwrap();
2077
2078 for path in [db.clone(), db.with_extension("sqlite-wal")] {
2079 if !path.exists() {
2080 continue;
2081 }
2082 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2083 assert_eq!(
2084 mode,
2085 0o600,
2086 "{} must be owner-only, got {mode:o}",
2087 path.display()
2088 );
2089 }
2090 }
2091
2092 #[test]
2093 fn ensure_private_dir_is_idempotent_and_does_not_widen_existing_mode() {
2094 let root = tempdir().unwrap();
2095 let target = root.path().join("preexisting");
2096 std::fs::create_dir(&target).unwrap();
2097 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
2098 ensure_private_dir(&target).unwrap();
2099 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
2101 assert_eq!(mode, 0o755);
2102 }
2103
2104 #[test]
2105 fn open_creates_parent_with_private_mode() {
2106 let root = tempdir().unwrap();
2107 let db_path = root.path().join("nested/dirs/daemon.sqlite");
2108 let _store = test_store(&db_path);
2109 let parent = db_path.parent().unwrap();
2110 let mode = std::fs::metadata(parent).unwrap().permissions().mode() & 0o777;
2111 assert_eq!(mode, 0o700);
2112 }
2113
2114 fn queued_transaction() -> NewTransaction {
2115 NewTransaction {
2116 request_id: "req-1".to_string(),
2117 request_hash: "hash-abc".to_string(),
2118 action_name: "UpdateSystem".to_string(),
2119 risk_level: RiskLevel::High,
2120 summary: "Upgrade the system".to_string(),
2121 warnings: vec![],
2122 caller_role: CallerRole::Dev,
2123 caller_principal: CallerPrincipal::Uid(1000),
2124 }
2125 }
2126
2127 #[test]
2128 fn update_status_transitions_queued_to_running() {
2129 let dir = tempdir().unwrap();
2130 let store = test_store(dir.path().join("tx.db"));
2131 let tx = store.record(queued_transaction()).unwrap();
2132
2133 store
2134 .update_status(&tx.transaction_id, JobState::Running)
2135 .unwrap();
2136
2137 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2138 assert_eq!(updated.status, JobState::Running);
2139 }
2140
2141 #[test]
2142 fn update_status_transitions_running_to_succeeded() {
2143 let dir = tempdir().unwrap();
2144 let store = test_store(dir.path().join("tx.db"));
2145 let tx = store.record(queued_transaction()).unwrap();
2146
2147 store
2148 .update_status(&tx.transaction_id, JobState::Running)
2149 .unwrap();
2150 store
2151 .update_status(&tx.transaction_id, JobState::Succeeded)
2152 .unwrap();
2153
2154 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2155 assert_eq!(updated.status, JobState::Succeeded);
2156 }
2157
2158 #[test]
2159 fn update_status_for_unknown_id_returns_not_found() {
2160 let dir = tempdir().unwrap();
2161 let store = test_store(dir.path().join("tx.db"));
2162
2163 let result = store.update_status("does-not-exist", JobState::Running);
2164 assert!(
2165 matches!(result, Err(TransactionStoreError::NotFound(ref id)) if id == "does-not-exist"),
2166 "expected NotFound, got: {result:?}"
2167 );
2168 }
2169
2170 #[test]
2171 fn update_status_leaves_other_fields_intact() {
2172 let dir = tempdir().unwrap();
2173 let store = test_store(dir.path().join("tx.db"));
2174 let tx = store.record(queued_transaction()).unwrap();
2175
2176 store
2177 .update_status(&tx.transaction_id, JobState::Running)
2178 .unwrap();
2179 store
2180 .update_status(&tx.transaction_id, JobState::Failed)
2181 .unwrap();
2182
2183 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2184 assert_eq!(updated.action_name, "UpdateSystem");
2185 assert_eq!(updated.risk_level, RiskLevel::High);
2186 assert_eq!(updated.status, JobState::Failed);
2187 }
2188
2189 #[test]
2198 fn update_status_is_atomic_under_concurrent_conflicting_transitions() {
2199 let dir = tempdir().unwrap();
2200 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
2201 let tx = store.record(queued_transaction()).unwrap();
2202 store
2203 .update_status(&tx.transaction_id, JobState::Running)
2204 .unwrap();
2205
2206 let store_a = std::sync::Arc::clone(&store);
2207 let id_a = tx.transaction_id.clone();
2208 let succeed = std::thread::spawn(move || store_a.update_status(&id_a, JobState::Succeeded));
2209
2210 let store_b = std::sync::Arc::clone(&store);
2211 let id_b = tx.transaction_id.clone();
2212 let fail = std::thread::spawn(move || store_b.update_status(&id_b, JobState::Failed));
2213
2214 let succeed_result = succeed.join().unwrap();
2215 let fail_result = fail.join().unwrap();
2216
2217 let oks = [succeed_result.is_ok(), fail_result.is_ok()]
2219 .into_iter()
2220 .filter(|ok| *ok)
2221 .count();
2222 assert_eq!(
2223 oks, 1,
2224 "exactly one concurrent transition should succeed: succeed={succeed_result:?} \
2225 fail={fail_result:?}"
2226 );
2227
2228 let loser = if succeed_result.is_ok() {
2244 &fail_result
2245 } else {
2246 &succeed_result
2247 };
2248 match loser {
2249 Err(TransactionStoreError::ConcurrentStatusChange(id)) => {
2250 assert_eq!(id, &tx.transaction_id);
2251 }
2252 Err(TransactionStoreError::InvalidTransition { from, to }) => {
2253 let winner = if succeed_result.is_ok() {
2255 JobState::Succeeded
2256 } else {
2257 JobState::Failed
2258 };
2259 assert_eq!(
2260 *from, winner,
2261 "the loser must have observed the winner's committed status"
2262 );
2263 assert_ne!(*to, winner, "the loser must be the other transition");
2264 }
2265 other => panic!(
2266 "the losing transition must be refused with either \
2267 ConcurrentStatusChange or InvalidTransition, got: {other:?}"
2268 ),
2269 }
2270
2271 let final_status = store.get(&tx.transaction_id).unwrap().unwrap().status;
2274 if succeed_result.is_ok() {
2275 assert_eq!(final_status, JobState::Succeeded);
2276 } else {
2277 assert_eq!(final_status, JobState::Failed);
2278 }
2279 }
2280
2281 #[test]
2282 fn revoke_unconsumed_approval_removes_an_unused_receipt() {
2283 let dir = tempdir().unwrap();
2286 let store = test_store(dir.path().join("tx.db"));
2287 let tx = store.record(queued_transaction()).unwrap();
2288 let receipt = store
2289 .approve_transaction(&tx.transaction_id)
2290 .unwrap()
2291 .expect("approved");
2292 let digest = audit_chain::approval_receipt_digest(&receipt);
2293
2294 assert!(
2295 store
2296 .revoke_unconsumed_approval(&tx.transaction_id)
2297 .unwrap(),
2298 "an unconsumed approval must be revocable"
2299 );
2300 assert!(
2301 !store
2302 .claim_approved_for_execution(&tx.transaction_id, &digest)
2303 .unwrap(),
2304 "a revoked receipt must no longer be claimable"
2305 );
2306 }
2307
2308 #[test]
2309 fn revoke_cannot_retract_an_approval_that_was_already_used() {
2310 let dir = tempdir().unwrap();
2314 let store = test_store(dir.path().join("tx.db"));
2315 let tx = store.record(queued_transaction()).unwrap();
2316 let receipt = store
2317 .approve_transaction(&tx.transaction_id)
2318 .unwrap()
2319 .expect("approved");
2320 let digest = audit_chain::approval_receipt_digest(&receipt);
2321 assert!(store
2322 .claim_approved_for_execution(&tx.transaction_id, &digest)
2323 .unwrap());
2324
2325 assert!(
2326 !store
2327 .revoke_unconsumed_approval(&tx.transaction_id)
2328 .unwrap(),
2329 "a consumed approval must not be revocable"
2330 );
2331 assert_eq!(
2332 store.get(&tx.transaction_id).unwrap().unwrap().status,
2333 JobState::Running,
2334 "a failed revocation must not disturb the running transaction"
2335 );
2336 }
2337
2338 #[test]
2339 fn approve_refuses_a_transaction_that_is_no_longer_queued() {
2340 let dir = tempdir().unwrap();
2344 let store = test_store(dir.path().join("tx.db"));
2345 let tx = store.record(queued_transaction()).unwrap();
2346 let receipt = store
2347 .approve_transaction(&tx.transaction_id)
2348 .unwrap()
2349 .expect("approved");
2350 let digest = audit_chain::approval_receipt_digest(&receipt);
2351 assert!(store
2352 .claim_approved_for_execution(&tx.transaction_id, &digest)
2353 .unwrap());
2354
2355 assert!(
2357 store
2358 .approve_transaction(&tx.transaction_id)
2359 .unwrap()
2360 .is_none(),
2361 "a Running transaction must not be approvable"
2362 );
2363
2364 store
2366 .update_status(&tx.transaction_id, JobState::Succeeded)
2367 .unwrap();
2368 assert!(
2369 store
2370 .approve_transaction(&tx.transaction_id)
2371 .unwrap()
2372 .is_none(),
2373 "a completed transaction must not be approvable"
2374 );
2375 }
2376
2377 #[test]
2378 fn only_one_of_two_concurrent_claims_can_execute() {
2379 let dir = tempdir().unwrap();
2384 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
2385 let tx = store.record(queued_transaction()).unwrap();
2386 let receipt = store
2387 .approve_transaction(&tx.transaction_id)
2388 .unwrap()
2389 .expect("approved");
2390 let digest = audit_chain::approval_receipt_digest(&receipt);
2391
2392 let mut handles = Vec::new();
2393 for _ in 0..2 {
2394 let store = std::sync::Arc::clone(&store);
2395 let id = tx.transaction_id.clone();
2396 let digest = digest.clone();
2397 handles.push(std::thread::spawn(move || {
2398 store.claim_approved_for_execution(&id, &digest)
2399 }));
2400 }
2401 let claims: Vec<bool> = handles
2402 .into_iter()
2403 .map(|h| h.join().unwrap().unwrap_or(false))
2404 .collect();
2405
2406 assert_eq!(
2407 claims.iter().filter(|c| **c).count(),
2408 1,
2409 "exactly one claim may win: {claims:?}"
2410 );
2411 assert_eq!(
2412 store.get(&tx.transaction_id).unwrap().unwrap().status,
2413 JobState::Running
2414 );
2415 }
2416
2417 #[test]
2418 fn cancel_queued_cancels_a_queued_transaction_once() {
2419 let dir = tempdir().unwrap();
2420 let store = test_store(dir.path().join("tx.db"));
2421 let tx = store.record(queued_transaction()).unwrap();
2422
2423 assert!(
2424 store.cancel_queued(&tx.transaction_id).unwrap(),
2425 "a queued transaction is cancelable"
2426 );
2427 assert_eq!(
2428 store.get(&tx.transaction_id).unwrap().unwrap().status,
2429 JobState::Canceled
2430 );
2431 assert!(
2432 !store.cancel_queued(&tx.transaction_id).unwrap(),
2433 "an already-canceled transaction is not cancelable again"
2434 );
2435 assert!(
2436 !store.cancel_queued("no-such-transaction").unwrap(),
2437 "a missing transaction is not cancelable"
2438 );
2439 }
2440
2441 #[test]
2442 fn cancel_queued_refuses_a_running_transaction() {
2443 let dir = tempdir().unwrap();
2446 let store = test_store(dir.path().join("tx.db"));
2447 let tx = store.record(queued_transaction()).unwrap();
2448 let receipt = store
2449 .approve_transaction(&tx.transaction_id)
2450 .unwrap()
2451 .expect("approved");
2452 let digest = audit_chain::approval_receipt_digest(&receipt);
2453 assert!(store
2454 .claim_approved_for_execution(&tx.transaction_id, &digest)
2455 .unwrap());
2456
2457 assert!(
2458 !store.cancel_queued(&tx.transaction_id).unwrap(),
2459 "a running transaction must not be cancelable"
2460 );
2461 assert_eq!(
2462 store.get(&tx.transaction_id).unwrap().unwrap().status,
2463 JobState::Running,
2464 "cancel must not disturb a running transaction"
2465 );
2466 }
2467
2468 #[test]
2469 fn approved_receipt_is_required_and_consumed_once() {
2470 let dir = tempdir().unwrap();
2471 let store = test_store(dir.path().join("tx.db"));
2472 let tx = store.record(queued_transaction()).unwrap();
2473
2474 assert!(
2475 !store
2476 .claim_approved_for_execution(&tx.transaction_id, "digest-a")
2477 .unwrap(),
2478 "an unapproved preview must not execute"
2479 );
2480 let receipt = store
2481 .approve_transaction(&tx.transaction_id)
2482 .unwrap()
2483 .expect("first approval must succeed");
2484 let digest = audit_chain::approval_receipt_digest(&receipt);
2485 assert_eq!(tx.approval_id.as_deref(), Some(digest.as_str()));
2486 assert!(
2487 store
2488 .approve_transaction(&tx.transaction_id)
2489 .unwrap()
2490 .is_none(),
2491 "approval is immutable once issued"
2492 );
2493 assert!(
2494 !store
2495 .claim_approved_for_execution(&tx.transaction_id, "wrong-digest")
2496 .unwrap(),
2497 "a forged receipt must not execute"
2498 );
2499 assert!(
2500 store
2501 .claim_approved_for_execution(&tx.transaction_id, &digest)
2502 .unwrap(),
2503 "the exact approved receipt must execute"
2504 );
2505 assert!(
2506 !store
2507 .claim_approved_for_execution(&tx.transaction_id, &digest)
2508 .unwrap(),
2509 "the receipt must be one-time"
2510 );
2511 }
2512
2513 #[test]
2514 fn approval_commitment_is_covered_by_the_signed_chain() {
2515 let dir = tempdir().unwrap();
2516 let store = test_store(dir.path().join("tx.db"));
2517 let tx = store.record(queued_transaction()).unwrap();
2518 assert!(tx.approval_id.is_some());
2519
2520 store
2521 .connection()
2522 .unwrap()
2523 .execute(
2524 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
2525 params![tx.transaction_id],
2526 )
2527 .unwrap();
2528
2529 let key = AuditKey::from_bytes(vec![0x42; 32]);
2530 assert!(matches!(
2531 store.verify_audit_chain(&key).unwrap(),
2532 VerifyOutcome::Broken { .. }
2533 ));
2534 }
2535
2536 #[test]
2537 fn approve_rejects_a_forged_commitment_at_runtime() {
2538 let dir = tempdir().unwrap();
2542 let store = test_store(dir.path().join("tx.db"));
2543 let tx = store.record(queued_transaction()).unwrap();
2544
2545 store
2546 .connection()
2547 .unwrap()
2548 .execute(
2549 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
2550 params![tx.transaction_id],
2551 )
2552 .unwrap();
2553
2554 let err = store
2555 .approve_transaction(&tx.transaction_id)
2556 .expect_err("a forged commitment must be rejected, not approved");
2557 assert!(
2558 matches!(err, TransactionStoreError::DatabaseInvariant(_)),
2559 "forged commitment must surface as a DatabaseInvariant, got {err:?}"
2560 );
2561 }
2562
2563 #[test]
2564 fn stale_iso_timestamp_cannot_be_approved() {
2565 let dir = tempdir().unwrap();
2566 let store = test_store(dir.path().join("tx.db"));
2567 let tx = store.record(queued_transaction()).unwrap();
2568 let conn = store.connection().unwrap();
2569 conn.execute(
2570 "UPDATE transactions \
2571 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2572 WHERE transaction_id = ?1",
2573 params![tx.transaction_id],
2574 )
2575 .unwrap();
2576
2577 assert!(
2578 store
2579 .approve_transaction(&tx.transaction_id)
2580 .unwrap()
2581 .is_none(),
2582 "a production-format timestamp outside the TTL must not be approved"
2583 );
2584 }
2585
2586 #[test]
2587 fn stale_iso_timestamp_cannot_be_claimed_for_execution() {
2588 let dir = tempdir().unwrap();
2593 let store = test_store(dir.path().join("tx.db"));
2594 let tx = store.record(queued_transaction()).unwrap();
2595 let receipt = store
2596 .approve_transaction(&tx.transaction_id)
2597 .unwrap()
2598 .expect("a fresh approval succeeds");
2599 let digest = audit_chain::approval_receipt_digest(&receipt);
2600
2601 let conn = store.connection().unwrap();
2603 conn.execute(
2604 "UPDATE transactions \
2605 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2606 WHERE transaction_id = ?1",
2607 params![tx.transaction_id],
2608 )
2609 .unwrap();
2610
2611 assert!(
2612 !store
2613 .claim_approved_for_execution(&tx.transaction_id, &digest)
2614 .unwrap(),
2615 "an approval aged past the TTL must not be claimable at execute time"
2616 );
2617 assert_eq!(
2618 store.get(&tx.transaction_id).unwrap().unwrap().status,
2619 JobState::Queued,
2620 "a TTL-expired claim must leave the transaction Queued, never Running"
2621 );
2622 }
2623
2624 #[test]
2625 fn cleanup_stale_queued_cancels_old_records() {
2626 let dir = tempdir().unwrap();
2627 let store = test_store(dir.path().join("tx.db"));
2628
2629 let fresh = store.record(queued_transaction()).unwrap();
2631 let stale = store.record(queued_transaction()).unwrap();
2632
2633 let conn = store.connection().unwrap();
2635 conn.execute(
2636 "UPDATE transactions \
2637 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2638 WHERE transaction_id = ?1",
2639 params![stale.transaction_id],
2640 )
2641 .unwrap();
2642
2643 let canceled = store.cleanup_stale_queued().unwrap();
2644 assert_eq!(canceled, 1, "only the stale record should be canceled");
2645
2646 let stale_record = store.get(&stale.transaction_id).unwrap().unwrap();
2648 assert_eq!(stale_record.status, JobState::Canceled);
2649
2650 let fresh_record = store.get(&fresh.transaction_id).unwrap().unwrap();
2652 assert_eq!(fresh_record.status, JobState::Queued);
2653 }
2654
2655 #[test]
2658 fn update_status_rejects_queued_to_succeeded() {
2659 let dir = tempdir().unwrap();
2660 let store = test_store(dir.path().join("tx.db"));
2661 let tx = store.record(queued_transaction()).unwrap();
2662
2663 let result = store.update_status(&tx.transaction_id, JobState::Succeeded);
2664 assert!(
2665 matches!(
2666 result,
2667 Err(TransactionStoreError::InvalidTransition {
2668 from: JobState::Queued,
2669 to: JobState::Succeeded,
2670 })
2671 ),
2672 "Queued -> Succeeded must be rejected (must go through Running first): {result:?}"
2673 );
2674 }
2675
2676 #[test]
2677 fn update_status_rejects_succeeded_to_running() {
2678 let dir = tempdir().unwrap();
2679 let store = test_store(dir.path().join("tx.db"));
2680 let tx = store.record(queued_transaction()).unwrap();
2681
2682 store
2683 .update_status(&tx.transaction_id, JobState::Running)
2684 .unwrap();
2685 store
2686 .update_status(&tx.transaction_id, JobState::Succeeded)
2687 .unwrap();
2688
2689 let result = store.update_status(&tx.transaction_id, JobState::Running);
2690 assert!(
2691 matches!(
2692 result,
2693 Err(TransactionStoreError::InvalidTransition {
2694 from: JobState::Succeeded,
2695 to: JobState::Running,
2696 })
2697 ),
2698 "Succeeded -> Running must be rejected (terminal state): {result:?}"
2699 );
2700 }
2701
2702 #[test]
2703 fn update_status_accepts_running_to_failed() {
2704 let dir = tempdir().unwrap();
2705 let store = test_store(dir.path().join("tx.db"));
2706 let tx = store.record(queued_transaction()).unwrap();
2707
2708 store
2709 .update_status(&tx.transaction_id, JobState::Running)
2710 .unwrap();
2711 store
2712 .update_status(&tx.transaction_id, JobState::Failed)
2713 .unwrap();
2714
2715 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2716 assert_eq!(updated.status, JobState::Failed);
2717 }
2718
2719 #[test]
2720 fn update_status_accepts_running_to_rolled_back() {
2721 let dir = tempdir().unwrap();
2722 let store = test_store(dir.path().join("tx.db"));
2723 let tx = store.record(queued_transaction()).unwrap();
2724
2725 store
2726 .update_status(&tx.transaction_id, JobState::Running)
2727 .unwrap();
2728 store
2729 .update_status(&tx.transaction_id, JobState::RolledBack)
2730 .unwrap();
2731
2732 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2733 assert_eq!(updated.status, JobState::RolledBack);
2734 }
2735
2736 #[test]
2739 fn list_transactions_returns_empty_for_fresh_store() {
2740 let dir = tempdir().unwrap();
2741 let store = test_store(dir.path().join("tx.db"));
2742 let results = store.list_transactions(10, None, None, None).unwrap();
2743 assert!(results.is_empty());
2744 }
2745
2746 #[test]
2747 fn list_transactions_returns_all_records_ordered_by_newest_first() {
2748 let dir = tempdir().unwrap();
2749 let store = test_store(dir.path().join("tx.db"));
2750 store.record(queued_transaction()).unwrap();
2751
2752 let mut second = queued_transaction();
2753 second.action_name = "GetDiskUsage".to_string();
2754 second.risk_level = RiskLevel::Low;
2755 store.record(second).unwrap();
2756
2757 let results = store.list_transactions(10, None, None, None).unwrap();
2758 assert_eq!(results.len(), 2);
2759 assert_eq!(results[0].action_name, "GetDiskUsage");
2761 assert_eq!(results[1].action_name, "UpdateSystem");
2762 }
2763
2764 #[test]
2765 fn list_history_populates_created_at_and_risk_level() {
2766 let dir = tempdir().unwrap();
2767 let store = test_store(dir.path().join("tx.db"));
2768 store.record(queued_transaction()).unwrap();
2769
2770 let entries = store.list_history(10, None, None, None).unwrap();
2771 assert_eq!(entries.len(), 1);
2772 let entry = &entries[0];
2773 assert_eq!(entry.action_name, "UpdateSystem");
2774 assert_eq!(entry.risk_level, RiskLevel::High);
2775 assert_eq!(entry.status, JobState::Queued);
2776 assert!(
2777 !entry.created_at.is_empty(),
2778 "created_at must be populated from the stored row, not left blank"
2779 );
2780 }
2781
2782 #[test]
2783 fn list_history_applies_the_same_filters_as_list_transactions() {
2784 let dir = tempdir().unwrap();
2785 let store = test_store(dir.path().join("tx.db"));
2786 store.record(queued_transaction()).unwrap();
2787 let mut low = queued_transaction();
2788 low.action_name = "GetDiskUsage".to_string();
2789 low.risk_level = RiskLevel::Low;
2790 store.record(low).unwrap();
2791
2792 let only = store
2793 .list_history(10, None, Some("GetDiskUsage"), None)
2794 .unwrap();
2795 assert_eq!(only.len(), 1);
2796 assert_eq!(only[0].action_name, "GetDiskUsage");
2797 }
2798
2799 #[test]
2800 fn list_transactions_respects_limit() {
2801 let dir = tempdir().unwrap();
2802 let store = test_store(dir.path().join("tx.db"));
2803 for _ in 0..5 {
2804 store.record(queued_transaction()).unwrap();
2805 }
2806 let results = store.list_transactions(3, None, None, None).unwrap();
2807 assert_eq!(results.len(), 3);
2808 }
2809
2810 #[test]
2811 fn list_transactions_filters_by_status() {
2812 let dir = tempdir().unwrap();
2813 let store = test_store(dir.path().join("tx.db"));
2814 let tx = store.record(queued_transaction()).unwrap();
2815 store
2816 .update_status(&tx.transaction_id, JobState::Running)
2817 .unwrap();
2818 store
2819 .update_status(&tx.transaction_id, JobState::Succeeded)
2820 .unwrap();
2821
2822 store.record(queued_transaction()).unwrap();
2824
2825 let succeeded = store
2826 .list_transactions(10, Some("succeeded"), None, None)
2827 .unwrap();
2828 assert_eq!(succeeded.len(), 1);
2829 assert_eq!(succeeded[0].status, JobState::Succeeded);
2830
2831 let queued = store
2832 .list_transactions(10, Some("queued"), None, None)
2833 .unwrap();
2834 assert_eq!(queued.len(), 1);
2835 assert_eq!(queued[0].status, JobState::Queued);
2836 }
2837
2838 #[test]
2839 fn list_transactions_filters_by_action_name() {
2840 let dir = tempdir().unwrap();
2841 let store = test_store(dir.path().join("tx.db"));
2842 store.record(queued_transaction()).unwrap(); let mut disk = queued_transaction();
2845 disk.action_name = "GetDiskUsage".to_string();
2846 store.record(disk).unwrap();
2847
2848 let results = store
2849 .list_transactions(10, None, Some("GetDiskUsage"), None)
2850 .unwrap();
2851 assert_eq!(results.len(), 1);
2852 assert_eq!(results[0].action_name, "GetDiskUsage");
2853 }
2854
2855 #[test]
2856 fn list_transactions_filters_by_since_hours() {
2857 let dir = tempdir().unwrap();
2858 let store = test_store(dir.path().join("tx.db"));
2859
2860 let old = store.record(queued_transaction()).unwrap();
2862 let conn = store.connection().unwrap();
2863 conn.execute(
2864 "UPDATE transactions \
2865 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-48 hours') \
2866 WHERE transaction_id = ?1",
2867 params![old.transaction_id],
2868 )
2869 .unwrap();
2870
2871 store.record(queued_transaction()).unwrap();
2873
2874 let results = store.list_transactions(10, None, None, Some(24)).unwrap();
2876 assert_eq!(results.len(), 1);
2877
2878 let results = store.list_transactions(10, None, None, Some(72)).unwrap();
2880 assert_eq!(results.len(), 2);
2881 }
2882
2883 #[test]
2884 fn list_transactions_rejects_invalid_status_filter() {
2885 let dir = tempdir().unwrap();
2886 let store = test_store(dir.path().join("tx.db"));
2887 store.record(queued_transaction()).unwrap();
2888 let result = store.list_transactions(10, Some("bogus"), None, None);
2889 assert!(result.is_err(), "invalid status filter should return error");
2890 }
2891
2892 #[test]
2900 fn record_emits_one_watermark_per_entry() {
2901 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2902 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2903
2904 let dir = tempdir().unwrap();
2905 let store = test_store(dir.path().join("tx.db"));
2906 store.record(queued_transaction()).unwrap();
2907
2908 let calls = crate::audit_watermark::take_watermarks(&sink);
2909 assert_eq!(calls.len(), 1, "expected exactly 1 watermark per record()");
2910 }
2911
2912 #[test]
2914 fn record_previewed_emits_one_watermark() {
2915 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2916 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2917
2918 let dir = tempdir().unwrap();
2919 let store = test_store(dir.path().join("tx.db"));
2920 let preview = PreviewEnvelope {
2921 summary: "Upgrade the system".to_string(),
2922 risk_level: RiskLevel::High,
2923 current_state: serde_json::Value::Null,
2924 proposed_change: serde_json::Value::Null,
2925 expected_side_effects: vec![],
2926 reboot_required: false,
2927 rollback_available: false,
2928 warnings: vec![],
2929 request_hash: sysknife_types::RequestHash::from("hash-abc".to_string()),
2930 };
2931 store
2932 .record_previewed(queued_transaction(), preview)
2933 .unwrap();
2934
2935 let calls = crate::audit_watermark::take_watermarks(&sink);
2936 assert_eq!(
2937 calls.len(),
2938 1,
2939 "expected exactly 1 watermark per record_previewed()"
2940 );
2941 }
2942
2943 #[test]
2945 fn watermark_seq_and_hash_match_chain_row() {
2946 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2947 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2948
2949 let dir = tempdir().unwrap();
2950 let store = test_store(dir.path().join("tx.db"));
2951 store.record(queued_transaction()).unwrap();
2952
2953 let rows = store.fetch_chain_rows().unwrap();
2954 assert_eq!(rows.len(), 1);
2955 let row = &rows[0];
2956
2957 let calls = crate::audit_watermark::take_watermarks(&sink);
2958 assert_eq!(calls.len(), 1);
2959 assert_eq!(calls[0].seq, row.seq, "watermark seq must match chain row");
2960 assert_eq!(
2961 calls[0].chain_hash_hex, row.chain_hash,
2962 "watermark chain_hash_hex must match stored chain_hash"
2963 );
2964 }
2965
2966 #[test]
2968 fn multiple_records_produce_one_watermark_each() {
2969 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2970 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2971
2972 let dir = tempdir().unwrap();
2973 let store = test_store(dir.path().join("tx.db"));
2974 for _ in 0..3 {
2975 store.record(queued_transaction()).unwrap();
2976 }
2977
2978 let calls = crate::audit_watermark::take_watermarks(&sink);
2979 assert_eq!(calls.len(), 3, "one watermark per record call");
2980 assert_eq!(calls[0].seq, 1);
2981 assert_eq!(calls[1].seq, 2);
2982 assert_eq!(calls[2].seq, 3);
2983 }
2984
2985 #[test]
2999 fn no_watermark_emitted_before_any_record() {
3000 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3001 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3002
3003 let dir = tempdir().unwrap();
3004 let _store = test_store(dir.path().join("tx.db"));
3005
3006 let calls = crate::audit_watermark::take_watermarks(&sink);
3008 assert!(
3009 calls.is_empty(),
3010 "no watermark must be emitted without a record() call"
3011 );
3012 }
3013}