Skip to content

Check configured issuer set at policy registration - #396

Draft
samgbafa wants to merge 105 commits into
mainfrom
skgbafa/tc-1530-issuer-registration
Draft

samgbafa wants to merge 105 commits into
mainfrom
skgbafa/tc-1530-issuer-registration

Conversation

@samgbafa

@samgbafa samgbafa commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • At policy registration, refuse a credential requirement whose issuer (and optional issuerKid) is not admitted by the configured issuer set for the requirement's vct and profile.
  • Held-profile rows name the vct and the permitted issuers for tinycloud.email-held/v1, tinycloud.org-membership/v1 and tinycloud.org-membership-free/v1. A configured entry whose issuerDid violates a row named in its profiles makes the whole set invalid at load.
  • With no issuer set configured, the registration check is inert.

Scope

  • Registration time only. Already-stored policies are not re-validated.
  • Registration checks (issuerDid, kid?), vct and profile against the set. Validity windows (notBefore, retiredAt, notAfter) are enforced at verification, not at registration.
  • No list or register consumer changes (T9b).

Order of checks

  • The issuer check runs after owner_holds_capabilities and before any stored registration row is read.
  • Requests from owners without the required capabilities receive no answer about configured issuers.

Spec

  • Held credential profile v1, section 3.3: permitted issuers per row. The email row permits did:web:issuer.credentials.org; the org rows permit did:web:o + 26 [a-z2-7] + .orgs.credentials.org.
  • Section 6: an entry's issuerDid matches the permitted issuers of every row named in its profiles; one entry that does not makes the whole set invalid. A policy names issuerDids and may pin a kid; it cannot add an issuer the verifier's set lacks.

Refusal

  • Registration returns 403 credential-issuer-untrusted when the requirement's issuer is not admitted for its (vct, profile) by the configured set.
  • Profiles with no held row (for example tinycloud.email-proof/v1, tinycloud.email-domain-proof/v1) are not row-checked by this change.

Tests

  • policy_registration_refuses_an_issuer_outside_the_configured_set: full registration route with a configured set that omits the policy's issuer; asserts 403 credential-issuer-untrusted and that no registration or root row is written. The flow wrappers box the future (Box::pin) and take an issuer_trusted argument; the source-network tests pass true.
  • owner_authority_is_checked_before_the_issuer_set: an unauthorised owner naming an untrusted issuer gets the owner refusal.
  • registration_membership_requires_the_row_vct_for_the_profile: an entry listing both vcts is refused for the crossed (vct, profile) pair.
  • registration_membership_follows_did_kid_vct_and_profile: pinned-kid mismatch cases for the org vct and org profile.
  • registration_admits_a_free_row_requirement_the_entry_lists: current behaviour for the free-row profile. Refusal of the free-row requirement belongs to the list-admission work.
  • held_row_permits_matches_the_spec_parity_fixture: 72 (profile, issuerDid) pairs in testdata/held_row_parity.tsv. Expected verdicts come from the section 3.3 rules. This is a fixed set of cases, not an exhaustive comparison.
  • held_row_violation_invalidates_the_whole_set: load-time rejection of a row-violating entry.

Stack

This branch contains the head of #362 (merged in), so that the policy flow wrappers in policy_v3.rs take both the source-network tests and the issuer_trusted argument. Merge order: #363, #358, #360, #362, #396, #399, #392.

Verification

At this head, on SQLite: cargo fmt --all -- --check; cargo clippy --workspace --all-targets and cargo clippy -p tinycloud-core --features duckdb --all-targets, both with RUSTFLAGS=-Dwarnings; node scripts/gen-capabilities.mjs --check; and every test binary of tinycloud-core, tinycloud-node and tinycloud-auth pass, except the three startup_identity cases (the host has a dstack socket, so the node binary refuses to create an identity) and the three account_alias_tests, which time out when other jobs share the host and pass when the tinycloud-node lib tests run alone. PostgreSQL-backed tests run in CI.

Details: TC-1530

samgbafa and others added 30 commits October 18, 2026 15:05
…tions

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.
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.
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.
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.
…ty-record' into skgbafa/tc-1423-n2-root-lifetime-cap
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.
…ty-record' into skgbafa/tc-1423-n2-root-lifetime-cap

# Conflicts:
#	CHANGELOG.md
#	tinycloud-node-server/src/policy_v3/owner_authority.rs
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.
… (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 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.
…ty-record' into skgbafa/tc-1423-n2-root-lifetime-cap
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.
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.
… 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.
…-cap' into skgbafa/tc-1423-n3-owner-authority-liveness

# Conflicts:
#	CHANGELOG.md
#	tinycloud-node-server/src/policy_v3/owner_authority.rs
…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.
…-cap' into skgbafa/tc-1423-n3-owner-authority-liveness
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.
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.
…ty-record' into skgbafa/tc-1423-n2-root-lifetime-cap
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.
…-cap' into skgbafa/tc-1423-n3-owner-authority-liveness
- 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.
dryrun added 17 commits October 24, 2026 01:56
…e the harness network; update rollback runbook
…t-after-revoke check and the use-status check; neutral wording
@samgbafa

Copy link
Copy Markdown
Contributor Author

APPROVE: see TC-1530 (reviewed exact head ca24f6d)

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