1use chio_core::capability::CapabilityToken;
2pub use chio_kernel::capability_lineage::{
3 CapabilityLineageError, CapabilitySnapshot, StoredCapabilitySnapshot,
4};
5use rusqlite::{params, OptionalExtension, Row};
6
7use crate::receipt_store::SqliteReceiptStore;
8
9fn snapshot_from_row(row: &Row<'_>) -> rusqlite::Result<CapabilitySnapshot> {
10 Ok(CapabilitySnapshot {
11 capability_id: row.get::<_, String>(0)?,
12 subject_key: row.get::<_, String>(1)?,
13 issuer_key: row.get::<_, String>(2)?,
14 issued_at: row.get::<_, i64>(3)?.max(0) as u64,
15 expires_at: row.get::<_, i64>(4)?.max(0) as u64,
16 grants_json: row.get::<_, String>(5)?,
17 delegation_depth: row.get::<_, i64>(6)?.max(0) as u64,
18 parent_capability_id: row.get::<_, Option<String>>(7)?,
19 })
20}
21
22impl SqliteReceiptStore {
23 pub fn record_capability_snapshot(
32 &self,
33 token: &CapabilityToken,
34 parent_capability_id: Option<&str>,
35 ) -> Result<(), CapabilityLineageError> {
36 let grants_json = serde_json::to_string(&token.scope)?;
37 let subject_key = token.subject.to_hex();
38 let issuer_key = token.issuer.to_hex();
39
40 let delegation_depth: u64 = if let Some(parent_id) = parent_capability_id {
42 let parent_depth: Option<u64> = self
43 .connection()?
44 .query_row(
45 "SELECT delegation_depth FROM capability_lineage WHERE capability_id = ?1",
46 params![parent_id],
47 |row: &Row<'_>| row.get::<_, i64>(0),
48 )
49 .optional()?
50 .map(|d: i64| d.max(0) as u64);
51
52 parent_depth.map(|d| d.saturating_add(1)).unwrap_or(1)
53 } else {
54 0
55 };
56
57 self.connection()?.execute(
58 r#"
59 INSERT OR IGNORE INTO capability_lineage (
60 capability_id,
61 subject_key,
62 issuer_key,
63 issued_at,
64 expires_at,
65 grants_json,
66 delegation_depth,
67 parent_capability_id
68 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
69 "#,
70 params![
71 token.id,
72 subject_key,
73 issuer_key,
74 token.issued_at as i64,
75 token.expires_at as i64,
76 grants_json,
77 delegation_depth as i64,
78 parent_capability_id,
79 ],
80 )?;
81
82 Ok(())
83 }
84
85 pub fn upsert_capability_snapshot(
90 &mut self,
91 snapshot: &CapabilitySnapshot,
92 ) -> Result<(), CapabilityLineageError> {
93 self.connection()?.execute(
94 r#"
95 INSERT INTO capability_lineage (
96 capability_id,
97 subject_key,
98 issuer_key,
99 issued_at,
100 expires_at,
101 grants_json,
102 delegation_depth,
103 parent_capability_id
104 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
105 ON CONFLICT(capability_id) DO UPDATE SET
106 subject_key = excluded.subject_key,
107 issuer_key = excluded.issuer_key,
108 issued_at = excluded.issued_at,
109 expires_at = excluded.expires_at,
110 grants_json = excluded.grants_json,
111 delegation_depth = excluded.delegation_depth,
112 parent_capability_id = excluded.parent_capability_id
113 "#,
114 params![
115 snapshot.capability_id,
116 snapshot.subject_key,
117 snapshot.issuer_key,
118 snapshot.issued_at as i64,
119 snapshot.expires_at as i64,
120 snapshot.grants_json,
121 snapshot.delegation_depth as i64,
122 snapshot.parent_capability_id,
123 ],
124 )?;
125 Ok(())
126 }
127
128 pub fn get_lineage(
132 &self,
133 capability_id: &str,
134 ) -> Result<Option<CapabilitySnapshot>, CapabilityLineageError> {
135 let row = self
136 .connection()?
137 .query_row(
138 r#"
139 SELECT
140 capability_id,
141 subject_key,
142 issuer_key,
143 issued_at,
144 expires_at,
145 grants_json,
146 delegation_depth,
147 parent_capability_id
148 FROM capability_lineage
149 WHERE capability_id = ?1
150 "#,
151 params![capability_id],
152 snapshot_from_row,
153 )
154 .optional()?;
155
156 Ok(row)
157 }
158
159 pub fn get_delegation_chain(
168 &self,
169 capability_id: &str,
170 ) -> Result<Vec<CapabilitySnapshot>, CapabilityLineageError> {
171 let connection = self.connection()?;
172 let mut stmt = connection.prepare(
173 r#"
174 WITH RECURSIVE chain(
175 capability_id,
176 subject_key,
177 issuer_key,
178 issued_at,
179 expires_at,
180 grants_json,
181 delegation_depth,
182 parent_capability_id,
183 level
184 ) AS (
185 SELECT
186 capability_id,
187 subject_key,
188 issuer_key,
189 issued_at,
190 expires_at,
191 grants_json,
192 delegation_depth,
193 parent_capability_id,
194 0 AS level
195 FROM capability_lineage
196 WHERE capability_id = ?1
197
198 UNION ALL
199
200 SELECT
201 cl.capability_id,
202 cl.subject_key,
203 cl.issuer_key,
204 cl.issued_at,
205 cl.expires_at,
206 cl.grants_json,
207 cl.delegation_depth,
208 cl.parent_capability_id,
209 chain.level + 1
210 FROM capability_lineage cl
211 INNER JOIN chain ON cl.capability_id = chain.parent_capability_id
212 WHERE chain.level < 20
213 )
214 SELECT
215 capability_id,
216 subject_key,
217 issuer_key,
218 issued_at,
219 expires_at,
220 grants_json,
221 delegation_depth,
222 parent_capability_id
223 FROM chain
224 ORDER BY level DESC
225 "#,
226 )?;
227
228 let rows = stmt.query_map(params![capability_id], snapshot_from_row)?;
229
230 let mut chain = Vec::new();
231 for row in rows {
232 chain.push(row?);
233 }
234
235 Ok(chain)
236 }
237
238 pub fn list_capabilities_for_subject(
242 &self,
243 subject_key: &str,
244 ) -> Result<Vec<CapabilitySnapshot>, CapabilityLineageError> {
245 self.list_capability_snapshots(Some(subject_key), None)
246 }
247
248 pub fn list_capability_snapshots(
254 &self,
255 subject_key: Option<&str>,
256 issuer_key: Option<&str>,
257 ) -> Result<Vec<CapabilitySnapshot>, CapabilityLineageError> {
258 let connection = self.connection()?;
259 let mut stmt = connection.prepare(
260 r#"
261 SELECT
262 capability_id,
263 subject_key,
264 issuer_key,
265 issued_at,
266 expires_at,
267 grants_json,
268 delegation_depth,
269 parent_capability_id
270 FROM capability_lineage
271 WHERE (?1 IS NULL OR subject_key = ?1)
272 AND (?2 IS NULL OR issuer_key = ?2)
273 ORDER BY issued_at ASC, capability_id ASC
274 "#,
275 )?;
276
277 let rows = stmt.query_map(params![subject_key, issuer_key], snapshot_from_row)?;
278
279 let mut snapshots = Vec::new();
280 for row in rows {
281 snapshots.push(row?);
282 }
283
284 Ok(snapshots)
285 }
286
287 pub fn list_capability_snapshots_after_seq(
292 &self,
293 after_seq: u64,
294 limit: usize,
295 ) -> Result<Vec<StoredCapabilitySnapshot>, CapabilityLineageError> {
296 let connection = self.connection()?;
297 let mut stmt = connection.prepare(
298 r#"
299 SELECT
300 rowid,
301 capability_id,
302 subject_key,
303 issuer_key,
304 issued_at,
305 expires_at,
306 grants_json,
307 delegation_depth,
308 parent_capability_id
309 FROM capability_lineage
310 WHERE rowid > ?1
311 ORDER BY rowid ASC
312 LIMIT ?2
313 "#,
314 )?;
315
316 let rows = stmt.query_map(params![after_seq as i64, limit as i64], |row| {
317 Ok(StoredCapabilitySnapshot {
318 seq: row.get::<_, i64>(0)?.max(0) as u64,
319 snapshot: CapabilitySnapshot {
320 capability_id: row.get::<_, String>(1)?,
321 subject_key: row.get::<_, String>(2)?,
322 issuer_key: row.get::<_, String>(3)?,
323 issued_at: row.get::<_, i64>(4)?.max(0) as u64,
324 expires_at: row.get::<_, i64>(5)?.max(0) as u64,
325 grants_json: row.get::<_, String>(6)?,
326 delegation_depth: row.get::<_, i64>(7)?.max(0) as u64,
327 parent_capability_id: row.get::<_, Option<String>>(8)?,
328 },
329 })
330 })?;
331
332 let mut snapshots = Vec::new();
333 for row in rows {
334 snapshots.push(row?);
335 }
336
337 Ok(snapshots)
338 }
339}
340
341#[cfg(test)]
342#[allow(clippy::expect_used, clippy::unwrap_used)]
343mod tests {
344 use std::fs;
345 use std::time::{SystemTime, UNIX_EPOCH};
346
347 use chio_core::capability::{
348 CapabilityToken, CapabilityTokenBody, ChioScope, Operation, ToolGrant,
349 };
350 use chio_core::crypto::Keypair;
351 use rusqlite::params;
352
353 use crate::receipt_store::SqliteReceiptStore;
354
355 fn unique_db_path(prefix: &str) -> std::path::PathBuf {
356 let nonce = SystemTime::now()
357 .duration_since(UNIX_EPOCH)
358 .expect("time before epoch")
359 .as_nanos();
360 std::env::temp_dir().join(format!("{prefix}-{nonce}.sqlite3"))
361 }
362
363 fn make_token(
365 id: &str,
366 subject_kp: &Keypair,
367 issuer_kp: &Keypair,
368 issued_at: u64,
369 expires_at: u64,
370 ) -> CapabilityToken {
371 let body = CapabilityTokenBody {
372 id: id.to_string(),
373 issuer: issuer_kp.public_key(),
374 subject: subject_kp.public_key(),
375 scope: ChioScope {
376 grants: vec![ToolGrant {
377 server_id: "shell".to_string(),
378 tool_name: "bash".to_string(),
379 operations: vec![Operation::Invoke],
380 constraints: vec![],
381 max_invocations: None,
382 max_cost_per_invocation: None,
383 max_total_cost: None,
384 dpop_required: None,
385 }],
386 resource_grants: vec![],
387 prompt_grants: vec![],
388 },
389 issued_at,
390 expires_at,
391 delegation_chain: vec![],
392 };
393 CapabilityToken::sign(body, issuer_kp).expect("sign failed")
394 }
395
396 #[test]
397 fn record_and_get_lineage_returns_matching_fields() {
398 let path = unique_db_path("cl-persist");
399 let store = SqliteReceiptStore::open(&path).unwrap();
400
401 let subject_kp = Keypair::generate();
402 let issuer_kp = Keypair::generate();
403 let token = make_token("cap-001", &subject_kp, &issuer_kp, 1000, 2000);
404
405 store.record_capability_snapshot(&token, None).unwrap();
406
407 let snap = store.get_lineage("cap-001").unwrap().unwrap();
408 assert_eq!(snap.capability_id, "cap-001");
409 assert_eq!(snap.subject_key, subject_kp.public_key().to_hex());
410 assert_eq!(snap.issuer_key, issuer_kp.public_key().to_hex());
411 assert_eq!(snap.issued_at, 1000);
412 assert_eq!(snap.expires_at, 2000);
413 assert_eq!(snap.delegation_depth, 0);
414 assert!(snap.parent_capability_id.is_none());
415
416 let _ = fs::remove_file(path);
417 }
418
419 #[test]
420 fn record_capability_snapshot_is_idempotent() {
421 let path = unique_db_path("cl-idempotent");
422 let store = SqliteReceiptStore::open(&path).unwrap();
423
424 let subject_kp = Keypair::generate();
425 let issuer_kp = Keypair::generate();
426 let token = make_token("cap-idem-001", &subject_kp, &issuer_kp, 1000, 2000);
427
428 store.record_capability_snapshot(&token, None).unwrap();
430 store.record_capability_snapshot(&token, None).unwrap();
431
432 let connection = store.connection().unwrap();
434 let count: i64 = connection
435 .query_row(
436 "SELECT COUNT(*) FROM capability_lineage WHERE capability_id = ?1",
437 params!["cap-idem-001"],
438 |row| row.get(0),
439 )
440 .unwrap();
441 assert_eq!(count, 1);
442
443 let _ = fs::remove_file(path);
444 }
445
446 #[test]
447 fn grants_json_round_trips_without_field_loss() {
448 let path = unique_db_path("cl-json-rt");
449 let store = SqliteReceiptStore::open(&path).unwrap();
450
451 let subject_kp = Keypair::generate();
452 let issuer_kp = Keypair::generate();
453 let token = make_token("cap-json-001", &subject_kp, &issuer_kp, 1000, 2000);
454
455 store.record_capability_snapshot(&token, None).unwrap();
456
457 let snap = store.get_lineage("cap-json-001").unwrap().unwrap();
458 let round_tripped: ChioScope = serde_json::from_str(&snap.grants_json).unwrap();
459
460 assert_eq!(round_tripped.grants.len(), token.scope.grants.len());
461 assert_eq!(round_tripped.grants[0].server_id, "shell");
462 assert_eq!(round_tripped.grants[0].tool_name, "bash");
463
464 let _ = fs::remove_file(path);
465 }
466
467 #[test]
468 fn get_lineage_returns_none_for_missing_capability() {
469 let path = unique_db_path("cl-missing");
470 let store = SqliteReceiptStore::open(&path).unwrap();
471
472 let result = store.get_lineage("nonexistent-cap").unwrap();
473 assert!(result.is_none());
474
475 let _ = fs::remove_file(path);
476 }
477
478 #[test]
479 fn get_delegation_chain_returns_root_first_for_three_level_chain() {
480 let path = unique_db_path("cl-chain-3");
481 let store = SqliteReceiptStore::open(&path).unwrap();
482
483 let kp_root = Keypair::generate();
484 let kp_mid = Keypair::generate();
485 let kp_leaf = Keypair::generate();
486
487 let root = make_token("cap-root", &kp_root, &kp_root, 1000, 9000);
489 let parent = make_token("cap-parent", &kp_mid, &kp_root, 1100, 8000);
490 let child = make_token("cap-child", &kp_leaf, &kp_mid, 1200, 7000);
491
492 store.record_capability_snapshot(&root, None).unwrap();
493 store
494 .record_capability_snapshot(&parent, Some("cap-root"))
495 .unwrap();
496 store
497 .record_capability_snapshot(&child, Some("cap-parent"))
498 .unwrap();
499
500 let chain = store.get_delegation_chain("cap-child").unwrap();
502 assert_eq!(chain.len(), 3, "should have 3 entries in chain");
503 assert_eq!(chain[0].capability_id, "cap-root", "root should be first");
504 assert_eq!(
505 chain[1].capability_id, "cap-parent",
506 "parent should be second"
507 );
508 assert_eq!(chain[2].capability_id, "cap-child", "child should be last");
509
510 let _ = fs::remove_file(path);
511 }
512
513 #[test]
514 fn get_delegation_chain_returns_single_entry_for_root_capability() {
515 let path = unique_db_path("cl-chain-root");
516 let store = SqliteReceiptStore::open(&path).unwrap();
517
518 let kp = Keypair::generate();
519 let root = make_token("cap-solo", &kp, &kp, 1000, 9000);
520
521 store.record_capability_snapshot(&root, None).unwrap();
522
523 let chain = store.get_delegation_chain("cap-solo").unwrap();
524 assert_eq!(chain.len(), 1, "root has no parent -- only itself in chain");
525 assert_eq!(chain[0].capability_id, "cap-solo");
526
527 let _ = fs::remove_file(path);
528 }
529
530 #[test]
531 fn get_delegation_chain_enforces_max_depth_guard() {
532 let path = unique_db_path("cl-depth-guard");
533 let store = SqliteReceiptStore::open(&path).unwrap();
534
535 let kp = Keypair::generate();
537 let mut prev_id: Option<String> = None;
538 for i in 0..25usize {
539 let id = format!("cap-depth-{i:03}");
540 let token = make_token(&id, &kp, &kp, 1000 + i as u64, 9000);
541 store
542 .record_capability_snapshot(&token, prev_id.as_deref())
543 .unwrap();
544 prev_id = Some(id);
545 }
546
547 let chain = store.get_delegation_chain("cap-depth-024").unwrap();
549 assert!(
551 chain.len() <= 21,
552 "chain length {} exceeds max depth guard of 21",
553 chain.len()
554 );
555
556 let _ = fs::remove_file(path);
557 }
558
559 #[test]
560 fn capability_lineage_table_created_by_open() {
561 let path = unique_db_path("cl-table-exists");
562 let store = SqliteReceiptStore::open(&path).unwrap();
563
564 let connection = store.connection().unwrap();
566 let count: i64 = connection
567 .query_row("SELECT COUNT(*) FROM capability_lineage", [], |row| {
568 row.get(0)
569 })
570 .unwrap();
571 assert_eq!(count, 0, "table should exist and be empty");
572
573 let _ = fs::remove_file(path);
574 }
575
576 #[test]
577 fn subject_key_index_exists() {
578 let path = unique_db_path("cl-index-check");
579 let store = SqliteReceiptStore::open(&path).unwrap();
580
581 let connection = store.connection().unwrap();
583 let mut stmt = connection
584 .prepare("PRAGMA index_list(capability_lineage)")
585 .unwrap();
586 let index_names: Vec<String> = stmt
587 .query_map([], |row: &rusqlite::Row<'_>| row.get::<_, String>(1))
588 .unwrap()
589 .filter_map(|r: Result<String, _>| r.ok())
590 .collect();
591
592 assert!(
593 index_names
594 .iter()
595 .any(|n| n == "idx_capability_lineage_subject"),
596 "subject_key index not found; found: {index_names:?}"
597 );
598
599 let _ = fs::remove_file(path);
600 }
601}