Skip to main content

chio_store_sqlite/
capability_lineage.rs

1use chio_core::capability::token::CapabilityToken;
2pub use chio_kernel::capability_lineage::{
3    CapabilityLineageError, CapabilitySnapshot, CapabilitySnapshotProvenance,
4    StoredCapabilitySnapshot,
5};
6use rusqlite::types::Type;
7use rusqlite::{params, OptionalExtension, Row};
8
9use crate::receipt_store::support::sqlite_i64;
10use crate::receipt_store::{SqliteReceiptStore, SqliteStoreConnection};
11
12pub(crate) fn snapshot_from_row(row: &Row<'_>) -> rusqlite::Result<CapabilitySnapshot> {
13    snapshot_from_row_with_boundary(row, true)
14}
15
16fn snapshot_from_row_with_boundary(
17    row: &Row<'_>,
18    allow_legacy: bool,
19) -> rusqlite::Result<CapabilitySnapshot> {
20    validate_snapshot_from_row(
21        CapabilitySnapshot {
22            capability_id: row.get::<_, String>(0)?,
23            subject_key: row.get::<_, String>(1)?,
24            issuer_key: row.get::<_, String>(2)?,
25            issued_at: non_negative_u64_from_column(row, 3, "issued_at")?,
26            expires_at: non_negative_u64_from_column(row, 4, "expires_at")?,
27            grants_json: row.get::<_, String>(5)?,
28            delegation_depth: non_negative_u64_from_column(row, 6, "delegation_depth")?,
29            parent_capability_id: row.get::<_, Option<String>>(7)?,
30            federated_parent_capability_id: row.get::<_, Option<String>>(8)?,
31            provenance: provenance_from_row(row, 9)?,
32            signed_capability: signed_capability_from_row(row, 10)?,
33        },
34        10,
35        allow_legacy,
36    )
37}
38
39pub(crate) fn validate_snapshot_from_row(
40    snapshot: CapabilitySnapshot,
41    signed_capability_column: usize,
42    allow_legacy: bool,
43) -> rusqlite::Result<CapabilitySnapshot> {
44    let validation = if allow_legacy {
45        snapshot.validate_for_local_read()
46    } else {
47        snapshot.validate_for_transport()
48    };
49    validation.map_err(|error| {
50        rusqlite::Error::FromSqlConversionFailure(
51            signed_capability_column,
52            Type::Text,
53            Box::new(std::io::Error::new(
54                std::io::ErrorKind::InvalidData,
55                error.to_string(),
56            )),
57        )
58    })?;
59    Ok(snapshot)
60}
61
62pub(crate) fn provenance_from_row(
63    row: &Row<'_>,
64    column: usize,
65) -> rusqlite::Result<CapabilitySnapshotProvenance> {
66    row.get::<_, String>(column)?.parse().map_err(|error| {
67        rusqlite::Error::FromSqlConversionFailure(
68            column,
69            Type::Text,
70            Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, error)),
71        )
72    })
73}
74
75pub(crate) fn signed_capability_from_row(
76    row: &Row<'_>,
77    column: usize,
78) -> rusqlite::Result<Option<CapabilityToken>> {
79    row.get::<_, Option<String>>(column)?
80        .map(|json| {
81            serde_json::from_str(&json).map_err(|error| {
82                rusqlite::Error::FromSqlConversionFailure(column, Type::Text, Box::new(error))
83            })
84        })
85        .transpose()
86}
87
88pub(crate) fn validate_snapshot_for_transport(
89    snapshot: &CapabilitySnapshot,
90) -> Result<(), chio_kernel::ReceiptStoreError> {
91    snapshot.validate_for_transport()
92}
93
94pub(crate) fn ensure_snapshots_compatible(
95    existing: &CapabilitySnapshot,
96    incoming: &CapabilitySnapshot,
97) -> Result<(), chio_kernel::ReceiptStoreError> {
98    let existing_scope: serde_json::Value = serde_json::from_str(&existing.grants_json)?;
99    let incoming_scope: serde_json::Value = serde_json::from_str(&incoming.grants_json)?;
100    let scalar_fields_match = existing.capability_id == incoming.capability_id
101        && existing.subject_key == incoming.subject_key
102        && existing.issuer_key == incoming.issuer_key
103        && existing.issued_at == incoming.issued_at
104        && existing.expires_at == incoming.expires_at
105        && existing_scope == incoming_scope
106        && existing.delegation_depth == incoming.delegation_depth
107        && existing.parent_capability_id == incoming.parent_capability_id
108        && existing.federated_parent_capability_id == incoming.federated_parent_capability_id;
109    if !scalar_fields_match {
110        return Err(chio_kernel::ReceiptStoreError::Conflict(format!(
111            "capability lineage {} conflicts with the persisted projection",
112            incoming.capability_id
113        )));
114    }
115    if existing.provenance != incoming.provenance
116        && existing.provenance != CapabilitySnapshotProvenance::LegacyProjection
117    {
118        return Err(chio_kernel::ReceiptStoreError::Conflict(format!(
119            "capability lineage {} has conflicting provenance",
120            incoming.capability_id
121        )));
122    }
123    match (
124        existing.signed_capability.as_ref(),
125        incoming.signed_capability.as_ref(),
126    ) {
127        (Some(_), None) => Err(chio_kernel::ReceiptStoreError::Conflict(format!(
128            "capability lineage {} cannot discard its signed capability",
129            incoming.capability_id
130        ))),
131        (Some(existing), Some(incoming))
132            if serde_json::to_value(existing)? != serde_json::to_value(incoming)? =>
133        {
134            Err(chio_kernel::ReceiptStoreError::Conflict(format!(
135                "capability lineage {} has conflicting signed capabilities",
136                incoming.id
137            )))
138        }
139        _ => Ok(()),
140    }
141}
142
143pub(crate) fn persist_compatible_snapshot(
144    transaction: &rusqlite::Transaction<'_>,
145    incoming: &CapabilitySnapshot,
146) -> Result<(), chio_kernel::ReceiptStoreError> {
147    let issued_at = sqlite_i64(incoming.issued_at, "capability lineage issued_at")?;
148    let expires_at = sqlite_i64(incoming.expires_at, "capability lineage expires_at")?;
149    let delegation_depth = sqlite_i64(
150        incoming.delegation_depth,
151        "capability lineage delegation_depth",
152    )?;
153    let existing = transaction
154        .query_row(
155            r#"
156            SELECT
157                capability_id,
158                subject_key,
159                issuer_key,
160                issued_at,
161                expires_at,
162                grants_json,
163                delegation_depth,
164                parent_capability_id,
165                federated_parent_capability_id,
166                provenance,
167                signed_capability_json
168            FROM capability_lineage
169            WHERE capability_id = ?1
170            "#,
171            params![incoming.capability_id],
172            snapshot_from_row,
173        )
174        .optional()?;
175    let signed_capability_json = incoming
176        .signed_capability
177        .as_ref()
178        .map(serde_json::to_string)
179        .transpose()?;
180    let provenance = incoming.provenance.as_str();
181
182    if let Some(existing) = existing.as_ref() {
183        ensure_snapshots_compatible(existing, incoming)?;
184        if existing.provenance == CapabilitySnapshotProvenance::LegacyProjection
185            && incoming.provenance != CapabilitySnapshotProvenance::LegacyProjection
186        {
187            let max_rowid: i64 = transaction.query_row(
188                "SELECT COALESCE(MAX(rowid), 0) FROM capability_lineage",
189                [],
190                |row| row.get(0),
191            )?;
192            let next_rowid = max_rowid.checked_add(1).ok_or_else(|| {
193                chio_kernel::ReceiptStoreError::Conflict(
194                    "capability lineage replication sequence is exhausted".to_string(),
195                )
196            })?;
197            let updated = transaction.execute(
198                r#"
199                UPDATE capability_lineage
200                SET signed_capability_json = ?2,
201                    provenance = ?3,
202                    rowid = ?4
203                WHERE capability_id = ?1
204                "#,
205                params![
206                    incoming.capability_id,
207                    signed_capability_json,
208                    provenance,
209                    next_rowid
210                ],
211            )?;
212            if updated != 1 {
213                return Err(chio_kernel::ReceiptStoreError::Conflict(format!(
214                    "capability lineage {} disappeared during signed-token upgrade",
215                    incoming.capability_id
216                )));
217            }
218        }
219        return Ok(());
220    }
221
222    transaction.execute(
223        r#"
224        INSERT INTO capability_lineage (
225            capability_id,
226            subject_key,
227            issuer_key,
228            issued_at,
229            expires_at,
230            grants_json,
231            delegation_depth,
232            parent_capability_id,
233            federated_parent_capability_id,
234            provenance,
235            signed_capability_json
236        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
237        "#,
238        params![
239            incoming.capability_id,
240            incoming.subject_key,
241            incoming.issuer_key,
242            issued_at,
243            expires_at,
244            incoming.grants_json,
245            delegation_depth,
246            incoming.parent_capability_id,
247            incoming.federated_parent_capability_id,
248            provenance,
249            signed_capability_json,
250        ],
251    )?;
252    Ok(())
253}
254
255pub(crate) fn non_negative_u64_from_column(
256    row: &Row<'_>,
257    column: usize,
258    field_name: &'static str,
259) -> rusqlite::Result<u64> {
260    let value = row.get::<_, i64>(column)?;
261    if value < 0 {
262        return Err(negative_lineage_integer_error(column, field_name, value));
263    }
264    Ok(value as u64)
265}
266
267fn negative_lineage_integer_error(
268    column: usize,
269    field_name: &'static str,
270    value: i64,
271) -> rusqlite::Error {
272    rusqlite::Error::FromSqlConversionFailure(
273        column,
274        Type::Integer,
275        Box::new(std::io::Error::new(
276            std::io::ErrorKind::InvalidData,
277            format!("capability_lineage.{field_name} must be non-negative, got {value}"),
278        )),
279    )
280}
281
282impl SqliteReceiptStore {
283    /// Record a capability snapshot at issuance time.
284    ///
285    /// Identical duplicate inserts are idempotent. Conflicting projections are
286    /// rejected before the first-writer-wins insert.
287    ///
288    /// The `parent_capability_id` argument must refer to a capability already
289    /// present in the lineage table. If it is `Some` but the parent is missing,
290    /// the depth defaults to 1 (the minimum delegation depth).
291    pub fn record_capability_snapshot(
292        &self,
293        token: &CapabilityToken,
294        parent_capability_id: Option<&str>,
295    ) -> Result<(), CapabilityLineageError> {
296        self.record_capability_snapshot_bounded(token, parent_capability_id, None)
297    }
298
299    /// Bounded variant of [`record_capability_snapshot`]: the writer round trip
300    /// is capped at `budget`. The evaluation hot path uses this so a wedged or
301    /// dying commit writer that passed the pre-dispatch liveness check but stalls
302    /// on this snapshot cannot hang the request inside an unbounded write.
303    pub fn record_capability_snapshot_with_timeout(
304        &self,
305        token: &CapabilityToken,
306        parent_capability_id: Option<&str>,
307        budget: std::time::Duration,
308    ) -> Result<(), CapabilityLineageError> {
309        self.record_capability_snapshot_bounded(token, parent_capability_id, Some(budget))
310    }
311
312    fn record_capability_snapshot_bounded(
313        &self,
314        token: &CapabilityToken,
315        parent_capability_id: Option<&str>,
316        budget: Option<std::time::Duration>,
317    ) -> Result<(), CapabilityLineageError> {
318        let grants_json = serde_json::to_string(&token.scope)?;
319        let subject_key = token.subject.to_hex();
320        let issuer_key = token.issuer.to_hex();
321
322        let capability_id = token.id.clone();
323        let issued_at = token.issued_at;
324        let expires_at = token.expires_at;
325        let signed_capability = token.clone();
326        validate_snapshot_for_transport(&CapabilitySnapshot {
327            capability_id: capability_id.clone(),
328            subject_key: subject_key.clone(),
329            issuer_key: issuer_key.clone(),
330            issued_at,
331            expires_at,
332            grants_json: grants_json.clone(),
333            delegation_depth: token.delegation_chain.len() as u64,
334            parent_capability_id: token
335                .delegation_chain
336                .last()
337                .map(|link| link.capability_id.clone()),
338            federated_parent_capability_id: None,
339            provenance: CapabilitySnapshotProvenance::SignedToken,
340            signed_capability: Some(signed_capability.clone()),
341        })?;
342        let parent_capability_id = parent_capability_id.map(ToString::to_string);
343        let job = move |connection: &mut SqliteStoreConnection| {
344            let transaction =
345                connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
346            // Resolve the parent's delegation depth on the writer connection,
347            // inside the bounded job, so the read shares the same wall-clock
348            // budget as the insert. Running it earlier on a reader-pool
349            // connection would leave a stalled or pool-starved lookup unbounded
350            // on the pre-dispatch hot path, defeating the snapshot deadline.
351            let delegation_depth: u64 = if let Some(parent_id) = parent_capability_id.as_deref() {
352                let parent_depth: Option<u64> = transaction
353                    .query_row(
354                        "SELECT delegation_depth FROM capability_lineage WHERE capability_id = ?1",
355                        params![parent_id],
356                        |row: &Row<'_>| non_negative_u64_from_column(row, 0, "delegation_depth"),
357                    )
358                    .optional()?;
359
360                parent_depth.map(|d| d.saturating_add(1)).unwrap_or(1)
361            } else {
362                0
363            };
364            let incoming = CapabilitySnapshot {
365                capability_id: capability_id.clone(),
366                subject_key: subject_key.clone(),
367                issuer_key: issuer_key.clone(),
368                issued_at,
369                expires_at,
370                grants_json: grants_json.clone(),
371                delegation_depth,
372                parent_capability_id: parent_capability_id.clone(),
373                federated_parent_capability_id: None,
374                provenance: CapabilitySnapshotProvenance::SignedToken,
375                signed_capability: Some(signed_capability),
376            };
377            validate_snapshot_for_transport(&incoming)?;
378            persist_compatible_snapshot(&transaction, &incoming)?;
379            transaction.commit()?;
380            Ok(())
381        };
382        match budget {
383            Some(budget) => self.writer_handle().run_write_with_timeout(job, budget)?,
384            None => self.writer_handle().run_write(job)?,
385        }
386
387        Ok(())
388    }
389
390    /// Upsert an already-materialized capability snapshot.
391    ///
392    /// This is used by cluster replication so followers can converge on the
393    /// leader's lineage table without reconstructing full signed tokens.
394    pub fn upsert_capability_snapshot(
395        &mut self,
396        snapshot: &CapabilitySnapshot,
397    ) -> Result<(), CapabilityLineageError> {
398        validate_snapshot_for_transport(snapshot)?;
399        let snapshot = snapshot.clone();
400        self.writer_handle().run_write(move |connection| {
401            let transaction =
402                connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
403            persist_compatible_snapshot(&transaction, &snapshot)?;
404            transaction.commit()?;
405            Ok(())
406        })?;
407        Ok(())
408    }
409
410    /// Retrieve a single capability snapshot by its ID.
411    ///
412    /// Returns `None` if no snapshot exists for the given capability_id.
413    pub fn get_lineage(
414        &self,
415        capability_id: &str,
416    ) -> Result<Option<CapabilitySnapshot>, CapabilityLineageError> {
417        let row = self
418            .connection()?
419            .query_row(
420                r#"
421                SELECT
422                    capability_id,
423                    subject_key,
424                    issuer_key,
425                    issued_at,
426                    expires_at,
427                    grants_json,
428                    delegation_depth,
429                    parent_capability_id,
430                    federated_parent_capability_id,
431                    provenance,
432                    signed_capability_json
433                FROM capability_lineage
434                WHERE capability_id = ?1
435                "#,
436                params![capability_id],
437                snapshot_from_row,
438            )
439            .optional()?;
440
441        Ok(row)
442    }
443
444    /// Walk the delegation chain for a capability, returning root-first ordering.
445    ///
446    /// Uses a WITH RECURSIVE CTE that walks from the given capability up through
447    /// its parent chain, tracking depth level. The ORDER BY level DESC produces
448    /// root-first ordering because the root has the highest level value.
449    ///
450    /// A max-depth guard (level < 20) prevents infinite recursion caused by
451    /// accidental cycles in the parent chain.
452    pub fn get_delegation_chain(
453        &self,
454        capability_id: &str,
455    ) -> Result<Vec<CapabilitySnapshot>, CapabilityLineageError> {
456        let connection = self.connection()?;
457        let mut stmt = connection.prepare(
458            r#"
459            WITH RECURSIVE chain(
460                capability_id,
461                subject_key,
462                issuer_key,
463                issued_at,
464                expires_at,
465                grants_json,
466                delegation_depth,
467                parent_capability_id,
468                federated_parent_capability_id,
469                provenance,
470                signed_capability_json,
471                level
472            ) AS (
473                SELECT
474                    capability_id,
475                    subject_key,
476                    issuer_key,
477                    issued_at,
478                    expires_at,
479                    grants_json,
480                    delegation_depth,
481                    parent_capability_id,
482                    federated_parent_capability_id,
483                    provenance,
484                    signed_capability_json,
485                    0 AS level
486                FROM capability_lineage
487                WHERE capability_id = ?1
488
489                UNION ALL
490
491                SELECT
492                    cl.capability_id,
493                    cl.subject_key,
494                    cl.issuer_key,
495                    cl.issued_at,
496                    cl.expires_at,
497                    cl.grants_json,
498                    cl.delegation_depth,
499                    cl.parent_capability_id,
500                    cl.federated_parent_capability_id,
501                    cl.provenance,
502                    cl.signed_capability_json,
503                    chain.level + 1
504                FROM capability_lineage cl
505                INNER JOIN chain ON cl.capability_id = chain.parent_capability_id
506                WHERE chain.level < 20
507            )
508            SELECT
509                capability_id,
510                subject_key,
511                issuer_key,
512                issued_at,
513                expires_at,
514                grants_json,
515                delegation_depth,
516                parent_capability_id,
517                federated_parent_capability_id,
518                provenance,
519                signed_capability_json
520            FROM chain
521            ORDER BY level DESC
522            "#,
523        )?;
524
525        let rows = stmt.query_map(params![capability_id], snapshot_from_row)?;
526
527        let mut chain = Vec::new();
528        for row in rows {
529            chain.push(row?);
530        }
531
532        Ok(chain)
533    }
534
535    /// List all capability snapshots for a given subject key.
536    ///
537    /// Returns snapshots ordered newest-first by issued_at.
538    pub fn list_capabilities_for_subject(
539        &self,
540        subject_key: &str,
541    ) -> Result<Vec<CapabilitySnapshot>, CapabilityLineageError> {
542        self.list_capability_snapshots(Some(subject_key), None)
543    }
544
545    /// List capability snapshots filtered by subject and/or issuer.
546    ///
547    /// If both filters are present they are combined with AND semantics.
548    /// Results are ordered deterministically oldest-first to keep reputation
549    /// corpus construction stable across runs.
550    pub fn list_capability_snapshots(
551        &self,
552        subject_key: Option<&str>,
553        issuer_key: Option<&str>,
554    ) -> Result<Vec<CapabilitySnapshot>, CapabilityLineageError> {
555        let connection = self.connection()?;
556        let mut stmt = connection.prepare(
557            r#"
558            SELECT
559                capability_id,
560                subject_key,
561                issuer_key,
562                issued_at,
563                expires_at,
564                grants_json,
565                delegation_depth,
566                parent_capability_id,
567                federated_parent_capability_id,
568                provenance,
569                signed_capability_json
570            FROM capability_lineage
571            WHERE (?1 IS NULL OR subject_key = ?1)
572              AND (?2 IS NULL OR issuer_key = ?2)
573            ORDER BY issued_at ASC, capability_id ASC
574            "#,
575        )?;
576
577        let rows = stmt.query_map(params![subject_key, issuer_key], snapshot_from_row)?;
578
579        let mut snapshots = Vec::new();
580        for row in rows {
581            snapshots.push(row?);
582        }
583
584        Ok(snapshots)
585    }
586
587    /// Return capability lineage snapshots added after a given local sequence.
588    ///
589    /// The sequence is the SQLite `rowid`, which is monotonic for this
590    /// append-only table and therefore suitable as a replication cursor.
591    pub fn list_capability_snapshots_after_seq(
592        &self,
593        after_seq: u64,
594        limit: usize,
595    ) -> Result<Vec<StoredCapabilitySnapshot>, CapabilityLineageError> {
596        let after_seq = sqlite_i64(after_seq, "capability lineage after_seq")?;
597        let limit = i64::try_from(limit).map_err(|_| {
598            chio_kernel::ReceiptStoreError::Conflict(format!(
599                "capability lineage limit value {limit} exceeds SQLite INTEGER range"
600            ))
601        })?;
602        let connection = self.connection()?;
603        let mut stmt = connection.prepare(
604            r#"
605            SELECT
606                rowid,
607                capability_id,
608                subject_key,
609                issuer_key,
610                issued_at,
611                expires_at,
612                grants_json,
613                delegation_depth,
614                parent_capability_id,
615                federated_parent_capability_id,
616                provenance,
617                signed_capability_json
618            FROM capability_lineage
619            WHERE rowid > ?1
620            ORDER BY rowid ASC
621            LIMIT ?2
622            "#,
623        )?;
624
625        let rows = stmt.query_map(params![after_seq, limit], |row| {
626            Ok(StoredCapabilitySnapshot {
627                seq: non_negative_u64_from_column(row, 0, "rowid")?,
628                snapshot: validate_snapshot_from_row(
629                    CapabilitySnapshot {
630                        capability_id: row.get::<_, String>(1)?,
631                        subject_key: row.get::<_, String>(2)?,
632                        issuer_key: row.get::<_, String>(3)?,
633                        issued_at: non_negative_u64_from_column(row, 4, "issued_at")?,
634                        expires_at: non_negative_u64_from_column(row, 5, "expires_at")?,
635                        grants_json: row.get::<_, String>(6)?,
636                        delegation_depth: non_negative_u64_from_column(row, 7, "delegation_depth")?,
637                        parent_capability_id: row.get::<_, Option<String>>(8)?,
638                        federated_parent_capability_id: row.get::<_, Option<String>>(9)?,
639                        provenance: provenance_from_row(row, 10)?,
640                        signed_capability: signed_capability_from_row(row, 11)?,
641                    },
642                    11,
643                    false,
644                )?,
645            })
646        })?;
647
648        let mut snapshots = Vec::new();
649        for row in rows {
650            snapshots.push(row?);
651        }
652
653        Ok(snapshots)
654    }
655
656    /// Highest lineage snapshot seq (capability_lineage rowid), or 0 when empty.
657    /// list_capability_snapshots_after_seq paginates on rowid, so the head is
658    /// MAX(rowid). Returns ReceiptStoreError so the cluster status
659    /// path converts it through the same CliError variant as the receipt heads.
660    pub fn max_lineage_seq(&self) -> Result<u64, chio_kernel::ReceiptStoreError> {
661        let connection = self.connection()?;
662        let seq: i64 = connection.query_row(
663            "SELECT COALESCE(MAX(rowid), 0) FROM capability_lineage",
664            [],
665            |row| row.get(0),
666        )?;
667        crate::receipt_store::sqlite_u64(seq, "capability lineage max rowid")
668    }
669}
670
671#[cfg(test)]
672#[allow(clippy::expect_used, clippy::unwrap_used)]
673#[path = "capability_lineage_tests.rs"]
674mod tests;