Skip to content

Check the owner and existence of policy source networks at registration - #363

Open
samgbafa wants to merge 15 commits into
mainfrom
tc-1423/n1b-source-network-owner
Open

samgbafa wants to merge 15 commits into
mainfrom
tc-1423/n1b-source-network-owner

Conversation

@samgbafa

@samgbafa samgbafa commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Check the owner and existence of policy source networks at registration (TC-1423).

Change

POST /policy/v3/policies checks every encryption network a new policy names: contentSource.encryptionNetwork and each encryption capability-ceiling entry. Each one must:

  1. parse as a network id (urn:tinycloud:encryption:<ownerDid>:<name>);
  2. be owned by the owner of the content source's space. The owner is ResourceId::space().did() of contentSource.kvResource, compared with did_principal_matches, so EIP-55 letter case is ignored and the chain id must match;
  3. exist on this node in state active. The lookup uses the encryption_network row key and the same active rule as decrypt.

Every kv ceiling entry must also name a space of that same owner, so that every resource a policy grants belongs to one owner. A ceiling carries only kv and encryption (decrypt) entries: registration_projections refuses any other kind with 400, and the check fails closed on one too.

New registrations with a legacy tinycloud:// kvResource or kv ceiling entry are refused too. No client emits that form, and it names no space owner to compare with.

Every refusal returns 403 content-source-network-invalid and writes nothing. It is counted in tinycloud_policy_source_network_refused_total{reason}, where reason is one of unparsable, legacy_resource, foreign_owner, foreign_space, missing or inactive. There are no per-owner labels.

Compatibility (code-read: js-sdk origin/master 129c793, share origin/main c6488e7)

  • Share encrypts to urn:tinycloud:encryption:did:pkh:eip155:1:<address lowercased>:default (src/share/openkey-session.ts:72-75). It registers the node's descriptor spelling of that network (composer.ts:378; sdk-services EncryptionService.ts:150-163). The space id is the same did:pkh, checksummed. Both spellings match the space owner here.
    • The session's manifest grants decrypt + network.create on that URN (openkey-session.ts:97).
    • signIn ensures the network before any publish (node-sdk TinyCloudNode.ts:2461, :3404-3433, :5971-5995).
  • CLI tc share registers getEncryptionNetworkIdForSpace(ownerSpaceId) = urn:tinycloud:encryption:<space owner DID>:default (cli/src/share/adapters.ts:599-619; TinyCloudNode.ts:4901-4921). encryptToNetwork resolves the network first, so a missing network fails before registration.
  • Released policies carry exactly one kv ceiling entry, equal to contentSource.kvResource, and one encryption entry, equal to the source network (share-sdk/src/addressed-publish.ts:333-336; the CLI and Share both publish through it). Their kvResource is the ordinary tinycloud:<did>:<space>/kv/<path> form; neither repo emits tinycloud://.
  • Released SDK against this binary: @tinycloud/node-sdk 3.3.0-beta.7 and @tinycloud/share-sdk 1.1.1, using publishAddressedShare with Share's manifest. The results are under Verification.

Tests

  • policy_v3::tests::policy_source_network_must_belong_to_the_content_owner: a policy owner with kv get + metadata only registers over a network named after itself that does not exist. Refused.
  • policy_v3::source_network_tests uses the real /delegate, /encryption/networks and registration routes:
    • missing network;
    • pending, rotating and revoked networks (and active passes);
    • every ceiling network is checked (a self-named extra entry, or a second owner network that does not exist);
    • an existing network owned by someone else, with that owner's decrypt grant;
    • legacy tinycloud:// source;
    • unparsable network;
    • the Share shape: a did:pkh owner, the lowercase :default URN in the CACAO and the policy, and the network created through the route with the SDK's checksummed invocation. Passes;
    • :share on a did:pkh owner (passes);
    • a chain-id mismatch (eip155:137 vs :1);
    • postgres_source_network_check, which runs the same lookups on PostgreSQL when TINYCLOUD_TEST_POSTGRES_URL is set.
  • kv ceiling entries:
    • policy_v3::tests::policy_kv_entries_must_belong_to_the_content_owner: the source is in the key's own space on its own network, plus a kv entry on another owner's content. Refused with 403.
    • kv_entry_outside_the_content_owner_is_refused: the key creates its network through the route with no proof.
    • Controls: own_space_on_own_network_registers, kv_entries_all_owned_by_the_content_owner_register (a second space of the same owner), legacy_kv_entry_is_refused, unparsable_or_unknown_ceiling_entry_fails_the_predicate.
  • The Policy/v2 flow fixtures create the content owner's :mainnet network they register against. Their test wrappers box the flow future, which had reached the default test-thread stack.

