1use 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#[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 pub was_new: bool,
28}
29
30#[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#[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#[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#[derive(Clone)]
93pub struct MandateStore {
94 conn: Arc<Mutex<Connection>>,
95}
96
97impl MandateStore {
98 pub fn open(path: &Path) -> Result<Self, AuthzError> {
100 mandate_store_next::schema::open_impl(path)
101 }
102
103 pub fn memory() -> Result<Self, AuthzError> {
105 mandate_store_next::schema::memory_impl()
106 }
107
108 pub fn from_connection(conn: Connection) -> Result<Self, AuthzError> {
110 mandate_store_next::schema::from_connection_impl(conn)
111 }
112
113 pub fn upsert_mandate(&self, meta: &MandateMetadata) -> Result<(), AuthzError> {
115 mandate_store_next::upsert::upsert_mandate_impl(self, meta)
116 }
117
118 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 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 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 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 pub fn upsert_revocation(&self, r: &RevocationRecord) -> Result<(), AuthzError> {
159 mandate_store_next::revocation::upsert_revocation_impl(self, r)
160 }
161
162 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 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#[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
184pub 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 #[test]
234 fn test_store_bootstraps_schema() {
235 let store = MandateStore::memory().unwrap();
236 let conn = store.conn.lock().unwrap();
237
238 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 #[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(); 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 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 #[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 assert_eq!(receipt1.use_id, receipt2.use_id);
373 assert_eq!(receipt1.use_count, receipt2.use_count);
374
375 assert!(receipt1.was_new, "First consume should be was_new=true");
377 assert!(!receipt2.was_new, "Retry should be was_new=false");
378
379 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 #[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 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 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(); 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); let result = store.upsert_mandate(&meta);
514 assert!(matches!(
515 result,
516 Err(AuthzError::InvalidConstraints { max_uses: 10 })
517 ));
518 }
519
520 #[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 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 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 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 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 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 #[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 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 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 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 #[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 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(); 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}