Skip to main content

assay_core/runtime/
mandate_store.rs

1//! MandateStore: SQLite-backed mandate consumption tracking.
2//!
3//! Provides atomic, idempotent mandate consumption with:
4//! - Single-use / max_uses constraint enforcement
5//! - Nonce replay prevention
6//! - tool_call_id idempotency
7
8use chrono::{DateTime, Utc};
9use rusqlite::Connection;
10use std::path::Path;
11use std::sync::{Arc, Mutex};
12use thiserror::Error;
13
14#[path = "mandate_store_next/mod.rs"]
15mod mandate_store_next;
16
17/// Authorization receipt returned after successful consumption.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct AuthzReceipt {
20    pub mandate_id: String,
21    pub use_id: String,
22    pub use_count: u32,
23    pub consumed_at: DateTime<Utc>,
24    pub tool_call_id: String,
25    /// True if this was a new consumption, false if idempotent retry.
26    /// Used to avoid emitting duplicate lifecycle events on retries.
27    pub was_new: bool,
28}
29
30/// Authorization errors.
31#[derive(Debug, Error, PartialEq, Eq)]
32#[non_exhaustive]
33pub enum AuthzError {
34    #[error("Mandate not found: {mandate_id}")]
35    MandateNotFound { mandate_id: String },
36
37    #[error("Mandate already used (single_use=true)")]
38    AlreadyUsed,
39
40    #[error("Max uses exceeded: {current} > {max}")]
41    MaxUsesExceeded { max: u32, current: u32 },
42
43    #[error("Nonce replay detected: {nonce}")]
44    NonceReplay { nonce: String },
45
46    #[error("Mandate metadata conflict for {mandate_id}: stored {field} differs")]
47    MandateConflict { mandate_id: String, field: String },
48
49    #[error("Invalid mandate constraints: single_use=true with max_uses={max_uses}")]
50    InvalidConstraints { max_uses: u32 },
51
52    #[error("Mandate revoked at {revoked_at}")]
53    Revoked { revoked_at: DateTime<Utc> },
54
55    #[error("Database error: {0}")]
56    Database(String),
57}
58
59impl From<rusqlite::Error> for AuthzError {
60    fn from(e: rusqlite::Error) -> Self {
61        AuthzError::Database(e.to_string())
62    }
63}
64
65/// Mandate metadata for upsert.
66#[derive(Debug, Clone)]
67pub struct MandateMetadata {
68    pub mandate_id: String,
69    pub mandate_kind: String,
70    pub audience: String,
71    pub issuer: String,
72    pub expires_at: Option<DateTime<Utc>>,
73    pub single_use: bool,
74    pub max_uses: Option<u32>,
75    pub canonical_digest: String,
76    pub key_id: String,
77}
78
79/// Parameters for consume_mandate.
80#[derive(Debug, Clone)]
81pub struct ConsumeParams<'a> {
82    pub mandate_id: &'a str,
83    pub tool_call_id: &'a str,
84    pub nonce: Option<&'a str>,
85    pub audience: &'a str,
86    pub issuer: &'a str,
87    pub tool_name: &'a str,
88    pub operation_class: &'a str,
89    pub source_run_id: Option<&'a str>,
90}
91
92/// SQLite-backed mandate store.
93#[derive(Clone)]
94pub struct MandateStore {
95    conn: Arc<Mutex<Connection>>,
96}
97
98impl MandateStore {
99    /// Open a file-backed store.
100    pub fn open(path: &Path) -> Result<Self, AuthzError> {
101        mandate_store_next::schema::open_impl(path)
102    }
103
104    /// Create an in-memory store (for testing).
105    pub fn memory() -> Result<Self, AuthzError> {
106        mandate_store_next::schema::memory_impl()
107    }
108
109    /// Create store from existing connection (for multi-connection tests).
110    pub fn from_connection(conn: Connection) -> Result<Self, AuthzError> {
111        mandate_store_next::schema::from_connection_impl(conn)
112    }
113
114    /// Upsert mandate metadata. Idempotent for same content, errors on conflict.
115    pub fn upsert_mandate(&self, meta: &MandateMetadata) -> Result<(), AuthzError> {
116        mandate_store_next::upsert::upsert_mandate_impl(self, meta)
117    }
118
119    /// Consume mandate atomically. Idempotent on tool_call_id.
120    pub fn consume_mandate(&self, params: &ConsumeParams<'_>) -> Result<AuthzReceipt, AuthzError> {
121        mandate_store_next::txn::consume_mandate_in_txn_impl(self, params)
122    }
123
124    fn consume_mandate_inner(
125        &self,
126        conn: &Connection,
127        params: &ConsumeParams<'_>,
128    ) -> Result<AuthzReceipt, AuthzError> {
129        mandate_store_next::consume::consume_mandate_inner_impl(conn, params)
130    }
131
132    /// Get current use count for a mandate (for testing/debugging).
133    pub fn get_use_count(&self, mandate_id: &str) -> Result<Option<u32>, AuthzError> {
134        mandate_store_next::stats::get_use_count_impl(self, mandate_id)
135    }
136
137    /// Count use records for a mandate (for testing).
138    pub fn count_uses(&self, mandate_id: &str) -> Result<u32, AuthzError> {
139        mandate_store_next::stats::count_uses_impl(self, mandate_id)
140    }
141
142    /// Check if nonce exists (for testing).
143    pub fn nonce_exists(
144        &self,
145        audience: &str,
146        issuer: &str,
147        nonce: &str,
148    ) -> Result<bool, AuthzError> {
149        mandate_store_next::stats::nonce_exists_impl(self, audience, issuer, nonce)
150    }
151
152    // =========================================================================
153    // Revocation API (P0-A)
154    // =========================================================================
155
156    /// Insert or update a revocation record.
157    ///
158    /// Idempotent: re-inserting with same mandate_id updates the record.
159    pub fn upsert_revocation(&self, r: &RevocationRecord) -> Result<(), AuthzError> {
160        mandate_store_next::revocation::upsert_revocation_impl(self, r)
161    }
162
163    /// Get revoked_at timestamp for a mandate (if revoked).
164    pub fn get_revoked_at(&self, mandate_id: &str) -> Result<Option<DateTime<Utc>>, AuthzError> {
165        mandate_store_next::revocation::get_revoked_at_impl(self, mandate_id)
166    }
167
168    /// Check if a mandate is revoked (convenience method).
169    pub fn is_revoked(&self, mandate_id: &str) -> Result<bool, AuthzError> {
170        mandate_store_next::revocation::is_revoked_impl(self, mandate_id)
171    }
172}
173
174/// Revocation record for upsert.
175#[derive(Debug, Clone)]
176pub struct RevocationRecord {
177    pub mandate_id: String,
178    pub revoked_at: DateTime<Utc>,
179    pub reason: Option<String>,
180    pub revoked_by: Option<String>,
181    pub source: Option<String>,
182    pub event_id: Option<String>,
183}
184
185/// Compute deterministic use_id per SPEC-Mandate-v1.0.4 ยง7.4.
186///
187/// ```text
188/// use_id = "sha256:" + hex(SHA256(mandate_id + ":" + tool_call_id + ":" + use_count))
189/// ```
190pub fn compute_use_id(mandate_id: &str, tool_call_id: &str, use_count: u32) -> String {
191    mandate_store_next::stats::compute_use_id_impl(mandate_id, tool_call_id, use_count)
192}
193
194#[cfg(test)]
195#[path = "mandate_store_next/tests.rs"]
196mod tests;