Verification (local only; no Docker, no prod)

  • Test results. cargo test -p tinycloud-node --lib policy_v3:: runs 55 passed, 0 failed (with PostgreSQL). source_network_tests and the flow tests pass on SQLite and PostgreSQL. Run against a tree without the check, the registration tests that expect 403 get 200 (five cases), or are refused only later as policy-owner-not-authorized (the legacy and unparsable cases); the Share and :share cases pass either way.

  • Mutation check. Comparing against the space-id string (resource.space().to_string()) rather than the space DID fails 9 tests, including the Share shape and the three flow tests.

  • Lint and format. cargo clippy --workspace --all-targets -- -D warnings is clean, and cargo fmt --all -- --check passes.

  • cargo test --workspace --no-fail-fast on SQLite (CI unset): 1249 passed, 3 failed. The three failures are timing tests in tinycloud-core under host load average ~50 (PoolTimedOut, database is locked, Elapsed). All three pass when re-run alone.

  • Same, against native PostgreSQL 14 (TINYCLOUD_TEST_POSTGRES_URL): 1251 passed, 1 failed. The failure is postgres_space_filter_survives_a_collation_that_ignores_the_sentinel, because PG 14 has no collation rules (CI uses PG 16). It is unrelated to this change.

  • Released SDK (@tinycloud/node-sdk 3.3.0-beta.7, @tinycloud/share-sdk 1.1.1, publishAddressedShare with Share's manifest), against a local node:

    registration without this check with this check
    Share: descriptor spelling (checksummed) registered registered
    Share: lowercase :default URN registered registered
    session-named network (no client does this) registered 403 content-source-network-invalid
    owner's name, network not created 403 policy-owner-not-authorized 403 content-source-network-invalid

    The publish then stops at the envelope's https-origin check, because the local node is http. Registration is the step under test.

  • Merge with record owner authority for policy registrations #358. Two additive conflicts: the test-module list and the CHANGELOG. record owner authority for policy registrations #358's owner_authority_tests register on a :mainnet network they do not create, so record owner authority for policy registrations #358 carries a fixture change (create the network after boot_app); the patch is in the comment below.

    • With it, all policy_v3:: tests pass on the merge, on SQLite and PostgreSQL.

Related change

#358 classifies stored registrations with content_owner_networks; the exact change is in the PR comment below.

Details: TC-1423

… (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.
…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.
… 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.
…istence 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.
@samgbafa

Copy link
Copy Markdown
Contributor Author

For whichever of #358 and this PR merges second: the fixture change for #358's harness

owner_authority_tests registers on urn:tinycloud:encryption:<space owner>:mainnet but never creates it. After merging with this PR, 21 of those tests get 403 content-source-network-invalid. With this change, all 95 policy_v3:: tests pass on the merge (SQLite and PostgreSQL):

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 9911c5b..648dbb7 100644
--- a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs
+++ b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs
@@ -483,6 +483,18 @@ 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 be the
+        // content owner's (TC-1423 N1b).
+        client
+            .rocket()
+            .state::<tinycloud_core::encryption_network::EncryptionService>()
+            .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?;
         let harness = Self {
             client,
             db,

Follow-up for the N1 builder (#358, head 09d7e33): review item m3. It isn't part of this PR.

The goal is to classify Policy/v3 rows registered before N1b with the same predicate that N1b applies at registration (parse, owner and legacy form, but not existence). A stored policy whose networks fail it becomes unknown with reason foreign_network. It is then denied under O2 and restored only by the escape flag.

Change in tinycloud-node-server/src/policy_v3/owner_authority.rs:

  1. Add UnknownReason::ForeignNetwork, with as_str()/parse() value "foreign_network". It is final, like revoked and missing (only starved is retried).
  2. In PolicyV3Runtime::classify_registration, decode the stored policy first (validate_registration_projection(registration)), then call the N1b predicate before classify(...):
    let policy = validate_registration_projection(registration)
        .map_err(|_| "policy-registration-projection-mismatch")?;
    if super::content_owner_networks(&policy).is_err() {
        return Ok((Classification::unknown(UnknownReason::ForeignNetwork), mode));
    }
    • Classification::unknown is a constructor with no closure and no authority expiry. If none exists, use from_selection(Err(...)).
    • content_owner_networks lives in policy_v3.rs (added by N1b). It is a private function of the parent module, so the child module can call it.
    • Apply this in both modes (Backfill and Record). A Record row without a binding comes from a pre-N1b binary in a rollback window or a mixed deploy.
  3. Registration (register_policy) needs no change: N1b refuses these policies before N1 records anything.
  4. CLASSIFIER_VERSION: no bump is needed if this lands in record owner authority for policy registrations #358 before the first release with N1. If N1 has already shipped, bump it to 3, and also re-classify bound and root-authority rows whose policy fails the predicate. A version bump alone only re-classifies unknown rows.
  5. Count first (Q-query). Before enabling denial, report how many rows the change would flip. Use a read-only pass over policy_v3_registration (registered_at before the N1b deploy) that runs content_owner_networks on policy_bytes and counts failures by reason. Put the count in the existing backfill sweep log line, or in a one-off tinycloud admin read. The predicate needs principal canonicalisation, so it is computed in Rust, not SQL.
  6. Test (review item g). Insert a registration of the reproduction shape directly: kv-only owner, source network named after the policy owner, registered_at before the cutover. Then assert:
    • classification is unknown / foreign_network;
    • the next read through it is denied under O2;
    • the escape flag restores it.

@samgbafa

Copy link
Copy Markdown
Contributor Author

Review of 1bfa6cf240bba0bd028a0f61dfa2d62480e0ecdd (TC-1423 N1b). Local only, with native PostgreSQL 14. No approval at this head: one major finding is below.

B1 (major): a second kv ceiling entry bypasses the check.
content_owner_networks takes the owner from contentSource.kvResource alone. registration_projections requires the source kv entry in the ceiling but allows more kv entries in any space.

The shape:

  • K holds only kv get + metadata on V's content, delegated by V. No decrypt.
  • K creates urn:tinycloud:encryption:<K>:share with a self-signed network.create (200).
  • K registers a policy whose contentSource.kvResource is in K's own space (tinycloud:key:<K>:applications/kv/...) and whose source network is <K>:share. The ceiling has kv on that source, kv on V's content, and decrypt on <K>:share.

The network check passes, because K's network is compared with K's space. The owner check passes too.

Observed at this head, with the existing run_policy_v2_flow harness and the real routes:

  • registration 200;
  • delivery authorization issued to K;
  • the admitted recipient reads V's content (200, 19 bytes);
  • after V revokes K's grant, the recipient reads again: 200, 19 bytes.

This is the same outcome as the original reproduction at 989078c.

Suggested fix: in content_owner_networks, require every kind: "kv" ceiling entry to be non-legacy, to parse as a ResourceId, and to name a space whose space().did() matches content_owner (did_principal_matches). Otherwise refuse with foreign_owner, or a new fixed reason such as foreign_space.

for capability in policy.get("capabilityCeiling").and_then(Value::as_array).into_iter().flatten()
    .filter(|c| c.get("kind").and_then(Value::as_str) == Some("kv"))
{
    let value = capability.get("resource").and_then(Value::as_str).unwrap_or_default();
    if value.starts_with("tinycloud://") { return Err(SourceNetworkRefusal::LegacyResource); }
    let space = value.parse::<ResourceId>().map_err(|_| SourceNetworkRefusal::Unparsable)?;
    if !did_principal_matches(space.space().did().as_str(), content_owner.as_str()) {
        return Err(SourceNetworkRefusal::ForeignOwner);
    }
}

With this added:

  • all 49 policy_v3:: tests pass (SQLite + PostgreSQL);
  • the B1 registration gets 403 content-source-network-invalid;
  • K registering its own space on its own network still gets 200.

Released clients send exactly one kv entry, equal to contentSource.kvResource (share-sdk/src/addressed-publish.ts:333-336), so they are unaffected. The same predicate feeds the m3 classifier follow-up for #358.

Please add regression tests: the registration case in source_network_tests, and a run_policy_v2_flow variant asserting 403. Also update the CHANGELOG bullet and the doc comment ("every kv capability names a space of the same owner").

Verified as described:

  • Original reproduction: refused here. Accepted on a clean 989078c build and at b4eef99.
  • RED at 81560c6: 7 of 9 fail, for the stated reasons.
  • Mutations (each caught): space-id string instead of DID, drop existence, drop active, drop chain id, accept legacy, source network only, check after the owner check, drop the check. Exact == instead of did_principal_matches is an equivalent mutant: both sides are already canonical.
  • Lint: cargo fmt --check and cargo clippy --workspace --all-targets -D warnings are clean.
  • No new existence oracle: network state is already public via GET /encryption/networks/<id>. The metric has 5 fixed label values.
  • Merge with record owner authority for policy registrations #358 (09d7e33): without the fixture, 21 tests × 2 backends fail. With the 12-line patch above, 95/95 pass on SQLite and on PostgreSQL.
  • CHANGELOG: a single ### Changes heading, with every pending bullet present verbatim.

Details: TC-1423 (Linear).

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.
… 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.
@samgbafa

Copy link
Copy Markdown
Contributor Author

Review B1 addressed at 85fee4a (new commits, no force-push: 4a8b42f RED tests, 85fee4a fix).

  • content_owner_networks now also requires every kv ceiling entry to be non-legacy, to parse, and to name a space whose DID matches the content source's owner (did_principal_matches). Otherwise the refusal is the new fixed reason foreign_space (or legacy_resource / unparsable). A ceiling entry of any kind other than kv/encryption fails the predicate.
  • What else a ceiling can carry (checked): registration_projections admits only kind: kv (actions tinycloud.kv/get|list|metadata|put, resource parsed as a kv-service resource) and kind: encryption (action tinycloud.encryption/decrypt only, already covered by the network checks). Any other kind or action is a 400. The roots' attenuation must equal the projected ceiling (validate_root_pair), so roots cannot carry extra resources, and sessions are minted within the roots. Nothing else, such as sql, hooks or space, can be granted.
  • Red: the reviewer's tests failed at 1bfa6cf (b1-red-reviewer-tests-1bfa6cf.txt). The regression tests failed at 4a8b42f: 3 of 54.
  • Green: policy_v3:: 55/55 on SQLite and on native PostgreSQL 14; clippy -D warnings and fmt clean.
  • record owner authority for policy registrations #358 clash: with the same 12-line fixture patch (above), 101/101 policy_v3:: on the merge, on SQLite and PostgreSQL.

Updated m3 note (supersedes the one above):

Follow-up for the N1 builder (#358, head 09d7e33): review item m3. It isn't part of this PR.

The goal is to classify Policy/v3 rows registered before N1b with the same predicate that N1b applies at registration (parse, network owner, every kv ceiling entry in a space of the content owner, and legacy form, but not existence). A stored policy whose networks fail it becomes unknown with reason foreign_network. It is then denied under O2 and restored only by the escape flag.

Change in tinycloud-node-server/src/policy_v3/owner_authority.rs:

  1. Add UnknownReason::ForeignNetwork, with as_str()/parse() value "foreign_network". It is final, like revoked and missing (only starved is retried).
  2. In PolicyV3Runtime::classify_registration, decode the stored policy first (validate_registration_projection(registration)), then call the N1b predicate before classify(...):
    let policy = validate_registration_projection(registration)
        .map_err(|_| "policy-registration-projection-mismatch")?;
    if super::content_owner_networks(&policy).is_err() {
        return Ok((Classification::unknown(UnknownReason::ForeignNetwork), mode));
    }
    • Classification::unknown is a constructor with no closure and no authority expiry. If none exists, use from_selection(Err(...)).
    • Since the review fix for B1 on Check the owner and existence of policy source networks at registration #363, the predicate also refuses a kv ceiling entry outside the content owner's spaces (foreign_space). Pre-N1b rows of the B1 shape (source moved to the registrant's own space, the owner's content as a second kv entry) are therefore classified unknown/foreign_network too. Every Err reason maps to that one classifier reason.
    • content_owner_networks lives in policy_v3.rs (added by N1b). It is a private function of the parent module, so the child module can call it.
    • Apply this in both modes (Backfill and Record). A Record row without a binding comes from a pre-N1b binary in a rollback window or a mixed deploy.
  3. Registration (register_policy) needs no change: N1b refuses these policies before N1 records anything.
  4. CLASSIFIER_VERSION: no bump is needed if this lands in record owner authority for policy registrations #358 before the first release with N1. If N1 has already shipped, bump it to 3, and also re-classify bound and root-authority rows whose policy fails the predicate. A version bump alone only re-classifies unknown rows.
  5. Count first (Q-query). Before enabling denial, report how many rows the change would flip. Use a read-only pass over policy_v3_registration (registered_at before the N1b deploy) that runs content_owner_networks on policy_bytes and counts failures by reason. Put the count in the existing backfill sweep log line, or in a one-off tinycloud admin read. The predicate needs principal canonicalisation, so it is computed in Rust, not SQL.
  6. Test (review item g). Insert a registration of the reproduction shape directly: kv-only owner, source network named after the policy owner, registered_at before the cutover. Then assert:
    • classification is unknown / foreign_network;
    • the next read through it is denied under O2;
    • the escape flag restores it.

@samgbafa

Copy link
Copy Markdown
Contributor Author

APPROVE: see TC-1423
85fee4a

@samgbafa

Copy link
Copy Markdown
Contributor Author

APPROVE: see TC-1423 (reviewed exact head 86fc01b)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant