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