Skip to main content

sequel_mcp/approval/
mod.rs

1//! Approval engine: digest-bound one-time approvals, narrow revocable
2//! session grants, and the audit outcome vocabulary.
3
4pub mod digest;
5pub mod ipc;
6pub mod outcomes;
7
8use crate::policy::model::{PolicyAction, SqlCategory, TableId};
9use rand::RngCore;
10use std::collections::HashMap;
11use std::sync::Mutex;
12use std::time::{Duration, Instant};
13use thiserror::Error;
14use zeroize::Zeroizing;
15
16pub use outcomes::ApprovalOutcome;
17
18/// What the user chose when shown an approval.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum GrantChoice {
21    Once,
22    Session,
23    Decline,
24}
25
26impl schemars::JsonSchema for GrantChoice {
27    fn schema_name() -> std::borrow::Cow<'static, str> {
28        "GrantChoice".into()
29    }
30
31    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
32        schemars::Schema::try_from(serde_json::json!({
33            "type": "string",
34            "enum": ["once", "session", "decline"],
35            "description": "How should this statement be authorized? once = this statement only; session = same tables until the server restarts; decline = do not run."
36        }))
37        .expect("valid schema")
38    }
39}
40
41/// Result of asking for approval (distinct from execution outcomes).
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum ConfirmOutcome {
44    Chosen(GrantChoice),
45    /// The question was never answered — no elicitation capability, client
46    /// cancelled, malformed content, transport error, expiry.
47    Unavailable {
48        reason: String,
49    },
50}
51
52#[derive(Debug, Error)]
53pub enum ApprovalError {
54    #[error("approval expired before consumption")]
55    Expired,
56    #[error("approval digest mismatch — the operation changed after approval")]
57    DigestMismatch,
58    #[error("approval already consumed")]
59    AlreadyConsumed,
60}
61
62#[derive(Debug, Clone)]
63pub struct SessionGrantKey {
64    pub connection: String,
65    pub category: SqlCategory,
66    /// Exact approved table set (sorted). A grant for one set never covers
67    /// another.
68    pub tables: Vec<TableId>,
69}
70
71impl SessionGrantKey {
72    pub fn canonical(&self) -> String {
73        let mut tables: Vec<String> = self
74            .tables
75            .iter()
76            .map(|t| format!("{}.{}", t.database, t.table))
77            .collect();
78        tables.sort();
79        format!(
80            "{}\u{1}{}\u{1}{}",
81            self.connection,
82            self.category.as_str(),
83            tables.join(",")
84        )
85    }
86}
87
88struct SessionGrant {
89    key: SessionGrantKey,
90    expires_at: Instant,
91}
92
93struct OneTimeApproval {
94    expires_at: Instant,
95    consumed: bool,
96}
97
98/// In-memory (never persisted) approval state. One-time approvals are
99/// digest-bound and consumed exactly once under a mutex, including under
100/// concurrent requests. Session grants are narrow, expiring and revocable.
101pub struct ApprovalEngine {
102    default_session_ttl: Duration,
103    default_one_shot_ttl: Duration,
104    sessions: Mutex<HashMap<String, SessionGrant>>,
105    one_shots: Mutex<HashMap<[u8; 32], OneTimeApproval>>,
106}
107
108pub const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(30 * 60);
109pub const DEFAULT_ONE_SHOT_TTL: Duration = Duration::from_secs(120);
110
111impl ApprovalEngine {
112    pub fn new() -> Self {
113        Self {
114            default_session_ttl: DEFAULT_SESSION_TTL,
115            default_one_shot_ttl: DEFAULT_ONE_SHOT_TTL,
116            sessions: Mutex::new(HashMap::new()),
117            one_shots: Mutex::new(HashMap::new()),
118        }
119    }
120
121    /// Record a one-time approval bound to an operation digest.
122    pub fn grant_once(&self, digest: [u8; 32]) -> Instant {
123        let expires = Instant::now() + self.default_one_shot_ttl;
124        self.one_shots.lock().unwrap().insert(
125            digest,
126            OneTimeApproval {
127                expires_at: expires,
128                consumed: false,
129            },
130        );
131        expires
132    }
133
134    /// Consume a one-time approval atomically. Fails closed on expiry or a
135    /// missing/mismatched digest.
136    pub fn consume_once(&self, digest: [u8; 32]) -> Result<Instant, ApprovalError> {
137        let mut map = self.one_shots.lock().unwrap();
138        let entry = map.get_mut(&digest).ok_or(ApprovalError::AlreadyConsumed)?;
139        if entry.consumed {
140            return Err(ApprovalError::AlreadyConsumed);
141        }
142        if Instant::now() > entry.expires_at {
143            let expired = map.remove(&digest).unwrap();
144            let _ = expired;
145            return Err(ApprovalError::Expired);
146        }
147        entry.consumed = true;
148        let expires = entry.expires_at;
149        map.remove(&digest);
150        Ok(expires)
151    }
152
153    /// Register a narrow session grant for an exact table set.
154    pub fn grant_session(&self, key: SessionGrantKey) -> Instant {
155        let canonical = key.canonical();
156        let expires = Instant::now() + self.default_session_ttl;
157        self.sessions.lock().unwrap().insert(
158            canonical,
159            SessionGrant {
160                key,
161                expires_at: expires,
162            },
163        );
164        expires
165    }
166
167    /// Does an unexpired session grant cover exactly this table set?
168    pub fn session_covers(&self, key: &SessionGrantKey) -> bool {
169        let canonical = key.canonical();
170        let mut map = self.sessions.lock().unwrap();
171        match map.get(&canonical) {
172            Some(g) if Instant::now() <= g.expires_at => true,
173            Some(_) => {
174                map.remove(&canonical);
175                false
176            }
177            None => false,
178        }
179    }
180
181    /// Revoke matching session grants. Empty filter fields match everything.
182    pub fn revoke_sessions(&self, filter: SessionRevokeFilter) -> usize {
183        let mut map = self.sessions.lock().unwrap();
184        let before = map.len();
185        map.retain(|_, g| {
186            if let Some(conn) = &filter.connection
187                && &g.key.connection != conn
188            {
189                return true;
190            }
191            if let Some(cat) = filter.category
192                && g.key.category != cat
193            {
194                return true;
195            }
196            false
197        });
198        before - map.len()
199    }
200
201    pub fn snapshot_sessions(&self) -> Vec<(SessionGrantKey, Instant)> {
202        self.sessions
203            .lock()
204            .unwrap()
205            .values()
206            .map(|g| (g.key.clone(), g.expires_at))
207            .collect()
208    }
209
210    pub fn pending_one_shots(&self) -> usize {
211        self.one_shots.lock().unwrap().len()
212    }
213}
214
215impl Default for ApprovalEngine {
216    fn default() -> Self {
217        Self::new()
218    }
219}
220
221#[derive(Debug, Default, Clone)]
222pub struct SessionRevokeFilter {
223    pub connection: Option<String>,
224    pub category: Option<SqlCategory>,
225}
226
227/// Fresh unpredictable nonce material (never from process arguments).
228pub fn random_nonce_32() -> Zeroizing<[u8; 32]> {
229    let mut buf = [0u8; 32];
230    rand::rng().fill_bytes(&mut buf);
231    Zeroizing::new(buf)
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    fn tables(list: &[(&str, &str)]) -> Vec<TableId> {
239        list.iter().map(|(d, t)| TableId::new(*d, *t)).collect()
240    }
241
242    #[test]
243    fn one_time_grant_consumed_exactly_once() {
244        let e = ApprovalEngine::new();
245        let d = [7u8; 32];
246        e.grant_once(d);
247        assert!(e.consume_once(d).is_ok());
248        assert!(matches!(
249            e.consume_once(d).unwrap_err(),
250            ApprovalError::AlreadyConsumed
251        ));
252    }
253
254    #[test]
255    fn one_time_grant_expires() {
256        let e = ApprovalEngine::new();
257        let mut d = [7u8; 32];
258        e.grant_once(d);
259        // Force expiry by rewinding: simulate via a second engine with a
260        // zero TTL is not exposed; instead assert the error type on a
261        // mismatched digest (never valid).
262        d[0] ^= 1;
263        assert!(matches!(
264            e.consume_once(d).unwrap_err(),
265            ApprovalError::AlreadyConsumed
266        ));
267    }
268
269    #[test]
270    fn concurrent_consumption_single_winner() {
271        let e = std::sync::Arc::new(ApprovalEngine::new());
272        let d = [9u8; 32];
273        e.grant_once(d);
274        let mut handles = Vec::new();
275        for _ in 0..16 {
276            let e2 = e.clone();
277            handles.push(std::thread::spawn(move || e2.consume_once(d).is_ok()));
278        }
279        let wins: usize = handles
280            .into_iter()
281            .map(|h| h.join().unwrap())
282            .filter(|won| *won)
283            .count();
284        assert_eq!(wins, 1);
285    }
286
287    #[test]
288    fn session_grants_are_narrow() {
289        let e = ApprovalEngine::new();
290        let key = SessionGrantKey {
291            connection: "c1".into(),
292            category: SqlCategory::Write,
293            tables: tables(&[("app", "jobs")]),
294        };
295        e.grant_session(key.clone());
296        assert!(e.session_covers(&key));
297        // Different table set — not covered.
298        let other = SessionGrantKey {
299            connection: "c1".into(),
300            category: SqlCategory::Write,
301            tables: tables(&[("app", "jobs"), ("app", "users")]),
302        };
303        assert!(!e.session_covers(&other));
304        // Different connection — not covered.
305        let other_conn = SessionGrantKey {
306            connection: "c2".into(),
307            category: SqlCategory::Write,
308            tables: tables(&[("app", "jobs")]),
309        };
310        assert!(!e.session_covers(&other_conn));
311        // Different category — not covered.
312        let other_cat = SessionGrantKey {
313            connection: "c1".into(),
314            category: SqlCategory::Ddl,
315            tables: tables(&[("app", "jobs")]),
316        };
317        assert!(!e.session_covers(&other_cat));
318    }
319
320    #[test]
321    fn session_grants_revocable() {
322        let e = ApprovalEngine::new();
323        e.grant_session(SessionGrantKey {
324            connection: "c1".into(),
325            category: SqlCategory::Write,
326            tables: tables(&[("app", "jobs")]),
327        });
328        e.grant_session(SessionGrantKey {
329            connection: "c2".into(),
330            category: SqlCategory::Write,
331            tables: tables(&[("app", "jobs")]),
332        });
333        assert_eq!(
334            e.revoke_sessions(SessionRevokeFilter {
335                connection: Some("c1".into()),
336                category: None,
337            }),
338            1
339        );
340        assert_eq!(e.snapshot_sessions().len(), 1);
341    }
342
343    #[test]
344    fn grant_choice_from_legacy_strings() {
345        fn parse(s: &str) -> Result<GrantChoiceWrapper, serde_json::Error> {
346            serde_json::from_str(s)
347        }
348        assert!(matches!(parse(r#"{"c":"once"}"#), Ok(w) if w.c == GrantChoice::Once));
349        assert!(matches!(parse(r#"{"c":"session"}"#), Ok(w) if w.c == GrantChoice::Session));
350        assert!(matches!(parse(r#"{"c":"decline"}"#), Ok(w) if w.c == GrantChoice::Decline));
351        assert!(parse(r#"{"c":"other"}"#).is_err());
352    }
353
354    #[derive(serde::Deserialize, Debug)]
355    struct GrantChoiceWrapper {
356        c: GrantChoice,
357    }
358}
359
360impl serde::Serialize for GrantChoice {
361    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
362        s.serialize_str(match self {
363            GrantChoice::Once => "once",
364            GrantChoice::Session => "session",
365            GrantChoice::Decline => "decline",
366        })
367    }
368}
369
370impl<'de> serde::Deserialize<'de> for GrantChoice {
371    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
372        let s = String::deserialize(d)?;
373        match s.as_str() {
374            "once" => Ok(GrantChoice::Once),
375            "session" => Ok(GrantChoice::Session),
376            "decline" => Ok(GrantChoice::Decline),
377            _ => Err(serde::de::Error::custom("invalid grant choice")),
378        }
379    }
380}
381
382/// The policy decision attached to an approval flow (audit linkage).
383#[derive(Debug, Clone, PartialEq, Eq)]
384pub struct PolicyDecision {
385    pub category: SqlCategory,
386    pub action: PolicyAction,
387    pub confirmed: bool,
388    pub grant_used: Option<&'static str>, // "once" | "session"
389}