diff --git a/CHANGELOG.md b/CHANGELOG.md index fb211081..298f7e9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ - SQL local durability and reservation before commit (`docs/sql-local-durability.md`). Postgres stays the master copy. File-backed SQL databases run `PRAGMA synchronous = FULL` (`[global.storage.sql] synchronous = "full"|"normal"`, env `TINYCLOUD_STORAGE__SQL__SYNCHRONOUS`; `normal` selects the earlier fsync behaviour), and the directories and renames that make a database file or a hydrated cache visible are fsynced. A write is checked before COMMIT and rolled back whole if it would pass the owner's remaining storage budget (`402`), and, when configured, the per-database cap (`max_database_bytes`, `507 sql_database_size_limit`) or the disk headroom (`min_free_bytes`, bounded by the WAL and checkpoint growth the transaction can cause and shared by all databases on the node, `507 sql_disk_headroom`). `max_database_bytes` and `min_free_bytes` default to `0` (off) and are set by config. A COMMIT that fails on disk answers `500 sql_commit_failed` and the node re-hydrates the database from Postgres. A write that fails because the disk is full answers `507 sql_disk_headroom`, whatever `min_free_bytes` is, not the owner's quota or the size limit. Every write request runs in one transaction (a single statement, a batch, a nested write (`WITH … INSERT`) and a request that carries a `PRAGMA` included), so such a request counts two more native SQL calls in request telemetry, and all of them get the same owner budget, size cap and disk gate. A request that wrote nothing is not gated and reserves nothing. The growth of the main file that the next checkpoint will cause stays held in the disk reservation from the commit until a checkpoint runs or the database's connection closes. No migration. Details: TC-1491. - `MemorySink` caps its size hint at 64 MiB and treats a failed reservation as no hint. Details: TC-1491. - A local SQL commit failure (answered 500) records the same diagnostic failure note as other SQL 500 responses. Details: TC-1491. +- Policy/v3 registration checks the owner and existence of the policy's encryption networks. Every network the policy names (`contentSource.encryptionNetwork` and each `encryption` ceiling entry) must be owned by the owner of the content source's space, compared as principals (a `did:pkh` matches regardless of EIP-55 letter case; the chain id must match), and must exist on this node in state `active`. Every `kv` ceiling entry must name a space of that same owner. Otherwise registration returns `403 content-source-network-invalid` and writes nothing. A new registration whose `kvResource` or any `kv` ceiling entry uses the legacy `tinycloud://` form gets the same refusal; no client emits it. Share and CLI registrations carry one `kv` entry, equal to the source, and name the space owner's `:default` network, which the SDK creates or finds at sign-in, so they are unaffected. Stored registrations are not refused; they are classified with the same ownership rule (see the next entry). Refusals are counted as `tinycloud_policy_source_network_refused_total{reason}`, with reasons `unparsable`, `legacy_resource`, `foreign_owner`, `foreign_space`, `missing` and `inactive`. Details: TC-1423. +- Policy/v3 registration records which of the owner's delegations granted what the policy roots carry, with their ancestor closure, in two new tables (migration `m20261019_000000_policy_v3_owner_authority`; its `down` drops both). Delegations from the owner of the capability's space or encryption network are tried before the general window of recent delegations to the owner that touch that space or network; a delegation whose ancestry includes one issued by the policy owner is never recorded; a grant that outlives the root is preferred. Policies registered before the migration are classified in the background on first use (steady-state reads add no query), or by an opt-in sweep (`policy_v3.owner_binding_backfill.enabled`, off by default). A stored policy that names an encryption network or a `kv` space that does not belong to the owner of its content source is classified `unknown` with reason `foreign_network`. The legacy cutover defaults to the migration's applied-at time, is logged at boot, and `policy_v3.legacy_cutover` overrides it after a rollback and re-upgrade. New `POST /policy/v3/authorized` lists the policies whose recorded authority includes a delegation, authorized like `/delegation/status`. See `docs/policy-owner-authority.md`. Details: TC-1423. ## [1.22.0] - 2026-10-23 diff --git a/docs/policy-owner-authority.md b/docs/policy-owner-authority.md new file mode 100644 index 00000000..62025e96 --- /dev/null +++ b/docs/policy-owner-authority.md @@ -0,0 +1,144 @@ +# Policy/v3 owner authority (TC-1423) + +A Policy/v3 policy root is parentless and signed by the policy owner, so the +ordinary delegation graph cannot say which of the owner's delegations its +authority came from. The node records that at registration. + +## What is recorded + +Two tables, created by migration `m20261019_000000_policy_v3_owner_authority`: + +- `policy_v3_owner_binding`, one row per policy: `state`, `authority_expires_at` + (minimum expiry over the recorded closure, RFC 3339 UTC, NULL when nothing + expires), `unknown_reason`, `classifier_version`, `registered_mode`, + `created_at`. A missing row means the policy has not been classified yet. +- `policy_v3_owner_authority`, `(policy_cid, delegation_id, depth)`: the whole + ancestor closure of the delegations the owner used, at most 64 nodes (the + core traversal limit). Indexed by `(delegation_id, policy_cid)`. + +| state | meaning | +|---|---| +| `bound` | at least one delegation recorded; the root expires no later than it | +| `bound-uncapped` | at least one delegation recorded; the root outlives it | +| `root-authority` | every root capability is held as root authority (space or network owner) | +| `unknown` | no authority found; `unknown_reason` is `revoked`, `missing`, `starved` or `foreign_network` | + +`registered_mode` is `record` for policies classified at registration, and +`backfill` for policies registered before the legacy cutover and classified +later. + +## Selection + +Both roots carry the same capabilities, so one selection covers both. For +each capability: + +1. Root authority needs no delegation. +2. Owner-first: delegations to the owner issued by the owner of the + capability's space or encryption network (newest first, 64 rows). Only that + principal can create them, so delegations from other principals cannot + push them out of the window. +3. Otherwise the general window: the 256 newest delegations to the owner that + carry an ability on the capability's space or network (only holders of + authority derived from that space or network can create those), of which + at most 32 are tried. + +A candidate whose ancestor closure (the candidate included) contains a +delegation issued by the policy owner is skipped, without using up a try. +Among candidates that grant the capability, the first, newest first, whose +closure outlives the policy root is chosen, else the newest. `starved` is +recorded only when both steps found nothing and more candidates exist than +were examined; unlike the other `unknown` reasons it is not final and is +classified again on a later use. + +## Source networks + +A registration is also held to the source-network rule that new registrations +must meet (`content_owner_networks`): every encryption network the policy +names, and every `kv` ceiling entry, must belong to the owner of the content +source's space. A policy registered before that rule, or by a binary that +predates it, that does not meet it is classified `unknown` with reason +`foreign_network`, in either mode, whatever delegations the owner holds. The +rule is a pure check of the stored policy: it does not look up whether the +network exists. `foreign_network` is final: the row is not classified again +unless `classifier_version` changes. + +## Policies registered earlier + +A policy with no binding row is classified in the background on first use, +off the request: the selection runs as of its registration time (delegations +issued after it are not considered; UCANs carry no issue time and stay +eligible), then one transaction inserts the binding row first and its closure +after it. A concurrent writer that stored a row first wins. One task runs per +policy, and at most three in the whole process; a use that finds no free slot +starts none (a later use does). If the write fails (lock timeout, SQLite busy) or the result +is `starved`, the row stays pending and the request path doesn't start another +attempt for 60 seconds. Policies whose stored classification is final are +remembered in process (bounded), so steady-state reads add no query. An +`unknown` row written by an older `classifier_version` is classified again. + +The opt-in sweep classifies pending policies off the request path: + +```toml +[policy_v3.owner_binding_backfill] +enabled = false # TINYCLOUD_POLICY_V3__OWNER_BINDING_BACKFILL__ENABLED +policies_per_second = 1 # TINYCLOUD_POLICY_V3__OWNER_BINDING_BACKFILL__POLICIES_PER_SECOND +``` + +It visits every pending policy once after boot, at most `policies_per_second`, +pausing after each policy at least as long as it took. + +## Legacy cutover + +Only policies registered before the cutover are `backfill`. The cutover is the +applied-at time of the migration above, unless configured: + +```toml +[policy_v3] +legacy_cutover = "2026-10-20T00:00:00Z" # TINYCLOUD_POLICY_V3__LEGACY_CUTOVER +``` + +The node logs the effective cutover and its source (`config` or `migration`) +at boot: at `warn` when the source is the migration and its applied-at time is +less than a day before boot (a fresh migration, or a re-upgrade that needs the +override), otherwise at `info`. + +## Rollback + +The migration is unknown to the previous release image (1.22.0), which refuses to boot while `seaql_migrations` lists it. To roll back, run on the database: `DROP TABLE IF EXISTS policy_v3_owner_authority; DROP TABLE IF EXISTS policy_v3_owner_binding; DELETE FROM seaql_migrations WHERE version = 'm20261019_000000_policy_v3_owner_authority';` (the same as the migration's `down`), then deploy 1.22.0. If a migration released after this one has also been applied, delete its row too, as its release notes say. Policies registered while this release ran stay registered and get only the checks 1.22.0 has. On the re-upgrade, `policy_v3.legacy_cutover` must be set to this release's first deploy time; otherwise every policy registered during the rollback window, including roots longer than 31 days, is treated as legacy. + +Checklist (tick each step): + +1. [ ] Run the three statements above on the database. +2. [ ] If a migration released after this one has been applied, delete its `seaql_migrations` row as well, as its release notes say. +3. [ ] Deploy 1.22.0 and check that the node boots. +4. [ ] Before the re-upgrade starts, set `policy_v3.legacy_cutover` (`TINYCLOUD_POLICY_V3__LEGACY_CUTOVER`) to this release's first deploy time, as an RFC 3339 UTC timestamp. This is the time of the first deploy of this release, not the time of the re-upgrade. +5. [ ] After the re-upgrade, check that the boot log shows the effective cutover with source `config` and the value from step 4. + +## Listing + +`POST /policy/v3/authorized` with body `{"cid": "", "cursor": null}` +lists the policies whose recorded closure contains that delegation (or a CID +persisted as the same delegation). It is authorized like `/delegation/status` +on `urn:cid:`: one `tinycloud.delegation/status` capability on that +resource, from the delegation's issuer or delegatee, a space owner it targets, +or the holder of a status control. Anyone else gets `403`. + +```json +{ + "policies": [{ + "policyCid": "...", + "policyRootCid": "...", + "enforcementRootCid": "...", + "registeredAt": "...", + "expiresAt": "...", + "rootState": "active | expired | revoked", + "ownerAuthorityState": "bound | bound-uncapped | root-authority | unknown" + }], + "nextCursor": "" +} +``` + +Pages hold at most 100 policies in policy-CID order; `nextCursor` is set when a +page is full and passed back as `cursor`. There is no total count. Policies +not yet classified (legacy policies not used since the upgrade, with the sweep +off) are not listed. Database errors return `503 policy-listing-unavailable`. diff --git a/tinycloud-core/src/auth_graph.rs b/tinycloud-core/src/auth_graph.rs index 44c7e050..68b4f4d1 100644 --- a/tinycloud-core/src/auth_graph.rs +++ b/tinycloud-core/src/auth_graph.rs @@ -27,8 +27,8 @@ use crate::models::revocation::MAX_CHAIN_TRAVERSAL_NODES; /// fetches all reachable edges in one query rather than walking one node at a /// time. The in-memory pass enforces the same fail-closed node budget as the /// per-node traversal used by the delegate/revoke paths. Production locks use -/// `load_chain_lock_ids`; tests keep this as the sequential edge oracle. -#[cfg(test)] +/// `load_chain_lock_ids`; `policy_owner_authority::load_closure` also needs the +/// raw (non-canonical) parent edges from this loader. pub(crate) async fn load_closure_edges( db: &C, roots: &[Hash], diff --git a/tinycloud-core/src/lib.rs b/tinycloud-core/src/lib.rs index 4f240064..94b017d5 100644 --- a/tinycloud-core/src/lib.rs +++ b/tinycloud-core/src/lib.rs @@ -24,6 +24,7 @@ pub mod migrations; pub mod models; pub mod policy_authority; pub mod policy_capability; +pub mod policy_owner_authority; mod read_audit; pub mod relationships; pub mod request_cancel; diff --git a/tinycloud-core/src/migrations/m20261019_000000_policy_v3_owner_authority.rs b/tinycloud-core/src/migrations/m20261019_000000_policy_v3_owner_authority.rs new file mode 100644 index 00000000..20ceb89b --- /dev/null +++ b/tinycloud-core/src/migrations/m20261019_000000_policy_v3_owner_authority.rs @@ -0,0 +1,129 @@ +//! TC-1423: record which delegations a Policy/v3 owner held as authority. +//! +//! Two new, empty tables and one index; no ALTER of `policy_v3_registration` +//! and no foreign keys (as in m20261018), so the migration is O(1) and an +//! older binary simply ignores the tables. Existing registrations are +//! classified lazily on first use, or by the node's opt-in sweep. +//! +//! Rollback: run `down` (two `DROP TABLE`s), then deploy the previous binary. +//! The applied-at time of this migration is the default legacy cutover; after +//! a rollback and re-upgrade, set `policy_v3.legacy_cutover` to the first +//! deploy time. + +use sea_orm_migration::prelude::*; + +use crate::models::{policy_v3_owner_authority, policy_v3_owner_binding}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +const AUTHORITY_BY_DELEGATION_INDEX: &str = "idx_policy_v3_owner_authority_delegation"; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(policy_v3_owner_binding::Entity) + .if_not_exists() + .col( + ColumnDef::new(policy_v3_owner_binding::Column::PolicyCid) + .string() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(policy_v3_owner_binding::Column::State) + .string() + .not_null(), + ) + .col( + ColumnDef::new(policy_v3_owner_binding::Column::AuthorityExpiresAt) + .string(), + ) + .col(ColumnDef::new(policy_v3_owner_binding::Column::UnknownReason).string()) + .col( + ColumnDef::new(policy_v3_owner_binding::Column::ClassifierVersion) + .integer() + .not_null(), + ) + .col( + ColumnDef::new(policy_v3_owner_binding::Column::RegisteredMode) + .string() + .not_null(), + ) + .col( + ColumnDef::new(policy_v3_owner_binding::Column::CreatedAt) + .string() + .not_null(), + ) + .to_owned(), + ) + .await?; + + let mut delegation_id = ColumnDef::new(policy_v3_owner_authority::Column::DelegationId); + if manager.get_database_backend() == sea_orm::DbBackend::MySql { + // As in m20261014: MySQL's bare BINARY is one byte and fixed-width + // BINARY would pad the multihash. + delegation_id.var_binary(255); + } else { + delegation_id.binary(); + } + manager + .create_table( + Table::create() + .table(policy_v3_owner_authority::Entity) + .if_not_exists() + .col( + ColumnDef::new(policy_v3_owner_authority::Column::PolicyCid) + .string() + .not_null(), + ) + .col(delegation_id.not_null()) + .col( + ColumnDef::new(policy_v3_owner_authority::Column::Depth) + .integer() + .not_null(), + ) + .primary_key( + Index::create() + .col(policy_v3_owner_authority::Column::PolicyCid) + .col(policy_v3_owner_authority::Column::DelegationId), + ) + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .name(AUTHORITY_BY_DELEGATION_INDEX) + .table(policy_v3_owner_authority::Entity) + .col(policy_v3_owner_authority::Column::DelegationId) + .col(policy_v3_owner_authority::Column::PolicyCid) + .if_not_exists() + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Dropping a table drops its indexes on every supported backend. + manager + .drop_table( + Table::drop() + .table(policy_v3_owner_authority::Entity) + .if_exists() + .to_owned(), + ) + .await?; + manager + .drop_table( + Table::drop() + .table(policy_v3_owner_binding::Entity) + .if_exists() + .to_owned(), + ) + .await + } +} diff --git a/tinycloud-core/src/migrations/mod.rs b/tinycloud-core/src/migrations/mod.rs index 15a82db0..8197a8f5 100644 --- a/tinycloud-core/src/migrations/mod.rs +++ b/tinycloud-core/src/migrations/mod.rs @@ -28,6 +28,7 @@ pub mod m20261014_000000_delegation_cid_alias; pub mod m20261015_000000_delegation_cursor_index; pub mod m20261017_000000_epoch_order_space_index; pub mod m20261018_000000_epoch_head; +pub mod m20261019_000000_policy_v3_owner_authority; pub mod m20261020_000000_current_kv_key_c_index; pub mod m20261021_000000_sql_store_manifest; pub mod m20261022_000000_encryption_network_key; @@ -70,6 +71,9 @@ impl MigratorTrait for Migrator { Box::new(m20261020_000000_current_kv_key_c_index::Migration), Box::new(m20261021_000000_sql_store_manifest::Migration), Box::new(m20261022_000000_encryption_network_key::Migration), + // Last in the list: `Migrator::down` reverts in list order, so a + // rollback of this migration reverts it, not main's later ones. + Box::new(m20261019_000000_policy_v3_owner_authority::Migration), ] } } diff --git a/tinycloud-core/src/models/mod.rs b/tinycloud-core/src/models/mod.rs index 3c058d5f..9861a92c 100644 --- a/tinycloud-core/src/models/mod.rs +++ b/tinycloud-core/src/models/mod.rs @@ -29,6 +29,8 @@ pub mod policy_delegation; pub mod policy_edge; pub mod policy_issuance_audit; pub mod policy_v3_challenge; +pub mod policy_v3_owner_authority; +pub mod policy_v3_owner_binding; pub mod policy_v3_registration; pub mod policy_v3_root; pub mod policy_v3_session; diff --git a/tinycloud-core/src/models/policy_v3_owner_authority.rs b/tinycloud-core/src/models/policy_v3_owner_authority.rs new file mode 100644 index 00000000..a021c649 --- /dev/null +++ b/tinycloud-core/src/models/policy_v3_owner_authority.rs @@ -0,0 +1,23 @@ +//! TC-1423: the ancestor closure of the delegations a Policy/v3 owner used as +//! authority for a registration. The closure is immutable (edges are signed +//! parents); CID aliases are resolved when it is queried. + +use crate::hash::Hash; +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "policy_v3_owner_authority")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub policy_cid: String, + #[sea_orm(primary_key, auto_increment = false)] + pub delegation_id: Hash, + /// Distance from the nearest recorded delegation (0 for the delegation + /// itself). + pub depth: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/tinycloud-core/src/models/policy_v3_owner_binding.rs b/tinycloud-core/src/models/policy_v3_owner_binding.rs new file mode 100644 index 00000000..335adaed --- /dev/null +++ b/tinycloud-core/src/models/policy_v3_owner_binding.rs @@ -0,0 +1,32 @@ +//! TC-1423: how a Policy/v3 registration's owner held what its roots grant. +//! +//! One row per registered policy, written in the registration transaction or +//! lazily on first use for policies registered before this table existed. A +//! missing row means the policy is pending classification. + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "policy_v3_owner_binding")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub policy_cid: String, + /// `bound`, `bound-uncapped`, `root-authority` or `unknown`. + pub state: String, + /// Minimum expiry over the recorded authority closure (RFC 3339 UTC); + /// `None` when no closure node expires or nothing is recorded. + pub authority_expires_at: Option, + /// Why no authority is recorded (`unknown` rows only). + pub unknown_reason: Option, + /// Version of the classifier that wrote the row; `unknown` rows written + /// by an older version are classified again. + pub classifier_version: i32, + /// `record`, `enforce` or `backfill`. + pub registered_mode: String, + pub created_at: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/tinycloud-core/src/policy_owner_authority.rs b/tinycloud-core/src/policy_owner_authority.rs new file mode 100644 index 00000000..6c96499d --- /dev/null +++ b/tinycloud-core/src/policy_owner_authority.rs @@ -0,0 +1,125 @@ +//! TC-1423: graph queries behind Policy/v3 owner-authority bindings. +//! +//! The node records, for each policy registration, the ancestor closure of +//! the delegations its owner held as authority. These helpers load such a +//! closure in one round trip and resolve revocations, including those +//! recorded under an alias CID, in one more. + +use crate::hash::Hash; +use crate::models::{delegation, delegation_cid_alias, revocation}; +use sea_orm::{ + sea_query::{Expr, Query}, + ColumnTrait, ConnectionTrait, DbErr, EntityTrait, QueryFilter, +}; +use std::collections::{HashMap, HashSet, VecDeque}; +use time::OffsetDateTime; + +pub use crate::models::revocation::{ChainTraversalError, MAX_CHAIN_TRAVERSAL_NODES}; + +/// The migration that created the binding tables; its applied-at time is the +/// default legacy cutover. +pub const OWNER_AUTHORITY_MIGRATION: &str = "m20261019_000000_policy_v3_owner_authority"; + +/// One delegation in an authority closure. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ClosureNode { + pub id: Hash, + /// Distance from the nearest leaf of the loaded closure. + pub depth: u32, + pub delegator: String, + pub expiry: Option, + pub not_before: Option, +} + +/// Load `leaves` and every ancestor, bounded by the core traversal budget. +/// A closure node without a persisted delegation row fails closed. +pub async fn load_closure( + db: &C, + leaves: &[Hash], +) -> Result, ChainTraversalError> { + let (ids, parents) = crate::auth_graph::load_closure_edges(db, leaves).await?; + if ids.is_empty() { + return Ok(Vec::new()); + } + let mut depth: HashMap = HashMap::new(); + let mut queue = VecDeque::new(); + for leaf in leaves { + if depth.insert(*leaf, 0).is_none() { + queue.push_back(*leaf); + } + } + while let Some(current) = queue.pop_front() { + let next = depth[¤t] + 1; + for parent in parents.get(¤t).map(Vec::as_slice).unwrap_or(&[]) { + if !depth.contains_key(parent) { + depth.insert(*parent, next); + queue.push_back(*parent); + } + } + } + let rows: HashMap = delegation::Entity::find() + .filter(delegation::Column::Id.is_in(ids.iter().copied())) + .all(db) + .await? + .into_iter() + .map(|row| (row.id, row)) + .collect(); + ids.iter() + .map(|id| { + let row = rows.get(id).ok_or_else(|| { + ChainTraversalError::Db(DbErr::RecordNotFound( + "authority closure delegation".into(), + )) + })?; + Ok(ClosureNode { + id: *id, + depth: depth.get(id).copied().unwrap_or(0), + delegator: row.delegator.clone(), + expiry: row.expiry, + not_before: row.not_before, + }) + }) + .collect() +} + +/// The subset of `ids` that is revoked, directly or under an alias CID, in +/// one query. +pub async fn revoked_ids(db: &C, ids: &[Hash]) -> Result, DbErr> { + Ok(revocation::effective_rows(db, ids) + .await? + .into_iter() + .map(|(id, _)| id) + .collect()) +} + +/// `id` and every CID persisted as the same canonical delegation. +pub async fn equivalent_ids(db: &C, id: Hash) -> Result, DbErr> { + let canonical = Query::select() + .column(delegation_cid_alias::Column::Canonical) + .from(delegation_cid_alias::Entity) + .and_where(Expr::col(delegation_cid_alias::Column::Id).eq(id)) + .to_owned(); + let mut ids = delegation_cid_alias::Entity::find() + .filter(delegation_cid_alias::Column::Canonical.in_subquery(canonical)) + .all(db) + .await? + .into_iter() + .map(|row| row.id) + .collect::>(); + if !ids.contains(&id) { + ids.push(id); + } + Ok(ids) +} + +/// The applied-at time of `version`, in Unix seconds; `None` when it is not applied. +pub async fn migration_applied_at( + db: &C, + version: &str, +) -> Result, DbErr> { + use sea_orm_migration::seaql_migrations; + Ok(seaql_migrations::Entity::find_by_id(version.to_owned()) + .one(db) + .await? + .map(|row| row.applied_at)) +} diff --git a/tinycloud-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index 4928da24..d86a3659 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -59,6 +59,8 @@ pub struct Config { pub encryption_network: EncryptionNetworkConfig, #[serde(default)] pub sql: SqlConfig, + #[serde(default)] + pub policy_v3: PolicyV3Config, } /// Release gates for the encryption network routes. `named_keys_v1` @@ -1296,6 +1298,77 @@ impl SqlStoreConfig { } } +/// Policy/v3 owner-authority bindings (TC-1423). Section `[policy_v3]`. +#[derive(Serialize, Deserialize, Debug, Clone, Hash, PartialEq, Eq, Default)] +pub struct PolicyV3Config { + /// Registrations before this instant (RFC 3339) are classified as legacy. + /// Unset: the applied-at time of the migration that created the binding + /// tables. Set it to the first deploy time after a rollback and + /// re-upgrade (the rollback drops that migration record). Env + /// `TINYCLOUD_POLICY_V3__LEGACY_CUTOVER`. + #[serde(default)] + pub legacy_cutover: Option, + #[serde(default)] + pub owner_binding_backfill: OwnerBindingBackfillConfig, +} + +impl PolicyV3Config { + pub fn validate(&self) -> Result<(), &'static str> { + if let Some(cutover) = &self.legacy_cutover { + time::OffsetDateTime::parse(cutover, &time::format_description::well_known::Rfc3339) + .map_err(|_| "policy_v3.legacy_cutover must be an RFC 3339 timestamp")?; + } + self.owner_binding_backfill.validate() + } + + /// The configured legacy cutover, if any. Call [`Self::validate`] first. + pub fn legacy_cutover(&self) -> Option { + self.legacy_cutover.as_deref().and_then(|value| { + time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339).ok() + }) + } +} + +/// The online sweep that classifies Policy/v3 registrations made before the +/// binding tables existed. Policies are classified lazily on first use +/// without it; the sweep only moves that work off the request path. +/// +/// Off by default: production PostgreSQL is CPU-bound. Section +/// `[policy_v3.owner_binding_backfill]`; env +/// `TINYCLOUD_POLICY_V3__OWNER_BINDING_BACKFILL__ENABLED`, +/// `TINYCLOUD_POLICY_V3__OWNER_BINDING_BACKFILL__POLICIES_PER_SECOND`. +#[derive(Serialize, Deserialize, Debug, Clone, Hash, PartialEq, Eq)] +pub struct OwnerBindingBackfillConfig { + #[serde(default)] + pub enabled: bool, + /// Upper bound on policies classified per second. After each policy the + /// sweep also pauses at least as long as that policy took. + #[serde(default = "default_owner_binding_backfill_policies_per_second")] + pub policies_per_second: u32, +} + +fn default_owner_binding_backfill_policies_per_second() -> u32 { + 1 +} + +impl Default for OwnerBindingBackfillConfig { + fn default() -> Self { + Self { + enabled: false, + policies_per_second: default_owner_binding_backfill_policies_per_second(), + } + } +} + +impl OwnerBindingBackfillConfig { + pub fn validate(&self) -> Result<(), &'static str> { + if self.enabled && self.policies_per_second == 0 { + return Err("policy_v3.owner_binding_backfill.policies_per_second must be at least 1; set enabled = false to turn the sweep off"); + } + Ok(()) + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Hash, PartialEq, Eq, Default)] #[serde(tag = "type")] pub enum Keys { @@ -2082,6 +2155,41 @@ mod tests { assert_eq!(retention.history_days("team"), None); } + /// Every `Config` field except the ones listed here must load from a file + /// that omits it, so a config written before a section existed (for + /// example `[sql_store]` or `[policy_v3]`) loads. The fields are + /// read from the struct's own serialization: a new field without + /// `#[serde(default)]` fails here, and so does one that loses it. + #[test] + fn config_fields_without_a_required_marker_load_when_omitted() { + const REQUIRED: [&str; 6] = ["log", "storage", "spaces", "relay", "prometheus", "cors"]; + let full = toml::Value::try_from(Config::default()).unwrap(); + let full = full.as_table().unwrap().clone(); + for field in REQUIRED { + assert!(full.contains_key(field), "required field {field} missing"); + } + let load = |table: toml::Table| -> Result { + toml::Value::Table(table).try_into::() + }; + assert_eq!(load(full.clone()).unwrap(), Config::default()); + let optional: Vec<&String> = full + .keys() + .filter(|key| !REQUIRED.contains(&String::as_str(key))) + .collect(); + assert!(optional.iter().any(|key| *key == "sql_store")); + assert!(optional.iter().any(|key| *key == "policy_v3")); + for field in &optional { + let mut table = full.clone(); + table.remove(*field); + let config = load(table) + .unwrap_or_else(|error| panic!("a config without `{field}` must load: {error}")); + assert_eq!(config, Config::default(), "omitting `{field}`"); + } + let mut only_required = full.clone(); + only_required.retain(|key, _| REQUIRED.contains(&key)); + assert_eq!(load(only_required).unwrap(), Config::default()); + } + #[test] fn sql_store_empty_allowlist_uploads_nothing_even_when_enabled() { let config = SqlStoreConfig { diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index c8927dda..6bb06798 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -238,7 +238,8 @@ use invocation_replay::InvocationReplayCache; use node_control::control::ControlPlaneHandle; use node_control::key_provider::{self, IdentityPurpose}; use policy_v3::{ - authorize_delivery as authorize_policy_v3_delivery, challenge as policy_v3_challenge, + authorize_delivery as authorize_policy_v3_delivery, + authorized_policies as authorized_policies_v3, challenge as policy_v3_challenge, get_status as get_policy_v3_status, issue_enforcer_binding as policy_v3_enforcer_binding, mint as policy_v3_mint, register_policy as register_policy_v3, revoke_root as revoke_policy_v3_root, status as policy_v3_status, PolicyV3Runtime, @@ -564,6 +565,7 @@ pub fn resolve_runtime_config(tinycloud_config: &Config) -> Result { .epoch_head_backfill .validate() .map_err(anyhow::Error::msg)?; + resolved.policy_v3.validate().map_err(anyhow::Error::msg)?; resolved.sql_store.validate().map_err(anyhow::Error::msg)?; tracing::RequestIdOrigins::new(&resolved.log.request_id_first_party_origins) .map_err(anyhow::Error::msg)?; @@ -643,6 +645,7 @@ fn production_routes(named_keys_v1: bool) -> Vec { policy_v3_mint, policy_v3_status, revoke_policy_v3_root, + authorized_policies_v3, ]; if named_keys_v1 { routes.extend(named_key_routes()); @@ -676,6 +679,7 @@ pub async fn app_with_control( prometheus::observe_did_resolution_refused, ); lazy_static::initialize(&prometheus::DID_RESOLUTION_REFUSED); + lazy_static::initialize(&prometheus::POLICY_SOURCE_NETWORK_REFUSED); // Ensure local storage directories exist. // SQLite file paths and local dirs are resources the server owns — auto-create them. @@ -870,7 +874,9 @@ pub async fn app_with_control( let mut policy_v3_runtime = PolicyV3Runtime::new(seed_conn.clone(), key_setup.node_did(), key_setup.clone()) - .with_sqlite_writer_lock(tinycloud.sqlite_writer_lock()); + .with_sqlite_writer_lock(tinycloud.sqlite_writer_lock()) + .with_policy_config(&tinycloud_config.policy_v3); + policy_v3_runtime.log_owner_authority_cutover().await; if tinycloud_config.share_email.enabled { policy_v3_runtime = policy_v3_runtime.with_delivery(&tinycloud_config.share_email)?; } @@ -923,6 +929,7 @@ pub async fn app_with_control( let telemetry_enabled = tinycloud_config.telemetry.enabled; let telemetry_tinycloud = tinycloud.clone(); let backfill_tinycloud = tinycloud.clone(); + let owner_binding_runtime = policy_v3_runtime.clone(); let rocket = rocket::custom(config) .attach(AdHoc::on_liftoff("webhook-egress-refresh", { @@ -1046,6 +1053,29 @@ pub async fn app_with_control( rocket }; + // TC-1423: opt-in sweep that classifies Policy/v3 registrations made + // before owner-authority bindings existed. They are classified on first + // use without it. + let rocket = if tinycloud_config.policy_v3.owner_binding_backfill.enabled { + let sweep_config = tinycloud_config.policy_v3.owner_binding_backfill.clone(); + rocket.attach(AdHoc::on_liftoff( + "policy-owner-binding-backfill", + move |rocket| { + let runtime = owner_binding_runtime.clone(); + let sweep_config = sweep_config.clone(); + let shutdown = rocket.shutdown(); + Box::pin(async move { + tokio::spawn(async move { + policy_v3::run_owner_binding_backfill(&runtime, &sweep_config, shutdown) + .await; + }); + }) + }, + )) + } else { + rocket + }; + // TC-326: pool + read-audit telemetry sampler. Gated on telemetry.enabled; // an on-liftoff task that samples the DB pool gauges, probes pool-acquire // latency, and advances the read-audit commit counters every 5 seconds, diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 28337963..36e0f09c 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -30,13 +30,14 @@ use tinycloud_auth::{ resource::{ResourceId, SpaceId}, }; use tinycloud_core::{ + encryption_network::{NetworkId, NetworkState}, events::{Delegation, SerializedEvent}, hash::hash, keys::StaticSecret, models::{ - abilities, delegation as delegation_model, invocation as invocation_model, - policy_v3_challenge, policy_v3_registration, policy_v3_root, policy_v3_session, revocation, - share_invitation_authorization_jti, space, + abilities, delegation as delegation_model, encryption_network as encryption_network_model, + invocation as invocation_model, policy_v3_challenge, policy_v3_registration, + policy_v3_root, policy_v3_session, revocation, share_invitation_authorization_jti, space, }, relationships::parent_delegations, sea_orm::{ @@ -94,6 +95,10 @@ const EMAIL_DOMAIN_PROOF_PROFILE: &str = "tinycloud.email-domain-proof/v1"; const MAX_OWNER_AUTHORITY_CANDIDATES: usize = 32; const MAX_OWNER_AUTHORITY_ROWS: u64 = 256; +#[path = "policy_v3/owner_authority.rs"] +mod owner_authority; +pub use owner_authority::{authorized_policies, run_owner_binding_backfill}; + #[derive(Clone)] struct DeliveryRuntime { target_origin: String, @@ -109,6 +114,7 @@ pub struct PolicyV3Runtime { credential_issuers: IssuerSet, delivery: Option, sqlite_writer_lock: Option>>, + owner_authority: Arc, } impl PolicyV3Runtime { @@ -124,6 +130,7 @@ impl PolicyV3Runtime { credential_issuers: IssuerSet::default(), delivery: None, sqlite_writer_lock: None, + owner_authority: Arc::default(), } } @@ -1856,20 +1863,41 @@ pub async fn register_policy( { return Err((Status::Forbidden, "policy-digest-mismatch".into())); } + // Only signed, self-consistent requests reach the network lookups; the + // check runs before the owner-authority check, which would otherwise + // accept decrypt on a network that names the registrant. + if let Some(refusal) = source_network_refusal(&runtime.conn, &request.policy).await? { + crate::prometheus::observe_policy_source_network_refused(refusal.as_str()); + return Err((Status::Forbidden, "content-source-network-invalid".into())); + } let owner_did = request .policy .get("ownerDid") .and_then(Value::as_str) .ok_or((Status::BadRequest, "policy-owner-missing".into()))?; - for root in [&policy_root, &enforcement_root] { - owner_holds_capabilities( - &runtime.conn, - owner_did, - &root.0.capabilities, - OffsetDateTime::now_utc(), + // Both roots carry the same capabilities (`validate_root_pair`), so one + // selection covers both. The chosen authority is recorded with the + // registration below. + let authority = owner_authority::classify( + &runtime.conn, + owner_did, + &policy_root.0.capabilities, + OffsetDateTime::now_utc(), + None, + policy_root + .0 + .expiry + .ok_or((Status::Forbidden, "sibling-root-mismatch".into()))?, + ) + .await + .map_err(|_| { + ( + Status::Forbidden, + "policy-owner-authority-unavailable".into(), ) - .await - .map_err(|error| (Status::Forbidden, error.into()))?; + })?; + if authority.state == owner_authority::BindingState::Unknown { + return Err((Status::Forbidden, "policy-owner-not-authorized".into())); } for root in [&policy_root, &enforcement_root] { for space in root.0.spaces() { @@ -1971,6 +1999,8 @@ pub async fn register_policy( root_delegations, registration, &request.policy_cid, + &authority, + now, roots, ) .await; @@ -1990,6 +2020,7 @@ pub async fn register_policy( /// The writes of `register_policy`, inside the transaction its caller opened. /// The caller commits when this returns `Ok` and rolls back on `Err`. +#[allow(clippy::too_many_arguments)] async fn register_policy_writes( txn: &DatabaseTransaction, tinycloud: &crate::TinyCloud, @@ -1997,6 +2028,8 @@ async fn register_policy_writes( root_delegations: Vec, registration: policy_v3_registration::ActiveModel, policy_cid: &str, + authority: &owner_authority::Classification, + now: OffsetDateTime, roots: [(String, &'static str, Vec, Vec); 2], ) -> Result<(), (Status, String)> { tinycloud_core::kv_sync::lock_space_sequences(txn, sequence_spaces) @@ -2012,6 +2045,15 @@ async fn register_policy_writes( .insert(txn) .await .map_err(|e| (Status::Conflict, e.to_string()))?; + owner_authority::insert_classification( + txn, + policy_cid, + authority, + owner_authority::RegisteredMode::Record, + now, + ) + .await + .map_err(db_error)?; for (cid, role, bytes, status) in roots { let checked_at = status_field(&status, "checkedAt") .ok_or((Status::InternalServerError, "status-invalid".into()))?; @@ -2817,6 +2859,10 @@ impl PolicyV3Runtime { now, ) .map_err(|_| "enforcer-binding-invalid")?; + // TC-1423: classify a registration made before owner-authority + // bindings existed, in the background. Recorded only; it does not + // gate this request or add work to it. + self.note_owner_binding(registration); Ok(()) } } @@ -4657,6 +4703,129 @@ fn validate_policy_encryption_resource(value: &str) -> Result<(), (Status, Strin Ok(()) } +/// Why a new policy's resources were refused; the metric label. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SourceNetworkRefusal { + Unparsable, + LegacyResource, + ForeignOwner, + ForeignSpace, + Missing, + Inactive, +} + +impl SourceNetworkRefusal { + const fn as_str(self) -> &'static str { + match self { + Self::Unparsable => "unparsable", + Self::LegacyResource => "legacy_resource", + Self::ForeignOwner => "foreign_owner", + Self::ForeignSpace => "foreign_space", + Self::Missing => "missing", + Self::Inactive => "inactive", + } + } +} + +/// TC-1423: the encryption networks a policy names (the content source's +/// and every `encryption` ceiling entry), when every one parses and belongs to +/// the owner of the content source's space, and every `kv` ceiling entry +/// names a space of that owner; otherwise why not. Pure, so it applies to +/// stored registrations as well as new ones. +/// +/// A network URN is rooted at the DID it names, whether or not the network +/// exists, and any DID may create a network that names itself. Every +/// resource a policy grants therefore has to belong to one owner: the +/// content source's network and every ceiling network are that owner's, and +/// every `kv` entry names that owner's space. A registrant other than that +/// owner holds decrypt on those networks only through a delegation rooted at +/// that owner. +/// +/// The owner is the space's DID (`ResourceId::space().did()`), compared with +/// `did_principal_matches`: EIP-55 letter case is ignored, the chain id is +/// not. `tinycloud://` resources name no space owner, and no client emits +/// them. A ceiling carries only `kv` and `encryption` entries +/// (`registration_projections`); any other kind is refused as unparsable. +fn content_owner_networks(policy: &Value) -> Result, SourceNetworkRefusal> { + let kv_owner = |resource: Option<&str>| { + let resource = resource.unwrap_or_default(); + if resource.starts_with("tinycloud://") { + return Err(SourceNetworkRefusal::LegacyResource); + } + resource + .parse::() + .map(|resource| resource.space().did().to_owned()) + .map_err(|_| SourceNetworkRefusal::Unparsable) + }; + let source = policy.get("contentSource"); + let content_owner = kv_owner( + source + .and_then(|source| source.get("kvResource")) + .and_then(Value::as_str), + )?; + let mut networks = std::collections::BTreeSet::from([source + .and_then(|source| source.get("encryptionNetwork")) + .and_then(Value::as_str)]); + for capability in policy + .get("capabilityCeiling") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let resource = capability.get("resource").and_then(Value::as_str); + match capability.get("kind").and_then(Value::as_str) { + Some("kv") => { + if !did_principal_matches(kv_owner(resource)?.as_str(), content_owner.as_str()) { + return Err(SourceNetworkRefusal::ForeignSpace); + } + } + Some("encryption") => { + networks.insert(resource); + } + _ => return Err(SourceNetworkRefusal::Unparsable), + } + } + networks + .into_iter() + .map(|network| { + let network_id = network + .and_then(|network| network.parse::().ok()) + .ok_or(SourceNetworkRefusal::Unparsable)?; + if did_principal_matches(network_id.owner_did(), content_owner.as_str()) { + Ok(network_id) + } else { + Err(SourceNetworkRefusal::ForeignOwner) + } + }) + .collect() +} + +/// [`content_owner_networks`], and each network exists on this node in state +/// `active`: the row key and the rule decrypt uses. A policy on any other +/// network could never be opened here. +async fn source_network_refusal( + conn: &C, + policy: &Value, +) -> Result, (Status, String)> { + let networks = match content_owner_networks(policy) { + Ok(networks) => networks, + Err(refusal) => return Ok(Some(refusal)), + }; + for network_id in networks { + let Some(row) = encryption_network_model::Entity::find_by_id(network_id.to_string()) + .one(conn) + .await + .map_err(db_error)? + else { + return Ok(Some(SourceNetworkRefusal::Missing)); + }; + if NetworkState::parse(&row.state) != Some(NetworkState::Active) { + return Ok(Some(SourceNetworkRefusal::Inactive)); + } + } + Ok(None) +} + fn canonical_policy_actions( value: Option<&Value>, kv: bool, @@ -8521,7 +8690,7 @@ mod tests { #[tokio::test] async fn policy_v2_admits_v3_account_and_v4_accountless_receivers() -> anyhow::Result<()> { - run_policy_v2_flow(PolicyOwner::SpaceOwner).await + Box::pin(run_policy_v2_flow(PolicyOwner::SpaceOwner)).await } /// The real Share and CLI shape: the policy owner is a session key the @@ -8529,7 +8698,7 @@ mod tests { /// space owner revokes that delegation, no new session can be minted. #[tokio::test] async fn policy_owner_may_be_a_session_the_space_owner_delegated_to() -> anyhow::Result<()> { - run_policy_v2_flow(PolicyOwner::DelegatedSession).await + Box::pin(run_policy_v2_flow(PolicyOwner::DelegatedSession)).await } /// The policy owner signs a valid, self-consistent policy over content in @@ -8537,7 +8706,26 @@ mod tests { /// Registration must refuse it, so no session can ever read the bytes. #[tokio::test] async fn policy_registration_requires_the_owner_to_hold_what_it_grants() -> anyhow::Result<()> { - run_policy_v2_flow(PolicyOwner::Unauthorized).await + Box::pin(run_policy_v2_flow(PolicyOwner::Unauthorized)).await + } + + /// TC-1423: a policy owner that holds only `kv/get` and + /// `kv/metadata` on the content (no decrypt on the content owner's + /// network) registers a policy whose content source names a network + /// called after the policy owner itself. Registration refuses it: the + /// source network must exist and belong to the content space's owner. + #[tokio::test] + async fn policy_source_network_must_belong_to_the_content_owner() -> anyhow::Result<()> { + Box::pin(run_policy_v2_flow(PolicyOwner::KvOnlySelfNamedNetwork)).await + } + + /// TC-1423: the same kv-only policy owner moves the content source + /// to its own space, on a network it created, and adds a second kv + /// ceiling entry on the space owner's content. Every kv entry must name + /// a space of the content source's owner, so registration refuses it. + #[tokio::test] + async fn policy_kv_entries_must_belong_to_the_content_owner() -> anyhow::Result<()> { + Box::pin(run_policy_v2_flow(PolicyOwner::KvOnlyOwnSpaceSource)).await } #[derive(Clone, Copy, PartialEq, Eq)] @@ -8548,6 +8736,14 @@ mod tests { DelegatedSession, /// The policy owner holds nothing in the content space. Unauthorized, + /// The policy owner holds only kv/get + kv/metadata through a + /// delegation from the space owner (no decrypt), and the policy's + /// source network is one named after the policy owner itself. + KvOnlySelfNamedNetwork, + /// As `KvOnlySelfNamedNetwork`, but the content source is the policy + /// owner's own space on its own (created) network, and a second kv + /// ceiling entry names the space owner's content. + KvOnlyOwnSpaceSource, } async fn run_policy_v2_flow(policy_owner: PolicyOwner) -> anyhow::Result<()> { @@ -8602,6 +8798,14 @@ mod tests { None, None, ); + // `KvOnlyOwnSpaceSource`: the same path in the policy owner's space. + let own_source_resource = + SpaceId::new(owner_did.parse::()?, "applications".parse()?).to_resource( + "kv".parse()?, + Some("shares/tc-470/document.txt".parse()?), + None, + None, + ); let node_secret = StaticSecret::new(vec![29; 32]).unwrap(); let node_did = node_secret.node_did(); let issuer_did = projection["issuerDid"].as_str().unwrap(); @@ -8629,8 +8833,15 @@ mod tests { "selector": "exact", "actions": ["tinycloud.kv/get"] })]; - let encryption_resource = format!("urn:tinycloud:encryption:{content_owner_did}:mainnet"); - let ceiling = vec![ + let encryption_resource = if matches!( + policy_owner, + PolicyOwner::KvOnlySelfNamedNetwork | PolicyOwner::KvOnlyOwnSpaceSource + ) { + format!("urn:tinycloud:encryption:{owner_did}:share") + } else { + format!("urn:tinycloud:encryption:{content_owner_did}:mainnet") + }; + let mut ceiling = vec![ json!({ "kind": "kv", "resource": content_resource.to_string(), @@ -8643,9 +8854,20 @@ mod tests { "action": "tinycloud.encryption/decrypt" }), ]; + let source_kv = if policy_owner == PolicyOwner::KvOnlyOwnSpaceSource { + ceiling.push(json!({ + "kind": "kv", + "resource": own_source_resource.to_string(), + "selector": "exact", + "actions": ["tinycloud.kv/get", "tinycloud.kv/metadata"] + })); + own_source_resource.to_string() + } else { + content_resource.to_string() + }; let content_source = json!({ "shareId": "share-tc-470", - "kvResource": content_resource.to_string(), + "kvResource": source_kv, "selector": "exact", "encryptionNetwork": encryption_resource, "encryptedSymmetricKeyDigestHex": "aa".repeat(32), @@ -8995,6 +9217,28 @@ mod tests { runtime.signer.node_keypair(), encryption_backend, ); + // Registration requires the source network to exist on this node and + // to be the content owner's (TC-1423). The self-named network of + // `KvOnlySelfNamedNetwork` is deliberately never created. + encryption + .create_one_of_one_network(tinycloud_core::encryption_network::CreateNetworkRequest { + name: "mainnet".into(), + owner_did: content_owner_did.clone(), + threshold: tinycloud_core::encryption_network::Threshold::one_of_one(), + }) + .await?; + if policy_owner == PolicyOwner::KvOnlyOwnSpaceSource { + // The policy owner's own network, which any DID may create. + encryption + .create_one_of_one_network( + tinycloud_core::encryption_network::CreateNetworkRequest { + name: "share".into(), + owner_did: owner_did.clone(), + threshold: tinycloud_core::encryption_network::Threshold::one_of_one(), + }, + ) + .await?; + } let rocket = rocket::build() .mount( "/", @@ -9106,7 +9350,12 @@ mod tests { // A session the space owner delegated to may own a policy over what // that delegation covers, as the Share and CLI owners do. - let owner_delegation_cid = if policy_owner == PolicyOwner::DelegatedSession { + let owner_delegation_cid = if matches!( + policy_owner, + PolicyOwner::DelegatedSession + | PolicyOwner::KvOnlySelfNamedNetwork + | PolicyOwner::KvOnlyOwnSpaceSource + ) { let mut owner_capabilities = Capabilities::::new(); for ability in ["tinycloud.kv/get", "tinycloud.kv/metadata"] { owner_capabilities.with_action( @@ -9115,11 +9364,13 @@ mod tests { [std::collections::BTreeMap::::new()], ); } - owner_capabilities.with_action( - encryption_resource.parse()?, - "tinycloud.encryption/decrypt".parse::()?, - [std::collections::BTreeMap::::new()], - ); + if policy_owner == PolicyOwner::DelegatedSession { + owner_capabilities.with_action( + encryption_resource.parse()?, + "tinycloud.encryption/decrypt".parse::()?, + [std::collections::BTreeMap::::new()], + ); + } let authorization = TinyCloudDelegation::Ucan(Box::new( Payload { issuer: content_owner_vm.parse()?, @@ -9326,6 +9577,26 @@ mod tests { assert!(policy_v3_root::Entity::find().one(conn).await?.is_none()); return Ok(()); } + if matches!( + policy_owner, + PolicyOwner::KvOnlySelfNamedNetwork | PolicyOwner::KvOnlyOwnSpaceSource + ) { + assert_eq!( + register_status, + Status::Forbidden, + "register: {register_body}" + ); + assert_eq!(register_body, "content-source-network-invalid"); + let conn = &client.rocket().state::().unwrap().conn; + assert!( + policy_v3_registration::Entity::find_by_id(policy_cid.clone()) + .one(conn) + .await? + .is_none() + ); + assert!(policy_v3_root::Entity::find().one(conn).await?.is_none()); + return Ok(()); + } assert_eq!(register_status, Status::Ok, "register: {register_body}"); let expected_identity = tinycloud_core::request_telemetry::RequestContext::new( uuid::Uuid::new_v4(), @@ -10789,3 +11060,11 @@ mod root_revocation_tests; #[cfg(test)] #[path = "policy_v3/registration_probe_tests.rs"] mod registration_probe_tests; + +#[cfg(test)] +#[path = "policy_v3/owner_authority_tests.rs"] +mod owner_authority_tests; + +#[cfg(test)] +#[path = "policy_v3/source_network_tests.rs"] +mod source_network_tests; diff --git a/tinycloud-node-server/src/policy_v3/owner_authority.rs b/tinycloud-node-server/src/policy_v3/owner_authority.rs new file mode 100644 index 00000000..825c8a28 --- /dev/null +++ b/tinycloud-node-server/src/policy_v3/owner_authority.rs @@ -0,0 +1,1595 @@ +//! TC-1423: record which delegations a Policy/v3 owner held as authority. +//! +//! A policy root is parentless and signed by the policy owner, so the +//! ordinary graph cannot tell which owner delegation the root's authority +//! came from. Registration therefore selects, per root capability, the owner +//! delegation that grants it and records that delegation's whole ancestor +//! closure. Policies registered before the binding tables existed are +//! classified on first use (claim-first, served from memory when the write +//! fails) or by the opt-in sweep. +//! +//! Selection per capability: +//! - root authority (the owner owns the space or encryption network) needs +//! no delegation; +//! - step 1 looks only at delegations to the owner issued by the owner of +//! that capability's space or network, the only principal that can issue +//! them, so delegations from other issuers do not displace them in the window; +//! - step 2 falls back to the general window of delegations to the owner; +//! - a candidate whose closure contains a delegation issued by the policy +//! owner itself is skipped: authority that loops back through the owner key +//! adds nothing and would let a downstream holder alter the record; +//! - among satisfying candidates the first, newest-first, whose closure +//! outlives the policy root wins, else the newest. + +use super::*; +use std::collections::HashMap; +use std::future::Future; +use tinycloud_core::{ + encryption_network::NetworkId, + hash::Hash, + models::{ + abilities, + invocation::{Error as InvocationModelError, InvocationError}, + policy_v3_owner_authority, policy_v3_owner_binding, + }, + policy_owner_authority::{self as authority_graph, ChainTraversalError, ClosureNode}, + sea_orm::{ + sea_query::{Func, Query}, + DbErr, + }, + types::Resource, + util::Capability, +}; + +/// Bump when classification rules change: `unknown` rows written by an +/// older version are classified again on their next use. +pub(super) const CLASSIFIER_VERSION: i32 = 2; +/// Owner-first rows examined per capability (step 1). +const OWNER_FIRST_ROWS: u64 = 64; +/// Listing page size. +const LISTING_PAGE: u64 = 100; +/// How long a background classification waits for the SQLite writer. +const LAZY_WRITE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_millis(250); +/// After a classification that could not be stored, or that ended +/// `starved`, the request path doesn't start another one for this long. +const RETRY_BACKOFF: std::time::Duration = std::time::Duration::from_secs(60); +/// Bound on the process-local sets below; a full set is cleared. +const MAX_TRACKED_POLICIES: usize = 65_536; +/// Background classifications running at once, process-wide. A first use +/// that finds no free slot starts none (and doesn't back off); a later use +/// retries. +const MAX_BACKGROUND_CLASSIFICATIONS: usize = 3; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BindingState { + /// At least one delegation recorded; the root expires no later than it. + Bound, + /// At least one delegation recorded; the root outlives it. + BoundUncapped, + /// Every capability is held as root authority. + RootAuthority, + /// No authority found. + Unknown, +} + +impl BindingState { + pub fn as_str(self) -> &'static str { + match self { + Self::Bound => "bound", + Self::BoundUncapped => "bound-uncapped", + Self::RootAuthority => "root-authority", + Self::Unknown => "unknown", + } + } + + fn parse(value: &str) -> Option { + Some(match value { + "bound" => Self::Bound, + "bound-uncapped" => Self::BoundUncapped, + "root-authority" => Self::RootAuthority, + "unknown" => Self::Unknown, + _ => return None, + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UnknownReason { + /// A candidate satisfied the capability except that it, or an ancestor, + /// is revoked. + Revoked, + /// No candidate satisfied the capability. + Missing, + /// No candidate satisfied the capability within the examined windows, + /// and more delegations to the owner exist than were examined. + Starved, + /// The stored policy names a network or kv space that does not belong to + /// the owner of its content source (the registration-time source-network + /// check, applied to a registration made before that check). + ForeignNetwork, +} + +impl UnknownReason { + pub fn as_str(self) -> &'static str { + match self { + Self::Revoked => "revoked", + Self::Missing => "missing", + Self::Starved => "starved", + Self::ForeignNetwork => "foreign_network", + } + } + + fn parse(value: &str) -> Option { + Some(match value { + "revoked" => Self::Revoked, + "missing" => Self::Missing, + "starved" => Self::Starved, + "foreign_network" => Self::ForeignNetwork, + _ => return None, + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RegisteredMode { + /// Classified at registration, without refusing roots that outlive + /// their authority. + Record, + /// Classified after registration, for a policy registered before the + /// legacy cutover. + Backfill, +} + +impl RegisteredMode { + pub fn as_str(self) -> &'static str { + match self { + Self::Record => "record", + Self::Backfill => "backfill", + } + } +} + +/// How a registration's owner held what its roots grant. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Classification { + pub state: BindingState, + pub reason: Option, + pub authority_expires_at: Option, + /// `(delegation, depth)` over the recorded closure. Empty when read back + /// from a persisted row. + pub closure: Vec<(Hash, u32)>, + /// Whether this classification is stored, rather than computed for this + /// request after its write failed. + pub persisted: bool, +} + +impl Classification { + /// Stored and not re-evaluated on use: everything except `starved` (more + /// candidates may fit the window later) and rows from an older + /// classifier. + fn is_final(&self) -> bool { + self.persisted && self.reason != Some(UnknownReason::Starved) + } + + /// A policy over a network or kv space that is not its content owner's: + /// no recorded delegation, no closure. + fn foreign_network() -> Self { + Self { + state: BindingState::Unknown, + reason: Some(UnknownReason::ForeignNetwork), + authority_expires_at: None, + closure: Vec::new(), + persisted: false, + } + } + + fn from_selection( + selection: Result, UnknownReason>, + root_expiry: OffsetDateTime, + ) -> Self { + match selection { + Err(reason) => Self { + state: BindingState::Unknown, + reason: Some(reason), + authority_expires_at: None, + closure: Vec::new(), + persisted: false, + }, + Ok(closure) if closure.is_empty() => Self { + state: BindingState::RootAuthority, + reason: None, + authority_expires_at: None, + closure: Vec::new(), + persisted: false, + }, + Ok(closure) => { + let authority_expires_at = min_expiry(&closure); + Self { + state: if covers(authority_expires_at, root_expiry) { + BindingState::Bound + } else { + BindingState::BoundUncapped + }, + reason: None, + authority_expires_at, + closure: closure.iter().map(|node| (node.id, node.depth)).collect(), + persisted: false, + } + } + } + } + + fn from_row(row: &policy_v3_owner_binding::Model) -> Result { + Ok(Self { + state: BindingState::parse(&row.state).ok_or("policy-owner-binding-invalid")?, + reason: row + .unknown_reason + .as_deref() + .map(|reason| UnknownReason::parse(reason).ok_or("policy-owner-binding-invalid")) + .transpose()?, + authority_expires_at: row + .authority_expires_at + .as_deref() + .map(|value| parse_time(value).map_err(|_| "policy-owner-binding-invalid")) + .transpose()?, + closure: Vec::new(), + persisted: true, + }) + } +} + +/// The selection failed for a reason other than missing authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct SelectionUnavailable; + +fn min_expiry(closure: &[ClosureNode]) -> Option { + closure.iter().filter_map(|node| node.expiry).min() +} + +fn covers(authority_expires_at: Option, root_expiry: OffsetDateTime) -> bool { + authority_expires_at.is_none_or(|expiry| root_expiry <= expiry) +} + +/// DIDs that can issue a delegation granting `capability` from scratch: the +/// owner of its space, or of its encryption network. +fn capability_issuers(capability: &Capability) -> Vec { + let mut issuers = Vec::new(); + if let Some(space) = capability.resource.space() { + issuers.push(space.did().as_str().to_owned()); + } + if let Resource::Other(uri) = &capability.resource { + if let Ok(network) = uri.as_str().parse::() { + issuers.push(network.owner_did().to_owned()); + } + } + issuers +} + +/// SQL prefilter for a principal column. `did:pkh` addresses compare +/// case-insensitively (checksummed and lowercase forms name one account); +/// every candidate is re-checked with `did_principal_matches`. +fn principal_condition(column: delegation_model::Column, did: &str) -> Condition { + if did.starts_with("did:pkh:") { + let lower = did.to_ascii_lowercase(); + Condition::any() + .add(Expr::expr(Func::lower(Expr::col(column))).eq(lower.clone())) + .add(Expr::expr(Func::lower(Expr::col(column))).like(format!("{lower}#%"))) + } else { + Condition::any() + .add(column.eq(did)) + .add(column.like(format!("{did}#%"))) + } +} + +/// Ability-resource conditions for delegations touching the space or +/// encryption network `capability` targets. Only principals holding authority +/// derived from that space or network can create such rows. +fn capability_scope(capability: &Capability) -> Option { + let mut scope = Condition::any(); + let mut any = false; + if let Some(space) = capability.resource.space() { + let space = space.to_string(); + scope = scope + .add(abilities::Column::Resource.eq(space.clone())) + .add(abilities::Column::Resource.like(format!("{space}/%"))); + any = true; + } + if let Resource::Other(uri) = &capability.resource { + if uri.as_str().parse::().is_ok() { + scope = scope.add(abilities::Column::Resource.eq(uri.as_str().to_owned())); + any = true; + } + } + any.then_some(scope) +} + +fn is_policy_fact_row(row: &delegation_model::Model) -> bool { + row.facts.as_ref().is_some_and(|facts| { + facts.0.contains_key("xyz.tinycloud.policy/session-fact") + || facts.0.contains_key("xyz.tinycloud.policy/root-profile") + }) +} + +async fn candidate_rows( + conn: &C, + owner_did: &str, + issuers: Option<&[String]>, + scope: Option, + at: OffsetDateTime, + issued_before: Option, + limit: u64, +) -> Result, DbErr> { + let mut query = delegation_model::Entity::find() + .filter( + Condition::any() + .add(delegation_model::Column::Delegatee.eq(owner_did)) + .add(delegation_model::Column::Delegatee.like(format!("{owner_did}#%"))), + ) + .filter( + Condition::any() + .add(delegation_model::Column::Expiry.is_null()) + .add(delegation_model::Column::Expiry.gt(at)), + ); + if let Some(issuers) = issuers { + let mut any = Condition::any(); + for issuer in issuers { + any = any.add(principal_condition( + delegation_model::Column::Delegator, + issuer, + )); + } + query = query.filter(any); + } + if let Some(scope) = scope { + query = query.filter( + delegation_model::Column::Id.in_subquery( + Query::select() + .column(abilities::Column::Delegation) + .from(abilities::Entity) + .cond_where(scope) + .to_owned(), + ), + ); + } + if let Some(issued_before) = issued_before { + // UCANs carry no issue time; they cannot be ordered against the + // registration and stay eligible, as before the binding existed. + query = query.filter( + Condition::any() + .add(delegation_model::Column::IssuedAt.is_null()) + .add(delegation_model::Column::IssuedAt.lte(issued_before)), + ); + } + query + .order_by_with_nulls( + delegation_model::Column::IssuedAt, + tinycloud_core::sea_orm::Order::Desc, + tinycloud_core::sea_orm::sea_query::NullOrdering::Last, + ) + .order_by_asc(delegation_model::Column::Id) + .limit(limit) + .all(conn) + .await +} + +struct Chosen { + closure: Vec, +} + +#[derive(Default)] +struct Attempt { + saw_revoked: bool, + untried: bool, +} + +/// The first candidate, newest first, that satisfies `capability` and whose +/// closure outlives the root; else the first that satisfies it. +#[allow(clippy::too_many_arguments)] +async fn choose( + conn: &C, + owner_did: &str, + capability: &Capability, + rows: Vec, + issuers: Option<&[String]>, + at: OffsetDateTime, + root_expiry: OffsetDateTime, + max_tries: usize, + attempt: &mut Attempt, +) -> Result, SelectionUnavailable> { + let mut first = None; + let mut tried = 0; + for row in rows.into_iter().filter(|row| !is_policy_fact_row(row)) { + if issuers.is_some_and(|issuers| { + !issuers + .iter() + .any(|issuer| did_principal_matches(&row.delegator, issuer)) + }) { + continue; + } + let closure = match authority_graph::load_closure(conn, &[row.id]).await { + Ok(closure) => closure, + Err(ChainTraversalError::LimitExceeded) => continue, + Err(ChainTraversalError::Db(_)) => return Err(SelectionUnavailable), + }; + // Loop-back candidates are skipped without using up a try. + if closure + .iter() + .any(|node| did_principal_matches(&node.delegator, owner_did)) + { + continue; + } + if tried == max_tries { + attempt.untried = true; + break; + } + tried += 1; + match invocation_model::authorize_holder( + conn, + owner_did, + std::slice::from_ref(capability), + &[row.id.to_cid(0x55)], + at, + ) + .await + { + Ok(()) => { + if covers(min_expiry(&closure), root_expiry) { + return Ok(Some(Chosen { closure })); + } + if first.is_none() { + first = Some(Chosen { closure }); + } + } + Err(InvocationModelError::Db(_)) => return Err(SelectionUnavailable), + Err(InvocationModelError::InvalidInvocation( + InvocationError::DelegationRevoked(_) + | InvocationError::DelegationAncestorRevoked { .. }, + )) => attempt.saw_revoked = true, + Err(_) => {} + } + } + Ok(first) +} + +/// Select the owner delegations that grant `capabilities` at `at` and return +/// their merged ancestor closure: empty when every capability is held as +/// root authority, `Err(Ok(reason))` when some capability has no authority. +pub(super) async fn select_owner_authority( + conn: &C, + owner_did: &str, + capabilities: &[Capability], + at: OffsetDateTime, + issued_before: Option, + root_expiry: OffsetDateTime, +) -> Result, UnknownReason>, SelectionUnavailable> { + let mut merged: HashMap = HashMap::new(); + for capability in capabilities { + if invocation_model::authorize_holder( + conn, + owner_did, + std::slice::from_ref(capability), + &[], + at, + ) + .await + .is_ok() + { + continue; + } + let mut attempt = Attempt::default(); + let issuers = capability_issuers(capability); + let mut chosen = None; + if !issuers.is_empty() { + let rows = candidate_rows( + conn, + owner_did, + Some(&issuers), + None, + at, + issued_before, + OWNER_FIRST_ROWS, + ) + .await + .map_err(|_| SelectionUnavailable)?; + chosen = choose( + conn, + owner_did, + capability, + rows, + Some(&issuers), + at, + root_expiry, + OWNER_FIRST_ROWS as usize, + &mut attempt, + ) + .await?; + } + if chosen.is_none() { + let rows = candidate_rows( + conn, + owner_did, + None, + capability_scope(capability), + at, + issued_before, + MAX_OWNER_AUTHORITY_ROWS, + ) + .await + .map_err(|_| SelectionUnavailable)?; + if rows.len() as u64 >= MAX_OWNER_AUTHORITY_ROWS { + attempt.untried = true; + } + chosen = choose( + conn, + owner_did, + capability, + rows, + None, + at, + root_expiry, + MAX_OWNER_AUTHORITY_CANDIDATES, + &mut attempt, + ) + .await?; + } + let Some(chosen) = chosen else { + return Ok(Err(if attempt.saw_revoked { + UnknownReason::Revoked + } else if attempt.untried { + UnknownReason::Starved + } else { + UnknownReason::Missing + })); + }; + for node in chosen.closure { + match merged.get_mut(&node.id) { + Some(existing) => existing.depth = existing.depth.min(node.depth), + None => { + merged.insert(node.id, node); + } + } + } + } + let mut closure = merged.into_values().collect::>(); + closure.sort_by(|left, right| { + (left.depth, left.id.as_ref()).cmp(&(right.depth, right.id.as_ref())) + }); + Ok(Ok(closure)) +} + +/// Classify a registration at `at` for the given root expiry. +pub(super) async fn classify( + conn: &C, + owner_did: &str, + capabilities: &[Capability], + at: OffsetDateTime, + issued_before: Option, + root_expiry: OffsetDateTime, +) -> Result { + let selection = select_owner_authority( + conn, + owner_did, + capabilities, + at, + issued_before, + root_expiry, + ) + .await?; + Ok(Classification::from_selection(selection, root_expiry)) +} + +/// Write a classification: the binding row first, then its closure. +pub(super) async fn insert_classification( + conn: &C, + policy_cid: &str, + classification: &Classification, + mode: RegisteredMode, + now: OffsetDateTime, +) -> Result<(), DbErr> { + policy_v3_owner_binding::ActiveModel { + policy_cid: Set(policy_cid.to_owned()), + state: Set(classification.state.as_str().to_owned()), + authority_expires_at: Set(classification.authority_expires_at.map(format_time)), + unknown_reason: Set(classification + .reason + .map(|reason| reason.as_str().to_owned())), + classifier_version: Set(CLASSIFIER_VERSION), + registered_mode: Set(mode.as_str().to_owned()), + created_at: Set(format_time(now)), + } + .insert(conn) + .await?; + if !classification.closure.is_empty() { + policy_v3_owner_authority::Entity::insert_many(classification.closure.iter().map( + |(delegation_id, depth)| policy_v3_owner_authority::ActiveModel { + policy_cid: Set(policy_cid.to_owned()), + delegation_id: Set(*delegation_id), + depth: Set(i32::try_from(*depth).unwrap_or(i32::MAX)), + }, + )) + .exec(conn) + .await?; + } + Ok(()) +} + +/// Where the effective legacy cutover came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CutoverSource { + Config, + Migration, +} + +impl CutoverSource { + fn as_str(self) -> &'static str { + match self { + Self::Config => "config", + Self::Migration => "migration", + } + } +} + +/// Owner-authority settings and request-path bookkeeping shared by every +/// clone of the runtime. +#[derive(Default)] +pub(super) struct OwnerAuthorityState { + configured_cutover: Option, + migration_cutover: tokio::sync::OnceCell, + /// Policies whose stored classification is final. + final_policies: std::sync::Mutex>, + /// Policies with a background classification running. + in_flight: std::sync::Mutex>, + /// Slots for background classifications. + background: BackgroundSlots, + /// Policies whose last classification is not final, until when. + retry_after: std::sync::Mutex>, +} + +impl OwnerAuthorityState { + fn is_final(&self, policy_cid: &str) -> bool { + self.final_policies + .lock() + .expect("owner-authority cache") + .contains_key(policy_cid) + } + + fn remember_final(&self, policy_cid: &str, state: BindingState) { + let mut cache = self.final_policies.lock().expect("owner-authority cache"); + if cache.len() >= MAX_TRACKED_POLICIES { + cache.clear(); + } + cache.insert(policy_cid.to_owned(), state); + } + + fn backing_off(&self, policy_cid: &str) -> bool { + let mut retry = self.retry_after.lock().expect("owner-authority backoff"); + match retry.get(policy_cid) { + Some(until) if *until > tokio::time::Instant::now() => true, + Some(_) => { + retry.remove(policy_cid); + false + } + None => false, + } + } + + fn back_off(&self, policy_cid: &str) { + let mut retry = self.retry_after.lock().expect("owner-authority backoff"); + if retry.len() >= MAX_TRACKED_POLICIES { + let now = tokio::time::Instant::now(); + retry.retain(|_, until| *until > now); + if retry.len() >= MAX_TRACKED_POLICIES { + retry.clear(); + } + } + retry.insert( + policy_cid.to_owned(), + tokio::time::Instant::now() + RETRY_BACKOFF, + ); + } +} + +/// The process-wide limit on background classifications. +pub(super) struct BackgroundSlots(Arc); + +impl Default for BackgroundSlots { + fn default() -> Self { + Self(Arc::new(tokio::sync::Semaphore::new( + MAX_BACKGROUND_CLASSIFICATIONS, + ))) + } +} + +/// A running background classification: holds a slot and the policy's +/// in-flight mark, and releases both when dropped, including when the task +/// panics. +struct InFlight { + state: Arc, + policy_cid: String, + _slot: tokio::sync::OwnedSemaphorePermit, +} + +impl InFlight { + /// Claim a slot and the policy's mark; `None` when the policy is + /// already in flight, or no slot is free. + fn claim(state: &Arc, policy_cid: &str) -> Option { + let slot = Arc::clone(&state.background.0).try_acquire_owned().ok()?; + let mut in_flight = state.in_flight.lock().expect("owner-authority in-flight"); + if in_flight.len() >= MAX_TRACKED_POLICIES || !in_flight.insert(policy_cid.to_owned()) { + return None; + } + Some(Self { + state: Arc::clone(state), + policy_cid: policy_cid.to_owned(), + _slot: slot, + }) + } +} + +impl Drop for InFlight { + fn drop(&mut self) { + if let Ok(mut in_flight) = self.state.in_flight.lock() { + in_flight.remove(&self.policy_cid); + } + } +} + +/// SQL form of `!row_is_final`. +fn not_final_rows() -> Condition { + Condition::all() + .add(policy_v3_owner_binding::Column::State.eq(BindingState::Unknown.as_str())) + .add( + Condition::any() + .add(policy_v3_owner_binding::Column::ClassifierVersion.lt(CLASSIFIER_VERSION)) + .add( + policy_v3_owner_binding::Column::UnknownReason + .eq(UnknownReason::Starved.as_str()), + ), + ) +} + +/// Whether a stored row is final: not `starved`, not an `unknown` from an +/// older classifier. +fn row_is_final(row: &policy_v3_owner_binding::Model) -> bool { + !(row.state == BindingState::Unknown.as_str() + && (row.classifier_version < CLASSIFIER_VERSION + || row.unknown_reason.as_deref() == Some(UnknownReason::Starved.as_str()))) +} + +enum LazyWrite { + Written, + /// Another writer stored a classification first. + Lost(policy_v3_owner_binding::Model), + Failed, +} + +impl PolicyV3Runtime { + /// Apply the `[policy_v3]` settings. + pub fn with_policy_config(mut self, config: &crate::config::PolicyV3Config) -> Self { + self.owner_authority = Arc::new(OwnerAuthorityState { + configured_cutover: config.legacy_cutover(), + ..OwnerAuthorityState::default() + }); + self + } + + /// Registrations before this instant are legacy: the configured cutover, + /// else the applied-at time of the binding-table migration. + pub async fn legacy_cutover(&self) -> Result<(OffsetDateTime, CutoverSource), DbErr> { + if let Some(configured) = self.owner_authority.configured_cutover { + return Ok((configured, CutoverSource::Config)); + } + let applied = self + .owner_authority + .migration_cutover + .get_or_try_init(|| async { + let seconds = authority_graph::migration_applied_at( + &self.conn, + authority_graph::OWNER_AUTHORITY_MIGRATION, + ) + .await? + .ok_or_else(|| { + DbErr::RecordNotFound(authority_graph::OWNER_AUTHORITY_MIGRATION.into()) + })?; + OffsetDateTime::from_unix_timestamp(seconds) + .map_err(|error| DbErr::Custom(error.to_string())) + }) + .await?; + Ok((*applied, CutoverSource::Migration)) + } + + /// Log the effective legacy cutover once at boot, so a forgotten + /// override after a rollback is visible. + pub async fn log_owner_authority_cutover(&self) { + match self.legacy_cutover().await { + // Warn when the default cutover is under a day old: that is a + // fresh migration, or a re-upgrade after a rollback that needs + // `policy_v3.legacy_cutover` set to the first deploy time. + Ok((cutover, CutoverSource::Migration)) + if OffsetDateTime::now_utc() - cutover < Duration::days(1) => + { + ::tracing::warn!( + event = "policy_owner_authority", + legacy_cutover = %format_time(cutover), + source = CutoverSource::Migration.as_str(), + "policy owner-authority legacy cutover is the binding migration's applied-at time, less than a day ago; after a rollback and re-upgrade, set policy_v3.legacy_cutover to the first deploy time" + ) + } + Ok((cutover, source)) => ::tracing::info!( + event = "policy_owner_authority", + legacy_cutover = %format_time(cutover), + source = source.as_str(), + "policy owner-authority legacy cutover" + ), + Err(_) => ::tracing::warn!( + event = "policy_owner_authority", + "policy owner-authority legacy cutover is unavailable; legacy policies are classified when it can be read" + ), + } + } + + /// The stored classification of `registration`, classifying it first if + /// none is stored (or a stored `unknown` came from an older classifier). + /// A classification that cannot be stored is returned, unstored. + pub(super) async fn owner_binding( + &self, + registration: &policy_v3_registration::Model, + ) -> Result { + let existing = policy_v3_owner_binding::Entity::find_by_id(registration.policy_cid.clone()) + .one(&self.conn) + .await + .map_err(|_| "policy-owner-authority-unavailable")?; + let stale = match &existing { + Some(row) if row_is_final(row) => { + let classification = Classification::from_row(row)?; + self.owner_authority + .remember_final(®istration.policy_cid, classification.state); + return Ok(classification); + } + Some(_) => true, + None => false, + }; + let (classification, mode) = self.classify_registration(registration).await?; + match self + .store_lazy_classification(®istration.policy_cid, &classification, mode, stale) + .await + { + LazyWrite::Written => { + let classification = Classification { + persisted: true, + ..classification + }; + if classification.is_final() { + self.owner_authority + .remember_final(®istration.policy_cid, classification.state); + } + Ok(classification) + } + LazyWrite::Lost(winner) => { + let classification = Classification::from_row(&winner)?; + self.owner_authority + .remember_final(®istration.policy_cid, classification.state); + Ok(classification) + } + LazyWrite::Failed => Ok(classification), + } + } + + /// Request path: make sure `registration` gets classified, without + /// waiting for it. A policy whose stored classification is final costs + /// nothing; otherwise one background task per policy (and at most + /// [`MAX_BACKGROUND_CLASSIFICATIONS`] in all) classifies it, and a result + /// that is not final (a failed write, `starved`) holds off the next + /// attempt for [`RETRY_BACKOFF`]. + pub(super) fn note_owner_binding( + &self, + registration: &policy_v3_registration::Model, + ) -> Option> { + let state = &self.owner_authority; + let policy_cid = ®istration.policy_cid; + if state.is_final(policy_cid) || state.backing_off(policy_cid) { + return None; + } + let claim = InFlight::claim(state, policy_cid)?; + let runtime = self.clone(); + let registration = registration.clone(); + Some(tokio::spawn(async move { + let _claim = claim; + let result = runtime.owner_binding(®istration).await; + if !result.is_ok_and(|classification| classification.is_final()) { + runtime.owner_authority.back_off(®istration.policy_cid); + } + })) + } + + async fn classify_registration( + &self, + registration: &policy_v3_registration::Model, + ) -> Result<(Classification, RegisteredMode), &'static str> { + let registered_at = + parse_time(®istration.registered_at).map_err(|_| "policy-registration-invalid")?; + let (cutover, _) = self + .legacy_cutover() + .await + .map_err(|_| "policy-owner-authority-unavailable")?; + let mode = if registered_at < cutover { + RegisteredMode::Backfill + } else { + // Registered without a binding after the cutover: by a binary + // that predates it (a rollback window or a mixed deploy). + RegisteredMode::Record + }; + // A registration made before the source-network check is held to the + // same predicate: a policy over a network or kv space that is not its + // content owner's is not served by any recorded delegation. + let policy = super::validate_registration_projection(registration) + .map_err(|_| "policy-registration-projection-mismatch")?; + if super::content_owner_networks(&policy).is_err() { + return Ok((Classification::foreign_network(), mode)); + } + let root = policy_v3_root::Entity::find_by_id(registration.policy_root_cid.clone()) + .one(&self.conn) + .await + .map_err(|_| "policy-owner-authority-unavailable")? + .ok_or("policy-root-missing")?; + let root = decode_delegation( + std::str::from_utf8(&root.authorization_bytes).map_err(|_| "policy-root-invalid")?, + ) + .map_err(|_| "policy-root-invalid")?; + let root_expiry = root.0.expiry.ok_or("policy-root-expiry-missing")?; + // As the mint check always has: the authority the owner held when + // it registered, revoked as of now. + let classification = classify( + &self.conn, + ®istration.owner_did, + &root.0.capabilities, + registered_at, + Some(registered_at), + root_expiry, + ) + .await + .map_err(|_| "policy-owner-authority-unavailable")?; + Ok((classification, mode)) + } + + async fn store_lazy_classification( + &self, + policy_cid: &str, + classification: &Classification, + mode: RegisteredMode, + stale: bool, + ) -> LazyWrite { + let _writer = match &self.sqlite_writer_lock { + Some(lock) => match tokio::time::timeout(LAZY_WRITE_LOCK_WAIT, lock.lock()).await { + Ok(guard) => Some(guard), + Err(_) => return LazyWrite::Failed, + }, + None => None, + }; + let Ok(txn) = tinycloud_core::db::begin_write_transaction(&self.conn, None).await else { + return LazyWrite::Failed; + }; + let written = async { + if stale { + let claimed = policy_v3_owner_binding::Entity::delete_many() + .filter(policy_v3_owner_binding::Column::PolicyCid.eq(policy_cid)) + .filter(not_final_rows()) + .exec(&txn) + .await?; + if claimed.rows_affected == 0 { + return Err(DbErr::RecordNotUpdated); + } + policy_v3_owner_authority::Entity::delete_many() + .filter(policy_v3_owner_authority::Column::PolicyCid.eq(policy_cid)) + .exec(&txn) + .await?; + } + insert_classification( + &txn, + policy_cid, + classification, + mode, + OffsetDateTime::now_utc(), + ) + .await + } + .await; + if written.is_ok() && txn.commit().await.is_ok() { + return LazyWrite::Written; + } + // The transaction rolled back (on drop, if not already). A concurrent + // writer that stored first wins; anything else is a write failure. + match policy_v3_owner_binding::Entity::find_by_id(policy_cid.to_owned()) + .one(&self.conn) + .await + { + Ok(Some(winner)) if row_is_final(&winner) => LazyWrite::Lost(winner), + _ => { + ::tracing::warn!( + event = "policy_owner_authority", + outcome = "write_failed_served", + "policy owner-authority classification could not be stored; served from memory" + ); + LazyWrite::Failed + } + } + } + + /// Classify up to `limit` registrations with no current classification, + /// after `after` in policy-CID order. Returns the last policy CID seen + /// and how many were classified. + pub async fn backfill_owner_bindings_page( + &self, + after: Option<&str>, + limit: u64, + ) -> Result<(Option, Vec), DbErr> { + let classified = Query::select() + .column(policy_v3_owner_binding::Column::PolicyCid) + .from(policy_v3_owner_binding::Entity) + .cond_where(not_final_rows().not()) + .to_owned(); + let mut query = policy_v3_registration::Entity::find() + .filter(policy_v3_registration::Column::PolicyCid.not_in_subquery(classified)) + .order_by_asc(policy_v3_registration::Column::PolicyCid) + .limit(limit); + if let Some(after) = after { + query = query.filter(policy_v3_registration::Column::PolicyCid.gt(after)); + } + let page = query.all(&self.conn).await?; + let last = page.last().map(|row| row.policy_cid.clone()); + Ok((last, page.into_iter().map(|row| row.policy_cid).collect())) + } + + /// Classify one registration for the sweep. Errors are counted, not + /// raised: the lazy path classifies it on first use anyway. + pub async fn backfill_owner_binding(&self, policy_cid: &str) -> Result { + let Some(registration) = policy_v3_registration::Entity::find_by_id(policy_cid.to_owned()) + .one(&self.conn) + .await? + else { + return Ok(false); + }; + Ok(self + .owner_binding(®istration) + .await + .is_ok_and(|classification| classification.persisted)) + } +} + +/// Run the owner-binding sweep until every pending registration has been +/// visited once or the node shuts down. `config` is validated. +pub async fn run_owner_binding_backfill( + runtime: &PolicyV3Runtime, + config: &crate::config::OwnerBindingBackfillConfig, + shutdown: impl Future, +) -> (u64, u64) { + tokio::pin!(shutdown); + let interval = std::time::Duration::from_secs(1) / config.policies_per_second.max(1); + let (mut visited, mut stored) = (0u64, 0u64); + let mut after: Option = None; + ::tracing::info!( + event = "policy_owner_binding_backfill", + phase = "start", + policies_per_second = config.policies_per_second, + "policy owner-binding sweep started" + ); + loop { + let (last, page) = match runtime + .backfill_owner_bindings_page(after.as_deref(), 100) + .await + { + Ok(page) => page, + Err(_) => { + ::tracing::error!( + event = "policy_owner_binding_backfill", + phase = "stopped", + visited, + stored, + "policy owner-binding sweep stopped at its first error; policies are still classified on first use" + ); + return (visited, stored); + } + }; + if page.is_empty() { + ::tracing::info!( + event = "policy_owner_binding_backfill", + phase = "done", + visited, + stored, + "policy owner-binding sweep finished" + ); + return (visited, stored); + } + for policy_cid in page { + let started = tokio::time::Instant::now(); + visited += 1; + if runtime + .backfill_owner_binding(&policy_cid) + .await + .unwrap_or(false) + { + stored += 1; + } + let pause = interval.max(started.elapsed()); + tokio::select! { + _ = &mut shutdown => return (visited, stored), + _ = tokio::time::sleep(pause) => {} + } + } + after = last; + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuthorizedPoliciesRequest { + pub cid: String, + #[serde(default)] + pub cursor: Option, +} + +/// Policies whose recorded owner authority includes `cid`: the delegation +/// itself or any descendant of it the owner held. Authorized like +/// `/delegation/status` on `urn:cid:`. Policies not yet classified are +/// not listed. Keyset-paginated by policy CID, no total count. +#[post("/policy/v3/authorized", format = "json", data = "")] +pub async fn authorized_policies( + invocation: AuthHeaderGetter, + request: Json, + tinycloud: &State, + runtime: &State, +) -> Result, (Status, String)> { + let request = request.into_inner(); + let auth = &invocation.0 .0; + invocation_model::verify_invocation(&auth.invocation) + .await + .map_err(|_| (Status::Unauthorized, "invocation-invalid".to_string()))?; + let expected_resource = format!("urn:cid:{}", request.cid); + let bound = auth.capabilities.len() == 1 + && auth.capabilities.iter().all(|capability| { + capability.ability.as_ref().as_ref() == "tinycloud.delegation/status" + && matches!( + &capability.resource, + Resource::Other(resource) if resource.as_str() == expected_resource + ) + }); + if !bound { + return Err(( + Status::Forbidden, + "policy-listing-invocation-unbound".to_string(), + )); + } + let cid: tinycloud_auth::ipld_core::cid::Cid = request + .cid + .parse() + .map_err(|_| (Status::BadRequest, "cid-invalid".to_string()))?; + let target = Hash::from(cid); + match tinycloud + .delegation_status(target, &auth.invoker, &auth.parents) + .await + .map_err(|_| { + ( + Status::ServiceUnavailable, + "policy-listing-unavailable".to_string(), + ) + })? { + None => { + return Err(( + Status::Forbidden, + "policy-listing-not-authorized".to_string(), + )) + } + Some(tinycloud_core::db::DelegationStatus::Unavailable) => { + return Err(( + Status::ServiceUnavailable, + "policy-listing-unavailable".to_string(), + )) + } + Some(_) => {} + } + let after = request + .cursor + .as_deref() + .map(|cursor| { + decode_config(cursor, URL_SAFE_NO_PAD) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .ok_or((Status::BadRequest, "cursor-invalid".to_string())) + }) + .transpose()?; + let conn = &runtime.conn; + let ids = authority_graph::equivalent_ids(conn, target) + .await + .map_err(listing_unavailable)?; + let mut page_query = policy_v3_owner_authority::Entity::find() + .select_only() + .column(policy_v3_owner_authority::Column::PolicyCid) + .distinct() + .filter(policy_v3_owner_authority::Column::DelegationId.is_in(ids)) + .order_by_asc(policy_v3_owner_authority::Column::PolicyCid) + .limit(LISTING_PAGE); + if let Some(after) = &after { + page_query = page_query.filter(policy_v3_owner_authority::Column::PolicyCid.gt(after)); + } + let policy_cids: Vec = page_query + .into_tuple() + .all(conn) + .await + .map_err(listing_unavailable)?; + let registrations: HashMap = + policy_v3_registration::Entity::find() + .filter(policy_v3_registration::Column::PolicyCid.is_in(policy_cids.clone())) + .all(conn) + .await + .map_err(listing_unavailable)? + .into_iter() + .map(|row| (row.policy_cid.clone(), row)) + .collect(); + let bindings: HashMap = + policy_v3_owner_binding::Entity::find() + .filter(policy_v3_owner_binding::Column::PolicyCid.is_in(policy_cids.clone())) + .all(conn) + .await + .map_err(listing_unavailable)? + .into_iter() + .map(|row| (row.policy_cid.clone(), row)) + .collect(); + let root_cids = registrations + .values() + .flat_map(|row| { + [ + row.policy_root_cid.clone(), + row.enforcement_root_cid.clone(), + ] + }) + .collect::>(); + let roots: HashMap = policy_v3_root::Entity::find() + .filter(policy_v3_root::Column::RootCid.is_in(root_cids.clone())) + .all(conn) + .await + .map_err(listing_unavailable)? + .into_iter() + .map(|row| (row.root_cid.clone(), row)) + .collect(); + let root_hashes = root_cids + .iter() + .filter_map(|cid| tinycloud_auth::ipld_core::cid::Cid::try_from(cid.as_str()).ok()) + .map(Hash::from) + .collect::>(); + let generically_revoked = authority_graph::revoked_ids(conn, &root_hashes) + .await + .map_err(listing_unavailable)?; + let now = OffsetDateTime::now_utc(); + let mut policies = Vec::with_capacity(policy_cids.len()); + for policy_cid in &policy_cids { + let Some(registration) = registrations.get(policy_cid) else { + continue; + }; + let revoked = [ + ®istration.policy_root_cid, + ®istration.enforcement_root_cid, + ] + .into_iter() + .any(|cid| { + roots.get(cid).is_none_or(|root| root.revoked_at.is_some()) + || tinycloud_auth::ipld_core::cid::Cid::try_from(cid.as_str()) + .is_ok_and(|parsed| generically_revoked.contains(&Hash::from(parsed))) + }); + let expired = parse_time(®istration.expires_at).is_ok_and(|expiry| expiry <= now); + policies.push(serde_json::json!({ + "policyCid": registration.policy_cid, + "policyRootCid": registration.policy_root_cid, + "enforcementRootCid": registration.enforcement_root_cid, + "registeredAt": registration.registered_at, + "expiresAt": registration.expires_at, + "rootState": if revoked { "revoked" } else if expired { "expired" } else { "active" }, + "ownerAuthorityState": bindings + .get(policy_cid) + .map(|binding| binding.state.as_str()) + .unwrap_or("pending"), + })); + } + let next_cursor = (policy_cids.len() as u64 == LISTING_PAGE) + .then(|| policy_cids.last()) + .flatten() + .map(|last| encode_config(last.as_bytes(), URL_SAFE_NO_PAD)); + Ok(Json(serde_json::json!({ + "policies": policies, + "nextCursor": next_cursor, + }))) +} + +fn listing_unavailable(_: DbErr) -> (Status, String) { + ( + Status::ServiceUnavailable, + "policy-listing-unavailable".to_string(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tinycloud_core::{ + migrations::Migrator, + sea_orm::{ConnectOptions, Database}, + sea_orm_migration::MigratorTrait, + }; + + async fn counted_runtime() -> (PolicyV3Runtime, Arc) { + let mut db = Database::connect(ConnectOptions::new("sqlite::memory:".to_owned())) + .await + .unwrap(); + Migrator::up(&db, None).await.unwrap(); + let queries = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&queries); + db.set_metric_callback(move |_| { + counter.fetch_add(1, Ordering::SeqCst); + }); + let runtime = PolicyV3Runtime::new( + db, + "did:key:z6MkNode", + StaticSecret::new(vec![7; 32]).unwrap(), + ); + (runtime, queries) + } + + async fn registration( + runtime: &PolicyV3Runtime, + policy_cid: &str, + ) -> policy_v3_registration::Model { + policy_v3_registration::ActiveModel { + policy_cid: Set(policy_cid.to_owned()), + policy_bytes: Set(b"{}".to_vec()), + policy_digest_hex: Set("00".into()), + owner_did: Set("did:key:z6MkOwner".into()), + policy_root_cid: Set(format!("{policy_cid}-root")), + enforcement_root_cid: Set(format!("{policy_cid}-enforcement")), + content_source_digest_hex: Set("00".into()), + native_projection_hash_hex: Set("00".into()), + attested_enforcer_binding_bytes: Set(b"{}".to_vec()), + registered_at: Set(format_time(OffsetDateTime::now_utc())), + expires_at: Set(format_time(OffsetDateTime::now_utc() + Duration::days(1))), + } + .insert(&runtime.conn) + .await + .unwrap() + } + + /// A policy with a final stored classification adds no query to the + /// request path once seen; the first sighting only spawns. + #[tokio::test] + async fn final_classifications_cost_the_request_path_nothing() { + let (runtime, queries) = counted_runtime().await; + let registration = registration(&runtime, "policy-final").await; + let classification = + Classification::from_selection(Ok(Vec::new()), OffsetDateTime::now_utc()); + insert_classification( + &runtime.conn, + ®istration.policy_cid, + &classification, + RegisteredMode::Record, + OffsetDateTime::now_utc(), + ) + .await + .unwrap(); + + let before = queries.load(Ordering::SeqCst); + let first = runtime.note_owner_binding(®istration).expect("spawned"); + assert_eq!(queries.load(Ordering::SeqCst), before, "spawn only"); + assert!( + runtime.note_owner_binding(®istration).is_none(), + "deduplicated" + ); + first.await.unwrap(); + + let before = queries.load(Ordering::SeqCst); + for _ in 0..10 { + assert!(runtime.note_owner_binding(®istration).is_none()); + } + tokio::task::yield_now().await; + assert_eq!(queries.load(Ordering::SeqCst), before); + } + + /// At most a few classifications run in the background at once; a use + /// that finds no free slot starts none and doesn't back off. + #[tokio::test] + async fn background_classifications_are_limited_without_backing_off() { + let (runtime, _) = counted_runtime().await; + let registration = registration(&runtime, "policy-busy").await; + let held = (0..MAX_BACKGROUND_CLASSIFICATIONS) + .map(|_| { + Arc::clone(&runtime.owner_authority.background.0) + .try_acquire_owned() + .unwrap() + }) + .collect::>(); + assert!(runtime.note_owner_binding(®istration).is_none()); + assert!(runtime.owner_authority.in_flight.lock().unwrap().is_empty()); + drop(held); + assert!( + runtime.note_owner_binding(®istration).is_some(), + "no back-off after a busy skip" + ); + } + + /// A background classification that panics releases its policy and + /// its slot. + #[tokio::test] + async fn a_panicking_classification_releases_its_claim() { + let (runtime, _) = counted_runtime().await; + let claim = InFlight::claim(&runtime.owner_authority, "policy-panics").unwrap(); + let result = tokio::spawn(async move { + let _claim = claim; + panic!("classification failed"); + }) + .await; + assert!(result.unwrap_err().is_panic()); + assert!(runtime.owner_authority.in_flight.lock().unwrap().is_empty()); + assert_eq!( + runtime.owner_authority.background.0.available_permits(), + MAX_BACKGROUND_CLASSIFICATIONS + ); + } + + /// A classification that is not final (here it cannot even be computed) + /// is not retried on every request. + #[tokio::test] + async fn non_final_classifications_back_off() { + let (runtime, queries) = counted_runtime().await; + // No root row: classification fails. + let registration = registration(&runtime, "policy-unclassifiable").await; + runtime + .note_owner_binding(®istration) + .expect("spawned") + .await + .unwrap(); + let before = queries.load(Ordering::SeqCst); + for _ in 0..10 { + assert!(runtime.note_owner_binding(®istration).is_none()); + } + tokio::task::yield_now().await; + assert_eq!(queries.load(Ordering::SeqCst), before); + } + + /// A stored registration over a signed policy that names `network` as its + /// source, as a registration made before the source-network check could + /// have been stored. + fn stored_registration( + owner: &super::super::owner_authority_tests::Signer, + content_resource: &str, + network: &str, + registered_at: OffsetDateTime, + ) -> policy_v3_registration::Model { + use super::super::owner_authority_tests::{signed_policy, Window}; + let vector: Value = serde_json::from_str(include_str!( + "../../test-fixtures/tc-470-policy-credential-requirement.json" + )) + .unwrap(); + let policy = signed_policy( + owner, + &vector, + content_resource, + network, + &Window::until(OffsetDateTime::now_utc() + Duration::days(2)), + ); + let policy_bytes = canonical_json_value(&policy); + let projections = registration_projections(&policy).unwrap(); + policy_v3_registration::Model { + policy_cid: tinycloud_auth::ipld_core::cid::Cid::new_v1( + 0x55, + tinycloud_auth::multihash_codetable::Code::Sha2_256.digest(&policy_bytes), + ) + .to_string(), + policy_digest_hex: policy_digest_hex(&policy).unwrap(), + policy_bytes, + owner_did: owner.did().to_owned(), + policy_root_cid: "root-not-stored".into(), + enforcement_root_cid: "enforcement-not-stored".into(), + content_source_digest_hex: projections.content_source_digest_hex, + native_projection_hash_hex: projections.native_projection_hash_hex, + attested_enforcer_binding_bytes: b"{}".to_vec(), + registered_at: format_time(registered_at), + expires_at: format_time(registered_at + Duration::days(2)), + } + } + + /// The content's resource in a space owned by `space_owner`. + fn content_in_space_of(space_owner: &str) -> String { + SpaceId::new( + space_owner.parse::().unwrap(), + "applications".parse().unwrap(), + ) + .to_resource( + "kv".parse().unwrap(), + Some("shares/document.txt".parse().unwrap()), + None, + None, + ) + .to_string() + } + + /// A stored registration whose policy names a network that is not its + /// content owner's is classified `unknown` / `foreign_network`, whether + /// it is registered before the cutover (`backfill`) or after it + /// (`record`). A policy over the content owner's network goes on to the + /// delegation search, which here fails for lack of a stored root. + #[tokio::test] + async fn stored_registrations_over_foreign_networks_are_unknown() { + use super::super::owner_authority_tests::Signer; + let (runtime, _) = counted_runtime().await; + let space_owner = Signer::key(); + let policy_owner = Signer::key(); + let content = content_in_space_of(space_owner.did()); + let own_network = format!("urn:tinycloud:encryption:{}:share", policy_owner.did()); + let third_network = format!("urn:tinycloud:encryption:{}:share", Signer::key().did()); + let content_owner_network = + format!("urn:tinycloud:encryption:{}:mainnet", space_owner.did()); + let (before, after) = ( + OffsetDateTime::now_utc() - Duration::hours(1), + OffsetDateTime::now_utc() + Duration::hours(1), + ); + for (registered_at, mode) in [ + (before, RegisteredMode::Backfill), + (after, RegisteredMode::Record), + ] { + for network in [&own_network, &third_network] { + let registration = + stored_registration(&policy_owner, &content, network, registered_at); + let (classification, classified_mode) = + runtime.classify_registration(®istration).await.unwrap(); + assert_eq!(classified_mode, mode); + assert_eq!(classification.state, BindingState::Unknown, "{network}"); + assert_eq!(classification.reason, Some(UnknownReason::ForeignNetwork)); + assert!(classification.closure.is_empty()); + assert_eq!(classification.authority_expires_at, None); + } + let registration = stored_registration( + &policy_owner, + &content, + &content_owner_network, + registered_at, + ); + assert_eq!( + runtime.classify_registration(®istration).await.err(), + Some("policy-root-missing"), + "a policy over the content owner's network reaches the delegation search" + ); + } + } + + /// `foreign_network` is a final, stored reason: it reads back from the + /// binding row and is not re-evaluated on use. + #[tokio::test] + async fn foreign_network_classifications_are_final_once_stored() { + let (runtime, _) = counted_runtime().await; + let registration = registration(&runtime, "policy-foreign").await; + insert_classification( + &runtime.conn, + ®istration.policy_cid, + &Classification::foreign_network(), + RegisteredMode::Backfill, + OffsetDateTime::now_utc(), + ) + .await + .unwrap(); + let row = policy_v3_owner_binding::Entity::find_by_id(registration.policy_cid.clone()) + .one(&runtime.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(row.unknown_reason.as_deref(), Some("foreign_network")); + let read_back = Classification::from_row(&row).unwrap(); + assert_eq!(read_back.state, BindingState::Unknown); + assert_eq!(read_back.reason, Some(UnknownReason::ForeignNetwork)); + assert!(read_back.is_final()); + for reason in [ + UnknownReason::Revoked, + UnknownReason::Missing, + UnknownReason::Starved, + UnknownReason::ForeignNetwork, + ] { + assert_eq!(UnknownReason::parse(reason.as_str()), Some(reason)); + } + } +} diff --git a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs new file mode 100644 index 00000000..d14ad157 --- /dev/null +++ b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs @@ -0,0 +1,2230 @@ +//! TC-1423: Policy/v3 owner-authority bindings, end to end through the +//! production app on SQLite and on PostgreSQL. +//! +//! PostgreSQL runs need `TINYCLOUD_TEST_POSTGRES_URL` (each test uses its own +//! schema); they are skipped when it is unset outside CI. + +use super::*; +use rocket::{ + figment::{providers::Serialized, Figment}, + http::{ContentType, Header}, + local::asynchronous::Client, +}; +use serde_json::json; +use std::collections::BTreeMap; +use tinycloud_auth::{ + authorization::{make_invocation, HeaderEncode, InvocationOptions, TinyCloudRevocation}, + cacaos::{ + siwe::{encode_eip55, Message, Version}, + siwe_cacao::{ + Eip191, Header as SiweHeader, Payload as CacaoPayload, Signature as SiweSignature, + SiweCacao, + }, + }, + siwe_recap::{Ability as RecapAbility, Capability as RecapCapability}, + ssi::{claims::jwt::NumericDate, ucan::Payload}, + ucan_capabilities_object::Capabilities, +}; +use tinycloud_core::sea_orm::{ + sea_query::{Alias, Query}, + ConnectOptions, Database, DbBackend, Statement, +}; + +type Cid = tinycloud_auth::ipld_core::cid::Cid; + +const CONTENT: &[u8] = b"tc-1423-content"; +const KV_GET: &str = "tinycloud.kv/get"; +const KV_METADATA: &str = "tinycloud.kv/metadata"; +const DECRYPT: &str = "tinycloud.encryption/decrypt"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Backend { + Sqlite, + Postgres, +} + +/// Run an async test body once per backend. +macro_rules! on_both_backends { + ($(#[$meta:meta])* $name:ident) => { + mod $name { + use super::*; + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn sqlite() -> anyhow::Result<()> { + super::$name(Backend::Sqlite).await + } + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn postgres() -> anyhow::Result<()> { + super::$name(Backend::Postgres).await + } + } + }; +} + +fn postgres_url(test: &str) -> Option { + match std::env::var("TINYCLOUD_TEST_POSTGRES_URL") { + Ok(url) if !url.trim().is_empty() => Some(url), + _ => { + assert!( + std::env::var_os("CI").is_none(), + "{test} requires TINYCLOUD_TEST_POSTGRES_URL, which is unset in CI (TC-381)" + ); + eprintln!("skipping {test}: TINYCLOUD_TEST_POSTGRES_URL is unset"); + None + } + } +} + +fn unique(label: &str) -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + format!( + "{label}{}x{}x{}", + std::process::id(), + OffsetDateTime::now_utc().unix_timestamp_nanos() % 1_000_000_000_000, + NEXT.fetch_add(1, Ordering::SeqCst) + ) +} + +/// A database the production app can be booted on, more than once. +pub(super) struct TestDatabase { + pub backend: Backend, + url: String, + _dir: tempfile::TempDir, +} + +impl TestDatabase { + pub async fn new(backend: Backend, test: &str) -> anyhow::Result> { + let dir = tempfile::tempdir()?; + let url = match backend { + Backend::Sqlite => format!("sqlite://{}/node.db?mode=rwc", dir.path().display()), + Backend::Postgres => { + let Some(url) = postgres_url(test) else { + return Ok(None); + }; + let schema = unique("tc1423_"); + let admin = Database::connect(ConnectOptions::new(url.clone())).await?; + admin + .execute_unprepared(&format!("CREATE SCHEMA {schema}")) + .await?; + let mut parsed = reqwest::Url::parse(&url)?; + parsed + .query_pairs_mut() + .append_pair("options", &format!("-c search_path={schema}")); + parsed.to_string() + } + }; + Ok(Some(Self { + backend, + url, + _dir: dir, + })) + } + + pub fn url(&self) -> &str { + &self.url + } + + pub fn datadir(&self) -> &std::path::Path { + self._dir.path() + } +} + +/// A principal that signs delegations, invocations and revocations. +#[allow(clippy::large_enum_variant)] +pub(super) enum Signer { + Key { + keypair: tinycloud_core::libp2p::identity::ed25519::Keypair, + jwk: JWK, + did: String, + vm: String, + }, + Pkh { + key: k256::ecdsa::SigningKey, + address: [u8; 20], + did: String, + }, +} + +#[derive(Clone, Copy)] +pub(super) struct Window { + pub not_before: OffsetDateTime, + pub expiry: OffsetDateTime, + /// The CACAO issue time; UCANs carry none. + pub issued_at: Option, +} + +impl Window { + pub fn until(expiry: OffsetDateTime) -> Self { + Self { + not_before: now() - Duration::seconds(30), + expiry, + issued_at: None, + } + } + + pub fn issued(mut self, issued_at: OffsetDateTime) -> Self { + self.issued_at = Some(issued_at); + self + } +} + +pub(super) fn now() -> OffsetDateTime { + OffsetDateTime::now_utc().replace_nanosecond(0).unwrap() +} + +fn seconds(value: OffsetDateTime) -> anyhow::Result { + Ok(NumericDate::try_from_seconds(value.unix_timestamp() as f64)?) +} + +fn capabilities(grants: &[(String, &str)]) -> anyhow::Result> { + let mut capabilities = Capabilities::::new(); + for (resource, ability) in grants { + capabilities.with_action( + resource.parse()?, + ability.parse::()?, + [BTreeMap::::new()], + ); + } + Ok(capabilities) +} + +impl Signer { + pub fn key() -> Self { + let keypair = tinycloud_core::libp2p::identity::ed25519::Keypair::generate(); + let did = tinycloud_core::keys::public_key_to_did_key( + tinycloud_core::libp2p::identity::Keypair::from(keypair.clone()).public(), + ); + let mut jwk = JWK::from(Params::OKP(OctetParams { + curve: "Ed25519".to_owned(), + public_key: Base64urlUInt(keypair.public().to_bytes().to_vec()), + private_key: Some(Base64urlUInt(keypair.secret().as_ref().to_vec())), + })); + jwk.algorithm = Some(Algorithm::EdDSA); + let vm = format!("{did}#{}", did.trim_start_matches("did:key:")); + Self::Key { + keypair, + jwk, + did, + vm, + } + } + + pub fn pkh() -> Self { + use sha3::Keccak256; + let mut secret = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut secret); + let key = k256::ecdsa::SigningKey::from_bytes(&secret.into()).expect("k256 key"); + let public = key.verifying_key().to_encoded_point(false); + let digest = Keccak256::digest(&public.as_bytes()[1..]); + let address: [u8; 20] = digest[12..].try_into().unwrap(); + let did = format!("did:pkh:eip155:1:0x{}", encode_eip55(&address)); + Self::Pkh { key, address, did } + } + + pub fn did(&self) -> &str { + match self { + Self::Key { did, .. } | Self::Pkh { did, .. } => did, + } + } + + fn keypair(&self) -> &tinycloud_core::libp2p::identity::ed25519::Keypair { + match self { + Self::Key { keypair, .. } => keypair, + Self::Pkh { .. } => panic!("not an Ed25519 signer"), + } + } + + fn sign_siwe(&self, message: &Message) -> anyhow::Result { + let Self::Pkh { key, .. } = self else { + anyhow::bail!("not a did:pkh signer"); + }; + let (signature, recovery) = key.sign_prehash_recoverable(&message.eip191_hash()?)?; + let mut bytes = [0u8; 65]; + bytes[..64].copy_from_slice(signature.to_bytes().as_ref()); + bytes[64] = u8::from(recovery) + 27; + Ok(SiweSignature::from(bytes)) + } + + fn siwe_message( + &self, + uri: &str, + nonce: &str, + issued_at: OffsetDateTime, + not_before: Option, + expiry: OffsetDateTime, + ) -> anyhow::Result { + let Self::Pkh { address, .. } = self else { + anyhow::bail!("not a did:pkh signer"); + }; + Ok(Message { + scheme: Some("https".parse()?), + domain: "tc1423.test".parse()?, + address: *address, + statement: None, + uri: uri.parse()?, + version: Version::V1, + chain_id: 1, + nonce: nonce.replace(['-', '_'], ""), + issued_at: issued_at.into(), + expiration_time: Some(expiry.into()), + not_before: not_before.map(Into::into), + request_id: None, + resources: vec![], + }) + } + + /// An encoded delegation to `audience`. + pub fn delegate( + &self, + audience: &str, + grants: &[(String, &str)], + window: Window, + proofs: &[Cid], + ) -> anyhow::Result { + let nonce = unique("tc1423delegation"); + match self { + Self::Key { jwk, vm, .. } => Ok(TinyCloudDelegation::Ucan(Box::new( + Payload { + issuer: vm.parse()?, + audience: audience.parse()?, + not_before: Some(seconds(window.not_before)?), + expiration: seconds(window.expiry)?, + nonce: Some(nonce), + facts: Some(vec![]), + proof: proofs.to_vec(), + attenuation: capabilities(grants)?, + } + .sign(Algorithm::EdDSA, jwk)?, + )) + .encode()?), + Self::Pkh { .. } => { + anyhow::ensure!(proofs.is_empty(), "SIWE fixtures carry no proofs"); + let mut recap = RecapCapability::::new(); + for (resource, ability) in grants { + recap.with_action( + resource.parse()?, + ability.parse::()?, + [BTreeMap::::new()], + ); + } + let message = recap.build_message(self.siwe_message( + audience, + &nonce, + window.issued_at.unwrap_or(window.not_before), + Some(window.not_before), + window.expiry, + )?)?; + let signature = self.sign_siwe(&message)?; + Ok(TinyCloudDelegation::Cacao(Box::new(SiweCacao::new( + message.into(), + signature, + SiweHeader, + ))) + .encode()?) + } + } + } + + /// An encoded revocation of `cid`. + pub fn revoke(&self, cid: &str) -> anyhow::Result { + match self { + Self::Key { jwk, vm, did, .. } => Ok(TinyCloudRevocation::Ucan(Box::new( + Payload { + issuer: vm.parse()?, + audience: did.parse()?, + not_before: None, + expiration: seconds(now() + Duration::seconds(60))?, + nonce: Some(unique("tc1423revoke")), + facts: Some(Vec::new()), + proof: Vec::new(), + attenuation: capabilities(&[( + format!("urn:cid:{cid}"), + "tinycloud.delegation/revoke", + )])?, + } + .sign(Algorithm::EdDSA, jwk)?, + )) + .encode()?), + Self::Pkh { .. } => { + let message = self.siwe_message( + &format!("ucan:{cid}"), + &unique("tc1423revoke"), + now(), + None, + now() + Duration::seconds(60), + )?; + let signature = self.sign_siwe(&message)?; + Ok(TinyCloudRevocation::Cacao(Box::new( + CacaoPayload::from(message).sign::(signature), + )) + .encode()?) + } + } + } + + /// An encoded invocation of `attenuation` citing `proof`. + pub fn invoke(&self, attenuation: Capabilities, proof: Cid) -> anyhow::Result { + let Self::Key { jwk, vm, did, .. } = self else { + anyhow::bail!("invocations are signed by keys"); + }; + let at = now(); + Ok(Payload { + issuer: vm.parse()?, + audience: did.parse()?, + not_before: Some(seconds(at)?), + expiration: seconds(at + Duration::seconds(30))?, + nonce: Some(unique("tc1423invoke")), + facts: Some(Vec::::new()), + proof: vec![proof], + attenuation, + } + .sign(Algorithm::EdDSA, jwk)? + .encode()?) + } +} + +/// The owner-binding row as stored. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct BindingRow { + pub state: String, + pub unknown_reason: Option, + pub registered_mode: String, + pub authority_expires_at: Option, + pub classifier_version: i32, +} + +#[derive(Debug, Clone)] +pub(super) struct Registered { + pub policy_cid: String, + pub policy_root_cid: String, +} + +/// A Policy/v3 document over `content_resource`, signed by `owner`, whose +/// source and decrypt ceiling name `network`. +pub(super) fn signed_policy( + owner: &Signer, + vector: &Value, + content_resource: &str, + network: &str, + root: &Window, +) -> Value { + let owner_did = owner.did().to_owned(); + let projection = vector["policyProjection"].clone(); + let created = (now() - Duration::seconds(60)).min(root.not_before); + let ceiling = vec![ + json!({ + "kind": "kv", + "resource": content_resource, + "selector": "exact", + "actions": [KV_GET, KV_METADATA], + }), + json!({ + "kind": "encryption", + "resource": network, + "action": DECRYPT, + }), + ]; + let mut policy = json!({ + "schema": POLICY_V2_SCHEMA, + "policyId": "pending", + "ownerDid": owner_did, + "createdAt": format_time(created), + "expiresAt": format_time(root.expiry), + "contentSource": { + "shareId": unique("share-tc-1423-"), + "kvResource": content_resource, + "selector": "exact", + "encryptionNetwork": network, + "encryptedSymmetricKeyDigestHex": "aa".repeat(32), + "keyVersion": 1, + "mode": "immutable", + "initialCiphertextDigestHex": "bb".repeat(32) + }, + "capabilityCeiling": ceiling, + "credentialRequirement": projection, + "signature": {"suite": "Ed25519", "signerDid": owner_did, "value": ""} + }); + let mut unsigned = policy.clone(); + unsigned.as_object_mut().unwrap().remove("policyId"); + unsigned.as_object_mut().unwrap().remove("signature"); + let mut preimage = POLICY_V2_SCHEMA.as_bytes().to_vec(); + preimage.push(0); + preimage.extend_from_slice(&canonical_json_value(&unsigned)); + policy["policyId"] = json!(format!("pol_{}", base32_lower(&Sha256::digest(&preimage)))); + policy["signature"]["value"] = json!(encode_config( + owner.keypair().sign(&Sha256::digest(preimage)), + URL_SAFE_NO_PAD + )); + policy +} + +/// Which principal owns the content space, and who owns the policy. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Shape { + /// A did:key space owner delegates (UCAN) to the policy owner's key. + KeySession, + /// A did:pkh space owner signs SIWE to the policy owner's key, as Share + /// and the CLI do. + PkhSession, + /// The policy owner owns the content space. + SpaceOwner, +} + +pub(super) struct Harness { + pub client: Client, + pub db: TestDatabase, + pub space_owner: Signer, + /// The policy owner (a key: it signs the policy and its roots). + pub owner: Signer, + pub holder: Signer, + pub enforcer: Signer, + pub content_space: SpaceId, + pub content_resource: tinycloud_auth::resource::ResourceId, + pub network: String, + issuer: tinycloud_core::libp2p::identity::ed25519::Keypair, + vector: Value, +} + +/// Settings for one boot of the production app. +#[derive(Clone, Default)] +pub(super) struct Boot { + /// The `[policy_v3]` section, passed through figment. + pub policy_v3: Option, +} + +impl Harness { + pub async fn new(backend: Backend, shape: Shape, test: &str) -> anyhow::Result> { + Self::with_boot(backend, shape, test, Boot::default()).await + } + + pub async fn with_boot( + backend: Backend, + shape: Shape, + test: &str, + boot: Boot, + ) -> anyhow::Result> { + let Some(db) = TestDatabase::new(backend, test).await? else { + return Ok(None); + }; + let vector: Value = serde_json::from_str(include_str!( + "../../test-fixtures/tc-470-policy-credential-requirement.json" + ))?; + let issuer = tinycloud_core::libp2p::identity::ed25519::Keypair::generate(); + let owner = Signer::key(); + let space_owner = match shape { + Shape::KeySession => Signer::key(), + Shape::PkhSession => Signer::pkh(), + // The same key: a separate value keeps the struct simple. + Shape::SpaceOwner => match &owner { + Signer::Key { + keypair, + jwk, + did, + vm, + } => Signer::Key { + keypair: keypair.clone(), + jwk: jwk.clone(), + did: did.clone(), + vm: vm.clone(), + }, + Signer::Pkh { .. } => unreachable!(), + }, + }; + let content_space = SpaceId::new( + space_owner.did().parse::()?, + "applications".parse()?, + ); + let content_resource = content_space.clone().to_resource( + "kv".parse()?, + Some("shares/tc-1423/document.txt".parse()?), + None, + None, + ); + let network = format!("urn:tinycloud:encryption:{}:mainnet", space_owner.did()); + let client = Self::boot_app(&db, &vector, &issuer, &boot).await?; + // Registration requires the source network to exist and to be the + // content owner's (TC-1423), so the harness creates it. + client + .rocket() + .state::() + .expect("encryption service") + .create_one_of_one_network(tinycloud_core::encryption_network::CreateNetworkRequest { + name: "mainnet".into(), + owner_did: space_owner.did().to_string(), + threshold: tinycloud_core::encryption_network::Threshold::one_of_one(), + }) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; + let harness = Self { + client, + db, + space_owner, + owner, + holder: Signer::key(), + enforcer: Signer::key(), + content_space, + content_resource, + network, + issuer, + vector, + }; + harness.seed_content().await?; + Ok(Some(harness)) + } + + async fn boot_app( + db: &TestDatabase, + vector: &Value, + issuer: &tinycloud_core::libp2p::identity::ed25519::Keypair, + boot: &Boot, + ) -> anyhow::Result { + let mut config = crate::config::Config::default(); + config.storage.datadir = db.datadir().into(); + config.storage.database = Some(db.url().to_owned()); + config.keys = serde_json::from_value(json!({ + "type": "static", + "secret": encode_config([53u8; 32], URL_SAFE_NO_PAD), + }))?; + config.share_email.issuer_did = vector["policyProjection"]["issuerDid"] + .as_str() + .unwrap() + .to_owned(); + config.share_email.issuer_vct = "opencredentials.email/v1".to_owned(); + config.share_email.issuer_key_version = 1; + config.share_email.issuer_kid = vector["policyProjection"]["issuerKid"] + .as_str() + .unwrap() + .to_owned(); + config.share_email.issuer_public_key = + Some(encode_config(issuer.public().to_bytes(), URL_SAFE_NO_PAD)); + let mut figment = Figment::from(rocket::Config { + address: "127.0.0.1".parse()?, + log_level: rocket::config::LogLevel::Off, + ..rocket::Config::default() + }) + .merge(Serialized::defaults(&config)); + if let Some(policy_v3) = &boot.policy_v3 { + figment = figment.merge(Serialized::default("policy_v3", policy_v3)); + } + let config: crate::config::Config = figment.extract()?; + Ok(Client::tracked(crate::app_with_control(&figment, &config, None).await?).await?) + } + + /// Boot the production app again on the same database. + pub async fn reboot(&mut self, boot: Boot) -> anyhow::Result<()> { + let client = Self::boot_app(&self.db, &self.vector, &self.issuer, &boot).await?; + self.client = client; + Ok(()) + } + + pub fn conn(&self) -> &tinycloud_core::RecoveringConnection { + self.client + .rocket() + .state::() + .expect("node pool") + } + + pub fn node_did(&self) -> String { + self.client + .rocket() + .state::() + .expect("policy runtime") + .node_did + .clone() + } + + /// What a policy over the content grants, and what the owner must hold. + pub fn owner_grants(&self) -> Vec<(String, &'static str)> { + vec![ + (self.content_resource.to_string(), KV_GET), + (self.content_resource.to_string(), KV_METADATA), + (self.network.clone(), DECRYPT), + ] + } + + pub async fn delegate(&self, authorization: String) -> (Status, String) { + let response = self + .client + .post("/delegate") + .header(Header::new("Authorization", authorization)) + .dispatch() + .await; + let status = response.status(); + (status, response.into_string().await.unwrap_or_default()) + } + + /// Import a delegation and return its CID. + pub async fn import(&self, authorization: String) -> anyhow::Result { + let (status, body) = self.delegate(authorization).await; + anyhow::ensure!(status == Status::Ok, "/delegate: {status} {body}"); + Ok(serde_json::from_str::(&body)?["cid"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing cid"))? + .to_owned()) + } + + /// The space owner grants the policy owner what a policy over the + /// content needs. + pub async fn grant_owner(&self, window: Window) -> anyhow::Result { + self.grant_owner_with(&self.owner_grants(), window).await + } + + pub async fn grant_owner_with( + &self, + grants: &[(String, &str)], + window: Window, + ) -> anyhow::Result { + self.import( + self.space_owner + .delegate(self.owner.did(), grants, window, &[])?, + ) + .await + } + + async fn seed_content(&self) -> anyhow::Result<()> { + let space_resource = + self.content_space + .clone() + .to_resource("space".parse()?, None, None, None); + let grant = self + .import(self.space_owner.delegate( + self.holder.did(), + &[ + (space_resource.to_string(), "tinycloud.space/host"), + (self.content_resource.to_string(), "tinycloud.kv/put"), + (self.content_resource.to_string(), KV_GET), + ], + Window::until(now() + Duration::hours(2)), + &[], + )?) + .await?; + let Signer::Key { jwk, vm, .. } = &self.holder else { + unreachable!() + }; + let seed = make_invocation( + [( + self.content_resource.clone(), + ["tinycloud.kv/put".parse::()?], + )], + &grant.parse()?, + jwk, + vm, + (now() + Duration::seconds(45)).unix_timestamp() as f64, + InvocationOptions { + nonce: Some(unique("tc1423seed")), + ..InvocationOptions::default() + }, + )?; + let response = self + .client + .post("/invoke") + .header(Header::new("Authorization", seed.encode()?)) + .header(ContentType::Plain) + .body(CONTENT) + .dispatch() + .await; + anyhow::ensure!( + response.status() == Status::Ok, + "seed: {}", + response.status() + ); + Ok(()) + } + + fn requested(&self) -> Vec { + vec![json!({ + "kind": "kv", + "resource": self.content_resource.to_string(), + "selector": "exact", + "actions": [KV_GET], + })] + } + + /// Register a policy over the content whose roots span `root`, signed + /// by the policy owner. + pub async fn register(&self, root: Window) -> anyhow::Result<(Status, String, Registered)> { + let owner_did = self.owner.did().to_owned(); + let policy = signed_policy( + &self.owner, + &self.vector, + &self.content_resource.to_string(), + &self.network, + &root, + ); + let policy_bytes = canonical_json_value(&policy); + let policy_cid = Cid::new_v1( + 0x55, + tinycloud_auth::multihash_codetable::Code::Sha2_256.digest(&policy_bytes), + ) + .to_string(); + let projections = + registration_projections(&policy).map_err(|(_, error)| anyhow::anyhow!(error))?; + let digest = policy_digest_hex(&policy).map_err(|(_, error)| anyhow::anyhow!(error))?; + let node_did = self.node_did(); + let common = json!({ + "ownerDid": owner_did, + "policyId": policy["policyId"], + "policyDigestHex": digest, + "policyCid": policy_cid, + "contentSourceDigestHex": projections.content_source_digest_hex, + "capabilityCeilingHashHex": projections.capability_ceiling_hash_hex, + "nativeProjectionHashHex": projections.native_projection_hash_hex, + "nodeAudience": node_did, + }); + let Signer::Key { keypair, vm, .. } = &self.owner else { + unreachable!() + }; + let root_authorization = + |audience: &str, role: &str, mode: &str, enforcer: Option<&str>| { + let mut facts = common.as_object().unwrap().clone(); + facts.insert("role".into(), json!(role)); + facts.insert("mode".into(), json!(mode)); + if let Some(enforcer) = enforcer { + facts.insert("enforcerDid".into(), json!(enforcer)); + } + let header = json!({ + "alg": "EdDSA", + "jwk": { + "alg": "EdDSA", + "crv": "Ed25519", + "kty": "OKP", + "x": encode_config(keypair.public().to_bytes(), URL_SAFE_NO_PAD), + }, + "typ": "JWT", + "ucv": "0.10.0", + }); + let payload = json!({ + "att": projections.attenuation.clone(), + "aud": audience, + "exp": root.expiry.unix_timestamp(), + "fct": [Value::Object(facts)], + "iss": vm, + "nbf": root.not_before.unix_timestamp(), + "nnc": unique(&format!("tc-1423-{role}-")), + "prf": [], + }); + let protected = encode_config(canonical_json_value(&header), URL_SAFE_NO_PAD); + let payload = encode_config(canonical_json_value(&payload), URL_SAFE_NO_PAD); + let signing_input = format!("{protected}.{payload}"); + format!( + "{signing_input}.{}", + encode_config(keypair.sign(signing_input.as_bytes()), URL_SAFE_NO_PAD) + ) + }; + let policy_root = root_authorization( + &format!("did:tinycloud:policy:{digest}"), + "policy-authority", + "policy-source", + None, + ); + let enforcement_root = root_authorization( + self.enforcer.did(), + "policy-enforcement", + "conditional-mint", + Some(self.enforcer.did()), + ); + let policy_root_cid = decode_root(&policy_root) + .map_err(|(_, error)| anyhow::anyhow!(error))? + .0; + let binding = self + .client + .post("/policy/v3/enforcer-bindings") + .header(ContentType::JSON) + .body( + json!({"rootExpiresAt": format_time(root.expiry), "enforcerDid": self.enforcer.did()}) + .to_string(), + ) + .dispatch() + .await; + anyhow::ensure!(binding.status() == Status::Ok, "enforcer binding"); + let binding: Value = binding.into_json().await.unwrap(); + let response = self + .client + .post("/policy/v3/policies") + .header(ContentType::JSON) + .body( + json!({ + "policyCid": policy_cid, + "policy": policy, + "policyRoot": policy_root, + "enforcementRoot": enforcement_root, + "contentSourceDigestHex": projections.content_source_digest_hex, + "nativeProjectionHashHex": projections.native_projection_hash_hex, + "attestedEnforcerBinding": binding, + }) + .to_string(), + ) + .dispatch() + .await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + Ok(( + status, + body, + Registered { + policy_cid, + policy_root_cid, + }, + )) + } + + /// Register and require success. + pub async fn register_ok(&self, root: Window) -> anyhow::Result { + let (status, body, registered) = self.register(root).await?; + anyhow::ensure!(status == Status::Ok, "register: {status} {body}"); + Ok(registered) + } + + pub async fn challenge(&self, policy: &Registered) -> (Status, String) { + let response = self + .client + .post("/policy/v3/challenges") + .header(ContentType::JSON) + .body( + json!({ + "policyCid": policy.policy_cid, + "recipientDid": self.holder.did(), + "requestedCapabilities": self.requested(), + }) + .to_string(), + ) + .dispatch() + .await; + let status = response.status(); + (status, response.into_string().await.unwrap_or_default()) + } + + /// Challenge and mint an accountless (v4) session for the holder. + pub async fn mint( + &self, + policy: &Registered, + ) -> anyhow::Result> { + let (status, body) = self.challenge(policy).await; + if status != Status::Ok { + return Ok(Err((status, body))); + } + let challenge: Value = serde_json::from_str(&body)?; + let issuer_did = self.vector["policyProjection"]["issuerDid"] + .as_str() + .unwrap(); + let issued = now() - Duration::seconds(5); + let expires = now() + Duration::seconds(600); + let holder = self.holder.did(); + let disclosure = encode_config( + canonical_json_value(&json!(["salt-tc-1423", "email", "alice@example.test"])), + URL_SAFE_NO_PAD, + ); + let credential_jti = unique("credential-tc-1423-"); + let payload = json!({ + "iss": issuer_did, + "sub": holder, + "iat": issued.unix_timestamp(), + "nbf": issued.unix_timestamp(), + "exp": expires.unix_timestamp(), + "jti": credential_jti, + "vct": "opencredentials.email/v1", + "profile": "tinycloud.email-proof/v1", + "profileVersion": 1, + "descriptorDigest": self.vector["policyProjection"]["descriptorDigest"], + "holderBinding": {"did": holder, "signingDomain": "tinycloud.credentials/holder-binding/v1"}, + "_sd_alg": "sha-256", + "_sd": [encode_config(Sha256::digest(disclosure.as_bytes()), URL_SAFE_NO_PAD)] + }); + let header = json!({ + "alg": "EdDSA", + "typ": "vc+sd-jwt", + "kid": self.vector["policyProjection"]["issuerKid"] + }); + let signing_input = format!( + "{}.{}", + encode_config(canonical_json_value(&header), URL_SAFE_NO_PAD), + encode_config(canonical_json_value(&payload), URL_SAFE_NO_PAD) + ); + let compact = format!( + "{signing_input}.{}~{disclosure}", + encode_config(self.issuer.sign(signing_input.as_bytes()), URL_SAFE_NO_PAD) + ); + let claims = json!({"email": "alice@example.test"}); + let credential = json!({ + "type": "OpenCredentialsIssuedCredential", + "version": 1, + "protocol": "tinycloud.credentials/acquisition/v1", + "profile": {"id": "tinycloud.email-proof/v1", "version": 1}, + "credentialType": {"id": "opencredentials.email/v1", "version": 1}, + "schema": "opencredentials.email/v1", + "format": "vc+sd-jwt", + "issuerDid": issuer_did, + "issuerKid": self.vector["policyProjection"]["issuerKid"], + "subjectDid": holder, + "holderDid": holder, + "claims": claims, + "claimsDigest": canonical_digest_base64url(&claims), + "descriptorDigest": self.vector["policyProjection"]["descriptorDigest"], + "credentialId": credential_jti, + "issuedAt": format_time(issued), + "notBefore": format_time(issued), + "expiresAt": format_time(expires), + "status": {"method": "none", "freshnessSeconds": 300}, + "credential": compact + }); + let mut presentation = json!({ + "schema": CREDENTIAL_PRESENTATION_V4_SCHEMA, + "jti": unique("presentation-tc-1423-"), + "challengeId": challenge["challengeId"], + "nonce": challenge["nonce"], + "policyCid": policy.policy_cid, + "nodeAudience": self.node_did(), + "holderDid": holder, + "subjectDid": holder, + "credentialDigest": encode_config(Sha256::digest(compact.as_bytes()), URL_SAFE_NO_PAD), + "requirementDigest": self.vector["requirementDigest"], + "descriptorDigest": self.vector["policyProjection"]["descriptorDigest"], + "requestedCapabilities": self.requested(), + "issuedAt": format_time(OffsetDateTime::now_utc()), + "expiresAt": format_time(OffsetDateTime::now_utc() + Duration::seconds(45)), + "signature": {"suite": "Ed25519", "signerDid": holder, "value": ""} + }); + let mut unsigned = presentation.clone(); + unsigned.as_object_mut().unwrap().remove("signature"); + let mut preimage = CREDENTIAL_PRESENTATION_V4_DOMAIN.to_vec(); + preimage.extend_from_slice(&canonical_json_value(&unsigned)); + presentation["signature"]["value"] = json!(encode_config( + self.holder.keypair().sign(&Sha256::digest(preimage)), + URL_SAFE_NO_PAD + )); + let response = self + .client + .post("/policy/v3/delegations") + .header(ContentType::JSON) + .body( + json!({ + "policyCid": policy.policy_cid, + "challengeId": challenge["challengeId"], + "nonce": challenge["nonce"], + "requirement": self.vector["sdkRequirement"], + "credential": credential, + "presentation": presentation, + "requestedExpiresAt": format_time(now() + Duration::seconds(300)), + }) + .to_string(), + ) + .dispatch() + .await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + if status != Status::Ok { + return Ok(Err((status, body))); + } + Ok(Ok(serde_json::from_str::(&body)?["authorization"] + .as_str() + .unwrap() + .to_owned())) + } + + pub async fn mint_ok(&self, policy: &Registered) -> anyhow::Result { + self.mint(policy) + .await? + .map_err(|(status, body)| anyhow::anyhow!("mint: {status} {body}")) + } + + /// The holder reads the content under a minted session. + pub async fn read(&self, session: &str) -> anyhow::Result<(Status, Vec)> { + let s0 = decode_delegation(session).map_err(|(_, error)| anyhow::anyhow!(error))?; + let invocation = self.holder.invoke( + serde_json::from_value( + attenuation_for_policy_capabilities(&self.requested()) + .map_err(|(_, error)| anyhow::anyhow!(error))?, + )?, + s0.content_hash().to_cid(0x55), + )?; + let response = self + .client + .post("/invoke") + .header(Header::new("Authorization", invocation)) + .dispatch() + .await; + let status = response.status(); + Ok((status, response.into_bytes().await.unwrap_or_default())) + } + + pub async fn revoke(&self, signer: &Signer, cid: &str) -> anyhow::Result<()> { + let response = self + .client + .post("/revoke") + .header(Header::new("Authorization", signer.revoke(cid)?)) + .dispatch() + .await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + anyhow::ensure!(status == Status::Ok, "revoke: {status} {body}"); + Ok(()) + } + + /// `POST /policy/v3/authorized`, signed by `signer` citing `proof`. + pub async fn list( + &self, + signer: &Signer, + cid: &str, + cursor: Option<&str>, + proof: Option, + ) -> anyhow::Result<(Status, Value)> { + let Signer::Key { jwk, vm, did, .. } = signer else { + anyhow::bail!("listing is invoked by keys"); + }; + let at = now(); + let invocation = Payload { + issuer: vm.parse()?, + audience: did.parse()?, + not_before: Some(seconds(at)?), + expiration: seconds(at + Duration::seconds(30))?, + nonce: Some(unique("tc1423list")), + facts: Some(Vec::::new()), + proof: proof.into_iter().collect(), + attenuation: capabilities(&[( + format!("urn:cid:{cid}"), + "tinycloud.delegation/status", + )])?, + } + .sign(Algorithm::EdDSA, jwk)? + .encode()?; + let response = self + .client + .post("/policy/v3/authorized") + .header(ContentType::JSON) + .header(Header::new("Authorization", invocation)) + .body(json!({"cid": cid, "cursor": cursor}).to_string()) + .dispatch() + .await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + Ok((status, serde_json::from_str(&body).unwrap_or(json!(body)))) + } + + fn backend(&self) -> DbBackend { + match self.db.backend { + Backend::Sqlite => DbBackend::Sqlite, + Backend::Postgres => DbBackend::Postgres, + } + } + + pub async fn binding(&self, policy_cid: &str) -> anyhow::Result> { + let query = Query::select() + .columns([ + Alias::new("state"), + Alias::new("unknown_reason"), + Alias::new("registered_mode"), + Alias::new("authority_expires_at"), + Alias::new("classifier_version"), + ]) + .from(Alias::new("policy_v3_owner_binding")) + .and_where(Expr::col(Alias::new("policy_cid")).eq(policy_cid)) + .to_owned(); + let Some(row) = self.conn().query_one(self.backend().build(&query)).await? else { + return Ok(None); + }; + Ok(Some(BindingRow { + state: row.try_get("", "state")?, + unknown_reason: row.try_get("", "unknown_reason")?, + registered_mode: row.try_get("", "registered_mode")?, + authority_expires_at: row.try_get("", "authority_expires_at")?, + classifier_version: row.try_get("", "classifier_version")?, + })) + } + + /// The recorded closure as CID strings. + pub async fn closure(&self, policy_cid: &str) -> anyhow::Result> { + let query = Query::select() + .column(Alias::new("delegation_id")) + .from(Alias::new("policy_v3_owner_authority")) + .and_where(Expr::col(Alias::new("policy_cid")).eq(policy_cid)) + .to_owned(); + let mut ids = self + .conn() + .query_all(self.backend().build(&query)) + .await? + .into_iter() + .map(|row| { + row.try_get::>("", "delegation_id") + .map_err(anyhow::Error::from) + .and_then(|bytes| { + Ok(Cid::new_v1( + 0x55, + tinycloud_auth::multihash_codetable::Multihash::from_bytes(&bytes)?, + ) + .to_string()) + }) + }) + .collect::>>()?; + ids.sort(); + Ok(ids) + } + + /// Forget a policy's classification, as for one registered before the + /// binding tables existed. + pub async fn forget_binding(&self, policy_cid: &str) -> anyhow::Result<()> { + for table in ["policy_v3_owner_authority", "policy_v3_owner_binding"] { + let query = Query::delete() + .from_table(Alias::new(table)) + .and_where(Expr::col(Alias::new("policy_cid")).eq(policy_cid)) + .to_owned(); + self.conn().execute(self.backend().build(&query)).await?; + } + Ok(()) + } + + pub async fn execute(&self, sql: &str) -> anyhow::Result<()> { + self.conn() + .execute(Statement::from_string(self.backend(), sql.to_owned())) + .await?; + Ok(()) + } + + /// Use the policy (a challenge) and wait for its background + /// classification. + pub async fn classify_on_use(&self, policy: &Registered) -> anyhow::Result { + let (status, body) = self.challenge(policy).await; + anyhow::ensure!(status == Status::Ok, "challenge: {status} {body}"); + self.wait_for_binding(&policy.policy_cid, std::time::Duration::from_secs(20)) + .await? + .ok_or_else(|| anyhow::anyhow!("not classified")) + } + + /// Wait until `policy_cid` is classified as `state`, or time out; + /// returns the last row seen. + pub async fn wait_for_state( + &self, + policy_cid: &str, + state: &str, + timeout: std::time::Duration, + ) -> anyhow::Result { + let deadline = tokio::time::Instant::now() + timeout; + loop { + let row = self.binding(policy_cid).await?; + if row.as_ref().is_some_and(|row| row.state == state) + || tokio::time::Instant::now() >= deadline + { + return row.ok_or_else(|| anyhow::anyhow!("not classified")); + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + } + + /// Wait until `policy_cid` has a classification, or time out. + pub async fn wait_for_binding( + &self, + policy_cid: &str, + timeout: std::time::Duration, + ) -> anyhow::Result> { + let deadline = tokio::time::Instant::now() + timeout; + loop { + if let Some(row) = self.binding(policy_cid).await? { + return Ok(Some(row)); + } + if tokio::time::Instant::now() >= deadline { + return Ok(None); + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + } +} + +fn policy_window() -> Window { + Window::until(now() + Duration::days(1)) +} + +fn legacy_boot() -> Boot { + // Every registration in these tests is before this cutover, so a row + // without a classification is classified as legacy. + Boot { + policy_v3: Some(json!({ + "legacy_cutover": format_time(now() + Duration::days(1)), + })), + } +} + +// --------------------------------------------------------------------------- +// Registration records the owner authority it used. +// --------------------------------------------------------------------------- + +async fn registration_records_the_owner_authority(backend: Backend) -> anyhow::Result<()> { + for shape in [Shape::KeySession, Shape::PkhSession] { + let Some(h) = + Harness::new(backend, shape, "registration_records_the_owner_authority").await? + else { + return Ok(()); + }; + let authority = h + .grant_owner(Window::until(now() + Duration::days(2))) + .await?; + let policy = h.register_ok(policy_window()).await?; + let binding = h.binding(&policy.policy_cid).await?.expect("binding"); + assert_eq!(binding.state, "bound", "{shape:?}"); + assert_eq!(binding.registered_mode, "record"); + assert_eq!(binding.unknown_reason, None); + assert!(binding.authority_expires_at.is_some()); + assert_eq!(h.closure(&policy.policy_cid).await?, vec![authority]); + } + Ok(()) +} +on_both_backends!(registration_records_the_owner_authority); + +async fn space_owner_policies_record_root_authority(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::new( + backend, + Shape::SpaceOwner, + "space_owner_policies_record_root_authority", + ) + .await? + else { + return Ok(()); + }; + let policy = h.register_ok(policy_window()).await?; + let binding = h.binding(&policy.policy_cid).await?.expect("binding"); + assert_eq!(binding.state, "root-authority"); + assert!(h.closure(&policy.policy_cid).await?.is_empty()); + Ok(()) +} +on_both_backends!(space_owner_policies_record_root_authority); + +/// A root that outlives the owner's authority is recorded as uncapped, not +/// refused. +async fn roots_outliving_their_authority_are_recorded_uncapped( + backend: Backend, +) -> anyhow::Result<()> { + let Some(h) = Harness::new( + backend, + Shape::KeySession, + "roots_outliving_their_authority_are_recorded_uncapped", + ) + .await? + else { + return Ok(()); + }; + let authority_expiry = now() + Duration::hours(1); + h.grant_owner(Window::until(authority_expiry)).await?; + let policy = h.register_ok(policy_window()).await?; + let binding = h.binding(&policy.policy_cid).await?.expect("binding"); + assert_eq!(binding.state, "bound-uncapped"); + assert_eq!( + binding.authority_expires_at.as_deref(), + Some(format_time(authority_expiry).as_str()) + ); + Ok(()) +} +on_both_backends!(roots_outliving_their_authority_are_recorded_uncapped); + +/// A link holder that delegates back to the policy owner, or the owner +/// delegating to itself, never becomes the recorded authority. +async fn delegations_back_to_the_owner_are_never_recorded(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::new( + backend, + Shape::KeySession, + "delegations_back_to_the_owner_are_never_recorded", + ) + .await? + else { + return Ok(()); + }; + // One window for the grant and everything derived from it: a child may + // not outlive its parent, and two `now()`s can straddle a second. + let window = Window::until(now() + Duration::days(2)); + let authority = h.grant_owner(window).await?; + let authority_cid: Cid = authority.parse()?; + let link_holder = Signer::key(); + let grants = h.owner_grants(); + let link = h + .import( + h.owner + .delegate(link_holder.did(), &grants, window, &[authority_cid])?, + ) + .await?; + let back = h + .import(link_holder.delegate(h.owner.did(), &grants, window, &[link.parse()?])?) + .await?; + let (self_status, self_body) = h + .delegate( + h.owner + .delegate(h.owner.did(), &grants, window, &[authority_cid])?, + ) + .await; + let policy = h.register_ok(policy_window()).await?; + let closure = h.closure(&policy.policy_cid).await?; + assert_eq!( + closure, + vec![authority.clone()], + "back={back} self={self_status} {self_body}" + ); + let (status, page) = h.list(&h.space_owner, &authority, None, None).await?; + assert_eq!(status, Status::Ok, "{page}"); + assert_eq!(page["policies"][0]["policyCid"], json!(policy.policy_cid)); + Ok(()) +} +on_both_backends!(delegations_back_to_the_owner_are_never_recorded); + +/// The same in the general window, where the owner's authority is +/// re-delegated (so the owner-first step finds nothing) and the loop-back +/// delegations sort ahead of it. +async fn loop_back_delegations_are_skipped_in_the_general_window( + backend: Backend, +) -> anyhow::Result<()> { + let Some(h) = Harness::new( + backend, + Shape::KeySession, + "loop_back_delegations_are_skipped_in_the_general_window", + ) + .await? + else { + return Ok(()); + }; + let grants = h.owner_grants(); + let window = Window::until(now() + Duration::days(2)); + let intermediary = Signer::key(); + let upstream = h + .import( + h.space_owner + .delegate(intermediary.did(), &grants, window, &[])?, + ) + .await?; + let authority_encoded = + high_id(|| intermediary.delegate(h.owner.did(), &grants, window, &[upstream.parse()?]))?; + let authority_id = graph_id(&authority_encoded)?; + let authority = h.import(authority_encoded).await?; + let link_holder = Signer::key(); + let link = h + .import( + h.owner + .delegate(link_holder.did(), &grants, window, &[authority.parse()?])?, + ) + .await?; + // Both loop-back delegations sort before the authority in the window's + // tie-break order (no issue time, then id), so only the filter skips them. + let back = sorting_before(&authority_id, || { + link_holder.delegate(h.owner.did(), &grants, window, &[link.parse()?]) + })?; + let back = h.import(back).await?; + let to_self = sorting_before(&authority_id, || { + h.owner + .delegate(h.owner.did(), &grants, window, &[authority.parse()?]) + })?; + let (self_status, self_body) = h.delegate(to_self).await; + let policy = h.register_ok(policy_window()).await?; + let mut expected = vec![authority.clone(), upstream.clone()]; + expected.sort(); + assert_eq!( + h.closure(&policy.policy_cid).await?, + expected, + "back={back} self={self_status} {self_body}" + ); + let (status, page) = h.list(&h.space_owner, &upstream, None, None).await?; + assert_eq!(status, Status::Ok, "{page}"); + assert_eq!(page["policies"][0]["policyCid"], json!(policy.policy_cid)); + Ok(()) +} +on_both_backends!(loop_back_delegations_are_skipped_in_the_general_window); + +/// The stored graph id of an encoded delegation. +fn graph_id(encoded: &str) -> anyhow::Result> { + Ok(decode_delegation(encoded) + .map_err(|(_, error)| anyhow::anyhow!(error))? + .content_hash() + .as_ref() + .to_vec()) +} + +/// The highest-sorting of 64 freshly signed delegations, so that later ids +/// can be chosen to sort before it (each fresh one does with probability +/// about 63/64, whatever the id layout). +fn high_id(mut sign: impl FnMut() -> anyhow::Result) -> anyhow::Result { + let mut best: Option<(Vec, String)> = None; + for _ in 0..64 { + let encoded = sign()?; + let id = graph_id(&encoded)?; + if best.as_ref().is_none_or(|(best_id, _)| id > *best_id) { + best = Some((id, encoded)); + } + } + Ok(best.expect("64 candidates").1) +} + +/// Set a registration's `registered_at`, as for one registered earlier. +async fn backdate( + h: &Harness, + policy_cid: &str, + registered_at: OffsetDateTime, +) -> anyhow::Result<()> { + let update = Query::update() + .table(Alias::new("policy_v3_registration")) + .value(Alias::new("registered_at"), format_time(registered_at)) + .and_where(Expr::col(Alias::new("policy_cid")).eq(policy_cid)) + .to_owned(); + h.conn().execute(h.backend().build(&update)).await?; + Ok(()) +} + +/// Sign fresh delegations until one's graph id sorts before `target`. +fn sorting_before( + target: &[u8], + mut sign: impl FnMut() -> anyhow::Result, +) -> anyhow::Result { + for _ in 0..512 { + let encoded = sign()?; + if graph_id(&encoded)?.as_slice() < target { + return Ok(encoded); + } + } + anyhow::bail!("no delegation sorted before the target") +} + +/// Delegations to the policy owner from other issuers' own spaces, with +/// future or backdated issue times, do not push the space owner's grant out +/// of the candidate window, at registration or in the legacy backfill. +async fn delegations_from_other_issuers_do_not_displace_the_owner_grant( + backend: Backend, +) -> anyhow::Result<()> { + let Some(h) = Harness::with_boot( + backend, + Shape::KeySession, + "delegations_from_other_issuers_do_not_displace_the_owner_grant", + legacy_boot(), + ) + .await? + else { + return Ok(()); + }; + let authority = h + .grant_owner(Window::until(now() + Duration::days(2))) + .await?; + let legacy = h.register_ok(policy_window()).await?; + h.forget_binding(&legacy.policy_cid).await?; + // More than the general window (256 rows) on each side of the + // registration time: future issue times lead it at registration, and + // backdated ones lead it in the legacy backfill. + import_delegations_from_other_issuers(&h, 260, Duration::days(300)).await?; + import_delegations_from_other_issuers(&h, 260, -Duration::days(300)).await?; + let policy = h.register_ok(policy_window()).await?; + assert_eq!( + h.closure(&policy.policy_cid).await?, + vec![authority.clone()] + ); + // First use of the legacy registration classifies it. + let binding = h.classify_on_use(&legacy).await?; + assert_eq!(binding.registered_mode, "backfill"); + assert_eq!(binding.state, "bound"); + assert_eq!(h.closure(&legacy.policy_cid).await?, vec![authority]); + Ok(()) +} +on_both_backends!(delegations_from_other_issuers_do_not_displace_the_owner_grant); + +/// SIWE delegations from other issuers' own spaces to the policy owner. +async fn import_delegations_from_other_issuers( + h: &Harness, + count: usize, + issued_offset: Duration, +) -> anyhow::Result<()> { + for _ in 0..count { + let issuer = Signer::pkh(); + let space = SpaceId::new(issuer.did().parse::()?, "applications".parse()?); + let resource = space.to_resource("kv".parse()?, Some("x".parse()?), None, None); + h.import(issuer.delegate( + h.owner.did(), + &[(resource.to_string(), KV_GET)], + Window::until(now() + Duration::days(400)).issued(now() + issued_offset), + &[], + )?) + .await?; + } + Ok(()) +} + +/// The general window only holds delegations touching the capability's space +/// or network, so delegations from other issuers do not crowd out a +/// re-delegated authority that the owner-first step does not see. +async fn delegations_from_other_issuers_do_not_fill_the_general_window( + backend: Backend, +) -> anyhow::Result<()> { + let Some(h) = Harness::with_boot( + backend, + Shape::KeySession, + "delegations_from_other_issuers_do_not_fill_the_general_window", + legacy_boot(), + ) + .await? + else { + return Ok(()); + }; + let intermediary = Signer::key(); + let window = Window::until(now() + Duration::days(2)); + let upstream = h + .import( + h.space_owner + .delegate(intermediary.did(), &h.owner_grants(), window, &[])?, + ) + .await?; + let authority = h + .import(intermediary.delegate( + h.owner.did(), + &h.owner_grants(), + window, + &[upstream.parse()?], + )?) + .await?; + let legacy = h.register_ok(policy_window()).await?; + h.forget_binding(&legacy.policy_cid).await?; + import_delegations_from_other_issuers(&h, 300, -Duration::days(300)).await?; + let binding = h.classify_on_use(&legacy).await?; + assert_eq!(binding.state, "bound"); + let mut expected = vec![authority, upstream]; + expected.sort(); + assert_eq!(h.closure(&legacy.policy_cid).await?, expected); + Ok(()) +} +on_both_backends!(delegations_from_other_issuers_do_not_fill_the_general_window); + +/// `starved` needs both steps to miss with candidates left unexamined: here +/// the general window is full of loop-back delegations (skipped without using +/// a try) ahead of the re-delegated authority. `starved` is not final. +async fn a_general_window_full_of_skipped_rows_is_starved(backend: Backend) -> anyhow::Result<()> { + let Some(mut h) = Harness::with_boot( + backend, + Shape::KeySession, + "a_general_window_full_of_skipped_rows_is_starved", + legacy_boot(), + ) + .await? + else { + return Ok(()); + }; + let grants = h.owner_grants(); + let window = Window::until(now() + Duration::days(2)); + let intermediary = Signer::key(); + let upstream = h + .import( + h.space_owner + .delegate(intermediary.did(), &grants, window, &[])?, + ) + .await?; + let authority_encoded = + high_id(|| intermediary.delegate(h.owner.did(), &grants, window, &[upstream.parse()?]))?; + let authority_id = graph_id(&authority_encoded)?; + let authority = h.import(authority_encoded).await?; + let legacy = h.register_ok(policy_window()).await?; + h.forget_binding(&legacy.policy_cid).await?; + let link_holder = Signer::key(); + let link = h + .import( + h.owner + .delegate(link_holder.did(), &grants, window, &[authority.parse()?])?, + ) + .await?; + for _ in 0..260 { + let back = sorting_before(&authority_id, || { + link_holder.delegate(h.owner.did(), &grants, window, &[link.parse()?]) + })?; + h.import(back).await?; + } + let binding = h.classify_on_use(&legacy).await?; + assert_eq!(binding.state, "unknown"); + assert_eq!(binding.unknown_reason.as_deref(), Some("starved")); + // `starved` is not final: once the owner holds a grant the owner-first + // step finds, a later use (here in a fresh process, past the back-off) + // classifies it again. + h.grant_owner(Window { + not_before: now() - Duration::hours(1), + expiry: now() + Duration::days(2), + issued_at: None, + }) + .await?; + h.reboot(legacy_boot()).await?; + h.challenge(&legacy).await; + let binding = h + .wait_for_state( + &legacy.policy_cid, + "bound", + std::time::Duration::from_secs(20), + ) + .await?; + assert_eq!(binding.state, "bound", "starved was treated as final"); + Ok(()) +} +on_both_backends!(a_general_window_full_of_skipped_rows_is_starved); + +/// Loop-back candidates ahead of the authority in the general window don't +/// use up its tries: 40 of them (more than the 32 tries) leave the +/// authority reachable. +async fn loop_back_rows_do_not_use_up_tries(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::new( + backend, + Shape::KeySession, + "loop_back_rows_do_not_use_up_tries", + ) + .await? + else { + return Ok(()); + }; + let grants = h.owner_grants(); + let window = Window::until(now() + Duration::days(2)); + let intermediary = Signer::key(); + let upstream = h + .import( + h.space_owner + .delegate(intermediary.did(), &grants, window, &[])?, + ) + .await?; + let authority_encoded = + high_id(|| intermediary.delegate(h.owner.did(), &grants, window, &[upstream.parse()?]))?; + let authority_id = graph_id(&authority_encoded)?; + let authority = h.import(authority_encoded).await?; + let link_holder = Signer::key(); + let link = h + .import( + h.owner + .delegate(link_holder.did(), &grants, window, &[authority.parse()?])?, + ) + .await?; + for _ in 0..40 { + let back = sorting_before(&authority_id, || { + link_holder.delegate(h.owner.did(), &grants, window, &[link.parse()?]) + })?; + h.import(back).await?; + } + let policy = h.register_ok(policy_window()).await?; + let mut expected = vec![authority, upstream]; + expected.sort(); + assert_eq!(h.closure(&policy.policy_cid).await?, expected); + Ok(()) +} +on_both_backends!(loop_back_rows_do_not_use_up_tries); + +/// Delegations to the owner from holders of the space owner's authority +/// (they pass the general window's scope) sort ahead of the space owner's +/// own grant; the owner-first step records that grant. +async fn owner_derived_delegations_cannot_displace_the_owner_grant( + backend: Backend, +) -> anyhow::Result<()> { + let Some(h) = Harness::new( + backend, + Shape::KeySession, + "owner_derived_delegations_cannot_displace_the_owner_grant", + ) + .await? + else { + return Ok(()); + }; + let grants = h.owner_grants(); + let window = Window::until(now() + Duration::days(2)); + let authority_encoded = + high_id(|| h.space_owner.delegate(h.owner.did(), &grants, window, &[]))?; + let authority_id = graph_id(&authority_encoded)?; + let authority = h.import(authority_encoded).await?; + let holder = Signer::key(); + let upstream = h + .import(h.space_owner.delegate(holder.did(), &grants, window, &[])?) + .await?; + for _ in 0..260 { + let derived = sorting_before(&authority_id, || { + holder.delegate(h.owner.did(), &grants, window, &[upstream.parse()?]) + })?; + h.import(derived).await?; + } + let policy = h.register_ok(policy_window()).await?; + assert_eq!(h.closure(&policy.policy_cid).await?, vec![authority]); + Ok(()) +} +on_both_backends!(owner_derived_delegations_cannot_displace_the_owner_grant); + +/// The legacy classifier only considers grants issued no later than the +/// registration. +async fn the_legacy_classifier_ignores_grants_issued_after_registration( + backend: Backend, +) -> anyhow::Result<()> { + let Some(h) = Harness::with_boot( + backend, + Shape::PkhSession, + "the_legacy_classifier_ignores_grants_issued_after_registration", + legacy_boot(), + ) + .await? + else { + return Ok(()); + }; + // Valid since two hours ago, but issued now. + h.grant_owner(Window { + not_before: now() - Duration::hours(2), + expiry: now() + Duration::days(2), + issued_at: Some(now()), + }) + .await?; + let legacy = h.register_ok(policy_window()).await?; + h.forget_binding(&legacy.policy_cid).await?; + backdate(&h, &legacy.policy_cid, now() - Duration::hours(1)).await?; + let binding = h.classify_on_use(&legacy).await?; + assert_eq!(binding.state, "unknown"); + assert_eq!(binding.unknown_reason.as_deref(), Some("missing")); + Ok(()) +} +on_both_backends!(the_legacy_classifier_ignores_grants_issued_after_registration); + +/// With a SIWE owner grant: delegations from other issuers issued after it +/// (future at registration, or between it and a legacy registration) do not +/// displace it. +async fn delegations_from_other_issuers_do_not_displace_a_siwe_owner_grant( + backend: Backend, +) -> anyhow::Result<()> { + let Some(h) = Harness::with_boot( + backend, + Shape::PkhSession, + "delegations_from_other_issuers_do_not_displace_a_siwe_owner_grant", + legacy_boot(), + ) + .await? + else { + return Ok(()); + }; + let authority = h + .grant_owner(Window { + not_before: now() - Duration::hours(3), + expiry: now() + Duration::days(2), + issued_at: Some(now() - Duration::hours(2)), + }) + .await?; + let legacy = h.register_ok(policy_window()).await?; + h.forget_binding(&legacy.policy_cid).await?; + backdate(&h, &legacy.policy_cid, now() - Duration::minutes(30)).await?; + import_delegations_from_other_issuers(&h, 260, -Duration::hours(1)).await?; + import_delegations_from_other_issuers(&h, 260, Duration::days(300)).await?; + let policy = h.register_ok(policy_window()).await?; + assert_eq!( + h.closure(&policy.policy_cid).await?, + vec![authority.clone()] + ); + let binding = h.classify_on_use(&legacy).await?; + assert_eq!(binding.state, "bound"); + assert_eq!(h.closure(&legacy.policy_cid).await?, vec![authority]); + Ok(()) +} +on_both_backends!(delegations_from_other_issuers_do_not_displace_a_siwe_owner_grant); + +/// An older grant that outlives the root is recorded over a newer one +/// that does not. +async fn a_grant_outliving_the_root_is_preferred(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::new( + backend, + Shape::PkhSession, + "a_grant_outliving_the_root_is_preferred", + ) + .await? + else { + return Ok(()); + }; + let long = h + .grant_owner(Window::until(now() + Duration::days(2)).issued(now() - Duration::minutes(10))) + .await?; + let short = h + .grant_owner(Window::until(now() + Duration::hours(1)).issued(now() - Duration::minutes(1))) + .await?; + let policy = h.register_ok(policy_window()).await?; + assert_eq!( + h.closure(&policy.policy_cid).await?, + vec![long], + "short={short}" + ); + assert_eq!(h.binding(&policy.policy_cid).await?.unwrap().state, "bound"); + Ok(()) +} +on_both_backends!(a_grant_outliving_the_root_is_preferred); + +// --------------------------------------------------------------------------- +// Legacy registrations are classified on first use, claim-first. +// --------------------------------------------------------------------------- + +async fn concurrent_first_uses_store_one_classification(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::with_boot( + backend, + Shape::KeySession, + "concurrent_first_uses_store_one_classification", + legacy_boot(), + ) + .await? + else { + return Ok(()); + }; + let authority = h + .grant_owner(Window::until(now() + Duration::days(2))) + .await?; + let policy = h.register_ok(policy_window()).await?; + h.forget_binding(&policy.policy_cid).await?; + let results = futures::future::join_all((0..6).map(|_| h.challenge(&policy))).await; + for (status, body) in results { + assert_eq!(status, Status::Ok, "{body}"); + } + h.wait_for_binding(&policy.policy_cid, std::time::Duration::from_secs(20)) + .await? + .expect("classified"); + let count = Query::select() + .expr(Expr::col(Alias::new("policy_cid")).count()) + .from(Alias::new("policy_v3_owner_binding")) + .and_where(Expr::col(Alias::new("policy_cid")).eq(policy.policy_cid.clone())) + .to_owned(); + let row = h + .conn() + .query_one(h.backend().build(&count)) + .await? + .unwrap(); + assert_eq!(row.try_get_by_index::(0)?, 1); + assert_eq!(h.closure(&policy.policy_cid).await?, vec![authority]); + Ok(()) +} +on_both_backends!(concurrent_first_uses_store_one_classification); + +/// A classification that cannot be stored is served from memory and +/// leaves the row pending. +async fn a_failed_classification_write_still_serves(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::with_boot( + backend, + Shape::KeySession, + "a_failed_classification_write_still_serves", + legacy_boot(), + ) + .await? + else { + return Ok(()); + }; + h.grant_owner(Window::until(now() + Duration::days(2))) + .await?; + let policy = h.register_ok(policy_window()).await?; + h.forget_binding(&policy.policy_cid).await?; + h.execute("DROP TABLE policy_v3_owner_authority").await?; + // The challenge starts a background classification whose write fails. + let session = h.mint_ok(&policy).await?; + let (status, body) = h.read(&session).await?; + assert_eq!(status, Status::Ok, "{body:?}"); + assert_eq!(body, CONTENT); + assert_eq!( + h.wait_for_binding(&policy.policy_cid, std::time::Duration::from_secs(2)) + .await?, + None + ); + Ok(()) +} +on_both_backends!(a_failed_classification_write_still_serves); + +/// A legacy registration whose original authority is revoked binds +/// to a live alternate; with none left it is recorded `unknown`. +async fn legacy_classification_falls_through_to_a_live_alternate( + backend: Backend, +) -> anyhow::Result<()> { + let Some(mut h) = Harness::with_boot( + backend, + Shape::KeySession, + "legacy_classification_falls_through_to_a_live_alternate", + legacy_boot(), + ) + .await? + else { + return Ok(()); + }; + let first = h + .grant_owner(Window::until(now() + Duration::days(2))) + .await?; + let second = h + .grant_owner(Window::until(now() + Duration::days(2))) + .await?; + let policy = h.register_ok(policy_window()).await?; + let recorded = h.closure(&policy.policy_cid).await?; + assert_eq!(recorded.len(), 1); + let alternate = if recorded[0] == first { + &second + } else { + &first + }; + h.revoke(&h.space_owner, &recorded[0]).await?; + h.forget_binding(&policy.policy_cid).await?; + h.classify_on_use(&policy).await?; + assert_eq!( + h.closure(&policy.policy_cid).await?, + vec![alternate.clone()] + ); + + h.revoke(&h.space_owner, alternate).await?; + h.forget_binding(&policy.policy_cid).await?; + // A fresh process: this one remembers the earlier classification as + // final. + h.reboot(legacy_boot()).await?; + let binding = h.classify_on_use(&policy).await?; + assert_eq!(binding.state, "unknown"); + assert_eq!(binding.unknown_reason.as_deref(), Some("revoked")); + assert!(h.closure(&policy.policy_cid).await?.is_empty()); + Ok(()) +} +on_both_backends!(legacy_classification_falls_through_to_a_live_alternate); + +/// An `unknown` row written by an older classifier is classified again. +async fn older_unknown_classifications_are_reevaluated(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::with_boot( + backend, + Shape::KeySession, + "older_unknown_classifications_are_reevaluated", + legacy_boot(), + ) + .await? + else { + return Ok(()); + }; + let authority = h + .grant_owner(Window::until(now() + Duration::days(2))) + .await?; + let policy = h.register_ok(policy_window()).await?; + h.forget_binding(&policy.policy_cid).await?; + let insert = Query::insert() + .into_table(Alias::new("policy_v3_owner_binding")) + .columns([ + Alias::new("policy_cid"), + Alias::new("state"), + Alias::new("authority_expires_at"), + Alias::new("unknown_reason"), + Alias::new("classifier_version"), + Alias::new("registered_mode"), + Alias::new("created_at"), + ]) + .values_panic([ + policy.policy_cid.clone().into(), + "unknown".into(), + Option::::None.into(), + "missing".into(), + 0i32.into(), + "backfill".into(), + format_time(now()).into(), + ]) + .to_owned(); + h.conn().execute(h.backend().build(&insert)).await?; + h.challenge(&policy).await; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(20); + let binding = loop { + let binding = h.binding(&policy.policy_cid).await?.expect("row"); + if binding.classifier_version > 0 || tokio::time::Instant::now() >= deadline { + break binding; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + }; + assert_eq!(binding.state, "bound"); + assert!(binding.classifier_version >= 2); + assert_eq!(h.closure(&policy.policy_cid).await?, vec![authority]); + Ok(()) +} +on_both_backends!(older_unknown_classifications_are_reevaluated); + +/// The sweep classifies pending registrations only when enabled. +async fn the_sweep_runs_only_when_enabled(backend: Backend) -> anyhow::Result<()> { + let Some(mut h) = Harness::with_boot( + backend, + Shape::KeySession, + "the_sweep_runs_only_when_enabled", + legacy_boot(), + ) + .await? + else { + return Ok(()); + }; + h.grant_owner(Window::until(now() + Duration::days(2))) + .await?; + let policy = h.register_ok(policy_window()).await?; + h.forget_binding(&policy.policy_cid).await?; + h.reboot(legacy_boot()).await?; + assert_eq!( + h.wait_for_binding(&policy.policy_cid, std::time::Duration::from_secs(2)) + .await?, + None + ); + let mut enabled = legacy_boot(); + enabled.policy_v3.as_mut().unwrap()["owner_binding_backfill"] = + json!({"enabled": true, "policies_per_second": 50}); + h.reboot(enabled).await?; + let binding = h + .wait_for_binding(&policy.policy_cid, std::time::Duration::from_secs(20)) + .await? + .expect("swept"); + assert_eq!(binding.registered_mode, "backfill"); + assert_eq!(binding.state, "bound"); + Ok(()) +} +on_both_backends!(the_sweep_runs_only_when_enabled); + +/// Rollback: `down` drops both tables; after a re-upgrade only +/// registrations before the configured cutover are legacy. +async fn rollback_and_reupgrade_respect_the_configured_cutover( + backend: Backend, +) -> anyhow::Result<()> { + use tinycloud_core::sea_orm_migration::MigratorTrait; + let Some(mut h) = Harness::new( + backend, + Shape::KeySession, + "rollback_and_reupgrade_respect_the_configured_cutover", + ) + .await? + else { + return Ok(()); + }; + h.grant_owner(Window::until(now() + Duration::days(2))) + .await?; + let before = h.register_ok(policy_window()).await?; + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + let cutover = OffsetDateTime::now_utc(); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + let during = h.register_ok(policy_window()).await?; + let direct = Database::connect(ConnectOptions::new(h.db.url().to_owned())).await?; + tinycloud_core::migrations::Migrator::down(&direct, Some(1)).await?; + drop(direct); + assert!( + h.binding(&before.policy_cid).await.is_err(), + "tables dropped" + ); + h.reboot(Boot { + policy_v3: Some(json!({"legacy_cutover": format_time(cutover)})), + }) + .await?; + assert_eq!( + h.classify_on_use(&before).await?.registered_mode, + "backfill" + ); + assert_eq!(h.classify_on_use(&during).await?.registered_mode, "record"); + Ok(()) +} +on_both_backends!(rollback_and_reupgrade_respect_the_configured_cutover); + +// --------------------------------------------------------------------------- +// Listing by any closure member. +// --------------------------------------------------------------------------- + +async fn listing_is_limited_to_parties_of_the_delegation(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::new( + backend, + Shape::KeySession, + "listing_is_limited_to_parties_of_the_delegation", + ) + .await? + else { + return Ok(()); + }; + let authority = h + .grant_owner(Window::until(now() + Duration::days(2))) + .await?; + let policy = h.register_ok(policy_window()).await?; + for (label, signer) in [("space owner", &h.space_owner), ("policy owner", &h.owner)] { + let (status, page) = h.list(signer, &authority, None, None).await?; + assert_eq!(status, Status::Ok, "{label}: {page}"); + let listed = &page["policies"][0]; + assert_eq!(listed["policyCid"], json!(policy.policy_cid)); + assert_eq!(listed["policyRootCid"], json!(policy.policy_root_cid)); + assert_eq!(listed["rootState"], json!("active")); + assert_eq!(listed["ownerAuthorityState"], json!("bound")); + assert!(listed.get("policy").is_none()); + assert_eq!(page["nextCursor"], Value::Null); + } + let stranger = Signer::key(); + for (label, signer) in [("recipient", &h.holder), ("stranger", &stranger)] { + let (status, page) = h.list(signer, &authority, None, None).await?; + assert_eq!(status, Status::Forbidden, "{label}: {page}"); + } + Ok(()) +} +on_both_backends!(listing_is_limited_to_parties_of_the_delegation); + +/// Keyset pages neither skip nor repeat existing rows while rows are added. +async fn listing_pages_are_stable_under_inserts(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::new( + backend, + Shape::KeySession, + "listing_pages_are_stable_under_inserts", + ) + .await? + else { + return Ok(()); + }; + let authority = h + .grant_owner(Window::until(now() + Duration::days(2))) + .await?; + let template = h.register_ok(policy_window()).await?; + let authority_bytes = authority.parse::()?.hash().to_bytes(); + let existing = (0..120) + .map(|index| format!("synthetic-{:04}", index * 2 + 1)) + .collect::>(); + insert_synthetic(&h, &template.policy_cid, &authority_bytes, existing.clone()).await?; + let (status, first) = h.list(&h.space_owner, &authority, None, None).await?; + assert_eq!(status, Status::Ok, "{first}"); + let first_page = first["policies"] + .as_array() + .unwrap() + .iter() + .map(|row| row["policyCid"].as_str().unwrap().to_owned()) + .collect::>(); + assert_eq!(first_page.len(), 100); + let cursor = first["nextCursor"].as_str().expect("cursor").to_owned(); + // Rows added on both sides of the cursor. + insert_synthetic( + &h, + &template.policy_cid, + &authority_bytes, + vec!["synthetic-0000".into(), "synthetic-0300".into()], + ) + .await?; + let (status, second) = h + .list(&h.space_owner, &authority, Some(&cursor), None) + .await?; + assert_eq!(status, Status::Ok, "{second}"); + let second_page = second["policies"] + .as_array() + .unwrap() + .iter() + .map(|row| row["policyCid"].as_str().unwrap().to_owned()) + .collect::>(); + let mut all_existing = existing.clone(); + all_existing.push(template.policy_cid.clone()); + all_existing.sort(); + let mut seen = first_page.clone(); + seen.extend( + second_page + .iter() + .filter(|cid| cid.as_str() != "synthetic-0300") + .cloned(), + ); + seen.sort(); + assert_eq!(seen, all_existing, "no existing row skipped or repeated"); + assert!(second_page.contains(&"synthetic-0300".to_owned())); + assert!(!second_page.contains(&"synthetic-0000".to_owned())); + assert_eq!(second["nextCursor"], Value::Null); + Ok(()) +} +on_both_backends!(listing_pages_are_stable_under_inserts); + +/// Copy `template`'s registration and classification under synthetic policy +/// CIDs whose closure is `authority`. +async fn insert_synthetic( + h: &Harness, + template: &str, + authority: &[u8], + cids: Vec, +) -> anyhow::Result<()> { + for cid in cids { + for sql in [ + format!( + "INSERT INTO policy_v3_registration (policy_cid, policy_bytes, policy_digest_hex, owner_did, policy_root_cid, enforcement_root_cid, content_source_digest_hex, native_projection_hash_hex, attested_enforcer_binding_bytes, registered_at, expires_at) SELECT '{cid}', policy_bytes, policy_digest_hex, owner_did, policy_root_cid, enforcement_root_cid, content_source_digest_hex, native_projection_hash_hex, attested_enforcer_binding_bytes, registered_at, expires_at FROM policy_v3_registration WHERE policy_cid = '{template}'" + ), + format!( + "INSERT INTO policy_v3_owner_binding (policy_cid, state, authority_expires_at, unknown_reason, classifier_version, registered_mode, created_at) SELECT '{cid}', state, authority_expires_at, unknown_reason, classifier_version, registered_mode, created_at FROM policy_v3_owner_binding WHERE policy_cid = '{template}'" + ), + ] { + h.execute(&sql).await?; + } + let insert = Query::insert() + .into_table(Alias::new("policy_v3_owner_authority")) + .columns([ + Alias::new("policy_cid"), + Alias::new("delegation_id"), + Alias::new("depth"), + ]) + .values_panic([cid.into(), authority.to_vec().into(), 0i32.into()]) + .to_owned(); + h.conn().execute(h.backend().build(&insert)).await?; + } + Ok(()) +} diff --git a/tinycloud-node-server/src/policy_v3/source_network_tests.rs b/tinycloud-node-server/src/policy_v3/source_network_tests.rs new file mode 100644 index 00000000..41da5a7c --- /dev/null +++ b/tinycloud-node-server/src/policy_v3/source_network_tests.rs @@ -0,0 +1,1019 @@ +//! TC-1423: every encryption network a Policy/v3 names must be the +//! content owner's, and active on this node, when the policy is registered. +//! +//! Registration-only fixtures over the real routes: `/delegate` imports the +//! registrant's grants (UCAN from a did:key owner, SIWE CACAO from a did:pkh +//! owner), `POST /encryption/networks` creates the Share-shaped network, and +//! `POST /policy/v3/policies` registers. Every refused case is otherwise +//! registrable: the registrant holds every root capability, so the refusal +//! comes from the source-network check alone. + +use super::*; +use k256::ecdsa::SigningKey; +use rocket::{http::ContentType, local::asynchronous::Client}; +use serde_json::json; +use sha3::Keccak256; +use std::future::Future; +use tinycloud_auth::{ + authorization::HeaderEncode, + cacaos::{ + siwe::{encode_eip55, Message, Version}, + siwe_cacao::{Header as SiweHeader, Signature as SiweSignature, SiweCacao}, + }, + siwe_recap::{Ability as RecapAbility, Capability as RecapCapability}, + ssi::{claims::jwt::NumericDate, ucan::Payload}, + ucan_capabilities_object::Capabilities, +}; +use tinycloud_core::{ + encryption_network::{ + canonical::canonical_hash, CreateNetworkRequest, EncryptionService, NetworkId, Threshold, + NETWORK_ADMIN_TYPE, NETWORK_CREATE_ACTION, + }, + models::encryption_network, + sea_orm::Database, +}; + +const KV_PATH: &str = "shares/tc-1423-n1b/document.txt"; +const DECRYPT: &str = "tinycloud.encryption/decrypt"; + +type Ed25519Keypair = tinycloud_core::libp2p::identity::ed25519::Keypair; + +/// A did:key principal: the policy owner (a session key, as in Share and the +/// CLI) or a did:key content owner. +struct KeyPrincipal { + key: Ed25519Keypair, + did: String, + vm: String, + jwk: JWK, +} + +impl KeyPrincipal { + fn generate() -> Self { + let key = Ed25519Keypair::generate(); + let did = tinycloud_core::keys::public_key_to_did_key( + tinycloud_core::libp2p::identity::Keypair::from(key.clone()).public(), + ); + let mut jwk = JWK::from(Params::OKP(OctetParams { + curve: "Ed25519".to_owned(), + public_key: Base64urlUInt(key.public().to_bytes().to_vec()), + private_key: Some(Base64urlUInt(key.secret().as_ref().to_vec())), + })); + jwk.algorithm = Some(Algorithm::EdDSA); + let vm = format!("{did}#{}", did.trim_start_matches("did:key:")); + Self { key, did, vm, jwk } + } +} + +/// An Ethereum account: a did:pkh content owner, as in Share. +struct PkhPrincipal { + key: SigningKey, + address: [u8; 20], +} + +impl PkhPrincipal { + /// A random account whose EIP-55 spelling has upper-case letters, so the + /// case-insensitivity cases mean something. + fn generate() -> Self { + loop { + let key = SigningKey::random(&mut rand::rngs::OsRng); + let public = key.verifying_key().to_encoded_point(false); + let digest = Keccak256::digest(&public.as_bytes()[1..]); + let mut address = [0_u8; 20]; + address.copy_from_slice(&digest[12..]); + let principal = Self { key, address }; + if principal.did(1) != principal.lowercase_did(1) { + return principal; + } + } + } + + /// The EIP-55 spelling space ids and session DIDs carry. + fn did(&self, chain_id: u64) -> String { + format!( + "did:pkh:eip155:{chain_id}:0x{}", + encode_eip55(&self.address) + ) + } + + /// The spelling Share puts in its network URN (`ownerEncryptionNetwork`). + fn lowercase_did(&self, chain_id: u64) -> String { + format!("did:pkh:eip155:{chain_id}:0x{}", hex::encode(self.address)) + } +} + +fn network(owner_did: &str, name: &str) -> String { + format!("urn:tinycloud:encryption:{owner_did}:{name}") +} + +fn kv_resource(owner_did: &str) -> anyhow::Result { + Ok( + SpaceId::new(owner_did.parse::()?, "applications".parse()?).to_resource( + "kv".parse()?, + Some(KV_PATH.parse()?), + None, + None, + ), + ) +} + +/// The create-network body for a network `:share`. +fn own_network_body(owner_did: &str) -> Value { + json!({"name": "share", "ownerDid": owner_did, "threshold": {"n": 1, "t": 1}}) +} + +/// The registrant's grants on the content: kv get + metadata on the content +/// resource, and decrypt on each listed network. +fn content_grants(kv: &ResourceId, decrypt: &[&str]) -> Vec<(String, &'static str)> { + let mut grants = vec![ + (kv.as_uri().to_string(), "tinycloud.kv/get"), + (kv.as_uri().to_string(), "tinycloud.kv/metadata"), + ]; + grants.extend(decrypt.iter().map(|network| (network.to_string(), DECRYPT))); + grants +} + +fn refused_total(reason: &str) -> u64 { + crate::prometheus::POLICY_SOURCE_NETWORK_REFUSED + .with_label_values(&[reason]) + .get() +} + +struct Harness { + client: Client, + node_did: String, + enforcer: KeyPrincipal, + credential_requirement: Value, +} + +impl Harness { + async fn new() -> anyhow::Result { + Self::on(Database::connect("sqlite::memory:").await?).await + } + + /// A fresh schema on the PostgreSQL server at `url`. + async fn postgres(url: &str) -> anyhow::Result { + let schema = format!("tc1423_n1b_{}", uuid::Uuid::new_v4().simple()); + Database::connect(url) + .await? + .execute_unprepared(&format!("CREATE SCHEMA {schema}")) + .await?; + let mut url = reqwest::Url::parse(url)?; + url.query_pairs_mut() + .append_pair("options", &format!("-c search_path={schema}")); + Self::on(Database::connect(url.as_str()).await?).await + } + + async fn on(db: tinycloud_core::sea_orm::DatabaseConnection) -> anyhow::Result { + use tinycloud_core::{ + database_artifacts::SeaOrmDatabaseArtifactRepository, + encryption::ColumnEncryption, + encryption_network::LocalOneOfOneBackend, + sql::SqlService, + storage::{either::Either, StorageConfig as _}, + }; + let vector: Value = serde_json::from_str(include_str!( + "../../test-fixtures/tc-470-policy-credential-requirement.json" + ))?; + let node_secret = + StaticSecret::new(vec![29; 32]).map_err(|_| anyhow::anyhow!("invalid secret"))?; + let node_did = node_secret.node_did(); + let runtime = PolicyV3Runtime::new(db.clone(), node_did.clone(), node_secret); + let storage_dir = tempfile::TempDir::new()?; + let storage = crate::storage::file_system::FileSystemConfig::new(storage_dir.path()) + .open() + .await?; + let _storage_dir = storage_dir.keep(); + let tinycloud = crate::TinyCloud::new( + db.clone().into(), + Either::B(storage), + StaticSecret::new(vec![0; 32]).map_err(|_| anyhow::anyhow!("invalid secret"))?, + ) + .await?; + let sql_dir = tempfile::TempDir::new()?; + let sql_service = SqlService::new( + sql_dir.path().to_string_lossy().to_string(), + u64::MAX, + Arc::new(SeaOrmDatabaseArtifactRepository::new(db.clone())), + ); + let _sql_dir = sql_dir.keep(); + let encryption = EncryptionService::new_with_node_keypair( + db.clone(), + runtime.signer.node_keypair(), + Arc::new(LocalOneOfOneBackend::new(ColumnEncryption::new( + runtime + .signer + .derive_key(b"tinycloud/encryption/network-seal"), + ))), + ); + assert_eq!(encryption.node_did(), node_did); + let rocket = rocket::build() + .mount( + "/", + rocket::routes![ + register_policy, + issue_enforcer_binding, + crate::routes::delegate, + crate::routes::encryption::create_network, + ], + ) + .attach(crate::tracing::TracingFairing::for_test( + &crate::config::Config::default().log.tracing, + )) + .manage(tinycloud) + .manage(runtime) + .manage(sql_service) + .manage(crate::config::Config::default()) + .manage(crate::quota::QuotaCache::new(None, None)) + .manage(crate::invocation_replay::InvocationReplayCache::new( + db.clone(), + )) + .manage(crate::BlockStage::from( + crate::config::StagingStorage::Memory, + )) + .manage(encryption); + Ok(Self { + client: Client::tracked(rocket).await?, + node_did, + enforcer: KeyPrincipal::generate(), + credential_requirement: vector["policyProjection"].clone(), + }) + } + + fn encryption(&self) -> &EncryptionService { + self.client.rocket().state::().unwrap() + } + + fn conn(&self) -> &RecoveringConnection { + &self + .client + .rocket() + .state::() + .unwrap() + .conn + } + + async fn post_delegation(&self, authorization: String) -> anyhow::Result { + let response = self + .client + .post("/delegate") + .header(rocket::http::Header::new("Authorization", authorization)) + .dispatch() + .await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + assert_eq!(status, Status::Ok, "/delegate: {body}"); + Ok(serde_json::from_str::(&body)?["cid"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing delegation cid"))? + .to_owned()) + } + + /// A did:key issuer delegates `grants` to `audience` (a UCAN). + async fn delegate_ucan( + &self, + issuer: &KeyPrincipal, + audience: &str, + grants: &[(String, &str)], + ) -> anyhow::Result { + let now = OffsetDateTime::now_utc(); + let mut attenuation = Capabilities::::new(); + for (resource, ability) in grants { + attenuation.with_action( + resource.parse()?, + ability.parse::()?, + [std::collections::BTreeMap::::new()], + ); + } + let authorization = TinyCloudDelegation::Ucan(Box::new( + Payload { + issuer: issuer.vm.parse()?, + audience: audience.parse()?, + not_before: Some(NumericDate::try_from_seconds( + (now - Duration::seconds(5)).unix_timestamp() as f64, + )?), + expiration: NumericDate::try_from_seconds( + (now + Duration::seconds(900)).unix_timestamp() as f64, + )?, + nonce: Some(format!("n1b-ucan-{}", uuid::Uuid::new_v4())), + facts: Some(vec![]), + proof: vec![], + attenuation, + } + .sign(Algorithm::EdDSA, &issuer.jwk)?, + )) + .encode()?; + self.post_delegation(authorization).await + } + + /// A did:pkh account on `chain_id` delegates `grants` to `audience` (a + /// SIWE ReCap CACAO, as a Share session key receives). + async fn delegate_siwe( + &self, + issuer: &PkhPrincipal, + chain_id: u64, + audience: &str, + grants: &[(String, &str)], + ) -> anyhow::Result { + let now = OffsetDateTime::now_utc().replace_nanosecond(0)?; + let mut recap = RecapCapability::::new(); + for (resource, ability) in grants { + recap.with_action( + resource.parse()?, + ability.parse::()?, + [std::collections::BTreeMap::::new()], + ); + } + let message = recap.build_message(Message { + scheme: Some("https".parse()?), + domain: "n1b.test".parse()?, + address: issuer.address, + statement: None, + uri: audience.parse()?, + version: Version::V1, + chain_id, + nonce: format!("n1b{}", uuid::Uuid::new_v4().simple()), + issued_at: (now - Duration::seconds(5)).into(), + expiration_time: Some((now + Duration::seconds(900)).into()), + not_before: None, + request_id: None, + resources: vec![], + })?; + let (signature, recovery_id) = issuer + .key + .sign_prehash_recoverable(&message.eip191_hash()?)?; + let mut signature_bytes = [0_u8; 65]; + signature_bytes[..64].copy_from_slice(signature.to_bytes().as_ref()); + signature_bytes[64] = u8::from(recovery_id) + 27; + let authorization = TinyCloudDelegation::Cacao(Box::new(SiweCacao::new( + message.into(), + SiweSignature::from(signature_bytes), + SiweHeader, + ))) + .encode()?; + self.post_delegation(authorization).await + } + + /// `POST /encryption/networks` as the SDK sends it: the session key + /// invokes `network.create` on `invocation_network` under its CACAO, or + /// with no proof when it names its own network (root authority by name). + async fn create_network_via_route( + &self, + invoker: &KeyPrincipal, + proof: Option<&str>, + invocation_network: &str, + body: Value, + ) -> anyhow::Result { + let now = OffsetDateTime::now_utc(); + let mut attenuation = Capabilities::::new(); + attenuation.with_action( + invocation_network.parse()?, + NETWORK_CREATE_ACTION.parse::()?, + [std::collections::BTreeMap::::new()], + ); + let invocation = Payload { + issuer: invoker.vm.parse()?, + audience: self.node_did.parse()?, + not_before: Some(NumericDate::try_from_seconds(now.unix_timestamp() as f64)?), + expiration: NumericDate::try_from_seconds( + (now + Duration::seconds(30)).unix_timestamp() as f64, + )?, + nonce: Some(format!("n1b-create-{}", uuid::Uuid::new_v4())), + facts: Some(vec![json!({ + "type": NETWORK_ADMIN_TYPE, + "targetNode": self.node_did, + "networkId": invocation_network, + "bodyHash": canonical_hash(&body), + "action": NETWORK_CREATE_ACTION, + })]), + proof: proof + .map(str::parse) + .into_iter() + .collect::>()?, + attenuation, + } + .sign(Algorithm::EdDSA, &invoker.jwk)?; + let response = self + .client + .post("/encryption/networks") + .header(ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + invocation.encode()?, + )) + .body(body.to_string()) + .dispatch() + .await; + let status = response.status(); + let text = response.into_string().await.unwrap_or_default(); + assert_eq!(status, Status::Ok, "create network: {text}"); + Ok(status) + } + + async fn create_network(&self, owner_did: &str, name: &str) -> anyhow::Result<()> { + self.encryption() + .create_one_of_one_network(CreateNetworkRequest { + name: name.to_owned(), + owner_did: owner_did.to_owned(), + threshold: Threshold::one_of_one(), + }) + .await?; + Ok(()) + } + + async fn set_network_state(&self, urn: &str, state: &str) -> anyhow::Result<()> { + let updated = encryption_network::Entity::update_many() + .col_expr(encryption_network::Column::State, Expr::value(state)) + .filter(encryption_network::Column::NetworkId.eq(urn.parse::()?.to_string())) + .exec(self.conn()) + .await?; + assert_eq!(updated.rows_affected, 1); + Ok(()) + } + + /// Sign and register a policy owned by `owner` over `kv_resource`, + /// sourced from `source_network`, whose ceiling also decrypts `extra`. + async fn register( + &self, + owner: &KeyPrincipal, + kv_resource: &str, + source_network: &str, + extra: &[&str], + ) -> anyhow::Result<(Status, String)> { + self.register_full(owner, kv_resource, source_network, extra, &[]) + .await + } + + /// [`Self::register`], with `extra_kv` as more kv ceiling entries. + async fn register_full( + &self, + owner: &KeyPrincipal, + kv_resource: &str, + source_network: &str, + extra: &[&str], + extra_kv: &[&str], + ) -> anyhow::Result<(Status, String)> { + let now = OffsetDateTime::now_utc().replace_nanosecond(0)?; + let issued = now - Duration::seconds(5); + let expires = now + Duration::seconds(600); + let mut ceiling = vec![json!({ + "kind": "kv", + "resource": kv_resource, + "selector": "exact", + "actions": ["tinycloud.kv/get", "tinycloud.kv/metadata"] + })]; + for kv in extra_kv { + ceiling.push(json!({ + "kind": "kv", + "resource": kv, + "selector": "exact", + "actions": ["tinycloud.kv/get", "tinycloud.kv/metadata"] + })); + } + for network in std::iter::once(&source_network).chain(extra) { + ceiling.push(json!({"kind": "encryption", "resource": network, "action": DECRYPT})); + } + let mut policy = json!({ + "schema": POLICY_V2_SCHEMA, + "policyId": "pending", + "ownerDid": owner.did, + "createdAt": format_time(issued), + "expiresAt": format_time(expires), + "contentSource": { + // Unique per call, so a refused policy never shares a CID + // with one registered earlier in the same test. + "shareId": format!("share-tc-1423-n1b-{}", uuid::Uuid::new_v4()), + "kvResource": kv_resource, + "selector": "exact", + "encryptionNetwork": source_network, + "encryptedSymmetricKeyDigestHex": "aa".repeat(32), + "keyVersion": 1, + "mode": "immutable", + "initialCiphertextDigestHex": "bb".repeat(32) + }, + "capabilityCeiling": ceiling, + "credentialRequirement": self.credential_requirement, + "signature": {"suite": "Ed25519", "signerDid": owner.did, "value": ""} + }); + let mut unsigned = policy.clone(); + unsigned.as_object_mut().unwrap().remove("policyId"); + unsigned.as_object_mut().unwrap().remove("signature"); + let mut preimage = POLICY_V2_SCHEMA.as_bytes().to_vec(); + preimage.push(0); + preimage.extend_from_slice(&canonical_json_value(&unsigned)); + policy["policyId"] = json!(format!("pol_{}", base32_lower(&Sha256::digest(&preimage)))); + policy["signature"]["value"] = json!(encode_config( + owner.key.sign(&Sha256::digest(preimage)), + URL_SAFE_NO_PAD + )); + let policy_cid = tinycloud_auth::ipld_core::cid::Cid::new_v1( + 0x55, + tinycloud_auth::multihash_codetable::Code::Sha2_256 + .digest(&canonical_json_value(&policy)), + ) + .to_string(); + let projections = + registration_projections(&policy).map_err(|(_, error)| anyhow::anyhow!(error))?; + let policy_digest = + policy_digest_hex(&policy).map_err(|(_, error)| anyhow::anyhow!(error))?; + let root = |audience: &str, role: &str, mode: &str, enforcer: Option<&str>| { + let mut facts = json!({ + "ownerDid": owner.did, + "policyId": policy["policyId"], + "policyDigestHex": policy_digest, + "policyCid": policy_cid, + "contentSourceDigestHex": projections.content_source_digest_hex, + "capabilityCeilingHashHex": projections.capability_ceiling_hash_hex, + "nativeProjectionHashHex": projections.native_projection_hash_hex, + "nodeAudience": self.node_did, + "role": role, + "mode": mode, + }); + if let Some(enforcer) = enforcer { + facts["enforcerDid"] = json!(enforcer); + } + let header = json!({ + "alg": "EdDSA", + "jwk": { + "alg": "EdDSA", + "crv": "Ed25519", + "kty": "OKP", + "x": encode_config(owner.key.public().to_bytes(), URL_SAFE_NO_PAD), + }, + "typ": "JWT", + "ucv": "0.10.0", + }); + let payload = json!({ + "att": projections.attenuation, + "aud": audience, + "exp": expires.unix_timestamp(), + "fct": [facts], + "iss": owner.vm, + "nbf": issued.unix_timestamp(), + "nnc": format!("tc-1423-n1b-{role}"), + "prf": [], + }); + let signing_input = format!( + "{}.{}", + encode_config(canonical_json_value(&header), URL_SAFE_NO_PAD), + encode_config(canonical_json_value(&payload), URL_SAFE_NO_PAD) + ); + format!( + "{signing_input}.{}", + encode_config(owner.key.sign(signing_input.as_bytes()), URL_SAFE_NO_PAD) + ) + }; + let policy_root = root( + &format!("did:tinycloud:policy:{policy_digest}"), + "policy-authority", + "policy-source", + None, + ); + let enforcement_root = root( + &self.enforcer.did, + "policy-enforcement", + "conditional-mint", + Some(&self.enforcer.did), + ); + let binding = self + .client + .post("/policy/v3/enforcer-bindings") + .header(ContentType::JSON) + .body( + json!({"rootExpiresAt": format_time(expires), "enforcerDid": self.enforcer.did}) + .to_string(), + ) + .dispatch() + .await; + assert_eq!(binding.status(), Status::Ok); + let binding: Value = binding.into_json().await.unwrap(); + let response = self + .client + .post("/policy/v3/policies") + .header(ContentType::JSON) + .body( + json!({ + "policyCid": policy_cid, + "policy": policy, + "policyRoot": policy_root, + "enforcementRoot": enforcement_root, + "contentSourceDigestHex": projections.content_source_digest_hex, + "nativeProjectionHashHex": projections.native_projection_hash_hex, + "attestedEnforcerBinding": binding, + }) + .to_string(), + ) + .dispatch() + .await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + if status != Status::Ok { + assert!( + policy_v3_registration::Entity::find_by_id(policy_cid) + .one(self.conn()) + .await? + .is_none(), + "a refused registration must write nothing" + ); + } + Ok((status, body)) + } +} + +/// A did:key content owner V and a session key K that V delegated kv +/// get + metadata on the content, plus decrypt on `decrypt`. +struct KeyOwnerCase { + harness: Harness, + owner: KeyPrincipal, + session: KeyPrincipal, + kv: ResourceId, +} + +impl KeyOwnerCase { + async fn new(decrypt: &[&str]) -> anyhow::Result { + Self::on(Harness::new().await?, decrypt).await + } + + async fn on(harness: Harness, decrypt: &[&str]) -> anyhow::Result { + let owner = KeyPrincipal::generate(); + let session = KeyPrincipal::generate(); + let kv = kv_resource(&owner.did)?; + let networks = decrypt + .iter() + .map(|name| network(&owner.did, name)) + .collect::>(); + let networks = networks.iter().map(String::as_str).collect::>(); + harness + .delegate_ucan(&owner, &session.did, &content_grants(&kv, &networks)) + .await?; + Ok(Self { + harness, + owner, + session, + kv, + }) + } + + fn network(&self, name: &str) -> String { + network(&self.owner.did, name) + } + + async fn register(&self, source: &str, extra: &[&str]) -> anyhow::Result<(Status, String)> { + self.harness + .register(&self.session, &self.kv.to_string(), source, extra) + .await + } +} + +async fn assert_refused( + outcome: impl Future>, + reason: &str, +) -> anyhow::Result<()> { + let before = refused_total(reason); + let (status, body) = outcome.await?; + assert_eq!(status, Status::Forbidden, "register: {body}"); + assert_eq!(body, "content-source-network-invalid"); + assert!( + refused_total(reason) > before, + "refusal counted under reason {reason}" + ); + Ok(()) +} + +fn assert_registered((status, body): (Status, String)) { + assert_eq!(status, Status::Ok, "register: {body}"); +} + +/// The content owner's own network, delegated to the session, but never +/// created: no recipient could ever decrypt through it here. +#[tokio::test] +async fn source_network_must_exist() -> anyhow::Result<()> { + let case = KeyOwnerCase::new(&["default"]).await?; + assert_refused(case.register(&case.network("default"), &[]), "missing").await +} + +/// Decrypt requires an `active` network; registration matches it. +#[tokio::test] +async fn source_network_must_be_active() -> anyhow::Result<()> { + let case = KeyOwnerCase::new(&["default"]).await?; + let source = case.network("default"); + case.harness + .create_network(&case.owner.did, "default") + .await?; + for state in ["pending", "rotating"] { + case.harness.set_network_state(&source, state).await?; + assert_refused(case.register(&source, &[]), "inactive").await?; + } + case.harness.set_network_state(&source, "active").await?; + assert_registered(case.register(&source, &[]).await?); + case.harness + .encryption() + .revoke_network(&source.parse()?) + .await?; + assert_refused(case.register(&source, &[]), "inactive").await +} + +/// Every network in the ceiling is checked, not only the source: one of the +/// session's own (self-named, root by name, never created) is refused, and so +/// is a second owner network that doesn't exist. +#[tokio::test] +async fn every_policy_network_is_checked() -> anyhow::Result<()> { + let case = KeyOwnerCase::new(&["default", "backup"]).await?; + let source = case.network("default"); + let backup = case.network("backup"); + case.harness + .create_network(&case.owner.did, "default") + .await?; + let self_named = network(&case.session.did, "share"); + assert_refused(case.register(&source, &[&self_named]), "foreign_owner").await?; + assert_refused(case.register(&source, &[&backup]), "missing").await?; + case.harness + .create_network(&case.owner.did, "backup") + .await?; + assert_registered(case.register(&source, &[&backup]).await?); + Ok(()) +} + +/// A network that exists and is active, but belongs to another principal X, +/// is refused even though the session holds X's decrypt grant on it. +#[tokio::test] +async fn existing_foreign_network_is_refused() -> anyhow::Result<()> { + let case = KeyOwnerCase::new(&[]).await?; + let other = KeyPrincipal::generate(); + let foreign = network(&other.did, "default"); + case.harness.create_network(&other.did, "default").await?; + case.harness + .delegate_ucan(&other, &case.session.did, &[(foreign.clone(), DECRYPT)]) + .await?; + assert_refused(case.register(&foreign, &[]), "foreign_owner").await +} + +/// `tinycloud://` source resources name no space owner to compare with; no +/// client emits them, so new registrations refuse them. +#[tokio::test] +async fn legacy_source_resource_is_refused() -> anyhow::Result<()> { + let case = KeyOwnerCase::new(&["default"]).await?; + let source = case.network("default"); + case.harness + .create_network(&case.owner.did, "default") + .await?; + assert_refused( + case.harness.register( + &case.session, + &format!("tinycloud://applications/kv/{KV_PATH}"), + &source, + &[], + ), + "legacy_resource", + ) + .await +} + +/// A network id whose owner isn't a valid principal can't be compared. +#[tokio::test] +async fn unparsable_network_is_refused() -> anyhow::Result<()> { + let case = KeyOwnerCase::new(&["default"]).await?; + let source = case.network("default"); + case.harness + .create_network(&case.owner.did, "default") + .await?; + assert_refused( + case.register( + &source, + &["urn:tinycloud:encryption:did:pkh:eip155:1:0xnotanaddress:default"], + ), + "unparsable", + ) + .await +} + +/// A did:pkh content owner and its Share-style session key: one CACAO grants +/// kv get + metadata, decrypt and network.create, with the network named in +/// Share's lower-case spelling. The session creates the network through the +/// real route, as the SDK does at sign-in (checksummed invocation resource). +async fn pkh_owner_registers(harness: Harness, name: &str) -> anyhow::Result<()> { + let account = PkhPrincipal::generate(); + let session = KeyPrincipal::generate(); + let kv = kv_resource(&account.did(1))?; + assert_eq!(kv.space().did().as_str(), account.did(1)); + let lowercase = network(&account.lowercase_did(1), name); + let mut grants = content_grants(&kv, &[&lowercase]); + grants.push((lowercase.clone(), NETWORK_CREATE_ACTION)); + let cacao = harness + .delegate_siwe(&account, 1, &session.did, &grants) + .await?; + harness + .create_network_via_route( + &session, + Some(&cacao), + &network(&account.did(1), name), + json!({"name": name, "ownerDid": account.did(1), "threshold": {"n": 1, "t": 1}}), + ) + .await?; + assert_registered( + harness + .register(&session, &kv.to_string(), &lowercase, &[]) + .await?, + ); + Ok(()) +} + +/// Released Share: `:default` against a checksummed space. +#[tokio::test] +async fn share_shape_registers() -> anyhow::Result<()> { + pkh_owner_registers(Harness::new().await?, "default").await +} + +/// D3: the owner's dedicated `:share` network. +#[tokio::test] +async fn share_network_of_pkh_owner_registers() -> anyhow::Result<()> { + pkh_owner_registers(Harness::new().await?, "share").await +} + +/// Same account, other chain: a different principal, so a different owner. +#[tokio::test] +async fn chain_id_mismatch_is_refused() -> anyhow::Result<()> { + let harness = Harness::new().await?; + let account = PkhPrincipal::generate(); + let session = KeyPrincipal::generate(); + let kv = kv_resource(&account.did(1))?; + let other_chain = network(&account.lowercase_did(137), "default"); + harness + .delegate_siwe(&account, 1, &session.did, &content_grants(&kv, &[])) + .await?; + harness + .delegate_siwe( + &account, + 137, + &session.did, + &[(other_chain.clone(), DECRYPT)], + ) + .await?; + harness.create_network(&account.did(137), "default").await?; + assert_refused( + harness.register(&session, &kv.to_string(), &other_chain, &[]), + "foreign_owner", + ) + .await +} + +/// A policy owner K that holds only kv on V's content creates a network named +/// after itself and registers a policy whose source is K's own space on that +/// network, with a second kv ceiling entry on V's content. Every kv entry must +/// name a space of the source's owner, so registration is refused. +#[tokio::test] +async fn kv_entry_outside_the_content_owner_is_refused() -> anyhow::Result<()> { + let case = KeyOwnerCase::new(&[]).await?; + let k = &case.session; + let own_network = network(&k.did, "share"); + case.harness + .create_network_via_route(k, None, &own_network, own_network_body(&k.did)) + .await?; + let own_source = kv_resource(&k.did)?.to_string(); + assert_refused( + case.harness + .register_full(k, &own_source, &own_network, &[], &[&case.kv.to_string()]), + "foreign_space", + ) + .await +} + +/// K sharing its own space on its own network is unaffected. +#[tokio::test] +async fn own_space_on_own_network_registers() -> anyhow::Result<()> { + let case = KeyOwnerCase::new(&[]).await?; + let k = &case.session; + let own_network = network(&k.did, "share"); + case.harness + .create_network_via_route(k, None, &own_network, own_network_body(&k.did)) + .await?; + assert_registered( + case.harness + .register(k, &kv_resource(&k.did)?.to_string(), &own_network, &[]) + .await?, + ); + Ok(()) +} + +/// Several kv entries pass when every one names a space of the content +/// owner, here a second space of V's. +#[tokio::test] +async fn kv_entries_all_owned_by_the_content_owner_register() -> anyhow::Result<()> { + let case = KeyOwnerCase::new(&["default"]).await?; + case.harness + .create_network(&case.owner.did, "default") + .await?; + let notes = SpaceId::new(case.owner.did.parse::()?, "notes".parse()?).to_resource( + "kv".parse()?, + Some(KV_PATH.parse()?), + None, + None, + ); + case.harness + .delegate_ucan(&case.owner, &case.session.did, &content_grants(¬es, &[])) + .await?; + assert_registered( + case.harness + .register_full( + &case.session, + &case.kv.to_string(), + &case.network("default"), + &[], + &[¬es.to_string()], + ) + .await?, + ); + Ok(()) +} + +/// A legacy `tinycloud://` kv entry names no space owner: refused like a +/// legacy source. +#[tokio::test] +async fn legacy_kv_entry_is_refused() -> anyhow::Result<()> { + let case = KeyOwnerCase::new(&["default"]).await?; + case.harness + .create_network(&case.owner.did, "default") + .await?; + let legacy = format!("tinycloud://applications/kv/{KV_PATH}"); + assert_refused( + case.harness.register_full( + &case.session, + &case.kv.to_string(), + &case.network("default"), + &[], + &[&legacy], + ), + "legacy_resource", + ) + .await +} + +/// Registration refuses a kv entry that doesn't parse, or a ceiling entry of +/// another kind, earlier (`400` from `registration_projections`). The pure +/// predicate, which also classifies stored rows, refuses both on its own. +#[test] +fn unparsable_or_unknown_ceiling_entry_fails_the_predicate() { + let owner = KeyPrincipal::generate(); + let kv = kv_resource(&owner.did).unwrap().to_string(); + let source = network(&owner.did, "default"); + let policy = |extra: Value| { + json!({ + "contentSource": {"kvResource": kv, "encryptionNetwork": source}, + "capabilityCeiling": [ + {"kind": "kv", "resource": kv}, + {"kind": "encryption", "resource": source}, + extra, + ], + }) + }; + assert!( + content_owner_networks(&policy(json!({"kind": "encryption", "resource": source}))).is_ok() + ); + for extra in [ + json!({"kind": "kv", "resource": "not-a-resource"}), + json!({"kind": "kv"}), + json!({"kind": "sql", "resource": kv}), + ] { + assert_eq!( + content_owner_networks(&policy(extra.clone())).err(), + Some(SourceNetworkRefusal::Unparsable), + "{extra}" + ); + } +} + +fn postgres_url(test: &str) -> Option { + match std::env::var("TINYCLOUD_TEST_POSTGRES_URL") { + Ok(url) if !url.trim().is_empty() => Some(url), + _ => { + assert!( + std::env::var_os("CI").is_none(), + "{test} requires TINYCLOUD_TEST_POSTGRES_URL, which is unset in CI (TC-381)" + ); + eprintln!("skipping {test}: TINYCLOUD_TEST_POSTGRES_URL is unset"); + None + } + } +} + +/// The same lookups against PostgreSQL: the Share shape registers; a missing, +/// inactive or self-named source network is refused. +#[tokio::test] +async fn postgres_source_network_check() -> anyhow::Result<()> { + let Some(url) = postgres_url("postgres_source_network_check") else { + return Ok(()); + }; + pkh_owner_registers(Harness::postgres(&url).await?, "default").await?; + let case = KeyOwnerCase::on(Harness::postgres(&url).await?, &["default"]).await?; + let source = case.network("default"); + assert_refused(case.register(&source, &[]), "missing").await?; + case.harness + .create_network(&case.owner.did, "default") + .await?; + case.harness.set_network_state(&source, "rotating").await?; + assert_refused(case.register(&source, &[]), "inactive").await?; + case.harness.set_network_state(&source, "active").await?; + let self_named = network(&case.session.did, "share"); + assert_refused(case.register(&self_named, &[]), "foreign_owner").await?; + assert_registered(case.register(&source, &[]).await?); + Ok(()) +} diff --git a/tinycloud-node-server/src/policy_v3/test_harness.rs b/tinycloud-node-server/src/policy_v3/test_harness.rs index f84bc164..1156662e 100644 --- a/tinycloud-node-server/src/policy_v3/test_harness.rs +++ b/tinycloud-node-server/src/policy_v3/test_harness.rs @@ -447,6 +447,21 @@ impl Harness { vector, }; harness.seed_content().await?; + // Registration requires the content owner's network to exist and be + // active (TC-1423), so the harness creates the network its + // policies name. + harness + .client + .rocket() + .state::() + .expect("EncryptionService is managed") + .create_one_of_one_network(tinycloud_core::encryption_network::CreateNetworkRequest { + name: "mainnet".to_owned(), + owner_did: harness.space_owner.did().to_owned(), + threshold: tinycloud_core::encryption_network::Threshold::one_of_one(), + }) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; Ok(Some(harness)) } diff --git a/tinycloud-node-server/src/prometheus.rs b/tinycloud-node-server/src/prometheus.rs index 26c0bf04..78035a6b 100644 --- a/tinycloud-node-server/src/prometheus.rs +++ b/tinycloud-node-server/src/prometheus.rs @@ -32,12 +32,29 @@ pub fn observe_did_resolution_refused(method: &str, reason: &str) { .inc(); } +/// A Policy/v3 registration refused because one of its encryption networks +/// is not the content owner's active network on this node, or one of its kv +/// entries names a space of another owner. `reason` is a fixed label: +/// `unparsable`, `legacy_resource`, `foreign_owner`, `foreign_space`, +/// `missing` or `inactive`. +pub fn observe_policy_source_network_refused(reason: &'static str) { + POLICY_SOURCE_NETWORK_REFUSED + .with_label_values(&[reason]) + .inc(); +} + lazy_static! { pub static ref DID_RESOLUTION_REFUSED: IntCounterVec = register_int_counter_vec!( "tinycloud_did_resolution_refused_total", "UCAN issuer resolution refusals by method and reason.", &["method", "reason"] ).unwrap(); + pub static ref POLICY_SOURCE_NETWORK_REFUSED: IntCounterVec = register_int_counter_vec!( + "tinycloud_policy_source_network_refused_total", + "Policy/v3 registrations refused because an encryption network is not the content owner's active network on this node, or a kv entry names another owner's space, by reason.", + &["reason"] + ) + .unwrap(); pub static ref REQUEST_HISTOGRAM: HistogramVec = register_histogram_vec!( "tinycloud_http_request_duration_seconds", "HTTP request latencies in seconds.",