Skip to main content

chio_store_sqlite/
capability_lineage.rs

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    /// Record a capability snapshot at issuance time.
24    ///
25    /// Uses INSERT OR IGNORE for idempotency -- duplicate inserts are silently
26    /// dropped, preserving the first-writer-wins record.
27    ///
28    /// The `parent_capability_id` argument must refer to a capability already
29    /// present in the lineage table. If it is `Some` but the parent is missing,
30    /// the depth defaults to 1 (the minimum delegation depth).
31    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        // Compute delegation depth from parent if present.
41        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    /// Upsert an already-materialized capability snapshot.
86    ///
87    /// This is used by cluster replication so followers can converge on the
88    /// leader's lineage table without reconstructing full signed tokens.
89    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    /// Retrieve a single capability snapshot by its ID.
129    ///
130    /// Returns `None` if no snapshot exists for the given capability_id.
131    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    /// Walk the delegation chain for a capability, returning root-first ordering.
160    ///
161    /// Uses a WITH RECURSIVE CTE that walks from the given capability up through
162    /// its parent chain, tracking depth level. The ORDER BY level DESC produces
163    /// root-first ordering because the root has the highest level value.
164    ///
165    /// A max-depth guard (level < 20) prevents infinite recursion caused by
166    /// accidental cycles in the parent chain.
167    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    /// List all capability snapshots for a given subject key.
239    ///
240    /// Returns snapshots ordered newest-first by issued_at.
241    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    /// List capability snapshots filtered by subject and/or issuer.
249    ///
250    /// If both filters are present they are combined with AND semantics.
251    /// Results are ordered deterministically oldest-first to keep reputation
252    /// corpus construction stable across runs.
253    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    /// Return capability lineage snapshots added after a given local sequence.
288    ///
289    /// The sequence is the SQLite `rowid`, which is monotonic for this
290    /// append-only table and therefore suitable as a replication cursor.
291    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    /// Build a test CapabilityToken with the given ID and subject/issuer keypairs.
364    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        // Insert twice -- must not panic or error.
429        store.record_capability_snapshot(&token, None).unwrap();
430        store.record_capability_snapshot(&token, None).unwrap();
431
432        // Only one row should exist.
433        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        // root -> parent -> child
488        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        // Walking the chain from child should return root, parent, child (root-first).
501        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        // Build a chain of 25 entries (exceeds the level < 20 guard).
536        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        // Walking the chain from the deepest node should be capped at 21 entries (depth guard).
548        let chain = store.get_delegation_chain("cap-depth-024").unwrap();
549        // With level < 20, the recursion visits at most 21 distinct rows.
550        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        // Query the table to verify it exists; COUNT(*) fails if the table is absent.
565        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        // PRAGMA index_list returns rows for each index on the table.
582        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}