use super::mandate_store::{
AuthzError, AuthzReceipt, ConsumeParams, MandateMetadata, MandateStore,
};
use chrono::{DateTime, Duration, Utc};
use thiserror::Error;
pub const DEFAULT_CLOCK_SKEW_SECONDS: i64 = 30;
#[derive(Debug, Clone)]
pub struct AuthzConfig {
pub clock_skew_seconds: i64,
pub expected_audience: String,
pub trusted_issuers: Vec<String>,
}
impl Default for AuthzConfig {
fn default() -> Self {
Self {
clock_skew_seconds: DEFAULT_CLOCK_SKEW_SECONDS,
expected_audience: String::new(),
trusted_issuers: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum OperationClass {
Read = 0,
Write = 1,
Commit = 2,
}
impl OperationClass {
pub fn as_str(&self) -> &'static str {
match self {
Self::Read => "read",
Self::Write => "write",
Self::Commit => "commit",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MandateKind {
Intent,
Transaction,
}
impl MandateKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Intent => "intent",
Self::Transaction => "transaction",
}
}
pub fn max_operation_class(&self) -> OperationClass {
match self {
Self::Intent => OperationClass::Write, Self::Transaction => OperationClass::Commit, }
}
}
#[derive(Debug, Clone)]
pub struct MandateData {
pub mandate_id: String,
pub mandate_kind: MandateKind,
pub audience: String,
pub issuer: String,
pub tool_patterns: Vec<String>,
pub operation_class: Option<OperationClass>,
pub transaction_ref: Option<String>,
pub not_before: Option<DateTime<Utc>>,
pub expires_at: Option<DateTime<Utc>>,
pub single_use: bool,
pub max_uses: Option<u32>,
pub nonce: Option<String>,
pub canonical_digest: String,
pub key_id: String,
}
#[derive(Debug, Clone)]
pub struct ToolCallData {
pub tool_call_id: String,
pub tool_name: String,
pub operation_class: OperationClass,
pub transaction_object: Option<serde_json::Value>,
pub source_run_id: Option<String>,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum PolicyError {
#[error("Mandate expired: expires_at={expires_at}, now={now}")]
Expired {
expires_at: DateTime<Utc>,
now: DateTime<Utc>,
},
#[error("Mandate not yet valid: not_before={not_before}, now={now}")]
NotYetValid {
not_before: DateTime<Utc>,
now: DateTime<Utc>,
},
#[error("Tool '{tool}' not in mandate scope")]
ToolNotInScope { tool: String },
#[error("Mandate kind '{kind}' does not allow operation class '{op_class}'")]
KindMismatch { kind: String, op_class: String },
#[error("Audience mismatch: expected '{expected}', got '{actual}'")]
AudienceMismatch { expected: String, actual: String },
#[error("Issuer '{issuer}' not in trusted issuers")]
IssuerNotTrusted { issuer: String },
#[error("Missing transaction object for commit tool")]
MissingTransactionObject,
#[error("Transaction ref mismatch: expected '{expected}', got '{actual}'")]
TransactionRefMismatch { expected: String, actual: String },
}
#[derive(Debug, Error)]
pub enum AuthorizeError {
#[error("Policy error: {0}")]
Policy(#[from] PolicyError),
#[error("Store error: {0}")]
Store(#[from] AuthzError),
#[error("Failed to compute transaction ref: {0}")]
TransactionRef(String),
}
pub struct Authorizer {
store: MandateStore,
config: AuthzConfig,
}
impl Authorizer {
pub fn new(store: MandateStore, config: AuthzConfig) -> Self {
Self { store, config }
}
pub fn authorize_and_consume(
&self,
mandate: &MandateData,
tool_call: &ToolCallData,
) -> Result<AuthzReceipt, AuthorizeError> {
self.authorize_at(Utc::now(), mandate, tool_call)
}
pub fn authorize_at(
&self,
now: DateTime<Utc>,
mandate: &MandateData,
tool_call: &ToolCallData,
) -> Result<AuthzReceipt, AuthorizeError> {
let skew = Duration::seconds(self.config.clock_skew_seconds);
if let Some(not_before) = mandate.not_before {
if now < not_before - skew {
return Err(PolicyError::NotYetValid { not_before, now }.into());
}
}
if let Some(expires_at) = mandate.expires_at {
if now >= expires_at + skew {
return Err(PolicyError::Expired { expires_at, now }.into());
}
}
if let Some(revoked_at) = self.store.get_revoked_at(&mandate.mandate_id)? {
if now >= revoked_at {
return Err(AuthzError::Revoked { revoked_at }.into());
}
}
if !self.config.expected_audience.is_empty()
&& mandate.audience != self.config.expected_audience
{
return Err(PolicyError::AudienceMismatch {
expected: self.config.expected_audience.clone(),
actual: mandate.audience.clone(),
}
.into());
}
if !self.config.trusted_issuers.is_empty()
&& !self.config.trusted_issuers.contains(&mandate.issuer)
{
return Err(PolicyError::IssuerNotTrusted {
issuer: mandate.issuer.clone(),
}
.into());
}
if !self.tool_matches_scope(&tool_call.tool_name, &mandate.tool_patterns) {
return Err(PolicyError::ToolNotInScope {
tool: tool_call.tool_name.clone(),
}
.into());
}
let max_allowed = mandate.mandate_kind.max_operation_class();
if tool_call.operation_class > max_allowed {
return Err(PolicyError::KindMismatch {
kind: mandate.mandate_kind.as_str().to_string(),
op_class: tool_call.operation_class.as_str().to_string(),
}
.into());
}
if tool_call.operation_class == OperationClass::Commit {
if let Some(expected_ref) = &mandate.transaction_ref {
let tx_obj = tool_call
.transaction_object
.as_ref()
.ok_or(PolicyError::MissingTransactionObject)?;
let actual_ref = compute_transaction_ref(tx_obj)
.map_err(|e| AuthorizeError::TransactionRef(e.to_string()))?;
if actual_ref != *expected_ref {
return Err(PolicyError::TransactionRefMismatch {
expected: expected_ref.clone(),
actual: actual_ref,
}
.into());
}
}
}
let meta = MandateMetadata {
mandate_id: mandate.mandate_id.clone(),
mandate_kind: mandate.mandate_kind.as_str().to_string(),
audience: mandate.audience.clone(),
issuer: mandate.issuer.clone(),
expires_at: mandate.expires_at,
single_use: mandate.single_use,
max_uses: mandate.max_uses,
canonical_digest: mandate.canonical_digest.clone(),
key_id: mandate.key_id.clone(),
};
self.store.upsert_mandate(&meta)?;
let receipt = self.store.consume_mandate(&ConsumeParams {
mandate_id: &mandate.mandate_id,
tool_call_id: &tool_call.tool_call_id,
nonce: mandate.nonce.as_deref(),
audience: &mandate.audience,
issuer: &mandate.issuer,
tool_name: &tool_call.tool_name,
operation_class: tool_call.operation_class.as_str(),
source_run_id: tool_call.source_run_id.as_deref(),
})?;
Ok(receipt)
}
fn tool_matches_scope(&self, tool_name: &str, patterns: &[String]) -> bool {
for pattern in patterns {
if glob_matches(pattern, tool_name) {
return true;
}
}
false
}
}
fn glob_matches(pattern: &str, input: &str) -> bool {
let mut pattern_chars = pattern.chars().peekable();
let mut input_chars = input.chars().peekable();
while let Some(p) = pattern_chars.next() {
match p {
'*' => {
if pattern_chars.peek() == Some(&'*') {
pattern_chars.next(); let remaining: String = pattern_chars.collect();
if remaining.is_empty() {
return true; }
let remaining_input: String = input_chars.collect();
for i in 0..=remaining_input.len() {
if glob_matches(&remaining, &remaining_input[i..]) {
return true;
}
}
return false;
} else {
let remaining: String = pattern_chars.collect();
if remaining.is_empty() {
return input_chars.all(|c| c != '.');
}
let mut remaining_input: String = input_chars.collect();
loop {
if glob_matches(&remaining, &remaining_input) {
return true;
}
if remaining_input.is_empty() || remaining_input.starts_with('.') {
return false;
}
remaining_input = remaining_input[1..].to_string();
}
}
}
'\\' => {
if let Some(escaped) = pattern_chars.next() {
if input_chars.next() != Some(escaped) {
return false;
}
} else {
return false; }
}
c => {
if input_chars.next() != Some(c) {
return false;
}
}
}
}
input_chars.next().is_none()
}
fn compute_transaction_ref(tx_object: &serde_json::Value) -> Result<String, String> {
use sha2::{Digest, Sha256};
let canonical = serde_jcs::to_vec(tx_object).map_err(|e| e.to_string())?;
let hash = Sha256::digest(&canonical);
Ok(format!("sha256:{}", hex::encode(hash)))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn test_config() -> AuthzConfig {
AuthzConfig {
clock_skew_seconds: 30,
expected_audience: "org/app".to_string(),
trusted_issuers: vec!["auth.org.com".to_string()],
}
}
fn test_mandate() -> MandateData {
MandateData {
mandate_id: "sha256:test123".to_string(),
mandate_kind: MandateKind::Intent,
audience: "org/app".to_string(),
issuer: "auth.org.com".to_string(),
tool_patterns: vec!["search_*".to_string(), "get_*".to_string()],
operation_class: Some(OperationClass::Read),
transaction_ref: None,
not_before: None,
expires_at: Some(Utc::now() + Duration::hours(1)),
single_use: false,
max_uses: None,
nonce: None,
canonical_digest: "sha256:digest123".to_string(),
key_id: "sha256:key123".to_string(),
}
}
fn test_tool_call(name: &str) -> ToolCallData {
ToolCallData {
tool_call_id: format!("tc_{}", name),
tool_name: name.to_string(),
operation_class: OperationClass::Read,
transaction_object: None,
source_run_id: None,
}
}
#[test]
fn test_glob_exact_match() {
assert!(glob_matches("search", "search"));
assert!(!glob_matches("search", "search_products"));
assert!(!glob_matches("search", "my_search"));
}
#[test]
fn test_glob_single_star() {
assert!(glob_matches("search_*", "search_products"));
assert!(glob_matches("search_*", "search_users"));
assert!(glob_matches("search_*", "search_"));
assert!(!glob_matches("search_*", "search.products")); }
#[test]
fn test_glob_double_star() {
assert!(glob_matches("fs.**", "fs.read_file"));
assert!(glob_matches("fs.**", "fs.write.nested.path"));
assert!(glob_matches("**", "anything.at.all"));
}
#[test]
fn test_glob_escaped() {
assert!(glob_matches(r"file\*name", "file*name"));
assert!(!glob_matches(r"file\*name", "filename"));
}
fn fixed_now() -> DateTime<Utc> {
Utc.timestamp_opt(1_700_000_000, 0).unwrap() }
#[test]
fn test_authorize_rejects_expired() {
let store = MandateStore::memory().unwrap();
let config = test_config();
let authorizer = Authorizer::new(store, config);
let now = fixed_now();
let mut mandate = test_mandate();
mandate.expires_at = Some(now - Duration::seconds(31));
let tool_call = test_tool_call("search_products");
let result = authorizer.authorize_at(now, &mandate, &tool_call);
assert!(matches!(
result,
Err(AuthorizeError::Policy(PolicyError::Expired { .. }))
));
}
#[test]
fn test_authorize_allows_within_expiry_skew() {
let store = MandateStore::memory().unwrap();
let config = test_config();
let authorizer = Authorizer::new(store, config);
let now = fixed_now();
let mut mandate = test_mandate();
mandate.expires_at = Some(now - Duration::seconds(5));
let tool_call = test_tool_call("search_products");
let result = authorizer.authorize_at(now, &mandate, &tool_call);
assert!(result.is_ok());
}
#[test]
fn test_authorize_rejects_not_yet_valid() {
let store = MandateStore::memory().unwrap();
let config = test_config();
let authorizer = Authorizer::new(store, config);
let now = fixed_now();
let mut mandate = test_mandate();
mandate.not_before = Some(now + Duration::seconds(31));
let tool_call = test_tool_call("search_products");
let result = authorizer.authorize_at(now, &mandate, &tool_call);
assert!(matches!(
result,
Err(AuthorizeError::Policy(PolicyError::NotYetValid { .. }))
));
}
#[test]
fn test_authorize_rejects_tool_not_in_scope() {
let store = MandateStore::memory().unwrap();
let config = test_config();
let authorizer = Authorizer::new(store, config);
let mandate = test_mandate(); let tool_call = test_tool_call("purchase_item");
let result = authorizer.authorize_and_consume(&mandate, &tool_call);
assert!(matches!(
result,
Err(AuthorizeError::Policy(PolicyError::ToolNotInScope { tool })) if tool == "purchase_item"
));
}
#[test]
fn test_authorize_allows_tool_in_scope() {
let store = MandateStore::memory().unwrap();
let config = test_config();
let authorizer = Authorizer::new(store, config);
let mandate = test_mandate();
let tool_call = test_tool_call("search_products");
let result = authorizer.authorize_and_consume(&mandate, &tool_call);
assert!(result.is_ok());
}
#[test]
fn test_authorize_rejects_commit_with_intent_mandate() {
let store = MandateStore::memory().unwrap();
let config = test_config();
let authorizer = Authorizer::new(store, config);
let mut mandate = test_mandate();
mandate.mandate_kind = MandateKind::Intent;
mandate.tool_patterns = vec!["purchase_*".to_string()];
let mut tool_call = test_tool_call("purchase_item");
tool_call.operation_class = OperationClass::Commit;
let result = authorizer.authorize_and_consume(&mandate, &tool_call);
assert!(matches!(
result,
Err(AuthorizeError::Policy(PolicyError::KindMismatch { .. }))
));
}
#[test]
fn test_authorize_allows_commit_with_transaction_mandate() {
let store = MandateStore::memory().unwrap();
let config = test_config();
let authorizer = Authorizer::new(store, config);
let mut mandate = test_mandate();
mandate.mandate_kind = MandateKind::Transaction;
mandate.tool_patterns = vec!["purchase_*".to_string()];
let mut tool_call = test_tool_call("purchase_item");
tool_call.operation_class = OperationClass::Commit;
let result = authorizer.authorize_and_consume(&mandate, &tool_call);
assert!(result.is_ok());
}
#[test]
fn test_authorize_rejects_missing_transaction_object() {
let store = MandateStore::memory().unwrap();
let config = test_config();
let authorizer = Authorizer::new(store, config);
let mut mandate = test_mandate();
mandate.mandate_kind = MandateKind::Transaction;
mandate.tool_patterns = vec!["purchase_*".to_string()];
mandate.transaction_ref = Some("sha256:expected".to_string());
let mut tool_call = test_tool_call("purchase_item");
tool_call.operation_class = OperationClass::Commit;
tool_call.transaction_object = None;
let result = authorizer.authorize_and_consume(&mandate, &tool_call);
assert!(matches!(
result,
Err(AuthorizeError::Policy(
PolicyError::MissingTransactionObject
))
));
}
#[test]
fn test_authorize_rejects_transaction_ref_mismatch() {
let store = MandateStore::memory().unwrap();
let config = test_config();
let authorizer = Authorizer::new(store, config);
let expected_obj = serde_json::json!({
"merchant_id": "shop_123",
"amount_cents": 4999,
"currency": "EUR"
});
let expected_ref = compute_transaction_ref(&expected_obj).unwrap();
let mut mandate = test_mandate();
mandate.mandate_kind = MandateKind::Transaction;
mandate.tool_patterns = vec!["purchase_*".to_string()];
mandate.transaction_ref = Some(expected_ref);
let mut tool_call = test_tool_call("purchase_item");
tool_call.operation_class = OperationClass::Commit;
tool_call.transaction_object = Some(serde_json::json!({
"merchant_id": "shop_123",
"amount_cents": 9999, "currency": "EUR"
}));
let result = authorizer.authorize_and_consume(&mandate, &tool_call);
assert!(matches!(
result,
Err(AuthorizeError::Policy(
PolicyError::TransactionRefMismatch { .. }
))
));
}
#[test]
fn test_authorize_allows_matching_transaction_ref() {
let store = MandateStore::memory().unwrap();
let config = test_config();
let authorizer = Authorizer::new(store, config);
let tx_obj = serde_json::json!({
"merchant_id": "shop_123",
"amount_cents": 4999,
"currency": "EUR"
});
let tx_ref = compute_transaction_ref(&tx_obj).unwrap();
let mut mandate = test_mandate();
mandate.mandate_kind = MandateKind::Transaction;
mandate.tool_patterns = vec!["purchase_*".to_string()];
mandate.transaction_ref = Some(tx_ref);
let mut tool_call = test_tool_call("purchase_item");
tool_call.operation_class = OperationClass::Commit;
tool_call.transaction_object = Some(tx_obj);
let result = authorizer.authorize_and_consume(&mandate, &tool_call);
assert!(result.is_ok());
}
#[test]
fn test_authorize_rejects_wrong_audience() {
let store = MandateStore::memory().unwrap();
let config = test_config(); let authorizer = Authorizer::new(store, config);
let mut mandate = test_mandate();
mandate.audience = "other/app".to_string();
let tool_call = test_tool_call("search_products");
let result = authorizer.authorize_and_consume(&mandate, &tool_call);
assert!(matches!(
result,
Err(AuthorizeError::Policy(PolicyError::AudienceMismatch { .. }))
));
}
#[test]
fn test_authorize_rejects_untrusted_issuer() {
let store = MandateStore::memory().unwrap();
let config = test_config(); let authorizer = Authorizer::new(store, config);
let mut mandate = test_mandate();
mandate.issuer = "evil.attacker.com".to_string();
let tool_call = test_tool_call("search_products");
let result = authorizer.authorize_and_consume(&mandate, &tool_call);
assert!(matches!(
result,
Err(AuthorizeError::Policy(PolicyError::IssuerNotTrusted { .. }))
));
}
#[test]
fn test_authorize_rejects_revoked_mandate() {
let store = MandateStore::memory().unwrap();
let config = test_config();
let authorizer = Authorizer::new(store.clone(), config);
let mandate = test_mandate();
store
.upsert_revocation(&super::super::mandate_store::RevocationRecord {
mandate_id: mandate.mandate_id.clone(),
revoked_at: Utc::now() - chrono::Duration::minutes(5),
reason: Some("User requested".to_string()),
revoked_by: None,
source: None,
event_id: None,
})
.unwrap();
let tool_call = test_tool_call("search_products");
let result = authorizer.authorize_and_consume(&mandate, &tool_call);
assert!(
matches!(
result,
Err(AuthorizeError::Store(AuthzError::Revoked { .. }))
),
"Expected Revoked error, got {:?}",
result
);
}
#[test]
fn test_authorize_allows_if_revoked_in_future() {
let store = MandateStore::memory().unwrap();
let config = test_config();
let authorizer = Authorizer::new(store.clone(), config);
let mandate = test_mandate();
store
.upsert_revocation(&super::super::mandate_store::RevocationRecord {
mandate_id: mandate.mandate_id.clone(),
revoked_at: Utc::now() + chrono::Duration::hours(1),
reason: Some("Scheduled revocation".to_string()),
revoked_by: None,
source: None,
event_id: None,
})
.unwrap();
let tool_call = test_tool_call("search_products");
let result = authorizer.authorize_and_consume(&mandate, &tool_call);
assert!(result.is_ok(), "Should allow use before revoked_at");
}
}