1use super::mandate_store::{
13 AuthzError, AuthzReceipt, ConsumeParams, MandateMetadata, MandateStore,
14};
15use chrono::{DateTime, Duration, Utc};
16use thiserror::Error;
17
18pub const DEFAULT_CLOCK_SKEW_SECONDS: i64 = 30;
20
21#[derive(Debug, Clone)]
23pub struct AuthzConfig {
24 pub clock_skew_seconds: i64,
26 pub expected_audience: String,
28 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#[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#[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 pub fn max_operation_class(&self) -> OperationClass {
77 match self {
78 Self::Intent => OperationClass::Write, Self::Transaction => OperationClass::Commit, }
81 }
82}
83
84#[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#[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#[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#[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
160pub struct Authorizer {
162 store: MandateStore,
163 config: AuthzConfig,
164}
165
166impl Authorizer {
167 pub fn new(store: MandateStore, config: AuthzConfig) -> Self {
169 Self { store, config }
170 }
171
172 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 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 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 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 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 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 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 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 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 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
307fn 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 if pattern_chars.peek() == Some(&'*') {
322 pattern_chars.next(); let remaining: String = pattern_chars.collect();
325 if remaining.is_empty() {
326 return true; }
328 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 let remaining: String = pattern_chars.collect();
339 if remaining.is_empty() {
340 return input_chars.all(|c| c != '.');
342 }
343 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 if let Some(escaped) = pattern_chars.next() {
359 if input_chars.next() != Some(escaped) {
360 return false;
361 }
362 } else {
363 return false; }
365 }
366 c => {
367 if input_chars.next() != Some(c) {
368 return false;
369 }
370 }
371 }
372 }
373
374 input_chars.next().is_none()
376}
377
378fn compute_transaction_ref(tx_object: &serde_json::Value) -> Result<String, String> {
380 use sha2::{Digest, Sha256};
381
382 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 #[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")); }
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 #[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)); 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)); 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)); 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 #[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(); let tool_call = test_tool_call("purchase_item"); 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 #[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 #[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; 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 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 tool_call.transaction_object = Some(serde_json::json!({
635 "merchant_id": "shop_123",
636 "amount_cents": 9999, "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 #[test]
679 fn test_authorize_rejects_wrong_audience() {
680 let store = MandateStore::memory().unwrap();
681 let config = test_config(); 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(); 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 #[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 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 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}