Skip to main content

assay_core/runtime/
mandate_store.rs

1//! MandateStore: SQLite-backed mandate consumption tracking.
2//!
3//! Provides atomic, idempotent mandate consumption with:
4//! - Single-use / max_uses constraint enforcement
5//! - Nonce replay prevention
6//! - tool_call_id idempotency
7
8use chrono::{DateTime, Utc};
9use rusqlite::Connection;
10use std::path::Path;
11use std::sync::{Arc, Mutex};
12use thiserror::Error;
13
14#[path = "mandate_store_next/mod.rs"]
15mod mandate_store_next;
16
17/// Authorization receipt returned after successful consumption.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct AuthzReceipt {
20    pub mandate_id: String,
21    pub use_id: String,
22    pub use_count: u32,
23    pub consumed_at: DateTime<Utc>,
24    pub tool_call_id: String,
25    /// True if this was a new consumption, false if idempotent retry.
26    /// Used to avoid emitting duplicate lifecycle events on retries.
27    pub was_new: bool,
28}
29
30/// Authorization errors.
31#[derive(Debug, Error, PartialEq, Eq)]
32pub enum AuthzError {
33    #[error("Mandate not found: {mandate_id}")]
34    MandateNotFound { mandate_id: String },
35
36    #[error("Mandate already used (single_use=true)")]
37    AlreadyUsed,
38
39    #[error("Max uses exceeded: {current} > {max}")]
40    MaxUsesExceeded { max: u32, current: u32 },
41
42    #[error("Nonce replay detected: {nonce}")]
43    NonceReplay { nonce: String },
44
45    #[error("Mandate metadata conflict for {mandate_id}: stored {field} differs")]
46    MandateConflict { mandate_id: String, field: String },
47
48    #[error("Invalid mandate constraints: single_use=true with max_uses={max_uses}")]
49    InvalidConstraints { max_uses: u32 },
50
51    #[error("Mandate revoked at {revoked_at}")]
52    Revoked { revoked_at: DateTime<Utc> },
53
54    #[error("Database error: {0}")]
55    Database(String),
56}
57
58impl From<rusqlite::Error> for AuthzError {
59    fn from(e: rusqlite::Error) -> Self {
60        AuthzError::Database(e.to_string())
61    }
62}
63
64/// Mandate metadata for upsert.
65#[derive(Debug, Clone)]
66pub struct MandateMetadata {
67    pub mandate_id: String,
68    pub mandate_kind: String,
69    pub audience: String,
70    pub issuer: String,
71    pub expires_at: Option<DateTime<Utc>>,
72    pub single_use: bool,
73    pub max_uses: Option<u32>,
74    pub canonical_digest: String,
75    pub key_id: String,
76}
77
78/// Parameters for consume_mandate.
79#[derive(Debug, Clone)]
80pub struct ConsumeParams<'a> {
81    pub mandate_id: &'a str,
82    pub tool_call_id: &'a str,
83    pub nonce: Option<&'a str>,
84    pub audience: &'a str,
85    pub issuer: &'a str,
86    pub tool_name: &'a str,
87    pub operation_class: &'a str,
88    pub source_run_id: Option<&'a str>,
89}
90
91/// SQLite-backed mandate store.
92#[derive(Clone)]
93pub struct MandateStore {
94    conn: Arc<Mutex<Connection>>,
95}
96
97impl MandateStore {
98    /// Open a file-backed store.
99    pub fn open(path: &Path) -> Result<Self, AuthzError> {
100        mandate_store_next::schema::open_impl(path)
101    }
102
103    /// Create an in-memory store (for testing).
104    pub fn memory() -> Result<Self, AuthzError> {
105        mandate_store_next::schema::memory_impl()
106    }
107
108    /// Create store from existing connection (for multi-connection tests).
109    pub fn from_connection(conn: Connection) -> Result<Self, AuthzError> {
110        mandate_store_next::schema::from_connection_impl(conn)
111    }
112
113    /// Upsert mandate metadata. Idempotent for same content, errors on conflict.
114    pub fn upsert_mandate(&self, meta: &MandateMetadata) -> Result<(), AuthzError> {
115        mandate_store_next::upsert::upsert_mandate_impl(self, meta)
116    }
117
118    /// Consume mandate atomically. Idempotent on tool_call_id.
119    pub fn consume_mandate(&self, params: &ConsumeParams<'_>) -> Result<AuthzReceipt, AuthzError> {
120        mandate_store_next::txn::consume_mandate_in_txn_impl(self, params)
121    }
122
123    fn consume_mandate_inner(
124        &self,
125        conn: &Connection,
126        params: &ConsumeParams<'_>,
127    ) -> Result<AuthzReceipt, AuthzError> {
128        mandate_store_next::consume::consume_mandate_inner_impl(conn, params)
129    }
130
131    /// Get current use count for a mandate (for testing/debugging).
132    pub fn get_use_count(&self, mandate_id: &str) -> Result<Option<u32>, AuthzError> {
133        mandate_store_next::stats::get_use_count_impl(self, mandate_id)
134    }
135
136    /// Count use records for a mandate (for testing).
137    pub fn count_uses(&self, mandate_id: &str) -> Result<u32, AuthzError> {
138        mandate_store_next::stats::count_uses_impl(self, mandate_id)
139    }
140
141    /// Check if nonce exists (for testing).
142    pub fn nonce_exists(
143        &self,
144        audience: &str,
145        issuer: &str,
146        nonce: &str,
147    ) -> Result<bool, AuthzError> {
148        mandate_store_next::stats::nonce_exists_impl(self, audience, issuer, nonce)
149    }
150
151    // =========================================================================
152    // Revocation API (P0-A)
153    // =========================================================================
154
155    /// Insert or update a revocation record.
156    ///
157    /// Idempotent: re-inserting with same mandate_id updates the record.
158    pub fn upsert_revocation(&self, r: &RevocationRecord) -> Result<(), AuthzError> {
159        mandate_store_next::revocation::upsert_revocation_impl(self, r)
160    }
161
162    /// Get revoked_at timestamp for a mandate (if revoked).
163    pub fn get_revoked_at(&self, mandate_id: &str) -> Result<Option<DateTime<Utc>>, AuthzError> {
164        mandate_store_next::revocation::get_revoked_at_impl(self, mandate_id)
165    }
166
167    /// Check if a mandate is revoked (convenience method).
168    pub fn is_revoked(&self, mandate_id: &str) -> Result<bool, AuthzError> {
169        mandate_store_next::revocation::is_revoked_impl(self, mandate_id)
170    }
171}
172
173/// Revocation record for upsert.
174#[derive(Debug, Clone)]
175pub struct RevocationRecord {
176    pub mandate_id: String,
177    pub revoked_at: DateTime<Utc>,
178    pub reason: Option<String>,
179    pub revoked_by: Option<String>,
180    pub source: Option<String>,
181    pub event_id: Option<String>,
182}
183
184/// Compute deterministic use_id per SPEC-Mandate-v1.0.4 ยง7.4.
185///
186/// ```text
187/// use_id = "sha256:" + hex(SHA256(mandate_id + ":" + tool_call_id + ":" + use_count))
188/// ```
189pub fn compute_use_id(mandate_id: &str, tool_call_id: &str, use_count: u32) -> String {
190    mandate_store_next::stats::compute_use_id_impl(mandate_id, tool_call_id, use_count)
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    fn test_metadata() -> MandateMetadata {
198        MandateMetadata {
199            mandate_id: "sha256:test123".to_string(),
200            mandate_kind: "intent".to_string(),
201            audience: "org/app".to_string(),
202            issuer: "auth.org.com".to_string(),
203            expires_at: None,
204            single_use: false,
205            max_uses: None,
206            canonical_digest: "sha256:digest123".to_string(),
207            key_id: "sha256:key123".to_string(),
208        }
209    }
210
211    fn consume(
212        store: &MandateStore,
213        mandate_id: &str,
214        tool_call_id: &str,
215        nonce: Option<&str>,
216        audience: &str,
217        issuer: &str,
218    ) -> Result<AuthzReceipt, AuthzError> {
219        store.consume_mandate(&ConsumeParams {
220            mandate_id,
221            tool_call_id,
222            nonce,
223            audience,
224            issuer,
225            tool_name: "test_tool",
226            operation_class: "read",
227            source_run_id: None,
228        })
229    }
230
231    // === A) Schema/migrations ===
232
233    #[test]
234    fn test_store_bootstraps_schema() {
235        let store = MandateStore::memory().unwrap();
236        let conn = store.conn.lock().unwrap();
237
238        // Check tables exist
239        let tables: Vec<String> = conn
240            .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
241            .unwrap()
242            .query_map([], |row| row.get(0))
243            .unwrap()
244            .filter_map(|r| r.ok())
245            .collect();
246
247        assert!(tables.contains(&"mandates".to_string()));
248        assert!(tables.contains(&"mandate_uses".to_string()));
249        assert!(tables.contains(&"nonces".to_string()));
250    }
251
252    #[test]
253    fn test_store_sets_foreign_keys() {
254        let store = MandateStore::memory().unwrap();
255        let conn = store.conn.lock().unwrap();
256
257        let fk: i32 = conn
258            .query_row("PRAGMA foreign_keys", [], |row| row.get(0))
259            .unwrap();
260        assert_eq!(fk, 1);
261    }
262
263    // === B) Upsert invariants ===
264
265    #[test]
266    fn test_upsert_mandate_inserts_new() {
267        let store = MandateStore::memory().unwrap();
268        let meta = test_metadata();
269
270        store.upsert_mandate(&meta).unwrap();
271
272        let count = store.get_use_count(&meta.mandate_id).unwrap();
273        assert_eq!(count, Some(0));
274    }
275
276    #[test]
277    fn test_upsert_mandate_is_noop_on_same_content() {
278        let store = MandateStore::memory().unwrap();
279        let meta = test_metadata();
280
281        store.upsert_mandate(&meta).unwrap();
282        store.upsert_mandate(&meta).unwrap(); // Should not error
283
284        let count = store.get_use_count(&meta.mandate_id).unwrap();
285        assert_eq!(count, Some(0));
286    }
287
288    #[test]
289    fn test_upsert_mandate_rejects_conflicting_metadata() {
290        let store = MandateStore::memory().unwrap();
291        let meta = test_metadata();
292        store.upsert_mandate(&meta).unwrap();
293
294        // Try to upsert with different audience
295        let mut conflict = meta.clone();
296        conflict.audience = "different/app".to_string();
297
298        let result = store.upsert_mandate(&conflict);
299        assert!(matches!(
300            result,
301            Err(AuthzError::MandateConflict { field, .. }) if field == "audience"
302        ));
303    }
304
305    // === C) Consume - idempotency & counts ===
306
307    #[test]
308    fn test_consume_fails_if_mandate_missing() {
309        let store = MandateStore::memory().unwrap();
310
311        let result = consume(
312            &store,
313            "sha256:nonexistent",
314            "tc_1",
315            None,
316            "org/app",
317            "auth.org.com",
318        );
319
320        assert!(matches!(result, Err(AuthzError::MandateNotFound { .. })));
321    }
322
323    #[test]
324    fn test_consume_first_time_returns_use_count_1() {
325        let store = MandateStore::memory().unwrap();
326        let meta = test_metadata();
327        store.upsert_mandate(&meta).unwrap();
328
329        let receipt = consume(
330            &store,
331            &meta.mandate_id,
332            "tc_1",
333            None,
334            &meta.audience,
335            &meta.issuer,
336        )
337        .unwrap();
338
339        assert_eq!(receipt.use_count, 1);
340        assert!(!receipt.use_id.is_empty());
341        assert_eq!(receipt.tool_call_id, "tc_1");
342        assert_eq!(store.get_use_count(&meta.mandate_id).unwrap(), Some(1));
343        assert_eq!(store.count_uses(&meta.mandate_id).unwrap(), 1);
344    }
345
346    #[test]
347    fn test_consume_is_idempotent_for_same_tool_call_id() {
348        let store = MandateStore::memory().unwrap();
349        let meta = test_metadata();
350        store.upsert_mandate(&meta).unwrap();
351
352        let receipt1 = consume(
353            &store,
354            &meta.mandate_id,
355            "tc_1",
356            None,
357            &meta.audience,
358            &meta.issuer,
359        )
360        .unwrap();
361        let receipt2 = consume(
362            &store,
363            &meta.mandate_id,
364            "tc_1",
365            None,
366            &meta.audience,
367            &meta.issuer,
368        )
369        .unwrap();
370
371        // Same receipt (idempotent)
372        assert_eq!(receipt1.use_id, receipt2.use_id);
373        assert_eq!(receipt1.use_count, receipt2.use_count);
374
375        // was_new distinguishes first vs retry
376        assert!(receipt1.was_new, "First consume should be was_new=true");
377        assert!(!receipt2.was_new, "Retry should be was_new=false");
378
379        // Count didn't increment
380        assert_eq!(store.get_use_count(&meta.mandate_id).unwrap(), Some(1));
381        assert_eq!(store.count_uses(&meta.mandate_id).unwrap(), 1);
382    }
383
384    #[test]
385    fn test_consume_increments_for_different_tool_call_ids() {
386        let store = MandateStore::memory().unwrap();
387        let meta = test_metadata();
388        store.upsert_mandate(&meta).unwrap();
389
390        let r1 = consume(
391            &store,
392            &meta.mandate_id,
393            "tc_1",
394            None,
395            &meta.audience,
396            &meta.issuer,
397        )
398        .unwrap();
399        let r2 = consume(
400            &store,
401            &meta.mandate_id,
402            "tc_2",
403            None,
404            &meta.audience,
405            &meta.issuer,
406        )
407        .unwrap();
408
409        assert_eq!(r1.use_count, 1);
410        assert_eq!(r2.use_count, 2);
411        assert_eq!(store.get_use_count(&meta.mandate_id).unwrap(), Some(2));
412        assert_eq!(store.count_uses(&meta.mandate_id).unwrap(), 2);
413    }
414
415    // === D) Constraints - single_use/max_uses ===
416
417    #[test]
418    fn test_single_use_allows_first_then_rejects_second() {
419        let store = MandateStore::memory().unwrap();
420        let mut meta = test_metadata();
421        meta.single_use = true;
422        meta.max_uses = Some(1);
423        store.upsert_mandate(&meta).unwrap();
424
425        let r1 = consume(
426            &store,
427            &meta.mandate_id,
428            "tc_1",
429            None,
430            &meta.audience,
431            &meta.issuer,
432        );
433        assert!(r1.is_ok());
434
435        let r2 = consume(
436            &store,
437            &meta.mandate_id,
438            "tc_2",
439            None,
440            &meta.audience,
441            &meta.issuer,
442        );
443        assert!(matches!(r2, Err(AuthzError::AlreadyUsed)));
444
445        // use_count stayed at 1 (rollback worked)
446        assert_eq!(store.get_use_count(&meta.mandate_id).unwrap(), Some(1));
447    }
448
449    #[test]
450    fn test_max_uses_allows_up_to_n_then_rejects() {
451        let store = MandateStore::memory().unwrap();
452        let mut meta = test_metadata();
453        meta.max_uses = Some(3);
454        store.upsert_mandate(&meta).unwrap();
455
456        for i in 1..=3 {
457            let r = consume(
458                &store,
459                &meta.mandate_id,
460                &format!("tc_{}", i),
461                None,
462                &meta.audience,
463                &meta.issuer,
464            );
465            assert!(r.is_ok(), "Call {} should succeed", i);
466        }
467
468        let r4 = consume(
469            &store,
470            &meta.mandate_id,
471            "tc_4",
472            None,
473            &meta.audience,
474            &meta.issuer,
475        );
476        assert!(matches!(
477            r4,
478            Err(AuthzError::MaxUsesExceeded { max: 3, current: 4 })
479        ));
480
481        // use_count stayed at 3
482        assert_eq!(store.get_use_count(&meta.mandate_id).unwrap(), Some(3));
483    }
484
485    #[test]
486    fn test_max_uses_null_is_unlimited() {
487        let store = MandateStore::memory().unwrap();
488        let meta = test_metadata(); // max_uses = None
489        store.upsert_mandate(&meta).unwrap();
490
491        for i in 1..=20 {
492            let r = consume(
493                &store,
494                &meta.mandate_id,
495                &format!("tc_{}", i),
496                None,
497                &meta.audience,
498                &meta.issuer,
499            );
500            assert!(r.is_ok(), "Call {} should succeed", i);
501        }
502
503        assert_eq!(store.get_use_count(&meta.mandate_id).unwrap(), Some(20));
504    }
505
506    #[test]
507    fn test_single_use_true_and_max_uses_gt_1_is_invalid() {
508        let store = MandateStore::memory().unwrap();
509        let mut meta = test_metadata();
510        meta.single_use = true;
511        meta.max_uses = Some(10); // Invalid: single_use with max > 1
512
513        let result = store.upsert_mandate(&meta);
514        assert!(matches!(
515            result,
516            Err(AuthzError::InvalidConstraints { max_uses: 10 })
517        ));
518    }
519
520    // === E) Nonce replay prevention ===
521
522    #[test]
523    fn test_nonce_inserted_on_first_consume() {
524        let store = MandateStore::memory().unwrap();
525        let meta = test_metadata();
526        store.upsert_mandate(&meta).unwrap();
527
528        consume(
529            &store,
530            &meta.mandate_id,
531            "tc_1",
532            Some("nonce_123"),
533            &meta.audience,
534            &meta.issuer,
535        )
536        .unwrap();
537
538        assert!(store
539            .nonce_exists(&meta.audience, &meta.issuer, "nonce_123")
540            .unwrap());
541    }
542
543    #[test]
544    fn test_nonce_replay_rejected() {
545        let store = MandateStore::memory().unwrap();
546        let meta = test_metadata();
547        store.upsert_mandate(&meta).unwrap();
548
549        let r1 = consume(
550            &store,
551            &meta.mandate_id,
552            "tc_1",
553            Some("nonce_123"),
554            &meta.audience,
555            &meta.issuer,
556        );
557        assert!(r1.is_ok());
558
559        // Different tool_call_id, same nonce
560        let r2 = consume(
561            &store,
562            &meta.mandate_id,
563            "tc_2",
564            Some("nonce_123"),
565            &meta.audience,
566            &meta.issuer,
567        );
568        assert!(matches!(r2, Err(AuthzError::NonceReplay { nonce }) if nonce == "nonce_123"));
569
570        // use_count stayed at 1 (rollback worked)
571        assert_eq!(store.get_use_count(&meta.mandate_id).unwrap(), Some(1));
572    }
573
574    #[test]
575    fn test_nonce_scope_is_audience_and_issuer() {
576        let store = MandateStore::memory().unwrap();
577
578        // Create two mandates with different audience
579        let meta1 = test_metadata();
580        let mut meta2 = test_metadata();
581        meta2.mandate_id = "sha256:test456".to_string();
582        meta2.audience = "different/app".to_string();
583        meta2.canonical_digest = "sha256:digest456".to_string();
584
585        store.upsert_mandate(&meta1).unwrap();
586        store.upsert_mandate(&meta2).unwrap();
587
588        // Same nonce, different audience should be allowed
589        let r1 = consume(
590            &store,
591            &meta1.mandate_id,
592            "tc_1",
593            Some("shared_nonce"),
594            &meta1.audience,
595            &meta1.issuer,
596        );
597        assert!(r1.is_ok());
598
599        // Same nonce but different audience
600        let r2 = consume(
601            &store,
602            &meta2.mandate_id,
603            "tc_2",
604            Some("shared_nonce"),
605            &meta2.audience,
606            &meta2.issuer,
607        );
608        assert!(
609            r2.is_ok(),
610            "Same nonce with different audience should be allowed"
611        );
612    }
613
614    // === F) Multi-call invariants (serialized via mutex) ===
615
616    #[test]
617    fn test_multicall_produces_monotonic_counts_no_gaps() {
618        let store = MandateStore::memory().unwrap();
619        let meta = test_metadata();
620        store.upsert_mandate(&meta).unwrap();
621
622        let mut counts = Vec::new();
623        for i in 1..=50 {
624            let r = consume(
625                &store,
626                &meta.mandate_id,
627                &format!("tc_{}", i),
628                None,
629                &meta.audience,
630                &meta.issuer,
631            )
632            .unwrap();
633            counts.push(r.use_count);
634        }
635
636        // Verify monotonic: 1, 2, 3, ..., 50
637        let expected: Vec<u32> = (1..=50).collect();
638        assert_eq!(counts, expected);
639        assert_eq!(store.get_use_count(&meta.mandate_id).unwrap(), Some(50));
640        assert_eq!(store.count_uses(&meta.mandate_id).unwrap(), 50);
641    }
642
643    #[test]
644    fn test_multicall_idempotent_same_tool_call_id() {
645        let store = MandateStore::memory().unwrap();
646        let meta = test_metadata();
647        store.upsert_mandate(&meta).unwrap();
648
649        let mut receipts = Vec::new();
650        for _ in 0..20 {
651            let r = consume(
652                &store,
653                &meta.mandate_id,
654                "tc_same",
655                None,
656                &meta.audience,
657                &meta.issuer,
658            )
659            .unwrap();
660            receipts.push(r);
661        }
662
663        // All receipts should be identical
664        let first = &receipts[0];
665        for r in &receipts {
666            assert_eq!(r.use_id, first.use_id);
667            assert_eq!(r.use_count, first.use_count);
668        }
669
670        // Only one actual use
671        assert_eq!(store.get_use_count(&meta.mandate_id).unwrap(), Some(1));
672        assert_eq!(store.count_uses(&meta.mandate_id).unwrap(), 1);
673    }
674
675    // === H) Revocation API ===
676
677    #[test]
678    fn test_revocation_roundtrip() {
679        let store = MandateStore::memory().unwrap();
680
681        let revoked_at = Utc::now();
682        let record = RevocationRecord {
683            mandate_id: "sha256:revoked123".to_string(),
684            revoked_at,
685            reason: Some("User requested".to_string()),
686            revoked_by: Some("admin@example.com".to_string()),
687            source: Some("assay://myorg/myapp".to_string()),
688            event_id: Some("evt_revoke_001".to_string()),
689        };
690
691        store.upsert_revocation(&record).unwrap();
692
693        let got = store.get_revoked_at(&record.mandate_id).unwrap();
694        assert!(got.is_some());
695        // Compare within 1 second tolerance (RFC3339 loses sub-second precision)
696        let diff = (got.unwrap() - revoked_at).num_seconds().abs();
697        assert!(diff <= 1, "revoked_at timestamps differ by {}s", diff);
698    }
699
700    #[test]
701    fn test_revocation_is_revoked_helper() {
702        let store = MandateStore::memory().unwrap();
703
704        assert!(!store.is_revoked("sha256:not_revoked").unwrap());
705
706        store
707            .upsert_revocation(&RevocationRecord {
708                mandate_id: "sha256:is_revoked".to_string(),
709                revoked_at: Utc::now(),
710                reason: None,
711                revoked_by: None,
712                source: None,
713                event_id: None,
714            })
715            .unwrap();
716
717        assert!(store.is_revoked("sha256:is_revoked").unwrap());
718    }
719
720    #[test]
721    fn test_revocation_upsert_is_idempotent() {
722        let store = MandateStore::memory().unwrap();
723
724        let record = RevocationRecord {
725            mandate_id: "sha256:idem".to_string(),
726            revoked_at: Utc::now(),
727            reason: Some("First".to_string()),
728            revoked_by: None,
729            source: None,
730            event_id: None,
731        };
732
733        store.upsert_revocation(&record).unwrap();
734        store.upsert_revocation(&record).unwrap(); // Should not fail
735        store.upsert_revocation(&record).unwrap();
736
737        assert!(store.is_revoked(&record.mandate_id).unwrap());
738    }
739
740    #[test]
741    fn test_compute_use_id_contract_vector() {
742        let use_id = compute_use_id("sha256:m", "tc_1", 2);
743        assert_eq!(
744            use_id,
745            "sha256:333a7fdcb27b62d01a6a56e8c6c57f59782c93f547d4755ee0bcb11fe22fd15c"
746        );
747    }
748}