1use std::fs;
15use std::path::Path;
16
17use chio_kernel::{
18 ApprovalDecision, ApprovalFilter, ApprovalOutcome, ApprovalRequest, ApprovalStore,
19 ApprovalStoreError, ResolvedApproval,
20};
21use r2d2::Pool;
22use r2d2_sqlite::SqliteConnectionManager;
23use rusqlite::{params, OptionalExtension};
24
25pub struct SqliteApprovalStore {
30 pool: Pool<SqliteConnectionManager>,
31}
32
33impl SqliteApprovalStore {
34 pub fn open(path: impl AsRef<Path>) -> Result<Self, ApprovalStoreError> {
37 let path = path.as_ref();
38 if let Some(parent) = path.parent() {
39 if !parent.as_os_str().is_empty() {
40 fs::create_dir_all(parent)
41 .map_err(|e| ApprovalStoreError::Backend(format!("create dir: {e}")))?;
42 }
43 }
44 let manager = SqliteConnectionManager::file(path);
45 let pool = Pool::builder()
46 .max_size(8)
47 .build(manager)
48 .map_err(|e| ApprovalStoreError::Backend(format!("pool build: {e}")))?;
49 let store = Self { pool };
50 store.run_migrations()?;
51 Ok(store)
52 }
53
54 pub fn open_in_memory() -> Result<Self, ApprovalStoreError> {
56 let manager = SqliteConnectionManager::memory();
57 let pool = Pool::builder()
58 .max_size(1)
59 .build(manager)
60 .map_err(|e| ApprovalStoreError::Backend(format!("pool build: {e}")))?;
61 let store = Self { pool };
62 store.run_migrations()?;
63 Ok(store)
64 }
65
66 fn run_migrations(&self) -> Result<(), ApprovalStoreError> {
67 let conn = self
68 .pool
69 .get()
70 .map_err(|e| ApprovalStoreError::Backend(format!("pool get: {e}")))?;
71 conn.execute_batch(
72 r#"
73 PRAGMA journal_mode = WAL;
74 PRAGMA synchronous = FULL;
75 PRAGMA busy_timeout = 5000;
76 PRAGMA foreign_keys = ON;
77
78 CREATE TABLE IF NOT EXISTS chio_hitl_pending (
79 approval_id TEXT PRIMARY KEY,
80 policy_id TEXT NOT NULL,
81 subject_id TEXT NOT NULL,
82 tool_server TEXT NOT NULL,
83 tool_name TEXT NOT NULL,
84 parameter_hash TEXT NOT NULL,
85 expires_at INTEGER NOT NULL,
86 created_at INTEGER NOT NULL,
87 payload TEXT NOT NULL
88 );
89 CREATE INDEX IF NOT EXISTS idx_chio_hitl_pending_subject
90 ON chio_hitl_pending(subject_id);
91 CREATE INDEX IF NOT EXISTS idx_chio_hitl_pending_expires
92 ON chio_hitl_pending(expires_at);
93
94 CREATE TABLE IF NOT EXISTS chio_hitl_resolved (
95 approval_id TEXT PRIMARY KEY,
96 policy_id TEXT NOT NULL,
97 subject_id TEXT NOT NULL,
98 outcome TEXT NOT NULL,
99 resolved_at INTEGER NOT NULL,
100 approver_hex TEXT NOT NULL,
101 token_id TEXT NOT NULL
102 );
103 CREATE INDEX IF NOT EXISTS idx_chio_hitl_resolved_counts
104 ON chio_hitl_resolved(subject_id, policy_id, outcome);
105
106 CREATE TABLE IF NOT EXISTS chio_hitl_consumed_tokens (
107 token_id TEXT NOT NULL,
108 parameter_hash TEXT NOT NULL,
109 consumed_at INTEGER NOT NULL,
110 PRIMARY KEY (token_id, parameter_hash)
111 );
112 "#,
113 )
114 .map_err(|e| ApprovalStoreError::Backend(format!("migration: {e}")))?;
115 Ok(())
116 }
117}
118
119fn serialize_payload(request: &ApprovalRequest) -> Result<String, ApprovalStoreError> {
120 serde_json::to_string(request).map_err(|e| ApprovalStoreError::Serialization(e.to_string()))
121}
122
123fn deserialize_payload(raw: &str) -> Result<ApprovalRequest, ApprovalStoreError> {
124 serde_json::from_str(raw).map_err(|e| ApprovalStoreError::Serialization(e.to_string()))
125}
126
127impl ApprovalStore for SqliteApprovalStore {
128 fn store_pending(&self, request: &ApprovalRequest) -> Result<(), ApprovalStoreError> {
129 let payload = serialize_payload(request)?;
130 let conn = self
131 .pool
132 .get()
133 .map_err(|e| ApprovalStoreError::Backend(format!("pool get: {e}")))?;
134 let inserted = conn
135 .execute(
136 r#"
137 INSERT INTO chio_hitl_pending (
138 approval_id, policy_id, subject_id, tool_server, tool_name,
139 parameter_hash, expires_at, created_at, payload
140 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
141 ON CONFLICT(approval_id) DO NOTHING
142 "#,
143 params![
144 request.approval_id,
145 request.policy_id,
146 request.subject_id,
147 request.tool_server,
148 request.tool_name,
149 request.parameter_hash,
150 request.expires_at as i64,
151 request.created_at as i64,
152 payload,
153 ],
154 )
155 .map_err(|e| ApprovalStoreError::Backend(format!("insert pending: {e}")))?;
156 if inserted == 0 {
157 let existing: Option<String> = conn
158 .query_row(
159 "SELECT payload FROM chio_hitl_pending WHERE approval_id = ?1",
160 params![request.approval_id],
161 |row| row.get::<_, String>(0),
162 )
163 .optional()
164 .map_err(|e| {
165 ApprovalStoreError::Backend(format!("select existing pending: {e}"))
166 })?;
167 match existing {
168 Some(existing) if existing == payload => Ok(()),
169 Some(_) => Err(ApprovalStoreError::Backend(format!(
170 "approval_id {} already exists with different payload",
171 request.approval_id
172 ))),
173 None => Err(ApprovalStoreError::Backend(format!(
174 "approval_id {} conflicted but existing row could not be loaded",
175 request.approval_id
176 ))),
177 }
178 } else {
179 Ok(())
180 }
181 }
182
183 fn get_pending(&self, id: &str) -> Result<Option<ApprovalRequest>, ApprovalStoreError> {
184 let conn = self
185 .pool
186 .get()
187 .map_err(|e| ApprovalStoreError::Backend(format!("pool get: {e}")))?;
188 let row: Option<String> = conn
189 .query_row(
190 "SELECT payload FROM chio_hitl_pending WHERE approval_id = ?1",
191 params![id],
192 |row| row.get::<_, String>(0),
193 )
194 .optional()
195 .map_err(|e| ApprovalStoreError::Backend(format!("select pending: {e}")))?;
196 match row {
197 Some(raw) => Ok(Some(deserialize_payload(&raw)?)),
198 None => Ok(None),
199 }
200 }
201
202 fn list_pending(
203 &self,
204 filter: &ApprovalFilter,
205 ) -> Result<Vec<ApprovalRequest>, ApprovalStoreError> {
206 let conn = self
207 .pool
208 .get()
209 .map_err(|e| ApprovalStoreError::Backend(format!("pool get: {e}")))?;
210 let mut sql = String::from("SELECT payload FROM chio_hitl_pending WHERE 1=1");
211 if filter.subject_id.is_some() {
212 sql.push_str(" AND subject_id = :subject_id");
213 }
214 if filter.tool_server.is_some() {
215 sql.push_str(" AND tool_server = :tool_server");
216 }
217 if filter.tool_name.is_some() {
218 sql.push_str(" AND tool_name = :tool_name");
219 }
220 if filter.not_expired_at.is_some() {
221 sql.push_str(" AND expires_at > :not_expired_at");
222 }
223 sql.push_str(" ORDER BY created_at ASC");
224 if filter.limit.is_some() {
225 sql.push_str(" LIMIT :limit");
226 }
227
228 let mut stmt = conn
229 .prepare(&sql)
230 .map_err(|e| ApprovalStoreError::Backend(format!("prepare list: {e}")))?;
231
232 let mut params_vec: Vec<(&str, Box<dyn rusqlite::ToSql>)> = Vec::new();
233 if let Some(s) = &filter.subject_id {
234 params_vec.push((":subject_id", Box::new(s.clone())));
235 }
236 if let Some(s) = &filter.tool_server {
237 params_vec.push((":tool_server", Box::new(s.clone())));
238 }
239 if let Some(s) = &filter.tool_name {
240 params_vec.push((":tool_name", Box::new(s.clone())));
241 }
242 if let Some(t) = &filter.not_expired_at {
243 params_vec.push((":not_expired_at", Box::new(*t as i64)));
244 }
245 if let Some(limit) = &filter.limit {
246 params_vec.push((":limit", Box::new(*limit as i64)));
247 }
248
249 let refs: Vec<(&str, &dyn rusqlite::ToSql)> = params_vec
250 .iter()
251 .map(|(name, value)| (*name, value.as_ref()))
252 .collect();
253
254 let rows = stmt
255 .query_map(refs.as_slice(), |row| row.get::<_, String>(0))
256 .map_err(|e| ApprovalStoreError::Backend(format!("query list: {e}")))?;
257
258 let mut out = Vec::new();
259 for row in rows {
260 let raw = row.map_err(|e| ApprovalStoreError::Backend(format!("row: {e}")))?;
261 out.push(deserialize_payload(&raw)?);
262 }
263 Ok(out)
264 }
265
266 fn resolve(&self, id: &str, decision: &ApprovalDecision) -> Result<(), ApprovalStoreError> {
267 let mut conn = self
268 .pool
269 .get()
270 .map_err(|e| ApprovalStoreError::Backend(format!("pool get: {e}")))?;
271 let tx = conn
272 .transaction()
273 .map_err(|e| ApprovalStoreError::Backend(format!("begin tx: {e}")))?;
274
275 let pending: Option<(String, String)> = tx
277 .query_row(
278 "SELECT policy_id, parameter_hash FROM chio_hitl_pending WHERE approval_id = ?1",
279 params![id],
280 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
281 )
282 .optional()
283 .map_err(|e| ApprovalStoreError::Backend(format!("select: {e}")))?;
284 let (policy_id, parameter_hash) = match pending {
285 Some(p) => p,
286 None => return Err(ApprovalStoreError::NotFound(id.to_string())),
287 };
288
289 let already: Option<i64> = tx
291 .query_row(
292 "SELECT 1 FROM chio_hitl_consumed_tokens WHERE token_id = ?1 AND parameter_hash = ?2",
293 params![decision.token.id, parameter_hash],
294 |row| row.get(0),
295 )
296 .optional()
297 .map_err(|e| ApprovalStoreError::Backend(format!("replay check: {e}")))?;
298 if already.is_some() {
299 return Err(ApprovalStoreError::Replay(id.to_string()));
300 }
301
302 let already_resolved: Option<i64> = tx
304 .query_row(
305 "SELECT 1 FROM chio_hitl_resolved WHERE approval_id = ?1",
306 params![id],
307 |row| row.get(0),
308 )
309 .optional()
310 .map_err(|e| ApprovalStoreError::Backend(format!("resolved check: {e}")))?;
311 if already_resolved.is_some() {
312 return Err(ApprovalStoreError::AlreadyResolved(id.to_string()));
313 }
314
315 let outcome = match decision.outcome {
316 ApprovalOutcome::Approved => "approved",
317 ApprovalOutcome::Denied => "denied",
318 };
319
320 tx.execute(
321 r#"INSERT INTO chio_hitl_resolved (
322 approval_id, policy_id, subject_id, outcome, resolved_at,
323 approver_hex, token_id
324 ) SELECT approval_id, policy_id, subject_id, ?2, ?3, ?4, ?5
325 FROM chio_hitl_pending WHERE approval_id = ?1"#,
326 params![
327 id,
328 outcome,
329 decision.received_at as i64,
330 decision.approver.to_hex(),
331 decision.token.id,
332 ],
333 )
334 .map_err(|e| ApprovalStoreError::Backend(format!("insert resolved: {e}")))?;
335
336 tx.execute(
337 "INSERT INTO chio_hitl_consumed_tokens (token_id, parameter_hash, consumed_at) VALUES (?1, ?2, ?3)",
338 params![decision.token.id, parameter_hash, decision.received_at as i64],
339 )
340 .map_err(|e| ApprovalStoreError::Backend(format!("insert consumed: {e}")))?;
341
342 tx.execute(
343 "DELETE FROM chio_hitl_pending WHERE approval_id = ?1",
344 params![id],
345 )
346 .map_err(|e| ApprovalStoreError::Backend(format!("delete pending: {e}")))?;
347
348 tx.commit()
349 .map_err(|e| ApprovalStoreError::Backend(format!("commit: {e}")))?;
350
351 let _ = policy_id;
354 Ok(())
355 }
356
357 fn count_approved(&self, subject_id: &str, policy_id: &str) -> Result<u64, ApprovalStoreError> {
358 let conn = self
359 .pool
360 .get()
361 .map_err(|e| ApprovalStoreError::Backend(format!("pool get: {e}")))?;
362 let count: i64 = conn
363 .query_row(
364 "SELECT COUNT(*) FROM chio_hitl_resolved WHERE subject_id = ?1 AND policy_id = ?2 AND outcome = 'approved'",
365 params![subject_id, policy_id],
366 |row| row.get(0),
367 )
368 .map_err(|e| ApprovalStoreError::Backend(format!("count: {e}")))?;
369 Ok(count.max(0) as u64)
370 }
371
372 fn record_consumed(
373 &self,
374 token_id: &str,
375 parameter_hash: &str,
376 now: u64,
377 ) -> Result<(), ApprovalStoreError> {
378 let conn = self
379 .pool
380 .get()
381 .map_err(|e| ApprovalStoreError::Backend(format!("pool get: {e}")))?;
382 let rows = conn.execute(
383 "INSERT OR IGNORE INTO chio_hitl_consumed_tokens (token_id, parameter_hash, consumed_at) VALUES (?1, ?2, ?3)",
384 params![token_id, parameter_hash, now as i64],
385 )
386 .map_err(|e| ApprovalStoreError::Backend(format!("insert consumed: {e}")))?;
387 if rows == 0 {
388 return Err(ApprovalStoreError::Replay(format!(
389 "token {token_id} already consumed"
390 )));
391 }
392 Ok(())
393 }
394
395 fn is_consumed(
396 &self,
397 token_id: &str,
398 parameter_hash: &str,
399 ) -> Result<bool, ApprovalStoreError> {
400 let conn = self
401 .pool
402 .get()
403 .map_err(|e| ApprovalStoreError::Backend(format!("pool get: {e}")))?;
404 let row: Option<i64> = conn
405 .query_row(
406 "SELECT 1 FROM chio_hitl_consumed_tokens WHERE token_id = ?1 AND parameter_hash = ?2",
407 params![token_id, parameter_hash],
408 |row| row.get(0),
409 )
410 .optional()
411 .map_err(|e| ApprovalStoreError::Backend(format!("is_consumed: {e}")))?;
412 Ok(row.is_some())
413 }
414
415 fn get_resolution(&self, id: &str) -> Result<Option<ResolvedApproval>, ApprovalStoreError> {
416 let conn = self
417 .pool
418 .get()
419 .map_err(|e| ApprovalStoreError::Backend(format!("pool get: {e}")))?;
420 let row: Option<(String, String, i64, String, String)> = conn
421 .query_row(
422 r#"SELECT approval_id, outcome, resolved_at, approver_hex, token_id
423 FROM chio_hitl_resolved WHERE approval_id = ?1"#,
424 params![id],
425 |row| {
426 Ok((
427 row.get::<_, String>(0)?,
428 row.get::<_, String>(1)?,
429 row.get::<_, i64>(2)?,
430 row.get::<_, String>(3)?,
431 row.get::<_, String>(4)?,
432 ))
433 },
434 )
435 .optional()
436 .map_err(|e| ApprovalStoreError::Backend(format!("get_resolution: {e}")))?;
437 match row {
438 Some((approval_id, outcome_str, resolved_at, approver_hex, token_id)) => {
439 let outcome = match outcome_str.as_str() {
440 "approved" => ApprovalOutcome::Approved,
441 "denied" => ApprovalOutcome::Denied,
442 other => {
443 return Err(ApprovalStoreError::Serialization(format!(
444 "unknown outcome: {other}"
445 )))
446 }
447 };
448 Ok(Some(ResolvedApproval {
449 approval_id,
450 outcome,
451 resolved_at: resolved_at.max(0) as u64,
452 approver_hex,
453 token_id,
454 }))
455 }
456 None => Ok(None),
457 }
458 }
459}
460
461#[cfg(test)]
462#[allow(clippy::expect_used, clippy::unwrap_used)]
463mod tests {
464 use super::*;
465 use chio_core::crypto::Keypair;
466
467 fn sample_request(id: &str, hash: &str) -> ApprovalRequest {
468 let subject = Keypair::generate();
469 let approver = Keypair::generate();
470 ApprovalRequest {
471 approval_id: id.into(),
472 policy_id: "policy-1".into(),
473 subject_id: "agent-1".into(),
474 capability_id: "cap-1".into(),
475 subject_public_key: Some(subject.public_key()),
476 tool_server: "srv".into(),
477 tool_name: "tool".into(),
478 action: "invoke".into(),
479 parameter_hash: hash.into(),
480 expires_at: 1_000_000,
481 callback_hint: None,
482 created_at: 42,
483 summary: "unit".into(),
484 governed_intent: None,
485 trusted_approvers: vec![approver.public_key()],
486 triggered_by: vec![],
487 }
488 }
489
490 #[test]
491 fn store_and_list_round_trip() {
492 let store = SqliteApprovalStore::open_in_memory().unwrap();
493 let r1 = sample_request("a-1", "h-1");
494 let r2 = sample_request("a-2", "h-2");
495 store.store_pending(&r1).unwrap();
496 store.store_pending(&r2).unwrap();
497
498 let all = store.list_pending(&ApprovalFilter::default()).unwrap();
499 assert_eq!(all.len(), 2);
500
501 let fetched = store.get_pending("a-1").unwrap().unwrap();
502 assert_eq!(fetched.approval_id, "a-1");
503 assert_eq!(fetched.parameter_hash, "h-1");
504 }
505
506 #[test]
507 fn duplicate_pending_insert_is_idempotent_only_when_payload_matches() {
508 let store = SqliteApprovalStore::open_in_memory().unwrap();
509 let original = sample_request("dup-1", "hash-a");
510 let identical = original.clone();
511 let mut mismatched = original.clone();
512 mismatched.parameter_hash = "hash-b".into();
513
514 store.store_pending(&original).unwrap();
515 store.store_pending(&identical).unwrap();
516
517 let err = store.store_pending(&mismatched).unwrap_err();
518 match err {
519 ApprovalStoreError::Backend(message) => {
520 assert!(message.contains("already exists with different payload"));
521 }
522 other => panic!("expected Backend mismatch error, got {other:?}"),
523 }
524
525 let fetched = store.get_pending("dup-1").unwrap().unwrap();
526 assert_eq!(fetched.parameter_hash, "hash-a");
527 }
528}