1pub 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum ConfirmOutcome {
44 Chosen(GrantChoice),
45 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 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
98pub 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 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 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 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 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 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
227pub 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 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 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 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 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#[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>, }