diff --git a/CHANGELOG.md b/CHANGELOG.md index 24b6cb28..1e22cb63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- Account delegation session discovery keyset-pages delegation rows independently of ability fan-out, using a composite delegator/id index so large grant sets are not truncated (TC-1195). + ## [1.20.7] - 2026-10-14 A security hardening release (TC-1132, #292) for webhook, quota and tunnel egress. diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index d04594bb..d1e6d8c0 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -4096,12 +4096,13 @@ async fn account_query_principal( } const MAX_ACCOUNT_SESSION_NODES: usize = 256; -const MAX_ACCOUNT_SESSION_EDGES_PER_LEVEL: u64 = 1025; +const ACCOUNT_SESSION_DELEGATION_PAGE_SIZE: u64 = 512; const MAX_ACCOUNT_ANCESTOR_NODES: usize = 4096; /// Discover only session/control DIDs that were delegated the account's list /// capability. Ordinary recipients are deliberately not pulled into the -/// account relationship graph. +/// account relationship graph. Delegations are keyset-paged independently of +/// their abilities, so neither fan-out nor a large grant set truncates discovery. async fn account_session_dids( db: &C, principal: &str, @@ -4112,44 +4113,54 @@ async fn account_session_dids( if frontier.is_empty() { return Ok(account_dids); } - let rows = delegation::Entity::find() - .filter(delegation::Column::Delegator.is_in(frontier.clone())) - .limit(MAX_ACCOUNT_SESSION_EDGES_PER_LEVEL) - .find_with_related(abilities::Entity) - .all(db) - .await?; - if rows.len() as u64 == MAX_ACCOUNT_SESSION_EDGES_PER_LEVEL { - return Err(DbErr::Custom( - "account-session-graph-level-limit-exceeded".to_string(), - )); - } let mut next = Vec::new(); - for (delegation, abilities) in rows { - let controls_account = abilities.iter().any(|ability| { - ability.ability.as_ref().as_ref() == "tinycloud.delegation/list" - && ability - .resource - .tinycloud_resource() - .is_some_and(|resource| { - resource.service().as_str() == "delegation" - && resource - .path() - .map(|path| path.as_str().is_empty()) - .unwrap_or(true) - && resource.query().is_none() - && resource.fragment().is_none() - && did_principal_matches(resource.space().did().as_str(), principal) - }) - }); - if controls_account - && delegation.delegatee.starts_with("did:key:") - && account_dids.insert(delegation.delegatee.clone()) - { - next.push(delegation.delegatee); - if account_dids.len() > MAX_ACCOUNT_SESSION_NODES { - return Err(DbErr::Custom( - "account-session-graph-node-limit-exceeded".to_string(), - )); + for delegator in &frontier { + let mut after = None; + loop { + let mut query = delegation::Entity::find() + .filter(delegation::Column::Delegator.eq(delegator.as_str())) + .order_by_asc(delegation::Column::Id) + .limit(ACCOUNT_SESSION_DELEGATION_PAGE_SIZE); + if let Some(id) = after { + query = query.filter(delegation::Column::Id.gt(id)); + } + let delegations = query.all(db).await?; + let Some(last) = delegations.last() else { + break; + }; + after = Some(last.id); + let ability_rows = delegations.load_many(abilities::Entity, db).await?; + for (delegation, abilities) in delegations.into_iter().zip(ability_rows) { + let controls_account = abilities.iter().any(|ability| { + ability.ability.as_ref().as_ref() == "tinycloud.delegation/list" + && ability + .resource + .tinycloud_resource() + .is_some_and(|resource| { + resource.service().as_str() == "delegation" + && resource + .path() + .map(|path| path.as_str().is_empty()) + .unwrap_or(true) + && resource.query().is_none() + && resource.fragment().is_none() + && did_principal_matches( + resource.space().did().as_str(), + principal, + ) + }) + }); + if controls_account + && delegation.delegatee.starts_with("did:key:") + && account_dids.insert(delegation.delegatee.clone()) + { + next.push(delegation.delegatee); + if account_dids.len() > MAX_ACCOUNT_SESSION_NODES { + return Err(DbErr::Custom( + "account-session-graph-node-limit-exceeded".to_string(), + )); + } + } } } } @@ -4602,6 +4613,12 @@ mod test { } } + fn maximum_test_hash() -> Hash { + use tinycloud_auth::multihash_codetable::{Code, MultihashDigest}; + + Hash::from(Code::Blake3_256.wrap(&[0xff; 32]).unwrap()) + } + #[tokio::test] async fn sqlite_write_transaction_drop_rolls_back_and_connection_is_reusable() { let directory = tempfile::tempdir().unwrap(); @@ -5864,6 +5881,462 @@ mod test { ); } + #[tokio::test] + async fn account_session_discovery_paginates_delegations_not_ability_rows() { + use sea_orm::{ActiveModelTrait, ActiveValue::Set}; + + let db = get_db().await.unwrap(); + let principal = "did:pkh:eip155:1:0x0000000000000000000000000000000000000001"; + let principal_suffix = principal.strip_prefix("did:").unwrap(); + test_actor(principal).insert(&db.conn).await.unwrap(); + + for sign_in in 0..35 { + let session = format!("did:key:session-{sign_in:02}"); + test_actor(&session).insert(&db.conn).await.unwrap(); + for delegation_index in 0..15 { + let id = crate::hash::hash( + format!("tc1195-signin-{sign_in}-{delegation_index}").as_bytes(), + ); + let delegatee = if delegation_index == 0 { + session.clone() + } else { + format!("did:key:recipient-{sign_in}-{delegation_index}") + }; + if delegation_index != 0 { + test_actor(&delegatee).insert(&db.conn).await.unwrap(); + } + delegation::ActiveModel { + id: Set(id), + delegator: Set(principal.to_string()), + delegatee: Set(delegatee), + expiry: Set(None), + issued_at: Set(None), + not_before: Set(None), + facts: Set(None), + serialization: Set(id.as_ref().to_vec()), + } + .insert(&db.conn) + .await + .unwrap(); + for ability_index in 0..6 { + let (resource, ability) = if delegation_index == 0 && ability_index == 0 { + ( + format!("tinycloud:{principal_suffix}:default/delegation"), + "tinycloud.delegation/list", + ) + } else { + ( + format!("tinycloud:{principal_suffix}:default/kv/key-{ability_index}"), + "tinycloud.kv/get", + ) + }; + abilities::ActiveModel { + resource: Set(resource.parse().unwrap()), + ability: Set(ability.to_string().try_into().unwrap()), + delegation: Set(id), + caveats: Set(Default::default()), + } + .insert(&db.conn) + .await + .unwrap(); + } + } + } + let later_session = "did:key:session-after-page"; + test_actor(later_session).insert(&db.conn).await.unwrap(); + let later_id = maximum_test_hash(); + delegation::ActiveModel { + id: Set(later_id), + delegator: Set(principal.to_string()), + delegatee: Set(later_session.to_string()), + expiry: Set(None), + issued_at: Set(None), + not_before: Set(None), + facts: Set(None), + serialization: Set(later_id.as_ref().to_vec()), + } + .insert(&db.conn) + .await + .unwrap(); + abilities::ActiveModel { + resource: Set(format!("tinycloud:{principal_suffix}:default/delegation") + .parse() + .unwrap()), + ability: Set("tinycloud.delegation/list".to_string().try_into().unwrap()), + delegation: Set(later_id), + caveats: Set(Default::default()), + } + .insert(&db.conn) + .await + .unwrap(); + + let page_boundary = delegation::Entity::find() + .filter(delegation::Column::Delegator.eq(principal)) + .order_by_asc(delegation::Column::Id) + .limit(ACCOUNT_SESSION_DELEGATION_PAGE_SIZE) + .all(&db.conn) + .await + .unwrap() + .last() + .unwrap() + .id; + assert!( + later_id > page_boundary, + "the control grant is after page one" + ); + + let discovered = account_session_dids(&db.conn, principal).await.unwrap(); + assert_eq!(discovered.len(), 37); + assert!(discovered.contains(later_session)); + for sign_in in 0..35 { + assert!( + discovered.contains(&format!("did:key:session-{sign_in:02}")), + "session {sign_in} was omitted" + ); + } + } + + #[tokio::test] + async fn account_session_discovery_pages_past_the_edge_limit_boundary() { + use sea_orm::{ActiveModelTrait, ActiveValue::Set}; + + for count in [1023, 1024, 1025] { + let db = get_db().await.unwrap(); + let principal = "did:pkh:eip155:1:0x0000000000000000000000000000000000000001"; + let principal_suffix = principal.strip_prefix("did:").unwrap(); + let session = "did:key:tc1195-boundary-session"; + let later_session = "did:key:tc1195-boundary-later-session"; + test_actor(principal).insert(&db.conn).await.unwrap(); + test_actor(session).insert(&db.conn).await.unwrap(); + test_actor(later_session).insert(&db.conn).await.unwrap(); + + let control_id = crate::hash::hash(format!("tc1195-control-{count}").as_bytes()); + delegation::ActiveModel { + id: Set(control_id), + delegator: Set(principal.to_string()), + delegatee: Set(session.to_string()), + expiry: Set(None), + issued_at: Set(None), + not_before: Set(None), + facts: Set(None), + serialization: Set(control_id.as_ref().to_vec()), + } + .insert(&db.conn) + .await + .unwrap(); + abilities::ActiveModel { + resource: Set(format!("tinycloud:{principal_suffix}:default/delegation") + .parse() + .unwrap()), + ability: Set("tinycloud.delegation/list".to_string().try_into().unwrap()), + delegation: Set(control_id), + caveats: Set(Default::default()), + } + .insert(&db.conn) + .await + .unwrap(); + + for index in 0..count { + let id = crate::hash::hash(format!("tc1195-grant-{count}-{index}").as_bytes()); + let delegatee = format!("did:key:tc1195-boundary-recipient-{index}"); + test_actor(&delegatee).insert(&db.conn).await.unwrap(); + delegation::ActiveModel { + id: Set(id), + delegator: Set(session.to_string()), + delegatee: Set(delegatee), + expiry: Set(None), + issued_at: Set(None), + not_before: Set(None), + facts: Set(None), + serialization: Set(id.as_ref().to_vec()), + } + .insert(&db.conn) + .await + .unwrap(); + abilities::ActiveModel { + resource: Set( + format!("tinycloud:{principal_suffix}:default/kv/key-{index}") + .parse() + .unwrap(), + ), + ability: Set("tinycloud.kv/get".to_string().try_into().unwrap()), + delegation: Set(id), + caveats: Set(Default::default()), + } + .insert(&db.conn) + .await + .unwrap(); + } + let later_id = maximum_test_hash(); + delegation::ActiveModel { + id: Set(later_id), + delegator: Set(session.to_string()), + delegatee: Set(later_session.to_string()), + expiry: Set(None), + issued_at: Set(None), + not_before: Set(None), + facts: Set(None), + serialization: Set(later_id.as_ref().to_vec()), + } + .insert(&db.conn) + .await + .unwrap(); + abilities::ActiveModel { + resource: Set(format!("tinycloud:{principal_suffix}:default/delegation") + .parse() + .unwrap()), + ability: Set("tinycloud.delegation/list".to_string().try_into().unwrap()), + delegation: Set(later_id), + caveats: Set(Default::default()), + } + .insert(&db.conn) + .await + .unwrap(); + + let page_boundary = delegation::Entity::find() + .filter(delegation::Column::Delegator.eq(session)) + .order_by_asc(delegation::Column::Id) + .limit(ACCOUNT_SESSION_DELEGATION_PAGE_SIZE) + .all(&db.conn) + .await + .unwrap() + .last() + .unwrap() + .id; + assert!( + later_id > page_boundary, + "the control grant is after page one" + ); + + let discovered = account_session_dids(&db.conn, principal).await.unwrap(); + assert!(discovered.contains(later_session)); + assert!( + discovered.contains(session), + "{count} session grants must retain their session DID" + ); + let matching_grants = delegation::Entity::find() + .filter(delegation::Column::Delegator.eq(session)) + .find_with_related(abilities::Entity) + .all(&db.conn) + .await + .unwrap(); + assert_eq!(matching_grants.len(), count + 1); + assert!( + matching_grants + .iter() + .all(|(_, abilities)| abilities.len() == 1), + "{count} grants and the later control each have one ability" + ); + assert_eq!( + matching_grants + .iter() + .filter(|(_, abilities)| { + abilities[0].ability.as_ref().as_ref() == "tinycloud.kv/get" + }) + .count(), + count + ); + } + } + + #[tokio::test] + async fn account_session_keyset_query_uses_composite_delegator_index() { + let db = get_db().await.unwrap(); + let mut plan = Vec::new(); + for delegator in ["did:key:tc1195-index-a", "did:key:tc1195-index-b"] { + let mut statement = delegation::Entity::find() + .filter(delegation::Column::Delegator.eq(delegator)) + .filter(delegation::Column::Id.gt(crate::hash::hash(b"tc1195-cursor"))) + .order_by_asc(delegation::Column::Id) + .limit(ACCOUNT_SESSION_DELEGATION_PAGE_SIZE) + .build(DbBackend::Sqlite); + statement.sql = format!("EXPLAIN QUERY PLAN {}", statement.sql); + plan.extend( + db.conn + .query_all(statement) + .await + .unwrap() + .into_iter() + .map(|row| row.try_get::("", "detail").unwrap()), + ); + } + assert_eq!( + plan.iter() + .filter(|line| { + line.contains("idx_delegation_delegator") && line.contains("id>?") + }) + .count(), + 2, + "each frontier DID must use the composite cursor index: {plan:?}" + ); + assert!( + plan.iter() + .all(|line| !line.contains("USE TEMP B-TREE FOR ORDER BY")), + "per-DID pagination must not sort pages: {plan:?}" + ); + } + #[tokio::test] + async fn sqlite_account_cursor_index_migration_recovers_after_index_drop() { + use sea_orm_migration::{MigrationTrait, SchemaManager}; + + async fn index_columns(db: &sea_orm::DatabaseConnection) -> Vec { + db.query_all(Statement::from_string( + DbBackend::Sqlite, + "PRAGMA index_info('idx_delegation_delegator')".to_string(), + )) + .await + .unwrap() + .into_iter() + .map(|row| row.try_get::("", "name").unwrap()) + .collect() + } + + let db = get_db().await.unwrap(); + let migration = crate::migrations::m20261015_000000_delegation_cursor_index::Migration; + let manager = SchemaManager::new(&db.conn); + db.conn + .execute(Statement::from_string( + DbBackend::Sqlite, + "DROP INDEX idx_delegation_delegator".to_string(), + )) + .await + .unwrap(); + migration.up(&manager).await.unwrap(); + assert_eq!(index_columns(&db.conn).await, ["delegator", "id"]); + + db.conn + .execute(Statement::from_string( + DbBackend::Sqlite, + "DROP INDEX idx_delegation_delegator".to_string(), + )) + .await + .unwrap(); + migration.down(&manager).await.unwrap(); + assert_eq!(index_columns(&db.conn).await, ["delegator"]); + } + + #[tokio::test] + async fn postgres_account_session_discovery_paginates_ability_fanout() { + use sea_orm::ActiveValue::Set; + + let Some(schema) = crate::test_support::PostgresTestSchema::new( + "postgres_account_session_discovery_paginates_ability_fanout", + ) + .await + else { + return; + }; + let conn = &schema.conn; + let principal = "did:pkh:eip155:1:0x0000000000000000000000000000000000000001"; + let principal_suffix = principal.strip_prefix("did:").unwrap(); + let mut actors = vec![test_actor(principal)]; + let mut delegations = Vec::new(); + let mut ability_rows = Vec::new(); + + for sign_in in 0..35 { + let session = format!("did:key:tc1195-pg-session-{sign_in:02}"); + actors.push(test_actor(&session)); + for delegation_index in 0..15 { + let id = crate::hash::hash( + format!("tc1195-pg-signin-{sign_in}-{delegation_index}").as_bytes(), + ); + let delegatee = if delegation_index == 0 { + session.clone() + } else { + let recipient = + format!("did:key:tc1195-pg-recipient-{sign_in}-{delegation_index}"); + actors.push(test_actor(&recipient)); + recipient + }; + delegations.push(delegation::ActiveModel { + id: Set(id), + delegator: Set(principal.to_string()), + delegatee: Set(delegatee), + expiry: Set(None), + issued_at: Set(None), + not_before: Set(None), + facts: Set(None), + serialization: Set(id.as_ref().to_vec()), + }); + for ability_index in 0..6 { + let (resource, ability) = if delegation_index == 0 && ability_index == 0 { + ( + format!("tinycloud:{principal_suffix}:default/delegation"), + "tinycloud.delegation/list", + ) + } else { + ( + format!( + "tinycloud:{principal_suffix}:default/kv/key-{sign_in}-{delegation_index}-{ability_index}" + ), + "tinycloud.kv/get", + ) + }; + ability_rows.push(abilities::ActiveModel { + resource: Set(resource.parse().unwrap()), + ability: Set(ability.to_string().try_into().unwrap()), + delegation: Set(id), + caveats: Set(Default::default()), + }); + } + } + } + let later_session = "did:key:tc1195-pg-session-after-page"; + actors.push(test_actor(later_session)); + let later_id = maximum_test_hash(); + delegations.push(delegation::ActiveModel { + id: Set(later_id), + delegator: Set(principal.to_string()), + delegatee: Set(later_session.to_string()), + expiry: Set(None), + issued_at: Set(None), + not_before: Set(None), + facts: Set(None), + serialization: Set(later_id.as_ref().to_vec()), + }); + ability_rows.push(abilities::ActiveModel { + resource: Set(format!("tinycloud:{principal_suffix}:default/delegation") + .parse() + .unwrap()), + ability: Set("tinycloud.delegation/list".to_string().try_into().unwrap()), + delegation: Set(later_id), + caveats: Set(Default::default()), + }); + actor::Entity::insert_many(actors).exec(conn).await.unwrap(); + delegation::Entity::insert_many(delegations) + .exec(conn) + .await + .unwrap(); + abilities::Entity::insert_many(ability_rows) + .exec(conn) + .await + .unwrap(); + + let page_boundary = delegation::Entity::find() + .filter(delegation::Column::Delegator.eq(principal)) + .order_by_asc(delegation::Column::Id) + .limit(ACCOUNT_SESSION_DELEGATION_PAGE_SIZE) + .all(conn) + .await + .unwrap() + .last() + .unwrap() + .id; + assert!( + later_id > page_boundary, + "the control grant is after page one" + ); + let discovered = account_session_dids(conn, principal).await.unwrap(); + assert_eq!(discovered.len(), 37); + assert!(discovered.contains(later_session)); + for sign_in in 0..35 { + assert!( + discovered.contains(&format!("did:key:tc1195-pg-session-{sign_in:02}")), + "PostgreSQL omitted session {sign_in}" + ); + } + } + #[tokio::test] async fn account_query_groups_resources_and_distinguishes_ancestor_revocation() { use sea_orm::{ActiveModelTrait, ActiveValue::Set}; diff --git a/tinycloud-core/src/migrations/m20261015_000000_delegation_cursor_index.rs b/tinycloud-core/src/migrations/m20261015_000000_delegation_cursor_index.rs new file mode 100644 index 00000000..09074535 --- /dev/null +++ b/tinycloud-core/src/migrations/m20261015_000000_delegation_cursor_index.rs @@ -0,0 +1,55 @@ +use sea_orm_migration::prelude::*; + +use crate::models::delegation; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +async fn drop_index_if_exists(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + if manager + .has_index("delegation", "idx_delegation_delegator") + .await? + { + manager + .drop_index( + Index::drop() + .name("idx_delegation_delegator") + .table(delegation::Entity) + .to_owned(), + ) + .await?; + } + Ok(()) +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + drop_index_if_exists(manager).await?; + manager + .create_index( + Index::create() + .name("idx_delegation_delegator") + .table(delegation::Entity) + .col(delegation::Column::Delegator) + .col(delegation::Column::Id) + .if_not_exists() + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + drop_index_if_exists(manager).await?; + manager + .create_index( + Index::create() + .name("idx_delegation_delegator") + .table(delegation::Entity) + .col(delegation::Column::Delegator) + .if_not_exists() + .to_owned(), + ) + .await + } +} diff --git a/tinycloud-core/src/migrations/mod.rs b/tinycloud-core/src/migrations/mod.rs index b73c241c..ee7f0d2f 100644 --- a/tinycloud-core/src/migrations/mod.rs +++ b/tinycloud-core/src/migrations/mod.rs @@ -24,6 +24,7 @@ pub mod m20261005_000000_current_kv_sync_order; pub mod m20261005_000000_deactivate_hook_subscriptions; pub mod m20261007_000000_database_alias; pub mod m20261007_010000_database_identity_fence; +pub mod m20261015_000000_delegation_cursor_index; pub struct Migrator; @@ -56,6 +57,7 @@ impl MigratorTrait for Migrator { Box::new(m20261005_000000_deactivate_hook_subscriptions::Migration), Box::new(m20261007_000000_database_alias::Migration), Box::new(m20261007_010000_database_identity_fence::Migration), + Box::new(m20261015_000000_delegation_cursor_index::Migration), ] } }