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::{AuthzError, AuthzReceipt, MandateStore};
13use chrono::{DateTime, Utc};
14use thiserror::Error;
15
16#[path = "authorizer_internal/mod.rs"]
17mod authorizer_internal;
18
19/// See `runtime::glob_matches_impl` for why this is a re-export and not a wider `mod`.
20#[cfg(test)]
21pub(crate) use authorizer_internal::policy::glob_matches_impl;
22
23/// Default clock skew tolerance in seconds.
24pub const DEFAULT_CLOCK_SKEW_SECONDS: i64 = 30;
25
26/// Authorization configuration.
27#[derive(Debug, Clone)]
28pub struct AuthzConfig {
29    /// Clock skew tolerance for validity checks.
30    pub clock_skew_seconds: i64,
31    /// Expected audience (must match mandate.context.audience).
32    pub expected_audience: String,
33    /// Trusted issuers (mandate.context.issuer must be in this list).
34    pub trusted_issuers: Vec<String>,
35}
36
37impl Default for AuthzConfig {
38    fn default() -> Self {
39        Self {
40            clock_skew_seconds: DEFAULT_CLOCK_SKEW_SECONDS,
41            expected_audience: String::new(),
42            trusted_issuers: Vec::new(),
43        }
44    }
45}
46
47/// Operation class for tool classification.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
49pub enum OperationClass {
50    Read = 0,
51    Write = 1,
52    Commit = 2,
53}
54
55impl OperationClass {
56    pub fn as_str(&self) -> &'static str {
57        match self {
58            Self::Read => "read",
59            Self::Write => "write",
60            Self::Commit => "commit",
61        }
62    }
63}
64
65/// Mandate kind.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum MandateKind {
68    Intent,
69    Transaction,
70}
71
72impl MandateKind {
73    pub fn as_str(&self) -> &'static str {
74        match self {
75            Self::Intent => "intent",
76            Self::Transaction => "transaction",
77        }
78    }
79
80    /// Returns the maximum operation class this mandate kind allows.
81    pub fn max_operation_class(&self) -> OperationClass {
82        match self {
83            Self::Intent => OperationClass::Write, // intent allows read, write
84            Self::Transaction => OperationClass::Commit, // transaction allows all
85        }
86    }
87}
88
89/// Mandate data for authorization (extracted from signed mandate).
90#[derive(Debug, Clone)]
91pub struct MandateData {
92    pub mandate_id: String,
93    pub mandate_kind: MandateKind,
94    pub audience: String,
95    pub issuer: String,
96    pub tool_patterns: Vec<String>,
97    pub operation_class: Option<OperationClass>,
98    pub transaction_ref: Option<String>,
99    pub not_before: Option<DateTime<Utc>>,
100    pub expires_at: Option<DateTime<Utc>>,
101    pub single_use: bool,
102    pub max_uses: Option<u32>,
103    pub nonce: Option<String>,
104    pub canonical_digest: String,
105    pub key_id: String,
106}
107
108/// Tool call data for authorization.
109#[derive(Debug, Clone)]
110pub struct ToolCallData {
111    pub tool_call_id: String,
112    pub tool_name: String,
113    pub operation_class: OperationClass,
114    pub transaction_object: Option<serde_json::Value>,
115    pub source_run_id: Option<String>,
116}
117
118/// Policy-level authorization errors (before DB).
119#[derive(Debug, Error, PartialEq, Eq)]
120#[non_exhaustive]
121pub enum PolicyError {
122    #[error("Mandate expired: expires_at={expires_at}, now={now}")]
123    Expired {
124        expires_at: DateTime<Utc>,
125        now: DateTime<Utc>,
126    },
127
128    #[error("Mandate not yet valid: not_before={not_before}, now={now}")]
129    NotYetValid {
130        not_before: DateTime<Utc>,
131        now: DateTime<Utc>,
132    },
133
134    #[error("Tool '{tool}' not in mandate scope")]
135    ToolNotInScope { tool: String },
136
137    #[error("Mandate kind '{kind}' does not allow operation class '{op_class}'")]
138    KindMismatch { kind: String, op_class: String },
139
140    #[error("Audience mismatch: expected '{expected}', got '{actual}'")]
141    AudienceMismatch { expected: String, actual: String },
142
143    #[error("Issuer '{issuer}' not in trusted issuers")]
144    IssuerNotTrusted { issuer: String },
145
146    #[error("Missing transaction object for commit tool")]
147    MissingTransactionObject,
148
149    #[error("Transaction ref mismatch: expected '{expected}', got '{actual}'")]
150    TransactionRefMismatch { expected: String, actual: String },
151}
152
153/// Combined authorization error.
154#[derive(Debug, Error)]
155#[non_exhaustive]
156pub enum AuthorizeError {
157    #[error("Policy error: {0}")]
158    Policy(#[from] PolicyError),
159
160    #[error("Store error: {0}")]
161    Store(#[from] AuthzError),
162
163    #[error("Failed to compute transaction ref: {0}")]
164    TransactionRef(String),
165}
166
167/// Runtime authorizer.
168pub struct Authorizer {
169    store: MandateStore,
170    config: AuthzConfig,
171}
172
173impl Authorizer {
174    /// Create a new authorizer with the given store and config.
175    pub fn new(store: MandateStore, config: AuthzConfig) -> Self {
176        Self { store, config }
177    }
178
179    /// Authorize and consume a mandate for a tool call.
180    ///
181    /// Implements SPEC-Mandate-v1.0.3 §7 flow:
182    /// 1. Verify validity window
183    /// 2. Verify context (audience, issuer)
184    /// 3. Verify scope matches tool
185    /// 4. Verify mandate_kind matches operation_class
186    /// 5. Verify transaction_ref for commit tools
187    /// 6. Upsert mandate metadata
188    /// 7. Consume mandate atomically
189    pub fn authorize_and_consume(
190        &self,
191        mandate: &MandateData,
192        tool_call: &ToolCallData,
193    ) -> Result<AuthzReceipt, AuthorizeError> {
194        authorizer_internal::run::authorize_and_consume_impl(self, mandate, tool_call)
195    }
196
197    /// Like [`Self::authorize_and_consume`] but with an explicit `now` timestamp.
198    /// Use this in tests to avoid flaky clock-dependent assertions.
199    pub fn authorize_at(
200        &self,
201        now: DateTime<Utc>,
202        mandate: &MandateData,
203        tool_call: &ToolCallData,
204    ) -> Result<AuthzReceipt, AuthorizeError> {
205        authorizer_internal::run::authorize_at_impl(self, now, mandate, tool_call)
206    }
207}