From 57ffdf1a8524101ba1692551b619ac256a7fecc8 Mon Sep 17 00:00:00 2001 From: dryrun Date: Mon, 26 Oct 2026 23:00:07 +0000 Subject: [PATCH 1/2] Add direction "issued" to POST /delegation/query List the delegations the calling key issued: one tinycloud.delegation/list capability on the invoker's own DID, no proof, delegator == invoker, keyset paging by id on idx_delegation_delegator, abilities and ancestor state loaded for the page's rows only. Advertised as delegation-issued-query-v1 in /info. Details: TC-2132 --- CHANGELOG.md | 3 + tinycloud-core/src/db.rs | 707 +++++++++++++++--- tinycloud-core/src/types/delegation_query.rs | 80 ++ tinycloud-node-server/src/routes/mod.rs | 9 + .../routes/tests/delegation_issued_query.rs | 694 +++++++++++++++++ 5 files changed, 1400 insertions(+), 93 deletions(-) create mode 100644 tinycloud-node-server/src/routes/tests/delegation_issued_query.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 18cae16a..32161b46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Changes +- `POST /delegation/query` accepts `direction: "issued"` to list the delegations the calling key issued. Details: TC-2132 + ## [1.25.7] - 2026-10-26 ### Changes diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index e6f0b12d..f1f6e2d8 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -39,7 +39,7 @@ use time::{format_description::well_known::Rfc3339, OffsetDateTime}; use tinycloud_auth::multihash_codetable::MultihashDigest; use tinycloud_auth::{ authorization::{make_invocation, EncodingError, InvocationOptions, TinyCloudDelegation}, - identity::{canonicalize_did, did_principal_matches}, + identity::{canonicalize_did, did_principal_matches, principal_did}, ipld_core::cid::Cid, resource::{Path, SpaceId}, }; @@ -114,6 +114,11 @@ pub enum AccountDelegationQueryError { Db(#[from] DbErr), #[error("delegation query invocation is not authorized")] Unauthorized, + /// The `issued` cursor is not one minted for this invoker and filters. + /// Also the answer to any malformed cursor, so a cursor from another + /// invoker or filter cannot be told apart from garbage. + #[error("invalid delegation query cursor")] + InvalidCursor, } /// TC-409: which trust mode an invocation entered `invoke_with_options_mode` @@ -851,6 +856,11 @@ where ) .await .map_err(|_| AccountDelegationQueryError::Unauthorized)?; + if matches!(query.direction, DelegationQueryDirection::Issued) { + let invoker = issued_query_principal(invocation) + .ok_or(AccountDelegationQueryError::Unauthorized)?; + return issued_delegations_page(&self.conn, &invoker, query).await; + } let principal = account_query_principal(&self.conn, invocation) .await? .ok_or(AccountDelegationQueryError::Unauthorized)?; @@ -880,99 +890,18 @@ where continue; } - let mut grouped: std::collections::BTreeMap< - String, - Vec<(String, crate::types::Caveats)>, - > = std::collections::BTreeMap::new(); - for ability in abilities { - grouped - .entry(ability.resource.to_string()) - .or_default() - .push((ability.ability.to_string(), ability.caveats)); - } - if let Some(space_filter) = query.space.as_deref() { - let matches_space = grouped.keys().any(|resource| { - resource - .parse::() - .ok() - .and_then(|resource| resource.space().cloned()) - .map(|space| { - space.to_string() == space_filter - || space.name().as_str() == space_filter - }) - .unwrap_or(false) - }); - if !matches_space { - continue; - } - } - let resources = grouped - .into_iter() - .map(|(resource, mut entries)| { - entries.sort_by(|left, right| { - left.0.cmp(&right.0).then_with(|| { - serde_json::to_string(&left.1) - .unwrap_or_default() - .cmp(&serde_json::to_string(&right.1).unwrap_or_default()) - }) - }); - DelegationResource { - resource, - actions: entries.iter().map(|entry| entry.0.clone()).collect(), - caveats: entries.into_iter().map(|entry| entry.1).collect(), - } - }) - .collect(); - - let lifecycle = ancestor_state.lifecycle(delegation.id, now)?; - let status_matches = match query.status { - None => true, - Some(DelegationQueryStatus::Active) => lifecycle.status == "active", - Some(DelegationQueryStatus::Pending) => lifecycle.status == "pending", - Some(DelegationQueryStatus::Expired) => lifecycle.status == "expired", - Some(DelegationQueryStatus::Revoked) => { - matches!(lifecycle.status, "revoked" | "ancestor_revoked") - } - Some(DelegationQueryStatus::AncestorRevoked) => { - lifecycle.status == "ancestor_revoked" - } - }; - if !status_matches { + let Some(record) = build_account_record( + delegation, + abilities, + &ancestor_state, + now, + direction, + query, + )? + else { continue; - } - - let cid = delegation.id.to_cid(0x55).to_string(); - let mut parents = ancestor_state - .parents - .get(&delegation.id) - .cloned() - .unwrap_or_default(); - parents.sort_by(|left, right| left.as_ref().cmp(right.as_ref())); - parents.dedup(); - records.push(AccountDelegationRecord { - cid, - direction: direction.to_string(), - delegator_did: delegation.delegator, - delegate_did: delegation.delegatee, - resources, - parents: parents - .into_iter() - .map(|parent| parent.to_cid(0x55).to_string()) - .collect(), - issued_at: delegation.issued_at, - not_before: delegation.not_before, - expires_at: delegation.expiry, - status: lifecycle.status.to_string(), - revoked_at: lifecycle - .direct_revocation - .as_ref() - .and_then(|row| row.revoked_at), - revoked_by: lifecycle - .direct_revocation - .as_ref() - .map(|row| row.revoker.clone()), - revoked_ancestor_cid: lifecycle.revoked_ancestor_cid, - }); + }; + records.push(record); } records.sort_by(|left, right| { @@ -4931,6 +4860,39 @@ async fn account_query_principal( .then_some(principal)) } +/// Principal of a `direction: "issued"` query (TC-2132): the invoker itself. +/// +/// Exactly one `tinycloud.delegation/list` capability on +/// `/delegation` (empty path, query and fragment) and no proof. +/// Nothing about the account graph is consulted, so the result can only ever +/// be the invoker's own rows. The did:pkh-only [`account_query_principal`] +/// used by the other directions is a separate path and is not affected. +fn issued_query_principal(invocation: &crate::util::InvocationInfo) -> Option { + if !invocation.parents.is_empty() { + return None; + } + let [capability] = invocation.capabilities.as_slice() else { + return None; + }; + if capability.ability.as_ref().as_ref() != "tinycloud.delegation/list" { + return None; + } + let Resource::TinyCloud(resource) = &capability.resource else { + return None; + }; + if resource.service().as_str() != "delegation" + || resource + .path() + .is_some_and(|path| !path.as_str().is_empty()) + || resource.query().is_some() + || resource.fragment().is_some() + { + return None; + } + let invoker = principal_did(&invocation.invoker).ok()?; + (principal_did(resource.space().did().as_str()).ok()? == invoker).then_some(invoker) +} + const MAX_ACCOUNT_SESSION_NODES: usize = 256; const ACCOUNT_SESSION_DELEGATION_PAGE_SIZE: u64 = 512; const MAX_ACCOUNT_ANCESTOR_NODES: usize = 4096; @@ -5099,6 +5061,175 @@ impl AccountAncestorState { } } +/// Rows of `invoker`'s own delegations after `after`, in `id` order: a seek on +/// `idx_delegation_delegator (delegator, id)` that reads at most `limit + 1` +/// rows and never sorts. +fn issued_delegation_select( + invoker: &str, + after: Option, + limit: u64, +) -> sea_orm::Select { + let mut select = delegation::Entity::find() + .filter(delegation::Column::Delegator.eq(invoker)) + .order_by_asc(delegation::Column::Id) + .limit(limit + 1); + if let Some(id) = after { + select = select.filter(delegation::Column::Id.gt(id)); + } + select +} + +/// One page of the delegations `invoker` issued. Work is bounded by `limit`: +/// one indexed scan of at most `limit + 1` rows, then abilities and ancestor +/// state for the page's rows only. `status` and `space` filter the scanned +/// rows, so a page can be short or empty while `next_cursor` is still set; +/// only a missing `next_cursor` ends the listing. +async fn issued_delegations_page( + db: &C, + invoker: &str, + query: &DelegationQuery, +) -> Result { + let after = query + .decoded_issued_cursor(invoker) + .map_err(|_| AccountDelegationQueryError::InvalidCursor)?; + let limit = query.limit.unwrap_or(50).clamp(1, 100) as usize; + let mut rows = issued_delegation_select(invoker, after, limit as u64) + .all(db) + .await?; + let has_more = rows.len() > limit; + rows.truncate(limit); + let Some(last) = rows.last().map(|row| row.id) else { + return Ok(DelegationQueryPage { + schema_version: 2, + items: Vec::new(), + next_cursor: None, + }); + }; + let next_cursor = has_more.then(|| query.encode_issued_cursor(invoker, last)); + let ability_rows = rows.load_many(abilities::Entity, db).await?; + let roots = rows.iter().map(|row| row.id).collect::>(); + let ancestor_state = load_account_ancestor_state(db, &roots).await?; + let now = OffsetDateTime::now_utc(); + let mut items = Vec::new(); + for (delegation, ability_rows) in rows.into_iter().zip(ability_rows) { + if let Some(record) = build_account_record( + delegation, + ability_rows, + &ancestor_state, + now, + "granted", + query, + )? { + items.push(record); + } + } + Ok(DelegationQueryPage { + schema_version: 2, + items, + next_cursor, + }) +} + +/// Group one delegation's abilities, apply the `space` and `status` filters +/// and compute its lifecycle. `None` when a filter excludes the row. +fn build_account_record( + delegation: delegation::Model, + ability_rows: Vec, + ancestor_state: &AccountAncestorState, + now: OffsetDateTime, + direction: &str, + query: &DelegationQuery, +) -> Result, DbErr> { + let mut grouped: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for ability in ability_rows { + grouped + .entry(ability.resource.to_string()) + .or_default() + .push((ability.ability.to_string(), ability.caveats)); + } + if let Some(space_filter) = query.space.as_deref() { + let matches_space = grouped.keys().any(|resource| { + resource + .parse::() + .ok() + .and_then(|resource| resource.space().cloned()) + .map(|space| { + space.to_string() == space_filter || space.name().as_str() == space_filter + }) + .unwrap_or(false) + }); + if !matches_space { + return Ok(None); + } + } + let resources = grouped + .into_iter() + .map(|(resource, mut entries)| { + entries.sort_by(|left, right| { + left.0.cmp(&right.0).then_with(|| { + serde_json::to_string(&left.1) + .unwrap_or_default() + .cmp(&serde_json::to_string(&right.1).unwrap_or_default()) + }) + }); + DelegationResource { + resource, + actions: entries.iter().map(|entry| entry.0.clone()).collect(), + caveats: entries.into_iter().map(|entry| entry.1).collect(), + } + }) + .collect(); + + let lifecycle = ancestor_state.lifecycle(delegation.id, now)?; + let status_matches = match query.status { + None => true, + Some(DelegationQueryStatus::Active) => lifecycle.status == "active", + Some(DelegationQueryStatus::Pending) => lifecycle.status == "pending", + Some(DelegationQueryStatus::Expired) => lifecycle.status == "expired", + Some(DelegationQueryStatus::Revoked) => { + matches!(lifecycle.status, "revoked" | "ancestor_revoked") + } + Some(DelegationQueryStatus::AncestorRevoked) => lifecycle.status == "ancestor_revoked", + }; + if !status_matches { + return Ok(None); + } + + let cid = delegation.id.to_cid(0x55).to_string(); + let mut parents = ancestor_state + .parents + .get(&delegation.id) + .cloned() + .unwrap_or_default(); + parents.sort_by(|left, right| left.as_ref().cmp(right.as_ref())); + parents.dedup(); + Ok(Some(AccountDelegationRecord { + cid, + direction: direction.to_string(), + delegator_did: delegation.delegator, + delegate_did: delegation.delegatee, + resources, + parents: parents + .into_iter() + .map(|parent| parent.to_cid(0x55).to_string()) + .collect(), + issued_at: delegation.issued_at, + not_before: delegation.not_before, + expires_at: delegation.expiry, + status: lifecycle.status.to_string(), + revoked_at: lifecycle + .direct_revocation + .as_ref() + .and_then(|row| row.revoked_at), + revoked_by: lifecycle + .direct_revocation + .as_ref() + .map(|row| row.revoker.clone()), + revoked_ancestor_cid: lifecycle.revoked_ancestor_cid, + })) +} + async fn load_account_ancestor_state( db: &C, roots: &[Hash], @@ -7991,6 +8122,396 @@ mod test { "per-DID pagination must not sort pages: {plan:?}" ); } + + // ── TC-2132: `direction: "issued"` ───────────────────────────────────── + + /// Records every statement a query runs and how many rows it returned. + struct RecordingConn<'a> { + inner: &'a sea_orm::DatabaseConnection, + log: tokio::sync::Mutex>, + } + + impl<'a> RecordingConn<'a> { + fn new(inner: &'a sea_orm::DatabaseConnection) -> Self { + Self { + inner, + log: Default::default(), + } + } + + async fn statements(&self) -> Vec<(String, usize)> { + self.log.lock().await.clone() + } + } + + #[async_trait::async_trait] + impl ConnectionTrait for RecordingConn<'_> { + fn get_database_backend(&self) -> DbBackend { + self.inner.get_database_backend() + } + + async fn execute(&self, stmt: Statement) -> Result { + self.inner.execute(stmt).await + } + + async fn execute_unprepared(&self, sql: &str) -> Result { + self.inner.execute_unprepared(sql).await + } + + async fn query_one(&self, stmt: Statement) -> Result, DbErr> { + let sql = stmt.sql.clone(); + let row = self.inner.query_one(stmt).await?; + self.log + .lock() + .await + .push((sql, usize::from(row.is_some()))); + Ok(row) + } + + async fn query_all(&self, stmt: Statement) -> Result, DbErr> { + let sql = stmt.sql.clone(); + let rows = self.inner.query_all(stmt).await?; + self.log.lock().await.push((sql, rows.len())); + Ok(rows) + } + + fn support_returning(&self) -> bool { + self.inner.support_returning() + } + + fn is_mock_connection(&self) -> bool { + self.inner.is_mock_connection() + } + } + + /// Insert `count` delegations issued by `issuer`, each with one ability. + /// Returns their ids in `id` order. + async fn seed_issued( + db: &sea_orm::DatabaseConnection, + issuer: &str, + count: usize, + ) -> Vec { + use sea_orm::{ActiveModelTrait, ActiveValue::Set}; + + for did in [issuer, "did:key:tc2132-recipient"] { + if actor::Entity::find_by_id(did) + .one(db) + .await + .unwrap() + .is_none() + { + actor::ActiveModel { + id: Set(did.to_string()), + } + .insert(db) + .await + .unwrap(); + } + } + let now = OffsetDateTime::now_utc(); + let ids = (0..count) + .map(|n| crate::hash::hash(format!("tc2132-{issuer}-{n}").as_bytes())) + .collect::>(); + for chunk in ids.chunks(100) { + delegation::Entity::insert_many(chunk.iter().map(|id| delegation::ActiveModel { + id: Set(*id), + delegator: Set(issuer.to_string()), + delegatee: Set("did:key:tc2132-recipient".to_string()), + expiry: Set(Some(now + time::Duration::hours(1))), + issued_at: Set(Some(now)), + not_before: Set(None), + facts: Set(None), + serialization: Set(id.as_ref().to_vec()), + })) + .exec(db) + .await + .unwrap(); + abilities::Entity::insert_many(chunk.iter().map(|id| abilities::ActiveModel { + resource: Set("tinycloud:pkh:eip155:1:0x0000000000000000000000000000000000000001:files/kv/docs" + .parse() + .unwrap()), + ability: Set("tinycloud.kv/get".to_string().try_into().unwrap()), + delegation: Set(*id), + caveats: Set(Default::default()), + })) + .exec(db) + .await + .unwrap(); + } + let mut ids = ids; + ids.sort(); + ids + } + + fn issued_query(limit: u16, cursor: Option) -> DelegationQuery { + DelegationQuery { + direction: DelegationQueryDirection::Issued, + limit: Some(limit), + cursor, + ..Default::default() + } + } + + fn cids(ids: &[Hash]) -> Vec { + ids.iter().map(|id| id.to_cid(0x55).to_string()).collect() + } + + #[tokio::test] + async fn issued_pages_visit_every_row_once_and_end() { + let db = get_db().await.unwrap(); + let issuer = "did:key:tc2132-issuer-a"; + let mine = seed_issued(&db.conn, issuer, 25).await; + seed_issued(&db.conn, "did:key:tc2132-issuer-b", 7).await; + + let mut seen = Vec::new(); + let mut sizes = Vec::new(); + let mut cursor = None; + loop { + let page = issued_delegations_page(&db.conn, issuer, &issued_query(10, cursor)) + .await + .unwrap(); + sizes.push(page.items.len()); + seen.extend(page.items.into_iter().map(|item| item.cid)); + cursor = page.next_cursor; + if cursor.is_none() { + break; + } + } + assert_eq!(sizes, [10, 10, 5]); + assert_eq!(seen, cids(&mine), "every row once, in id order"); + + // Exactly two full pages: the second is the last, no empty third page. + let db = get_db().await.unwrap(); + let mine = seed_issued(&db.conn, issuer, 20).await; + let first = issued_delegations_page(&db.conn, issuer, &issued_query(10, None)) + .await + .unwrap(); + assert_eq!(first.items.len(), 10); + let second = + issued_delegations_page(&db.conn, issuer, &issued_query(10, first.next_cursor)) + .await + .unwrap(); + assert_eq!( + second + .items + .iter() + .map(|i| i.cid.clone()) + .collect::>(), + cids(&mine[10..]) + ); + assert!(second.next_cursor.is_none()); + assert!(second.items.iter().all(|item| item.direction == "granted")); + } + + #[tokio::test] + async fn issued_status_filter_reaches_every_match_in_bounded_queries() { + use sea_orm::{ActiveModelTrait, ActiveValue::Set}; + + let db = get_db().await.unwrap(); + let issuer = "did:key:tc2132-issuer-a"; + let mine = seed_issued(&db.conn, issuer, 1000).await; + let revoked = mine.iter().step_by(10).copied().collect::>(); + for id in &revoked { + revocation::ActiveModel { + id: Set(crate::hash::hash( + format!("tc2132-revocation-{id:?}").as_bytes(), + )), + revoker: Set(issuer.to_string()), + revoked: Set(*id), + serialization: Set(id.as_ref().to_vec()), + revoked_at: Set(Some(OffsetDateTime::now_utc())), + } + .insert(&db.conn) + .await + .unwrap(); + } + + let recording = RecordingConn::new(&db.conn); + let mut found = Vec::new(); + let mut pages = 0; + let mut cursor = None; + loop { + let query = DelegationQuery { + status: Some(DelegationQueryStatus::Revoked), + ..issued_query(50, cursor) + }; + let page = issued_delegations_page(&recording, issuer, &query) + .await + .unwrap(); + pages += 1; + assert!(page.items.len() <= 50); + assert!(page.items.iter().all(|item| item.status == "revoked")); + found.extend(page.items.into_iter().map(|item| item.cid)); + cursor = page.next_cursor; + if cursor.is_none() { + break; + } + } + let mut expected = cids(&revoked); + expected.sort(); + found.sort(); + assert_eq!(found, expected, "every matching row once"); + assert_eq!(pages, 20, "1000 rows scanned 50 at a time"); + let statements = recording.statements().await; + assert!( + statements.len() <= pages * 8, + "bounded queries per page: {} statements over {pages} pages", + statements.len() + ); + } + + #[tokio::test] + async fn issued_page_reads_at_most_limit_plus_one_delegation_rows_on_the_index() { + let db = get_db().await.unwrap(); + let issuer = "did:key:tc2132-issuer-a"; + let mine = seed_issued(&db.conn, issuer, 120).await; + // Many other issuers: none of their rows may be read. + for other in 0..20 { + seed_issued(&db.conn, &format!("did:key:tc2132-other-{other}"), 100).await; + } + + let limit = 10u16; + let recording = RecordingConn::new(&db.conn); + let page = issued_delegations_page(&recording, issuer, &issued_query(limit, None)) + .await + .unwrap(); + assert_eq!( + page.items.iter().map(|i| i.cid.clone()).collect::>(), + cids(&mine[..limit as usize]) + ); + let statements = recording.statements().await; + let delegation_reads = statements + .iter() + .filter(|(sql, _)| sql.contains("FROM \"delegation\"")) + .collect::>(); + let (scan_sql, scan_rows) = delegation_reads[0]; + assert!( + scan_sql.contains("\"delegator\" = ") && scan_sql.contains("LIMIT"), + "{scan_sql}" + ); + assert_eq!(*scan_rows, limit as usize + 1); + for (sql, _) in &delegation_reads[1..] { + assert!( + sql.contains("WHERE \"delegation\".\"id\" IN") && !sql.contains("\"delegator\" ="), + "only the page's ancestors may be loaded: {sql}" + ); + } + let delegation_rows: usize = delegation_reads.iter().map(|(_, rows)| rows).sum(); + assert!( + delegation_rows <= 2 * limit as usize + 1, + "{delegation_rows} delegation rows read for a page of {limit}" + ); + assert!( + statements.iter().all(|(sql, _)| { + !sql.contains("\"delegatee\" IN") && !sql.contains("\"delegatee\" =") + }), + "no account-wide delegator-or-delegatee load" + ); + + let cursor = DelegationQuery::default().encode_issued_cursor(issuer, mine[40]); + let after = DelegationQuery { + cursor: Some(cursor), + ..issued_query(limit, None) + }; + let decoded = after.decoded_issued_cursor(issuer).unwrap(); + for after_id in [None, decoded] { + let mut statement = + issued_delegation_select(issuer, after_id, limit as u64).build(DbBackend::Sqlite); + statement.sql = format!("EXPLAIN QUERY PLAN {}", statement.sql); + let plan = db + .conn + .query_all(statement) + .await + .unwrap() + .into_iter() + .map(|row| row.try_get::("", "detail").unwrap()) + .collect::>(); + assert!( + plan.iter() + .any(|line| line.contains("idx_delegation_delegator") + && line.contains("delegator=?")), + "must seek the delegator index: {plan:?}" + ); + assert!( + plan.iter().all(|line| !line.contains("TEMP B-TREE")), + "must not sort: {plan:?}" + ); + } + } + + #[tokio::test] + async fn issued_cursor_is_bound_to_invoker_and_filters_and_never_looked_up() { + let db = get_db().await.unwrap(); + let a = "did:key:tc2132-issuer-a"; + let b = "did:key:tc2132-issuer-b"; + let mine = seed_issued(&db.conn, a, 5).await; + let theirs = seed_issued(&db.conn, b, 5).await; + + let first = issued_delegations_page(&db.conn, a, &issued_query(2, None)) + .await + .unwrap(); + let cursor = first.next_cursor.unwrap(); + let b_first = issued_delegations_page(&db.conn, b, &issued_query(2, None)) + .await + .unwrap(); + let b_cursor = b_first.next_cursor.unwrap(); + + let invalid = |result: Result| { + matches!(result, Err(AccountDelegationQueryError::InvalidCursor)) + }; + // Another invoker's cursor, another filter's cursor, the legacy CID + // cursor and plain garbage are the same error. + for bad in [ + b_cursor, + DelegationQuery::encode_cursor(&theirs[0].to_cid(0x55).to_string()), + DelegationQuery::encode_cursor("garbage"), + ] { + assert!(invalid( + issued_delegations_page(&db.conn, a, &issued_query(2, Some(bad))).await + )); + } + let other_filter = DelegationQuery { + status: Some(DelegationQueryStatus::Active), + ..issued_query(2, Some(cursor.clone())) + }; + assert!(invalid( + issued_delegations_page(&db.conn, a, &other_filter).await + )); + let other_space = DelegationQuery { + space: Some("files".to_string()), + ..issued_query(2, Some(cursor.clone())) + }; + assert!(invalid( + issued_delegations_page(&db.conn, a, &other_space).await + )); + + // Same invoker and filter: continues after the last scanned row. + let second = issued_delegations_page(&db.conn, a, &issued_query(2, Some(cursor))) + .await + .unwrap(); + assert_eq!( + second + .items + .iter() + .map(|i| i.cid.clone()) + .collect::>(), + cids(&mine[2..4]) + ); + + // A well-formed key is only a position among the invoker's rows: a key + // that is another issuer's row, or no row at all, never changes the + // kind of answer (no existence oracle). + let template = DelegationQuery::default(); + for key in [theirs[0], crate::hash::hash(b"tc2132-no-such-row")] { + let query = issued_query(2, Some(template.encode_issued_cursor(a, key))); + let page = issued_delegations_page(&db.conn, a, &query).await.unwrap(); + assert!(page.items.iter().all(|item| item.delegator_did == a + && mine + .iter() + .any(|id| id.to_cid(0x55).to_string() == item.cid))); + } + } + #[tokio::test] async fn sqlite_account_cursor_index_migration_recovers_after_index_drop() { use sea_orm_migration::{MigrationTrait, SchemaManager}; diff --git a/tinycloud-core/src/types/delegation_query.rs b/tinycloud-core/src/types/delegation_query.rs index b9af5ffa..3cf7521d 100644 --- a/tinycloud-core/src/types/delegation_query.rs +++ b/tinycloud-core/src/types/delegation_query.rs @@ -3,12 +3,20 @@ use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use super::Caveats; +use crate::hash::Hash; +use tinycloud_auth::ipld_core::cid::Cid; + +const RAW_CODEC: u64 = 0x55; +const BLAKE3_256_CODE: u64 = 0x1e; #[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum DelegationQueryDirection { Granted, Received, + /// Delegations whose delegator is the invoking principal itself. Has its + /// own authorization path and keyset cursor (TC-2132). + Issued, #[default] All, } @@ -23,6 +31,18 @@ pub enum DelegationQueryStatus { AncestorRevoked, } +impl DelegationQueryStatus { + fn label(self) -> &'static str { + match self { + Self::Active => "active", + Self::Pending => "pending", + Self::Expired => "expired", + Self::Revoked => "revoked", + Self::AncestorRevoked => "ancestor_revoked", + } + } +} + #[derive(Debug, Clone, Deserialize, Default)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct DelegationQuery { @@ -79,6 +99,66 @@ impl DelegationQuery { pub fn encode_cursor(cid: &str) -> String { URL_SAFE_NO_PAD.encode(cid.as_bytes()) } + + /// Digest of everything an `issued` cursor is bound to: the invoker and + /// the `status` / `space` filters. A cursor minted under another invoker + /// or filter does not decode. + fn issued_binding(&self, invoker: &str) -> String { + let binding = serde_json::to_vec(&( + "delegation-issued-query-v1", + invoker, + self.status.map(DelegationQueryStatus::label), + self.space.as_deref(), + )) + .expect("a tuple of strings serializes"); + URL_SAFE_NO_PAD.encode(crate::hash::hash(&binding).as_ref()) + } + + /// Cursor for the `issued` direction: the last scanned row id plus the + /// binding of invoker and filters. + pub fn encode_issued_cursor(&self, invoker: &str, last: Hash) -> String { + URL_SAFE_NO_PAD.encode(format!( + "i1.{}.{}", + last.to_cid(RAW_CODEC), + self.issued_binding(invoker) + )) + } + + /// Decode an `issued` cursor into the row id to continue after. The id is + /// only ever a key position inside the invoker's own rows; it is never + /// looked up. Anything that is not a cursor minted for this invoker and + /// these filters is `InvalidCursor`. + pub fn decoded_issued_cursor( + &self, + invoker: &str, + ) -> Result, DelegationQueryValidationError> { + let Some(cursor) = self.cursor.as_deref() else { + return Ok(None); + }; + let invalid = || DelegationQueryValidationError::InvalidCursor; + let text = URL_SAFE_NO_PAD + .decode(cursor) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .ok_or_else(invalid)?; + let mut parts = text.split('.'); + let (Some("i1"), Some(cid), Some(binding), None) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + return Err(invalid()); + }; + if binding != self.issued_binding(invoker) { + return Err(invalid()); + } + let cid = Cid::try_from(cid).map_err(|_| invalid())?; + if cid.codec() != RAW_CODEC + || cid.hash().code() != BLAKE3_256_CODE + || cid.hash().digest().len() != 32 + { + return Err(invalid()); + } + Ok(Some(Hash::from(cid))) + } } #[derive(Debug, Clone, Serialize)] diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index 758738ef..051c999e 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -319,6 +319,7 @@ fn build_info( "encryption", "kv-sync-v1", "kv-sync-root", + "delegation-issued-query-v1", ]); #[cfg(feature = "dstack")] features.push("tee"); @@ -661,6 +662,13 @@ pub async fn delegation_query( Status::Forbidden, "Delegation query proof is not authorized".to_string(), ), + tinycloud_core::db::AccountDelegationQueryError::InvalidCursor => ( + Status::BadRequest, + format!( + "Invalid delegation query: {}", + tinycloud_core::types::DelegationQueryValidationError::InvalidCursor + ), + ), tinycloud_core::db::AccountDelegationQueryError::Db(_) => ( Status::ServiceUnavailable, "Delegation query unavailable".to_string(), @@ -4279,6 +4287,7 @@ async fn verify_auth_admitted( #[cfg(test)] mod tests { + mod delegation_issued_query; mod encryption_keys; mod key_delete; mod revocation_scope; diff --git a/tinycloud-node-server/src/routes/tests/delegation_issued_query.rs b/tinycloud-node-server/src/routes/tests/delegation_issued_query.rs new file mode 100644 index 00000000..b00bf73e --- /dev/null +++ b/tinycloud-node-server/src/routes/tests/delegation_issued_query.rs @@ -0,0 +1,694 @@ +//! TC-2132: `POST /delegation/query` with `direction: "issued"`. +//! +//! The agent signs with its own key, one `tinycloud.delegation/list` +//! capability on `/delegation`, no proof; it sees only the rows +//! it is the delegator of. + +use super::*; +use rocket::http::{ContentType, Header, Status}; +use rocket::local::asynchronous::Client; +use tinycloud_auth::{ + authorization::Cid as AuthCid, + ssi::{ + claims::jwt::NumericDate, + dids::{DIDBuf, DIDURLBuf}, + jwk::Algorithm, + ucan::Payload, + }, + ucan_capabilities_object::Capabilities, +}; +use tinycloud_core::{ + hash::Hash, + models::{abilities, actor, delegation}, + sea_orm::{ActiveModelTrait, ActiveValue::Set}, + types::DelegationQuery, +}; + +/// A did:key that signs queries. +struct Agent { + jwk: JWK, + vm: String, + did: String, +} + +impl Agent { + fn new() -> Result { + let mut jwk = JWK::generate_ed25519()?; + jwk.algorithm = Some(Algorithm::EdDSA); + let did = DID_METHODS.generate(&jwk, "key")?.to_string(); + let fragment = did + .rsplit_once(':') + .ok_or_else(|| anyhow::anyhow!("missing did:key fragment"))? + .1 + .to_string(); + Ok(Self { + vm: format!("{did}#{fragment}"), + did, + jwk, + }) + } + + /// `:agent/delegation`: the resource of the list capability. + fn list_resource(&self, did: &str) -> Result { + Ok(Resource::TinyCloud( + SpaceId::new(did.parse::()?, "agent".parse()?).to_resource( + "delegation".parse()?, + None, + None, + None, + ), + )) + } + + fn header(&self, resource: &Resource, nonce: &str, proofs: Vec) -> Result { + let mut capabilities = Capabilities::new(); + capabilities.with_action( + resource.to_string().parse()?, + "tinycloud.delegation/list".parse::()?, + [std::collections::BTreeMap::::new()], + ); + Ok(Payload { + issuer: self.vm.parse::()?, + audience: self.did.parse::()?, + not_before: None, + expiration: NumericDate::try_from_seconds(4_102_444_800.0)?, + nonce: Some(nonce.to_string()), + facts: Some(Vec::::new()), + proof: proofs, + attenuation: capabilities, + } + .sign(self.jwk.get_algorithm().unwrap_or_default(), &self.jwk)? + .encode()?) + } +} + +/// Rows are seeded in one transaction that `serve` commits before the first +/// request. +struct Fixture { + tempdir: TempDir, + tinycloud: TinyCloud, + conn: tinycloud_core::sea_orm::DatabaseTransaction, +} + +impl Fixture { + async fn new() -> Result { + let (tempdir, tinycloud) = test_tinycloud().await?; + let conn = tinycloud.readable().await?; + Ok(Self { + tempdir, + tinycloud, + conn, + }) + } + + async fn serve(self) -> Result { + self.conn.commit().await?; + let rocket = rocket::build() + .mount("/", rocket::routes![delegation_query]) + .attach(crate::tracing::TracingFairing::for_test( + &Config::default().log.tracing, + )) + .manage(self.tinycloud); + Ok(Served { + _tempdir: self.tempdir, + client: Client::tracked(rocket).await?, + }) + } + + async fn actor(&self, did: &str) -> Result<()> { + actor::ActiveModel { + id: Set(did.to_string()), + } + .insert(&self.conn) + .await?; + Ok(()) + } + + /// A delegation row with one ability on `resource`. + async fn delegation( + &self, + label: &str, + delegator: &str, + delegatee: &str, + issued_at: i64, + resource: Resource, + ability: &str, + ) -> Result { + let id = tinycloud_core::hash::hash(label.as_bytes()); + delegation::ActiveModel { + id: Set(id), + delegator: Set(delegator.to_string()), + delegatee: Set(delegatee.to_string()), + expiry: Set(None), + issued_at: Set(Some(OffsetDateTime::from_unix_timestamp(issued_at)?)), + not_before: Set(None), + facts: Set(None), + serialization: Set(label.as_bytes().to_vec()), + } + .insert(&self.conn) + .await?; + abilities::ActiveModel { + delegation: Set(id), + resource: Set(resource), + ability: Set(Ability::try_from(ability.to_string())?), + caveats: Set(Default::default()), + } + .insert(&self.conn) + .await?; + Ok(id) + } +} + +struct Served { + _tempdir: TempDir, + client: Client, +} + +impl Served { + async fn query(&self, header: String, body: serde_json::Value) -> (Status, String) { + let response = self + .client + .post("/delegation/query") + .header(Header::new("Authorization", header)) + .header(ContentType::JSON) + .body(body.to_string()) + .dispatch() + .await; + let status = response.status(); + (status, response.into_string().await.unwrap_or_default()) + } +} + +fn kv_resource(space_did: &str, path: &str) -> Result { + Ok(Resource::TinyCloud( + SpaceId::new(space_did.parse::()?, "files".parse()?).to_resource( + "kv".parse()?, + Some(path.parse()?), + None, + None, + ), + )) +} + +fn cids(ids: &[Hash]) -> Vec { + let mut ids = ids.to_vec(); + ids.sort(); + ids.iter().map(|id| id.to_cid(0x55).to_string()).collect() +} + +fn page_cids(body: &str) -> Result> { + let page: serde_json::Value = serde_json::from_str(body)?; + Ok(page["items"] + .as_array() + .expect("items") + .iter() + .map(|item| item["cid"].as_str().expect("cid").to_string()) + .collect()) +} + +#[tokio::test] +async fn issued_lists_only_the_invokers_own_delegations() -> Result<()> { + let fixture = Fixture::new().await?; + let (a, b) = (Agent::new()?, Agent::new()?); + let recipient = "did:key:zIssuedRecipient"; + for did in [&a.did, &b.did, &recipient.to_string()] { + fixture.actor(did).await?; + } + let mut a_issued = Vec::new(); + for n in 0..3 { + a_issued.push( + fixture + .delegation( + &format!("a-issued-{n}"), + &a.did, + recipient, + 1_700_000_000 + n, + kv_resource(&a.did, "notes")?, + "tinycloud.kv/get", + ) + .await?, + ); + } + let mut b_issued = Vec::new(); + for n in 0..2 { + b_issued.push( + fixture + .delegation( + &format!("b-issued-{n}"), + &b.did, + recipient, + 1_700_000_000 + n, + kv_resource(&b.did, "notes")?, + "tinycloud.kv/get", + ) + .await?, + ); + } + // Rows A received, from B and from the recipient, never appear for A. + // B issued the one to A, so it is in B's list. + b_issued.push( + fixture + .delegation( + "b-to-a", + &b.did, + &a.did, + 1_700_000_000, + kv_resource(&b.did, "notes")?, + "tinycloud.kv/get", + ) + .await?, + ); + b_issued.push( + fixture + .delegation( + "b-issued-2", + &b.did, + recipient, + 1_700_000_005, + kv_resource(&b.did, "notes")?, + "tinycloud.kv/get", + ) + .await?, + ); + fixture + .delegation( + "recipient-to-a", + recipient, + &a.did, + 1_700_000_000, + kv_resource(recipient, "notes")?, + "tinycloud.kv/get", + ) + .await?; + + let fixture = fixture.serve().await?; + let body = serde_json::json!({ "direction": "issued" }); + let (status, text) = fixture + .query( + a.header(&a.list_resource(&a.did)?, "issued-a", vec![])?, + body.clone(), + ) + .await; + assert_eq!(status, Status::Ok, "{text}"); + assert_eq!( + page_cids(&text)?, + cids(&a_issued), + "A sees exactly its three" + ); + let page: serde_json::Value = serde_json::from_str(&text)?; + assert_eq!(page["schemaVersion"], 2); + assert!(page.get("nextCursor").is_none()); + for item in page["items"].as_array().unwrap() { + assert_eq!(item["direction"], "granted"); + assert_eq!(item["delegatorDid"], a.did.as_str()); + } + + let (status, text) = fixture + .query( + b.header(&b.list_resource(&b.did)?, "issued-b", vec![])?, + body, + ) + .await; + assert_eq!(status, Status::Ok, "{text}"); + assert_eq!(page_cids(&text)?, cids(&b_issued), "B sees only its own"); + Ok(()) +} + +#[tokio::test] +async fn issued_refuses_a_proof_or_another_principals_resource() -> Result<()> { + let fixture = Fixture::new().await?; + let (a, b) = (Agent::new()?, Agent::new()?); + for did in [&a.did, &b.did] { + fixture.actor(did).await?; + } + fixture + .delegation( + "a-issued", + &a.did, + &b.did, + 1_700_000_000, + kv_resource(&a.did, "notes")?, + "tinycloud.kv/get", + ) + .await?; + // Valid proofs for the list capability: A over its own DID, and B's grant + // of B's DID to A. + let a_self = fixture + .delegation( + "a-self-list", + &a.did, + &a.did, + 1_700_000_000, + a.list_resource(&a.did)?, + "tinycloud.delegation/list", + ) + .await?; + let b_to_a = fixture + .delegation( + "b-list-to-a", + &b.did, + &a.did, + 1_700_000_000, + b.list_resource(&b.did)?, + "tinycloud.delegation/list", + ) + .await?; + let fixture = fixture.serve().await?; + let issued = serde_json::json!({ "direction": "issued" }); + + let (status, text) = fixture + .query( + a.header(&a.list_resource(&a.did)?, "control", vec![])?, + issued.clone(), + ) + .await; + assert_eq!(status, Status::Ok, "no-proof control: {text}"); + + for (label, header) in [ + ( + "with a proof", + a.header( + &a.list_resource(&a.did)?, + "with-proof", + vec![a_self.to_cid(0x55)], + )?, + ), + ( + "capability on another DID, no proof", + a.header(&a.list_resource(&b.did)?, "other-did", vec![])?, + ), + ( + "capability on another DID, with B's grant as proof", + a.header( + &a.list_resource(&b.did)?, + "other-did-proof", + vec![b_to_a.to_cid(0x55)], + )?, + ), + ] { + let (status, text) = fixture.query(header, issued.clone()).await; + assert_eq!(status, Status::Forbidden, "{label}: {text}"); + assert_eq!(text, "Delegation query proof is not authorized", "{label}"); + } + + // A narrower path than the whole delegation service is refused too. + let narrow = Resource::TinyCloud( + SpaceId::new(a.did.parse::()?, "agent".parse()?).to_resource( + "delegation".parse()?, + Some("narrow".parse()?), + None, + None, + ), + ); + let (status, _) = fixture + .query(a.header(&narrow, "narrow", vec![])?, issued) + .await; + assert_eq!(status, Status::Forbidden); + + // The did:pkh-only gate of the other directions is unchanged. + for direction in ["granted", "received", "all"] { + let (status, _) = fixture + .query( + a.header(&a.list_resource(&a.did)?, direction, vec![])?, + serde_json::json!({ "direction": direction }), + ) + .await; + assert_eq!(status, Status::Forbidden, "{direction}"); + } + Ok(()) +} + +#[tokio::test] +async fn issued_pages_over_http_and_refuses_foreign_cursors_like_garbage() -> Result<()> { + let fixture = Fixture::new().await?; + let (a, b) = (Agent::new()?, Agent::new()?); + for did in [&a.did, &b.did, &"did:key:zIssuedRecipient".to_string()] { + fixture.actor(did).await?; + } + let mut a_issued = Vec::new(); + let mut b_issued = Vec::new(); + for n in 0..5 { + for (agent, ids) in [(&a, &mut a_issued), (&b, &mut b_issued)] { + ids.push( + fixture + .delegation( + &format!("{}-{n}", agent.did), + &agent.did, + "did:key:zIssuedRecipient", + 1_700_000_000, + kv_resource(&agent.did, "notes")?, + "tinycloud.kv/get", + ) + .await, + ); + } + } + let a_issued = a_issued.into_iter().collect::>>()?; + let b_issued = b_issued.into_iter().collect::>>()?; + + let fixture = fixture.serve().await?; + // Paging over HTTP: 2 + 2 + 1, each row once, ending without a cursor. + let mut seen = Vec::new(); + let mut cursor: Option = None; + let mut pages = 0; + loop { + let mut body = serde_json::json!({ "direction": "issued", "limit": 2 }); + if let Some(cursor) = &cursor { + body["cursor"] = cursor.as_str().into(); + } + let (status, text) = fixture + .query( + a.header(&a.list_resource(&a.did)?, &format!("page-{pages}"), vec![])?, + body, + ) + .await; + assert_eq!(status, Status::Ok, "{text}"); + seen.extend(page_cids(&text)?); + let page: serde_json::Value = serde_json::from_str(&text)?; + cursor = page["nextCursor"].as_str().map(str::to_string); + pages += 1; + if cursor.is_none() { + break; + } + } + assert_eq!((pages, seen), (3, cids(&a_issued))); + + // B's own first page yields a cursor that is not A's. + let (_, text) = fixture + .query( + b.header(&b.list_resource(&b.did)?, "b-page", vec![])?, + serde_json::json!({ "direction": "issued", "limit": 2 }), + ) + .await; + let b_cursor = serde_json::from_str::(&text)?["nextCursor"] + .as_str() + .expect("B has more rows") + .to_string(); + let a_cursor = { + let (_, text) = fixture + .query( + a.header(&a.list_resource(&a.did)?, "a-page", vec![])?, + serde_json::json!({ "direction": "issued", "limit": 2 }), + ) + .await; + serde_json::from_str::(&text)?["nextCursor"] + .as_str() + .expect("A has more rows") + .to_string() + }; + + let mut answers = Vec::new(); + let encode = |text: &str| DelegationQuery::encode_cursor(text); + for (n, cursor) in [ + ("not base64!".to_string()), + encode("garbage"), + b_cursor, + encode(&b_issued[0].to_cid(0x55).to_string()), + ] + .into_iter() + .enumerate() + { + let (status, text) = fixture + .query( + a.header( + &a.list_resource(&a.did)?, + &format!("bad-cursor-{n}"), + vec![], + )?, + serde_json::json!({ "direction": "issued", "limit": 2, "cursor": cursor }), + ) + .await; + answers.push((status, text)); + } + // A's own cursor under a different status filter is foreign too. + let (status, text) = fixture + .query( + a.header(&a.list_resource(&a.did)?, "bad-filter", vec![])?, + serde_json::json!({ + "direction": "issued", "limit": 2, "status": "active", "cursor": a_cursor + }), + ) + .await; + answers.push((status, text)); + assert_eq!(answers[0].0, Status::BadRequest, "{answers:?}"); + for answer in &answers { + assert_eq!(answer, &answers[0], "one error for every bad cursor"); + } + Ok(()) +} + +/// `granted`, `received` and `all` answer with the same bytes as before the +/// `issued` direction existed. The expected strings are written out here in +/// the response field order; only resource and caveat spellings come from +/// their own serializers. +#[tokio::test] +async fn issued_direction_leaves_granted_received_all_bytes_unchanged() -> Result<()> { + let fixture = Fixture::new().await?; + let holder = Agent::new()?; + let wallet = "did:pkh:eip155:1:0x1111111111111111111111111111111111111111"; + let peer = "did:key:zIssuedPeer"; + for did in [holder.did.as_str(), wallet, peer] { + fixture.actor(did).await?; + } + let control = SpaceId::new(wallet.parse::()?, "control".parse()?); + let list_resource = Resource::TinyCloud(control.clone().to_resource( + "delegation".parse()?, + None, + None, + None, + )); + let notes = + Resource::TinyCloud(control.to_resource("kv".parse()?, Some("notes".parse()?), None, None)); + let session = fixture + .delegation( + "bytes-session", + wallet, + &holder.did, + 1_700_000_100, + list_resource.clone(), + "tinycloud.delegation/list", + ) + .await?; + let received = fixture + .delegation( + "bytes-received", + peer, + wallet, + 1_700_000_000, + notes.clone(), + "tinycloud.kv/get", + ) + .await?; + let granted = fixture + .delegation( + "bytes-granted", + wallet, + peer, + 1_700_000_200, + notes.clone(), + "tinycloud.kv/get", + ) + .await?; + + let fixture = fixture.serve().await?; + let caveats = serde_json::to_string(&tinycloud_core::types::Caveats::default())?; + let item = |id: Hash, + direction: &str, + delegator: &str, + delegatee: &str, + resource: &Resource, + ability: &str, + issued_at: &str| { + format!( + "{{\"cid\":\"{}\",\"direction\":\"{direction}\",\"delegatorDid\":\"{delegator}\",\ + \"delegateDid\":\"{delegatee}\",\"resources\":[{{\"resource\":\"{resource}\",\ + \"actions\":[\"{ability}\"],\"caveats\":[{caveats}]}}],\"parents\":[],\ + \"issuedAt\":\"{issued_at}\",\"notBefore\":null,\"expiresAt\":null,\ + \"status\":\"active\"}}", + id.to_cid(0x55) + ) + }; + let granted_item = item( + granted, + "granted", + wallet, + peer, + ¬es, + "tinycloud.kv/get", + "2023-11-14T22:16:40Z", + ); + let session_item = item( + session, + "granted", + wallet, + &holder.did, + &list_resource, + "tinycloud.delegation/list", + "2023-11-14T22:15:00Z", + ); + let received_item = item( + received, + "received", + peer, + wallet, + ¬es, + "tinycloud.kv/get", + "2023-11-14T22:13:20Z", + ); + + let header = |nonce: &str| holder.header(&list_resource, nonce, vec![session.to_cid(0x55)]); + for (label, body, items) in [ + ( + "all (default)", + serde_json::json!({}), + vec![&granted_item, &session_item, &received_item], + ), + ( + "all", + serde_json::json!({ "direction": "all" }), + vec![&granted_item, &session_item, &received_item], + ), + ( + "granted", + serde_json::json!({ "direction": "granted" }), + vec![&granted_item, &session_item], + ), + ( + "received", + serde_json::json!({ "direction": "received" }), + vec![&received_item], + ), + ] { + let (status, text) = fixture.query(header(label)?, body).await; + assert_eq!(status, Status::Ok, "{label}: {text}"); + let expected = format!( + "{{\"schemaVersion\":2,\"items\":[{}]}}", + items + .iter() + .map(|item| item.as_str()) + .collect::>() + .join(",") + ); + assert_eq!(text, expected, "{label}"); + } + Ok(()) +} + +/// `/info` advertises `delegation-issued-query-v1` unconditionally. +#[tokio::test] +async fn issued_query_feature_is_advertised_by_info() -> Result<()> { + let fixture = KvSyncFixture::new_configured(Config::default(), |rocket| rocket).await?; + let response = fixture.client.get("/info").dispatch().await; + assert_eq!(response.status(), Status::Ok); + let info: serde_json::Value = + serde_json::from_str(&response.into_string().await.unwrap_or_default())?; + assert!( + info["features"] + .as_array() + .unwrap() + .iter() + .any(|feature| feature == "delegation-issued-query-v1"), + "{info}" + ); + Ok(()) +} From 6245fee763f3a7b779433f0f296a793c210658f8 Mon Sep 17 00:00:00 2001 From: dryrun Date: Mon, 26 Oct 2026 23:10:55 +0000 Subject: [PATCH 2/2] Test issued query depth caps, extra capabilities and did:pkh issuers Details: TC-2132 --- docs/releasing.md | 2 + tinycloud-core/src/db.rs | 139 ++++++++++- .../routes/tests/delegation_issued_query.rs | 216 ++++++++++++++++++ 3 files changed, 354 insertions(+), 3 deletions(-) diff --git a/docs/releasing.md b/docs/releasing.md index 9a88c576..67ac9d41 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -430,6 +430,8 @@ its branch policy before any workflow that names it runs. Compare `features` with the previous release (1.20.1 reports `kv`, `delegation`, `sharing`, `sql`, `hooks`, `signed-urls`, `encryption`, `kv-sync-v1`, `kv-sync-root`, `tee`). + A release that includes TC-2132 also reports `delegation-issued-query-v1`; + without it `POST /delegation/query` answers `direction: "issued"` with 400. - Every PostgreSQL connection got its TCP keepalive options (TC-1054, 1.20.5 on). The production logs are private, so this is read from `/info`, which carries two counts (no hosts or errors): diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index f1f6e2d8..e4815e4d 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -4868,10 +4868,22 @@ async fn account_query_principal( /// be the invoker's own rows. The did:pkh-only [`account_query_principal`] /// used by the other directions is a separate path and is not affected. fn issued_query_principal(invocation: &crate::util::InvocationInfo) -> Option { - if !invocation.parents.is_empty() { + issued_principal( + &invocation.capabilities, + &invocation.invoker, + !invocation.parents.is_empty(), + ) +} + +fn issued_principal( + capabilities: &[crate::util::Capability], + invoker: &str, + has_proof: bool, +) -> Option { + if has_proof { return None; } - let [capability] = invocation.capabilities.as_slice() else { + let [capability] = capabilities else { return None; }; if capability.ability.as_ref().as_ref() != "tinycloud.delegation/list" { @@ -4889,7 +4901,7 @@ fn issued_query_principal(invocation: &crate::util::InvocationInfo) -> Option ( + Vec, + Result, + ) { + use sea_orm::ActiveValue::Set; + + let db = get_db().await.unwrap(); + let issuer = "did:key:tc2132-issuer-a"; + let mine = seed_issued(&db.conn, issuer, 3).await; + let chain = seed_issued(&db.conn, "did:key:tc2132-ancestors", ancestors).await; + let mut links = Vec::new(); + for (index, ancestor) in chain.iter().enumerate() { + let child = if wide || index == 0 { + mine[0] + } else { + chain[index - 1] + }; + links.push((*ancestor, child)); + } + for chunk in links.chunks(500) { + parent_delegations::Entity::insert_many(chunk.iter().map(|(parent, child)| { + parent_delegations::ActiveModel { + parent: Set(*parent), + child: Set(*child), + } + })) + .exec(&db.conn) + .await + .unwrap(); + } + let page = issued_delegations_page(&db.conn, issuer, &issued_query(10, None)).await; + (mine, page) + } + + /// The ancestor walk allows 64 levels: the row plus 63 ancestors passes, + /// one more fails the whole page (no partial page), as on the account path. + #[tokio::test] + async fn issued_page_fails_whole_when_an_ancestor_chain_exceeds_the_depth_cap() { + let (mine, page) = issued_page_with_ancestors(63, false).await; + let page = page.expect("a chain at the cap passes"); + assert_eq!(page.items.len(), 3); + assert_eq!(page.items[0].cid, mine[0].to_cid(0x55).to_string()); + assert_eq!(page.items[0].parents.len(), 1); + + let (_, page) = issued_page_with_ancestors(64, false).await; + assert!( + matches!(&page, Err(AccountDelegationQueryError::Db(DbErr::Custom(reason))) + if reason == "account-ancestor-graph-depth-limit-exceeded"), + "{page:?}" + ); + } + + /// The ancestor graph allows 4096 nodes including the page's rows. + #[tokio::test] + async fn issued_page_fails_whole_when_the_ancestor_graph_exceeds_the_node_cap() { + // 3 page rows + 4093 parents = 4096 nodes. + let (_, page) = issued_page_with_ancestors(4093, true).await; + assert_eq!(page.expect("a graph at the cap passes").items.len(), 3); + + let (_, page) = issued_page_with_ancestors(4094, true).await; + assert!( + matches!(&page, Err(AccountDelegationQueryError::Db(DbErr::Custom(reason))) + if reason == "account-ancestor-graph-node-limit-exceeded"), + "{page:?}" + ); + } + + #[tokio::test] + async fn issued_page_lists_a_did_pkh_issuers_own_rows() { + let db = get_db().await.unwrap(); + let pkh = "did:pkh:eip155:1:0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"; + let mine = seed_issued(&db.conn, pkh, 4).await; + seed_issued(&db.conn, "did:key:tc2132-issuer-b", 4).await; + let page = issued_delegations_page(&db.conn, pkh, &issued_query(10, None)) + .await + .unwrap(); + assert_eq!( + page.items.iter().map(|i| i.cid.clone()).collect::>(), + cids(&mine) + ); + } + + #[test] + fn issued_principal_accepts_a_did_pkh_invoker_for_its_own_did_only() { + let capability = |did: &str| crate::util::Capability { + resource: Resource::TinyCloud( + SpaceId::new(did.parse::().unwrap(), "default".parse().unwrap()) + .to_resource("delegation".parse().unwrap(), None, None, None), + ), + ability: "tinycloud.delegation/list".to_string().try_into().unwrap(), + caveats: Default::default(), + }; + let lower = "did:pkh:eip155:1:0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"; + let checksum = "did:pkh:eip155:1:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + let other = "did:pkh:eip155:1:0x70997970c51812dc3a010c7d01b50e0d17dc79c8"; + + // The address spelling does not matter; the result is the canonical + // (checksummed) form that delegations store as their delegator. + for resource_did in [lower, checksum] { + assert_eq!( + issued_principal(&[capability(resource_did)], &format!("{lower}#x"), false), + Some(checksum.to_string()) + ); + } + assert_eq!(issued_principal(&[capability(other)], lower, false), None); + assert_eq!(issued_principal(&[capability(lower)], other, false), None); + // A proof, or a second capability, is refused even for the own DID. + assert_eq!(issued_principal(&[capability(lower)], lower, true), None); + assert_eq!( + issued_principal(&[capability(lower), capability(lower)], lower, false), + None + ); + assert_eq!(issued_principal(&[], lower, false), None); + } + #[tokio::test] async fn sqlite_account_cursor_index_migration_recovers_after_index_drop() { use sea_orm_migration::{MigrationTrait, SchemaManager}; diff --git a/tinycloud-node-server/src/routes/tests/delegation_issued_query.rs b/tinycloud-node-server/src/routes/tests/delegation_issued_query.rs index b00bf73e..d32f02d3 100644 --- a/tinycloud-node-server/src/routes/tests/delegation_issued_query.rs +++ b/tinycloud-node-server/src/routes/tests/delegation_issued_query.rs @@ -20,6 +20,7 @@ use tinycloud_auth::{ use tinycloud_core::{ hash::Hash, models::{abilities, actor, delegation}, + relationships::parent_delegations, sea_orm::{ActiveModelTrait, ActiveValue::Set}, types::DelegationQuery, }; @@ -80,6 +81,30 @@ impl Agent { .sign(self.jwk.get_algorithm().unwrap_or_default(), &self.jwk)? .encode()?) } + + /// A header carrying several `(resource, ability)` capabilities. + fn header_with(&self, capabilities: &[(&Resource, &str)], nonce: &str) -> Result { + let mut attenuation = Capabilities::new(); + for (resource, ability) in capabilities { + attenuation.with_action( + resource.to_string().parse()?, + ability.parse::()?, + [std::collections::BTreeMap::::new()], + ); + } + Ok(Payload { + issuer: self.vm.parse::()?, + audience: self.did.parse::()?, + not_before: None, + expiration: NumericDate::try_from_seconds(4_102_444_800.0)?, + nonce: Some(nonce.to_string()), + facts: Some(Vec::::new()), + proof: Vec::new(), + attenuation, + } + .sign(self.jwk.get_algorithm().unwrap_or_default(), &self.jwk)? + .encode()?) + } } /// Rows are seeded in one transaction that `serve` commits before the first @@ -157,6 +182,32 @@ impl Fixture { .await?; Ok(id) } + + /// Give `child` a chain of `count` ancestors, one level each. + async fn ancestors(&self, tag: &str, child: Hash, count: usize) -> Result<()> { + let issuer = "did:key:zIssuedAncestors"; + let mut child = child; + for n in 0..count { + let parent = self + .delegation( + &format!("{tag}-ancestor-{n}"), + issuer, + issuer, + 1_700_000_000, + kv_resource(issuer, "notes")?, + "tinycloud.kv/get", + ) + .await?; + parent_delegations::ActiveModel { + parent: Set(parent), + child: Set(child), + } + .insert(&self.conn) + .await?; + child = parent; + } + Ok(()) + } } struct Served { @@ -692,3 +743,168 @@ async fn issued_query_feature_is_advertised_by_info() -> Result<()> { ); Ok(()) } + +/// The ancestor walk allows 64 levels: a page whose row has 63 ancestors +/// passes, one with 64 fails whole with the same 503 as the account path. +#[tokio::test] +async fn issued_deep_ancestor_chain_is_the_same_503_as_the_account_path() -> Result<()> { + let fixture = Fixture::new().await?; + let (at_cap, over_cap, holder) = (Agent::new()?, Agent::new()?, Agent::new()?); + let wallet = "did:pkh:eip155:1:0x1111111111111111111111111111111111111111"; + let recipient = "did:key:zIssuedRecipient"; + for did in [ + at_cap.did.as_str(), + over_cap.did.as_str(), + holder.did.as_str(), + wallet, + recipient, + "did:key:zIssuedAncestors", + ] { + fixture.actor(did).await?; + } + for (agent, depth) in [(&at_cap, 63), (&over_cap, 64)] { + for name in ["shallow", "deep"] { + let id = fixture + .delegation( + &format!("{}-{name}", agent.did), + &agent.did, + recipient, + 1_700_000_000, + kv_resource(&agent.did, "notes")?, + "tinycloud.kv/get", + ) + .await?; + if name == "deep" { + fixture + .ancestors(&format!("issued-{depth}"), id, depth) + .await?; + } + } + } + // The account path over the same depth: a wallet row with 64 ancestors. + let control = SpaceId::new(wallet.parse::()?, "control".parse()?); + let list_resource = + Resource::TinyCloud(control.to_resource("delegation".parse()?, None, None, None)); + let session = fixture + .delegation( + "deep-session", + wallet, + &holder.did, + 1_700_000_100, + list_resource.clone(), + "tinycloud.delegation/list", + ) + .await?; + let wallet_row = fixture + .delegation( + "deep-wallet-row", + wallet, + recipient, + 1_700_000_000, + kv_resource(wallet, "notes")?, + "tinycloud.kv/get", + ) + .await?; + fixture.ancestors("account-64", wallet_row, 64).await?; + let fixture = fixture.serve().await?; + let issued = serde_json::json!({ "direction": "issued" }); + + let (status, text) = fixture + .query( + at_cap.header(&at_cap.list_resource(&at_cap.did)?, "at-cap", vec![])?, + issued.clone(), + ) + .await; + assert_eq!(status, Status::Ok, "{text}"); + assert_eq!(page_cids(&text)?.len(), 2); + + let over = fixture + .query( + over_cap.header(&over_cap.list_resource(&over_cap.did)?, "over-cap", vec![])?, + issued, + ) + .await; + assert_eq!(over.0, Status::ServiceUnavailable, "{}", over.1); + assert_eq!(over.1, "Delegation query unavailable"); + assert!(!over.1.contains("items"), "no partial page"); + + let account = fixture + .query( + holder.header(&list_resource, "account", vec![session.to_cid(0x55)])?, + serde_json::json!({ "direction": "all" }), + ) + .await; + assert_eq!(account, over, "the account path answers the same"); + Ok(()) +} + +#[tokio::test] +async fn issued_refuses_extra_capabilities_and_query_or_fragment_resources() -> Result<()> { + let fixture = Fixture::new().await?; + let a = Agent::new()?; + fixture.actor(&a.did).await?; + let fixture = fixture.serve().await?; + let issued = serde_json::json!({ "direction": "issued" }); + let own = a.list_resource(&a.did)?; + let space = SpaceId::new(a.did.parse::()?, "agent".parse()?); + let with_query = Resource::TinyCloud(space.clone().to_resource( + "delegation".parse()?, + None, + Some("scope=limited".parse()?), + None, + )); + let with_fragment = Resource::TinyCloud(space.to_resource( + "delegation".parse()?, + None, + None, + Some("limited".parse()?), + )); + + let (status, text) = fixture + .query( + a.header_with(&[(&own, "tinycloud.delegation/list")], "control")?, + issued.clone(), + ) + .await; + assert_eq!(status, Status::Ok, "one capability passes: {text}"); + + for (label, header) in [ + ( + "list plus status", + a.header_with( + &[ + (&own, "tinycloud.delegation/list"), + (&own, "tinycloud.delegation/status"), + ], + "plus-status", + )?, + ), + ( + "list plus list on a second resource", + a.header_with( + &[ + (&own, "tinycloud.delegation/list"), + (&with_query, "tinycloud.delegation/list"), + ], + "plus-resource", + )?, + ), + ( + "query on the resource", + a.header_with(&[(&with_query, "tinycloud.delegation/list")], "query")?, + ), + ( + "fragment on the resource", + a.header_with(&[(&with_fragment, "tinycloud.delegation/list")], "fragment")?, + ), + ] { + let (status, text) = fixture.query(header, issued.clone()).await; + assert_eq!(status, Status::Forbidden, "{label}: {text}"); + } + Ok(()) +} + +// A did:pkh invoker cannot be signed for in this harness (a wallet signs a +// SIWE message, not a UCAN). `issued_principal_accepts_a_did_pkh_invoker_for_its_own_did_only` +// and `issued_page_lists_a_did_pkh_issuers_own_rows` in `tinycloud-core` +// cover it.