From 0c17677d9e9bfbaff07608411f012fcec7d3c43b Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 15:05:33 +0000 Subject: [PATCH 01/41] test(TC-1423): cover recording the owner authority of policy registrations Tests first, on SQLite and PostgreSQL through the production app: what a registration records, the owner-first and loop-back selection rules, first-use classification of earlier registrations, the opt-in sweep, rollback and the legacy cutover, and listing by a closure member. --- tinycloud-node-server/src/policy_v3.rs | 4 + .../src/policy_v3/owner_authority_tests.rs | 1902 +++++++++++++++++ 2 files changed, 1906 insertions(+) create mode 100644 tinycloud-node-server/src/policy_v3/owner_authority_tests.rs diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 31e1139b..244c13ca 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -10233,3 +10233,7 @@ mod tests { #[cfg(test)] #[path = "policy_v3/account_alias_tests.rs"] mod account_alias_tests; + +#[cfg(test)] +#[path = "policy_v3/owner_authority_tests.rs"] +mod owner_authority_tests; 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..3f421641 --- /dev/null +++ b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs @@ -0,0 +1,1902 @@ +//! 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, +} + +/// 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?; + 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 projection = self.vector["policyProjection"].clone(); + let created = (now() - Duration::seconds(60)).min(root.not_before); + let ceiling = vec![ + json!({ + "kind": "kv", + "resource": self.content_resource.to_string(), + "selector": "exact", + "actions": [KV_GET, KV_METADATA], + }), + json!({ + "kind": "encryption", + "resource": self.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": self.content_resource.to_string(), + "selector": "exact", + "encryptionNetwork": self.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( + self.owner.keypair().sign(&Sha256::digest(preimage)), + URL_SAFE_NO_PAD + )); + 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(()) + } + + /// 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)), + })), + } +} + +// --------------------------------------------------------------------------- +// N1: 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); + +/// [F1] 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(()); + }; + let authority = h + .grant_owner(Window::until(now() + Duration::days(2))) + .await?; + let authority_cid: Cid = authority.parse()?; + let link_holder = Signer::key(); + let grants = h.owner_grants(); + let window = Window::until(now() + Duration::days(2)); + 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); + +/// [F1] 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 = + 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()) +} + +/// 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") +} + +/// [C1] Delegations to the policy owner from strangers' own spaces, with +/// future or backdated issue times, cannot push the space owner's grant out +/// of the candidate window, at registration or in the legacy backfill. +async fn stranger_delegations_cannot_starve_the_owner_grant( + backend: Backend, +) -> anyhow::Result<()> { + let Some(h) = Harness::with_boot( + backend, + Shape::KeySession, + "stranger_delegations_cannot_starve_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. + flood(&h, 260, Duration::days(300)).await?; + flood(&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. + assert_eq!(h.challenge(&legacy).await.0, Status::Ok); + let binding = h.binding(&legacy.policy_cid).await?.expect("backfilled"); + 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!(stranger_delegations_cannot_starve_the_owner_grant); + +/// SIWE delegations from strangers' own spaces to the policy owner. +async fn flood(h: &Harness, count: usize, issued_offset: Duration) -> anyhow::Result<()> { + for _ in 0..count { + let stranger = Signer::pkh(); + let space = SpaceId::new(stranger.did().parse::()?, "applications".parse()?); + let resource = space.to_resource("kv".parse()?, Some("x".parse()?), None, None); + h.import(stranger.delegate( + h.owner.did(), + &[(resource.to_string(), KV_GET)], + Window::until(now() + Duration::days(400)).issued(now() + issued_offset), + &[], + )?) + .await?; + } + Ok(()) +} + +/// `starved` is recorded only after the owner-first step and the general +/// window both found nothing: here the owner's authority is re-delegated, so +/// only the (flooded) general window could find it. +async fn starved_is_recorded_only_after_both_steps(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::with_boot( + backend, + Shape::KeySession, + "starved_is_recorded_only_after_both_steps", + 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?; + 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?; + // Backdated, so they lead the backfill's window (issued before the + // registration). + flood(&h, 300, -Duration::days(300)).await?; + h.challenge(&legacy).await; + let binding = h.binding(&legacy.policy_cid).await?.expect("classified"); + assert_eq!(binding.state, "unknown"); + assert_eq!(binding.unknown_reason.as_deref(), Some("starved")); + Ok(()) +} +on_both_backends!(starved_is_recorded_only_after_both_steps); + +/// [F9] 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); + +// --------------------------------------------------------------------------- +// N1: 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}"); + } + 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); + +/// [F3] 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?; + let session = h.mint_ok(&policy).await?; + h.forget_binding(&policy.policy_cid).await?; + h.execute("DROP TABLE policy_v3_owner_authority").await?; + let (status, body) = h.read(&session).await?; + assert_eq!(status, Status::Ok, "{body:?}"); + assert_eq!(body, CONTENT); + assert_eq!(h.binding(&policy.policy_cid).await?, None); + Ok(()) +} +on_both_backends!(a_failed_classification_write_still_serves); + +/// [F8] A legacy registration whose original authority was 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(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.challenge(&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?; + h.challenge(&policy).await; + let binding = h.binding(&policy.policy_cid).await?.expect("classified"); + 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 binding = h.binding(&policy.policy_cid).await?.expect("classified"); + assert_eq!(binding.state, "bound"); + assert!(binding.classifier_version >= 1); + 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); + +/// [F5][F6] 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?; + h.challenge(&before).await; + h.challenge(&during).await; + assert_eq!( + h.binding(&before.policy_cid) + .await? + .unwrap() + .registered_mode, + "backfill" + ); + assert_eq!( + h.binding(&during.policy_cid) + .await? + .unwrap() + .registered_mode, + "record" + ); + Ok(()) +} +on_both_backends!(rollback_and_reupgrade_respect_the_configured_cutover); + +// --------------------------------------------------------------------------- +// N1: 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(()) +} From 0ccb45e54f02821a7bbe25b0ec9cc23056c196e8 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 15:07:39 +0000 Subject: [PATCH 02/41] feat(TC-1423): record the owner authority of policy registrations Registration selects, per root capability, the owner delegation that grants it (root authority; then delegations from the owner of the capability's space or network; then the general window), skipping candidates whose closure includes a delegation issued by the policy owner and preferring one that outlives the root, and stores its ancestor closure in two new tables. Earlier registrations are classified on first use, claim-first, or by an opt-in sweep. POST /policy/v3/authorized lists policies by a closure member. Admission is unchanged. --- CHANGELOG.md | 4 + docs/policy-owner-authority.md | 119 ++ tinycloud-core/src/lib.rs | 1 + ...261019_000000_policy_v3_owner_authority.rs | 129 ++ tinycloud-core/src/migrations/mod.rs | 2 + tinycloud-core/src/models/mod.rs | 2 + .../src/models/policy_v3_owner_authority.rs | 23 + .../src/models/policy_v3_owner_binding.rs | 32 + tinycloud-core/src/policy_owner_authority.rs | 125 ++ tinycloud-node-server/src/config.rs | 73 ++ tinycloud-node-server/src/lib.rs | 33 +- tinycloud-node-server/src/policy_v3.rs | 54 +- .../src/policy_v3/owner_authority.rs | 1071 +++++++++++++++++ 13 files changed, 1658 insertions(+), 10 deletions(-) create mode 100644 docs/policy-owner-authority.md create mode 100644 tinycloud-core/src/migrations/m20261019_000000_policy_v3_owner_authority.rs create mode 100644 tinycloud-core/src/models/policy_v3_owner_authority.rs create mode 100644 tinycloud-core/src/models/policy_v3_owner_binding.rs create mode 100644 tinycloud-core/src/policy_owner_authority.rs create mode 100644 tinycloud-node-server/src/policy_v3/owner_authority.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f67c4401..5e680661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changes + +- 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; 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 on first use, or by an opt-in sweep (`policy_v3.owner_binding_backfill.enabled`, off by default). 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`. The record does not change admission yet. See `docs/policy-owner-authority.md` (TC-1423). + ## [1.21.0] - 2026-10-18 ### Release notes diff --git a/docs/policy-owner-authority.md b/docs/policy-owner-authority.md new file mode 100644 index 00000000..df86da8e --- /dev/null +++ b/docs/policy-owner-authority.md @@ -0,0 +1,119 @@ +# 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` or `starved` | + +`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, 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. 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 delegations exist than were examined. + +## Policies registered earlier + +A policy with no binding row is classified on first use: the selection runs in +memory 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. If the write fails (lock timeout, SQLite busy), +the in-memory result is used for that request and the row stays pending. 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. + +## Rollback + +The migration is unknown to 1.21.0, which refuses to boot with it applied. To +roll back, run the migration's `down` (it drops both tables), then deploy +1.21.0. While rolled back, policies have only the checks 1.21.0 has. + +On the re-upgrade, set `policy_v3.legacy_cutover` to the first deploy time: +`down` erased the original applied-at time, and without the override every +policy registered during the rollback window would be classified as legacy. + +## 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 are not listed. diff --git a/tinycloud-core/src/lib.rs b/tinycloud-core/src/lib.rs index db327f91..b4722c77 100644 --- a/tinycloud-core/src/lib.rs +++ b/tinycloud-core/src/lib.rs @@ -22,6 +22,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_telemetry; 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 3d0618ba..ce692e55 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 struct Migrator; @@ -64,6 +65,7 @@ impl MigratorTrait for Migrator { Box::new(m20261015_000000_delegation_cursor_index::Migration), Box::new(m20261017_000000_epoch_order_space_index::Migration), Box::new(m20261018_000000_epoch_head::Migration), + 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 8e7f85a0..40dfa1a4 100644 --- a/tinycloud-core/src/models/mod.rs +++ b/tinycloud-core/src/models/mod.rs @@ -28,6 +28,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..1782e1bd --- /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 still 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 was found (`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..489d01ab --- /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 the closure was loaded for. + 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) +} + +/// When `version` was applied, in Unix seconds, if it was. +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 464c951b..4fc6dbb9 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -52,6 +52,8 @@ pub struct Config { pub retention: RetentionConfig, #[serde(default)] pub epoch_head_backfill: EpochHeadBackfillConfig, + #[serde(default)] + pub policy_v3: PolicyV3Config, } /// Production exact-email composition. The capability remains unavailable @@ -985,6 +987,77 @@ impl EpochHeadBackfillConfig { } } +/// 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 { diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index d9f12c4e..815e1cb2 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -107,7 +107,8 @@ use hooks::HookRuntime; use invocation_replay::InvocationReplayCache; use node_control::control::ControlPlaneHandle; 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, @@ -429,6 +430,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)?; tracing::RequestIdOrigins::new(&resolved.log.request_id_first_party_origins) .map_err(anyhow::Error::msg)?; resolved.storage.resolve(); @@ -489,6 +491,7 @@ fn production_routes() -> Vec { policy_v3_mint, policy_v3_status, revoke_policy_v3_root, + authorized_policies_v3, ] } @@ -678,7 +681,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)?; } @@ -741,6 +746,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", { @@ -864,6 +870,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 244c13ca..66cc0722 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -93,6 +93,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, @@ -108,6 +112,7 @@ pub struct PolicyV3Runtime { credential_issuer: Option, delivery: Option, sqlite_writer_lock: Option>>, + owner_authority: Arc, } impl PolicyV3Runtime { @@ -123,6 +128,7 @@ impl PolicyV3Runtime { credential_issuer: None, delivery: None, sqlite_writer_lock: None, + owner_authority: Arc::default(), } } @@ -1854,15 +1860,29 @@ pub async fn register_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() { @@ -1956,6 +1976,15 @@ pub async fn register_policy( .insert(&txn) .await .map_err(|e| (Status::Conflict, e.to_string()))?; + owner_authority::insert_classification( + &txn, + &request.policy_cid, + &authority, + owner_authority::RegisteredMode::Record, + now, + ) + .await + .map_err(db_error)?; for (cid, role, bytes, status) in [ ( policy_root_cid.clone(), @@ -2779,6 +2808,15 @@ impl PolicyV3Runtime { now, ) .map_err(|_| "enforcer-binding-invalid")?; + // TC-1423: classify a registration made before owner-authority + // bindings existed on its first use. Recorded only; the result does + // not gate this request. + if self.owner_binding(registration).await.is_err() { + ::tracing::debug!( + event = "policy_owner_authority", + "policy owner-authority classification unavailable" + ); + } Ok(()) } } 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..7f9d6965 --- /dev/null +++ b/tinycloud-node-server/src/policy_v3/owner_authority.rs @@ -0,0 +1,1071 @@ +//! 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 strangers cannot push them out of 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 steer 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::{ + 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 = 1; +/// 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 lazy classification waits for the SQLite writer before it +/// serves its in-memory result instead. +const LAZY_WRITE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_millis(250); + +#[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, +} + +impl UnknownReason { + pub fn as_str(self) -> &'static str { + match self { + Self::Revoked => "revoked", + Self::Missing => "missing", + Self::Starved => "starved", + } + } + + fn parse(value: &str) -> Option { + Some(match value { + "revoked" => Self::Revoked, + "missing" => Self::Missing, + "starved" => Self::Starved, + _ => 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 { + 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}#%"))) + } +} + +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]>, + 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(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; + } + if tried == max_tries { + attempt.untried = true; + break; + } + tried += 1; + let closure = match authority_graph::load_closure(conn, &[row.id]).await { + Ok(closure) => closure, + Err(ChainTraversalError::LimitExceeded) => continue, + Err(ChainTraversalError::Db(_)) => return Err(SelectionUnavailable), + }; + if closure + .iter() + .any(|node| did_principal_matches(&node.delegator, owner_did)) + { + continue; + } + 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), + 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, + 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 shared by every clone of the runtime. +#[derive(Default)] +pub(super) struct OwnerAuthorityState { + configured_cutover: Option, + migration_cutover: tokio::sync::OnceCell, + #[cfg(test)] + pub(super) fail_lazy_writes: std::sync::atomic::AtomicBool, +} + +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 { + 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 still 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.state == BindingState::Unknown.as_str() + && row.classifier_version < CLASSIFIER_VERSION => + { + true + } + Some(row) => return Classification::from_row(row), + None => false, + }; + let (classification, mode) = self.classify_registration(registration).await?; + match self + .store_lazy_classification(®istration.policy_cid, &classification, mode, stale) + .await + { + LazyWrite::Written => Ok(Classification { + persisted: true, + ..classification + }), + LazyWrite::Lost(winner) => Classification::from_row(&winner), + LazyWrite::Failed => Ok(classification), + } + } + + 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 + }; + 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 { + #[cfg(test)] + if self + .owner_authority + .fail_lazy_writes + .load(std::sync::atomic::Ordering::SeqCst) + { + return LazyWrite::Failed; + } + 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( + policy_v3_owner_binding::Column::State.eq(BindingState::Unknown.as_str()), + ) + .filter( + policy_v3_owner_binding::Column::ClassifierVersion.lt(CLASSIFIER_VERSION), + ) + .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 !(winner.state == BindingState::Unknown.as_str() + && winner.classifier_version < CLASSIFIER_VERSION) => + { + 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( + Condition::any() + .add(policy_v3_owner_binding::Column::State.ne(BindingState::Unknown.as_str())) + .add( + policy_v3_owner_binding::Column::ClassifierVersion.gte(CLASSIFIER_VERSION), + ), + ) + .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(db_error)?; + 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(db_error)?; + let registrations: HashMap = + policy_v3_registration::Entity::find() + .filter(policy_v3_registration::Column::PolicyCid.is_in(policy_cids.clone())) + .all(conn) + .await + .map_err(db_error)? + .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(db_error)? + .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(db_error)? + .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(db_error)?; + 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, + }))) +} From 3df0584391cb74cf150d36eedc83244990514029 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 15:15:40 +0000 Subject: [PATCH 03/41] test(TC-1423): cover the policy root lifetime cap and authority mode Tests first, on SQLite and PostgreSQL: the registration-time lifetime cap in both modes (with a released-SDK-shaped root accepted), record versus enforce for roots that outlive their authority, the advertised mode in GET /info, legacy long roots counted, and the current rules for unclassified registrations after the cutover. --- .../src/policy_v3/owner_authority_tests.rs | 340 ++++++++++++++++++ 1 file changed, 340 insertions(+) diff --git a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs index 3f421641..dea7a3f6 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs @@ -1900,3 +1900,343 @@ async fn insert_synthetic( } Ok(()) } + +// --------------------------------------------------------------------------- +// N2: the root-lifetime cap, the record/enforce mode and the advertised mode. +// --------------------------------------------------------------------------- + +fn boot(policy_v3: Value) -> Boot { + Boot { + policy_v3: Some(policy_v3), + } +} + +fn enforce_boot() -> Boot { + boot(json!({"owner_authority_expiry": "enforce"})) +} + +/// The process-wide value of a Prometheus counter series (0 when absent). +fn counter(name: &str, labels: &[(&str, &str)]) -> f64 { + prometheus::gather() + .iter() + .filter(|family| family.get_name() == name) + .flat_map(|family| family.get_metric()) + .filter(|metric| { + labels.iter().all(|(key, value)| { + metric + .get_label() + .iter() + .any(|label| label.get_name() == *key && label.get_value() == *value) + }) + }) + .map(|metric| metric.get_counter().get_value()) + .sum() +} + +/// 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(()) +} + +/// [F2][C2] Roots are capped at registration time + 31 days (+5 minutes of +/// skew) with `nbf <= exp`, in both modes; a root shaped like a released +/// SDK's is accepted. +async fn root_lifetime_is_capped_in_both_modes(backend: Backend) -> anyhow::Result<()> { + for mode in ["record", "enforce"] { + let Some(h) = Harness::with_boot( + backend, + Shape::KeySession, + "root_lifetime_is_capped_in_both_modes", + boot(json!({"owner_authority_expiry": mode})), + ) + .await? + else { + return Ok(()); + }; + h.grant_owner(Window::until(now() + Duration::days(800))) + .await?; + let base = now(); + for (label, root) in [ + ("32 days", Window::until(base + Duration::days(32))), + ( + "not before in a year, 30-day window", + Window { + not_before: base + Duration::days(365), + expiry: base + Duration::days(395), + issued_at: None, + }, + ), + ( + "not before after expiry", + Window { + not_before: base + Duration::days(2), + expiry: base + Duration::days(1), + issued_at: None, + }, + ), + ] { + let refused = counter("tinycloud_policy_root_lifetime_refused_total", &[]); + let (status, body, registered) = h.register(root).await?; + assert_eq!(status, Status::Forbidden, "{mode} {label}: {body}"); + assert_eq!(body, "policy-root-lifetime-exceeded", "{mode} {label}"); + assert!(counter("tinycloud_policy_root_lifetime_refused_total", &[]) >= refused + 1.0); + assert_eq!(h.binding(®istered.policy_cid).await?, None); + } + // A released SDK signs `nbf` at signing time with `exp - nbf <= 31 d`. + let signed_at = now() - Duration::seconds(10); + let released = h + .register_ok(Window { + not_before: signed_at, + expiry: signed_at + Duration::days(31), + issued_at: None, + }) + .await?; + let binding = h.binding(&released.policy_cid).await?.expect("binding"); + assert_eq!(binding.registered_mode, mode); + } + Ok(()) +} +on_both_backends!(root_lifetime_is_capped_in_both_modes); + +/// A root that outlives its authority: `enforce` refuses it; `record` +/// stores it uncapped and counts it in the flip signal. +async fn enforce_refuses_roots_outliving_their_authority(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::with_boot( + backend, + Shape::KeySession, + "enforce_refuses_roots_outliving_their_authority", + enforce_boot(), + ) + .await? + else { + return Ok(()); + }; + h.grant_owner(Window::until(now() + Duration::seconds(90))) + .await?; + let (status, body, registered) = h.register(policy_window()).await?; + assert_eq!(status, Status::Forbidden, "{body}"); + assert_eq!(body, "policy-root-outlives-owner-authority"); + assert_eq!(h.binding(®istered.policy_cid).await?, None); + + let Some(h) = Harness::new( + backend, + Shape::KeySession, + "enforce_refuses_roots_outliving_their_authority", + ) + .await? + else { + return Ok(()); + }; + h.grant_owner(Window::until(now() + Duration::seconds(90))) + .await?; + let series = [("authority_ttl_bucket", "le_1h")]; + let exceeded = counter( + "tinycloud_policy_owner_authority_expiry_exceeded_total", + &series, + ); + 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.registered_mode, "record"); + assert!( + counter( + "tinycloud_policy_owner_authority_expiry_exceeded_total", + &series + ) >= exceeded + 1.0 + ); + Ok(()) +} +on_both_backends!(enforce_refuses_roots_outliving_their_authority); + +/// [F9] Under `enforce`, an older grant that outlives the root is chosen +/// over a newer one that does not, so the registration is accepted. +async fn enforce_chooses_the_grant_that_outlives_the_root(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::with_boot( + backend, + Shape::PkhSession, + "enforce_chooses_the_grant_that_outlives_the_root", + enforce_boot(), + ) + .await? + else { + return Ok(()); + }; + let long = h + .grant_owner(Window::until(now() + Duration::days(2)).issued(now() - Duration::minutes(10))) + .await?; + h.grant_owner(Window::until(now() + Duration::hours(1)).issued(now() - Duration::minutes(1))) + .await?; + let policy = h.register_ok(policy_window()).await?; + let binding = h.binding(&policy.policy_cid).await?.expect("binding"); + assert_eq!(binding.state, "bound"); + assert_eq!(binding.registered_mode, "enforce"); + assert_eq!(h.closure(&policy.policy_cid).await?, vec![long]); + Ok(()) +} +on_both_backends!(enforce_chooses_the_grant_that_outlives_the_root); + +/// `GET /info` advertises the capability marker and exactly one mode. +async fn info_advertises_the_owner_authority_mode(backend: Backend) -> anyhow::Result<()> { + for (boot, expected, absent) in [ + ( + Boot::default(), + "policy-owner-authority-record", + "policy-owner-authority-enforce", + ), + ( + enforce_boot(), + "policy-owner-authority-enforce", + "policy-owner-authority-record", + ), + ] { + let Some(h) = Harness::with_boot( + backend, + Shape::KeySession, + "info_advertises_the_owner_authority_mode", + boot, + ) + .await? + else { + return Ok(()); + }; + let response = h.client.get("/info").dispatch().await; + assert_eq!(response.status(), Status::Ok); + assert!(response.headers().get_one("Cache-Control").is_none()); + let info: Value = response.into_json().await.unwrap(); + let features = info["features"].as_array().unwrap(); + let has = |feature: &str| features.iter().any(|value| value == feature); + assert!(has("policy-owner-authority-v1"), "{info}"); + assert!(has(expected), "{info}"); + assert!(!has(absent), "{info}"); + } + Ok(()) +} +on_both_backends!(info_advertises_the_owner_authority_mode); + +/// [F2] A legacy registration whose root outlives 31 days is classified as +/// before and counted, not refused. +async fn legacy_long_roots_are_counted_not_refused(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::new( + backend, + Shape::KeySession, + "legacy_long_roots_are_counted_not_refused", + ) + .await? + else { + return Ok(()); + }; + let registered_at = now() - Duration::days(40); + h.grant_owner(Window { + not_before: registered_at - Duration::days(1), + expiry: now() + Duration::days(2), + issued_at: None, + }) + .await?; + let policy = h.register_ok(policy_window()).await?; + h.forget_binding(&policy.policy_cid).await?; + backdate(&h, &policy.policy_cid, registered_at).await?; + let counted = counter("tinycloud_policy_legacy_root_lifetime_exceeded_total", &[]); + let backfilled = counter( + "tinycloud_policy_owner_binding_backfill_total", + &[("outcome", "bound")], + ); + assert_eq!(h.challenge(&policy).await.0, Status::Ok); + let binding = h.binding(&policy.policy_cid).await?.expect("classified"); + assert_eq!(binding.registered_mode, "backfill"); + assert_eq!(binding.state, "bound"); + assert!(counter("tinycloud_policy_legacy_root_lifetime_exceeded_total", &[]) >= counted + 1.0); + assert!( + counter( + "tinycloud_policy_owner_binding_backfill_total", + &[("outcome", "bound")], + ) >= backfilled + 1.0 + ); + Ok(()) +} +on_both_backends!(legacy_long_roots_are_counted_not_refused); + +/// [F6] A registration after the cutover with no classification (written by +/// an older binary) gets the lifetime cap, unlike a legacy one. +async fn post_cutover_unclassified_rows_get_the_lifetime_cap( + backend: Backend, +) -> anyhow::Result<()> { + let Some(mut h) = Harness::new( + backend, + Shape::KeySession, + "post_cutover_unclassified_rows_get_the_lifetime_cap", + ) + .await? + else { + return Ok(()); + }; + let registered_at = now() - Duration::days(40); + h.grant_owner(Window { + not_before: registered_at - Duration::days(1), + expiry: now() + Duration::days(2), + issued_at: None, + }) + .await?; + let policy = h.register_ok(policy_window()).await?; + h.forget_binding(&policy.policy_cid).await?; + backdate(&h, &policy.policy_cid, registered_at).await?; + h.reboot(boot(json!({ + "legacy_cutover": format_time(now() - Duration::days(60)), + }))) + .await?; + h.challenge(&policy).await; + let binding = h.binding(&policy.policy_cid).await?.expect("classified"); + assert_eq!(binding.registered_mode, "record"); + assert_eq!(binding.state, "unknown"); + assert_eq!(binding.unknown_reason.as_deref(), Some("lifetime-exceeded")); + Ok(()) +} +on_both_backends!(post_cutover_unclassified_rows_get_the_lifetime_cap); + +/// [F6] Under `enforce`, a post-cutover unclassified row whose root outlives +/// its only authority is recorded `unknown`. +async fn post_cutover_rows_outliving_their_authority_are_unknown_under_enforce( + backend: Backend, +) -> anyhow::Result<()> { + let Some(mut h) = Harness::new( + backend, + Shape::KeySession, + "post_cutover_rows_outliving_their_authority_are_unknown_under_enforce", + ) + .await? + else { + return Ok(()); + }; + h.grant_owner(Window::until(now() + Duration::seconds(90))) + .await?; + let policy = h.register_ok(policy_window()).await?; + assert_eq!( + h.binding(&policy.policy_cid).await?.unwrap().state, + "bound-uncapped" + ); + h.forget_binding(&policy.policy_cid).await?; + h.reboot(boot(json!({ + "owner_authority_expiry": "enforce", + "legacy_cutover": format_time(now() - Duration::days(1)), + }))) + .await?; + h.challenge(&policy).await; + let binding = h.binding(&policy.policy_cid).await?.expect("classified"); + assert_eq!(binding.registered_mode, "enforce"); + assert_eq!(binding.state, "unknown"); + assert_eq!( + binding.unknown_reason.as_deref(), + Some("outlives-authority") + ); + Ok(()) +} +on_both_backends!(post_cutover_rows_outliving_their_authority_are_unknown_under_enforce); From c7003c1ccaaf55a23a491fdd19547f4efa67bbf0 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 15:24:18 +0000 Subject: [PATCH 04/41] refactor(TC-1423): drop an unused test failpoint from owner-authority writes --- tinycloud-node-server/src/policy_v3/owner_authority.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tinycloud-node-server/src/policy_v3/owner_authority.rs b/tinycloud-node-server/src/policy_v3/owner_authority.rs index 7f9d6965..d0d5251a 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority.rs @@ -563,8 +563,6 @@ impl CutoverSource { pub(super) struct OwnerAuthorityState { configured_cutover: Option, migration_cutover: tokio::sync::OnceCell, - #[cfg(test)] - pub(super) fail_lazy_writes: std::sync::atomic::AtomicBool, } enum LazyWrite { @@ -710,14 +708,6 @@ impl PolicyV3Runtime { mode: RegisteredMode, stale: bool, ) -> LazyWrite { - #[cfg(test)] - if self - .owner_authority - .fail_lazy_writes - .load(std::sync::atomic::Ordering::SeqCst) - { - return LazyWrite::Failed; - } let _writer = match &self.sqlite_writer_lock { Some(lock) => match tokio::time::timeout(LAZY_WRITE_LOCK_WAIT, lock.lock()).await { Ok(guard) => Some(guard), From e2f33f1e2399e6694f57687e720f3f0b89976529 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 15:26:22 +0000 Subject: [PATCH 05/41] feat(TC-1423): cap policy root lifetime and add the authority mode Registration refuses a root unless exp <= registration + 31 days (+5 min) and nbf <= exp, in both modes. policy_v3.owner_authority_expiry selects record (default: roots outliving their authority are accepted and counted) or enforce (refused). Unclassified registrations after the cutover get the current rules; earlier ones are counted, never capped. GET /info advertises policy-owner-authority-v1 and the active mode. Adds the owner-authority counters and the counting queries. --- CHANGELOG.md | 1 + docs/policy-owner-authority.md | 103 +++++++++++++++- tinycloud-node-server/src/config.rs | 15 +++ tinycloud-node-server/src/policy_v3.rs | 43 +++++-- .../src/policy_v3/owner_authority.rs | 111 ++++++++++++++++-- .../src/policy_v3/owner_authority_tests.rs | 12 +- tinycloud-node-server/src/prometheus.rs | 52 ++++++++ tinycloud-node-server/src/routes/mod.rs | 49 +++++++- 8 files changed, 361 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e680661..4fe26c2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changes - 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; 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 on first use, or by an opt-in sweep (`policy_v3.owner_binding_backfill.enabled`, off by default). 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`. The record does not change admission yet. See `docs/policy-owner-authority.md` (TC-1423). +- Policy/v3 registration refuses a root unless `exp <= registration + 31 days (+5 minutes)` and `nbf <= exp` (`403 policy-root-lifetime-exceeded`), in both modes; released SDK roots pass. New `policy_v3.owner_authority_expiry`: `record` (default) accepts a root that outlives the owner authority and counts it, `enforce` refuses it (`403 policy-root-outlives-owner-authority`). Earlier registrations are never capped; roots over 31 days are counted. `GET /info` lists `policy-owner-authority-v1` and one of `policy-owner-authority-record` / `policy-owner-authority-enforce`. New counters `tinycloud_policy_owner_authority_expiry_exceeded_total{authority_ttl_bucket}`, `tinycloud_policy_root_lifetime_refused_total`, `tinycloud_policy_legacy_root_lifetime_exceeded_total`, `tinycloud_policy_owner_binding_backfill_total{outcome}`; counting queries in `docs/policy-owner-authority.md` (TC-1423). ## [1.21.0] - 2026-10-18 diff --git a/docs/policy-owner-authority.md b/docs/policy-owner-authority.md index df86da8e..02ca79f3 100644 --- a/docs/policy-owner-authority.md +++ b/docs/policy-owner-authority.md @@ -21,11 +21,106 @@ Two tables, created by migration `m20261019_000000_policy_v3_owner_authority`: | `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` or `starved` | +| `unknown` | no authority found (`revoked`, `missing`, `starved`), or a post-cutover registration that breaks the current rules (`lifetime-exceeded`, `outlives-authority`) | -`registered_mode` is `record` for policies classified at registration, and -`backfill` for policies registered before the legacy cutover and classified -later. +`registered_mode` is the mode (`record` or `enforce`) a policy was classified +under, or `backfill` for policies registered before the legacy cutover and +classified later. + +## Root lifetime and mode + +At registration, in both modes, a root is refused with `403 +policy-root-lifetime-exceeded` unless `exp <= registered_at + 31 days + 5 +minutes` and `nbf <= exp`. Released SDKs sign `nbf` at signing time with +`exp - nbf <= 31 days`, so their roots pass. + +```toml +[policy_v3] +owner_authority_expiry = "record" # or "enforce"; TINYCLOUD_POLICY_V3__OWNER_AUTHORITY_EXPIRY +``` + +- `record` (default): a root that outlives the recorded authority is + accepted as `bound-uncapped` and counted in + `tinycloud_policy_owner_authority_expiry_exceeded_total`. +- `enforce`: such a root is refused with `403 + policy-root-outlives-owner-authority`. + +Policies registered before the legacy cutover are never capped; roots longer +than 31 days are counted in +`tinycloud_policy_legacy_root_lifetime_exceeded_total`. A policy registered +after the cutover with no binding row (by an older binary) gets the current +mode's rules when it is classified: `unknown` with `lifetime-exceeded`, or +under `enforce` `outlives-authority`. + +### Advertised mode + +`GET /info` (and `/version`) lists `policy-owner-authority-v1` in `features`, +plus exactly one of `policy-owner-authority-record` and +`policy-owner-authority-enforce`. A client treats the node as enforcing only +if `policy-owner-authority-enforce` is listed. The response carries no +`Cache-Control` header. + +### Metrics + +Always counted, independent of `telemetry.enabled`: + +| metric | labels | counts | +|---|---|---| +| `tinycloud_policy_owner_authority_expiry_exceeded_total` | `authority_ttl_bucket` = `le_1h`, `le_30d`, `gt_30d`, `none` | `record` registrations whose root outlives the authority (authority lifetime from registration) | +| `tinycloud_policy_root_lifetime_refused_total` | | lifetime-cap refusals | +| `tinycloud_policy_legacy_root_lifetime_exceeded_total` | | legacy roots longer than 31 days, when classified | +| `tinycloud_policy_owner_binding_backfill_total` | `outcome` = `bound`, `bound_uncapped`, `root_authority`, `unknown`, `write_failed_served` | classifications after registration | + +### Counting queries (PostgreSQL, read-only) + +The fallback when the metrics aren't scraped. `registered_at`, `expires_at` +and `authority_expires_at` are RFC 3339 UTC text. + +Q1, the `enforce` flip signal, by authority lifetime (`le_1h`: released Share +signing with a login session; `le_30d`: CLI device sessions): + +```sql +SELECT CASE WHEN b.authority_expires_at IS NULL THEN 'none' + WHEN b.authority_expires_at::timestamptz - r.registered_at::timestamptz <= interval '1 hour' THEN 'le_1h' + WHEN b.authority_expires_at::timestamptz - r.registered_at::timestamptz <= interval '30 days' THEN 'le_30d' + ELSE 'gt_30d' END AS authority_ttl_bucket, + count(*), count(DISTINCT r.owner_did) +FROM policy_v3_owner_binding b JOIN policy_v3_registration r ON r.policy_cid = b.policy_cid +WHERE b.registered_mode = 'record' AND b.state = 'bound-uncapped' + AND r.registered_at::timestamptz >= now() - interval '7 days' +GROUP BY 1; +``` + +Q2, policies without authority: + +```sql +SELECT unknown_reason, count(*) FROM policy_v3_owner_binding WHERE state = 'unknown' GROUP BY unknown_reason; +``` + +Q3, live legacy roots longer than 31 days: + +```sql +SELECT count(*) FROM policy_v3_registration r +LEFT JOIN policy_v3_owner_binding b ON b.policy_cid = r.policy_cid +WHERE (b.registered_mode IS NULL OR b.registered_mode = 'backfill') + AND r.expires_at::timestamptz - r.registered_at::timestamptz > interval '31 days' + AND r.expires_at::timestamptz > now(); +``` + +Q4, an upper bound on admitted sessions a legacy denial could affect (run +before deploying): + +```sql +SELECT count(*) FROM policy_v3_session s JOIN policy_v3_registration r ON r.policy_cid = s.policy_cid +WHERE s.state = 'admitted' AND s.expires_at::timestamptz > now(); +``` + +### Flipping to `enforce` + +Set `owner_authority_expiry = "enforce"` once the cap-compliant publisher has +run for at least 7 days, Q1 shows no `le_1h` rows over the trailing 7 days +(and the `le_30d` remainder is accepted), and no rollback is pending. Clients +see the flip in `/info` on their next read of it. ## Selection diff --git a/tinycloud-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index 4fc6dbb9..485783cf 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -997,10 +997,25 @@ pub struct PolicyV3Config { /// `TINYCLOUD_POLICY_V3__LEGACY_CUTOVER`. #[serde(default)] pub legacy_cutover: Option, + /// Whether a policy root may outlive the owner authority it was + /// registered under. `record` (the default) accepts and counts such + /// roots; `enforce` refuses them. Env + /// `TINYCLOUD_POLICY_V3__OWNER_AUTHORITY_EXPIRY`. + #[serde(default)] + pub owner_authority_expiry: OwnerAuthorityExpiry, #[serde(default)] pub owner_binding_backfill: OwnerBindingBackfillConfig, } +/// See [`PolicyV3Config::owner_authority_expiry`]. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum OwnerAuthorityExpiry { + #[default] + Record, + Enforce, +} + impl PolicyV3Config { pub fn validate(&self) -> Result<(), &'static str> { if let Some(cutover) = &self.legacy_cutover { diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 66cc0722..71ee23d2 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -1860,19 +1860,29 @@ pub async fn register_policy( .get("ownerDid") .and_then(Value::as_str) .ok_or((Status::BadRequest, "policy-owner-missing".into()))?; - // Both roots carry the same capabilities (`validate_root_pair`), so one - // selection covers both. The chosen authority is recorded with the - // registration below. + // Both roots carry the same validity window and capabilities + // (`validate_root_pair`), so one check covers both. + let checked_at = OffsetDateTime::now_utc(); + let policy_root_expiry = policy_root + .0 + .expiry + .ok_or((Status::Forbidden, "sibling-root-mismatch".into()))?; + let root_not_before = policy_root + .0 + .not_before + .ok_or((Status::Forbidden, "sibling-root-mismatch".into()))?; + if !owner_authority::root_lifetime_allowed(checked_at, root_not_before, policy_root_expiry) { + crate::prometheus::observe_policy_root_lifetime_refused(); + return Err((Status::Forbidden, "policy-root-lifetime-exceeded".into())); + } + // 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(), + checked_at, None, - policy_root - .0 - .expiry - .ok_or((Status::Forbidden, "sibling-root-mismatch".into()))?, + policy_root_expiry, ) .await .map_err(|_| { @@ -1884,6 +1894,15 @@ pub async fn register_policy( if authority.state == owner_authority::BindingState::Unknown { return Err((Status::Forbidden, "policy-owner-not-authorized".into())); } + let registered_mode = + owner_authority::RegisteredMode::current(runtime.owner_authority_expiry()); + let expiry_exceeded = authority.state == owner_authority::BindingState::BoundUncapped; + if expiry_exceeded && registered_mode == owner_authority::RegisteredMode::Enforce { + return Err(( + Status::Forbidden, + "policy-root-outlives-owner-authority".into(), + )); + } for root in [&policy_root, &enforcement_root] { for space in root.0.spaces() { tinycloud_core::request_telemetry::observe_space(space); @@ -1980,7 +1999,7 @@ pub async fn register_policy( &txn, &request.policy_cid, &authority, - owner_authority::RegisteredMode::Record, + registered_mode, now, ) .await @@ -2022,6 +2041,12 @@ pub async fn register_policy( .map_err(|e| (Status::Conflict, e.to_string()))?; } txn.commit().await.map_err(db_error)?; + if expiry_exceeded { + // The `enforce` flip signal: counted once per stored registration. + crate::prometheus::observe_policy_owner_authority_expiry_exceeded( + owner_authority::authority_ttl_bucket(checked_at, authority.authority_expires_at), + ); + } Ok(Json(RegisterResponse { policy_cid: request.policy_cid, policy_root_cid, diff --git a/tinycloud-node-server/src/policy_v3/owner_authority.rs b/tinycloud-node-server/src/policy_v3/owner_authority.rs index 7f9d6965..fb068106 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority.rs @@ -22,6 +22,7 @@ //! outlives the policy root wins, else the newest. use super::*; +use crate::config::OwnerAuthorityExpiry; use std::collections::HashMap; use std::future::Future; use tinycloud_core::{ @@ -94,6 +95,12 @@ pub enum UnknownReason { /// No candidate satisfied the capability within the examined windows, /// and more delegations to the owner exist than were examined. Starved, + /// Registered after the legacy cutover without a classification, and + /// its root breaks the registration-time lifetime cap. + LifetimeExceeded, + /// Registered after the legacy cutover without a classification, and + /// under `enforce` its root outlives the recorded authority. + OutlivesAuthority, } impl UnknownReason { @@ -102,6 +109,8 @@ impl UnknownReason { Self::Revoked => "revoked", Self::Missing => "missing", Self::Starved => "starved", + Self::LifetimeExceeded => "lifetime-exceeded", + Self::OutlivesAuthority => "outlives-authority", } } @@ -110,6 +119,8 @@ impl UnknownReason { "revoked" => Self::Revoked, "missing" => Self::Missing, "starved" => Self::Starved, + "lifetime-exceeded" => Self::LifetimeExceeded, + "outlives-authority" => Self::OutlivesAuthority, _ => return None, }) } @@ -120,15 +131,26 @@ pub enum RegisteredMode { /// Classified at registration, without refusing roots that outlive /// their authority. Record, + /// Classified at registration, refusing roots that outlive their + /// authority. + Enforce, /// Classified after registration, for a policy registered before the /// legacy cutover. Backfill, } impl RegisteredMode { + pub(super) fn current(expiry: OwnerAuthorityExpiry) -> Self { + match expiry { + OwnerAuthorityExpiry::Record => Self::Record, + OwnerAuthorityExpiry::Enforce => Self::Enforce, + } + } + pub fn as_str(self) -> &'static str { match self { Self::Record => "record", + Self::Enforce => "enforce", Self::Backfill => "backfill", } } @@ -185,6 +207,10 @@ impl Classification { } } + fn unknown(reason: UnknownReason) -> Self { + Self::from_selection(Err(reason), OffsetDateTime::UNIX_EPOCH) + } + fn from_row(row: &policy_v3_owner_binding::Model) -> Result { Ok(Self { state: BindingState::parse(&row.state).ok_or("policy-owner-binding-invalid")?, @@ -208,6 +234,35 @@ impl Classification { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) struct SelectionUnavailable; +/// Longest a policy root may live past its registration, and the clock skew +/// allowed on top. +const MAX_ROOT_LIFETIME: Duration = Duration::days(31); +const ROOT_LIFETIME_SKEW: Duration = Duration::minutes(5); + +/// The registration-time root-lifetime cap: `exp <= registered_at + 31 d +/// (+5 min)` and `nbf <= exp`. `nbf` is signer-chosen, so it doesn't anchor +/// the cap. +pub(super) fn root_lifetime_allowed( + registered_at: OffsetDateTime, + not_before: OffsetDateTime, + expiry: OffsetDateTime, +) -> bool { + not_before <= expiry && expiry <= registered_at + MAX_ROOT_LIFETIME + ROOT_LIFETIME_SKEW +} + +/// The `authority_ttl_bucket` of a root that outlives its authority. +pub(super) fn authority_ttl_bucket( + registered_at: OffsetDateTime, + authority_expires_at: Option, +) -> &'static str { + match authority_expires_at { + None => "none", + Some(expiry) if expiry - registered_at <= Duration::hours(1) => "le_1h", + Some(expiry) if expiry - registered_at <= Duration::days(30) => "le_30d", + Some(_) => "gt_30d", + } +} + fn min_expiry(closure: &[ClosureNode]) -> Option { closure.iter().filter_map(|node| node.expiry).min() } @@ -562,6 +617,7 @@ impl CutoverSource { #[derive(Default)] pub(super) struct OwnerAuthorityState { configured_cutover: Option, + expiry: OwnerAuthorityExpiry, migration_cutover: tokio::sync::OnceCell, #[cfg(test)] pub(super) fail_lazy_writes: std::sync::atomic::AtomicBool, @@ -579,11 +635,18 @@ impl PolicyV3Runtime { pub fn with_policy_config(mut self, config: &crate::config::PolicyV3Config) -> Self { self.owner_authority = Arc::new(OwnerAuthorityState { configured_cutover: config.legacy_cutover(), + expiry: config.owner_authority_expiry, ..OwnerAuthorityState::default() }); self } + /// Whether roots may outlive their authority (`record`) or not + /// (`enforce`). + pub fn owner_authority_expiry(&self) -> OwnerAuthorityExpiry { + self.owner_authority.expiry + } + /// 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> { @@ -652,12 +715,25 @@ impl PolicyV3Runtime { .store_lazy_classification(®istration.policy_cid, &classification, mode, stale) .await { - LazyWrite::Written => Ok(Classification { - persisted: true, - ..classification - }), + LazyWrite::Written => { + crate::prometheus::observe_policy_owner_binding_backfill( + match classification.state { + BindingState::Bound => "bound", + BindingState::BoundUncapped => "bound_uncapped", + BindingState::RootAuthority => "root_authority", + BindingState::Unknown => "unknown", + }, + ); + Ok(Classification { + persisted: true, + ..classification + }) + } LazyWrite::Lost(winner) => Classification::from_row(&winner), - LazyWrite::Failed => Ok(classification), + LazyWrite::Failed => { + crate::prometheus::observe_policy_owner_binding_backfill("write_failed_served"); + Ok(classification) + } } } @@ -675,8 +751,9 @@ impl PolicyV3Runtime { 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 + // that predates it (a rollback window or a mixed deploy). The + // current mode's registration rules apply. + RegisteredMode::current(self.owner_authority_expiry()) }; let root = policy_v3_root::Entity::find_by_id(registration.policy_root_cid.clone()) .one(&self.conn) @@ -688,9 +765,17 @@ impl PolicyV3Runtime { ) .map_err(|_| "policy-root-invalid")?; let root_expiry = root.0.expiry.ok_or("policy-root-expiry-missing")?; + let root_not_before = root.0.not_before.ok_or("policy-root-invalid")?; + let lifetime_allowed = root_lifetime_allowed(registered_at, root_not_before, root_expiry); + if mode != RegisteredMode::Backfill && !lifetime_allowed { + return Ok(( + Classification::unknown(UnknownReason::LifetimeExceeded), + mode, + )); + } // As the mint check always has: the authority the owner held when // it registered, revoked as of now. - let classification = classify( + let mut classification = classify( &self.conn, ®istration.owner_did, &root.0.capabilities, @@ -700,6 +785,16 @@ impl PolicyV3Runtime { ) .await .map_err(|_| "policy-owner-authority-unavailable")?; + match mode { + // Legacy roots longer than the cap are counted, never refused. + RegisteredMode::Backfill if !lifetime_allowed => { + crate::prometheus::observe_policy_legacy_root_lifetime_exceeded(); + } + RegisteredMode::Enforce if classification.state == BindingState::BoundUncapped => { + classification = Classification::unknown(UnknownReason::OutlivesAuthority); + } + _ => {} + } Ok((classification, mode)) } diff --git a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs index dea7a3f6..cb5fd63f 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs @@ -1988,8 +1988,16 @@ async fn root_lifetime_is_capped_in_both_modes(backend: Backend) -> anyhow::Resu let refused = counter("tinycloud_policy_root_lifetime_refused_total", &[]); let (status, body, registered) = h.register(root).await?; assert_eq!(status, Status::Forbidden, "{mode} {label}: {body}"); - assert_eq!(body, "policy-root-lifetime-exceeded", "{mode} {label}"); - assert!(counter("tinycloud_policy_root_lifetime_refused_total", &[]) >= refused + 1.0); + if root.not_before > now() { + // A root that is not yet valid already fails the signed + // time check that runs before the cap. + assert_eq!(body, "policy-root-signature-invalid", "{mode} {label}"); + } else { + assert_eq!(body, "policy-root-lifetime-exceeded", "{mode} {label}"); + assert!( + counter("tinycloud_policy_root_lifetime_refused_total", &[]) >= refused + 1.0 + ); + } assert_eq!(h.binding(®istered.policy_cid).await?, None); } // A released SDK signs `nbf` at signing time with `exp - nbf <= 31 d`. diff --git a/tinycloud-node-server/src/prometheus.rs b/tinycloud-node-server/src/prometheus.rs index 5686f848..6cc22950 100644 --- a/tinycloud-node-server/src/prometheus.rs +++ b/tinycloud-node-server/src/prometheus.rs @@ -370,3 +370,55 @@ mod tests { assert_eq!(TimeRejection::NotYetValid.as_str(), "not_yet_valid"); } } + +// TC-1423: Policy/v3 owner-authority counters. Always counted (they are +// rare, cheap, and the `enforce` flip is decided on them), like the DID +// resolution refusals above. +lazy_static! { + static ref POLICY_OWNER_AUTHORITY_EXPIRY_EXCEEDED: IntCounterVec = register_int_counter_vec!( + "tinycloud_policy_owner_authority_expiry_exceeded_total", + "Policy/v3 registrations accepted in record mode whose root outlives the owner authority, by authority lifetime at registration.", + &["authority_ttl_bucket"] + ) + .unwrap(); + static ref POLICY_ROOT_LIFETIME_REFUSED: IntCounter = register_int_counter!( + "tinycloud_policy_root_lifetime_refused_total", + "Policy/v3 registrations refused because a root outlives registration + 31 days or starts after it expires." + ) + .unwrap(); + static ref POLICY_LEGACY_ROOT_LIFETIME_EXCEEDED: IntCounter = register_int_counter!( + "tinycloud_policy_legacy_root_lifetime_exceeded_total", + "Policy/v3 registrations from before the legacy cutover whose root outlives registration + 31 days, counted when classified." + ) + .unwrap(); + static ref POLICY_OWNER_BINDING_BACKFILL: IntCounterVec = register_int_counter_vec!( + "tinycloud_policy_owner_binding_backfill_total", + "Policy/v3 registrations classified after registration, by outcome.", + &["outcome"] + ) + .unwrap(); +} + +/// Authority lifetime at registration: `le_1h` (released Share, signed by a +/// login session), `le_30d` (CLI device sessions), `gt_30d`, or `none`. +pub fn observe_policy_owner_authority_expiry_exceeded(bucket: &'static str) { + POLICY_OWNER_AUTHORITY_EXPIRY_EXCEEDED + .with_label_values(&[bucket]) + .inc(); +} + +pub fn observe_policy_root_lifetime_refused() { + POLICY_ROOT_LIFETIME_REFUSED.inc(); +} + +pub fn observe_policy_legacy_root_lifetime_exceeded() { + POLICY_LEGACY_ROOT_LIFETIME_EXCEEDED.inc(); +} + +/// `bound`, `bound_uncapped`, `root_authority`, `unknown` or +/// `write_failed_served`. +pub fn observe_policy_owner_binding_backfill(outcome: &'static str) { + POLICY_OWNER_BINDING_BACKFILL + .with_label_values(&[outcome]) + .inc(); +} diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index 582dadbc..6db70c2c 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -246,6 +246,7 @@ fn build_info( quota_cache: &State, encryption: &State, database: Option<&tinycloud_core::RecoveringConnection>, + owner_authority: Option, ) -> NodeInfo { #[allow(unused_mut)] let mut features = vec!["kv", "delegation", "sharing", "sql"]; @@ -260,6 +261,16 @@ fn build_info( ]); #[cfg(feature = "dstack")] features.push("tee"); + // TC-1423: Policy/v3 registrations are bound to the owner authority + // (`-v1`), plus exactly one active expiry mode. Clients treat the node as + // enforcing only when `-enforce` is listed. + if let Some(mode) = owner_authority { + features.push("policy-owner-authority-v1"); + features.push(match mode { + crate::config::OwnerAuthorityExpiry::Record => "policy-owner-authority-record", + crate::config::OwnerAuthorityExpiry::Enforce => "policy-owner-authority-enforce", + }); + } NodeInfo { protocol: tinycloud_auth::protocol::PROTOCOL_VERSION, version: env!("CARGO_PKG_VERSION").to_string(), @@ -284,6 +295,26 @@ fn build_info( } } +/// The Policy/v3 owner-authority mode, if the node serves Policy/v3. Never +/// fails, so a fixture without the policy runtime still launches. +pub struct OwnerAuthorityMode(Option); + +#[rocket::async_trait] +impl<'r> rocket::request::FromRequest<'r> for OwnerAuthorityMode { + type Error = std::convert::Infallible; + + async fn from_request( + request: &'r rocket::Request<'_>, + ) -> rocket::request::Outcome { + rocket::request::Outcome::Success(OwnerAuthorityMode( + request + .rocket() + .state::() + .map(crate::policy_v3::PolicyV3Runtime::owner_authority_expiry), + )) + } +} + /// The node's pool, if it manages one. Unlike a `State` guard it never /// fails, so a node without a pool (a test fixture) still launches. pub struct NodePool<'r>(Option<&'r tinycloud_core::RecoveringConnection>); @@ -309,8 +340,15 @@ pub fn info( quota_cache: &State, encryption: &State, pool: NodePool<'_>, + owner_authority: OwnerAuthorityMode, ) -> Json { - Json(build_info(tee, quota_cache, encryption, pool.0)) + Json(build_info( + tee, + quota_cache, + encryption, + pool.0, + owner_authority.0, + )) } #[get("/version")] @@ -319,8 +357,15 @@ pub fn version( quota_cache: &State, encryption: &State, pool: NodePool<'_>, + owner_authority: OwnerAuthorityMode, ) -> Json { - Json(build_info(tee, quota_cache, encryption, pool.0)) + Json(build_info( + tee, + quota_cache, + encryption, + pool.0, + owner_authority.0, + )) } #[allow(clippy::let_unit_value)] From 65df9d5ed7ead119d40f8f0ab3451a5ce3cf010d Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 15:49:28 +0000 Subject: [PATCH 06/41] fix(TC-1423): classify owner authority off the request path Policy reads, challenges and mints no longer wait on owner-authority classification: final classifications are remembered in process, other policies are classified by one background task each, and an attempt that doesn't end final (a failed write, starved) is not retried for 60 s. The general window only holds delegations touching the capability's space or network, loop-back candidates don't use up its tries, and starved is re-evaluated instead of final (classifier version 2). Listing errors return a fixed body; the default cutover is logged at warn while it is under a day old. Adds tests for SIWE grants issued after registration, SIWE floods, owner-derived floods and a window full of skipped rows. --- CHANGELOG.md | 2 +- docs/policy-owner-authority.md | 38 +- tinycloud-node-server/src/policy_v3.rs | 11 +- .../src/policy_v3/owner_authority.rs | 374 +++++++++++++++--- .../src/policy_v3/owner_authority_tests.rs | 281 +++++++++++-- 5 files changed, 598 insertions(+), 108 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e680661..01584720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Changes -- 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; 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 on first use, or by an opt-in sweep (`policy_v3.owner_binding_backfill.enabled`, off by default). 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`. The record does not change admission yet. See `docs/policy-owner-authority.md` (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). 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`. The record does not change admission yet. See `docs/policy-owner-authority.md` (TC-1423). ## [1.21.0] - 2026-10-18 diff --git a/docs/policy-owner-authority.md b/docs/policy-owner-authority.md index df86da8e..51d30e1b 100644 --- a/docs/policy-owner-authority.md +++ b/docs/policy-owner-authority.md @@ -37,23 +37,30 @@ each capability: 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, of - which at most 32 are tried. +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. 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 delegations exist than were examined. +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. ## Policies registered earlier -A policy with no binding row is classified on first use: the selection runs in -memory 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. If the write fails (lock timeout, SQLite busy), -the in-memory result is used for that request and the row stays pending. An +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 at a time. 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: @@ -78,7 +85,9 @@ 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 boot: at `warn` when the source is the migration and it was applied less +than a day earlier (a fresh migration, or a re-upgrade that still needs the +override), otherwise at `info`. ## Rollback @@ -116,4 +125,5 @@ or the holder of a status control. Anyone else gets `403`. 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 are not listed. +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-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 66cc0722..563ef3f8 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -2809,14 +2809,9 @@ impl PolicyV3Runtime { ) .map_err(|_| "enforcer-binding-invalid")?; // TC-1423: classify a registration made before owner-authority - // bindings existed on its first use. Recorded only; the result does - // not gate this request. - if self.owner_binding(registration).await.is_err() { - ::tracing::debug!( - event = "policy_owner_authority", - "policy owner-authority classification unavailable" - ); - } + // bindings existed, in the background. Recorded only; it does not + // gate this request or add work to it. + self.note_owner_binding(registration); Ok(()) } } diff --git a/tinycloud-node-server/src/policy_v3/owner_authority.rs b/tinycloud-node-server/src/policy_v3/owner_authority.rs index d0d5251a..4a25d174 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority.rs @@ -28,6 +28,7 @@ use tinycloud_core::{ encryption_network::NetworkId, hash::Hash, models::{ + abilities, invocation::{Error as InvocationModelError, InvocationError}, policy_v3_owner_authority, policy_v3_owner_binding, }, @@ -42,14 +43,18 @@ use tinycloud_core::{ /// 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 = 1; +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 lazy classification waits for the SQLite writer before it -/// serves its in-memory result instead. +/// 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; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum BindingState { @@ -149,6 +154,13 @@ pub struct Classification { } 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) + } + fn from_selection( selection: Result, UnknownReason>, root_expiry: OffsetDateTime, @@ -247,6 +259,28 @@ fn principal_condition(column: delegation_model::Column, did: &str) -> Condition } } +/// 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") @@ -258,6 +292,7 @@ async fn candidate_rows( conn: &C, owner_did: &str, issuers: Option<&[String]>, + scope: Option, at: OffsetDateTime, issued_before: Option, limit: u64, @@ -283,6 +318,17 @@ async fn candidate_rows( } 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. @@ -338,22 +384,23 @@ async fn choose( }) { continue; } - if tried == max_tries { - attempt.untried = true; - break; - } - tried += 1; 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, @@ -415,6 +462,7 @@ pub(super) async fn select_owner_authority( conn, owner_did, Some(&issuers), + None, at, issued_before, OWNER_FIRST_ROWS, @@ -439,6 +487,7 @@ pub(super) async fn select_owner_authority( conn, owner_did, None, + capability_scope(capability), at, issued_before, MAX_OWNER_AUTHORITY_ROWS, @@ -558,11 +607,84 @@ impl CutoverSource { } } -/// Owner-authority settings shared by every clone of the runtime. +/// 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>, + /// Policies whose last classification was 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, + ); + } +} + +/// 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 { @@ -611,6 +733,19 @@ impl PolicyV3Runtime { /// 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 still + // 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), @@ -636,13 +771,13 @@ impl PolicyV3Runtime { .await .map_err(|_| "policy-owner-authority-unavailable")?; let stale = match &existing { - Some(row) - if row.state == BindingState::Unknown.as_str() - && row.classifier_version < CLASSIFIER_VERSION => - { - true + 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(row) => return Classification::from_row(row), + Some(_) => true, None => false, }; let (classification, mode) = self.classify_registration(registration).await?; @@ -650,15 +785,63 @@ impl PolicyV3Runtime { .store_lazy_classification(®istration.policy_cid, &classification, mode, stale) .await { - LazyWrite::Written => Ok(Classification { - persisted: true, - ..classification - }), - LazyWrite::Lost(winner) => Classification::from_row(&winner), + 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 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 mut in_flight = state.in_flight.lock().expect("owner-authority in-flight"); + if in_flight.len() >= MAX_TRACKED_POLICIES || !in_flight.insert(policy_cid.clone()) { + return None; + } + } + let runtime = self.clone(); + let registration = registration.clone(); + Some(tokio::spawn(async move { + let result = runtime.owner_binding(®istration).await; + let state = &runtime.owner_authority; + if !result.is_ok_and(|classification| classification.is_final()) { + state.back_off(®istration.policy_cid); + } + state + .in_flight + .lock() + .expect("owner-authority in-flight") + .remove(®istration.policy_cid); + })) + } + async fn classify_registration( &self, registration: &policy_v3_registration::Model, @@ -722,12 +905,7 @@ impl PolicyV3Runtime { if stale { let claimed = policy_v3_owner_binding::Entity::delete_many() .filter(policy_v3_owner_binding::Column::PolicyCid.eq(policy_cid)) - .filter( - policy_v3_owner_binding::Column::State.eq(BindingState::Unknown.as_str()), - ) - .filter( - policy_v3_owner_binding::Column::ClassifierVersion.lt(CLASSIFIER_VERSION), - ) + .filter(not_final_rows()) .exec(&txn) .await?; if claimed.rows_affected == 0 { @@ -757,12 +935,7 @@ impl PolicyV3Runtime { .one(&self.conn) .await { - Ok(Some(winner)) - if !(winner.state == BindingState::Unknown.as_str() - && winner.classifier_version < CLASSIFIER_VERSION) => - { - LazyWrite::Lost(winner) - } + Ok(Some(winner)) if row_is_final(&winner) => LazyWrite::Lost(winner), _ => { ::tracing::warn!( event = "policy_owner_authority", @@ -785,13 +958,7 @@ impl PolicyV3Runtime { let classified = Query::select() .column(policy_v3_owner_binding::Column::PolicyCid) .from(policy_v3_owner_binding::Entity) - .cond_where( - Condition::any() - .add(policy_v3_owner_binding::Column::State.ne(BindingState::Unknown.as_str())) - .add( - policy_v3_owner_binding::Column::ClassifierVersion.gte(CLASSIFIER_VERSION), - ), - ) + .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)) @@ -965,7 +1132,7 @@ pub async fn authorized_policies( let conn = &runtime.conn; let ids = authority_graph::equivalent_ids(conn, target) .await - .map_err(db_error)?; + .map_err(listing_unavailable)?; let mut page_query = policy_v3_owner_authority::Entity::find() .select_only() .column(policy_v3_owner_authority::Column::PolicyCid) @@ -976,13 +1143,17 @@ pub async fn authorized_policies( 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(db_error)?; + 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(db_error)? + .map_err(listing_unavailable)? .into_iter() .map(|row| (row.policy_cid.clone(), row)) .collect(); @@ -991,7 +1162,7 @@ pub async fn authorized_policies( .filter(policy_v3_owner_binding::Column::PolicyCid.is_in(policy_cids.clone())) .all(conn) .await - .map_err(db_error)? + .map_err(listing_unavailable)? .into_iter() .map(|row| (row.policy_cid.clone(), row)) .collect(); @@ -1008,7 +1179,7 @@ pub async fn authorized_policies( .filter(policy_v3_root::Column::RootCid.is_in(root_cids.clone())) .all(conn) .await - .map_err(db_error)? + .map_err(listing_unavailable)? .into_iter() .map(|row| (row.root_cid.clone(), row)) .collect(); @@ -1019,7 +1190,7 @@ pub async fn authorized_policies( .collect::>(); let generically_revoked = authority_graph::revoked_ids(conn, &root_hashes) .await - .map_err(db_error)?; + .map_err(listing_unavailable)?; let now = OffsetDateTime::now_utc(); let mut policies = Vec::with_capacity(policy_cids.len()); for policy_cid in &policy_cids { @@ -1059,3 +1230,116 @@ pub async fn authorized_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); + } + + /// 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); + } +} diff --git a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs index 3f421641..744c7e9a 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs @@ -1146,6 +1146,16 @@ impl Harness { 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` has a classification, or time out. pub async fn wait_for_binding( &self, @@ -1368,6 +1378,34 @@ fn graph_id(encoded: &str) -> anyhow::Result> { .to_vec()) } +/// Sign fresh delegations until one's graph id sorts in the top quarter, so +/// that later ids can be chosen to sort before it. +fn high_id(mut sign: impl FnMut() -> anyhow::Result) -> anyhow::Result { + for _ in 0..512 { + let encoded = sign()?; + // Byte 2 is the first digest byte after the multihash prefix. + if graph_id(&encoded)?.get(2).is_some_and(|byte| *byte >= 0xC0) { + return Ok(encoded); + } + } + anyhow::bail!("no delegation sorted high") +} + +/// 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], @@ -1414,8 +1452,7 @@ async fn stranger_delegations_cannot_starve_the_owner_grant( vec![authority.clone()] ); // First use of the legacy registration classifies it. - assert_eq!(h.challenge(&legacy).await.0, Status::Ok); - let binding = h.binding(&legacy.policy_cid).await?.expect("backfilled"); + 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]); @@ -1440,14 +1477,14 @@ async fn flood(h: &Harness, count: usize, issued_offset: Duration) -> anyhow::Re Ok(()) } -/// `starved` is recorded only after the owner-first step and the general -/// window both found nothing: here the owner's authority is re-delegated, so -/// only the (flooded) general window could find it. -async fn starved_is_recorded_only_after_both_steps(backend: Backend) -> anyhow::Result<()> { +/// The general window only holds delegations touching the capability's space +/// or network, so strangers cannot crowd out a re-delegated authority that +/// the owner-first step does not see. +async fn strangers_cannot_crowd_the_general_window(backend: Backend) -> anyhow::Result<()> { let Some(h) = Harness::with_boot( backend, Shape::KeySession, - "starved_is_recorded_only_after_both_steps", + "strangers_cannot_crowd_the_general_window", legacy_boot(), ) .await? @@ -1462,25 +1499,183 @@ async fn starved_is_recorded_only_after_both_steps(backend: Backend) -> anyhow:: .delegate(intermediary.did(), &h.owner_grants(), window, &[])?, ) .await?; - h.import(intermediary.delegate( - h.owner.did(), - &h.owner_grants(), - window, - &[upstream.parse()?], - )?) - .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?; - // Backdated, so they lead the backfill's window (issued before the - // registration). flood(&h, 300, -Duration::days(300)).await?; - h.challenge(&legacy).await; - let binding = h.binding(&legacy.policy_cid).await?.expect("classified"); + 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!(strangers_cannot_crowd_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(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")); Ok(()) } -on_both_backends!(starved_is_recorded_only_after_both_steps); +on_both_backends!(a_general_window_full_of_skipped_rows_is_starved); + +/// 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 still 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); + +/// [C1] With a SIWE owner grant: strangers' delegations issued after it +/// (future at registration, or between it and a legacy registration) don't +/// displace it. +async fn stranger_delegations_cannot_starve_a_siwe_owner_grant( + backend: Backend, +) -> anyhow::Result<()> { + let Some(h) = Harness::with_boot( + backend, + Shape::PkhSession, + "stranger_delegations_cannot_starve_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?; + flood(&h, 260, -Duration::hours(1)).await?; + flood(&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!(stranger_delegations_cannot_starve_a_siwe_owner_grant); /// [F9] An older grant that outlives the root is recorded over a newer one /// that does not. @@ -1535,6 +1730,9 @@ async fn concurrent_first_uses_store_one_classification(backend: Backend) -> any 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")) @@ -1567,13 +1765,18 @@ async fn a_failed_classification_write_still_serves(backend: Backend) -> anyhow: h.grant_owner(Window::until(now() + Duration::days(2))) .await?; let policy = h.register_ok(policy_window()).await?; - let session = h.mint_ok(&policy).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.binding(&policy.policy_cid).await?, None); + 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); @@ -1583,7 +1786,7 @@ on_both_backends!(a_failed_classification_write_still_serves); async fn legacy_classification_falls_through_to_a_live_alternate( backend: Backend, ) -> anyhow::Result<()> { - let Some(h) = Harness::with_boot( + let Some(mut h) = Harness::with_boot( backend, Shape::KeySession, "legacy_classification_falls_through_to_a_live_alternate", @@ -1609,7 +1812,7 @@ async fn legacy_classification_falls_through_to_a_live_alternate( }; h.revoke(&h.space_owner, &recorded[0]).await?; h.forget_binding(&policy.policy_cid).await?; - h.challenge(&policy).await; + h.classify_on_use(&policy).await?; assert_eq!( h.closure(&policy.policy_cid).await?, vec![alternate.clone()] @@ -1617,8 +1820,10 @@ async fn legacy_classification_falls_through_to_a_live_alternate( h.revoke(&h.space_owner, alternate).await?; h.forget_binding(&policy.policy_cid).await?; - h.challenge(&policy).await; - let binding = h.binding(&policy.policy_cid).await?.expect("classified"); + // 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()); @@ -1666,9 +1871,16 @@ async fn older_unknown_classifications_are_reevaluated(backend: Backend) -> anyh .to_owned(); h.conn().execute(h.backend().build(&insert)).await?; h.challenge(&policy).await; - let binding = h.binding(&policy.policy_cid).await?.expect("classified"); + 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 >= 1); + assert!(binding.classifier_version >= 2); assert_eq!(h.closure(&policy.policy_cid).await?, vec![authority]); Ok(()) } @@ -1743,22 +1955,11 @@ async fn rollback_and_reupgrade_respect_the_configured_cutover( policy_v3: Some(json!({"legacy_cutover": format_time(cutover)})), }) .await?; - h.challenge(&before).await; - h.challenge(&during).await; assert_eq!( - h.binding(&before.policy_cid) - .await? - .unwrap() - .registered_mode, + h.classify_on_use(&before).await?.registered_mode, "backfill" ); - assert_eq!( - h.binding(&during.policy_cid) - .await? - .unwrap() - .registered_mode, - "record" - ); + assert_eq!(h.classify_on_use(&during).await?.registered_mode, "record"); Ok(()) } on_both_backends!(rollback_and_reupgrade_respect_the_configured_cutover); From b9d2177c9caaa681bde08c97db112b193433983e Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 16:00:36 +0000 Subject: [PATCH 07/41] test(TC-1423): cover checking the owner authority on policy use Tests first, on SQLite and PostgreSQL: revoking the owner's grant, an agent session (did:pkh wallet -> agent key), an ancestor or an alias-CID encoding ends already-admitted sessions across read, decrypt, re-shared children, challenge, mint, signed-KV mint and re-share admission with a fixed code; all-of grants; typed expiry codes; legacy registrations denied without authority unless escaped and never expiry-capped; hook streams closing after a grant is revoked or after repeated check errors; the 16-authority ticket cap. The delegated-session flow now expects the admitted session to stop when the owner's delegation is revoked. --- tinycloud-node-server/src/policy_v3.rs | 84 +- .../src/policy_v3/owner_authority_tests.rs | 860 +++++++++++++++++- 2 files changed, 894 insertions(+), 50 deletions(-) diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index fb040249..53979dd4 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -9698,8 +9698,9 @@ mod tests { assert_eq!(replay.status(), Status::Unauthorized); if let Some(owner_delegation_cid) = owner_delegation_cid.as_deref() { - // Revoking the delegation the policy owner held stops new - // sessions, although the policy and its roots are untouched. + // Revoking the delegation the policy owner held ends the policy: + // the already-admitted session, new challenges and new sessions, + // although the policy and its roots are untouched. let mut capabilities = Capabilities::::new(); capabilities.with_action( format!("urn:cid:{owner_delegation_cid}").parse()?, @@ -9737,6 +9738,37 @@ mod tests { "owner session revoke: {revoked_body}" ); + let post_owner_revoke_read = Payload { + issuer: reader_vm.parse()?, + audience: reader_did.parse()?, + not_before: Some(NumericDate::try_from_seconds( + OffsetDateTime::now_utc().unix_timestamp() as f64, + )?), + expiration: NumericDate::try_from_seconds( + (OffsetDateTime::now_utc() + Duration::seconds(30)).unix_timestamp() as f64, + )?, + nonce: Some("tc1423-read-after-owner-revoke".into()), + facts: Some(Vec::::new()), + proof: vec![child_cid], + attenuation: serde_json::from_value::>( + attenuation_for_policy_capabilities(&requested) + .map_err(|(_, error)| anyhow::anyhow!(error))?, + )?, + } + .sign(Algorithm::EdDSA, &reader_jwk)?; + let refused_read = client + .post("/invoke") + .header(rocket::http::Header::new( + "Authorization", + post_owner_revoke_read.encode()?, + )) + .dispatch() + .await; + assert_eq!(refused_read.status(), Status::Forbidden); + assert_eq!( + refused_read.into_string().await.unwrap_or_default(), + "policy-owner-authority-revoked" + ); let challenge_response = client .post("/policy/v3/challenges") .header(ContentType::JSON) @@ -9750,53 +9782,11 @@ mod tests { ) .dispatch() .await; - assert_eq!(challenge_response.status(), Status::Ok); - let fresh_challenge: Value = challenge_response.into_json().await.unwrap(); - let mut fresh = presentation.clone(); - fresh["schema"] = json!(CREDENTIAL_PRESENTATION_V4_SCHEMA); - fresh["jti"] = json!("presentation-tc-597-after-owner-revoke"); - fresh["challengeId"] = fresh_challenge["challengeId"].clone(); - fresh["nonce"] = fresh_challenge["nonce"].clone(); - fresh - .as_object_mut() - .unwrap() - .remove("credentialSpaceOwnerDid"); - fresh["issuedAt"] = json!(format_time(OffsetDateTime::now_utc())); - fresh["expiresAt"] = json!(format_time( - OffsetDateTime::now_utc() + Duration::seconds(45) - )); - let mut unsigned_fresh = fresh.clone(); - unsigned_fresh.as_object_mut().unwrap().remove("signature"); - let mut fresh_preimage = CREDENTIAL_PRESENTATION_V4_DOMAIN.to_vec(); - fresh_preimage.extend_from_slice(&canonical_json_value(&unsigned_fresh)); - fresh["signature"]["value"] = json!(encode_config( - holder_key.sign(&Sha256::digest(fresh_preimage)), - URL_SAFE_NO_PAD - )); - let refused = client - .post("/policy/v3/delegations") - .header(ContentType::JSON) - .body( - json!({ - "policyCid": policy_cid, - "challengeId": fresh_challenge["challengeId"], - "nonce": fresh_challenge["nonce"], - "requirement": requirement, - "credential": credential, - "presentation": fresh, - }) - .to_string(), - ) - .dispatch() - .await; - let refused_status = refused.status(); - let refused_body = refused.into_string().await.unwrap_or_default(); + assert_eq!(challenge_response.status(), Status::Forbidden); assert_eq!( - refused_status, - Status::Forbidden, - "mint after owner revoke: {refused_body}" + challenge_response.into_string().await.unwrap_or_default(), + "policy-owner-authority-revoked" ); - assert_eq!(refused_body, "policy-owner-not-authorized"); } // SDK revocation writes the generic graph. Policy/v3 joins that diff --git a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs index 7623b4a5..9c64ac34 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs @@ -431,6 +431,8 @@ pub(super) struct Harness { pub(super) struct Boot { /// The `[policy_v3]` section, passed through figment. pub policy_v3: Option, + /// The `[hooks]` section, passed through figment. + pub hooks: Option, } impl Harness { @@ -534,6 +536,9 @@ impl Harness { if let Some(policy_v3) = &boot.policy_v3 { figment = figment.merge(Serialized::default("policy_v3", policy_v3)); } + if let Some(hooks) = &boot.hooks { + figment = figment.merge(Serialized::default("hooks", hooks)); + } let config: crate::config::Config = figment.extract()?; Ok(Client::tracked(crate::app_with_control(&figment, &config, None).await?).await?) } @@ -660,12 +665,24 @@ impl Harness { } fn requested(&self) -> Vec { - vec![json!({ + vec![self.requested_kv(), self.requested_decrypt()] + } + + fn requested_kv(&self) -> Value { + json!({ "kind": "kv", "resource": self.content_resource.to_string(), "selector": "exact", "actions": [KV_GET], - })] + }) + } + + fn requested_decrypt(&self) -> Value { + json!({ + "kind": "encryption", + "resource": self.network, + "action": DECRYPT, + }) } /// Register a policy over the content whose roots span `root`, signed @@ -999,7 +1016,7 @@ impl Harness { 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()) + attenuation_for_policy_capabilities(&[self.requested_kv()]) .map_err(|(_, error)| anyhow::anyhow!(error))?, )?, s0.content_hash().to_cid(0x55), @@ -1186,6 +1203,7 @@ fn legacy_boot() -> Boot { policy_v3: Some(json!({ "legacy_cutover": format_time(now() + Duration::days(1)), })), + ..Boot::default() } } @@ -1953,6 +1971,7 @@ async fn rollback_and_reupgrade_respect_the_configured_cutover( ); h.reboot(Boot { policy_v3: Some(json!({"legacy_cutover": format_time(cutover)})), + ..Boot::default() }) .await?; assert_eq!( @@ -2109,6 +2128,7 @@ async fn insert_synthetic( fn boot(policy_v3: Value) -> Boot { Boot { policy_v3: Some(policy_v3), + ..Boot::default() } } @@ -2431,3 +2451,837 @@ async fn post_cutover_rows_outliving_their_authority_are_unknown_under_enforce( Ok(()) } on_both_backends!(post_cutover_rows_outliving_their_authority_are_unknown_under_enforce); + +// --------------------------------------------------------------------------- +// N3: the recorded owner authority is checked whenever a policy is used. +// --------------------------------------------------------------------------- + +const REVOKED: &str = "policy-owner-authority-revoked"; + +/// A recipient body must not name any CID or DID. +fn assert_opaque(label: &str, body: &str) { + assert!( + !body.contains("bafy") && !body.contains("bafk") && !body.contains("did:"), + "{label} leaks an identifier: {body}" + ); +} + +fn session_cid(session: &str) -> anyhow::Result { + Ok(decode_delegation(session) + .map_err(|(_, error)| anyhow::anyhow!(error))? + .content_hash() + .to_cid(0x55)) +} + +fn text(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).into_owned() +} + +impl Harness { + /// The holder decrypts through the network route under a session. + pub async fn decrypt(&self, session: &str) -> anyhow::Result<(Status, String)> { + let invocation = self.holder.invoke( + serde_json::from_value( + attenuation_for_policy_capabilities(&[self.requested_decrypt()]) + .map_err(|(_, error)| anyhow::anyhow!(error))?, + )?, + session_cid(session)?, + )?; + let response = self + .client + .post(format!("/encryption/networks/{}/decrypt", self.network)) + .header(ContentType::JSON) + .header(Header::new("Authorization", invocation)) + .body("{}") + .dispatch() + .await; + let status = response.status(); + Ok((status, response.into_string().await.unwrap_or_default())) + } + + /// The holder re-shares a session to a fresh reader; returns the reader + /// and the child CID. + pub async fn reshare( + &self, + session: &str, + ) -> anyhow::Result<(Status, String, Signer, Option)> { + let s0 = decode_delegation(session).map_err(|(_, error)| anyhow::anyhow!(error))?; + let reader = Signer::key(); + let mut facts = match &s0.0.delegation { + TinyCloudDelegation::Ucan(ucan) => ucan.payload().facts.clone().unwrap(), + TinyCloudDelegation::Cacao(_) => unreachable!(), + }; + facts[0]["remainingRedelegationDepth"] = json!(7); + let Signer::Key { jwk, vm, .. } = &self.holder else { + unreachable!() + }; + let child = Payload { + issuer: vm.parse()?, + audience: reader.did().parse()?, + not_before: Some(NumericDate::try_from_seconds( + (s0.0.not_before.unwrap() + Duration::milliseconds(1)).unix_timestamp_nanos() + as f64 + / 1_000_000_000.0, + )?), + expiration: NumericDate::try_from_seconds( + (s0.0.expiry.unwrap() - Duration::seconds(1)).unix_timestamp_nanos() as f64 + / 1_000_000_000.0, + )?, + nonce: Some(unique("tc1423child")), + facts: Some(facts), + proof: vec![s0.content_hash().to_cid(0x55)], + attenuation: serde_json::from_value( + attenuation_for_policy_capabilities(&[self.requested_kv()]) + .map_err(|(_, error)| anyhow::anyhow!(error))?, + )?, + } + .sign(Algorithm::EdDSA, jwk)?; + let (status, body) = self + .delegate(TinyCloudDelegation::Ucan(Box::new(child)).encode()?) + .await; + let cid = (status == Status::Ok) + .then(|| serde_json::from_str::(&body).ok()) + .flatten() + .and_then(|value| value["cid"].as_str().and_then(|cid| cid.parse().ok())); + Ok((status, body, reader, cid)) + } + + /// `reader` reads under the re-shared child `child`. + pub async fn read_as(&self, reader: &Signer, child: Cid) -> anyhow::Result<(Status, String)> { + let invocation = reader.invoke( + serde_json::from_value( + attenuation_for_policy_capabilities(&[self.requested_kv()]) + .map_err(|(_, error)| anyhow::anyhow!(error))?, + )?, + child, + )?; + let response = self + .client + .post("/invoke") + .header(Header::new("Authorization", invocation)) + .dispatch() + .await; + let status = response.status(); + Ok(( + status, + text(&response.into_bytes().await.unwrap_or_default()), + )) + } + + /// The holder asks for a signed KV URL under a session. + pub async fn signed_kv(&self, session: &str) -> anyhow::Result<(Status, String)> { + let invocation = self.holder.invoke( + serde_json::from_value( + attenuation_for_policy_capabilities(&[self.requested_kv()]) + .map_err(|(_, error)| anyhow::anyhow!(error))?, + )?, + session_cid(session)?, + )?; + let path = self + .content_resource + .path() + .map(ToString::to_string) + .unwrap_or_default(); + let response = self + .client + .post("/signed/kv") + .header(ContentType::JSON) + .header(Header::new("Authorization", invocation)) + .body(json!({"space": self.content_space.to_string(), "path": path}).to_string()) + .dispatch() + .await; + let status = response.status(); + Ok((status, response.into_string().await.unwrap_or_default())) + } + + /// `/delegation/status` on `cid`, invoked by `signer`. + pub async fn delegation_status(&self, signer: &Signer, cid: &str) -> anyhow::Result { + let Signer::Key { jwk, vm, did, .. } = signer else { + anyhow::bail!("status 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("tc1423status")), + facts: Some(Vec::::new()), + proof: Vec::new(), + attenuation: capabilities(&[( + format!("urn:cid:{cid}"), + "tinycloud.delegation/status", + )])?, + } + .sign(Algorithm::EdDSA, jwk)? + .encode()?; + let response = self + .client + .post("/delegation/status") + .header(ContentType::JSON) + .header(Header::new("Authorization", invocation)) + .body(json!({"cid": cid}).to_string()) + .dispatch() + .await; + Ok(response.into_json().await.unwrap_or(Value::Null)) + } + + pub async fn root_status(&self, root_cid: &str) -> Value { + self.client + .get(format!("/policy/v3/status/{root_cid}")) + .dispatch() + .await + .into_json() + .await + .unwrap_or(Value::Null) + } +} + +/// [1][4][8][11] Revoking the owner's grant ends already-admitted sessions: +/// read, decrypt, a re-shared child, challenge, mint, signed-KV mint and new +/// re-share admission, with a fixed, opaque code. +async fn revoking_the_owner_grant_ends_admitted_sessions(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::new( + backend, + Shape::KeySession, + "revoking_the_owner_grant_ends_admitted_sessions", + ) + .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 session = h.mint_ok(&policy).await?; + let (status, body) = h.read(&session).await?; + assert_eq!((status, body.as_slice()), (Status::Ok, CONTENT)); + let (_, decrypt_before) = h.decrypt(&session).await?; + assert_ne!(decrypt_before, REVOKED); + let (status, body, reader, child) = h.reshare(&session).await?; + assert_eq!(status, Status::Ok, "{body}"); + let child = child.unwrap(); + assert_eq!(h.read_as(&reader, child).await?.0, Status::Ok); + assert_eq!(h.signed_kv(&session).await?.0, Status::Ok); + + h.revoke(&h.space_owner, &authority).await?; + + let (status, body) = h.read(&session).await?; + assert_eq!((status, text(&body).as_str()), (Status::Forbidden, REVOKED)); + let checks = [ + ("decrypt", h.decrypt(&session).await?), + ("re-shared child read", h.read_as(&reader, child).await?), + ("challenge", h.challenge(&policy).await), + ("signed KV mint", h.signed_kv(&session).await?), + ]; + for (label, (status, body)) in checks { + assert_eq!(status, Status::Forbidden, "{label}: {body}"); + assert_eq!(body, REVOKED, "{label}"); + assert_opaque(label, &body); + } + let (status, body) = h.mint(&policy).await?.unwrap_err(); + assert_eq!((status, body.as_str()), (Status::Forbidden, REVOKED)); + let (status, body, _, _) = h.reshare(&session).await?; + assert_eq!(status, Status::Forbidden, "re-share admission: {body}"); + assert_opaque("re-share admission", &body); + // The listing by the revoked grant reports it. + let (status, page) = h.list(&h.space_owner, &authority, None, None).await?; + assert_eq!(status, Status::Ok); + assert_eq!(page["policies"][0]["ownerAuthorityState"], json!("revoked")); + Ok(()) +} +on_both_backends!(revoking_the_owner_grant_ends_admitted_sessions); + +/// [B-1] The CLI agent flow: wallet W signs a session for key A, A signs the +/// policy roots. W revokes the A session over the generic route; admitted +/// recipients stop, the root itself stays active. +async fn revoking_the_agent_session_ends_its_policies(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::new( + backend, + Shape::PkhSession, + "revoking_the_agent_session_ends_its_policies", + ) + .await? + else { + return Ok(()); + }; + let agent_session = h + .grant_owner(Window::until(now() + Duration::days(2))) + .await?; + let policy = h.register_ok(policy_window()).await?; + let session = h.mint_ok(&policy).await?; + assert_eq!(h.read(&session).await?.0, Status::Ok); + + h.revoke(&h.space_owner, &agent_session).await?; + + let (status, body) = h.read(&session).await?; + assert_eq!((status, text(&body).as_str()), (Status::Forbidden, REVOKED)); + assert_eq!( + h.decrypt(&session).await?, + (Status::Forbidden, REVOKED.to_owned()) + ); + assert_eq!(h.signed_kv(&session).await?.0, Status::Forbidden); + // The root itself was never revoked. + assert_eq!( + h.root_status(&policy.policy_root_cid).await["state"], + json!("active") + ); + assert_eq!( + h.delegation_status(&h.owner, &agent_session).await?["status"], + json!("revoked") + ); + let (status, page) = h.list(&h.owner, &agent_session, None, None).await?; + assert_eq!(status, Status::Ok, "{page}"); + assert_eq!(page["policies"][0]["ownerAuthorityState"], json!("revoked")); + Ok(()) +} +on_both_backends!(revoking_the_agent_session_ends_its_policies); + +/// [2] Revoking an ancestor of the recorded grant ends sessions too. +async fn revoking_an_ancestor_ends_admitted_sessions(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::new( + backend, + Shape::KeySession, + "revoking_an_ancestor_ends_admitted_sessions", + ) + .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?; + h.import(intermediary.delegate( + h.owner.did(), + &h.owner_grants(), + window, + &[upstream.parse()?], + )?) + .await?; + let policy = h.register_ok(policy_window()).await?; + let session = h.mint_ok(&policy).await?; + assert_eq!(h.read(&session).await?.0, Status::Ok); + h.revoke(&h.space_owner, &upstream).await?; + let (status, body) = h.read(&session).await?; + assert_eq!((status, text(&body).as_str()), (Status::Forbidden, REVOKED)); + Ok(()) +} +on_both_backends!(revoking_an_ancestor_ends_admitted_sessions); + +/// [2] A revocation stored under an alias CID of a closure node counts. +async fn a_revocation_under_an_alias_cid_ends_sessions(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::new( + backend, + Shape::KeySession, + "a_revocation_under_an_alias_cid_ends_sessions", + ) + .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 session = h.mint_ok(&policy).await?; + assert_eq!(h.read(&session).await?.0, Status::Ok); + // A second stored encoding of the same grant, and a revocation that names + // only that encoding. + let canonical = authority.parse::()?.hash().to_bytes(); + let alias = hash(b"tc-1423-alias-encoding") + .to_cid(0x55) + .hash() + .to_bytes(); + let space_owner = h.space_owner.did().to_owned(); + let owner = h.owner.did().to_owned(); + let statements = [ + Query::insert() + .into_table(Alias::new("delegation")) + .columns([ + Alias::new("id"), + Alias::new("delegator"), + Alias::new("delegatee"), + Alias::new("serialization"), + ]) + .values_panic([ + alias.clone().into(), + space_owner.clone().into(), + owner.into(), + b"alias".to_vec().into(), + ]) + .to_owned(), + Query::insert() + .into_table(Alias::new("delegation_cid_alias")) + .columns([Alias::new("id"), Alias::new("canonical")]) + .values_panic([canonical.clone().into(), canonical.clone().into()]) + .values_panic([alias.clone().into(), canonical.into()]) + .to_owned(), + Query::insert() + .into_table(Alias::new("revocation")) + .columns([ + Alias::new("id"), + Alias::new("revoker"), + Alias::new("revoked"), + Alias::new("serialization"), + ]) + .values_panic([ + hash(b"tc-1423-alias-revocation") + .to_cid(0x55) + .hash() + .to_bytes() + .into(), + space_owner.into(), + alias.into(), + b"alias-revocation".to_vec().into(), + ]) + .to_owned(), + ]; + for statement in statements { + h.conn().execute(h.backend().build(&statement)).await?; + } + let (status, body) = h.read(&session).await?; + assert_eq!((status, text(&body).as_str()), (Status::Forbidden, REVOKED)); + Ok(()) +} +on_both_backends!(a_revocation_under_an_alias_cid_ends_sessions); + +/// [7] All-of: kv granted by one delegation and decrypt by another; revoking +/// either ends the policy. +async fn each_recorded_grant_is_required(backend: Backend) -> anyhow::Result<()> { + for revoke_kv in [true, false] { + let Some(h) = Harness::new( + backend, + Shape::KeySession, + "each_recorded_grant_is_required", + ) + .await? + else { + return Ok(()); + }; + let window = Window::until(now() + Duration::days(2)); + let grants = h.owner_grants(); + let kv = h.grant_owner_with(&grants[..2], window).await?; + let decrypt = h.grant_owner_with(&grants[2..], window).await?; + let policy = h.register_ok(policy_window()).await?; + let mut expected = vec![kv.clone(), decrypt.clone()]; + expected.sort(); + assert_eq!(h.closure(&policy.policy_cid).await?, expected); + let session = h.mint_ok(&policy).await?; + assert_eq!(h.read(&session).await?.0, Status::Ok); + h.revoke(&h.space_owner, if revoke_kv { &kv } else { &decrypt }) + .await?; + let (status, body) = h.read(&session).await?; + assert_eq!( + (status, text(&body).as_str()), + (Status::Forbidden, REVOKED), + "revoke kv: {revoke_kv}" + ); + } + Ok(()) +} +on_both_backends!(each_recorded_grant_is_required); + +/// [3] A root bound to its authority's expiry: once both pass, reads see the +/// session expired and mints see the root expired. +async fn expiry_codes_are_typed(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::new(backend, Shape::KeySession, "expiry_codes_are_typed").await? else { + return Ok(()); + }; + let expiry = now() + Duration::seconds(12); + h.grant_owner(Window::until(expiry)).await?; + let policy = h.register_ok(Window::until(expiry)).await?; + assert_eq!(h.binding(&policy.policy_cid).await?.unwrap().state, "bound"); + let session = h.mint_ok(&policy).await?; + assert_eq!(h.read(&session).await?.0, Status::Ok); + tokio::time::sleep( + (expiry - OffsetDateTime::now_utc()).unsigned_abs() + std::time::Duration::from_secs(1), + ) + .await; + let (status, body) = h.read(&session).await?; + assert_eq!(status, Status::Forbidden); + assert_eq!(text(&body), "policy-session-expired"); + let (status, body) = h.challenge(&policy).await; + assert_eq!((status, body.as_str()), (Status::Forbidden, "root-expired")); + Ok(()) +} +on_both_backends!(expiry_codes_are_typed); + +/// [O2][9] A legacy registration with no authority is denied; the operator +/// escape restores root-only checks and is counted. +async fn legacy_registrations_without_authority_are_denied_unless_escaped( + backend: Backend, +) -> anyhow::Result<()> { + for allow in [false, true] { + let mut policy_v3 = json!({"legacy_cutover": format_time(now() + Duration::days(1))}); + if allow { + policy_v3["legacy_unknown_allow"] = json!(true); + } + let Some(h) = Harness::with_boot( + backend, + Shape::KeySession, + "legacy_registrations_without_authority_are_denied_unless_escaped", + boot(policy_v3), + ) + .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 session = h.mint_ok(&policy).await?; + h.revoke(&h.space_owner, &authority).await?; + h.forget_binding(&policy.policy_cid).await?; + let allowed = counter( + "tinycloud_policy_owner_authority_unknown_allowed_total", + &[("reason", "revoked")], + ); + let (status, body) = h.read(&session).await?; + if allow { + assert_eq!((status, body.as_slice()), (Status::Ok, CONTENT)); + assert!( + counter( + "tinycloud_policy_owner_authority_unknown_allowed_total", + &[("reason", "revoked")], + ) >= allowed + 1.0 + ); + } else { + assert_eq!( + (status, text(&body).as_str()), + (Status::Forbidden, "policy-owner-authority-unknown") + ); + } + let binding = h + .wait_for_binding(&policy.policy_cid, std::time::Duration::from_secs(20)) + .await? + .expect("classified"); + assert_eq!(binding.state, "unknown"); + assert_eq!(binding.unknown_reason.as_deref(), Some("revoked")); + } + Ok(()) +} +on_both_backends!(legacy_registrations_without_authority_are_denied_unless_escaped); + +/// [9][F2] Legacy rows get liveness but no expiry cap: a legacy session +/// whose authority expired since registration still reads, and so does a +/// legacy root longer than 31 days. +async fn legacy_registrations_are_never_expiry_capped(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::with_boot( + backend, + Shape::KeySession, + "legacy_registrations_are_never_expiry_capped", + legacy_boot(), + ) + .await? + else { + return Ok(()); + }; + let short_expiry = now() + Duration::seconds(8); + h.grant_owner(Window::until(short_expiry)).await?; + let short = h.register_ok(policy_window()).await?; + let short_session = h.mint_ok(&short).await?; + h.forget_binding(&short.policy_cid).await?; + + let registered_at = now() - Duration::days(40); + h.grant_owner(Window { + not_before: registered_at - Duration::days(1), + expiry: now() + Duration::days(2), + issued_at: None, + }) + .await?; + let long = h.register_ok(policy_window()).await?; + let long_session = h.mint_ok(&long).await?; + h.forget_binding(&long.policy_cid).await?; + backdate(&h, &long.policy_cid, registered_at).await?; + + tokio::time::sleep( + (short_expiry - OffsetDateTime::now_utc()).unsigned_abs() + + std::time::Duration::from_secs(1), + ) + .await; + for (label, session) in [ + ("expired authority", &short_session), + ("41-day root", &long_session), + ] { + let (status, body) = h.read(session).await?; + assert_eq!(status, Status::Ok, "{label}: {}", text(&body)); + assert_eq!(body, CONTENT); + } + Ok(()) +} +on_both_backends!(legacy_registrations_are_never_expiry_capped); + +impl Harness { + /// The space owner grants the holder hooks subscription on the content + /// prefix; returns the grant CIDs. + async fn grant_hooks(&self, count: usize) -> anyhow::Result> { + let resource = self.content_space.clone().to_resource( + "hooks".parse()?, + Some("kv/shares".parse()?), + None, + None, + ); + let mut grants = Vec::new(); + for _ in 0..count { + grants.push( + self.import(self.space_owner.delegate( + self.holder.did(), + &[(resource.to_string(), "tinycloud.hooks/subscribe")], + Window::until(now() + Duration::hours(2)), + &[], + )?) + .await?, + ); + } + Ok(grants) + } + + /// The holder mints a hook ticket on the content prefix citing `proofs`. + async fn hook_ticket(&self, proofs: &[String]) -> anyhow::Result<(Status, String)> { + let resource = self.content_space.clone().to_resource( + "hooks".parse()?, + Some("kv/shares".parse()?), + None, + None, + ); + let Signer::Key { jwk, vm, did, .. } = &self.holder else { + unreachable!() + }; + let at = now(); + let invocation = Payload { + issuer: vm.parse()?, + audience: did.parse()?, + not_before: Some(seconds(at)?), + expiration: seconds(at + Duration::seconds(60))?, + nonce: Some(unique("tc1423hooks")), + facts: Some(Vec::::new()), + proof: proofs + .iter() + .map(|cid| cid.parse()) + .collect::>()?, + attenuation: capabilities(&[(resource.to_string(), "tinycloud.hooks/subscribe")])?, + } + .sign(Algorithm::EdDSA, jwk)? + .encode()?; + let response = self + .client + .post("/hooks/tickets") + .header(ContentType::JSON) + .header(Header::new("Authorization", invocation)) + .body( + json!({ + "subscriptions": [{ + "space": self.content_space.to_string(), + "service": "kv", + "pathPrefix": "shares", + }], + "ttlSeconds": 120, + }) + .to_string(), + ) + .dispatch() + .await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + Ok((status, body)) + } + + /// The holder writes the content again, emitting a hook event. + async fn write_content(&self, body: &'static [u8]) -> anyhow::Result<()> { + let grant = self + .import(self.space_owner.delegate( + self.holder.did(), + &[(self.content_resource.to_string(), "tinycloud.kv/put")], + Window::until(now() + Duration::hours(1)), + &[], + )?) + .await?; + let Signer::Key { jwk, vm, .. } = &self.holder else { + unreachable!() + }; + let write = 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("tc1423write")), + ..InvocationOptions::default() + }, + )?; + let response = self + .client + .post("/invoke") + .header(Header::new("Authorization", write.encode()?)) + .header(ContentType::Plain) + .body(body) + .dispatch() + .await; + anyhow::ensure!( + response.status() == Status::Ok, + "write: {}", + response.status() + ); + Ok(()) + } +} + +fn fast_hooks() -> Boot { + Boot { + hooks: Some(json!({ + "authority_recheck_seconds": 1, + "idle_authority_recheck_seconds": 2, + })), + ..Boot::default() + } +} + +/// Connect a hook stream (its authority is checked here). +async fn open_stream<'c>( + h: &'c Harness, + ticket: &str, +) -> rocket::local::asynchronous::LocalResponse<'c> { + let response = h + .client + .get(format!("/hooks/events?ticket={ticket}")) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + response +} + +/// Read a connected hook stream to its end, or give up after `limit`. +async fn drain_stream( + response: rocket::local::asynchronous::LocalResponse<'_>, + limit: std::time::Duration, +) -> Option { + tokio::time::timeout(limit, response.into_string()) + .await + .ok() + .map(Option::unwrap_or_default) +} + +/// [8][C3] An open hook stream stops delivering after its grant is revoked: +/// no event after the re-check interval, and the stream closes; an idle +/// stream closes on the idle re-check. +async fn hook_streams_close_after_their_grant_is_revoked(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::with_boot( + backend, + Shape::KeySession, + "hook_streams_close_after_their_grant_is_revoked", + fast_hooks(), + ) + .await? + else { + return Ok(()); + }; + let grant = h.grant_hooks(1).await?; + let (status, body) = h.hook_ticket(&grant).await?; + assert_eq!(status, Status::Ok, "{body}"); + let ticket = serde_json::from_str::(&body)?["ticket"] + .as_str() + .unwrap() + .to_owned(); + let stream = drain_stream( + open_stream(&h, &ticket).await, + std::time::Duration::from_secs(10), + ); + let actions = async { + h.revoke(&h.space_owner, &grant[0]).await?; + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + h.write_content(b"after-revoke").await?; + anyhow::Ok(()) + }; + let (body, actions) = tokio::join!(stream, actions); + actions?; + let body = body.expect("the stream closes after the revocation"); + assert!(!body.contains("event: write"), "{body}"); + + // Idle: nothing is written; the idle re-check closes it. + let grant = h.grant_hooks(1).await?; + let (_, body) = h.hook_ticket(&grant).await?; + let ticket = serde_json::from_str::(&body)?["ticket"] + .as_str() + .unwrap() + .to_owned(); + let stream = drain_stream( + open_stream(&h, &ticket).await, + std::time::Duration::from_secs(10), + ); + let revoke = h.revoke(&h.space_owner, &grant[0]); + let (body, revoked) = tokio::join!(stream, revoke); + revoked?; + assert!( + body.is_some(), + "the idle stream closes after the revocation" + ); + Ok(()) +} +on_both_backends!(hook_streams_close_after_their_grant_is_revoked); + +/// [C3] Re-check errors close a stream only after three in a row. +async fn hook_streams_close_after_repeated_check_errors(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::with_boot( + backend, + Shape::KeySession, + "hook_streams_close_after_repeated_check_errors", + fast_hooks(), + ) + .await? + else { + return Ok(()); + }; + let grant = h.grant_hooks(1).await?; + let (_, body) = h.hook_ticket(&grant).await?; + let ticket = serde_json::from_str::(&body)?["ticket"] + .as_str() + .unwrap() + .to_owned(); + let started = tokio::time::Instant::now(); + let stream = drain_stream( + open_stream(&h, &ticket).await, + std::time::Duration::from_secs(15), + ); + let break_checks = async { + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + h.execute("DROP TABLE revocation").await + }; + let (body, dropped) = tokio::join!(stream, break_checks); + dropped?; + assert!(body.is_some(), "three failed checks close the stream"); + // Idle checks every 2 s: three errors take at least ~4 s. + assert!(started.elapsed() >= std::time::Duration::from_secs(4)); + Ok(()) +} +on_both_backends!(hook_streams_close_after_repeated_check_errors); + +/// [C3] A ticket would carry more than 16 authority IDs: refused. +async fn hook_tickets_carry_at_most_16_authorities(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::new( + backend, + Shape::KeySession, + "hook_tickets_carry_at_most_16_authorities", + ) + .await? + else { + return Ok(()); + }; + let grants = h.grant_hooks(17).await?; + assert_eq!(h.hook_ticket(&grants[..16]).await?.0, Status::Ok); + let (status, body) = h.hook_ticket(&grants).await?; + assert_eq!(status, Status::Forbidden, "{body}"); + assert_eq!(body, "hook-ticket-authority-too-large"); + Ok(()) +} +on_both_backends!(hook_tickets_carry_at_most_16_authorities); From b4eef9957b835de3e588be9f64d9e92969798a07 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 16:36:38 +0000 Subject: [PATCH 08/41] test(TC-1423): policy source network must belong to the content owner (RED) Reproduction from the N1b design: a policy owner holding only kv/get and kv/metadata on the content registers a Policy/v3 whose source network is named after itself and was never created. Today registration is accepted. --- tinycloud-node-server/src/policy_v3.rs | 204 ++++++++++++++++++++++++- 1 file changed, 197 insertions(+), 7 deletions(-) diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 31e1139b..242869e5 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -7999,6 +7999,20 @@ mod tests { run_policy_v2_flow(PolicyOwner::Unauthorized).await } + /// TC-1423 (B2, N1b): 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, which needs no grant: a DID is + /// root authority over networks that name it. Registration must refuse + /// it: the source network must exist and belong to the content space's + /// owner, and the owner's decrypt on it must come through its authority + /// chain. Until then this test records what the node does instead and + /// fails. + #[tokio::test] + async fn policy_source_network_must_belong_to_the_content_owner() -> anyhow::Result<()> { + run_policy_v2_flow(PolicyOwner::KvOnlySelfNamedNetwork).await + } + #[derive(Clone, Copy, PartialEq, Eq)] enum PolicyOwner { /// The policy owner is the content space's owner. @@ -8007,6 +8021,135 @@ 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, + } + + /// Observations for `policy_source_network_must_belong_to_the_content_owner` + /// once registration was accepted: the recorded owner binding (N1, if + /// present), whether its closure holds the owner's delegation, and what an + /// admitted session gets after the space owner revokes that delegation. + #[allow(clippy::too_many_arguments)] + async fn kv_only_self_network_report( + client: &rocket::local::asynchronous::Client, + policy_cid: &String, + owner_delegation_cid: Option, + content_owner_vm: &String, + content_owner_did: &String, + content_owner_jwk: &JWK, + reader_vm: &String, + reader_did: &String, + reader_jwk: &JWK, + child_cid: tinycloud_auth::ipld_core::cid::Cid, + requested: &Vec, + ) -> anyhow::Result<(String, String, String)> { + use tinycloud_auth::{ + siwe_recap::Ability as RecapAbility, ucan_capabilities_object::Capabilities, + }; + use tinycloud_core::sea_orm::{ConnectionTrait as _, Statement}; + let owner_delegation_cid = owner_delegation_cid + .clone() + .ok_or_else(|| anyhow::anyhow!("missing owner delegation"))?; + let conn = &client.rocket().state::().unwrap().conn; + // What authority the node recorded, if it records any (N1, #358). + let backend = conn.get_database_backend(); + let binding = conn + .query_one(Statement::from_sql_and_values( + backend, + "SELECT state FROM policy_v3_owner_binding WHERE policy_cid = ?", + [policy_cid.clone().into()], + )) + .await; + let binding_state = match binding { + Ok(Some(row)) => row.try_get::("", "state").unwrap_or_default(), + Ok(None) => "no binding row".to_owned(), + Err(_) => "no binding table (pre-N1)".to_owned(), + }; + let owner_hash = tinycloud_core::hash::Hash::from( + owner_delegation_cid.parse::()?, + ); + let closure = conn + .query_one(Statement::from_sql_and_values( + backend, + "SELECT COUNT(*) AS n FROM policy_v3_owner_authority \ + WHERE policy_cid = ? AND delegation_id = ?", + [policy_cid.clone().into(), owner_hash.into()], + )) + .await; + let closure_has_share_root = match closure { + Ok(Some(row)) => format!("{}", row.try_get::("", "n").unwrap_or(-1) > 0), + Ok(None) => "no row".to_owned(), + Err(_) => "no closure table (pre-N1)".to_owned(), + }; + + // The space owner revokes the owner's (kv-only) delegation: the + // share root of the D3 design. + let mut capabilities = Capabilities::::new(); + capabilities.with_action( + format!("urn:cid:{owner_delegation_cid}").parse()?, + "tinycloud.delegation/revoke".parse::()?, + [std::collections::BTreeMap::::new()], + ); + let revocation = tinycloud_auth::authorization::TinyCloudRevocation::Ucan(Box::new( + Payload { + issuer: content_owner_vm.parse()?, + audience: content_owner_did.parse()?, + not_before: None, + expiration: NumericDate::try_from_seconds( + (OffsetDateTime::now_utc() + Duration::seconds(60)).unix_timestamp() as f64, + )?, + nonce: Some("tc1423-kv-only-owner-revoke".into()), + facts: Some(Vec::new()), + proof: Vec::new(), + attenuation: capabilities, + } + .sign(Algorithm::EdDSA, &content_owner_jwk)?, + )); + let revoke_status = client + .post("/revoke") + .header(rocket::http::Header::new( + "Authorization", + revocation.encode()?, + )) + .dispatch() + .await + .status(); + assert_eq!(revoke_status, Status::Ok, "owner revoke of the share root"); + + let reread_now = OffsetDateTime::now_utc(); + let reread = Payload { + issuer: reader_vm.parse()?, + audience: reader_did.parse()?, + not_before: Some(NumericDate::try_from_seconds( + reread_now.unix_timestamp() as f64 + )?), + expiration: NumericDate::try_from_seconds( + (reread_now + Duration::seconds(30)).unix_timestamp() as f64, + )?, + nonce: Some("tc1423-kv-only-reread".into()), + facts: Some(Vec::::new()), + proof: vec![child_cid], + attenuation: serde_json::from_value::>( + attenuation_for_policy_capabilities(&requested) + .map_err(|(_, error)| anyhow::anyhow!(error))?, + )?, + } + .sign(Algorithm::EdDSA, &reader_jwk)?; + let reread_response = client + .post("/invoke") + .header(rocket::http::Header::new("Authorization", reread.encode()?)) + .dispatch() + .await; + let reread_status = reread_response.status(); + let reread_body = reread_response.into_string().await.unwrap_or_default(); + let reread_outcome = if reread_status == Status::Ok { + format!("200, {} bytes (survives)", reread_body.len()) + } else { + format!("{} {reread_body}", reread_status.code) + }; + Ok((binding_state, closure_has_share_root, reread_outcome)) } async fn run_policy_v2_flow(policy_owner: PolicyOwner) -> anyhow::Result<()> { @@ -8088,7 +8231,11 @@ mod tests { "selector": "exact", "actions": ["tinycloud.kv/get"] })]; - let encryption_resource = format!("urn:tinycloud:encryption:{content_owner_did}:mainnet"); + let encryption_resource = if policy_owner == PolicyOwner::KvOnlySelfNamedNetwork { + format!("urn:tinycloud:encryption:{owner_did}:share") + } else { + format!("urn:tinycloud:encryption:{content_owner_did}:mainnet") + }; let ceiling = vec![ json!({ "kind": "kv", @@ -8567,7 +8714,10 @@ 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 + ) { let mut owner_capabilities = Capabilities::::new(); for ability in ["tinycloud.kv/get", "tinycloud.kv/metadata"] { owner_capabilities.with_action( @@ -8576,11 +8726,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()?, @@ -8787,6 +8939,15 @@ mod tests { assert!(policy_v3_root::Entity::find().one(conn).await?.is_none()); return Ok(()); } + if policy_owner == PolicyOwner::KvOnlySelfNamedNetwork && register_status != Status::Ok { + assert_eq!( + register_status, + Status::Forbidden, + "register: {register_body}" + ); + assert_eq!(register_body, "content-source-network-invalid"); + return Ok(()); + } assert_eq!(register_status, Status::Ok, "register: {register_body}"); let expected_identity = tinycloud_core::request_telemetry::RequestContext::new( uuid::Uuid::new_v4(), @@ -9216,6 +9377,35 @@ mod tests { assert_eq!(read_status, Status::Ok, "read response: {read_body:?}"); assert_eq!(read_body, b"tc-470-real-content"); + if policy_owner == PolicyOwner::KvOnlySelfNamedNetwork { + // Boxed: the shared flow's future is already close to the + // default test-thread stack. + let (binding_state, closure_has_share_root, reread_outcome) = + Box::pin(kv_only_self_network_report( + &client, + &policy_cid, + owner_delegation_cid.clone(), + &content_owner_vm, + content_owner_did, + &content_owner_jwk, + &reader_vm, + &reader_did, + &reader_jwk, + child_cid, + &requested, + )) + .await?; + panic!( + "TC-1423 N1b RED: registration over a self-named network was ACCEPTED \ + (200; source network {encryption_resource} is named by the kv-only \ + policy owner and was never created); the admitted recipient read the \ + content (200, {} bytes); owner binding state: {binding_state}; closure \ + contains the share root: {closure_has_share_root}; read after the space \ + owner revoked the share root: {reread_outcome}", + read_body.len() + ); + } + // TC-541: hooks requests pass the same Policy/v3 gate as `/invoke`. // Policies can only grant `kv` and `encryption` capabilities, so no // policy session can hold `tinycloud.hooks/*`: the reader's chain is From 81560c67faf8b97017599e2983f4b92119850fb5 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 16:41:30 +0000 Subject: [PATCH 09/41] test(TC-1423): source network owner and state cases at registration (RED) Foreign, missing, inactive, chain-mismatched and self-named networks, every ceiling network, the legacy source form, and the Share/D3 did:pkh shapes over the real /delegate, /encryption/networks and registration routes. Adds the refusal counter the tests read. --- tinycloud-node-server/src/policy_v3.rs | 4 + .../src/policy_v3/source_network_tests.rs | 791 ++++++++++++++++++ tinycloud-node-server/src/prometheus.rs | 16 + 3 files changed, 811 insertions(+) create mode 100644 tinycloud-node-server/src/policy_v3/source_network_tests.rs diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 242869e5..9a36c159 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -10423,3 +10423,7 @@ mod tests { #[cfg(test)] #[path = "policy_v3/account_alias_tests.rs"] mod account_alias_tests; + +#[cfg(test)] +#[path = "policy_v3/source_network_tests.rs"] +mod source_network_tests; 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..e87a80c5 --- /dev/null +++ b/tinycloud-node-server/src/policy_v3/source_network_tests.rs @@ -0,0 +1,791 @@ +//! TC-1423 N1b: 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 { + fn new(seed: u8) -> anyhow::Result { + let key = SigningKey::from_bytes(&[seed; 32].into())?; + let public = key.verifying_key().to_encoded_point(false); + let digest = Keccak256::digest(&public.as_bytes()[1..]); + let principal = Self { + key, + address: digest[12..].try_into()?, + }; + // The case-insensitivity cases mean something only if the checksum + // has upper-case letters. + assert_ne!(principal.did(1), principal.lowercase_did(1)); + Ok(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 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 { + 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 db = Database::connect("sqlite::memory:").await?; + 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. + async fn create_network_via_route( + &self, + invoker: &KeyPrincipal, + proof: &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: vec![proof.parse()?], + 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)> { + 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 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": { + "shareId": "share-tc-1423-n1b", + "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 { + let harness = Harness::new().await?; + 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(name: &str) -> anyhow::Result<()> { + let harness = Harness::new().await?; + let account = PkhPrincipal::new(0x5a)?; + 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, + &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("default").await +} + +/// D3: the owner's dedicated `:share` network. +#[tokio::test] +async fn share_network_of_pkh_owner_registers() -> anyhow::Result<()> { + pkh_owner_registers("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::new(0x5a)?; + 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 +} diff --git a/tinycloud-node-server/src/prometheus.rs b/tinycloud-node-server/src/prometheus.rs index 5686f848..f36e24f7 100644 --- a/tinycloud-node-server/src/prometheus.rs +++ b/tinycloud-node-server/src/prometheus.rs @@ -32,12 +32,28 @@ 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. `reason` is a +/// fixed label: `unparsable`, `legacy_resource`, `foreign_owner`, `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, by reason.", + &["reason"] + ) + .unwrap(); pub static ref REQUEST_HISTOGRAM: HistogramVec = register_histogram_vec!( "tinycloud_http_request_duration_seconds", "HTTP request latencies in seconds.", From 09d7e336bb7c68bd3b9a0c615a3ac570745d38fd Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 16:44:49 +0000 Subject: [PATCH 10/41] fix(TC-1423): bound background classifications and tighten their tests At most three owner-authority classifications run in the background at once; a use that finds no free slot starts none and doesn't back off. A classification task releases its policy and slot when dropped, including on panic. Tests: one window for a grant and its derived delegations (a child may not outlive its parent across a second boundary), a fixed high-id choice for the window-ordering fixtures, loop-back rows not using up tries, starved re-evaluated in a later process, the slot limit and the panic release. --- docs/policy-owner-authority.md | 3 +- .../src/policy_v3/owner_authority.rs | 115 +++++++++++++++--- .../src/policy_v3/owner_authority_tests.rs | 112 +++++++++++++++-- 3 files changed, 201 insertions(+), 29 deletions(-) diff --git a/docs/policy-owner-authority.md b/docs/policy-owner-authority.md index 51d30e1b..4e3fbc3f 100644 --- a/docs/policy-owner-authority.md +++ b/docs/policy-owner-authority.md @@ -57,7 +57,8 @@ 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 at a time. If the write fails (lock timeout, SQLite busy) or the result +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 diff --git a/tinycloud-node-server/src/policy_v3/owner_authority.rs b/tinycloud-node-server/src/policy_v3/owner_authority.rs index 4a25d174..8e1dd07f 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority.rs @@ -55,6 +55,10 @@ const LAZY_WRITE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_mill 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 { @@ -617,6 +621,8 @@ pub(super) struct OwnerAuthorityState { 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 was not final, until when. retry_after: std::sync::Mutex>, } @@ -665,6 +671,51 @@ impl OwnerAuthorityState { } } +/// 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() @@ -808,9 +859,10 @@ impl PolicyV3Runtime { /// 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 classifies it, and - /// a result that is not final (a failed write, `starved`) holds off the - /// next attempt for [`RETRY_BACKOFF`]. + /// 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, @@ -820,25 +872,15 @@ impl PolicyV3Runtime { if state.is_final(policy_cid) || state.backing_off(policy_cid) { return None; } - { - 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.clone()) { - 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; - let state = &runtime.owner_authority; if !result.is_ok_and(|classification| classification.is_final()) { - state.back_off(®istration.policy_cid); + runtime.owner_authority.back_off(®istration.policy_cid); } - state - .in_flight - .lock() - .expect("owner-authority in-flight") - .remove(®istration.policy_cid); })) } @@ -1323,6 +1365,47 @@ mod tests { 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] diff --git a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs index 744c7e9a..9911c5bd 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs @@ -1156,6 +1156,26 @@ impl Harness { .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, @@ -1272,13 +1292,13 @@ async fn delegations_back_to_the_owner_are_never_recorded(backend: Backend) -> a else { return Ok(()); }; - let authority = h - .grant_owner(Window::until(now() + Duration::days(2))) - .await?; + // 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 window = Window::until(now() + Duration::days(2)); let link = h .import( h.owner @@ -1378,17 +1398,19 @@ fn graph_id(encoded: &str) -> anyhow::Result> { .to_vec()) } -/// Sign fresh delegations until one's graph id sorts in the top quarter, so -/// that later ids can be chosen to sort before it. +/// 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 { - for _ in 0..512 { + let mut best: Option<(Vec, String)> = None; + for _ in 0..64 { let encoded = sign()?; - // Byte 2 is the first digest byte after the multihash prefix. - if graph_id(&encoded)?.get(2).is_some_and(|byte| *byte >= 0xC0) { - return Ok(encoded); + let id = graph_id(&encoded)?; + if best.as_ref().is_none_or(|(best_id, _)| id > *best_id) { + best = Some((id, encoded)); } } - anyhow::bail!("no delegation sorted high") + Ok(best.expect("64 candidates").1) } /// Set a registration's `registered_at`, as for one registered earlier. @@ -1523,7 +1545,7 @@ on_both_backends!(strangers_cannot_crowd_the_general_window); /// 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(h) = Harness::with_boot( + let Some(mut h) = Harness::with_boot( backend, Shape::KeySession, "a_general_window_full_of_skipped_rows_is_starved", @@ -1564,10 +1586,76 @@ async fn a_general_window_full_of_skipped_rows_is_starved(backend: Backend) -> a 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) still 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 still records that grant. From 65b5db6e105c82f83107ea69dea0f4a2b990e02e Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 16:50:39 +0000 Subject: [PATCH 11/41] fix(TC-1423): pin the lifetime cap and flip-signal edges Unit tests pin the root-lifetime cap at exactly registration + 31 days + 5 minutes and the authority_ttl_bucket edges (1 hour, 30 days). The bucket takes the authority expiry itself (an uncapped root's authority always expires; the unreachable 'none' bucket is gone). The boot log line carries the active owner_authority_expiry with the cutover. Docs: the counters are exported on the Prometheus port (telemetry.enabled; else the counting queries), the cap is described as an expiry bound, and re-posting an existing registration that breaks the current rules gets the same 403. --- CHANGELOG.md | 2 +- docs/policy-owner-authority.md | 21 ++++-- tinycloud-node-server/src/config.rs | 9 +++ tinycloud-node-server/src/policy_v3.rs | 6 +- .../src/policy_v3/owner_authority.rs | 64 +++++++++++++++++-- 5 files changed, 85 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efe66a9b..c8a55906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Changes - 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). 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`. The record does not change admission yet. See `docs/policy-owner-authority.md` (TC-1423). -- Policy/v3 registration refuses a root unless `exp <= registration + 31 days (+5 minutes)` and `nbf <= exp` (`403 policy-root-lifetime-exceeded`), in both modes; released SDK roots pass. New `policy_v3.owner_authority_expiry`: `record` (default) accepts a root that outlives the owner authority and counts it, `enforce` refuses it (`403 policy-root-outlives-owner-authority`). Earlier registrations are never capped; roots over 31 days are counted. `GET /info` lists `policy-owner-authority-v1` and one of `policy-owner-authority-record` / `policy-owner-authority-enforce`. New counters `tinycloud_policy_owner_authority_expiry_exceeded_total{authority_ttl_bucket}`, `tinycloud_policy_root_lifetime_refused_total`, `tinycloud_policy_legacy_root_lifetime_exceeded_total`, `tinycloud_policy_owner_binding_backfill_total{outcome}`; counting queries in `docs/policy-owner-authority.md` (TC-1423). +- Policy/v3 registration refuses a root that expires later than registration + 31 days (+5 minutes) (`403 policy-root-lifetime-exceeded`), in both modes; released SDK roots pass. New `policy_v3.owner_authority_expiry`: `record` (default) accepts a root that outlives the owner authority and counts it, `enforce` refuses it (`403 policy-root-outlives-owner-authority`). Earlier registrations are never capped; roots over 31 days are counted. `GET /info` lists `policy-owner-authority-v1` and one of `policy-owner-authority-record` / `policy-owner-authority-enforce`. New counters `tinycloud_policy_owner_authority_expiry_exceeded_total{authority_ttl_bucket}`, `tinycloud_policy_root_lifetime_refused_total`, `tinycloud_policy_legacy_root_lifetime_exceeded_total`, `tinycloud_policy_owner_binding_backfill_total{outcome}`; counting queries in `docs/policy-owner-authority.md` (TC-1423). ## [1.21.0] - 2026-10-18 diff --git a/docs/policy-owner-authority.md b/docs/policy-owner-authority.md index 0b8c1f02..64c04181 100644 --- a/docs/policy-owner-authority.md +++ b/docs/policy-owner-authority.md @@ -29,10 +29,17 @@ classified later. ## Root lifetime and mode -At registration, in both modes, a root is refused with `403 -policy-root-lifetime-exceeded` unless `exp <= registered_at + 31 days + 5 -minutes` and `nbf <= exp`. Released SDKs sign `nbf` at signing time with -`exp - nbf <= 31 days`, so their roots pass. +At registration, in both modes, a root that expires later than +`registered_at + 31 days + 5 minutes` is refused with `403 +policy-root-lifetime-exceeded`. (A root that isn't valid yet is already refused +by the signed time check, `policy-root-signature-invalid`; the node also +requires `nbf <= exp` when it classifies an unclassified registration later.) +Released SDKs sign `nbf` at signing time with `exp - nbf <= 31 days`, so their +roots pass. + +The cap and the `enforce` check run before the idempotent re-registration +shortcut, so re-posting an existing registration that breaks the current rules +gets the same `403`. ```toml [policy_v3] @@ -62,11 +69,13 @@ if `policy-owner-authority-enforce` is listed. The response carries no ### Metrics -Always counted, independent of `telemetry.enabled`: +Always counted, independent of the request-telemetry sampling. They are +exported on the node's Prometheus port, which runs only when +`telemetry.enabled` is set; without it, use the counting queries below. | metric | labels | counts | |---|---|---| -| `tinycloud_policy_owner_authority_expiry_exceeded_total` | `authority_ttl_bucket` = `le_1h`, `le_30d`, `gt_30d`, `none` | `record` registrations whose root outlives the authority (authority lifetime from registration) | +| `tinycloud_policy_owner_authority_expiry_exceeded_total` | `authority_ttl_bucket` = `le_1h`, `le_30d`, `gt_30d` (authority time left at registration; `le_1h` includes exactly one hour) | `record` registrations whose root outlives the authority | | `tinycloud_policy_root_lifetime_refused_total` | | lifetime-cap refusals | | `tinycloud_policy_legacy_root_lifetime_exceeded_total` | | legacy roots longer than 31 days, when classified | | `tinycloud_policy_owner_binding_backfill_total` | `outcome` = `bound`, `bound_uncapped`, `root_authority`, `unknown`, `write_failed_served` | classifications after registration | diff --git a/tinycloud-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index 485783cf..606eaad2 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -1016,6 +1016,15 @@ pub enum OwnerAuthorityExpiry { Enforce, } +impl OwnerAuthorityExpiry { + pub fn as_str(self) -> &'static str { + match self { + Self::Record => "record", + Self::Enforce => "enforce", + } + } +} + impl PolicyV3Config { pub fn validate(&self) -> Result<(), &'static str> { if let Some(cutover) = &self.legacy_cutover { diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index fb040249..e3bfce20 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -2041,10 +2041,10 @@ pub async fn register_policy( .map_err(|e| (Status::Conflict, e.to_string()))?; } txn.commit().await.map_err(db_error)?; - if expiry_exceeded { - // The `enforce` flip signal: counted once per stored registration. + // The `enforce` flip signal: counted once per stored registration. + if let (true, Some(authority_expires_at)) = (expiry_exceeded, authority.authority_expires_at) { crate::prometheus::observe_policy_owner_authority_expiry_exceeded( - owner_authority::authority_ttl_bucket(checked_at, authority.authority_expires_at), + owner_authority::authority_ttl_bucket(checked_at, authority_expires_at), ); } Ok(Json(RegisterResponse { diff --git a/tinycloud-node-server/src/policy_v3/owner_authority.rs b/tinycloud-node-server/src/policy_v3/owner_authority.rs index 8e32882f..d95979a7 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority.rs @@ -266,16 +266,22 @@ pub(super) fn root_lifetime_allowed( not_before <= expiry && expiry <= registered_at + MAX_ROOT_LIFETIME + ROOT_LIFETIME_SKEW } -/// The `authority_ttl_bucket` of a root that outlives its authority. +/// The `authority_ttl_bucket` of a root that outlives its authority, by +/// how long the authority had left at registration: `le_1h` (released +/// Share, signed by a login session), `le_30d` (CLI device sessions) or +/// `gt_30d`. Only `bound-uncapped` roots are bucketed, and their authority +/// always expires. pub(super) fn authority_ttl_bucket( registered_at: OffsetDateTime, - authority_expires_at: Option, + authority_expires_at: OffsetDateTime, ) -> &'static str { - match authority_expires_at { - None => "none", - Some(expiry) if expiry - registered_at <= Duration::hours(1) => "le_1h", - Some(expiry) if expiry - registered_at <= Duration::days(30) => "le_30d", - Some(_) => "gt_30d", + let remaining = authority_expires_at - registered_at; + if remaining <= Duration::hours(1) { + "le_1h" + } else if remaining <= Duration::days(30) { + "le_30d" + } else { + "gt_30d" } } @@ -857,6 +863,7 @@ impl PolicyV3Runtime { event = "policy_owner_authority", legacy_cutover = %format_time(cutover), source = CutoverSource::Migration.as_str(), + owner_authority_expiry = self.owner_authority_expiry().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" ) } @@ -864,10 +871,12 @@ impl PolicyV3Runtime { event = "policy_owner_authority", legacy_cutover = %format_time(cutover), source = source.as_str(), + owner_authority_expiry = self.owner_authority_expiry().as_str(), "policy owner-authority legacy cutover" ), Err(_) => ::tracing::warn!( event = "policy_owner_authority", + owner_authority_expiry = self.owner_authority_expiry().as_str(), "policy owner-authority legacy cutover is unavailable; legacy policies are classified when it can be read" ), } @@ -1518,4 +1527,45 @@ mod tests { tokio::task::yield_now().await; assert_eq!(queries.load(Ordering::SeqCst), before); } + + /// The cap is exact: registration + 31 days + 5 minutes is accepted, one + /// second more is refused; `nbf` may equal `exp` but not pass it. + #[test] + fn root_lifetime_cap_boundaries_are_exact() { + let registered_at = OffsetDateTime::from_unix_timestamp(1_800_000_000).unwrap(); + let cap = registered_at + Duration::days(31) + Duration::minutes(5); + assert!(root_lifetime_allowed(registered_at, registered_at, cap)); + assert!(!root_lifetime_allowed( + registered_at, + registered_at, + cap + Duration::seconds(1) + )); + assert!(root_lifetime_allowed(registered_at, cap, cap)); + assert!(!root_lifetime_allowed( + registered_at, + cap, + cap - Duration::seconds(1) + )); + } + + /// The flip signal's buckets: exactly one hour left is `le_1h`, one + /// second more `le_30d`; exactly 30 days `le_30d`, one second more + /// `gt_30d`. + #[test] + fn authority_ttl_bucket_edges_are_exact() { + let at = OffsetDateTime::from_unix_timestamp(1_800_000_000).unwrap(); + for (remaining, bucket) in [ + (Duration::seconds(90), "le_1h"), + (Duration::hours(1), "le_1h"), + (Duration::hours(1) + Duration::seconds(1), "le_30d"), + (Duration::days(30), "le_30d"), + (Duration::days(30) + Duration::seconds(1), "gt_30d"), + ] { + assert_eq!( + authority_ttl_bucket(at, at + remaining), + bucket, + "{remaining}" + ); + } + } } From 2972b5eeecee044bb4673e771f01e753ac733a90 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 16:53:59 +0000 Subject: [PATCH 12/41] feat(TC-1423): check owner authority on policy share use Every policy use checks the recorded owner authority after the root checks: reads, decrypts and re-shared children of admitted sessions, re-share admission, signed-KV and hook-ticket mints, challenge, mint (and its locked recheck), S0 admission and delivery. A revoked delegation or ancestor (also under an alias CID) gives policy-owner-authority-revoked; a non-legacy bound authority past expiry policy-owner-authority-expired; no authority policy-owner-authority-unknown, unless legacy and policy_v3.legacy_unknown_allow. The mint's registration-time rescan runs only for escaped policies. Expired roots and sessions get typed codes. Hook tickets carry a policy session's closure (at most 16 IDs) and open streams re-check their authority, closing on a revocation or three failed checks; the chain check is two queries at any depth. What to check is cached once final; liveness never is. --- CHANGELOG.md | 1 + docs/policy-owner-authority.md | 57 ++ tinycloud-core/src/db.rs | 12 +- tinycloud-core/src/policy_owner_authority.rs | 89 +++ tinycloud-node-server/src/config.rs | 27 + tinycloud-node-server/src/policy_v3.rs | 141 +++-- .../src/policy_v3/owner_authority.rs | 549 ++++++++++++++---- .../src/policy_v3/owner_authority_tests.rs | 132 ++++- tinycloud-node-server/src/prometheus.rs | 28 + tinycloud-node-server/src/routes/hooks.rs | 129 +++- tinycloud-node-server/src/routes/mod.rs | 3 +- 11 files changed, 975 insertions(+), 193 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efe66a9b..c49becdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - 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). 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`. The record does not change admission yet. See `docs/policy-owner-authority.md` (TC-1423). - Policy/v3 registration refuses a root unless `exp <= registration + 31 days (+5 minutes)` and `nbf <= exp` (`403 policy-root-lifetime-exceeded`), in both modes; released SDK roots pass. New `policy_v3.owner_authority_expiry`: `record` (default) accepts a root that outlives the owner authority and counts it, `enforce` refuses it (`403 policy-root-outlives-owner-authority`). Earlier registrations are never capped; roots over 31 days are counted. `GET /info` lists `policy-owner-authority-v1` and one of `policy-owner-authority-record` / `policy-owner-authority-enforce`. New counters `tinycloud_policy_owner_authority_expiry_exceeded_total{authority_ttl_bucket}`, `tinycloud_policy_root_lifetime_refused_total`, `tinycloud_policy_legacy_root_lifetime_exceeded_total`, `tinycloud_policy_owner_binding_backfill_total{outcome}`; counting queries in `docs/policy-owner-authority.md` (TC-1423). +- Policy/v3 uses check the recorded owner authority: once a recorded delegation or any ancestor is revoked (also under an alias CID), admitted sessions stop reading and decrypting, and re-share admission, signed-KV and hook-ticket mints, challenge, mint, S0 admission and delivery are refused (`403 policy-owner-authority-revoked`); a non-legacy `bound` authority past its expiry gives `policy-owner-authority-expired`; a policy without authority gives `policy-owner-authority-unknown`, unless it is legacy and `policy_v3.legacy_unknown_allow` (default off, logged) serves it with root-only checks. The mint's registration-time rescan runs only for such escaped policies. An expired root now returns `root-expired` instead of `root-not-live`. Hook tickets carry a policy session's authority closure (at most 16 IDs, else `403 hook-ticket-authority-too-large`), and open hook streams re-check their authority before delivering (at most every `hooks.authority_recheck_seconds`, 5) and while idle (`hooks.idle_authority_recheck_seconds`, 30), closing on a revocation or three consecutive failed checks; `delegation_chains_unrevoked` is now two queries at any depth. New counters `tinycloud_policy_owner_authority_denied_total{reason}` and `tinycloud_policy_owner_authority_unknown_allowed_total{reason}` (TC-1423). ## [1.21.0] - 2026-10-18 diff --git a/docs/policy-owner-authority.md b/docs/policy-owner-authority.md index 26e513f7..27f53a20 100644 --- a/docs/policy-owner-authority.md +++ b/docs/policy-owner-authority.md @@ -222,3 +222,60 @@ 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`. + +## Checking at use + +Every use of a policy checks its recorded owner authority, after the root +checks: reads and decrypts by admitted sessions (and their re-shared +children), re-share admission, signed-KV and hook-ticket mints, challenge, +mint (again on the mint's locked transaction), S0 admission, and delivery +authorization. + +| state | at use | +|---|---| +| `bound`, `bound-uncapped` | denied if any recorded delegation or ancestor is revoked, directly or under an alias CID (`403 policy-owner-authority-revoked`); a `bound` authority past its expiry is denied (`403 policy-owner-authority-expired`), except for legacy (`backfill`) rows, which are never expiry-checked | +| `root-authority` | nothing more | +| `unknown` | denied (`403 policy-owner-authority-unknown`) | + +The mint no longer rescans the owner's delegations as of registration for +policies with a recorded authority. + +What to check is cached in process once a classification is final (bounded); +liveness is not cached. A steady-state use costs one indexed revocation query +for a recorded closure and none for root authority. A policy without a final +classification is classified in the request, served from that result, and +stored in the background; a result that isn't final is reused for 60 seconds. + +A revocation takes effect on commit; a request already past the check may +finish. Signed-KV tickets minted before a revocation last at most 60 seconds. + +Other codes: an expired root now gives `root-expired` (other non-live status +remains `root-not-live`); S0 admission returns the owner-authority code itself; +delivery keeps `delivery-authorization-invalid`. No code carries a CID or DID. + +### Legacy escape + +```toml +[policy_v3] +legacy_unknown_allow = false # TINYCLOUD_POLICY_V3__LEGACY_UNKNOWN_ALLOW +``` + +When true, legacy (`backfill`) policies classified `unknown` are served with +the root-only checks (and the mint's registration-time rescan) instead of +being denied. The node logs a warning at boot, and per policy at most once an +hour when it serves one; uses are counted in +`tinycloud_policy_owner_authority_unknown_allowed_total{reason}`. Denials are +counted in `tinycloud_policy_owner_authority_denied_total{reason=revoked|expired|unknown}`. + +### Hook streams + +A hook ticket carries the delegations that authorized it, plus, for a policy +session, the recorded owner-authority closure; a ticket with more than 16 is +refused (`403 hook-ticket-authority-too-large`). An open `/hooks/events` +stream re-checks them (one closure query plus one revocation query) before +delivering an event, at most every `hooks.authority_recheck_seconds` (5), and +every `hooks.idle_authority_recheck_seconds` (30) while idle. It closes on a +revocation, or after three consecutive failed checks; a failed check withholds +that event but keeps the stream. Streams don't see a JSON-route +`RootRevocation` (it writes no core revocation) or an authority expiry; those +end at the ticket deadline (at most 300 seconds), as before. diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 91c32043..457fb912 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -660,15 +660,11 @@ where if roots.is_empty() { return Ok(true); } - let chain_ids = revocation::ancestor_chain_ids_for_roots(&self.conn, &roots) + // TC-1423: one closure query plus one alias-aware revocation query, + // whatever the depth; hook streams re-check this while open. + crate::policy_owner_authority::chains_unrevoked(&self.conn, &roots) .await - .map_err(|error| DbErr::Custom(error.to_string()))?; - for id in chain_ids { - if revocation::is_revoked(&self.conn, &id).await? { - return Ok(false); - } - } - Ok(true) + .map_err(|error| DbErr::Custom(error.to_string())) } /// Load and reparse a delegation from its exact signed Authorization diff --git a/tinycloud-core/src/policy_owner_authority.rs b/tinycloud-core/src/policy_owner_authority.rs index 489d01ab..0a8c0425 100644 --- a/tinycloud-core/src/policy_owner_authority.rs +++ b/tinycloud-core/src/policy_owner_authority.rs @@ -123,3 +123,92 @@ pub async fn migration_applied_at( .await? .map(|row| row.applied_at)) } + +/// Whether nothing in the ancestor closure of `roots` is revoked (directly +/// or under an alias CID): one recursive closure query plus one revocation +/// query, whatever the depth. Fails closed past the traversal budget. +pub async fn chains_unrevoked( + db: &C, + roots: &[Hash], +) -> Result { + if roots.is_empty() { + return Ok(true); + } + let (ids, _) = crate::auth_graph::load_closure_edges(db, roots).await?; + Ok(revoked_ids(db, &ids).await?.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::hash; + use crate::migrations::Migrator; + use crate::models::actor; + use crate::relationships::parent_delegations; + use sea_orm::{ActiveModelTrait, ActiveValue::Set, ConnectOptions, Database}; + use sea_orm_migration::MigratorTrait; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + #[tokio::test] + async fn chain_check_costs_two_queries_at_any_depth() { + let mut db = Database::connect(ConnectOptions::new("sqlite::memory:".to_string())) + .await + .unwrap(); + Migrator::up(&db, None).await.unwrap(); + actor::ActiveModel { + id: Set("did:key:actor".to_string()), + } + .insert(&db) + .await + .unwrap(); + let ids: Vec = (0..6) + .map(|i| hash(format!("chain-{i}").as_bytes())) + .collect(); + for id in &ids { + delegation::ActiveModel { + id: Set(*id), + delegator: Set("did:key:actor".to_string()), + delegatee: Set("did:key:actor".to_string()), + expiry: Set(None), + issued_at: Set(None), + not_before: Set(None), + facts: Set(None), + serialization: Set(id.as_ref().to_vec()), + } + .insert(&db) + .await + .unwrap(); + } + for pair in ids.windows(2) { + parent_delegations::ActiveModel { + child: Set(pair[0]), + parent: Set(pair[1]), + } + .insert(&db) + .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); + }); + assert!(chains_unrevoked(&db, &ids[..1]).await.unwrap()); + assert_eq!(queries.load(Ordering::SeqCst), 2); + + revocation::ActiveModel { + id: Set(hash(b"revoke-chain-top")), + revoker: Set("did:key:actor".to_string()), + revoked: Set(ids[5]), + serialization: Set(b"revocation".to_vec()), + revoked_at: Set(None), + } + .insert(&db) + .await + .unwrap(); + queries.store(0, Ordering::SeqCst); + assert!(!chains_unrevoked(&db, &ids[..1]).await.unwrap()); + assert_eq!(queries.load(Ordering::SeqCst), 2); + } +} diff --git a/tinycloud-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index 485783cf..2485ac70 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -822,6 +822,22 @@ pub struct HooksConfig { pub webhook_timeout_seconds: u64, #[serde(default = "default_hooks_webhook_max_attempts")] pub webhook_max_attempts: usize, + /// An open event stream re-checks its ticket's authority before + /// delivering an event, at most this often. + #[serde(default = "default_hooks_authority_recheck_seconds")] + pub authority_recheck_seconds: u64, + /// An open event stream re-checks its ticket's authority this often + /// even when no event arrives. + #[serde(default = "default_hooks_idle_authority_recheck_seconds")] + pub idle_authority_recheck_seconds: u64, +} + +fn default_hooks_authority_recheck_seconds() -> u64 { + 5 +} + +fn default_hooks_idle_authority_recheck_seconds() -> u64 { + 30 } fn default_hooks_max_ticket_ttl_seconds() -> u64 { @@ -863,6 +879,8 @@ impl Default for HooksConfig { ), webhook_timeout_seconds: default_hooks_webhook_timeout_seconds(), webhook_max_attempts: default_hooks_webhook_max_attempts(), + authority_recheck_seconds: default_hooks_authority_recheck_seconds(), + idle_authority_recheck_seconds: default_hooks_idle_authority_recheck_seconds(), } } } @@ -873,6 +891,9 @@ impl HooksConfig { if self.webhook_max_attempts > 10 { return Err("hooks.webhook_max_attempts must not exceed 10"); } + if self.authority_recheck_seconds == 0 || self.idle_authority_recheck_seconds == 0 { + return Err("hooks authority re-check intervals must be at least 1 second"); + } Ok(()) } } @@ -1003,6 +1024,12 @@ pub struct PolicyV3Config { /// `TINYCLOUD_POLICY_V3__OWNER_AUTHORITY_EXPIRY`. #[serde(default)] pub owner_authority_expiry: OwnerAuthorityExpiry, + /// Operator escape: serve policies registered before the legacy cutover + /// for which no owner authority was found with the root-only checks + /// instead of denying them. Off by default; logged at boot and per + /// policy when used. Env `TINYCLOUD_POLICY_V3__LEGACY_UNKNOWN_ALLOW`. + #[serde(default)] + pub legacy_unknown_allow: bool, #[serde(default)] pub owner_binding_backfill: OwnerBindingBackfillConfig, } diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 53979dd4..6de30e80 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -290,6 +290,10 @@ impl PolicyV3Runtime { return Err("policy-session-root-graph-mismatch"); } } + // TC-1423: the owner authority must still hold; its code passes + // through. + self.validate_owner_authority(&self.conn, ®istration, OffsetDateTime::now_utc()) + .await?; Ok(()) } @@ -355,12 +359,15 @@ impl PolicyV3Runtime { Ok(()) } + /// `Ok(None)` for an ordinary invocation; for a policy-session chain, + /// `Ok(Some(closure))` with the recorded owner-authority closure it + /// passed (empty for root authority). pub async fn authorize_invocation( &self, tinycloud: &crate::TinyCloud, invocation: &InvocationInfo, now: OffsetDateTime, - ) -> Result { + ) -> Result>, &'static str> { if invocation.parents.len() != 1 { for parent in invocation.parents.iter().copied() { if tinycloud @@ -373,7 +380,7 @@ impl PolicyV3Runtime { return Err("policy-session-requires-one-immediate-parent"); } } - return Ok(false); + return Ok(None); } let parent_cid = invocation.parents[0]; let loaded = tinycloud.load_signed_delegation(parent_cid).await; @@ -382,9 +389,9 @@ impl PolicyV3Runtime { Err(_) if self.is_policy_protected_parent(parent_cid).await? => { return Err("policy-session-invalid"); } - Err(_) => return Ok(false), + Err(_) => return Ok(None), }) else { - return Ok(false); + return Ok(None); }; // Sibling roots are stored in the ordinary graph only so S0 can have // normal proof edges. Neither root is invocation authority by itself. @@ -397,7 +404,7 @@ impl PolicyV3Runtime { return Err("policy-root-cannot-authorize-invocation"); } if !is_policy_session(&immediate.0) { - return Ok(false); + return Ok(None); } if immediate_row.delegatee != invocation.invoker || immediate.0.delegate != invocation.invoker @@ -405,12 +412,12 @@ impl PolicyV3Runtime { // Principal/audience mismatch is an ordinary graph authorization // failure. Defer it to the existing authorization path so TC-405 // does not change its status contract or error vocabulary. - return Ok(false); + return Ok(None); } if !capabilities_are_contained(&invocation.capabilities, &immediate.0.capabilities) { return Err("policy-invocation-capability-widening"); } - let (row, session) = self + let (row, session, owner_authority) = self .validate_policy_chain(tinycloud, invocation.parents[0]) .await?; let payload = invocation.invocation.payload(); @@ -520,7 +527,13 @@ impl PolicyV3Runtime { } validate_root_liveness(&self.conn, &root, &root_cid, &self.node_did, now, true).await?; } - Ok(true) + Ok(Some( + owner_authority + .closure + .iter() + .map(|id| id.to_cid(0x55)) + .collect(), + )) } async fn is_policy_protected_parent( @@ -560,7 +573,14 @@ impl PolicyV3Runtime { &self, tinycloud: &crate::TinyCloud, start: tinycloud_auth::ipld_core::cid::Cid, - ) -> Result<(policy_v3_session::Model, Delegation), &'static str> { + ) -> Result< + ( + policy_v3_session::Model, + Delegation, + owner_authority::OwnerAuthority, + ), + &'static str, + > { let mut current_cid = start; let mut child: Option = None; let mut visited = std::collections::HashSet::new(); @@ -575,9 +595,12 @@ impl PolicyV3Runtime { else { return Err("policy-session-parent-missing"); }; - verify_signed_delegation(&event.0.delegation) + verify_delegation_signature(&event.0.delegation) .await .map_err(|_| "policy-session-signature-invalid")?; + if !delegation_time_valid(&event.0.delegation) { + return Err("policy-session-expired"); + } self.validate_graph_projection(&row, &event).await?; if !is_policy_session(&event.0) { return Err("policy-session-protected-fact-missing"); @@ -637,7 +660,8 @@ impl PolicyV3Runtime { { return Err("policy-session-proof-index-mismatch"); } - self.authorize_roots(®istration, OffsetDateTime::now_utc()) + let owner_authority = self + .authorize_roots(®istration, OffsetDateTime::now_utc()) .await?; for root_cid in event.0.parents.iter().copied() { let Some((_, root)) = tinycloud @@ -651,7 +675,7 @@ impl PolicyV3Runtime { return Err("policy-session-capability-widening"); } } - return Ok((index, event)); + return Ok((index, event, owner_authority)); } if event.0.parents.len() != 1 { return Err("policy-session-parent-count-invalid"); @@ -886,9 +910,22 @@ async fn validate_persisted_root( if fact(&event.0.delegation, "role") != Some(expected_role) { return Err("policy-root-role-invalid"); } - verify_signed_delegation(&event.0.delegation) + verify_delegation_signature(&event.0.delegation) .await .map_err(|_| "policy-root-signature-invalid")?; + if !delegation_time_valid(&event.0.delegation) { + return Err( + if event + .0 + .expiry + .is_some_and(|expiry| OffsetDateTime::now_utc() >= expiry) + { + "root-expired" + } else { + "root-not-live" + }, + ); + } for space in event.0.spaces() { tinycloud_core::request_telemetry::observe_space(space); } @@ -1461,6 +1498,10 @@ pub async fn authorize_delivery( .await .map_err(|_| (Status::Forbidden, "delivery-authorization-invalid".into()))?; } + runtime + .validate_owner_authority(&runtime.conn, ®istration, now) + .await + .map_err(|_| (Status::Forbidden, "delivery-authorization-invalid".into()))?; let resource = envelope .get("contentSource") .and_then(Value::as_object) @@ -2191,24 +2232,27 @@ pub async fn mint( )?; let enforcer_did = fact(&enforcement_root.0.delegation, "enforcerDid") .ok_or((Status::Forbidden, "sibling-root-mismatch".into()))?; - runtime + let owner_authority = runtime .authorize_roots(®istration, now) .await .map_err(|e| (Status::Forbidden, e.into()))?; - // Policies registered before owners had to hold what they grant are - // checked here too: the owner must have held every root capability when + // A policy served under the legacy escape has no recorded authority, so + // the owner must still be shown to have held every root capability when // the policy was registered. (Owner sessions may since have expired.) - let registered_at = parse_time(®istration.registered_at) - .map_err(|_| (Status::Forbidden, "policy-registration-invalid".into()))?; - for root in [&policy_root, &enforcement_root] { - owner_holds_capabilities( - &runtime.conn, - ®istration.owner_did, - &root.0.capabilities, - registered_at, - ) - .await - .map_err(|error| (Status::Forbidden, error.into()))?; + // Recorded authority replaces this rescan. + if owner_authority.escaped { + let registered_at = parse_time(®istration.registered_at) + .map_err(|_| (Status::Forbidden, "policy-registration-invalid".into()))?; + for root in [&policy_root, &enforcement_root] { + owner_holds_capabilities( + &runtime.conn, + ®istration.owner_did, + &root.0.capabilities, + registered_at, + ) + .await + .map_err(|error| (Status::Forbidden, error.into()))?; + } } for (cid, expected) in [ (®istration.policy_root_cid, &policy_root), @@ -2591,6 +2635,14 @@ pub async fn mint( validate_root_liveness(&txn, &root, root_cid, &runtime.node_did, now, true) .await .map_err(|error| (Status::Forbidden, error.into()))?; + // The recorded owner authority, rechecked on the locked transaction. + if !tinycloud_core::policy_owner_authority::revoked_ids(&txn, &owner_authority.closure) + .await + .map_err(db_error)? + .is_empty() + { + return Err((Status::Forbidden, "policy-owner-authority-revoked".into())); + } let current_event = decode_delegation( std::str::from_utf8(&root.authorization_bytes) .map_err(|_| (Status::Forbidden, "policy-root-invalid".into()))?, @@ -2780,7 +2832,7 @@ impl PolicyV3Runtime { &self, registration: &policy_v3_registration::Model, now: OffsetDateTime, - ) -> Result<(), &'static str> { + ) -> Result { let policy = validate_registration_projection(registration) .map_err(|_| "policy-registration-projection-mismatch")?; let mut roots = Vec::new(); @@ -2833,11 +2885,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(()) + // TC-1423: the owner authority the roots were registered under must + // still hold. + self.validate_owner_authority(&self.conn, registration, now) + .await } } @@ -2912,13 +2963,12 @@ fn validate_stored_root_status( ) .map_err(|_| "root-status-invalid")?; let advertised_expiry = root_advertised_expiry(root)?; - if checked > now - || checked >= advertised_expiry - || fresh != advertised_expiry - || (require_fresh && advertised_expiry <= now) - { + if checked > now || checked >= advertised_expiry || fresh != advertised_expiry { return Err("root-not-live"); } + if require_fresh && advertised_expiry <= now { + return Err("root-expired"); + } let previous_digest = object .get("previousCheckpointDigestHex") .and_then(Value::as_str); @@ -4380,6 +4430,21 @@ fn decode_delegation(value: &str) -> Result Result<(), ()> { + let TinyCloudDelegation::Ucan(ucan) = delegation else { + return Err(()); + }; + tinycloud_core::did_resolution::verify_ucan_signature(ucan) + .await + .map_err(|_| ()) +} + +/// Whether a UCAN is within its signed validity window now. +fn delegation_time_valid(delegation: &TinyCloudDelegation) -> bool { + matches!(delegation, TinyCloudDelegation::Ucan(ucan) if ucan.payload().validate_time(None).is_ok()) +} + async fn verify_signed_delegation(delegation: &TinyCloudDelegation) -> Result<(), ()> { let TinyCloudDelegation::Ucan(ucan) = delegation else { return Err(()); diff --git a/tinycloud-node-server/src/policy_v3/owner_authority.rs b/tinycloud-node-server/src/policy_v3/owner_authority.rs index 0e750f49..f28b909d 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority.rs @@ -51,8 +51,9 @@ const OWNER_FIRST_ROWS: u64 = 64; 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. +/// A classification computed for a request that isn't final (its write +/// failed, or it ended `starved`) is reused for this long before it is +/// recomputed. 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; @@ -159,6 +160,46 @@ impl RegisteredMode { Self::Backfill => "backfill", } } + + fn parse(value: &str) -> Option { + Some(match value { + "record" => Self::Record, + "enforce" => Self::Enforce, + "backfill" => Self::Backfill, + _ => return None, + }) + } +} + +/// A classification as a policy use checks it. +#[derive(Clone, Debug)] +pub(super) struct Binding { + state: BindingState, + reason: Option, + mode: RegisteredMode, + authority_expires_at: Option, + /// The recorded closure (empty unless `bound*`). + closure: Arc<[Hash]>, +} + +impl Binding { + fn from_classification(classification: &Classification, mode: RegisteredMode) -> Self { + Self { + state: classification.state, + reason: classification.reason, + mode, + authority_expires_at: classification.authority_expires_at, + closure: classification.closure.iter().map(|(id, _)| *id).collect(), + } + } +} + +/// The owner authority a policy use passed: the closure to watch for +/// revocation, and whether it was served under the legacy escape. +#[derive(Clone, Debug, Default)] +pub struct OwnerAuthority { + pub closure: Arc<[Hash]>, + pub escaped: bool, } /// How a registration's owner held what its roots grant. @@ -668,57 +709,98 @@ impl CutoverSource { pub(super) struct OwnerAuthorityState { configured_cutover: Option, expiry: OwnerAuthorityExpiry, + legacy_unknown_allow: bool, migration_cutover: tokio::sync::OnceCell, - /// Policies whose stored classification is final. - final_policies: std::sync::Mutex>, - /// Policies with a background classification running. + /// Policies whose stored classification is final, with their closure. + /// Liveness is never cached, only what to check. + final_policies: std::sync::Mutex>, + /// Classifications computed for a request but not (yet) final, served + /// until the instant given, so a failed write or `starved` is not + /// recomputed on every request. + transient: std::sync::Mutex>, + /// Policies with a background write running. in_flight: std::sync::Mutex>, - /// Policies whose last classification was not final, until when. - retry_after: std::sync::Mutex>, + /// When each escaped policy was last logged. + escape_logged: std::sync::Mutex>, +} + +/// Insert into a bounded map, clearing it when full. +fn bounded_insert(map: &mut HashMap, key: &str, value: V) { + if map.len() >= MAX_TRACKED_POLICIES && !map.contains_key(key) { + map.clear(); + } + map.insert(key.to_owned(), value); } impl OwnerAuthorityState { - fn is_final(&self, policy_cid: &str) -> bool { - self.final_policies + fn cached(&self, policy_cid: &str) -> Option { + if let Some(binding) = 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(); + .get(policy_cid) + { + return Some(binding.clone()); } - 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, + let mut transient = self.transient.lock().expect("owner-authority cache"); + match transient.get(policy_cid) { + Some((binding, until)) if *until > tokio::time::Instant::now() => Some(binding.clone()), Some(_) => { - retry.remove(policy_cid); - false + transient.remove(policy_cid); + None } - None => false, + None => None, } } - 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, + fn remember_final(&self, policy_cid: &str, binding: Binding) { + self.transient + .lock() + .expect("owner-authority cache") + .remove(policy_cid); + bounded_insert( + &mut self.final_policies.lock().expect("owner-authority cache"), + policy_cid, + binding, + ); + } + + fn remember_transient(&self, policy_cid: &str, binding: Binding) { + bounded_insert( + &mut self.transient.lock().expect("owner-authority cache"), + policy_cid, + (binding, tokio::time::Instant::now() + RETRY_BACKOFF), ); } + + /// Whether this escaped use should be logged (once per policy per hour). + fn log_escape(&self, policy_cid: &str) -> bool { + let mut logged = self + .escape_logged + .lock() + .expect("owner-authority escape log"); + let now = tokio::time::Instant::now(); + if logged + .get(policy_cid) + .is_some_and(|at| now.duration_since(*at) < std::time::Duration::from_secs(3600)) + { + return false; + } + bounded_insert(&mut logged, policy_cid, now); + true + } +} + +/// The recorded closure of a policy. +async fn recorded_closure( + conn: &C, + policy_cid: &str, +) -> Result, DbErr> { + policy_v3_owner_authority::Entity::find() + .filter(policy_v3_owner_authority::Column::PolicyCid.eq(policy_cid)) + .all(conn) + .await + .map(|rows| rows.into_iter().map(|row| row.delegation_id).collect()) } /// SQL form of `!row_is_final`. @@ -756,6 +838,7 @@ impl PolicyV3Runtime { self.owner_authority = Arc::new(OwnerAuthorityState { configured_cutover: config.legacy_cutover(), expiry: config.owner_authority_expiry, + legacy_unknown_allow: config.legacy_unknown_allow, ..OwnerAuthorityState::default() }); self @@ -820,11 +903,17 @@ impl PolicyV3Runtime { "policy owner-authority legacy cutover is unavailable; legacy policies are classified when it can be read" ), } + if self.owner_authority.legacy_unknown_allow { + ::tracing::warn!( + event = "policy_owner_authority", + legacy_unknown_allow = true, + "policy_v3.legacy_unknown_allow is on: legacy policies without owner authority are served with root-only checks" + ); + } } - /// 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 still returned, unstored. + /// The stored classification of `registration`, classifying (and + /// storing) it first if none is final. Used by the sweep. pub(super) async fn owner_binding( &self, registration: &policy_v3_registration::Model, @@ -834,18 +923,27 @@ impl PolicyV3Runtime { .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(row) if row_is_final(row) => return Classification::from_row(row), Some(_) => true, None => false, }; let (classification, mode) = self.classify_registration(registration).await?; + Ok(self + .store_classification(®istration.policy_cid, classification, mode, stale) + .await) + } + + /// Store a computed classification claim-first, count it, and remember + /// it when final. Returns what is now in force. + async fn store_classification( + &self, + policy_cid: &str, + classification: Classification, + mode: RegisteredMode, + stale: bool, + ) -> Classification { match self - .store_lazy_classification(®istration.policy_cid, &classification, mode, stale) + .store_lazy_classification(policy_cid, &classification, mode, stale) .await { LazyWrite::Written => { @@ -862,58 +960,154 @@ impl PolicyV3Runtime { ..classification }; if classification.is_final() { - self.owner_authority - .remember_final(®istration.policy_cid, classification.state); + self.owner_authority.remember_final( + policy_cid, + Binding::from_classification(&classification, mode), + ); } - Ok(classification) + classification } + // The winner's closure is read on the next use. LazyWrite::Lost(winner) => { - let classification = Classification::from_row(&winner)?; - self.owner_authority - .remember_final(®istration.policy_cid, classification.state); - Ok(classification) + Classification::from_row(&winner).unwrap_or(Classification { + persisted: true, + ..classification + }) } LazyWrite::Failed => { crate::prometheus::observe_policy_owner_binding_backfill("write_failed_served"); - Ok(classification) + 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 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( + /// What a use of `registration` checks. A final stored classification is + /// loaded once (row and closure) and remembered. A policy without one is + /// classified in the request, served from that result, and stored in the + /// background; a result that isn't final is reused for + /// [`RETRY_BACKOFF`] instead of recomputed. + async fn binding_for_use( &self, registration: &policy_v3_registration::Model, - ) -> Option> { + ) -> Result { let state = &self.owner_authority; let policy_cid = ®istration.policy_cid; - if state.is_final(policy_cid) || state.backing_off(policy_cid) { - return None; + if let Some(binding) = state.cached(policy_cid) { + return Ok(binding); } - { - 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.clone()) { - return None; - } + let existing = policy_v3_owner_binding::Entity::find_by_id(policy_cid.clone()) + .one(&self.conn) + .await + .map_err(|_| "policy-owner-authority-unavailable")?; + if let Some(row) = existing.as_ref().filter(|row| row_is_final(row)) { + let classification = Classification::from_row(row)?; + let closure = match classification.state { + BindingState::Bound | BindingState::BoundUncapped => { + recorded_closure(&self.conn, policy_cid) + .await + .map_err(|_| "policy-owner-authority-unavailable")? + } + BindingState::RootAuthority | BindingState::Unknown => Vec::new(), + }; + let binding = Binding { + state: classification.state, + reason: classification.reason, + mode: RegisteredMode::parse(&row.registered_mode) + .ok_or("policy-owner-binding-invalid")?, + authority_expires_at: classification.authority_expires_at, + closure: closure.into(), + }; + state.remember_final(policy_cid, binding.clone()); + return Ok(binding); } - let runtime = self.clone(); - let registration = registration.clone(); - Some(tokio::spawn(async move { - let result = runtime.owner_binding(®istration).await; - let state = &runtime.owner_authority; - if !result.is_ok_and(|classification| classification.is_final()) { - state.back_off(®istration.policy_cid); + let (classification, mode) = self.classify_registration(registration).await?; + let binding = Binding::from_classification(&classification, mode); + state.remember_transient(policy_cid, binding.clone()); + let claimed = state + .in_flight + .lock() + .expect("owner-authority in-flight") + .insert(policy_cid.clone()); + if claimed { + let runtime = self.clone(); + let policy_cid = policy_cid.clone(); + let stale = existing.is_some(); + tokio::spawn(async move { + runtime + .store_classification(&policy_cid, classification, mode, stale) + .await; + runtime + .owner_authority + .in_flight + .lock() + .expect("owner-authority in-flight") + .remove(&policy_cid); + }); + } + Ok(binding) + } + + /// Check a policy use against its recorded owner authority: every + /// recorded delegation and ancestor must be unrevoked (as of `conn`, the + /// caller's transaction where it has one), and a `bound` authority + /// unexpired. Legacy registrations are never expiry-checked. A policy + /// without authority is denied, or served with root-only checks under + /// `policy_v3.legacy_unknown_allow` when it is legacy. + pub(super) async fn validate_owner_authority( + &self, + conn: &C, + registration: &policy_v3_registration::Model, + now: OffsetDateTime, + ) -> Result { + let binding = self.binding_for_use(registration).await?; + match binding.state { + BindingState::RootAuthority => Ok(OwnerAuthority::default()), + BindingState::Unknown => { + let reason = binding.reason.map_or("missing", UnknownReason::as_str); + if binding.mode == RegisteredMode::Backfill + && self.owner_authority.legacy_unknown_allow + { + crate::prometheus::observe_policy_owner_authority_unknown_allowed(reason); + if self.owner_authority.log_escape(®istration.policy_cid) { + ::tracing::warn!( + event = "policy_owner_authority", + outcome = "unknown_allowed", + policy_cid = %registration.policy_cid, + reason, + "legacy policy without owner authority served under policy_v3.legacy_unknown_allow" + ); + } + return Ok(OwnerAuthority { + closure: Arc::from([]), + escaped: true, + }); + } + crate::prometheus::observe_policy_owner_authority_denied("unknown"); + Err("policy-owner-authority-unknown") } - state - .in_flight - .lock() - .expect("owner-authority in-flight") - .remove(®istration.policy_cid); - })) + BindingState::Bound | BindingState::BoundUncapped => { + let revoked = authority_graph::revoked_ids(conn, &binding.closure) + .await + .map_err(|_| "policy-owner-authority-unavailable")?; + if !revoked.is_empty() { + crate::prometheus::observe_policy_owner_authority_denied("revoked"); + return Err("policy-owner-authority-revoked"); + } + if binding.state == BindingState::Bound + && binding.mode != RegisteredMode::Backfill + && binding + .authority_expires_at + .is_some_and(|expiry| now >= expiry) + { + crate::prometheus::observe_policy_owner_authority_denied("expired"); + return Err("policy-owner-authority-expired"); + } + Ok(OwnerAuthority { + closure: binding.closure, + escaped: false, + }) + } + } } async fn classify_registration( @@ -1284,6 +1478,24 @@ pub async fn authorized_policies( let generically_revoked = authority_graph::revoked_ids(conn, &root_hashes) .await .map_err(listing_unavailable)?; + // Live owner-authority state: a recorded closure with a revoked member + // is `revoked`; a non-legacy `bound` authority past its expiry `expired`. + let mut closures: HashMap> = HashMap::new(); + for row in policy_v3_owner_authority::Entity::find() + .filter(policy_v3_owner_authority::Column::PolicyCid.is_in(policy_cids.clone())) + .all(conn) + .await + .map_err(listing_unavailable)? + { + closures + .entry(row.policy_cid) + .or_default() + .push(row.delegation_id); + } + let closure_ids = closures.values().flatten().copied().collect::>(); + let revoked_authority = authority_graph::revoked_ids(conn, &closure_ids) + .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 { @@ -1310,7 +1522,24 @@ pub async fn authorized_policies( "rootState": if revoked { "revoked" } else if expired { "expired" } else { "active" }, "ownerAuthorityState": bindings .get(policy_cid) - .map(|binding| binding.state.as_str()) + .map(|binding| { + if closures.get(policy_cid).is_some_and(|closure| { + closure.iter().any(|id| revoked_authority.contains(id)) + }) { + "revoked" + } else if binding.state == BindingState::Bound.as_str() + && binding.registered_mode != RegisteredMode::Backfill.as_str() + && binding + .authority_expires_at + .as_deref() + .and_then(|value| parse_time(value).ok()) + .is_some_and(|expiry| expiry <= now) + { + "expired" + } else { + binding.state.as_str() + } + }) .unwrap_or("pending"), })); } @@ -1381,58 +1610,136 @@ mod tests { .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()); + async fn store(runtime: &PolicyV3Runtime, policy_cid: &str, classification: &Classification) { insert_classification( &runtime.conn, - ®istration.policy_cid, - &classification, + 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(); + /// Once a final classification is loaded, a use costs one revocation + /// query for a recorded closure and nothing for root authority. + #[tokio::test] + async fn steady_state_checks_cost_one_query_or_none() { + let (runtime, queries) = counted_runtime().await; + let now = OffsetDateTime::now_utc(); + let root_authority = registration(&runtime, "policy-root-authority").await; + store( + &runtime, + &root_authority.policy_cid, + &Classification::from_selection(Ok(Vec::new()), now), + ) + .await; + let bound = registration(&runtime, "policy-bound").await; + let closure = vec![ClosureNode { + id: tinycloud_core::hash::hash(b"grant"), + depth: 0, + delegator: "did:key:z6MkGrantor".into(), + expiry: None, + not_before: None, + }]; + store( + &runtime, + &bound.policy_cid, + &Classification::from_selection(Ok(closure), now), + ) + .await; + for (registration, steady_queries) in [(&root_authority, 0), (&bound, 1)] { + runtime + .validate_owner_authority(&runtime.conn, registration, now) + .await + .unwrap(); + for _ in 0..5 { + let before = queries.load(Ordering::SeqCst); + runtime + .validate_owner_authority(&runtime.conn, registration, now) + .await + .unwrap(); + assert_eq!( + queries.load(Ordering::SeqCst) - before, + steady_queries, + "{}", + registration.policy_cid + ); + } + } + } - let before = queries.load(Ordering::SeqCst); - for _ in 0..10 { - assert!(runtime.note_owner_binding(®istration).is_none()); + /// An `unknown` legacy classification is denied with a fixed code, or + /// served (escaped) when the operator allows it. + #[tokio::test] + async fn unknown_legacy_classifications_are_denied_unless_allowed() { + for allow in [false, true] { + let (runtime, _) = counted_runtime().await; + let runtime = runtime.with_policy_config(&crate::config::PolicyV3Config { + legacy_unknown_allow: allow, + ..Default::default() + }); + let registration = registration(&runtime, "policy-unknown").await; + insert_classification( + &runtime.conn, + ®istration.policy_cid, + &Classification::unknown(UnknownReason::Missing), + RegisteredMode::Backfill, + OffsetDateTime::now_utc(), + ) + .await + .unwrap(); + let result = runtime + .validate_owner_authority(&runtime.conn, ®istration, OffsetDateTime::now_utc()) + .await; + if allow { + assert!(result.unwrap().escaped); + } else { + assert_eq!(result.unwrap_err(), "policy-owner-authority-unknown"); + } } - tokio::task::yield_now().await; - assert_eq!(queries.load(Ordering::SeqCst), before); } - /// A classification that is not final (here it cannot even be computed) - /// is not retried on every request. + /// A `bound` (non-legacy) authority that has expired is denied with the + /// expiry code; a legacy one is not expiry-checked. #[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") + async fn expired_bound_authority_is_denied_except_legacy() { + for (mode, expected) in [ + ( + RegisteredMode::Record, + Err("policy-owner-authority-expired"), + ), + (RegisteredMode::Backfill, Ok(())), + ] { + let (runtime, _) = counted_runtime().await; + let registration = registration(&runtime, "policy-expiring").await; + let expiry = OffsetDateTime::now_utc() + Duration::seconds(5); + let closure = vec![ClosureNode { + id: tinycloud_core::hash::hash(b"short-grant"), + depth: 0, + delegator: "did:key:z6MkGrantor".into(), + expiry: Some(expiry), + not_before: None, + }]; + insert_classification( + &runtime.conn, + ®istration.policy_cid, + &Classification::from_selection(Ok(closure), expiry), + mode, + OffsetDateTime::now_utc(), + ) .await .unwrap(); - let before = queries.load(Ordering::SeqCst); - for _ in 0..10 { - assert!(runtime.note_owner_binding(®istration).is_none()); + let result = runtime + .validate_owner_authority( + &runtime.conn, + ®istration, + expiry + Duration::seconds(1), + ) + .await + .map(|_| ()); + assert_eq!(result, expected, "{mode:?}"); } - tokio::task::yield_now().await; - assert_eq!(queries.load(Ordering::SeqCst), before); } } diff --git a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs index 9c64ac34..80beed18 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs @@ -364,13 +364,23 @@ impl Signer { /// 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 { + self.invoke_for(self.did(), attenuation, proof) + } + + /// An encoded invocation addressed to `audience`. + pub fn invoke_for( + &self, + audience: &str, + attenuation: Capabilities, + proof: Cid, + ) -> anyhow::Result { + let Self::Key { jwk, vm, .. } = self else { anyhow::bail!("invocations are signed by keys"); }; let at = now(); Ok(Payload { issuer: vm.parse()?, - audience: did.parse()?, + audience: audience.parse()?, not_before: Some(seconds(at)?), expiration: seconds(at + Duration::seconds(30))?, nonce: Some(unique("tc1423invoke")), @@ -709,7 +719,9 @@ impl Harness { "policyId": "pending", "ownerDid": owner_did, "createdAt": format_time(created), - "expiresAt": format_time(root.expiry), + // The policy may outlive its roots; the roots' expiry is what + // the tests exercise. + "expiresAt": format_time(root.expiry + Duration::hours(1)), "contentSource": { "shareId": unique("share-tc-1423-"), "kvResource": self.content_resource.to_string(), @@ -1166,8 +1178,8 @@ impl Harness { /// 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}"); + // The use may be refused once the classification gates it. + self.challenge(policy).await; self.wait_for_binding(&policy.policy_cid, std::time::Duration::from_secs(20)) .await? .ok_or_else(|| anyhow::anyhow!("not classified")) @@ -2480,7 +2492,8 @@ fn text(bytes: &[u8]) -> String { impl Harness { /// The holder decrypts through the network route under a session. pub async fn decrypt(&self, session: &str) -> anyhow::Result<(Status, String)> { - let invocation = self.holder.invoke( + let invocation = self.holder.invoke_for( + &self.node_did(), serde_json::from_value( attenuation_for_policy_capabilities(&[self.requested_decrypt()]) .map_err(|(_, error)| anyhow::anyhow!(error))?, @@ -2518,14 +2531,13 @@ impl Harness { let child = Payload { issuer: vm.parse()?, audience: reader.did().parse()?, + // Whole seconds: sub-microsecond times don't survive PostgreSQL + // timestamps. not_before: Some(NumericDate::try_from_seconds( - (s0.0.not_before.unwrap() + Duration::milliseconds(1)).unix_timestamp_nanos() - as f64 - / 1_000_000_000.0, + (s0.0.not_before.unwrap().unix_timestamp() + 1) as f64, )?), expiration: NumericDate::try_from_seconds( - (s0.0.expiry.unwrap() - Duration::seconds(1)).unix_timestamp_nanos() as f64 - / 1_000_000_000.0, + (s0.0.expiry.unwrap().unix_timestamp() - 1) as f64, )?, nonce: Some(unique("tc1423child")), facts: Some(facts), @@ -2577,11 +2589,7 @@ impl Harness { )?, session_cid(session)?, )?; - let path = self - .content_resource - .path() - .map(ToString::to_string) - .unwrap_or_default(); + let path = "shares/tc-1423/document.txt"; let response = self .client .post("/signed/kv") @@ -2662,8 +2670,10 @@ async fn revoking_the_owner_grant_ends_admitted_sessions(backend: Backend) -> an let (status, body, reader, child) = h.reshare(&session).await?; assert_eq!(status, Status::Ok, "{body}"); let child = child.unwrap(); - assert_eq!(h.read_as(&reader, child).await?.0, Status::Ok); - assert_eq!(h.signed_kv(&session).await?.0, Status::Ok); + let (status, body) = h.read_as(&reader, child).await?; + assert_eq!(status, Status::Ok, "re-shared child read: {body}"); + let (status, body) = h.signed_kv(&session).await?; + assert_eq!(status, Status::Ok, "signed KV: {body}"); h.revoke(&h.space_owner, &authority).await?; @@ -2712,6 +2722,12 @@ async fn revoking_the_agent_session_ends_its_policies(backend: Backend) -> anyho let policy = h.register_ok(policy_window()).await?; let session = h.mint_ok(&policy).await?; assert_eq!(h.read(&session).await?.0, Status::Ok); + // Policies grant only kv and decrypt, so a policy session never holds a + // hook ticket (no stream to close). + let (status, body) = h + .hook_ticket(&[session_cid(&session)?.to_string()]) + .await?; + assert_ne!(status, Status::Ok, "{body}"); h.revoke(&h.space_owner, &agent_session).await?; @@ -2818,9 +2834,12 @@ async fn a_revocation_under_an_alias_cid_ends_sessions(backend: Backend) -> anyh .to_owned(), Query::insert() .into_table(Alias::new("delegation_cid_alias")) - .columns([Alias::new("id"), Alias::new("canonical")]) - .values_panic([canonical.clone().into(), canonical.clone().into()]) - .values_panic([alias.clone().into(), canonical.into()]) + .columns([ + Alias::new("id"), + Alias::new("canonical"), + Alias::new("encrypted"), + ]) + .values_panic([alias.clone().into(), canonical.into(), false.into()]) .to_owned(), Query::insert() .into_table(Alias::new("revocation")) @@ -2926,7 +2945,7 @@ async fn legacy_registrations_without_authority_are_denied_unless_escaped( backend, Shape::KeySession, "legacy_registrations_without_authority_are_denied_unless_escaped", - boot(policy_v3), + boot(policy_v3.clone()), ) .await? else { @@ -2939,6 +2958,9 @@ async fn legacy_registrations_without_authority_are_denied_unless_escaped( let session = h.mint_ok(&policy).await?; h.revoke(&h.space_owner, &authority).await?; h.forget_binding(&policy.policy_cid).await?; + // A fresh process: this one remembers the earlier classification. + let mut h = h; + h.reboot(boot(policy_v3)).await?; let allowed = counter( "tinycloud_policy_owner_authority_unknown_allowed_total", &[("reason", "revoked")], @@ -3285,3 +3307,69 @@ async fn hook_tickets_carry_at_most_16_authorities(backend: Backend) -> anyhow:: Ok(()) } on_both_backends!(hook_tickets_carry_at_most_16_authorities); + +/// [F13] PostgreSQL latency gate for a policy read (the owner-authority +/// check adds one indexed revocation query per read). Ignored by default; +/// run the same test on the baseline and candidate trees: +/// +/// ```sh +/// TINYCLOUD_TEST_POSTGRES_URL=... cargo test -p tinycloud-node --lib \ +/// postgres_policy_read_latency_gate -- --ignored --nocapture +/// ``` +/// +/// With `TC1423_BENCH_BASELINE_P50_US` and `TC1423_BENCH_BASELINE_P99_US` +/// set from the baseline run, it fails when p50 grows by more than 15% (+2 +/// ms) or p99 by more than 25% (+5 ms). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "benchmark; run explicitly against PostgreSQL"] +async fn postgres_policy_read_latency_gate() -> anyhow::Result<()> { + let Some(h) = Harness::new( + Backend::Postgres, + Shape::KeySession, + "postgres_policy_read_latency_gate", + ) + .await? + else { + return Ok(()); + }; + h.grant_owner(Window::until(now() + Duration::days(2))) + .await?; + let policy = h.register_ok(policy_window()).await?; + let session = h.mint_ok(&policy).await?; + for _ in 0..30 { + assert_eq!(h.read(&session).await?.0, Status::Ok); + } + let mut samples = Vec::with_capacity(400); + for _ in 0..400 { + let started = std::time::Instant::now(); + let (status, _) = h.read(&session).await?; + samples.push(started.elapsed().as_micros() as u64); + assert_eq!(status, Status::Ok); + } + samples.sort_unstable(); + let p50 = samples[samples.len() / 2]; + let p99 = samples[samples.len() * 99 / 100]; + println!( + "TC1423_BENCH reads={} p50_us={p50} p99_us={p99}", + samples.len() + ); + let baseline = |name: &str| { + std::env::var(name) + .ok() + .and_then(|value| value.parse::().ok()) + }; + if let (Some(base50), Some(base99)) = ( + baseline("TC1423_BENCH_BASELINE_P50_US"), + baseline("TC1423_BENCH_BASELINE_P99_US"), + ) { + assert!( + p50 <= base50 * 115 / 100 + 2_000, + "p50 {p50} µs vs baseline {base50} µs" + ); + assert!( + p99 <= base99 * 125 / 100 + 5_000, + "p99 {p99} µs vs baseline {base99} µs" + ); + } + Ok(()) +} diff --git a/tinycloud-node-server/src/prometheus.rs b/tinycloud-node-server/src/prometheus.rs index 6cc22950..d9c2c506 100644 --- a/tinycloud-node-server/src/prometheus.rs +++ b/tinycloud-node-server/src/prometheus.rs @@ -422,3 +422,31 @@ pub fn observe_policy_owner_binding_backfill(outcome: &'static str) { .with_label_values(&[outcome]) .inc(); } + +lazy_static! { + static ref POLICY_OWNER_AUTHORITY_DENIED: IntCounterVec = register_int_counter_vec!( + "tinycloud_policy_owner_authority_denied_total", + "Policy/v3 uses denied by the recorded owner authority, by reason.", + &["reason"] + ) + .unwrap(); + static ref POLICY_OWNER_AUTHORITY_UNKNOWN_ALLOWED: IntCounterVec = register_int_counter_vec!( + "tinycloud_policy_owner_authority_unknown_allowed_total", + "Policy/v3 uses of legacy registrations without owner authority served under policy_v3.legacy_unknown_allow, by reason.", + &["reason"] + ) + .unwrap(); +} + +/// `revoked`, `expired` or `unknown`. +pub fn observe_policy_owner_authority_denied(reason: &'static str) { + POLICY_OWNER_AUTHORITY_DENIED + .with_label_values(&[reason]) + .inc(); +} + +pub fn observe_policy_owner_authority_unknown_allowed(reason: &'static str) { + POLICY_OWNER_AUTHORITY_UNKNOWN_ALLOWED + .with_label_values(&[reason]) + .inc(); +} diff --git a/tinycloud-node-server/src/routes/hooks.rs b/tinycloud-node-server/src/routes/hooks.rs index c26add3a..ecb9bcc9 100644 --- a/tinycloud-node-server/src/routes/hooks.rs +++ b/tinycloud-node-server/src/routes/hooks.rs @@ -26,6 +26,7 @@ use rocket::{ use serde::{Deserialize, Serialize}; use std::{future::Future, net::IpAddr, time::Duration, time::Instant}; use time::{format_description::well_known::Rfc3339, OffsetDateTime}; +use tinycloud_auth::ipld_core::cid::Cid; use tinycloud_core::{ events::Invocation, hash::Blake3Hasher, @@ -46,6 +47,9 @@ use tinycloud_core::{ struct VerifiedHookInvocation { admitted: AdmittedInvocation, policy_session: bool, + /// For a policy session, the recorded owner-authority closure it passed; + /// tickets carry it so open streams watch it too. + policy_authority: Vec, } impl VerifiedHookInvocation { @@ -108,13 +112,14 @@ async fn authorize_hook_request( start.elapsed(), ); let admitted = admitted?; - let policy_session = policy_v3 + let policy_authority = policy_v3 .authorize_invocation(tinycloud, &admitted.invocation().0, now) .await .map_err(|error| (Status::Forbidden, error.to_string()))?; Ok(VerifiedHookInvocation { admitted, - policy_session, + policy_session: policy_authority.is_some(), + policy_authority: policy_authority.unwrap_or_default(), }) } @@ -239,6 +244,19 @@ async fn mint_hook_ticket( )); } + let mut authorizing_delegations = invocation.parents.clone(); + for cid in &verified.policy_authority { + if !authorizing_delegations.contains(cid) { + authorizing_delegations.push(*cid); + } + } + // The ticket travels in the stream URL. + if authorizing_delegations.len() > MAX_TICKET_AUTHORITIES { + return Err(( + Status::Forbidden, + "hook-ticket-authority-too-large".to_string(), + )); + } let claims = HookTicketClaims { v: 2, sub: invocation.invoker.clone(), @@ -246,7 +264,7 @@ async fn mint_hook_ticket( iat: now.unix_timestamp(), exp, parent_exp, - authorizing_delegations: invocation.parents.clone(), + authorizing_delegations, }; let ticket = hooks .sign_ticket(&claims) @@ -259,6 +277,63 @@ async fn mint_hook_ticket( Ok(HookTicketResponse { ticket, expires_at }) } +/// Most authority IDs a ticket carries (it travels in the stream URL). +const MAX_TICKET_AUTHORITIES: usize = 16; + +/// Consecutive failed authority checks after which an open stream closes. +const MAX_CONSECUTIVE_CHECK_ERRORS: u32 = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Recheck { + Live, + /// The check failed; keep the stream and check again later. + Retry, + Close, +} + +/// An open stream's authority re-check: before delivering an event at most +/// once per interval, and on an idle timer. It closes the stream on a +/// definite "revoked", or after three consecutive check errors, so a busy +/// database doesn't close streams. +struct AuthorityRecheck { + interval: Duration, + last: rocket::tokio::time::Instant, + consecutive_errors: u32, +} + +impl AuthorityRecheck { + fn new(interval: Duration) -> Self { + Self { + interval, + last: rocket::tokio::time::Instant::now(), + consecutive_errors: 0, + } + } + + fn due(&self) -> bool { + self.last.elapsed() >= self.interval || self.consecutive_errors > 0 + } + + fn record(&mut self, result: Result) -> Recheck { + self.last = rocket::tokio::time::Instant::now(); + match result { + Ok(true) => { + self.consecutive_errors = 0; + Recheck::Live + } + Ok(false) => Recheck::Close, + Err(_) => { + self.consecutive_errors += 1; + if self.consecutive_errors >= MAX_CONSECUTIVE_CHECK_ERRORS { + Recheck::Close + } else { + Recheck::Retry + } + } + } + } +} + async fn hook_stream_deadline( claims: &HookTicketClaims, max_ticket_ttl_seconds: u64, @@ -355,11 +430,18 @@ pub async fn hook_events<'r>( .map_err(|e| (Status::TooManyRequests, e))?; let mut receiver = hooks.bus().subscribe(); + let active_recheck = Duration::from_secs(hooks.config().authority_recheck_seconds); + let idle_recheck = Duration::from_secs(hooks.config().idle_authority_recheck_seconds); let stream = EventStream! { let _lease = lease; let deadline_sleep = rocket::tokio::time::sleep_until(stream_deadline); rocket::tokio::pin!(deadline_sleep); + let mut idle = rocket::tokio::time::interval_at( + rocket::tokio::time::Instant::now() + idle_recheck, + idle_recheck, + ); + let mut recheck = AuthorityRecheck::new(active_recheck); loop { rocket::tokio::select! { @@ -367,10 +449,30 @@ pub async fn hook_events<'r>( _ = &mut deadline_sleep => { break; } + _ = idle.tick() => { + let result = tinycloud + .delegation_chains_unrevoked(&claims.authorizing_delegations) + .await; + if recheck.record(result) == Recheck::Close { + break; + } + } message = receiver.recv() => { match message { Ok(event) => { if claims.scopes.iter().any(|scope| matches_scope(&event, scope)) { + if recheck.due() { + let result = tinycloud + .delegation_chains_unrevoked(&claims.authorizing_delegations) + .await; + match recheck.record(result) { + Recheck::Close => break, + // A failed check withholds this event + // and retries on the next one. + Recheck::Retry => continue, + Recheck::Live => {} + } + } yield Event::json(&event) .id(event.id.clone()) .event("write"); @@ -944,6 +1046,26 @@ async fn find_parent_expiry( #[cfg(test)] mod tests { + #[test] + fn authority_recheck_closes_on_revoked_or_three_consecutive_errors() { + let mut recheck = AuthorityRecheck::new(Duration::from_secs(5)); + assert!(!recheck.due()); + assert_eq!(recheck.record::<()>(Ok(true)), Recheck::Live); + assert_eq!(recheck.record(Err(())), Recheck::Retry); + assert!(recheck.due(), "a failed check is retried on the next event"); + assert_eq!(recheck.record(Err(())), Recheck::Retry); + assert_eq!( + recheck.record::<()>(Ok(true)), + Recheck::Live, + "a success resets" + ); + assert_eq!(recheck.record(Err(())), Recheck::Retry); + assert_eq!(recheck.record(Err(())), Recheck::Retry); + assert_eq!(recheck.record(Err(())), Recheck::Close); + let mut recheck = AuthorityRecheck::new(Duration::from_secs(5)); + assert_eq!(recheck.record::<()>(Ok(false)), Recheck::Close); + } + use super::*; use crate::{ config::HooksConfig, hooks::HookRuntime, @@ -1100,6 +1222,7 @@ mod tests { Ok(VerifiedHookInvocation { admitted: AdmittedInvocation::admit(invocation, 300).await?, policy_session: false, + policy_authority: Vec::new(), }) } diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index 6db70c2c..fc80dbc7 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -1748,7 +1748,8 @@ async fn invoke_impl( let policy_session_invocation = policy_v3 .authorize_invocation(tinycloud, &admitted.invocation().0, now) .await - .map_err(|error| (Status::Forbidden, error.to_string()))?; + .map_err(|error| (Status::Forbidden, error.to_string()))? + .is_some(); let decrypt_network = admitted .invocation() From 3cee65781453e59a809373bd073258800c37d714 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 16:54:36 +0000 Subject: [PATCH 13/41] fix(TC-1423): check the owner and existence of policy source networks at registration Every encryption network a new Policy/v3 names (the content source's and each encryption ceiling entry) must parse, belong to the owner of the content source's space (principal match: EIP-55 case-insensitive, chain id exact) and be active on this node; otherwise registration returns 403 content-source-network-invalid. New registrations with a legacy tinycloud:// kvResource are refused the same way. Refusals are counted in tinycloud_policy_source_network_refused_total{reason}. The Policy/v2 flow fixtures now create the content owner's network they register against. --- CHANGELOG.md | 8 + tinycloud-node-server/src/lib.rs | 1 + tinycloud-node-server/src/policy_v3.rs | 288 ++++++++---------- .../src/policy_v3/source_network_tests.rs | 147 ++++++--- 4 files changed, 240 insertions(+), 204 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f67c4401..f2f44446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Changes + +- `POST /revoke` authorizes the delegation's issuer, the owner of a space its abilities target, or the holder of a revoke control grant issued by one of them. The delegatee alone is no longer an authorized revoker, directly or through a control grant it issued; an intermediate delegator still revokes the delegations it issued. Delegation status visibility is unchanged (TC-1436). +- Revoke and status control grants may name an encryption network (`urn:tinycloud:encryption::`) owned by the control's grantor. Only the network's owner can issue such a control without a parent, because the owner is already the network's root authority (`/delegate` accepted this before); naming the network in a control grants no decrypt or other network ability, so this is not an escalation. Each resource of a control is typed as an exact `urn:cid:`, a `/delegation` root, or a network; any other resource is ignored. A control covers a target only when every one of the target's resources is covered: a space resource by a named space, a network resource by the same parsed network id (owner DID including chain id, and name), so lowercase and EIP-55 checksummed spellings of an address name the same network, and a resource that does not parse as a network is never covered. A control that names a network never counts as an exact-CID control, and a network named by a control issued by someone other than its owner is ignored. A control such as `/delegation` + `/delegation` + `:share` therefore revokes or reads the status of share roots that carry `:share` decrypt, but not grants on `:default` or any other network (TC-1423). +- Every response that carries an `ETag` now also carries `Cache-Control: no-transform` (TC-1432): KV get/head/metadata (200 and 304), KV put, the public KV read routes (200, 304 and metadata), signed-URL reads (200, 206, 304, 416) and the share-email responses that set an `ETag`. A CDN that re-compresses a response must weaken its strong `ETag` to `W/"…"`, and `If-Match` compare-and-swap only accepts the strong `"blake3-"` value. Existing `Cache-Control` directives (`private, no-cache`, `private, no-store`, `public, max-age=60`, `no-store`) are kept and `no-transform` is appended; responses without an `ETag` (KV list, SQL, error responses including 412) are unchanged. Bodies, `ETag` values and status codes are unchanged. +- 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). 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`. The record does not change admission yet. See `docs/policy-owner-authority.md` (TC-1423). +- 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`. Otherwise registration returns `403 content-source-network-invalid` and writes nothing. A new registration whose `kvResource` uses the legacy `tinycloud://` form gets the same refusal; no client emits it. Share and CLI registrations name the space owner's `:default` network, which the SDK creates or finds at sign-in, so they are unaffected; registered policies are not re-checked. Refusals are counted as `tinycloud_policy_source_network_refused_total{reason}`, with reasons `unparsable`, `legacy_resource`, `foreign_owner`, `missing` and `inactive` (TC-1423). + ## [1.21.0] - 2026-10-18 ### Release notes diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index d9f12c4e..bc35ad8c 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -518,6 +518,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. diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 9a36c159..a2a7acbc 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -29,13 +29,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::{ @@ -1849,6 +1850,13 @@ 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") @@ -4613,6 +4621,91 @@ fn validate_policy_encryption_resource(value: &str) -> Result<(), (Status, Strin Ok(()) } +/// Why a new policy's encryption networks were refused; the metric label. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SourceNetworkRefusal { + Unparsable, + LegacyResource, + ForeignOwner, + 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::Missing => "missing", + Self::Inactive => "inactive", + } + } +} + +/// TC-1423 N1b: every encryption network a new policy names (the content +/// source's and every `encryption` ceiling entry) must belong to the owner of +/// the content source's space and be active on this node. +/// +/// A DID is root authority over every network URN that names it, created or +/// not. Without this check, a registrant holding only kv on the content could +/// name a network of its own and hold its decrypt by name. With it, a +/// registrant other than the space owner holds decrypt on these 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. Existence and state use the row key and the `active` rule that +/// decrypt uses. `tinycloud://` sources name no space owner, and no client +/// emits them, so new registrations refuse them. +async fn source_network_refusal( + conn: &C, + policy: &Value, +) -> Result, (Status, String)> { + let source = policy.get("contentSource"); + let kv_resource = source + .and_then(|source| source.get("kvResource")) + .and_then(Value::as_str) + .ok_or((Status::BadRequest, "content-source-invalid".into()))?; + if kv_resource.starts_with("tinycloud://") { + return Ok(Some(SourceNetworkRefusal::LegacyResource)); + } + let resource = kv_resource + .parse::() + .map_err(|_| (Status::BadRequest, "kv-resource-invalid".into()))?; + let content_owner = resource.space().did(); + let ceiling_networks = policy + .get("capabilityCeiling") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|capability| capability.get("kind").and_then(Value::as_str) == Some("encryption")) + .map(|capability| capability.get("resource")); + let networks = std::iter::once(source.and_then(|source| source.get("encryptionNetwork"))) + .chain(ceiling_networks) + .map(|network| network.and_then(Value::as_str)) + .collect::>(); + for network in networks { + let Some(network_id) = network.and_then(|network| network.parse::().ok()) else { + return Ok(Some(SourceNetworkRefusal::Unparsable)); + }; + if !did_principal_matches(network_id.owner_did(), content_owner.as_str()) { + return Ok(Some(SourceNetworkRefusal::ForeignOwner)); + } + 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, @@ -7980,7 +8073,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 @@ -7988,7 +8081,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 @@ -7996,21 +8089,18 @@ 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 (B2, N1b): 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, which needs no grant: a DID is - /// root authority over networks that name it. Registration must refuse - /// it: the source network must exist and belong to the content space's - /// owner, and the owner's decrypt on it must come through its authority - /// chain. Until then this test records what the node does instead and - /// fails. + /// root authority over networks that name it. 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<()> { - run_policy_v2_flow(PolicyOwner::KvOnlySelfNamedNetwork).await + Box::pin(run_policy_v2_flow(PolicyOwner::KvOnlySelfNamedNetwork)).await } #[derive(Clone, Copy, PartialEq, Eq)] @@ -8027,131 +8117,6 @@ mod tests { KvOnlySelfNamedNetwork, } - /// Observations for `policy_source_network_must_belong_to_the_content_owner` - /// once registration was accepted: the recorded owner binding (N1, if - /// present), whether its closure holds the owner's delegation, and what an - /// admitted session gets after the space owner revokes that delegation. - #[allow(clippy::too_many_arguments)] - async fn kv_only_self_network_report( - client: &rocket::local::asynchronous::Client, - policy_cid: &String, - owner_delegation_cid: Option, - content_owner_vm: &String, - content_owner_did: &String, - content_owner_jwk: &JWK, - reader_vm: &String, - reader_did: &String, - reader_jwk: &JWK, - child_cid: tinycloud_auth::ipld_core::cid::Cid, - requested: &Vec, - ) -> anyhow::Result<(String, String, String)> { - use tinycloud_auth::{ - siwe_recap::Ability as RecapAbility, ucan_capabilities_object::Capabilities, - }; - use tinycloud_core::sea_orm::{ConnectionTrait as _, Statement}; - let owner_delegation_cid = owner_delegation_cid - .clone() - .ok_or_else(|| anyhow::anyhow!("missing owner delegation"))?; - let conn = &client.rocket().state::().unwrap().conn; - // What authority the node recorded, if it records any (N1, #358). - let backend = conn.get_database_backend(); - let binding = conn - .query_one(Statement::from_sql_and_values( - backend, - "SELECT state FROM policy_v3_owner_binding WHERE policy_cid = ?", - [policy_cid.clone().into()], - )) - .await; - let binding_state = match binding { - Ok(Some(row)) => row.try_get::("", "state").unwrap_or_default(), - Ok(None) => "no binding row".to_owned(), - Err(_) => "no binding table (pre-N1)".to_owned(), - }; - let owner_hash = tinycloud_core::hash::Hash::from( - owner_delegation_cid.parse::()?, - ); - let closure = conn - .query_one(Statement::from_sql_and_values( - backend, - "SELECT COUNT(*) AS n FROM policy_v3_owner_authority \ - WHERE policy_cid = ? AND delegation_id = ?", - [policy_cid.clone().into(), owner_hash.into()], - )) - .await; - let closure_has_share_root = match closure { - Ok(Some(row)) => format!("{}", row.try_get::("", "n").unwrap_or(-1) > 0), - Ok(None) => "no row".to_owned(), - Err(_) => "no closure table (pre-N1)".to_owned(), - }; - - // The space owner revokes the owner's (kv-only) delegation: the - // share root of the D3 design. - let mut capabilities = Capabilities::::new(); - capabilities.with_action( - format!("urn:cid:{owner_delegation_cid}").parse()?, - "tinycloud.delegation/revoke".parse::()?, - [std::collections::BTreeMap::::new()], - ); - let revocation = tinycloud_auth::authorization::TinyCloudRevocation::Ucan(Box::new( - Payload { - issuer: content_owner_vm.parse()?, - audience: content_owner_did.parse()?, - not_before: None, - expiration: NumericDate::try_from_seconds( - (OffsetDateTime::now_utc() + Duration::seconds(60)).unix_timestamp() as f64, - )?, - nonce: Some("tc1423-kv-only-owner-revoke".into()), - facts: Some(Vec::new()), - proof: Vec::new(), - attenuation: capabilities, - } - .sign(Algorithm::EdDSA, &content_owner_jwk)?, - )); - let revoke_status = client - .post("/revoke") - .header(rocket::http::Header::new( - "Authorization", - revocation.encode()?, - )) - .dispatch() - .await - .status(); - assert_eq!(revoke_status, Status::Ok, "owner revoke of the share root"); - - let reread_now = OffsetDateTime::now_utc(); - let reread = Payload { - issuer: reader_vm.parse()?, - audience: reader_did.parse()?, - not_before: Some(NumericDate::try_from_seconds( - reread_now.unix_timestamp() as f64 - )?), - expiration: NumericDate::try_from_seconds( - (reread_now + Duration::seconds(30)).unix_timestamp() as f64, - )?, - nonce: Some("tc1423-kv-only-reread".into()), - facts: Some(Vec::::new()), - proof: vec![child_cid], - attenuation: serde_json::from_value::>( - attenuation_for_policy_capabilities(&requested) - .map_err(|(_, error)| anyhow::anyhow!(error))?, - )?, - } - .sign(Algorithm::EdDSA, &reader_jwk)?; - let reread_response = client - .post("/invoke") - .header(rocket::http::Header::new("Authorization", reread.encode()?)) - .dispatch() - .await; - let reread_status = reread_response.status(); - let reread_body = reread_response.into_string().await.unwrap_or_default(); - let reread_outcome = if reread_status == Status::Ok { - format!("200, {} bytes (survives)", reread_body.len()) - } else { - format!("{} {reread_body}", reread_status.code) - }; - Ok((binding_state, closure_has_share_root, reread_outcome)) - } - async fn run_policy_v2_flow(policy_owner: PolicyOwner) -> anyhow::Result<()> { use k256::ecdsa::SigningKey; use sha3::Keccak256; @@ -8603,6 +8568,16 @@ 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 N1b). 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?; let rocket = rocket::build() .mount( "/", @@ -8939,13 +8914,21 @@ mod tests { assert!(policy_v3_root::Entity::find().one(conn).await?.is_none()); return Ok(()); } - if policy_owner == PolicyOwner::KvOnlySelfNamedNetwork && register_status != Status::Ok { + if policy_owner == PolicyOwner::KvOnlySelfNamedNetwork { 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}"); @@ -9377,35 +9360,6 @@ mod tests { assert_eq!(read_status, Status::Ok, "read response: {read_body:?}"); assert_eq!(read_body, b"tc-470-real-content"); - if policy_owner == PolicyOwner::KvOnlySelfNamedNetwork { - // Boxed: the shared flow's future is already close to the - // default test-thread stack. - let (binding_state, closure_has_share_root, reread_outcome) = - Box::pin(kv_only_self_network_report( - &client, - &policy_cid, - owner_delegation_cid.clone(), - &content_owner_vm, - content_owner_did, - &content_owner_jwk, - &reader_vm, - &reader_did, - &reader_jwk, - child_cid, - &requested, - )) - .await?; - panic!( - "TC-1423 N1b RED: registration over a self-named network was ACCEPTED \ - (200; source network {encryption_resource} is named by the kv-only \ - policy owner and was never created); the admitted recipient read the \ - content (200, {} bytes); owner binding state: {binding_state}; closure \ - contains the share root: {closure_has_share_root}; read after the space \ - owner revoked the share root: {reread_outcome}", - read_body.len() - ); - } - // TC-541: hooks requests pass the same Policy/v3 gate as `/invoke`. // Policies can only grant `kv` and `encryption` capabilities, so no // policy session can hold `tinycloud.hooks/*`: the reader's chain is diff --git a/tinycloud-node-server/src/policy_v3/source_network_tests.rs b/tinycloud-node-server/src/policy_v3/source_network_tests.rs index e87a80c5..4db14b6a 100644 --- a/tinycloud-node-server/src/policy_v3/source_network_tests.rs +++ b/tinycloud-node-server/src/policy_v3/source_network_tests.rs @@ -71,18 +71,20 @@ struct PkhPrincipal { } impl PkhPrincipal { - fn new(seed: u8) -> anyhow::Result { - let key = SigningKey::from_bytes(&[seed; 32].into())?; - let public = key.verifying_key().to_encoded_point(false); - let digest = Keccak256::digest(&public.as_bytes()[1..]); - let principal = Self { - key, - address: digest[12..].try_into()?, - }; - // The case-insensitivity cases mean something only if the checksum - // has upper-case letters. - assert_ne!(principal.did(1), principal.lowercase_did(1)); - Ok(principal) + /// 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. @@ -104,12 +106,14 @@ fn network(owner_did: &str, name: &str) -> String { } 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, - )) + Ok( + SpaceId::new(owner_did.parse::()?, "applications".parse()?).to_resource( + "kv".parse()?, + Some(KV_PATH.parse()?), + None, + None, + ), + ) } /// The registrant's grants on the content: kv get + metadata on the content @@ -138,6 +142,23 @@ struct Harness { 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, @@ -148,7 +169,6 @@ impl Harness { let vector: Value = serde_json::from_str(include_str!( "../../test-fixtures/tc-470-policy-credential-requirement.json" ))?; - let db = Database::connect("sqlite::memory:").await?; let node_secret = StaticSecret::new(vec![29; 32]).map_err(|_| anyhow::anyhow!("invalid secret"))?; let node_did = node_secret.node_did(); @@ -219,7 +239,12 @@ impl Harness { } fn conn(&self) -> &RecoveringConnection { - &self.client.rocket().state::().unwrap().conn + &self + .client + .rocket() + .state::() + .unwrap() + .conn } async fn post_delegation(&self, authorization: String) -> anyhow::Result { @@ -424,7 +449,9 @@ impl Harness { "createdAt": format_time(issued), "expiresAt": format_time(expires), "contentSource": { - "shareId": "share-tc-1423-n1b", + // 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, @@ -443,10 +470,7 @@ impl Harness { 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["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 @@ -576,7 +600,10 @@ struct KeyOwnerCase { impl KeyOwnerCase { async fn new(decrypt: &[&str]) -> anyhow::Result { - let harness = Harness::new().await?; + 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)?; @@ -639,7 +666,9 @@ async fn source_network_must_exist() -> anyhow::Result<()> { 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?; + 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?; @@ -661,11 +690,15 @@ 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?; + 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?; + case.harness + .create_network(&case.owner.did, "backup") + .await?; assert_registered(case.register(&source, &[&backup]).await?); Ok(()) } @@ -690,7 +723,9 @@ async fn existing_foreign_network_is_refused() -> anyhow::Result<()> { 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?; + case.harness + .create_network(&case.owner.did, "default") + .await?; assert_refused( case.harness.register( &case.session, @@ -708,7 +743,9 @@ async fn legacy_source_resource_is_refused() -> anyhow::Result<()> { 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?; + case.harness + .create_network(&case.owner.did, "default") + .await?; assert_refused( case.register( &source, @@ -723,9 +760,8 @@ async fn unparsable_network_is_refused() -> anyhow::Result<()> { /// 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(name: &str) -> anyhow::Result<()> { - let harness = Harness::new().await?; - let account = PkhPrincipal::new(0x5a)?; +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)); @@ -754,20 +790,20 @@ async fn pkh_owner_registers(name: &str) -> anyhow::Result<()> { /// Released Share: `:default` against a checksummed space. #[tokio::test] async fn share_shape_registers() -> anyhow::Result<()> { - pkh_owner_registers("default").await + 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("share").await + 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::new(0x5a)?; + let account = PkhPrincipal::generate(); let session = KeyPrincipal::generate(); let kv = kv_resource(&account.did(1))?; let other_chain = network(&account.lowercase_did(137), "default"); @@ -789,3 +825,40 @@ async fn chain_id_mismatch_is_refused() -> anyhow::Result<()> { ) .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 + } + } +} + +/// 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(()) +} From 1bfa6cf240bba0bd028a0f61dfa2d62480e0ecdd Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 17:01:16 +0000 Subject: [PATCH 14/41] refactor(TC-1423): split the pure network owner predicate from the existence lookup content_owner_networks (parse, owner, legacy form) is a pure function so stored registrations can be classified with the same rule; source_network_refusal adds the existence and state lookup for new registrations, after every network has passed the owner check. --- tinycloud-node-server/src/policy_v3.rs | 59 ++++++++++++++++---------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index a2a7acbc..5180a342 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -4643,9 +4643,10 @@ impl SourceNetworkRefusal { } } -/// TC-1423 N1b: every encryption network a new policy names (the content -/// source's and every `encryption` ceiling entry) must belong to the owner of -/// the content source's space and be active on this node. +/// TC-1423 N1b: 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; otherwise why not. Pure, so it +/// applies to stored registrations as well as new ones. /// /// A DID is root authority over every network URN that names it, created or /// not. Without this check, a registrant holding only kv on the content could @@ -4655,24 +4656,19 @@ impl SourceNetworkRefusal { /// /// 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. Existence and state use the row key and the `active` rule that -/// decrypt uses. `tinycloud://` sources name no space owner, and no client -/// emits them, so new registrations refuse them. -async fn source_network_refusal( - conn: &C, - policy: &Value, -) -> Result, (Status, String)> { +/// not. `tinycloud://` sources name no space owner, and no client emits them. +fn content_owner_networks(policy: &Value) -> Result, SourceNetworkRefusal> { let source = policy.get("contentSource"); let kv_resource = source .and_then(|source| source.get("kvResource")) .and_then(Value::as_str) - .ok_or((Status::BadRequest, "content-source-invalid".into()))?; + .unwrap_or_default(); if kv_resource.starts_with("tinycloud://") { - return Ok(Some(SourceNetworkRefusal::LegacyResource)); + return Err(SourceNetworkRefusal::LegacyResource); } let resource = kv_resource .parse::() - .map_err(|_| (Status::BadRequest, "kv-resource-invalid".into()))?; + .map_err(|_| SourceNetworkRefusal::Unparsable)?; let content_owner = resource.space().did(); let ceiling_networks = policy .get("capabilityCeiling") @@ -4681,17 +4677,36 @@ async fn source_network_refusal( .flatten() .filter(|capability| capability.get("kind").and_then(Value::as_str) == Some("encryption")) .map(|capability| capability.get("resource")); - let networks = std::iter::once(source.and_then(|source| source.get("encryptionNetwork"))) + std::iter::once(source.and_then(|source| source.get("encryptionNetwork"))) .chain(ceiling_networks) .map(|network| network.and_then(Value::as_str)) - .collect::>(); - for network in networks { - let Some(network_id) = network.and_then(|network| network.parse::().ok()) else { - return Ok(Some(SourceNetworkRefusal::Unparsable)); - }; - if !did_principal_matches(network_id.owner_did(), content_owner.as_str()) { - return Ok(Some(SourceNetworkRefusal::ForeignOwner)); - } + .collect::>() + .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 From b0c4900ca693ac8c1b2d88e166684e282e7be0eb Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 17:15:34 +0000 Subject: [PATCH 15/41] docs(TC-1423): drop the unreachable none bucket from the flip-signal query --- docs/policy-owner-authority.md | 6 +++--- tinycloud-node-server/src/prometheus.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/policy-owner-authority.md b/docs/policy-owner-authority.md index 64c04181..e0b02203 100644 --- a/docs/policy-owner-authority.md +++ b/docs/policy-owner-authority.md @@ -86,11 +86,11 @@ The fallback when the metrics aren't scraped. `registered_at`, `expires_at` and `authority_expires_at` are RFC 3339 UTC text. Q1, the `enforce` flip signal, by authority lifetime (`le_1h`: released Share -signing with a login session; `le_30d`: CLI device sessions): +signing with a login session; `le_30d`: CLI device sessions). A +`bound-uncapped` row always has an authority expiry: ```sql -SELECT CASE WHEN b.authority_expires_at IS NULL THEN 'none' - WHEN b.authority_expires_at::timestamptz - r.registered_at::timestamptz <= interval '1 hour' THEN 'le_1h' +SELECT CASE WHEN b.authority_expires_at::timestamptz - r.registered_at::timestamptz <= interval '1 hour' THEN 'le_1h' WHEN b.authority_expires_at::timestamptz - r.registered_at::timestamptz <= interval '30 days' THEN 'le_30d' ELSE 'gt_30d' END AS authority_ttl_bucket, count(*), count(DISTINCT r.owner_did) diff --git a/tinycloud-node-server/src/prometheus.rs b/tinycloud-node-server/src/prometheus.rs index 6cc22950..11b2f2bf 100644 --- a/tinycloud-node-server/src/prometheus.rs +++ b/tinycloud-node-server/src/prometheus.rs @@ -400,7 +400,7 @@ lazy_static! { } /// Authority lifetime at registration: `le_1h` (released Share, signed by a -/// login session), `le_30d` (CLI device sessions), `gt_30d`, or `none`. +/// login session), `le_30d` (CLI device sessions) or `gt_30d`. pub fn observe_policy_owner_authority_expiry_exceeded(bucket: &'static str) { POLICY_OWNER_AUTHORITY_EXPIRY_EXCEEDED .with_label_values(&[bucket]) From 4a8b42fe7d1a2898aa083fa39f168f7362bf79bf Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 17:48:23 +0000 Subject: [PATCH 16/41] test(TC-1423): kv ceiling entries outside the content owner (RED) From the #363 review (B1): a kv-only key 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. Registration is accepted at 1bfa6cf. Regression tests in the Policy/v2 flow and the registration harness, plus controls: the key sharing its own space, several kv entries all in the owner's spaces, and legacy or unparsable kv entries. --- tinycloud-node-server/src/policy_v3.rs | 64 +++++++- .../src/policy_v3/source_network_tests.rs | 147 +++++++++++++++++- 2 files changed, 202 insertions(+), 9 deletions(-) diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 5180a342..48622313 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -8118,6 +8118,17 @@ mod tests { Box::pin(run_policy_v2_flow(PolicyOwner::KvOnlySelfNamedNetwork)).await } + /// TC-1423 (N1b, review B1): 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; otherwise recipients would read the space owner's content + /// through a policy that outlives the owner's revocation. + #[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)] enum PolicyOwner { /// The policy owner is the content space's owner. @@ -8130,6 +8141,10 @@ mod tests { /// 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<()> { @@ -8184,6 +8199,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(); @@ -8211,12 +8234,15 @@ mod tests { "selector": "exact", "actions": ["tinycloud.kv/get"] })]; - let encryption_resource = if policy_owner == PolicyOwner::KvOnlySelfNamedNetwork { + 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 ceiling = vec![ + let mut ceiling = vec![ json!({ "kind": "kv", "resource": content_resource.to_string(), @@ -8229,9 +8255,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), @@ -8593,6 +8630,18 @@ mod tests { 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( "/", @@ -8706,7 +8755,9 @@ mod tests { // that delegation covers, as the Share and CLI owners do. let owner_delegation_cid = if matches!( policy_owner, - PolicyOwner::DelegatedSession | PolicyOwner::KvOnlySelfNamedNetwork + PolicyOwner::DelegatedSession + | PolicyOwner::KvOnlySelfNamedNetwork + | PolicyOwner::KvOnlyOwnSpaceSource ) { let mut owner_capabilities = Capabilities::::new(); for ability in ["tinycloud.kv/get", "tinycloud.kv/metadata"] { @@ -8929,7 +8980,10 @@ mod tests { assert!(policy_v3_root::Entity::find().one(conn).await?.is_none()); return Ok(()); } - if policy_owner == PolicyOwner::KvOnlySelfNamedNetwork { + if matches!( + policy_owner, + PolicyOwner::KvOnlySelfNamedNetwork | PolicyOwner::KvOnlyOwnSpaceSource + ) { assert_eq!( register_status, Status::Forbidden, diff --git a/tinycloud-node-server/src/policy_v3/source_network_tests.rs b/tinycloud-node-server/src/policy_v3/source_network_tests.rs index 4db14b6a..a07ad4dc 100644 --- a/tinycloud-node-server/src/policy_v3/source_network_tests.rs +++ b/tinycloud-node-server/src/policy_v3/source_network_tests.rs @@ -116,6 +116,11 @@ fn kv_resource(owner_did: &str) -> anyhow::Result { ) } +/// 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)> { @@ -349,11 +354,12 @@ impl Harness { } /// `POST /encryption/networks` as the SDK sends it: the session key - /// invokes `network.create` on `invocation_network` under its CACAO. + /// 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: &str, + proof: Option<&str>, invocation_network: &str, body: Value, ) -> anyhow::Result { @@ -379,7 +385,10 @@ impl Harness { "bodyHash": canonical_hash(&body), "action": NETWORK_CREATE_ACTION, })]), - proof: vec![proof.parse()?], + proof: proof + .map(str::parse) + .into_iter() + .collect::>()?, attenuation, } .sign(Algorithm::EdDSA, &invoker.jwk)?; @@ -429,6 +438,19 @@ impl Harness { 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); @@ -439,6 +461,14 @@ impl Harness { "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})); } @@ -774,7 +804,7 @@ async fn pkh_owner_registers(harness: Harness, name: &str) -> anyhow::Result<()> harness .create_network_via_route( &session, - &cacao, + Some(&cacao), &network(&account.did(1), name), json!({"name": name, "ownerDid": account.did(1), "threshold": {"n": 1, "t": 1}}), ) @@ -826,6 +856,115 @@ async fn chain_id_mismatch_is_refused() -> anyhow::Result<()> { .await } +/// The B1 shape (review of #363): K holds only kv on V's content. K creates a +/// network named after itself (any DID may, with no proof) 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 it 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. A kv entry that doesn't parse never gets this far +/// (`400 kv-resource-invalid`). +#[tokio::test] +async fn legacy_or_unparsable_kv_entry_is_refused() -> anyhow::Result<()> { + let case = KeyOwnerCase::new(&["default"]).await?; + case.harness + .create_network(&case.owner.did, "default") + .await?; + let source = case.network("default"); + let legacy = format!("tinycloud://applications/kv/{KV_PATH}"); + assert_refused( + case.harness.register_full( + &case.session, + &case.kv.to_string(), + &source, + &[], + &[&legacy], + ), + "legacy_resource", + ) + .await?; + let (status, body) = case + .harness + .register_full( + &case.session, + &case.kv.to_string(), + &source, + &[], + &["not-a-resource"], + ) + .await?; + assert_eq!( + (status, body.as_str()), + (Status::BadRequest, "kv-resource-invalid") + ); + Ok(()) +} + fn postgres_url(test: &str) -> Option { match std::env::var("TINYCLOUD_TEST_POSTGRES_URL") { Ok(url) if !url.trim().is_empty() => Some(url), From 85fee4a6d976da6bd8096d2b40b4e59f5aed58d6 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 17:51:36 +0000 Subject: [PATCH 17/41] fix(TC-1423): every kv ceiling entry must name a space of the content owner Review B1 on #363: the network check bound the networks to the owner of contentSource.kvResource only, so a kv-only key could move the source to its own space on its own network and add the space owner's content as a second kv entry. content_owner_networks now also requires every kv ceiling entry to be non-legacy, to parse, and to name a space of the content source's owner (refusal reason foreign_space); a ceiling entry of any other kind fails it. The predicate stays pure, so N1's classifier reuses it unchanged. --- CHANGELOG.md | 2 +- tinycloud-node-server/src/policy_v3.rs | 74 ++++++++++++------- .../src/policy_v3/source_network_tests.rs | 57 +++++++++----- tinycloud-node-server/src/prometheus.rs | 9 ++- 4 files changed, 92 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2f44446..1a1aa325 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ - Revoke and status control grants may name an encryption network (`urn:tinycloud:encryption::`) owned by the control's grantor. Only the network's owner can issue such a control without a parent, because the owner is already the network's root authority (`/delegate` accepted this before); naming the network in a control grants no decrypt or other network ability, so this is not an escalation. Each resource of a control is typed as an exact `urn:cid:`, a `/delegation` root, or a network; any other resource is ignored. A control covers a target only when every one of the target's resources is covered: a space resource by a named space, a network resource by the same parsed network id (owner DID including chain id, and name), so lowercase and EIP-55 checksummed spellings of an address name the same network, and a resource that does not parse as a network is never covered. A control that names a network never counts as an exact-CID control, and a network named by a control issued by someone other than its owner is ignored. A control such as `/delegation` + `/delegation` + `:share` therefore revokes or reads the status of share roots that carry `:share` decrypt, but not grants on `:default` or any other network (TC-1423). - Every response that carries an `ETag` now also carries `Cache-Control: no-transform` (TC-1432): KV get/head/metadata (200 and 304), KV put, the public KV read routes (200, 304 and metadata), signed-URL reads (200, 206, 304, 416) and the share-email responses that set an `ETag`. A CDN that re-compresses a response must weaken its strong `ETag` to `W/"…"`, and `If-Match` compare-and-swap only accepts the strong `"blake3-"` value. Existing `Cache-Control` directives (`private, no-cache`, `private, no-store`, `public, max-age=60`, `no-store`) are kept and `no-transform` is appended; responses without an `ETag` (KV list, SQL, error responses including 412) are unchanged. Bodies, `ETag` values and status codes are unchanged. - 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). 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`. The record does not change admission yet. See `docs/policy-owner-authority.md` (TC-1423). -- 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`. Otherwise registration returns `403 content-source-network-invalid` and writes nothing. A new registration whose `kvResource` uses the legacy `tinycloud://` form gets the same refusal; no client emits it. Share and CLI registrations name the space owner's `:default` network, which the SDK creates or finds at sign-in, so they are unaffected; registered policies are not re-checked. Refusals are counted as `tinycloud_policy_source_network_refused_total{reason}`, with reasons `unparsable`, `legacy_resource`, `foreign_owner`, `missing` and `inactive` (TC-1423). +- 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; registered policies are not re-checked. Refusals are counted as `tinycloud_policy_source_network_refused_total{reason}`, with reasons `unparsable`, `legacy_resource`, `foreign_owner`, `foreign_space`, `missing` and `inactive` (TC-1423). ## [1.21.0] - 2026-10-18 diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 48622313..ae03f5bc 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -4621,12 +4621,13 @@ fn validate_policy_encryption_resource(value: &str) -> Result<(), (Status, Strin Ok(()) } -/// Why a new policy's encryption networks were refused; the metric label. +/// 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, } @@ -4637,6 +4638,7 @@ impl SourceNetworkRefusal { Self::Unparsable => "unparsable", Self::LegacyResource => "legacy_resource", Self::ForeignOwner => "foreign_owner", + Self::ForeignSpace => "foreign_space", Self::Missing => "missing", Self::Inactive => "inactive", } @@ -4645,42 +4647,64 @@ impl SourceNetworkRefusal { /// TC-1423 N1b: 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; otherwise why not. Pure, so it -/// applies to stored registrations as well as new ones. +/// 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 DID is root authority over every network URN that names it, created or -/// not. Without this check, a registrant holding only kv on the content could -/// name a network of its own and hold its decrypt by name. With it, a -/// registrant other than the space owner holds decrypt on these networks only +/// not, and any DID may create such a network. Without this check, a +/// registrant holding only kv on someone's content could hand that content +/// out under a network of its own: named in the source, or with the source +/// moved to its own space and the content added as a second kv entry. With +/// it, every resource the policy grants belongs to one owner, and a +/// registrant other than that owner holds decrypt on its 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://` sources name no space owner, and no client emits them. +/// 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 kv_resource = source - .and_then(|source| source.get("kvResource")) - .and_then(Value::as_str) - .unwrap_or_default(); - if kv_resource.starts_with("tinycloud://") { - return Err(SourceNetworkRefusal::LegacyResource); - } - let resource = kv_resource - .parse::() - .map_err(|_| SourceNetworkRefusal::Unparsable)?; - let content_owner = resource.space().did(); - let ceiling_networks = policy + 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() - .filter(|capability| capability.get("kind").and_then(Value::as_str) == Some("encryption")) - .map(|capability| capability.get("resource")); - std::iter::once(source.and_then(|source| source.get("encryptionNetwork"))) - .chain(ceiling_networks) - .map(|network| network.and_then(Value::as_str)) - .collect::>() + { + 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 diff --git a/tinycloud-node-server/src/policy_v3/source_network_tests.rs b/tinycloud-node-server/src/policy_v3/source_network_tests.rs index a07ad4dc..9ca7a0eb 100644 --- a/tinycloud-node-server/src/policy_v3/source_network_tests.rs +++ b/tinycloud-node-server/src/policy_v3/source_network_tests.rs @@ -927,42 +927,59 @@ async fn kv_entries_all_owned_by_the_content_owner_register() -> anyhow::Result< } /// A legacy `tinycloud://` kv entry names no space owner: refused like a -/// legacy source. A kv entry that doesn't parse never gets this far -/// (`400 kv-resource-invalid`). +/// legacy source. #[tokio::test] -async fn legacy_or_unparsable_kv_entry_is_refused() -> anyhow::Result<()> { +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 source = case.network("default"); let legacy = format!("tinycloud://applications/kv/{KV_PATH}"); assert_refused( case.harness.register_full( &case.session, &case.kv.to_string(), - &source, + &case.network("default"), &[], &[&legacy], ), "legacy_resource", ) - .await?; - let (status, body) = case - .harness - .register_full( - &case.session, - &case.kv.to_string(), - &source, - &[], - &["not-a-resource"], - ) - .await?; - assert_eq!( - (status, body.as_str()), - (Status::BadRequest, "kv-resource-invalid") + .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() ); - 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 { diff --git a/tinycloud-node-server/src/prometheus.rs b/tinycloud-node-server/src/prometheus.rs index f36e24f7..0da892fe 100644 --- a/tinycloud-node-server/src/prometheus.rs +++ b/tinycloud-node-server/src/prometheus.rs @@ -33,9 +33,10 @@ pub fn observe_did_resolution_refused(method: &str, reason: &str) { } /// A Policy/v3 registration refused because one of its encryption networks -/// is not the content owner's active network on this node. `reason` is a -/// fixed label: `unparsable`, `legacy_resource`, `foreign_owner`, `missing` -/// or `inactive`. +/// 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]) @@ -50,7 +51,7 @@ lazy_static! { ).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, by reason.", + "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(); From 058eee017e4cf62da83f167c4b342a0beb461afc Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 17:59:07 +0000 Subject: [PATCH 18/41] test(TC-1423): pick the loop-back fixture's target with high_id The general-window loop-back test chose its target id from one signed delegation, so on rare runs no fresh delegation sorted before it. Every window-ordering fixture now picks its target with high_id. --- tinycloud-node-server/src/policy_v3/owner_authority_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs index 9911c5bd..cba272e5 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs @@ -1353,7 +1353,7 @@ async fn loop_back_delegations_are_skipped_in_the_general_window( ) .await?; let authority_encoded = - intermediary.delegate(h.owner.did(), &grants, window, &[upstream.parse()?])?; + 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(); From c7d587ed8172611dea01a867201330daf21128a4 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 17:59:21 +0000 Subject: [PATCH 19/41] test(TC-1423): import re-shared children once they are valid The re-share fixture signs the child from the next whole second after the session starts (PostgreSQL keeps microseconds); it now waits for that second before importing, instead of racing it. --- .../src/policy_v3/owner_authority_tests.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs index 2227b457..776b147b 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs @@ -2608,6 +2608,7 @@ impl Harness { ) -> anyhow::Result<(Status, String, Signer, Option)> { let s0 = decode_delegation(session).map_err(|(_, error)| anyhow::anyhow!(error))?; let reader = Signer::key(); + let child_not_before = s0.0.not_before.unwrap().unix_timestamp() + 1; let mut facts = match &s0.0.delegation { TinyCloudDelegation::Ucan(ucan) => ucan.payload().facts.clone().unwrap(), TinyCloudDelegation::Cacao(_) => unreachable!(), @@ -2619,11 +2620,9 @@ impl Harness { let child = Payload { issuer: vm.parse()?, audience: reader.did().parse()?, - // Whole seconds: sub-microsecond times don't survive PostgreSQL - // timestamps. - not_before: Some(NumericDate::try_from_seconds( - (s0.0.not_before.unwrap().unix_timestamp() + 1) as f64, - )?), + // Whole seconds (sub-microsecond times don't survive PostgreSQL + // timestamps), after the parent's start; we wait for it below. + not_before: Some(NumericDate::try_from_seconds(child_not_before as f64)?), expiration: NumericDate::try_from_seconds( (s0.0.expiry.unwrap().unix_timestamp() - 1) as f64, )?, @@ -2636,6 +2635,11 @@ impl Harness { )?, } .sign(Algorithm::EdDSA, jwk)?; + // The child must be valid when it is imported. + let wait = child_not_before - OffsetDateTime::now_utc().unix_timestamp(); + if wait >= 0 { + tokio::time::sleep(std::time::Duration::from_millis(wait as u64 * 1000 + 50)).await; + } let (status, body) = self .delegate(TinyCloudDelegation::Ucan(Box::new(child)).encode()?) .await; From 9c65a0e1132624bea76ab09636796b924c02d79b Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 18:32:02 +0000 Subject: [PATCH 20/41] fix(TC-1423): close review gaps in owner-authority checks - Hook streams: after a failed authority check, the next check waits the interval; events are withheld meanwhile without querying, so a burst during a brief database error no longer closes a healthy stream. - First-use classification is single-flight per policy: concurrent uses share one computation, and a use arriving just after it reuses the in-memory result. - Tests: the revoked-stream test matches rocket's `event:write`, has a positive control and no idle re-check, so the pre-event re-check is what closes it; S0 re-import, delivery, the escape's legacy-only scope and the escaped mint's rescan each get a test; concurrent first uses count one classification. The policy flow tests box their future. - CHANGELOG: delivery keeps `delivery-authorization-invalid`. Docs note that `policy-owner-authority-expired` is not reached by stored rows. --- CHANGELOG.md | 2 +- docs/policy-owner-authority.md | 11 +- tinycloud-node-server/src/policy_v3.rs | 42 +++- .../src/policy_v3/owner_authority.rs | 82 +++++++- .../src/policy_v3/owner_authority_tests.rs | 196 +++++++++++++++++- tinycloud-node-server/src/routes/hooks.rs | 25 ++- 6 files changed, 336 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31141a4b..663d9144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - 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). 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`. The record does not change admission yet. See `docs/policy-owner-authority.md` (TC-1423). - Policy/v3 registration refuses a root that expires later than registration + 31 days (+5 minutes) (`403 policy-root-lifetime-exceeded`), in both modes; released SDK roots pass. New `policy_v3.owner_authority_expiry`: `record` (default) accepts a root that outlives the owner authority and counts it, `enforce` refuses it (`403 policy-root-outlives-owner-authority`). Earlier registrations are never capped; roots over 31 days are counted. `GET /info` lists `policy-owner-authority-v1` and one of `policy-owner-authority-record` / `policy-owner-authority-enforce`. New counters `tinycloud_policy_owner_authority_expiry_exceeded_total{authority_ttl_bucket}`, `tinycloud_policy_root_lifetime_refused_total`, `tinycloud_policy_legacy_root_lifetime_exceeded_total`, `tinycloud_policy_owner_binding_backfill_total{outcome}`; counting queries in `docs/policy-owner-authority.md` (TC-1423). -- Policy/v3 uses check the recorded owner authority: once a recorded delegation or any ancestor is revoked (also under an alias CID), admitted sessions stop reading and decrypting, and re-share admission, signed-KV and hook-ticket mints, challenge, mint, S0 admission and delivery are refused (`403 policy-owner-authority-revoked`); a non-legacy `bound` authority past its expiry gives `policy-owner-authority-expired`; a policy without authority gives `policy-owner-authority-unknown`, unless it is legacy and `policy_v3.legacy_unknown_allow` (default off, logged) serves it with root-only checks. The mint's registration-time rescan runs only for such escaped policies. An expired root now returns `root-expired` instead of `root-not-live`. Hook tickets carry a policy session's authority closure (at most 16 IDs, else `403 hook-ticket-authority-too-large`), and open hook streams re-check their authority before delivering (at most every `hooks.authority_recheck_seconds`, 5) and while idle (`hooks.idle_authority_recheck_seconds`, 30), closing on a revocation or three consecutive failed checks; `delegation_chains_unrevoked` is now two queries at any depth. New counters `tinycloud_policy_owner_authority_denied_total{reason}` and `tinycloud_policy_owner_authority_unknown_allowed_total{reason}` (TC-1423). +- Policy/v3 uses check the recorded owner authority: once a recorded delegation or any ancestor is revoked (also under an alias CID), admitted sessions stop reading and decrypting, and re-share admission, signed-KV and hook-ticket mints, challenge, mint and S0 admission are refused (`403 policy-owner-authority-revoked`), and so is delivery (`403 delivery-authorization-invalid`); a non-legacy `bound` authority past its expiry gives `policy-owner-authority-expired`; a policy without authority gives `policy-owner-authority-unknown`, unless it is legacy and `policy_v3.legacy_unknown_allow` (default off, logged) serves it with root-only checks. The mint's registration-time rescan runs only for such escaped policies. An expired root now returns `root-expired` instead of `root-not-live`. Hook tickets carry a policy session's authority closure (at most 16 IDs, else `403 hook-ticket-authority-too-large`), and open hook streams re-check their authority before delivering (at most every `hooks.authority_recheck_seconds`, 5) and while idle (`hooks.idle_authority_recheck_seconds`, 30), closing on a revocation or three consecutive failed checks spaced an interval apart (events are withheld meanwhile); concurrent first uses of an unclassified policy share one classification; `delegation_chains_unrevoked` is now two queries at any depth. New counters `tinycloud_policy_owner_authority_denied_total{reason}` and `tinycloud_policy_owner_authority_unknown_allowed_total{reason}` (TC-1423). ## [1.21.0] - 2026-10-18 diff --git a/docs/policy-owner-authority.md b/docs/policy-owner-authority.md index f021f8a9..231aa971 100644 --- a/docs/policy-owner-authority.md +++ b/docs/policy-owner-authority.md @@ -254,6 +254,7 @@ liveness is not cached. A steady-state use costs one indexed revocation query for a recorded closure and none for root authority. A policy without a final classification is classified in the request, served from that result, and stored in the background; a result that isn't final is reused for 60 seconds. +Concurrent first uses of one policy share a single classification. A revocation takes effect on commit; a request already past the check may finish. Signed-KV tickets minted before a revocation last at most 60 seconds. @@ -261,6 +262,10 @@ finish. Signed-KV tickets minted before a revocation last at most 60 seconds. Other codes: an expired root now gives `root-expired` (other non-live status remains `root-not-live`); S0 admission returns the owner-authority code itself; delivery keeps `delivery-authorization-invalid`. No code carries a CID or DID. +`policy-owner-authority-expired` is not reached by stored rows: a `bound` +root expires no later than its authority, and the root and session expiry +checks run first (`policy-session-expired` on reads, `root-expired` on +challenge and mint). It stays as a defence in depth. ### Legacy escape @@ -284,7 +289,9 @@ refused (`403 hook-ticket-authority-too-large`). An open `/hooks/events` stream re-checks them (one closure query plus one revocation query) before delivering an event, at most every `hooks.authority_recheck_seconds` (5), and every `hooks.idle_authority_recheck_seconds` (30) while idle. It closes on a -revocation, or after three consecutive failed checks; a failed check withholds -that event but keeps the stream. Streams don't see a JSON-route +revocation, or after three consecutive failed checks, each at least an +interval after the last; until a check succeeds, events are withheld (dropped) +without querying, so a burst of events during a brief database error doesn't +close the stream. Streams don't see a JSON-route `RootRevocation` (it writes no core revocation) or an authority expiry; those end at the ticket deadline (at most 300 seconds), as before. diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 40f7866a..bf94c08e 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -2636,6 +2636,8 @@ pub async fn mint( .await .map_err(|error| (Status::Forbidden, error.into()))?; // The recorded owner authority, rechecked on the locked transaction. + // Only a revoke landing between the earlier check and this + // transaction reaches it, so no test pins it (it is race-only). if !tinycloud_core::policy_owner_authority::revoked_ids(&txn, &owner_authority.closure) .await .map_err(db_error)? @@ -8103,7 +8105,8 @@ mod tests { #[tokio::test] async fn policy_v2_admits_v3_account_and_v4_accountless_receivers() -> anyhow::Result<()> { - run_policy_v2_flow(PolicyOwner::SpaceOwner).await + // Boxed: the flow's future is too large for a test thread's stack. + Box::pin(run_policy_v2_flow(PolicyOwner::SpaceOwner)).await } /// The real Share and CLI shape: the policy owner is a session key the @@ -8111,7 +8114,8 @@ 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 + // Boxed: the flow's future is too large for a test thread's stack. + Box::pin(run_policy_v2_flow(PolicyOwner::DelegatedSession)).await } /// The policy owner signs a valid, self-consistent policy over content in @@ -8119,7 +8123,8 @@ 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 + // Boxed: the flow's future is too large for a test thread's stack. + Box::pin(run_policy_v2_flow(PolicyOwner::Unauthorized)).await } #[derive(Clone, Copy, PartialEq, Eq)] @@ -9852,6 +9857,37 @@ mod tests { challenge_response.into_string().await.unwrap_or_default(), "policy-owner-authority-revoked" ); + // Delivery checks the same authority: the retry that returned the + // stored receipt above is now refused. + let owner_revoked_delivery = make_invocation( + [( + content_resource.clone(), + ["tinycloud.kv/get".parse::()?], + )], + &sender_cid, + &delivery_jwk, + &format!("{holder_did}#{}", holder_did.trim_start_matches("did:key:")), + (OffsetDateTime::now_utc() + Duration::seconds(45)).unix_timestamp() as f64, + InvocationOptions { + nonce: Some("tc1423-delivery-after-owner-revoke".into()), + ..InvocationOptions::default() + }, + )?; + let refused_delivery = client + .post("/policy/v3/deliveries/authorize") + .header(ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + owner_revoked_delivery.encode()?, + )) + .body(serde_json::to_string(&delivery_request)?) + .dispatch() + .await; + assert_eq!(refused_delivery.status(), Status::Forbidden); + assert_eq!( + refused_delivery.into_string().await.unwrap_or_default(), + "delivery-authorization-invalid" + ); } // SDK revocation writes the generic graph. Policy/v3 joins that diff --git a/tinycloud-node-server/src/policy_v3/owner_authority.rs b/tinycloud-node-server/src/policy_v3/owner_authority.rs index 2220afbe..9c531a95 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority.rs @@ -732,6 +732,22 @@ pub(super) struct OwnerAuthorityState { background: BackgroundSlots, /// When each escaped policy was last logged. escape_logged: std::sync::Mutex>, + /// Request-path classifications running, one per policy; concurrent uses + /// of the policy wait for it and share its result. + classifying: std::sync::Mutex>>>, + /// Classifications computed (tests count them). + #[cfg(test)] + pub(super) classifications: std::sync::atomic::AtomicUsize, +} + +type ClassifyResult = Result<(Classification, RegisteredMode), &'static str>; + +/// Where a use's classification came from. +enum Classified { + /// Remembered from a classification that finished meanwhile. + InMemory(Binding), + /// Computed (or awaited) by this use. + Computed(Classification, RegisteredMode), } /// Insert into a bounded map, clearing it when full. @@ -1078,9 +1094,11 @@ impl PolicyV3Runtime { state.remember_final(policy_cid, binding.clone()); return Ok(binding); } - let (classification, mode) = self.classify_registration(registration).await?; + let (classification, mode) = match self.classify_once(registration).await? { + Classified::InMemory(binding) => return Ok(binding), + Classified::Computed(classification, mode) => (classification, mode), + }; let binding = Binding::from_classification(&classification, mode); - state.remember_transient(policy_cid, binding.clone()); // The write runs in the background (one per policy, a few at once). // Without a free slot this request still uses its result; the write // is retried once the in-memory result expires. @@ -1098,6 +1116,60 @@ impl PolicyV3Runtime { Ok(binding) } + /// Classify a policy for a use, one computation at a time per policy: + /// concurrent uses wait for it and share its result. A result is + /// remembered in memory before the computation's entry is dropped, and a + /// use that finds no entry checks memory under the same lock, so a use + /// arriving just after a computation reuses it rather than starting + /// another. No lock is held across an await; if the computing request is + /// cancelled, a waiting one takes over. + async fn classify_once( + &self, + registration: &policy_v3_registration::Model, + ) -> Result { + let state = &self.owner_authority; + let policy_cid = ®istration.policy_cid; + let cell = { + let mut classifying = state + .classifying + .lock() + .expect("owner-authority classifications"); + match classifying.get(policy_cid) { + Some(cell) => cell.clone(), + None => { + if let Some(binding) = state.cached(policy_cid) { + return Ok(Classified::InMemory(binding)); + } + classifying.entry(policy_cid.clone()).or_default().clone() + } + } + }; + let result = cell + .get_or_init(|| async { + let result = self.classify_registration(registration).await; + if let Ok((classification, mode)) = &result { + state.remember_transient( + policy_cid, + Binding::from_classification(classification, *mode), + ); + } + result + }) + .await + .clone(); + let mut classifying = state + .classifying + .lock() + .expect("owner-authority classifications"); + if classifying + .get(policy_cid) + .is_some_and(|current| Arc::ptr_eq(current, &cell)) + { + classifying.remove(policy_cid); + } + result.map(|(classification, mode)| Classified::Computed(classification, mode)) + } + /// Check a policy use against its recorded owner authority: every /// recorded delegation and ancestor must be unrevoked (as of `conn`, the /// caller's transaction where it has one), and a `bound` authority @@ -1164,7 +1236,11 @@ impl PolicyV3Runtime { async fn classify_registration( &self, registration: &policy_v3_registration::Model, - ) -> Result<(Classification, RegisteredMode), &'static str> { + ) -> ClassifyResult { + #[cfg(test)] + self.owner_authority + .classifications + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); let registered_at = parse_time(®istration.registered_at).map_err(|_| "policy-registration-invalid")?; let (cutover, _) = self diff --git a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs index cee6a151..9dd2619e 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs @@ -3130,6 +3130,170 @@ async fn legacy_registrations_are_never_expiry_capped(backend: Backend) -> anyho } on_both_backends!(legacy_registrations_are_never_expiry_capped); +/// [O2] The escape covers legacy rows only: a post-cutover row without +/// authority is denied with the flag on. +async fn the_escape_never_serves_post_cutover_rows(backend: Backend) -> anyhow::Result<()> { + let Some(mut h) = Harness::new( + backend, + Shape::KeySession, + "the_escape_never_serves_post_cutover_rows", + ) + .await? + else { + return Ok(()); + }; + let registered_at = now() - Duration::days(40); + h.grant_owner(Window { + not_before: registered_at - Duration::days(1), + expiry: now() + Duration::days(2), + issued_at: None, + }) + .await?; + let policy = h.register_ok(policy_window()).await?; + let session = h.mint_ok(&policy).await?; + h.forget_binding(&policy.policy_cid).await?; + backdate(&h, &policy.policy_cid, registered_at).await?; + h.reboot(boot(json!({ + "legacy_cutover": format_time(now() - Duration::days(60)), + "legacy_unknown_allow": true, + }))) + .await?; + let (status, body) = h.read(&session).await?; + assert_eq!( + (status, text(&body).as_str()), + (Status::Forbidden, "policy-owner-authority-unknown") + ); + let binding = h.binding(&policy.policy_cid).await?; + let binding = match binding { + Some(binding) => binding, + None => h + .wait_for_binding(&policy.policy_cid, std::time::Duration::from_secs(20)) + .await? + .expect("classified"), + }; + assert_eq!( + ( + binding.registered_mode.as_str(), + binding.unknown_reason.as_deref() + ), + ("record", Some("lifetime-exceeded")) + ); + Ok(()) +} +on_both_backends!(the_escape_never_serves_post_cutover_rows); + +/// [O2] An escaped legacy policy serves its admitted sessions, but a mint +/// still needs the owner to have held what the policy grants when it +/// registered. +async fn escaped_mints_still_check_the_owner_held_the_capability( + backend: Backend, +) -> anyhow::Result<()> { + let escape = || { + boot(json!({ + "legacy_cutover": format_time(now() + Duration::days(1)), + "legacy_unknown_allow": true, + })) + }; + let Some(mut h) = Harness::with_boot( + backend, + Shape::KeySession, + "escaped_mints_still_check_the_owner_held_the_capability", + escape(), + ) + .await? + else { + return Ok(()); + }; + h.grant_owner(Window { + not_before: now() - Duration::seconds(30), + expiry: now() + Duration::days(2), + issued_at: None, + }) + .await?; + let policy = h.register_ok(policy_window()).await?; + let session = h.mint_ok(&policy).await?; + h.forget_binding(&policy.policy_cid).await?; + // Registered before the owner held the grant: nothing to record. + backdate(&h, &policy.policy_cid, now() - Duration::hours(1)).await?; + h.reboot(escape()).await?; + let (status, body) = h.read(&session).await?; + assert_eq!((status, body.as_slice()), (Status::Ok, CONTENT), "escaped"); + let (status, body) = h.mint(&policy).await?.expect_err("mint refused"); + assert_eq!( + (status, body.as_str()), + (Status::Forbidden, "policy-owner-not-authorized") + ); + Ok(()) +} +on_both_backends!(escaped_mints_still_check_the_owner_held_the_capability); + +/// [8] An admitted session can't be imported again once its owner authority +/// is revoked. +async fn admitted_sessions_are_not_reimported_after_a_revoke( + backend: Backend, +) -> anyhow::Result<()> { + let Some(h) = Harness::new( + backend, + Shape::KeySession, + "admitted_sessions_are_not_reimported_after_a_revoke", + ) + .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 session = h.mint_ok(&policy).await?; + let (status, body) = h.delegate(session.clone()).await; + assert_eq!(status, Status::Ok, "before the revoke: {body}"); + h.revoke(&h.space_owner, &authority).await?; + let (status, body) = h.delegate(session).await; + assert_eq!((status, body.as_str()), (Status::Forbidden, REVOKED)); + Ok(()) +} +on_both_backends!(admitted_sessions_are_not_reimported_after_a_revoke); + +/// [F3] Concurrent first uses of an unclassified policy share one +/// classification. +async fn concurrent_first_uses_share_one_classification(backend: Backend) -> anyhow::Result<()> { + let Some(mut h) = Harness::with_boot( + backend, + Shape::KeySession, + "concurrent_first_uses_share_one_classification", + legacy_boot(), + ) + .await? + else { + return Ok(()); + }; + h.grant_owner(Window::until(now() + Duration::days(2))) + .await?; + let policy = h.register_ok(policy_window()).await?; + let session = h.mint_ok(&policy).await?; + h.forget_binding(&policy.policy_cid).await?; + h.reboot(legacy_boot()).await?; + let classifications = || { + h.client + .rocket() + .state::() + .expect("policy runtime") + .owner_authority + .classifications + .load(std::sync::atomic::Ordering::Relaxed) + }; + let before = classifications(); + let reads = futures::future::join_all((0..8).map(|_| h.read(&session))).await; + for read in reads { + let (status, body) = read?; + assert_eq!((status, body.as_slice()), (Status::Ok, CONTENT)); + } + assert_eq!(classifications() - before, 1); + Ok(()) +} +on_both_backends!(concurrent_first_uses_share_one_classification); + impl Harness { /// The space owner grants the holder hooks subscription on the content /// prefix; returns the grant CIDs. @@ -3259,6 +3423,20 @@ fn fast_hooks() -> Boot { } } +/// Re-checks before events only: the idle re-check never runs in a test. +fn event_rechecks_only() -> Boot { + Boot { + hooks: Some(json!({ + "authority_recheck_seconds": 1, + "idle_authority_recheck_seconds": 600, + })), + ..Boot::default() + } +} + +/// The SSE field rocket writes for each delivered hook event. +const WRITE_EVENT: &str = "event:write"; + /// Connect a hook stream (its authority is checked here). async fn open_stream<'c>( h: &'c Harness, @@ -3285,14 +3463,15 @@ async fn drain_stream( } /// [8][C3] An open hook stream stops delivering after its grant is revoked: -/// no event after the re-check interval, and the stream closes; an idle -/// stream closes on the idle re-check. +/// the next event after the re-check interval is withheld and the stream +/// closes (with no idle re-check to close it otherwise); an idle stream +/// closes on the idle re-check. async fn hook_streams_close_after_their_grant_is_revoked(backend: Backend) -> anyhow::Result<()> { - let Some(h) = Harness::with_boot( + let Some(mut h) = Harness::with_boot( backend, Shape::KeySession, "hook_streams_close_after_their_grant_is_revoked", - fast_hooks(), + event_rechecks_only(), ) .await? else { @@ -3307,9 +3486,11 @@ async fn hook_streams_close_after_their_grant_is_revoked(backend: Backend) -> an .to_owned(); let stream = drain_stream( open_stream(&h, &ticket).await, - std::time::Duration::from_secs(10), + std::time::Duration::from_secs(15), ); let actions = async { + // Delivered: the stream is live and within its interval. + h.write_content(b"before-revoke").await?; h.revoke(&h.space_owner, &grant[0]).await?; tokio::time::sleep(std::time::Duration::from_millis(1500)).await; h.write_content(b"after-revoke").await?; @@ -3317,10 +3498,11 @@ async fn hook_streams_close_after_their_grant_is_revoked(backend: Backend) -> an }; let (body, actions) = tokio::join!(stream, actions); actions?; - let body = body.expect("the stream closes after the revocation"); - assert!(!body.contains("event: write"), "{body}"); + let body = body.expect("the pre-event re-check closes the stream after the revocation"); + assert_eq!(body.matches(WRITE_EVENT).count(), 1, "{body}"); // Idle: nothing is written; the idle re-check closes it. + h.reboot(fast_hooks()).await?; let grant = h.grant_hooks(1).await?; let (_, body) = h.hook_ticket(&grant).await?; let ticket = serde_json::from_str::(&body)?["ticket"] diff --git a/tinycloud-node-server/src/routes/hooks.rs b/tinycloud-node-server/src/routes/hooks.rs index ecb9bcc9..1b38f8ca 100644 --- a/tinycloud-node-server/src/routes/hooks.rs +++ b/tinycloud-node-server/src/routes/hooks.rs @@ -293,8 +293,10 @@ enum Recheck { /// An open stream's authority re-check: before delivering an event at most /// once per interval, and on an idle timer. It closes the stream on a -/// definite "revoked", or after three consecutive check errors, so a busy -/// database doesn't close streams. +/// definite "revoked", or after three consecutive check errors, each at +/// least an interval apart, so a busy database or a burst of events during a +/// brief error doesn't close streams. While the last check failed, events +/// are withheld without querying. struct AuthorityRecheck { interval: Duration, last: rocket::tokio::time::Instant, @@ -311,7 +313,11 @@ impl AuthorityRecheck { } fn due(&self) -> bool { - self.last.elapsed() >= self.interval || self.consecutive_errors > 0 + self.last.elapsed() >= self.interval + } + + fn failing(&self) -> bool { + self.consecutive_errors > 0 } fn record(&mut self, result: Result) -> Recheck { @@ -467,11 +473,15 @@ pub async fn hook_events<'r>( .await; match recheck.record(result) { Recheck::Close => break, - // A failed check withholds this event - // and retries on the next one. + // A failed check withholds this event; + // the next check waits an interval. Recheck::Retry => continue, Recheck::Live => {} } + } else if recheck.failing() { + // Until the next check succeeds, events + // are withheld without querying. + continue; } yield Event::json(&event) .id(event.id.clone()) @@ -1052,7 +1062,10 @@ mod tests { assert!(!recheck.due()); assert_eq!(recheck.record::<()>(Ok(true)), Recheck::Live); assert_eq!(recheck.record(Err(())), Recheck::Retry); - assert!(recheck.due(), "a failed check is retried on the next event"); + assert!( + !recheck.due() && recheck.failing(), + "after a failed check, events are withheld until the interval passes" + ); assert_eq!(recheck.record(Err(())), Recheck::Retry); assert_eq!( recheck.record::<()>(Ok(true)), From f89ebc3970e127b69d9eb5846319fb69ec70664f Mon Sep 17 00:00:00 2001 From: Naomi Date: Wed, 21 Oct 2026 02:21:13 +0000 Subject: [PATCH 21/41] Check issuer set at policy registration Refuse a registered policy whose credential requirement names an issuer (and optional kid) outside the configured issuer set for the requirement's credential type and profile. Held-profile rows fix the permitted issuer DID shapes; a configured entry that violates its row invalidates the whole set at startup. Registration-time only; stored policies are not re-validated. With no issuer set configured the check is inert. --- tinycloud-node-server/src/config.rs | 39 +++++- tinycloud-node-server/src/issuer_set.rs | 160 +++++++++++++++++++++++- tinycloud-node-server/src/policy_v3.rs | 114 +++++++++++++++++ 3 files changed, 309 insertions(+), 4 deletions(-) diff --git a/tinycloud-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index 8e2c2dc2..76460a4c 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -1767,13 +1767,50 @@ mod tests { alg: "EdDSA".into(), public_key: key.into(), vcts: vec!["opencredentials.email/v1".into()], - profiles: vec!["tinycloud.email-held/v1".into()], + profiles: vec!["tinycloud.email-domain-proof/v1".into()], not_before: "2026-10-01T00:00:00Z".into(), retired_at: None, not_after: None, } } + #[test] + fn held_profile_rows_fail_startup_for_an_issuer_outside_the_row() { + let key = base64::encode_config( + ed25519_dalek::SigningKey::from_bytes(&[9; 32]) + .verifying_key() + .to_bytes(), + URL_SAFE_NO_PAD, + ); + let held = |did: &str, kid: &str| { + let mut entry = explicit_issuer("issuing-1", &key); + entry.issuer_did = did.into(); + entry.kid = kid.into(); + entry.profiles = vec!["tinycloud.email-held/v1".into()]; + let mut config = enabled_config(); + config.credential_issuers = vec![entry]; + config + }; + // The email row permits only did:web:issuer.credentials.org. + assert!(held( + "did:web:acme.credentials.org", + "did:web:acme.credentials.org#issuing-1" + ) + .credential_issuer_set() + .is_err()); + assert!(held( + "did:web:issuer.credentials.org", + "did:web:issuer.credentials.org#issuing-1" + ) + .credential_issuer_set() + .unwrap() + .resolve_pinned_issuer( + "did:web:issuer.credentials.org", + "did:web:issuer.credentials.org#issuing-1" + ) + .is_some()); + } + #[test] fn issuer_pin_checks_run_whenever_the_issuer_key_is_set() { // enabled is off, so the trust-bundle path never validated this. diff --git a/tinycloud-node-server/src/issuer_set.rs b/tinycloud-node-server/src/issuer_set.rs index 960b0b2e..03a6b81e 100644 --- a/tinycloud-node-server/src/issuer_set.rs +++ b/tinycloud-node-server/src/issuer_set.rs @@ -23,6 +23,53 @@ const LEGACY_PROFILES: &[&str] = &[ "tinycloud.email-domain-proof/v1", ]; +/// Permitted issuers for the held-credential profile rows (held credential +/// profile spec §3.3). A configured entry must name, for every profile in +/// `profiles` that has a row here, an `issuerDid` that row permits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PermittedIssuers { + /// Exactly this DID. + Literal(&'static str), + /// `did:web:o` + 26 characters of `[a-z2-7]` + `.orgs.credentials.org`. + OrgSubdomain, +} + +const HELD_PROFILE_ROWS: &[(&str, PermittedIssuers)] = &[ + ( + "tinycloud.email-held/v1", + PermittedIssuers::Literal("did:web:issuer.credentials.org"), + ), + ( + "tinycloud.org-membership/v1", + PermittedIssuers::OrgSubdomain, + ), + ( + "tinycloud.org-membership-free/v1", + PermittedIssuers::OrgSubdomain, + ), +]; + +/// Whether the held-profile row for `profile` permits `issuer_did`. `None` +/// when no held row exists for `profile`, which leaves legacy profiles +/// unchanged. +fn held_row_permits(profile: &str, issuer_did: &str) -> Option { + let (_, permitted) = HELD_PROFILE_ROWS + .iter() + .find(|(row_profile, _)| *row_profile == profile)?; + Some(match permitted { + PermittedIssuers::Literal(did) => issuer_did == *did, + PermittedIssuers::OrgSubdomain => issuer_did + .strip_prefix("did:web:o") + .and_then(|rest| rest.strip_suffix(".orgs.credentials.org")) + .is_some_and(|label| { + label.len() == 26 + && label + .bytes() + .all(|byte| matches!(byte, b'a'..=b'z' | b'2'..=b'7')) + }), + }) +} + #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum IssuerSetError { #[error("issuer set entry {index}: alg must be EdDSA")] @@ -41,6 +88,10 @@ pub enum IssuerSetError { FixtureKey { index: usize }, #[error("issuer set entry {index}: publicKey is already pinned by another entry")] DuplicatePublicKey { index: usize }, + #[error( + "issuer set entry {index}: a profile whose held row is defined does not permit this issuerDid" + )] + PermittedIssuer { index: usize }, #[error( "issuer set entry {index}: vcts and profiles must be non-empty lists of non-empty strings" )] @@ -242,6 +293,30 @@ impl IssuerSet { } self.entries.get(&(did.to_owned(), kid.to_owned())) } + + /// Registration-time membership check. True when the configured set names + /// `did` (and `kid`, when given) for this `vct` and `profile`. Validity + /// windows are enforced at acceptance, not here. + pub fn permits_requirement( + &self, + did: &str, + kid: Option<&str>, + vct: &str, + profile: &str, + ) -> bool { + match kid { + Some(kid) => self + .resolve_pinned_issuer(did, kid) + .is_some_and(|entry| entry.admits(vct, profile)), + None => { + did_method_allowed(did) + && self + .entries + .values() + .any(|entry| entry.issuer_did == did && entry.admits(vct, profile)) + } + } + } } fn parse_entry( @@ -269,6 +344,15 @@ fn parse_entry( if !non_empty(&config.vcts) || !non_empty(&config.profiles) { return Err(IssuerSetError::Admission { index }); } + // Every held row a profile names must permit this issuer. One violation + // invalidates the whole set. + if config + .profiles + .iter() + .any(|profile| held_row_permits(profile, &config.issuer_did) == Some(false)) + { + return Err(IssuerSetError::PermittedIssuer { index }); + } let not_before = parse_utc(&config.not_before).ok_or(IssuerSetError::Time { index, field: "notBefore", @@ -414,9 +498,79 @@ mod tests { OffsetDateTime::parse(text, &Rfc3339).unwrap() } + /// An entry under a legacy profile: it has no held row and is not row-checked. + fn legacy_entry(did: &str, fragment: &str, seed: u8) -> CredentialIssuerConfig { + let mut config = entry(did, fragment, seed); + config.profiles = vec!["tinycloud.email-domain-proof/v1".into()]; + config + } + + #[test] + fn held_rows_permit_only_their_issuer_shapes() { + let email = "tinycloud.email-held/v1"; + assert_eq!(held_row_permits(email, DID), Some(true)); + assert_eq!( + held_row_permits(email, "did:web:issuer.credentials.org.evil"), + Some(false) + ); + assert_eq!( + held_row_permits(email, "did:web:other.credentials.org"), + Some(false) + ); + let org = "did:web:oabcdefghijklmnopqrstuvwxyz.orgs.credentials.org"; + for profile in [ + "tinycloud.org-membership/v1", + "tinycloud.org-membership-free/v1", + ] { + assert_eq!(held_row_permits(profile, org), Some(true), "{profile}"); + for bad in [ + "did:web:oabcdefghijklmnopqrstuvwxy.orgs.credentials.org", + "did:web:oabcdefghijklmnopqrstuvwxyz0.orgs.credentials.org", + "did:web:oABCDEFGHIJKLMNOPQRSTUVWXYZ.orgs.credentials.org", + "did:web:oabcdefghijklmnopqrstuvwxy1.orgs.credentials.org", + "did:web:oabcdefghijklmnopqrstuvwxyz.orgs.credentials.org.evil", + "did:web:oabcdefghijklmnopqrstuvwxyz.x.orgs.credentials.org", + "did:web:xabcdefghijklmnopqrstuvwxyz.orgs.credentials.org", + ] { + assert_eq!(held_row_permits(profile, bad), Some(false), "{bad}"); + } + } + // Profiles without a held row are not row-checked. + assert_eq!( + held_row_permits("tinycloud.email-proof/v1", "did:web:a.example"), + None + ); + } + + #[test] + fn held_row_violation_invalidates_the_whole_set() { + let mut bad = entry("did:web:acme.credentials.org", "k", 1); + assert_eq!( + IssuerSet::load(std::slice::from_ref(&bad)).err(), + Some(IssuerSetError::PermittedIssuer { index: 0 }) + ); + // The same DID under a legacy profile has no row and loads. + bad.profiles = vec!["tinycloud.email-domain-proof/v1".into()]; + assert_eq!(IssuerSet::load(&[bad]).unwrap().len(), 1); + } + + #[test] + fn registration_membership_follows_did_kid_vct_and_profile() { + let set = IssuerSet::load(&[entry(DID, "k", 1)]).unwrap(); + let kid = format!("{DID}#k"); + let vct = "opencredentials.email/v1"; + let profile = "tinycloud.email-held/v1"; + assert!(set.permits_requirement(DID, None, vct, profile)); + assert!(set.permits_requirement(DID, Some(&kid), vct, profile)); + assert!(!set.permits_requirement("did:web:other.credentials.org", None, vct, profile)); + assert!(!set.permits_requirement(DID, Some(&format!("{DID}#other")), vct, profile)); + assert!(!set.permits_requirement(DID, None, "opencredentials.org-membership/v1", profile)); + assert!(!set.permits_requirement(DID, None, vct, "tinycloud.org-membership/v1")); + } + #[test] fn loads_a_valid_two_issuer_set_and_returns_the_full_entry() { - let mut second = entry("did:web:acme.credentials.org", "issuing-1", 2); + let mut second = legacy_entry("did:web:acme.credentials.org", "issuing-1", 2); second.retired_at = Some("2027-01-01T00:00:00Z".into()); second.not_after = Some("2028-01-01T00:00:00Z".into()); let set = IssuerSet::load(&[entry(DID, "issuing-1", 1), second]).unwrap(); @@ -443,7 +597,7 @@ mod tests { fn lookup_is_the_exact_pair_and_fails_closed() { let set = IssuerSet::load(&[ entry(DID, "issuing-1", 1), - entry("did:web:acme.credentials.org", "issuing-1", 2), + legacy_entry("did:web:acme.credentials.org", "issuing-1", 2), ]) .unwrap(); // Right issuer, wrong kid. @@ -687,7 +841,7 @@ mod tests { #[test] fn refuses_one_public_key_pinned_twice() { let a = entry(DID, "issuing-1", 1); - let mut b = entry("did:web:acme.credentials.org", "issuing-1", 1); + let mut b = legacy_entry("did:web:acme.credentials.org", "issuing-1", 1); assert_eq!( IssuerSet::load(&[a.clone(), b.clone()]).unwrap_err(), IssuerSetError::DuplicatePublicKey { index: 1 } diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index ad0f7e2c..cb036084 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -1804,6 +1804,7 @@ pub async fn register_policy( let request = request.into_inner(); let policy_bytes = canonical_json(&request.policy)?; validate_policy_document(&request.policy, &policy_bytes, &request.policy_cid)?; + validate_registration_credential_issuer(&request.policy, &runtime.credential_issuers)?; let projections = registration_projections(&request.policy)?; if request.content_source_digest_hex != projections.content_source_digest_hex || request.native_projection_hash_hex != projections.native_projection_hash_hex @@ -4290,6 +4291,45 @@ fn validate_policy_credential_requirement( Ok(object) } +/// Registration-time issuer check. A requirement whose issuer, pinned `issuerKid`, +/// `credentialType` or `profile` is not admitted by the configured issuer set is +/// refused. Inert when no issuer set is configured. Only registration is checked; +/// policies already stored keep their existing behaviour. +fn validate_registration_credential_issuer( + policy: &Value, + issuers: &IssuerSet, +) -> Result<(), (Status, String)> { + if issuers.is_empty() { + return Ok(()); + } + let Some(requirement) = policy.get("credentialRequirement") else { + return Ok(()); + }; + let projection = validate_policy_credential_requirement(requirement)?; + let id_of = |field: &str| { + projection + .get(field) + .and_then(Value::as_object) + .and_then(|identifier| identifier.get("id")) + .and_then(Value::as_str) + .unwrap_or_default() + }; + let issuer_did = projection + .get("issuerDid") + .and_then(Value::as_str) + .unwrap_or_default(); + if issuers.permits_requirement( + issuer_did, + projection.get("issuerKid").and_then(Value::as_str), + id_of("credentialType"), + id_of("profile"), + ) { + Ok(()) + } else { + Err((Status::Forbidden, "credential-issuer-untrusted".into())) + } +} + fn versioned_identifier(value: Option<&Value>) -> bool { value.and_then(Value::as_object).is_some_and(|object| { object.len() == 2 @@ -7265,6 +7305,80 @@ mod tests { Some((Status::Forbidden, code.into())) } + fn registration_policy( + issuer_did: &str, + issuer_kid: Option<&str>, + vct: &str, + profile: &str, + ) -> Value { + let mut fixture = SetFixture::new(issuer_did, issuer_kid, profile); + fixture.projection_value["credentialType"]["id"] = json!(vct); + json!({ "credentialRequirement": fixture.projection_value }) + } + + #[test] + fn registration_accepts_only_a_configured_issuer_kid_vct_and_profile() { + let key = keypair(); + let kid = format!("{ORG_A}#issuing-1"); + let set = IssuerSet::load(&[issuer_config(ORG_A, "issuing-1", &key)]).unwrap(); + let vct = "opencredentials.email/v1"; + let check = |policy: &Value| validate_registration_credential_issuer(policy, &set).err(); + assert_eq!( + check(®istration_policy(ORG_A, Some(&kid), vct, DOMAIN_PROFILE)), + None + ); + assert_eq!( + check(®istration_policy(ORG_A, None, vct, DOMAIN_PROFILE)), + None + ); + assert_eq!( + check(®istration_policy(ORG_B, None, vct, DOMAIN_PROFILE)), + forbidden("credential-issuer-untrusted") + ); + assert_eq!( + check(®istration_policy( + ORG_A, + Some(&format!("{ORG_A}#other")), + vct, + DOMAIN_PROFILE + )), + forbidden("credential-issuer-untrusted") + ); + assert_eq!( + check(®istration_policy( + ORG_A, + None, + vct, + "tinycloud.email-proof/v1" + )), + forbidden("credential-issuer-untrusted") + ); + assert_eq!( + check(®istration_policy( + ORG_A, + None, + "opencredentials.org-membership/v1", + DOMAIN_PROFILE + )), + forbidden("credential-issuer-untrusted") + ); + } + + #[test] + fn registration_is_inert_without_a_configured_issuer_set() { + let policy = registration_policy(ORG_B, None, "opencredentials.email/v1", DOMAIN_PROFILE); + assert_eq!( + validate_registration_credential_issuer(&policy, &IssuerSet::default()).err(), + None + ); + let key = keypair(); + let set = IssuerSet::load(&[issuer_config(ORG_A, "issuing-1", &key)]).unwrap(); + assert_eq!( + validate_registration_credential_issuer(&json!({}), &set).err(), + None + ); + } + #[test] fn issuer_set_requires_typ_for_configured_entries_only() { let key = keypair(); From bb9969ce385a7aff49b9272b061aace5f80a3f26 Mon Sep 17 00:00:00 2001 From: Naomi Date: Wed, 21 Oct 2026 03:17:34 +0000 Subject: [PATCH 22/41] Refuse unconfigured issuers at policy registration Check the held-profile row, vct, and kid for each registration against the configured issuer set after owner capability checks. Add row-level tests and a spec parity fixture for the held issuer rows. --- tinycloud-node-server/src/issuer_set.rs | 115 +++++++++++++++--- tinycloud-node-server/src/policy_v3.rs | 54 +++++++- .../src/testdata/held_row_parity.tsv | 73 +++++++++++ 3 files changed, 219 insertions(+), 23 deletions(-) create mode 100644 tinycloud-node-server/src/testdata/held_row_parity.tsv diff --git a/tinycloud-node-server/src/issuer_set.rs b/tinycloud-node-server/src/issuer_set.rs index 03a6b81e..a63999de 100644 --- a/tinycloud-node-server/src/issuer_set.rs +++ b/tinycloud-node-server/src/issuer_set.rs @@ -34,30 +34,42 @@ enum PermittedIssuers { OrgSubdomain, } -const HELD_PROFILE_ROWS: &[(&str, PermittedIssuers)] = &[ - ( - "tinycloud.email-held/v1", - PermittedIssuers::Literal("did:web:issuer.credentials.org"), - ), - ( - "tinycloud.org-membership/v1", - PermittedIssuers::OrgSubdomain, - ), - ( - "tinycloud.org-membership-free/v1", - PermittedIssuers::OrgSubdomain, - ), +/// One held-profile row: the `vct` it names and the issuers it permits. +struct HeldRow { + profile: &'static str, + vct: &'static str, + permitted: PermittedIssuers, +} + +const HELD_PROFILE_ROWS: &[HeldRow] = &[ + HeldRow { + profile: "tinycloud.email-held/v1", + vct: "opencredentials.email/v1", + permitted: PermittedIssuers::Literal("did:web:issuer.credentials.org"), + }, + HeldRow { + profile: "tinycloud.org-membership/v1", + vct: "opencredentials.org-membership/v1", + permitted: PermittedIssuers::OrgSubdomain, + }, + HeldRow { + profile: "tinycloud.org-membership-free/v1", + vct: "opencredentials.org-membership/v1", + permitted: PermittedIssuers::OrgSubdomain, + }, ]; +fn held_row(profile: &str) -> Option<&'static HeldRow> { + HELD_PROFILE_ROWS.iter().find(|row| row.profile == profile) +} + /// Whether the held-profile row for `profile` permits `issuer_did`. `None` /// when no held row exists for `profile`, which leaves legacy profiles /// unchanged. fn held_row_permits(profile: &str, issuer_did: &str) -> Option { - let (_, permitted) = HELD_PROFILE_ROWS - .iter() - .find(|(row_profile, _)| *row_profile == profile)?; + let permitted = held_row(profile)?.permitted; Some(match permitted { - PermittedIssuers::Literal(did) => issuer_did == *did, + PermittedIssuers::Literal(did) => issuer_did == did, PermittedIssuers::OrgSubdomain => issuer_did .strip_prefix("did:web:o") .and_then(|rest| rest.strip_suffix(".orgs.credentials.org")) @@ -304,6 +316,10 @@ impl IssuerSet { vct: &str, profile: &str, ) -> bool { + // Spec §3.3: the requirement's credential type must be the one the profile's row names. + if held_row(profile).is_some_and(|row| row.vct != vct) { + return false; + } match kid { Some(kid) => self .resolve_pinned_issuer(did, kid) @@ -566,6 +582,52 @@ mod tests { assert!(!set.permits_requirement(DID, Some(&format!("{DID}#other")), vct, profile)); assert!(!set.permits_requirement(DID, None, "opencredentials.org-membership/v1", profile)); assert!(!set.permits_requirement(DID, None, vct, "tinycloud.org-membership/v1")); + assert!(!set.permits_requirement( + DID, + Some(&kid), + "opencredentials.org-membership/v1", + profile + )); + assert!(!set.permits_requirement(DID, Some(&kid), vct, "tinycloud.org-membership/v1")); + } + + #[test] + fn registration_membership_requires_the_row_vct_for_the_profile() { + // Both vcts are listed, so only the row check can refuse the crossed pair. + let mut both = entry(DID, "k", 1); + both.vcts = vec![ + "opencredentials.email/v1".into(), + "opencredentials.org-membership/v1".into(), + ]; + let set = IssuerSet::load(&[both]).unwrap(); + let kid = format!("{DID}#k"); + let profile = "tinycloud.email-held/v1"; + assert!(set.permits_requirement(DID, Some(&kid), "opencredentials.email/v1", profile)); + assert!(set.permits_requirement(DID, None, "opencredentials.email/v1", profile)); + assert!(!set.permits_requirement( + DID, + Some(&kid), + "opencredentials.org-membership/v1", + profile + )); + assert!(!set.permits_requirement(DID, None, "opencredentials.org-membership/v1", profile)); + } + + #[test] + fn registration_admits_a_free_row_requirement_the_entry_lists() { + // Records current behaviour: a requirement naming the free row directly is admitted + // when an entry lists it. Spec §3.3 refuses it; the refusal belongs to T10 admittedRows. + let did = format!("did:web:o{}.orgs.credentials.org", "a".repeat(26)); + let mut free = entry(&did, "k", 1); + free.vcts = vec!["opencredentials.org-membership/v1".into()]; + free.profiles = vec!["tinycloud.org-membership-free/v1".into()]; + let set = IssuerSet::load(&[free]).unwrap(); + assert!(set.permits_requirement( + &did, + None, + "opencredentials.org-membership/v1", + "tinycloud.org-membership-free/v1" + )); } #[test] @@ -988,4 +1050,23 @@ mod tests { disabled.enabled = false; assert!(IssuerSet::from_legacy(&disabled).is_empty()); } + + #[test] + fn held_row_permits_matches_the_spec_parity_fixture() { + // Expected verdicts are derived from the spec §3.3 literal and pattern rules + // (email: exact literal; org rows: `^did:web:o[a-z2-7]{26}\.orgs\.credentials\.org$`), + // not from this implementation. Fixture: testdata/held_row_parity.tsv. + let rows = include_str!("testdata/held_row_parity.tsv"); + for line in rows.lines().skip(1) { + let mut cols = line.split('\t'); + let profile = cols.next().unwrap(); + let did = cols.next().unwrap(); + let want = cols.next().unwrap() == "true"; + assert_eq!( + held_row_permits(profile, did), + Some(want), + "{profile} {did:?}" + ); + } + } } diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index cb036084..ae29f240 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -1804,7 +1804,6 @@ pub async fn register_policy( let request = request.into_inner(); let policy_bytes = canonical_json(&request.policy)?; validate_policy_document(&request.policy, &policy_bytes, &request.policy_cid)?; - validate_registration_credential_issuer(&request.policy, &runtime.credential_issuers)?; let projections = registration_projections(&request.policy)?; if request.content_source_digest_hex != projections.content_source_digest_hex || request.native_projection_hash_hex != projections.native_projection_hash_hex @@ -1872,6 +1871,8 @@ pub async fn register_policy( .await .map_err(|error| (Status::Forbidden, error.into()))?; } + // Checked only after the owner's authority is proven, before any stored row is read. + validate_registration_credential_issuer(&request.policy, &runtime.credential_issuers)?; for root in [&policy_root, &enforcement_root] { for space in root.0.spaces() { tinycloud_core::request_telemetry::observe_space(space); @@ -8604,7 +8605,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 + run_policy_v2_flow(PolicyOwner::SpaceOwner, true).await } /// The real Share and CLI shape: the policy owner is a session key the @@ -8612,7 +8613,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 + run_policy_v2_flow(PolicyOwner::DelegatedSession, true).await } /// The policy owner signs a valid, self-consistent policy over content in @@ -8620,7 +8621,15 @@ 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 + run_policy_v2_flow(PolicyOwner::Unauthorized, true).await + } + + /// The node has an issuer set configured, but the policy names an issuer + /// outside it. Registration refuses before any row is written. + #[tokio::test] + async fn policy_registration_refuses_an_issuer_outside_the_configured_set() -> anyhow::Result<()> + { + run_policy_v2_flow(PolicyOwner::SpaceOwner, false).await } #[derive(Clone, Copy, PartialEq, Eq)] @@ -8633,7 +8642,10 @@ mod tests { Unauthorized, } - async fn run_policy_v2_flow(policy_owner: PolicyOwner) -> anyhow::Result<()> { + async fn run_policy_v2_flow( + policy_owner: PolicyOwner, + issuer_trusted: bool, + ) -> anyhow::Result<()> { use k256::ecdsa::SigningKey; use sha3::Keccak256; use tinycloud_auth::cacaos::siwe::encode_eip55; @@ -8906,7 +8918,7 @@ mod tests { ..ShareEmailConfig::default() }; let runtime = PolicyV3Runtime::new(db.clone(), node_did.clone(), node_secret) - .with_credential_issuer(issuer) + .with_credential_issuer(issuer.clone()) .with_delivery(&delivery_config)?; let admission = validate_credential_admission_v3( &requirement, @@ -8926,6 +8938,19 @@ mod tests { presentation["requestedCapabilities"].as_array().unwrap(), ) .map_err(|(_, error)| anyhow::anyhow!(error))?; + // Admission above ran against the trusted issuer. Registration runs on a node whose + // configured set omits that issuer; the gate must refuse it there. + let runtime = if issuer_trusted { + runtime + } else { + runtime.with_credential_issuer(IssuerKey::new( + "did:web:untrusted.example", + "opencredentials.email/v1", + 1, + "did:web:untrusted.example#controller", + issuer_key.public().to_bytes(), + )) + }; assert_eq!(admission.claim_jti, "credential-tc-470"); assert_eq!( admission.credential_space_owner_did.as_deref(), @@ -9392,6 +9417,23 @@ mod tests { .await; let register_status = register_response.status(); let register_body = register_response.into_string().await.unwrap_or_default(); + if !issuer_trusted { + assert_eq!( + register_status, + Status::Forbidden, + "register: {register_body}" + ); + assert_eq!(register_body, "credential-issuer-untrusted"); + 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(()); + } if policy_owner == PolicyOwner::Unauthorized { assert_eq!( register_status, diff --git a/tinycloud-node-server/src/testdata/held_row_parity.tsv b/tinycloud-node-server/src/testdata/held_row_parity.tsv new file mode 100644 index 00000000..05516022 --- /dev/null +++ b/tinycloud-node-server/src/testdata/held_row_parity.tsv @@ -0,0 +1,73 @@ +profile issuer_did expected +tinycloud.email-held/v1 did:web:issuer.credentials.org true +tinycloud.email-held/v1 did:web:oab234567abcdefghijklmnopqr.orgs.credentials.org false +tinycloud.email-held/v1 did:web:oab234567abcdefghijklmnopq.orgs.credentials.org false +tinycloud.email-held/v1 did:web:oab234567abcdefghijklmnopqra.orgs.credentials.org false +tinycloud.email-held/v1 did:web:o0b234567abcdefghijklmnopqr.orgs.credentials.org false +tinycloud.email-held/v1 did:web:o1b234567abcdefghijklmnopqr.orgs.credentials.org false +tinycloud.email-held/v1 did:web:o8b234567abcdefghijklmnopqr.orgs.credentials.org false +tinycloud.email-held/v1 did:web:o9b234567abcdefghijklmnopqr.orgs.credentials.org false +tinycloud.email-held/v1 did:web:oAB234567ABCDEFGHIJKLMNOPQR.orgs.credentials.org false +tinycloud.email-held/v1 did:web:xn--ab234567abcdefghijklmn.orgs.credentials.org false +tinycloud.email-held/v1 did:web:oab234567abcdefghijklmnop-.orgs.credentials.org false +tinycloud.email-held/v1 did:web:oab234567abcdefghijklmnopqr.orgs.credentials.org%3A443 false +tinycloud.email-held/v1 did:web:oab234567abcdefghijklmnopqr.orgs.credentials.org:path false +tinycloud.email-held/v1 did:web:oab234567abcdefghijklmnopqr.orgs.credentials.org. false +tinycloud.email-held/v1 did:web:oab234567abcdefghijklmnopqr.orgs.credentials.org false +tinycloud.email-held/v1 did:web:x.oab234567abcdefghijklmnopqr.orgs.credentials.org false +tinycloud.email-held/v1 did:web:oab234567abcdefghijklmnopqr.orgs.credentials.org.evil false +tinycloud.email-held/v1 did:web:oab234567abcdefghijklmnopqr.orgs.credentials.org#k false +tinycloud.email-held/v1 did:web:oab234567abcdefghijklmnopqr.orgs-credentials.org false +tinycloud.email-held/v1 did:web:éb234567abcdefghijklmnopqr.orgs.credentials.org false +tinycloud.email-held/v1 did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK false +tinycloud.email-held/v1 did:pkh:eip155:1:0x0000000000000000000000000000000000000000 false +tinycloud.email-held/v1 did:ethr:0x0000000000000000000000000000000000000000 false +tinycloud.email-held/v1 did:jwk:eyJrdHkiOiJPS1AifQ false +tinycloud.org-membership/v1 did:web:issuer.credentials.org false +tinycloud.org-membership/v1 did:web:oab234567abcdefghijklmnopqr.orgs.credentials.org true +tinycloud.org-membership/v1 did:web:oab234567abcdefghijklmnopq.orgs.credentials.org false +tinycloud.org-membership/v1 did:web:oab234567abcdefghijklmnopqra.orgs.credentials.org false +tinycloud.org-membership/v1 did:web:o0b234567abcdefghijklmnopqr.orgs.credentials.org false +tinycloud.org-membership/v1 did:web:o1b234567abcdefghijklmnopqr.orgs.credentials.org false +tinycloud.org-membership/v1 did:web:o8b234567abcdefghijklmnopqr.orgs.credentials.org false +tinycloud.org-membership/v1 did:web:o9b234567abcdefghijklmnopqr.orgs.credentials.org false +tinycloud.org-membership/v1 did:web:oAB234567ABCDEFGHIJKLMNOPQR.orgs.credentials.org false +tinycloud.org-membership/v1 did:web:xn--ab234567abcdefghijklmn.orgs.credentials.org false +tinycloud.org-membership/v1 did:web:oab234567abcdefghijklmnop-.orgs.credentials.org false +tinycloud.org-membership/v1 did:web:oab234567abcdefghijklmnopqr.orgs.credentials.org%3A443 false +tinycloud.org-membership/v1 did:web:oab234567abcdefghijklmnopqr.orgs.credentials.org:path false +tinycloud.org-membership/v1 did:web:oab234567abcdefghijklmnopqr.orgs.credentials.org. false +tinycloud.org-membership/v1 did:web:oab234567abcdefghijklmnopqr.orgs.credentials.org false +tinycloud.org-membership/v1 did:web:x.oab234567abcdefghijklmnopqr.orgs.credentials.org false +tinycloud.org-membership/v1 did:web:oab234567abcdefghijklmnopqr.orgs.credentials.org.evil false +tinycloud.org-membership/v1 did:web:oab234567abcdefghijklmnopqr.orgs.credentials.org#k false +tinycloud.org-membership/v1 did:web:oab234567abcdefghijklmnopqr.orgs-credentials.org false +tinycloud.org-membership/v1 did:web:éb234567abcdefghijklmnopqr.orgs.credentials.org false +tinycloud.org-membership/v1 did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK false +tinycloud.org-membership/v1 did:pkh:eip155:1:0x0000000000000000000000000000000000000000 false +tinycloud.org-membership/v1 did:ethr:0x0000000000000000000000000000000000000000 false +tinycloud.org-membership/v1 did:jwk:eyJrdHkiOiJPS1AifQ false +tinycloud.org-membership-free/v1 did:web:issuer.credentials.org false +tinycloud.org-membership-free/v1 did:web:oab234567abcdefghijklmnopqr.orgs.credentials.org true +tinycloud.org-membership-free/v1 did:web:oab234567abcdefghijklmnopq.orgs.credentials.org false +tinycloud.org-membership-free/v1 did:web:oab234567abcdefghijklmnopqra.orgs.credentials.org false +tinycloud.org-membership-free/v1 did:web:o0b234567abcdefghijklmnopqr.orgs.credentials.org false +tinycloud.org-membership-free/v1 did:web:o1b234567abcdefghijklmnopqr.orgs.credentials.org false +tinycloud.org-membership-free/v1 did:web:o8b234567abcdefghijklmnopqr.orgs.credentials.org false +tinycloud.org-membership-free/v1 did:web:o9b234567abcdefghijklmnopqr.orgs.credentials.org false +tinycloud.org-membership-free/v1 did:web:oAB234567ABCDEFGHIJKLMNOPQR.orgs.credentials.org false +tinycloud.org-membership-free/v1 did:web:xn--ab234567abcdefghijklmn.orgs.credentials.org false +tinycloud.org-membership-free/v1 did:web:oab234567abcdefghijklmnop-.orgs.credentials.org false +tinycloud.org-membership-free/v1 did:web:oab234567abcdefghijklmnopqr.orgs.credentials.org%3A443 false +tinycloud.org-membership-free/v1 did:web:oab234567abcdefghijklmnopqr.orgs.credentials.org:path false +tinycloud.org-membership-free/v1 did:web:oab234567abcdefghijklmnopqr.orgs.credentials.org. false +tinycloud.org-membership-free/v1 did:web:oab234567abcdefghijklmnopqr.orgs.credentials.org false +tinycloud.org-membership-free/v1 did:web:x.oab234567abcdefghijklmnopqr.orgs.credentials.org false +tinycloud.org-membership-free/v1 did:web:oab234567abcdefghijklmnopqr.orgs.credentials.org.evil false +tinycloud.org-membership-free/v1 did:web:oab234567abcdefghijklmnopqr.orgs.credentials.org#k false +tinycloud.org-membership-free/v1 did:web:oab234567abcdefghijklmnopqr.orgs-credentials.org false +tinycloud.org-membership-free/v1 did:web:éb234567abcdefghijklmnopqr.orgs.credentials.org false +tinycloud.org-membership-free/v1 did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK false +tinycloud.org-membership-free/v1 did:pkh:eip155:1:0x0000000000000000000000000000000000000000 false +tinycloud.org-membership-free/v1 did:ethr:0x0000000000000000000000000000000000000000 false +tinycloud.org-membership-free/v1 did:jwk:eyJrdHkiOiJPS1AifQ false From 7ef2642090bfbd9b48110e671120a6effc03942c Mon Sep 17 00:00:00 2001 From: Naomi Date: Wed, 21 Oct 2026 04:14:27 +0000 Subject: [PATCH 23/41] Cover row-pair registration and owner-first refusal order - Test that registration requires the configured entry to list the held row pair. - Test that an unauthorised owner gets the owner refusal before the issuer check. - Changelog entry for the registration issuer check. --- CHANGELOG.md | 4 ++++ tinycloud-node-server/src/issuer_set.rs | 21 +++++++++++++++++++++ tinycloud-node-server/src/policy_v3.rs | 10 +++++++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f62517c..b989577b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changes + +- Policy registration refuses a requirement whose issuer, pinned `issuerKid`, credential type or profile is not admitted by the configured issuer set, with `403 credential-issuer-untrusted`. The check runs after the owner's authority is proven and before any stored policy is read; it is inert when no issuer set is configured. At load, a configured issuer entry whose `issuerDid` does not match the held row named in its `profiles` now makes the whole issuer set invalid, and the node refuses to load it. + ## [1.21.6] - 2026-10-21 ### Release notes diff --git a/tinycloud-node-server/src/issuer_set.rs b/tinycloud-node-server/src/issuer_set.rs index a63999de..c97500f5 100644 --- a/tinycloud-node-server/src/issuer_set.rs +++ b/tinycloud-node-server/src/issuer_set.rs @@ -613,6 +613,27 @@ mod tests { assert!(!set.permits_requirement(DID, None, "opencredentials.org-membership/v1", profile)); } + #[test] + fn registration_requires_the_entry_to_list_the_row_pair() { + // The pair matches its own row, so only the entry's lists can refuse it. + let set = IssuerSet::load(&[entry(DID, "k", 1)]).unwrap(); + let kid = format!("{DID}#k"); + for kid in [Some(kid.as_str()), None] { + assert!(!set.permits_requirement( + DID, + kid, + "opencredentials.org-membership/v1", + "tinycloud.org-membership/v1" + )); + assert!(!set.permits_requirement( + DID, + kid, + "opencredentials.email/v1", + "tinycloud.email-proof/v1" + )); + } + } + #[test] fn registration_admits_a_free_row_requirement_the_entry_lists() { // Records current behaviour: a requirement naming the free row directly is admitted diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index ae29f240..bd717986 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -8632,6 +8632,13 @@ mod tests { run_policy_v2_flow(PolicyOwner::SpaceOwner, false).await } + /// An unauthorised owner naming an issuer outside the configured set gets the owner + /// refusal. The issuer check runs only after the owner's authority is proven. + #[tokio::test] + async fn owner_authority_is_checked_before_the_issuer_set() -> anyhow::Result<()> { + run_policy_v2_flow(PolicyOwner::Unauthorized, false).await + } + #[derive(Clone, Copy, PartialEq, Eq)] enum PolicyOwner { /// The policy owner is the content space's owner. @@ -9417,7 +9424,8 @@ mod tests { .await; let register_status = register_response.status(); let register_body = register_response.into_string().await.unwrap_or_default(); - if !issuer_trusted { + // An unauthorised owner gets the owner refusal: the issuer check runs only after authority is proven. + if !issuer_trusted && policy_owner != PolicyOwner::Unauthorized { assert_eq!( register_status, Status::Forbidden, From 73e0ad443ca8cdb971303a9d3d5ad1be39f33c6e Mon Sep 17 00:00:00 2001 From: Naomi Date: Wed, 21 Oct 2026 14:10:05 +0000 Subject: [PATCH 24/41] Ungate load_closure_edges for policy owner authority closure Main gated load_closure_edges to cfg(test). The PR's non-test policy_owner_authority::load_closure calls it for raw parent edges, so the merged non-test build failed (E0425). Ungate it; production locking still uses load_chain_lock_ids. --- tinycloud-core/src/auth_graph.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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], From d8d3df09bc82728e87ba7b4636b39cf918fe22c4 Mon Sep 17 00:00:00 2001 From: dryrun Date: Fri, 23 Oct 2026 23:26:14 +0000 Subject: [PATCH 25/41] Create the content owner's network in the Policy/v3 test harness --- .../src/policy_v3/test_harness.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tinycloud-node-server/src/policy_v3/test_harness.rs b/tinycloud-node-server/src/policy_v3/test_harness.rs index f84bc164..e959c15d 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 N1b), 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)) } From 69347493c53cb6e45f216840483885aa01f0b772 Mon Sep 17 00:00:00 2001 From: dryrun Date: Fri, 23 Oct 2026 23:27:03 +0000 Subject: [PATCH 26/41] Allow the extra argument on register_policy_writes --- tinycloud-node-server/src/policy_v3.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 6f9c20ac..988c2cf2 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -2012,6 +2012,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, From da8572bfb6fd5bebba96a23225087b5157820df5 Mon Sep 17 00:00:00 2001 From: dryrun Date: Fri, 23 Oct 2026 23:47:21 +0000 Subject: [PATCH 27/41] Order the owner-authority migration last; load a config without sql_store and policy_v3 sections --- tinycloud-core/src/migrations/mod.rs | 4 +++- tinycloud-node-server/src/config.rs | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/tinycloud-core/src/migrations/mod.rs b/tinycloud-core/src/migrations/mod.rs index 028fc42a..8197a8f5 100644 --- a/tinycloud-core/src/migrations/mod.rs +++ b/tinycloud-core/src/migrations/mod.rs @@ -69,9 +69,11 @@ impl MigratorTrait for Migrator { Box::new(m20261017_000000_epoch_order_space_index::Migration), Box::new(m20261018_000000_epoch_head::Migration), Box::new(m20261020_000000_current_kv_key_c_index::Migration), - Box::new(m20261019_000000_policy_v3_owner_authority::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-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index 4a426b16..f8ad7c75 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -2023,6 +2023,19 @@ mod tests { assert_eq!(retention.history_days("team"), None); } + /// A config file written before `[sql_store]` and `[policy_v3]` existed + /// still loads: both sections fall back to their defaults. + #[test] + fn config_without_sql_store_and_policy_v3_sections_loads() { + let mut value = serde_json::to_value(Config::default()).unwrap(); + let object = value.as_object_mut().unwrap(); + assert!(object.remove("sql_store").is_some()); + assert!(object.remove("policy_v3").is_some()); + let config: Config = serde_json::from_value(value).unwrap(); + assert_eq!(config.sql_store, SqlStoreConfig::default()); + assert_eq!(config.policy_v3, PolicyV3Config::default()); + } + #[test] fn sql_store_empty_allowlist_uploads_nothing_even_when_enabled() { let config = SqlStoreConfig { From c742ef24cee0a1db9359d155cf65d88474ec2b57 Mon Sep 17 00:00:00 2001 From: dryrun Date: Fri, 23 Oct 2026 23:48:41 +0000 Subject: [PATCH 28/41] Test that every optional Config section loads when omitted --- tinycloud-node-server/src/config.rs | 42 ++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/tinycloud-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index f8ad7c75..e0adb706 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -2023,17 +2023,39 @@ mod tests { assert_eq!(retention.history_days("team"), None); } - /// A config file written before `[sql_store]` and `[policy_v3]` existed - /// still loads: both sections fall back to their defaults. + /// 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]`) still 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_without_sql_store_and_policy_v3_sections_loads() { - let mut value = serde_json::to_value(Config::default()).unwrap(); - let object = value.as_object_mut().unwrap(); - assert!(object.remove("sql_store").is_some()); - assert!(object.remove("policy_v3").is_some()); - let config: Config = serde_json::from_value(value).unwrap(); - assert_eq!(config.sql_store, SqlStoreConfig::default()); - assert_eq!(config.policy_v3, PolicyV3Config::default()); + 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(&key.as_str())) + .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.as_str())); + assert_eq!(load(only_required).unwrap(), Config::default()); } #[test] From 05ea2b1d53aa4f5e98987b2ea7309e7fa01ba861 Mon Sep 17 00:00:00 2001 From: dryrun Date: Fri, 23 Oct 2026 23:52:20 +0000 Subject: [PATCH 29/41] Test that every optional Config section loads when omitted (fix) --- tinycloud-node-server/src/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinycloud-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index e0adb706..6b7aca8a 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -2042,7 +2042,7 @@ mod tests { assert_eq!(load(full.clone()).unwrap(), Config::default()); let optional: Vec<&String> = full .keys() - .filter(|key| !REQUIRED.contains(&key.as_str())) + .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")); From 1440bcbfa8d9433d178cf9553c105a136bdbdd19 Mon Sep 17 00:00:00 2001 From: dryrun Date: Fri, 23 Oct 2026 23:54:46 +0000 Subject: [PATCH 30/41] Test that every optional Config section loads when omitted (fix) --- tinycloud-node-server/src/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinycloud-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index 6b7aca8a..fd86d0af 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -2054,7 +2054,7 @@ mod tests { assert_eq!(config, Config::default(), "omitting `{field}`"); } let mut only_required = full.clone(); - only_required.retain(|key, _| REQUIRED.contains(&key.as_str())); + only_required.retain(|key, _| REQUIRED.contains(&key)); assert_eq!(load(only_required).unwrap(), Config::default()); } From facff5e92542195cae698c59b52bc6d7ab51c457 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 00:25:34 +0000 Subject: [PATCH 31/41] Advertise the owner-authority features before the setting-gated ones; rustfmt layout --- tinycloud-node-server/src/policy_v3.rs | 12 +++--------- tinycloud-node-server/src/routes/mod.rs | 15 ++++++++------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index b8c0ccbd..31361e76 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -2064,15 +2064,9 @@ async fn register_policy_writes( .insert(txn) .await .map_err(|e| (Status::Conflict, e.to_string()))?; - owner_authority::insert_classification( - txn, - policy_cid, - authority, - registered_mode, - now, - ) - .await - .map_err(db_error)?; + owner_authority::insert_classification(txn, policy_cid, authority, registered_mode, 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()))?; diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index 56cc11db..fc6e6389 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -323,15 +323,10 @@ fn build_info( ]); #[cfg(feature = "dstack")] features.push("tee"); - if config.encryption_network.named_keys_v1 { - features.push("encryption-named-keys-v1"); - } - if config.sql.delegated_caveats_enforced { - features.push("sql-delegated-caveats-v1"); - } // TC-1423: Policy/v3 registrations are bound to the owner authority // (`-v1`), plus exactly one active expiry mode. Clients treat the node as - // enforcing only when `-enforce` is listed. + // enforcing only when `-enforce` is listed. Listed before the features + // gated by settings, which are appended last. if let Some(mode) = owner_authority { features.push("policy-owner-authority-v1"); features.push(match mode { @@ -339,6 +334,12 @@ fn build_info( crate::config::OwnerAuthorityExpiry::Enforce => "policy-owner-authority-enforce", }); } + if config.encryption_network.named_keys_v1 { + features.push("encryption-named-keys-v1"); + } + if config.sql.delegated_caveats_enforced { + features.push("sql-delegated-caveats-v1"); + } NodeInfo { protocol: tinycloud_auth::protocol::PROTOCOL_VERSION, version: env!("CARGO_PKG_VERSION").to_string(), From c88e837ddd95e1b1f02b1e6cd96ad9d52dd36297 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 07:32:48 +0000 Subject: [PATCH 32/41] Neutral wording for the source-network comments and changelog entry --- CHANGELOG.md | 2 +- tinycloud-node-server/src/policy_v3.rs | 32 ++++++++----------- .../src/policy_v3/source_network_tests.rs | 9 +++--- 3 files changed, 19 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ba1da8f..497de979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ - 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; registered policies are not re-checked. Refusals are counted as `tinycloud_policy_source_network_refused_total{reason}`, with reasons `unparsable`, `legacy_resource`, `foreign_owner`, `foreign_space`, `missing` and `inactive` (TC-1423). +- 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; registered policies are not re-checked. 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. ## [1.22.0] - 2026-10-23 diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 68002154..e1a6b2f9 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -4695,14 +4695,13 @@ impl SourceNetworkRefusal { /// names a space of that owner; otherwise why not. Pure, so it applies to /// stored registrations as well as new ones. /// -/// A DID is root authority over every network URN that names it, created or -/// not, and any DID may create such a network. Without this check, a -/// registrant holding only kv on someone's content could hand that content -/// out under a network of its own: named in the source, or with the source -/// moved to its own space and the content added as a second kv entry. With -/// it, every resource the policy grants belongs to one owner, and a -/// registrant other than that owner holds decrypt on its networks only -/// through a delegation rooted at that owner. +/// 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 @@ -8672,23 +8671,20 @@ mod tests { Box::pin(run_policy_v2_flow(PolicyOwner::Unauthorized)).await } - /// TC-1423 (B2, N1b): a policy owner that holds only `kv/get` and + /// TC-1423 (N1b): 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, which needs no grant: a DID is - /// root authority over networks that name it. Registration refuses it: - /// the source network must exist and belong to the content space's owner. + /// 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 (N1b, review B1): 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; otherwise recipients would read the space owner's content - /// through a policy that outlives the owner's revocation. + /// TC-1423 (N1b): 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 diff --git a/tinycloud-node-server/src/policy_v3/source_network_tests.rs b/tinycloud-node-server/src/policy_v3/source_network_tests.rs index 9ca7a0eb..cfe513c2 100644 --- a/tinycloud-node-server/src/policy_v3/source_network_tests.rs +++ b/tinycloud-node-server/src/policy_v3/source_network_tests.rs @@ -856,11 +856,10 @@ async fn chain_id_mismatch_is_refused() -> anyhow::Result<()> { .await } -/// The B1 shape (review of #363): K holds only kv on V's content. K creates a -/// network named after itself (any DID may, with no proof) 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 it is refused. +/// 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?; From d16cf6dfe98227b344d9d4d030165e5613ede5f4 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 07:41:44 +0000 Subject: [PATCH 33/41] Classify stored registrations over foreign networks as unknown; create the harness network; update rollback runbook --- CHANGELOG.md | 4 +- docs/policy-owner-authority.md | 28 ++- .../src/policy_v3/owner_authority.rs | 171 +++++++++++++++- .../src/policy_v3/owner_authority_tests.rs | 193 +++++++++++------- 4 files changed, 308 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e9e8822..298f7e9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +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; registered policies are not re-checked. 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). 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`. The record does not change admission yet. See `docs/policy-owner-authority.md` (TC-1423). +- 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 index 4e3fbc3f..0edf3cb6 100644 --- a/docs/policy-owner-authority.md +++ b/docs/policy-owner-authority.md @@ -21,7 +21,7 @@ Two tables, created by migration `m20261019_000000_policy_v3_owner_authority`: | `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` or `starved` | +| `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 @@ -50,6 +50,18 @@ 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, @@ -92,13 +104,15 @@ override), otherwise at `info`. ## Rollback -The migration is unknown to 1.21.0, which refuses to boot with it applied. To -roll back, run the migration's `down` (it drops both tables), then deploy -1.21.0. While rolled back, policies have only the checks 1.21.0 has. +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): -On the re-upgrade, set `policy_v3.legacy_cutover` to the first deploy time: -`down` erased the original applied-at time, and without the override every -policy registered during the rollback window would be classified as legacy. +1. [ ] Run the three statements above on the database. +2. [ ] If a migration released after this one was 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 diff --git a/tinycloud-node-server/src/policy_v3/owner_authority.rs b/tinycloud-node-server/src/policy_v3/owner_authority.rs index 8e1dd07f..9d0e3de9 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority.rs @@ -13,11 +13,11 @@ //! 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 strangers cannot push them out of the window; +//! 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 steer the record; +//! 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. @@ -103,6 +103,10 @@ pub enum UnknownReason { /// 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 { @@ -111,6 +115,7 @@ impl UnknownReason { Self::Revoked => "revoked", Self::Missing => "missing", Self::Starved => "starved", + Self::ForeignNetwork => "foreign_network", } } @@ -119,6 +124,7 @@ impl UnknownReason { "revoked" => Self::Revoked, "missing" => Self::Missing, "starved" => Self::Starved, + "foreign_network" => Self::ForeignNetwork, _ => return None, }) } @@ -165,6 +171,18 @@ impl Classification { 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, @@ -901,6 +919,14 @@ impl PolicyV3Runtime { // 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 @@ -1425,4 +1451,145 @@ mod tests { 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 was 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 index cba272e5..ae88a5ee 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs @@ -399,6 +399,65 @@ pub(super) struct Registered { 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 { @@ -483,6 +542,19 @@ impl Harness { ); 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 N1b), 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, @@ -672,52 +744,13 @@ impl Harness { /// 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 projection = self.vector["policyProjection"].clone(); - let created = (now() - Duration::seconds(60)).min(root.not_before); - let ceiling = vec![ - json!({ - "kind": "kv", - "resource": self.content_resource.to_string(), - "selector": "exact", - "actions": [KV_GET, KV_METADATA], - }), - json!({ - "kind": "encryption", - "resource": self.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": self.content_resource.to_string(), - "selector": "exact", - "encryptionNetwork": self.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( - self.owner.keypair().sign(&Sha256::digest(preimage)), - URL_SAFE_NO_PAD - )); + 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, @@ -1280,7 +1313,7 @@ async fn roots_outliving_their_authority_are_recorded_uncapped( } on_both_backends!(roots_outliving_their_authority_are_recorded_uncapped); -/// [F1] A link holder that delegates back to the policy owner, or the owner +/// 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( @@ -1328,7 +1361,7 @@ async fn delegations_back_to_the_owner_are_never_recorded(backend: Backend) -> a } on_both_backends!(delegations_back_to_the_owner_are_never_recorded); -/// [F1] The same in the general window, where the owner's authority is +/// 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( @@ -1442,16 +1475,16 @@ fn sorting_before( anyhow::bail!("no delegation sorted before the target") } -/// [C1] Delegations to the policy owner from strangers' own spaces, with -/// future or backdated issue times, cannot push the space owner's grant out +/// 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 stranger_delegations_cannot_starve_the_owner_grant( +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, - "stranger_delegations_cannot_starve_the_owner_grant", + "delegations_from_other_issuers_do_not_displace_the_owner_grant", legacy_boot(), ) .await? @@ -1466,8 +1499,8 @@ async fn stranger_delegations_cannot_starve_the_owner_grant( // 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. - flood(&h, 260, Duration::days(300)).await?; - flood(&h, 260, -Duration::days(300)).await?; + 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?, @@ -1480,15 +1513,19 @@ async fn stranger_delegations_cannot_starve_the_owner_grant( assert_eq!(h.closure(&legacy.policy_cid).await?, vec![authority]); Ok(()) } -on_both_backends!(stranger_delegations_cannot_starve_the_owner_grant); +on_both_backends!(delegations_from_other_issuers_do_not_displace_the_owner_grant); -/// SIWE delegations from strangers' own spaces to the policy owner. -async fn flood(h: &Harness, count: usize, issued_offset: Duration) -> anyhow::Result<()> { +/// 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 stranger = Signer::pkh(); - let space = SpaceId::new(stranger.did().parse::()?, "applications".parse()?); + 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(stranger.delegate( + h.import(issuer.delegate( h.owner.did(), &[(resource.to_string(), KV_GET)], Window::until(now() + Duration::days(400)).issued(now() + issued_offset), @@ -1500,13 +1537,15 @@ async fn flood(h: &Harness, count: usize, issued_offset: Duration) -> anyhow::Re } /// The general window only holds delegations touching the capability's space -/// or network, so strangers cannot crowd out a re-delegated authority that -/// the owner-first step does not see. -async fn strangers_cannot_crowd_the_general_window(backend: Backend) -> anyhow::Result<()> { +/// 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, - "strangers_cannot_crowd_the_general_window", + "delegations_from_other_issuers_do_not_fill_the_general_window", legacy_boot(), ) .await? @@ -1531,7 +1570,7 @@ async fn strangers_cannot_crowd_the_general_window(backend: Backend) -> anyhow:: .await?; let legacy = h.register_ok(policy_window()).await?; h.forget_binding(&legacy.policy_cid).await?; - flood(&h, 300, -Duration::days(300)).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]; @@ -1539,7 +1578,7 @@ async fn strangers_cannot_crowd_the_general_window(backend: Backend) -> anyhow:: assert_eq!(h.closure(&legacy.policy_cid).await?, expected); Ok(()) } -on_both_backends!(strangers_cannot_crowd_the_general_window); +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 @@ -1725,16 +1764,16 @@ async fn the_legacy_classifier_ignores_grants_issued_after_registration( } on_both_backends!(the_legacy_classifier_ignores_grants_issued_after_registration); -/// [C1] With a SIWE owner grant: strangers' delegations issued after it -/// (future at registration, or between it and a legacy registration) don't +/// 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 stranger_delegations_cannot_starve_a_siwe_owner_grant( +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, - "stranger_delegations_cannot_starve_a_siwe_owner_grant", + "delegations_from_other_issuers_do_not_displace_a_siwe_owner_grant", legacy_boot(), ) .await? @@ -1751,8 +1790,8 @@ async fn stranger_delegations_cannot_starve_a_siwe_owner_grant( 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?; - flood(&h, 260, -Duration::hours(1)).await?; - flood(&h, 260, Duration::days(300)).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?, @@ -1763,9 +1802,9 @@ async fn stranger_delegations_cannot_starve_a_siwe_owner_grant( assert_eq!(h.closure(&legacy.policy_cid).await?, vec![authority]); Ok(()) } -on_both_backends!(stranger_delegations_cannot_starve_a_siwe_owner_grant); +on_both_backends!(delegations_from_other_issuers_do_not_displace_a_siwe_owner_grant); -/// [F9] An older grant that outlives the root is recorded over a newer one +/// 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( @@ -1837,7 +1876,7 @@ async fn concurrent_first_uses_store_one_classification(backend: Backend) -> any } on_both_backends!(concurrent_first_uses_store_one_classification); -/// [F3] A classification that cannot be stored is served from memory and +/// 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( @@ -1869,7 +1908,7 @@ async fn a_failed_classification_write_still_serves(backend: Backend) -> anyhow: } on_both_backends!(a_failed_classification_write_still_serves); -/// [F8] A legacy registration whose original authority was revoked binds +/// A legacy registration whose original authority was 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, @@ -2010,7 +2049,7 @@ async fn the_sweep_runs_only_when_enabled(backend: Backend) -> anyhow::Result<() } on_both_backends!(the_sweep_runs_only_when_enabled); -/// [F5][F6] Rollback: `down` drops both tables; after a re-upgrade only +/// 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, From 2068e65091fdc79133f07a73e5ee5dc5c4adac3d Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 07:45:26 +0000 Subject: [PATCH 34/41] Drop finding labels from test comments --- .../src/policy_v3/owner_authority_tests.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs index 53b5f52b..4e2d1b70 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs @@ -2261,7 +2261,7 @@ fn counter(name: &str, labels: &[(&str, &str)]) -> f64 { .sum() } -/// [F2][C2] Roots are capped at registration time + 31 days (+5 minutes of +/// Roots are capped at registration time + 31 days (+5 minutes of /// skew) with `nbf <= exp`, in both modes; a root shaped like a released /// SDK's is accepted. async fn root_lifetime_is_capped_in_both_modes(backend: Backend) -> anyhow::Result<()> { @@ -2379,7 +2379,7 @@ async fn enforce_refuses_roots_outliving_their_authority(backend: Backend) -> an } on_both_backends!(enforce_refuses_roots_outliving_their_authority); -/// [F9] Under `enforce`, an older grant that outlives the root is chosen +/// Under `enforce`, an older grant that outlives the root is chosen /// over a newer one that does not, so the registration is accepted. async fn enforce_chooses_the_grant_that_outlives_the_root(backend: Backend) -> anyhow::Result<()> { let Some(h) = Harness::with_boot( @@ -2444,7 +2444,7 @@ async fn info_advertises_the_owner_authority_mode(backend: Backend) -> anyhow::R } on_both_backends!(info_advertises_the_owner_authority_mode); -/// [F2] A legacy registration whose root outlives 31 days is classified as +/// A legacy registration whose root outlives 31 days is classified as /// before and counted, not refused. async fn legacy_long_roots_are_counted_not_refused(backend: Backend) -> anyhow::Result<()> { let Some(h) = Harness::new( @@ -2485,7 +2485,7 @@ async fn legacy_long_roots_are_counted_not_refused(backend: Backend) -> anyhow:: } on_both_backends!(legacy_long_roots_are_counted_not_refused); -/// [F6] A registration after the cutover with no classification (written by +/// A registration after the cutover with no classification (written by /// an older binary) gets the lifetime cap, unlike a legacy one. async fn post_cutover_unclassified_rows_get_the_lifetime_cap( backend: Backend, @@ -2521,7 +2521,7 @@ async fn post_cutover_unclassified_rows_get_the_lifetime_cap( } on_both_backends!(post_cutover_unclassified_rows_get_the_lifetime_cap); -/// [F6] Under `enforce`, a post-cutover unclassified row whose root outlives +/// Under `enforce`, a post-cutover unclassified row whose root outlives /// its only authority is recorded `unknown`. async fn post_cutover_rows_outliving_their_authority_are_unknown_under_enforce( backend: Backend, From b3ffc9bda7fe33edc6888d23042efbbb7980e15f Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 07:54:55 +0000 Subject: [PATCH 35/41] Restore unit coverage for classification limits and back-off, the mint-after-revoke check and the use-status check; neutral wording --- docs/policy-owner-authority.md | 10 +- tinycloud-node-server/src/policy_v3.rs | 62 +++++++++ .../src/policy_v3/owner_authority.rs | 131 ++++++++++++++++++ .../src/policy_v3/owner_authority_tests.rs | 52 ++++--- 4 files changed, 229 insertions(+), 26 deletions(-) diff --git a/docs/policy-owner-authority.md b/docs/policy-owner-authority.md index 27fbe986..618012d7 100644 --- a/docs/policy-owner-authority.md +++ b/docs/policy-owner-authority.md @@ -260,7 +260,7 @@ authorization. | `root-authority` | nothing more | | `unknown` | denied (`403 policy-owner-authority-unknown`) | -The mint no longer rescans the owner's delegations as of registration for +The mint does not rescan the owner's delegations as of registration for policies with a recorded authority. What to check is cached in process once a classification is final (bounded); @@ -273,8 +273,8 @@ Concurrent first uses of one policy share a single classification. A revocation takes effect on commit; a request already past the check may finish. Signed-KV tickets minted before a revocation last at most 60 seconds. -Other codes: an expired root now gives `root-expired` (other non-live status -remains `root-not-live`); S0 admission returns the owner-authority code itself; +Other codes: an expired root gives `root-expired` (other non-live status +gives `root-not-live`); S0 admission returns the owner-authority code itself; delivery keeps `delivery-authorization-invalid`. No code carries a CID or DID. `policy-owner-authority-expired` is not reached by stored rows: a `bound` root expires no later than its authority, and the root and session expiry @@ -289,8 +289,8 @@ legacy_unknown_allow = false # TINYCLOUD_POLICY_V3__LEGACY_UNKNOWN_ALLOW ``` When true, legacy (`backfill`) policies classified `unknown` are served with -the root-only checks (and the mint's registration-time rescan) instead of -being denied. The node logs a warning at boot, and per policy at most once an +the root-only checks (and the mint's registration-time rescan); when false they +are denied. The node logs a warning at boot, and per policy at most once an hour when it serves one; uses are counted in `tinycloud_policy_owner_authority_unknown_allowed_total{reason}`. Denials are counted in `tinycloud_policy_owner_authority_denied_total{reason=revoked|expired|unknown}`. diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 301b9497..dfdf9008 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -10544,6 +10544,22 @@ mod tests { // Revoking the delegation the policy owner held ends the policy: // the already-admitted session, new challenges and new sessions, // although the policy and its roots are untouched. + // A challenge issued before the revoke, to try the mint with after it. + let pre_revoke_challenge = client + .post("/policy/v3/challenges") + .header(ContentType::JSON) + .body( + json!({ + "policyCid": policy_cid, + "recipientDid": holder_did, + "requestedCapabilities": requested, + }) + .to_string(), + ) + .dispatch() + .await; + assert_eq!(pre_revoke_challenge.status(), Status::Ok); + let fresh_challenge: Value = pre_revoke_challenge.into_json().await.unwrap(); let mut capabilities = Capabilities::::new(); capabilities.with_action( format!("urn:cid:{owner_delegation_cid}").parse()?, @@ -10630,6 +10646,52 @@ mod tests { challenge_response.into_string().await.unwrap_or_default(), "policy-owner-authority-revoked" ); + // A mint with the challenge issued before the revoke is refused too. + let mut fresh = presentation.clone(); + fresh["schema"] = json!(CREDENTIAL_PRESENTATION_V4_SCHEMA); + fresh["jti"] = json!("presentation-tc-597-after-owner-revoke"); + fresh["challengeId"] = fresh_challenge["challengeId"].clone(); + fresh["nonce"] = fresh_challenge["nonce"].clone(); + fresh + .as_object_mut() + .unwrap() + .remove("credentialSpaceOwnerDid"); + fresh["issuedAt"] = json!(format_time(OffsetDateTime::now_utc())); + fresh["expiresAt"] = json!(format_time( + OffsetDateTime::now_utc() + Duration::seconds(45) + )); + let mut unsigned_fresh = fresh.clone(); + unsigned_fresh.as_object_mut().unwrap().remove("signature"); + let mut fresh_preimage = CREDENTIAL_PRESENTATION_V4_DOMAIN.to_vec(); + fresh_preimage.extend_from_slice(&canonical_json_value(&unsigned_fresh)); + fresh["signature"]["value"] = json!(encode_config( + holder_key.sign(&Sha256::digest(fresh_preimage)), + URL_SAFE_NO_PAD + )); + let refused = client + .post("/policy/v3/delegations") + .header(ContentType::JSON) + .body( + json!({ + "policyCid": policy_cid, + "challengeId": fresh_challenge["challengeId"], + "nonce": fresh_challenge["nonce"], + "requirement": requirement, + "credential": credential, + "presentation": fresh, + }) + .to_string(), + ) + .dispatch() + .await; + let refused_status = refused.status(); + let refused_body = refused.into_string().await.unwrap_or_default(); + assert_eq!( + refused_status, + Status::Forbidden, + "mint after owner revoke: {refused_body}" + ); + assert_eq!(refused_body, "policy-owner-authority-revoked"); // Delivery checks the same authority: the retry that returned the // stored receipt above is now refused. let owner_revoked_delivery = make_invocation( diff --git a/tinycloud-node-server/src/policy_v3/owner_authority.rs b/tinycloud-node-server/src/policy_v3/owner_authority.rs index 39e41ba6..acd5a21a 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority.rs @@ -2098,4 +2098,135 @@ mod tests { assert_eq!(UnknownReason::parse(reason.as_str()), Some(reason)); } } + + /// A stored registration over another owner's network: classifying it + /// needs no root rows. + fn foreign_registration() -> policy_v3_registration::Model { + use super::super::owner_authority_tests::Signer; + let space_owner = Signer::key(); + let policy_owner = Signer::key(); + stored_registration( + &policy_owner, + &content_in_space_of(space_owner.did()), + &format!("urn:tinycloud:encryption:{}:share", policy_owner.did()), + OffsetDateTime::now_utc() - Duration::hours(1), + ) + } + + /// Holds every background slot until dropped. + fn hold_every_slot(state: &Arc) -> Vec { + (0..MAX_BACKGROUND_CLASSIFICATIONS) + .map(|index| InFlight::claim(state, &format!("policy-held-{index}")).expect("a slot")) + .collect() + } + + /// A policy with a final stored classification adds no query to the + /// request path once its row is loaded. + #[tokio::test] + async fn final_classifications_cost_the_request_path_nothing() { + let (runtime, queries) = counted_runtime().await; + let registration = foreign_registration(); + insert_classification( + &runtime.conn, + ®istration.policy_cid, + &Classification::foreign_network(), + RegisteredMode::Backfill, + OffsetDateTime::now_utc(), + ) + .await + .unwrap(); + runtime.binding_for_use(®istration).await.unwrap(); + let before = queries.load(Ordering::SeqCst); + for _ in 0..10 { + runtime.binding_for_use(®istration).await.unwrap(); + } + assert_eq!(queries.load(Ordering::SeqCst), before); + assert_eq!( + runtime + .owner_authority + .classifications + .load(Ordering::SeqCst), + 0, + "a stored final row is not classified again" + ); + } + + /// At most `MAX_BACKGROUND_CLASSIFICATIONS` background writes run at + /// once. A use that finds no free slot is still served and starts none; + /// once a slot is free, a use of another policy stores its result. + #[tokio::test] + async fn background_classifications_are_limited_without_backing_off() { + let (runtime, _) = counted_runtime().await; + let state = &runtime.owner_authority; + // Three background writes may run at once; a fourth finds no slot. + let held = (0..3) + .map(|index| { + InFlight::claim(state, &format!("policy-held-{index}")).expect("one of three slots") + }) + .collect::>(); + assert!( + InFlight::claim(state, "policy-over-the-limit").is_none(), + "no slot beyond the third" + ); + let busy = foreign_registration(); + let binding = runtime.binding_for_use(&busy).await.unwrap(); + assert_eq!(binding.reason, Some(UnknownReason::ForeignNetwork)); + tokio::task::yield_now().await; + assert!( + policy_v3_owner_binding::Entity::find_by_id(busy.policy_cid.clone()) + .one(&runtime.conn) + .await + .unwrap() + .is_none(), + "nothing is stored while every slot is held" + ); + drop(held); + let free = foreign_registration(); + runtime.binding_for_use(&free).await.unwrap(); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + let stored = policy_v3_owner_binding::Entity::find_by_id(free.policy_cid.clone()) + .one(&runtime.conn) + .await + .unwrap(); + if stored.is_some() { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "the background write did not run" + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + } + + /// A classification that is not final (here it could not be stored, as + /// every slot is held) is reused for the back-off window: later uses + /// compute nothing and query nothing. + #[tokio::test] + async fn non_final_classifications_back_off() { + let (runtime, queries) = counted_runtime().await; + let _held = hold_every_slot(&runtime.owner_authority); + let registration = foreign_registration(); + runtime.binding_for_use(®istration).await.unwrap(); + assert_eq!( + runtime + .owner_authority + .classifications + .load(Ordering::SeqCst), + 1 + ); + let before = queries.load(Ordering::SeqCst); + for _ in 0..10 { + runtime.binding_for_use(®istration).await.unwrap(); + } + assert_eq!(queries.load(Ordering::SeqCst), before); + assert_eq!( + runtime + .owner_authority + .classifications + .load(Ordering::SeqCst), + 1 + ); + } } diff --git a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs index 5fdaccdd..66e58e9d 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs @@ -1208,14 +1208,24 @@ impl Harness { Ok(()) } - /// Use the policy (a challenge) and wait for its background - /// classification. + /// Use the policy (a challenge), wait for its background classification, + /// and check that the use was admitted when the policy has authority + /// (`bound`, `bound-uncapped`, `root-authority`: 200) and refused with + /// the fixed code when it has none (`unknown`: 403). pub async fn classify_on_use(&self, policy: &Registered) -> anyhow::Result { - // The use may be refused once the classification gates it. - self.challenge(policy).await; - self.wait_for_binding(&policy.policy_cid, std::time::Duration::from_secs(20)) + let (status, body) = self.challenge(policy).await; + let row = self + .wait_for_binding(&policy.policy_cid, std::time::Duration::from_secs(20)) .await? - .ok_or_else(|| anyhow::anyhow!("not classified")) + .ok_or_else(|| anyhow::anyhow!("not classified"))?; + match row.state.as_str() { + "unknown" => anyhow::ensure!( + status == Status::Forbidden && body == "policy-owner-authority-unknown", + "challenge on an unknown policy: {status} {body}" + ), + _ => anyhow::ensure!(status == Status::Ok, "challenge: {status} {body}"), + } + Ok(row) } /// Wait until `policy_cid` is classified as `state`, or time out; @@ -2776,7 +2786,7 @@ impl Harness { } } -/// [1][4][8][11] Revoking the owner's grant ends already-admitted sessions: +/// Revoking the owner's grant ends already-admitted sessions: /// read, decrypt, a re-shared child, challenge, mint, signed-KV mint and new /// re-share admission, with a fixed, opaque code. async fn revoking_the_owner_grant_ends_admitted_sessions(backend: Backend) -> anyhow::Result<()> { @@ -2883,7 +2893,7 @@ async fn revoking_the_agent_session_ends_its_policies(backend: Backend) -> anyho } on_both_backends!(revoking_the_agent_session_ends_its_policies); -/// [2] Revoking an ancestor of the recorded grant ends sessions too. +/// Revoking an ancestor of the recorded grant ends sessions too. async fn revoking_an_ancestor_ends_admitted_sessions(backend: Backend) -> anyhow::Result<()> { let Some(h) = Harness::new( backend, @@ -2919,7 +2929,7 @@ async fn revoking_an_ancestor_ends_admitted_sessions(backend: Backend) -> anyhow } on_both_backends!(revoking_an_ancestor_ends_admitted_sessions); -/// [2] A revocation stored under an alias CID of a closure node counts. +/// A revocation stored under an alias CID of a closure node counts. async fn a_revocation_under_an_alias_cid_ends_sessions(backend: Backend) -> anyhow::Result<()> { let Some(h) = Harness::new( backend, @@ -2999,7 +3009,7 @@ async fn a_revocation_under_an_alias_cid_ends_sessions(backend: Backend) -> anyh } on_both_backends!(a_revocation_under_an_alias_cid_ends_sessions); -/// [7] All-of: kv granted by one delegation and decrypt by another; revoking +/// All-of: kv granted by one delegation and decrypt by another; revoking /// either ends the policy. async fn each_recorded_grant_is_required(backend: Backend) -> anyhow::Result<()> { for revoke_kv in [true, false] { @@ -3035,7 +3045,7 @@ async fn each_recorded_grant_is_required(backend: Backend) -> anyhow::Result<()> } on_both_backends!(each_recorded_grant_is_required); -/// [3] A root bound to its authority's expiry: once both pass, reads see the +/// A root bound to its authority's expiry: once both pass, reads see the /// session expired and mints see the root expired. async fn expiry_codes_are_typed(backend: Backend) -> anyhow::Result<()> { let Some(h) = Harness::new(backend, Shape::KeySession, "expiry_codes_are_typed").await? else { @@ -3060,7 +3070,7 @@ async fn expiry_codes_are_typed(backend: Backend) -> anyhow::Result<()> { } on_both_backends!(expiry_codes_are_typed); -/// [O2][9] A legacy registration with no authority is denied; the operator +/// A legacy registration with no authority is denied; the operator /// escape restores root-only checks and is counted. async fn legacy_registrations_without_authority_are_denied_unless_escaped( backend: Backend, @@ -3120,7 +3130,7 @@ async fn legacy_registrations_without_authority_are_denied_unless_escaped( } on_both_backends!(legacy_registrations_without_authority_are_denied_unless_escaped); -/// [9][F2] Legacy rows get liveness but no expiry cap: a legacy session +/// Legacy rows get liveness but no expiry cap: a legacy session /// whose authority expired since registration still reads, and so does a /// legacy root longer than 31 days. async fn legacy_registrations_are_never_expiry_capped(backend: Backend) -> anyhow::Result<()> { @@ -3169,7 +3179,7 @@ async fn legacy_registrations_are_never_expiry_capped(backend: Backend) -> anyho } on_both_backends!(legacy_registrations_are_never_expiry_capped); -/// [O2] The escape covers legacy rows only: a post-cutover row without +/// The escape covers legacy rows only: a post-cutover row without /// authority is denied with the flag on. async fn the_escape_never_serves_post_cutover_rows(backend: Backend) -> anyhow::Result<()> { let Some(mut h) = Harness::new( @@ -3221,7 +3231,7 @@ async fn the_escape_never_serves_post_cutover_rows(backend: Backend) -> anyhow:: } on_both_backends!(the_escape_never_serves_post_cutover_rows); -/// [O2] An escaped legacy policy serves its admitted sessions, but a mint +/// An escaped legacy policy serves its admitted sessions, but a mint /// still needs the owner to have held what the policy grants when it /// registered. async fn escaped_mints_still_check_the_owner_held_the_capability( @@ -3266,7 +3276,7 @@ async fn escaped_mints_still_check_the_owner_held_the_capability( } on_both_backends!(escaped_mints_still_check_the_owner_held_the_capability); -/// [8] An admitted session can't be imported again once its owner authority +/// An admitted session can't be imported again once its owner authority /// is revoked. async fn admitted_sessions_are_not_reimported_after_a_revoke( backend: Backend, @@ -3294,7 +3304,7 @@ async fn admitted_sessions_are_not_reimported_after_a_revoke( } on_both_backends!(admitted_sessions_are_not_reimported_after_a_revoke); -/// [F3] Concurrent first uses of an unclassified policy share one +/// Concurrent first uses of an unclassified policy share one /// classification. async fn concurrent_first_uses_share_one_classification(backend: Backend) -> anyhow::Result<()> { let Some(mut h) = Harness::with_boot( @@ -3501,7 +3511,7 @@ async fn drain_stream( .map(Option::unwrap_or_default) } -/// [8][C3] An open hook stream stops delivering after its grant is revoked: +/// An open hook stream stops delivering after its grant is revoked: /// the next event after the re-check interval is withheld and the stream /// closes (with no idle re-check to close it otherwise); an idle stream /// closes on the idle re-check. @@ -3563,7 +3573,7 @@ async fn hook_streams_close_after_their_grant_is_revoked(backend: Backend) -> an } on_both_backends!(hook_streams_close_after_their_grant_is_revoked); -/// [C3] Re-check errors close a stream only after three in a row. +/// Re-check errors close a stream only after three in a row. async fn hook_streams_close_after_repeated_check_errors(backend: Backend) -> anyhow::Result<()> { let Some(h) = Harness::with_boot( backend, @@ -3599,7 +3609,7 @@ async fn hook_streams_close_after_repeated_check_errors(backend: Backend) -> any } on_both_backends!(hook_streams_close_after_repeated_check_errors); -/// [C3] A ticket would carry more than 16 authority IDs: refused. +/// A ticket would carry more than 16 authority IDs: refused. async fn hook_tickets_carry_at_most_16_authorities(backend: Backend) -> anyhow::Result<()> { let Some(h) = Harness::new( backend, @@ -3619,7 +3629,7 @@ async fn hook_tickets_carry_at_most_16_authorities(backend: Backend) -> anyhow:: } on_both_backends!(hook_tickets_carry_at_most_16_authorities); -/// [F13] PostgreSQL latency gate for a policy read (the owner-authority +/// PostgreSQL latency gate for a policy read (the owner-authority /// check adds one indexed revocation query per read). Ignored by default; /// run the same test on the baseline and candidate trees: /// From 93ae21651201130859aa881aae2e56360ffc2dc5 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 08:04:55 +0000 Subject: [PATCH 36/41] Changelog wording and details --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01cbee7c..54a5cee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ - 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 registration refuses a requirement whose issuer, pinned `issuerKid`, credential type or profile is not admitted by the configured issuer set, with `403 credential-issuer-untrusted`. The check runs after the owner's authority is proven and before any stored policy is read; it is inert when no issuer set is configured. At load, a configured issuer entry whose `issuerDid` does not match the held row named in its `profiles` now makes the whole issuer set invalid, and the node refuses to load it. +- Policy registration refuses a requirement whose issuer, pinned `issuerKid`, credential type or profile is not admitted by the configured issuer set, with `403 credential-issuer-untrusted`. The check runs after the owner's authority is proven and before any stored policy is read; it is inert when no issuer set is configured. At load, a configured issuer entry whose `issuerDid` does not match the held row named in its `profiles` makes the whole issuer set invalid, and the node refuses to load it. Details: TC-1530. ## [1.22.0] - 2026-10-23 From 86fc01bf4fdfa2f0a90b3411c4ccb1dbdb51fa75 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 10:49:11 +0000 Subject: [PATCH 37/41] Remove finding labels from source-network comments --- tinycloud-node-server/src/policy_v3.rs | 8 ++++---- .../src/policy_v3/source_network_tests.rs | 2 +- tinycloud-node-server/src/policy_v3/test_harness.rs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index e1a6b2f9..0c97aecd 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -4689,7 +4689,7 @@ impl SourceNetworkRefusal { } } -/// TC-1423 N1b: the encryption networks a policy names (the content source's +/// 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 @@ -8671,7 +8671,7 @@ mod tests { Box::pin(run_policy_v2_flow(PolicyOwner::Unauthorized)).await } - /// TC-1423 (N1b): a policy owner that holds only `kv/get` and + /// 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 @@ -8681,7 +8681,7 @@ mod tests { Box::pin(run_policy_v2_flow(PolicyOwner::KvOnlySelfNamedNetwork)).await } - /// TC-1423 (N1b): the same kv-only policy owner moves the content source + /// 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. @@ -9180,7 +9180,7 @@ mod tests { encryption_backend, ); // Registration requires the source network to exist on this node and - // to be the content owner's (TC-1423 N1b). The self-named network of + // 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 { diff --git a/tinycloud-node-server/src/policy_v3/source_network_tests.rs b/tinycloud-node-server/src/policy_v3/source_network_tests.rs index cfe513c2..41da5a7c 100644 --- a/tinycloud-node-server/src/policy_v3/source_network_tests.rs +++ b/tinycloud-node-server/src/policy_v3/source_network_tests.rs @@ -1,4 +1,4 @@ -//! TC-1423 N1b: every encryption network a Policy/v3 names must be the +//! 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 diff --git a/tinycloud-node-server/src/policy_v3/test_harness.rs b/tinycloud-node-server/src/policy_v3/test_harness.rs index e959c15d..1156662e 100644 --- a/tinycloud-node-server/src/policy_v3/test_harness.rs +++ b/tinycloud-node-server/src/policy_v3/test_harness.rs @@ -448,7 +448,7 @@ impl Harness { }; harness.seed_content().await?; // Registration requires the content owner's network to exist and be - // active (TC-1423 N1b), so the harness creates the network its + // active (TC-1423), so the harness creates the network its // policies name. harness .client From 4df933170b53bf085f284a451237f68d014be4b7 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 10:50:08 +0000 Subject: [PATCH 38/41] Neutral wording in comments, docs and test names text --- docs/policy-owner-authority.md | 6 +++--- .../src/models/policy_v3_owner_binding.rs | 4 ++-- tinycloud-core/src/policy_owner_authority.rs | 4 ++-- tinycloud-node-server/src/config.rs | 2 +- .../src/policy_v3/owner_authority.rs | 10 +++++----- .../src/policy_v3/owner_authority_tests.rs | 14 +++++++------- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/policy-owner-authority.md b/docs/policy-owner-authority.md index 0edf3cb6..62025e96 100644 --- a/docs/policy-owner-authority.md +++ b/docs/policy-owner-authority.md @@ -98,8 +98,8 @@ 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 it was applied less -than a day earlier (a fresh migration, or a re-upgrade that still needs the +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 @@ -109,7 +109,7 @@ The migration is unknown to the previous release image (1.22.0), which refuses t Checklist (tick each step): 1. [ ] Run the three statements above on the database. -2. [ ] If a migration released after this one was applied, delete its `seaql_migrations` row as well, as its release notes say. +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. diff --git a/tinycloud-core/src/models/policy_v3_owner_binding.rs b/tinycloud-core/src/models/policy_v3_owner_binding.rs index 1782e1bd..335adaed 100644 --- a/tinycloud-core/src/models/policy_v3_owner_binding.rs +++ b/tinycloud-core/src/models/policy_v3_owner_binding.rs @@ -2,7 +2,7 @@ //! //! 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 still pending classification. +//! missing row means the policy is pending classification. use sea_orm::entity::prelude::*; @@ -16,7 +16,7 @@ pub struct Model { /// 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 was found (`unknown` rows only). + /// 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. diff --git a/tinycloud-core/src/policy_owner_authority.rs b/tinycloud-core/src/policy_owner_authority.rs index 489d01ab..6c96499d 100644 --- a/tinycloud-core/src/policy_owner_authority.rs +++ b/tinycloud-core/src/policy_owner_authority.rs @@ -24,7 +24,7 @@ pub const OWNER_AUTHORITY_MIGRATION: &str = "m20261019_000000_policy_v3_owner_au #[derive(Clone, Debug, PartialEq, Eq)] pub struct ClosureNode { pub id: Hash, - /// Distance from the nearest leaf the closure was loaded for. + /// Distance from the nearest leaf of the loaded closure. pub depth: u32, pub delegator: String, pub expiry: Option, @@ -112,7 +112,7 @@ pub async fn equivalent_ids(db: &C, id: Hash) -> Result( db: &C, version: &str, diff --git a/tinycloud-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index 13c64dd6..d86a3659 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -2157,7 +2157,7 @@ mod tests { /// 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]`) still loads. The fields are + /// 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] diff --git a/tinycloud-node-server/src/policy_v3/owner_authority.rs b/tinycloud-node-server/src/policy_v3/owner_authority.rs index 9d0e3de9..825c8a28 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority.rs @@ -641,7 +641,7 @@ pub(super) struct OwnerAuthorityState { in_flight: std::sync::Mutex>, /// Slots for background classifications. background: BackgroundSlots, - /// Policies whose last classification was not final, until when. + /// Policies whose last classification is not final, until when. retry_after: std::sync::Mutex>, } @@ -803,8 +803,8 @@ impl PolicyV3Runtime { 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 still - // needs `policy_v3.legacy_cutover` set to the first deploy time. + // 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) => { @@ -830,7 +830,7 @@ impl PolicyV3Runtime { /// 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 still returned, unstored. + /// A classification that cannot be stored is returned, unstored. pub(super) async fn owner_binding( &self, registration: &policy_v3_registration::Model, @@ -1511,7 +1511,7 @@ mod tests { /// A stored registration whose policy names a network that is not its /// content owner's is classified `unknown` / `foreign_network`, whether - /// it was registered before the cutover (`backfill`) or after it + /// 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] diff --git a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs index ae88a5ee..d14ad157 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs @@ -543,7 +543,7 @@ impl Harness { 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 N1b), so the harness creates it. + // content owner's (TC-1423), so the harness creates it. client .rocket() .state::() @@ -1243,7 +1243,7 @@ fn legacy_boot() -> Boot { } // --------------------------------------------------------------------------- -// N1: registration records the owner authority it used. +// Registration records the owner authority it used. // --------------------------------------------------------------------------- async fn registration_records_the_owner_authority(backend: Backend) -> anyhow::Result<()> { @@ -1649,7 +1649,7 @@ async fn a_general_window_full_of_skipped_rows_is_starved(backend: Backend) -> a 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) still leave the +/// 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( @@ -1697,7 +1697,7 @@ 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 still records that grant. +/// own grant; the owner-first step records that grant. async fn owner_derived_delegations_cannot_displace_the_owner_grant( backend: Backend, ) -> anyhow::Result<()> { @@ -1834,7 +1834,7 @@ async fn a_grant_outliving_the_root_is_preferred(backend: Backend) -> anyhow::Re on_both_backends!(a_grant_outliving_the_root_is_preferred); // --------------------------------------------------------------------------- -// N1: legacy registrations are classified on first use, claim-first. +// Legacy registrations are classified on first use, claim-first. // --------------------------------------------------------------------------- async fn concurrent_first_uses_store_one_classification(backend: Backend) -> anyhow::Result<()> { @@ -1908,7 +1908,7 @@ async fn a_failed_classification_write_still_serves(backend: Backend) -> anyhow: } on_both_backends!(a_failed_classification_write_still_serves); -/// A legacy registration whose original authority was revoked binds +/// 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, @@ -2092,7 +2092,7 @@ async fn rollback_and_reupgrade_respect_the_configured_cutover( on_both_backends!(rollback_and_reupgrade_respect_the_configured_cutover); // --------------------------------------------------------------------------- -// N1: listing by any closure member. +// Listing by any closure member. // --------------------------------------------------------------------------- async fn listing_is_limited_to_parties_of_the_delegation(backend: Backend) -> anyhow::Result<()> { From 0684d2fb8a5083756ba02dadc588e9e99591fc19 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 10:50:51 +0000 Subject: [PATCH 39/41] Neutral wording in comments and docs --- docs/policy-owner-authority.md | 2 +- tinycloud-node-server/src/config.rs | 2 +- .../src/policy_v3/owner_authority_tests.rs | 6 +++--- tinycloud-node-server/src/routes/mod.rs | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/policy-owner-authority.md b/docs/policy-owner-authority.md index 36b1a1c1..68957693 100644 --- a/docs/policy-owner-authority.md +++ b/docs/policy-owner-authority.md @@ -23,7 +23,7 @@ Two tables, created by migration `m20261019_000000_policy_v3_owner_authority`: | `root-authority` | every root capability is held as root authority (space or network owner) | | `unknown` | no authority found (`revoked`, `missing`, `starved`), a policy whose stored networks or `kv` spaces do not belong to its content owner (`foreign_network`), or a post-cutover registration that breaks the current rules (`lifetime-exceeded`, `outlives-authority`) | -`registered_mode` is the mode (`record` or `enforce`) a policy was classified +`registered_mode` is the mode (`record` or `enforce`) a policy is classified under, or `backfill` for policies registered before the legacy cutover and classified later. diff --git a/tinycloud-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index 379c6b06..43870ba7 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -1308,7 +1308,7 @@ pub struct PolicyV3Config { /// `TINYCLOUD_POLICY_V3__LEGACY_CUTOVER`. #[serde(default)] pub legacy_cutover: Option, - /// Whether a policy root may outlive the owner authority it was + /// Whether a policy root may outlive the owner authority it is /// registered under. `record` (the default) accepts and counts such /// roots; `enforce` refuses them. Env /// `TINYCLOUD_POLICY_V3__OWNER_AUTHORITY_EXPIRY`. diff --git a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs index 918c0f50..326b1f32 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs @@ -2230,7 +2230,7 @@ async fn insert_synthetic( } // --------------------------------------------------------------------------- -// N2: the root-lifetime cap, the record/enforce mode and the advertised mode. +// The root-lifetime cap, the record/enforce mode and the advertised mode. // --------------------------------------------------------------------------- fn boot(policy_v3: Value) -> Boot { @@ -2444,8 +2444,8 @@ async fn info_advertises_the_owner_authority_mode(backend: Backend) -> anyhow::R } on_both_backends!(info_advertises_the_owner_authority_mode); -/// A legacy registration whose root outlives 31 days is classified as -/// before and counted, not refused. +/// A legacy registration whose root outlives 31 days is classified +/// (`bound`, `backfill`) and counted, not refused. async fn legacy_long_roots_are_counted_not_refused(backend: Backend) -> anyhow::Result<()> { let Some(h) = Harness::new( backend, diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index f01b0cf5..bcc79411 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -365,7 +365,7 @@ fn build_info( } /// The Policy/v3 owner-authority mode, if the node serves Policy/v3. Never -/// fails, so a fixture without the policy runtime still launches. +/// fails, so a fixture without the policy runtime launches. pub struct OwnerAuthorityMode(Option); #[rocket::async_trait] From 3adcabde19addba99935e64f638abebea3aa0c34 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 10:51:43 +0000 Subject: [PATCH 40/41] Neutral wording in comments --- tinycloud-node-server/src/config.rs | 4 ++-- tinycloud-node-server/src/policy_v3.rs | 10 +++++----- .../src/policy_v3/owner_authority.rs | 12 ++++++------ .../src/policy_v3/owner_authority_tests.rs | 10 +++++----- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/tinycloud-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index 2d40e0ec..559caf38 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -1336,8 +1336,8 @@ pub struct PolicyV3Config { #[serde(default)] pub owner_authority_expiry: OwnerAuthorityExpiry, /// Operator escape: serve policies registered before the legacy cutover - /// for which no owner authority was found with the root-only checks - /// instead of denying them. Off by default; logged at boot and per + /// for which no owner authority is recorded with the root-only checks; + /// when off they are denied. Off by default; logged at boot and per /// policy when used. Env `TINYCLOUD_POLICY_V3__LEGACY_UNKNOWN_ALLOW`. #[serde(default)] pub legacy_unknown_allow: bool, diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 44502333..36ae844e 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -298,7 +298,7 @@ impl PolicyV3Runtime { return Err("policy-session-root-graph-mismatch"); } } - // TC-1423: the owner authority must still hold; its code passes + // TC-1423: the owner authority must hold; its code passes // through. self.validate_owner_authority(&self.conn, ®istration, OffsetDateTime::now_utc()) .await?; @@ -2284,9 +2284,9 @@ pub async fn mint( .await .map_err(|e| (Status::Forbidden, e.into()))?; // A policy served under the legacy escape has no recorded authority, so - // the owner must still be shown to have held every root capability when + // the owner must be shown to have held every root capability when // the policy was registered. (Owner sessions may since have expired.) - // Recorded authority replaces this rescan. + // A recorded authority covers this check for other policies. if owner_authority.escaped { let registered_at = parse_time(®istration.registered_at) .map_err(|_| (Status::Forbidden, "policy-registration-invalid".into()))?; @@ -2935,7 +2935,7 @@ impl PolicyV3Runtime { ) .map_err(|_| "enforcer-binding-invalid")?; // TC-1423: the owner authority the roots were registered under must - // still hold. + // hold. self.validate_owner_authority(&self.conn, registration, now) .await } @@ -10693,7 +10693,7 @@ mod tests { ); assert_eq!(refused_body, "policy-owner-authority-revoked"); // Delivery checks the same authority: the retry that returned the - // stored receipt above is now refused. + // stored receipt above is refused. let owner_revoked_delivery = make_invocation( [( content_resource.clone(), diff --git a/tinycloud-node-server/src/policy_v3/owner_authority.rs b/tinycloud-node-server/src/policy_v3/owner_authority.rs index c8e9bae6..b423dbb9 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority.rs @@ -203,7 +203,7 @@ impl Binding { } /// The owner authority a policy use passed: the closure to watch for -/// revocation, and whether it was served under the legacy escape. +/// revocation, and whether it is served under the legacy escape. #[derive(Clone, Debug, Default)] pub struct OwnerAuthority { pub closure: Arc<[Hash]>, @@ -748,7 +748,7 @@ pub(super) struct OwnerAuthorityState { in_flight: std::sync::Mutex>, /// Slots for background writes. background: BackgroundSlots, - /// When each escaped policy was last logged. + /// The last log time of each escaped policy. escape_logged: std::sync::Mutex>, /// Request-path classifications running, one per policy; concurrent uses /// of the policy wait for it and share its result. @@ -1026,7 +1026,7 @@ impl PolicyV3Runtime { } /// Store a computed classification claim-first, count it, and remember - /// it when final. Returns what is now in force. + /// it when final. Returns what is in force. async fn store_classification( &self, policy_cid: &str, @@ -1077,7 +1077,7 @@ impl PolicyV3Runtime { /// loaded once (row and closure) and remembered. A policy without one is /// classified in the request, served from that result, and stored in the /// background; a result that isn't final is reused for - /// [`RETRY_BACKOFF`] instead of recomputed. + /// [`RETRY_BACKOFF`] rather than recomputed. async fn binding_for_use( &self, registration: &policy_v3_registration::Model, @@ -1118,7 +1118,7 @@ impl PolicyV3Runtime { }; let binding = Binding::from_classification(&classification, mode); // The write runs in the background (one per policy, a few at once). - // Without a free slot this request still uses its result; the write + // Without a free slot this request uses its result; the write // is retried once the in-memory result expires. if let Some(claim) = InFlight::claim(state, policy_cid) { let runtime = self.clone(); @@ -2152,7 +2152,7 @@ mod tests { } /// At most `MAX_BACKGROUND_CLASSIFICATIONS` background writes run at - /// once. A use that finds no free slot is still served and starts none; + /// once. A use that finds no free slot is served and starts none; /// once a slot is free, a use of another policy stores its result. #[tokio::test] async fn background_classifications_are_limited_without_backing_off() { diff --git a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs index e531a436..bbd8854f 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs @@ -1209,7 +1209,7 @@ impl Harness { } /// Use the policy (a challenge), wait for its background classification, - /// and check that the use was admitted when the policy has authority + /// and check that the use is admitted when the policy has authority /// (`bound`, `bound-uncapped`, `root-authority`: 200) and refused with /// the fixed code when it has none (`unknown`: 403). pub async fn classify_on_use(&self, policy: &Registered) -> anyhow::Result { @@ -2602,7 +2602,7 @@ async fn post_cutover_rows_outliving_their_authority_are_unknown_under_enforce( on_both_backends!(post_cutover_rows_outliving_their_authority_are_unknown_under_enforce); // --------------------------------------------------------------------------- -// N3: the recorded owner authority is checked whenever a policy is used. +// The recorded owner authority is checked whenever a policy is used. // --------------------------------------------------------------------------- const REVOKED: &str = "policy-owner-authority-revoked"; @@ -2877,7 +2877,7 @@ async fn revoking_the_agent_session_ends_its_policies(backend: Backend) -> anyho (Status::Forbidden, REVOKED.to_owned()) ); assert_eq!(h.signed_kv(&session).await?.0, Status::Forbidden); - // The root itself was never revoked. + // The root itself is not revoked. assert_eq!( h.root_status(&policy.policy_root_cid).await["state"], json!("active") @@ -3131,7 +3131,7 @@ async fn legacy_registrations_without_authority_are_denied_unless_escaped( on_both_backends!(legacy_registrations_without_authority_are_denied_unless_escaped); /// Legacy rows get liveness but no expiry cap: a legacy session -/// whose authority expired since registration still reads, and so does a +/// whose authority expired since registration reads, and so does a /// legacy root longer than 31 days. async fn legacy_registrations_are_never_expiry_capped(backend: Backend) -> anyhow::Result<()> { let Some(h) = Harness::with_boot( @@ -3232,7 +3232,7 @@ async fn the_escape_never_serves_post_cutover_rows(backend: Backend) -> anyhow:: on_both_backends!(the_escape_never_serves_post_cutover_rows); /// An escaped legacy policy serves its admitted sessions, but a mint -/// still needs the owner to have held what the policy grants when it +/// needs the owner to have held what the policy grants when it /// registered. async fn escaped_mints_still_check_the_owner_held_the_capability( backend: Backend, From ca24f6d9508b016ec9b42f43ae4c28ffb477f28b Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 10:52:25 +0000 Subject: [PATCH 41/41] Neutral wording in a comment --- tinycloud-node-server/src/issuer_set.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tinycloud-node-server/src/issuer_set.rs b/tinycloud-node-server/src/issuer_set.rs index c97500f5..da19673e 100644 --- a/tinycloud-node-server/src/issuer_set.rs +++ b/tinycloud-node-server/src/issuer_set.rs @@ -64,8 +64,7 @@ fn held_row(profile: &str) -> Option<&'static HeldRow> { } /// Whether the held-profile row for `profile` permits `issuer_did`. `None` -/// when no held row exists for `profile`, which leaves legacy profiles -/// unchanged. +/// when no held row exists for `profile`; such profiles are not row-checked. fn held_row_permits(profile: &str, issuer_did: &str) -> Option { let permitted = held_row(profile)?.permitted; Some(match permitted {