Skip to main content

assay_core/runtime/
authorizer.rs

1//! Runtime mandate authorization.
2//!
3//! Implements SPEC-Mandate-v1.0.3 §7: Runtime Enforcement.
4//!
5//! Flow:
6//! 1. Verify validity window (§7.6)
7//! 2. Verify scope matches tool
8//! 3. Verify mandate_kind matches operation_class
9//! 4. Verify transaction_ref for commit tools (§7.7)
10//! 5. Consume mandate atomically (§7.4)
11
12use super::mandate_store::{
13    AuthzError, AuthzReceipt, ConsumeParams, MandateMetadata, MandateStore,
14};
15use chrono::{DateTime, Duration, Utc};
16use thiserror::Error;
17
18/// Default clock skew tolerance in seconds.
19pub const DEFAULT_CLOCK_SKEW_SECONDS: i64 = 30;
20
21/// Authorization configuration.
22#[derive(Debug, Clone)]
23pub struct AuthzConfig {
24    /// Clock skew tolerance for validity checks.
25    pub clock_skew_seconds: i64,
26    /// Expected audience (must match mandate.context.audience).
27    pub expected_audience: String,
28    /// Trusted issuers (mandate.context.issuer must be in this list).
29    pub trusted_issuers: Vec<String>,
30}
31
32impl Default for AuthzConfig {
33    fn default() -> Self {
34        Self {
35            clock_skew_seconds: DEFAULT_CLOCK_SKEW_SECONDS,
36            expected_audience: String::new(),
37            trusted_issuers: Vec::new(),
38        }
39    }
40}
41
42/// Operation class for tool classification.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
44pub enum OperationClass {
45    Read = 0,
46    Write = 1,
47    Commit = 2,
48}
49
50impl OperationClass {
51    pub fn as_str(&self) -> &'static str {
52        match self {
53            Self::Read => "read",
54            Self::Write => "write",
55            Self::Commit => "commit",
56        }
57    }
58}
59
60/// Mandate kind.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum MandateKind {
63    Intent,
64    Transaction,
65}
66
67impl MandateKind {
68    pub fn as_str(&self) -> &'static str {
69        match self {
70            Self::Intent => "intent",
71            Self::Transaction => "transaction",
72        }
73    }
74
75    /// Returns the maximum operation class this mandate kind allows.
76    pub fn max_operation_class(&self) -> OperationClass {
77        match self {
78            Self::Intent => OperationClass::Write, // intent allows read, write
79            Self::Transaction => OperationClass::Commit, // transaction allows all
80        }
81    }
82}
83
84/// Mandate data for authorization (extracted from signed mandate).
85#[derive(Debug, Clone)]
86pub struct MandateData {
87    pub mandate_id: String,
88    pub mandate_kind: MandateKind,
89    pub audience: String,
90    pub issuer: String,
91    pub tool_patterns: Vec<String>,
92    pub operation_class: Option<OperationClass>,
93    pub transaction_ref: Option<String>,
94    pub not_before: Option<DateTime<Utc>>,
95    pub expires_at: Option<DateTime<Utc>>,
96    pub single_use: bool,
97    pub max_uses: Option<u32>,
98    pub nonce: Option<String>,
99    pub canonical_digest: String,
100    pub key_id: String,
101}
102
103/// Tool call data for authorization.
104#[derive(Debug, Clone)]
105pub struct ToolCallData {
106    pub tool_call_id: String,
107    pub tool_name: String,
108    pub operation_class: OperationClass,
109    pub transaction_object: Option<serde_json::Value>,
110    pub source_run_id: Option<String>,
111}
112
113/// Policy-level authorization errors (before DB).
114#[derive(Debug, Error, PartialEq, Eq)]
115pub enum PolicyError {
116    #[error("Mandate expired: expires_at={expires_at}, now={now}")]
117    Expired {
118        expires_at: DateTime<Utc>,
119        now: DateTime<Utc>,
120    },
121
122    #[error("Mandate not yet valid: not_before={not_before}, now={now}")]
123    NotYetValid {
124        not_before: DateTime<Utc>,
125        now: DateTime<Utc>,
126    },
127
128    #[error("Tool '{tool}' not in mandate scope")]
129    ToolNotInScope { tool: String },
130
131    #[error("Mandate kind '{kind}' does not allow operation class '{op_class}'")]
132    KindMismatch { kind: String, op_class: String },
133
134    #[error("Audience mismatch: expected '{expected}', got '{actual}'")]
135    AudienceMismatch { expected: String, actual: String },
136
137    #[error("Issuer '{issuer}' not in trusted issuers")]
138    IssuerNotTrusted { issuer: String },
139
140    #[error("Missing transaction object for commit tool")]
141    MissingTransactionObject,
142
143    #[error("Transaction ref mismatch: expected '{expected}', got '{actual}'")]
144    TransactionRefMismatch { expected: String, actual: String },
145}
146
147/// Combined authorization error.
148#[derive(Debug, Error)]
149pub enum AuthorizeError {
150    #[error("Policy error: {0}")]
151    Policy(#[from] PolicyError),
152
153    #[error("Store error: {0}")]
154    Store(#[from] AuthzError),
155
156    #[error("Failed to compute transaction ref: {0}")]
157    TransactionRef(String),
158}
159
160/// Runtime authorizer.
161pub struct Authorizer {
162    store: MandateStore,
163    config: AuthzConfig,
164}
165
166impl Authorizer {
167    /// Create a new authorizer with the given store and config.
168    pub fn new(store: MandateStore, config: AuthzConfig) -> Self {
169        Self { store, config }
170    }
171
172    /// Authorize and consume a mandate for a tool call.
173    ///
174    /// Implements SPEC-Mandate-v1.0.3 §7 flow:
175    /// 1. Verify validity window
176    /// 2. Verify context (audience, issuer)
177    /// 3. Verify scope matches tool
178    /// 4. Verify mandate_kind matches operation_class
179    /// 5. Verify transaction_ref for commit tools
180    /// 6. Upsert mandate metadata
181    /// 7. Consume mandate atomically
182    pub fn authorize_and_consume(
183        &self,
184        mandate: &MandateData,
185        tool_call: &ToolCallData,
186    ) -> Result<AuthzReceipt, AuthorizeError> {
187        let now = Utc::now();
188        let skew = Duration::seconds(self.config.clock_skew_seconds);
189
190        // 1. Verify validity window (§7.6)
191        if let Some(not_before) = mandate.not_before {
192            if now < not_before - skew {
193                return Err(PolicyError::NotYetValid { not_before, now }.into());
194            }
195        }
196        if let Some(expires_at) = mandate.expires_at {
197            if now >= expires_at + skew {
198                return Err(PolicyError::Expired { expires_at, now }.into());
199            }
200        }
201
202        // 1b. Check revocation status (P0-A)
203        if let Some(revoked_at) = self.store.get_revoked_at(&mandate.mandate_id)? {
204            if now >= revoked_at {
205                return Err(AuthzError::Revoked { revoked_at }.into());
206            }
207        }
208
209        // 2. Verify context
210        if !self.config.expected_audience.is_empty()
211            && mandate.audience != self.config.expected_audience
212        {
213            return Err(PolicyError::AudienceMismatch {
214                expected: self.config.expected_audience.clone(),
215                actual: mandate.audience.clone(),
216            }
217            .into());
218        }
219        if !self.config.trusted_issuers.is_empty()
220            && !self.config.trusted_issuers.contains(&mandate.issuer)
221        {
222            return Err(PolicyError::IssuerNotTrusted {
223                issuer: mandate.issuer.clone(),
224            }
225            .into());
226        }
227
228        // 3. Verify scope matches tool
229        if !self.tool_matches_scope(&tool_call.tool_name, &mandate.tool_patterns) {
230            return Err(PolicyError::ToolNotInScope {
231                tool: tool_call.tool_name.clone(),
232            }
233            .into());
234        }
235
236        // 4. Verify mandate_kind matches operation_class
237        let max_allowed = mandate.mandate_kind.max_operation_class();
238        if tool_call.operation_class > max_allowed {
239            return Err(PolicyError::KindMismatch {
240                kind: mandate.mandate_kind.as_str().to_string(),
241                op_class: tool_call.operation_class.as_str().to_string(),
242            }
243            .into());
244        }
245
246        // 5. Verify transaction_ref for commit tools (§7.7)
247        if tool_call.operation_class == OperationClass::Commit {
248            if let Some(expected_ref) = &mandate.transaction_ref {
249                let tx_obj = tool_call
250                    .transaction_object
251                    .as_ref()
252                    .ok_or(PolicyError::MissingTransactionObject)?;
253
254                let actual_ref = compute_transaction_ref(tx_obj)
255                    .map_err(|e| AuthorizeError::TransactionRef(e.to_string()))?;
256
257                if actual_ref != *expected_ref {
258                    return Err(PolicyError::TransactionRefMismatch {
259                        expected: expected_ref.clone(),
260                        actual: actual_ref,
261                    }
262                    .into());
263                }
264            }
265        }
266
267        // 6. Upsert mandate metadata
268        let meta = MandateMetadata {
269            mandate_id: mandate.mandate_id.clone(),
270            mandate_kind: mandate.mandate_kind.as_str().to_string(),
271            audience: mandate.audience.clone(),
272            issuer: mandate.issuer.clone(),
273            expires_at: mandate.expires_at,
274            single_use: mandate.single_use,
275            max_uses: mandate.max_uses,
276            canonical_digest: mandate.canonical_digest.clone(),
277            key_id: mandate.key_id.clone(),
278        };
279        self.store.upsert_mandate(&meta)?;
280
281        // 7. Consume mandate atomically
282        let receipt = self.store.consume_mandate(&ConsumeParams {
283            mandate_id: &mandate.mandate_id,
284            tool_call_id: &tool_call.tool_call_id,
285            nonce: mandate.nonce.as_deref(),
286            audience: &mandate.audience,
287            issuer: &mandate.issuer,
288            tool_name: &tool_call.tool_name,
289            operation_class: tool_call.operation_class.as_str(),
290            source_run_id: tool_call.source_run_id.as_deref(),
291        })?;
292
293        Ok(receipt)
294    }
295
296    /// Check if tool name matches any of the scope patterns.
297    fn tool_matches_scope(&self, tool_name: &str, patterns: &[String]) -> bool {
298        for pattern in patterns {
299            if glob_matches(pattern, tool_name) {
300                return true;
301            }
302        }
303        false
304    }
305}
306
307/// Simple glob matching for tool patterns.
308///
309/// Supports:
310/// - `*` matches any characters except `.`
311/// - `**` matches any characters including `.`
312/// - Literal characters match exactly
313fn glob_matches(pattern: &str, input: &str) -> bool {
314    let mut pattern_chars = pattern.chars().peekable();
315    let mut input_chars = input.chars().peekable();
316
317    while let Some(p) = pattern_chars.next() {
318        match p {
319            '*' => {
320                // Check for **
321                if pattern_chars.peek() == Some(&'*') {
322                    pattern_chars.next(); // consume second *
323                                          // ** matches everything including dots
324                    let remaining: String = pattern_chars.collect();
325                    if remaining.is_empty() {
326                        return true; // ** at end matches everything
327                    }
328                    // Try matching remaining pattern at every position
329                    let remaining_input: String = input_chars.collect();
330                    for i in 0..=remaining_input.len() {
331                        if glob_matches(&remaining, &remaining_input[i..]) {
332                            return true;
333                        }
334                    }
335                    return false;
336                } else {
337                    // * matches everything except dot
338                    let remaining: String = pattern_chars.collect();
339                    if remaining.is_empty() {
340                        // * at end - consume until dot or end
341                        return input_chars.all(|c| c != '.');
342                    }
343                    // Try matching remaining pattern at every position (stopping at dot)
344                    let mut remaining_input: String = input_chars.collect();
345                    loop {
346                        if glob_matches(&remaining, &remaining_input) {
347                            return true;
348                        }
349                        if remaining_input.is_empty() || remaining_input.starts_with('.') {
350                            return false;
351                        }
352                        remaining_input = remaining_input[1..].to_string();
353                    }
354                }
355            }
356            '\\' => {
357                // Escape sequence
358                if let Some(escaped) = pattern_chars.next() {
359                    if input_chars.next() != Some(escaped) {
360                        return false;
361                    }
362                } else {
363                    return false; // Trailing backslash
364                }
365            }
366            c => {
367                if input_chars.next() != Some(c) {
368                    return false;
369                }
370            }
371        }
372    }
373
374    // Pattern consumed, input should also be consumed
375    input_chars.next().is_none()
376}
377
378/// Compute transaction_ref hash from transaction object.
379fn compute_transaction_ref(tx_object: &serde_json::Value) -> Result<String, String> {
380    use sha2::{Digest, Sha256};
381
382    // Canonicalize using JCS (RFC 8785)
383    let canonical = serde_jcs::to_vec(tx_object).map_err(|e| e.to_string())?;
384
385    let hash = Sha256::digest(&canonical);
386    Ok(format!("sha256:{}", hex::encode(hash)))
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    fn test_config() -> AuthzConfig {
394        AuthzConfig {
395            clock_skew_seconds: 30,
396            expected_audience: "org/app".to_string(),
397            trusted_issuers: vec!["auth.org.com".to_string()],
398        }
399    }
400
401    fn test_mandate() -> MandateData {
402        MandateData {
403            mandate_id: "sha256:test123".to_string(),
404            mandate_kind: MandateKind::Intent,
405            audience: "org/app".to_string(),
406            issuer: "auth.org.com".to_string(),
407            tool_patterns: vec!["search_*".to_string(), "get_*".to_string()],
408            operation_class: Some(OperationClass::Read),
409            transaction_ref: None,
410            not_before: None,
411            expires_at: Some(Utc::now() + Duration::hours(1)),
412            single_use: false,
413            max_uses: None,
414            nonce: None,
415            canonical_digest: "sha256:digest123".to_string(),
416            key_id: "sha256:key123".to_string(),
417        }
418    }
419
420    fn test_tool_call(name: &str) -> ToolCallData {
421        ToolCallData {
422            tool_call_id: format!("tc_{}", name),
423            tool_name: name.to_string(),
424            operation_class: OperationClass::Read,
425            transaction_object: None,
426            source_run_id: None,
427        }
428    }
429
430    // === Glob matching tests ===
431
432    #[test]
433    fn test_glob_exact_match() {
434        assert!(glob_matches("search", "search"));
435        assert!(!glob_matches("search", "search_products"));
436        assert!(!glob_matches("search", "my_search"));
437    }
438
439    #[test]
440    fn test_glob_single_star() {
441        assert!(glob_matches("search_*", "search_products"));
442        assert!(glob_matches("search_*", "search_users"));
443        assert!(glob_matches("search_*", "search_"));
444        assert!(!glob_matches("search_*", "search.products")); // * stops at dot
445    }
446
447    #[test]
448    fn test_glob_double_star() {
449        assert!(glob_matches("fs.**", "fs.read_file"));
450        assert!(glob_matches("fs.**", "fs.write.nested.path"));
451        assert!(glob_matches("**", "anything.at.all"));
452    }
453
454    #[test]
455    fn test_glob_escaped() {
456        assert!(glob_matches(r"file\*name", "file*name"));
457        assert!(!glob_matches(r"file\*name", "filename"));
458    }
459
460    // === Validity window tests (§7.6) ===
461
462    #[test]
463    fn test_authorize_rejects_expired() {
464        let store = MandateStore::memory().unwrap();
465        let config = test_config();
466        let authorizer = Authorizer::new(store, config);
467
468        let mut mandate = test_mandate();
469        mandate.expires_at = Some(Utc::now() - Duration::seconds(31)); // Beyond skew
470
471        let tool_call = test_tool_call("search_products");
472        let result = authorizer.authorize_and_consume(&mandate, &tool_call);
473
474        assert!(matches!(
475            result,
476            Err(AuthorizeError::Policy(PolicyError::Expired { .. }))
477        ));
478    }
479
480    #[test]
481    fn test_authorize_allows_within_expiry_skew() {
482        let store = MandateStore::memory().unwrap();
483        let config = test_config();
484        let authorizer = Authorizer::new(store, config);
485
486        let mut mandate = test_mandate();
487        mandate.expires_at = Some(Utc::now() - Duration::seconds(5)); // Within skew
488
489        let tool_call = test_tool_call("search_products");
490        let result = authorizer.authorize_and_consume(&mandate, &tool_call);
491
492        assert!(result.is_ok());
493    }
494
495    #[test]
496    fn test_authorize_rejects_not_yet_valid() {
497        let store = MandateStore::memory().unwrap();
498        let config = test_config();
499        let authorizer = Authorizer::new(store, config);
500
501        let mut mandate = test_mandate();
502        mandate.not_before = Some(Utc::now() + Duration::seconds(31)); // Beyond skew
503
504        let tool_call = test_tool_call("search_products");
505        let result = authorizer.authorize_and_consume(&mandate, &tool_call);
506
507        assert!(matches!(
508            result,
509            Err(AuthorizeError::Policy(PolicyError::NotYetValid { .. }))
510        ));
511    }
512
513    // === Scope tests ===
514
515    #[test]
516    fn test_authorize_rejects_tool_not_in_scope() {
517        let store = MandateStore::memory().unwrap();
518        let config = test_config();
519        let authorizer = Authorizer::new(store, config);
520
521        let mandate = test_mandate(); // scope: search_*, get_*
522        let tool_call = test_tool_call("purchase_item"); // Not in scope
523
524        let result = authorizer.authorize_and_consume(&mandate, &tool_call);
525
526        assert!(matches!(
527            result,
528            Err(AuthorizeError::Policy(PolicyError::ToolNotInScope { tool })) if tool == "purchase_item"
529        ));
530    }
531
532    #[test]
533    fn test_authorize_allows_tool_in_scope() {
534        let store = MandateStore::memory().unwrap();
535        let config = test_config();
536        let authorizer = Authorizer::new(store, config);
537
538        let mandate = test_mandate();
539        let tool_call = test_tool_call("search_products");
540
541        let result = authorizer.authorize_and_consume(&mandate, &tool_call);
542        assert!(result.is_ok());
543    }
544
545    // === Kind/operation_class tests ===
546
547    #[test]
548    fn test_authorize_rejects_commit_with_intent_mandate() {
549        let store = MandateStore::memory().unwrap();
550        let config = test_config();
551        let authorizer = Authorizer::new(store, config);
552
553        let mut mandate = test_mandate();
554        mandate.mandate_kind = MandateKind::Intent;
555        mandate.tool_patterns = vec!["purchase_*".to_string()];
556
557        let mut tool_call = test_tool_call("purchase_item");
558        tool_call.operation_class = OperationClass::Commit;
559
560        let result = authorizer.authorize_and_consume(&mandate, &tool_call);
561
562        assert!(matches!(
563            result,
564            Err(AuthorizeError::Policy(PolicyError::KindMismatch { .. }))
565        ));
566    }
567
568    #[test]
569    fn test_authorize_allows_commit_with_transaction_mandate() {
570        let store = MandateStore::memory().unwrap();
571        let config = test_config();
572        let authorizer = Authorizer::new(store, config);
573
574        let mut mandate = test_mandate();
575        mandate.mandate_kind = MandateKind::Transaction;
576        mandate.tool_patterns = vec!["purchase_*".to_string()];
577
578        let mut tool_call = test_tool_call("purchase_item");
579        tool_call.operation_class = OperationClass::Commit;
580
581        let result = authorizer.authorize_and_consume(&mandate, &tool_call);
582        assert!(result.is_ok());
583    }
584
585    // === transaction_ref tests (§7.7) ===
586
587    #[test]
588    fn test_authorize_rejects_missing_transaction_object() {
589        let store = MandateStore::memory().unwrap();
590        let config = test_config();
591        let authorizer = Authorizer::new(store, config);
592
593        let mut mandate = test_mandate();
594        mandate.mandate_kind = MandateKind::Transaction;
595        mandate.tool_patterns = vec!["purchase_*".to_string()];
596        mandate.transaction_ref = Some("sha256:expected".to_string());
597
598        let mut tool_call = test_tool_call("purchase_item");
599        tool_call.operation_class = OperationClass::Commit;
600        tool_call.transaction_object = None; // Missing!
601
602        let result = authorizer.authorize_and_consume(&mandate, &tool_call);
603
604        assert!(matches!(
605            result,
606            Err(AuthorizeError::Policy(
607                PolicyError::MissingTransactionObject
608            ))
609        ));
610    }
611
612    #[test]
613    fn test_authorize_rejects_transaction_ref_mismatch() {
614        let store = MandateStore::memory().unwrap();
615        let config = test_config();
616        let authorizer = Authorizer::new(store, config);
617
618        // Compute expected ref from a specific object
619        let expected_obj = serde_json::json!({
620            "merchant_id": "shop_123",
621            "amount_cents": 4999,
622            "currency": "EUR"
623        });
624        let expected_ref = compute_transaction_ref(&expected_obj).unwrap();
625
626        let mut mandate = test_mandate();
627        mandate.mandate_kind = MandateKind::Transaction;
628        mandate.tool_patterns = vec!["purchase_*".to_string()];
629        mandate.transaction_ref = Some(expected_ref);
630
631        let mut tool_call = test_tool_call("purchase_item");
632        tool_call.operation_class = OperationClass::Commit;
633        // Different transaction object!
634        tool_call.transaction_object = Some(serde_json::json!({
635            "merchant_id": "shop_123",
636            "amount_cents": 9999, // Different amount
637            "currency": "EUR"
638        }));
639
640        let result = authorizer.authorize_and_consume(&mandate, &tool_call);
641
642        assert!(matches!(
643            result,
644            Err(AuthorizeError::Policy(
645                PolicyError::TransactionRefMismatch { .. }
646            ))
647        ));
648    }
649
650    #[test]
651    fn test_authorize_allows_matching_transaction_ref() {
652        let store = MandateStore::memory().unwrap();
653        let config = test_config();
654        let authorizer = Authorizer::new(store, config);
655
656        let tx_obj = serde_json::json!({
657            "merchant_id": "shop_123",
658            "amount_cents": 4999,
659            "currency": "EUR"
660        });
661        let tx_ref = compute_transaction_ref(&tx_obj).unwrap();
662
663        let mut mandate = test_mandate();
664        mandate.mandate_kind = MandateKind::Transaction;
665        mandate.tool_patterns = vec!["purchase_*".to_string()];
666        mandate.transaction_ref = Some(tx_ref);
667
668        let mut tool_call = test_tool_call("purchase_item");
669        tool_call.operation_class = OperationClass::Commit;
670        tool_call.transaction_object = Some(tx_obj);
671
672        let result = authorizer.authorize_and_consume(&mandate, &tool_call);
673        assert!(result.is_ok());
674    }
675
676    // === Context tests ===
677
678    #[test]
679    fn test_authorize_rejects_wrong_audience() {
680        let store = MandateStore::memory().unwrap();
681        let config = test_config(); // expects "org/app"
682        let authorizer = Authorizer::new(store, config);
683
684        let mut mandate = test_mandate();
685        mandate.audience = "other/app".to_string();
686
687        let tool_call = test_tool_call("search_products");
688        let result = authorizer.authorize_and_consume(&mandate, &tool_call);
689
690        assert!(matches!(
691            result,
692            Err(AuthorizeError::Policy(PolicyError::AudienceMismatch { .. }))
693        ));
694    }
695
696    #[test]
697    fn test_authorize_rejects_untrusted_issuer() {
698        let store = MandateStore::memory().unwrap();
699        let config = test_config(); // trusts "auth.org.com"
700        let authorizer = Authorizer::new(store, config);
701
702        let mut mandate = test_mandate();
703        mandate.issuer = "evil.attacker.com".to_string();
704
705        let tool_call = test_tool_call("search_products");
706        let result = authorizer.authorize_and_consume(&mandate, &tool_call);
707
708        assert!(matches!(
709            result,
710            Err(AuthorizeError::Policy(PolicyError::IssuerNotTrusted { .. }))
711        ));
712    }
713
714    // === Revocation tests (P0-A) ===
715
716    #[test]
717    fn test_authorize_rejects_revoked_mandate() {
718        let store = MandateStore::memory().unwrap();
719        let config = test_config();
720        let authorizer = Authorizer::new(store.clone(), config);
721
722        let mandate = test_mandate();
723
724        // Revoke it before first use
725        store
726            .upsert_revocation(&super::super::mandate_store::RevocationRecord {
727                mandate_id: mandate.mandate_id.clone(),
728                revoked_at: Utc::now() - chrono::Duration::minutes(5),
729                reason: Some("User requested".to_string()),
730                revoked_by: None,
731                source: None,
732                event_id: None,
733            })
734            .unwrap();
735
736        let tool_call = test_tool_call("search_products");
737        let result = authorizer.authorize_and_consume(&mandate, &tool_call);
738
739        assert!(
740            matches!(
741                result,
742                Err(AuthorizeError::Store(AuthzError::Revoked { .. }))
743            ),
744            "Expected Revoked error, got {:?}",
745            result
746        );
747    }
748
749    #[test]
750    fn test_authorize_allows_if_revoked_in_future() {
751        let store = MandateStore::memory().unwrap();
752        let config = test_config();
753        let authorizer = Authorizer::new(store.clone(), config);
754
755        let mandate = test_mandate();
756
757        // Revocation is in the future (shouldn't block yet)
758        store
759            .upsert_revocation(&super::super::mandate_store::RevocationRecord {
760                mandate_id: mandate.mandate_id.clone(),
761                revoked_at: Utc::now() + chrono::Duration::hours(1),
762                reason: Some("Scheduled revocation".to_string()),
763                revoked_by: None,
764                source: None,
765                event_id: None,
766            })
767            .unwrap();
768
769        let tool_call = test_tool_call("search_products");
770        let result = authorizer.authorize_and_consume(&mandate, &tool_call);
771
772        assert!(result.is_ok(), "Should allow use before revoked_at");
773    }
774}