Skip to content

check owner authority on policy share use - #362

Open
samgbafa wants to merge 24 commits into
skgbafa/tc-1423-n2-root-lifetime-capfrom
skgbafa/tc-1423-n3-owner-authority-liveness
Open

samgbafa wants to merge 24 commits into
skgbafa/tc-1423-n2-root-lifetime-capfrom
skgbafa/tc-1423-n3-owner-authority-liveness

Conversation

@samgbafa

@samgbafa samgbafa commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Linear: TC-1423 (part 3 of 3)

Every Policy/v3 use checks the owner authority that #358 recorded, after the existing root checks. A revoked owner delegation, or any revoked ancestor, ends the policies of already admitted sessions. Stacked on #360.

Changes

  • validate_owner_authority (policy_v3/owner_authority.rs) runs from:

    • authorize_roots, which covers reads, decrypts and re-shared children of admitted sessions, re-share admission, signed-KV and hook-ticket mints, and challenge and mint;
    • S0 admission, which passes the code through;
    • delivery authorization, which returns delivery-authorization-invalid;
    • the mint's locked recheck, which runs on the mint transaction.
  • Outcomes:

    state at use
    bound* a revoked closure member, including one revoked under an alias CID, gives 403 policy-owner-authority-revoked
    bound, not legacy past its authority expiry: policy-owner-authority-expired
    root-authority nothing more
    unknown policy-owner-authority-unknown

    Legacy (backfill) rows are not expiry-checked.

  • Legacy escape: policy_v3.legacy_unknown_allow (default off). Legacy unknown rows get root-only checks plus the mint's registration-time rescan. Logged with a warning at boot, and per policy at most once an hour. Counted in tinycloud_policy_owner_authority_unknown_allowed_total{reason}.

  • Mint: the registration-time owner_holds_capabilities rescan runs only for escaped policies; for the others the recorded authority covers it.

  • Typed codes:

    • An expired root gives root-expired, at validation and at the status check. Other non-live roots give root-not-live.
    • An expired session in a chain gives policy-session-expired; the signature is verified before the time window.
    • No code carries a CID or DID.
  • Cache and cost:

    • What to check (state, mode, expiry, closure IDs) is cached in process once a classification is final. The cache is bounded. Liveness is not cached.
    • Steady-state cost: one indexed alias-aware revocation query for a recorded closure, none for root authority.
    • A pending policy is classified in the request and served from the result. The write runs in the background, behind a three-slot limit and a drop guard. A result that is not final is reused for 60 s.
    • Concurrent first uses of one policy share a single classification (an OnceCell per policy that waiters await). The result is remembered before the entry is dropped, and a use that finds no entry checks memory under the same lock. No lock is held across an await. If the computing request is cancelled, a waiter takes over.
  • Hooks (routes/hooks.rs):

    • Tickets carry a policy session's closure IDs. A ticket with more than 16 is refused with 403 hook-ticket-authority-too-large.
    • Open /hooks/events streams re-check their authority before delivering an event, at most every hooks.authority_recheck_seconds (5), and every hooks.idle_authority_recheck_seconds (30) when idle.
    • A stream closes on a definite revocation, or after 3 consecutive failed checks spaced at least an interval apart. Until a check succeeds, events are withheld (dropped) without querying, so a burst of events during a brief DB error does not close a healthy stream.
    • The check, delegation_chains_unrevoked in core db.rs, is one closure CTE plus one batched revocation query, whatever the depth.
    • Policies grant only kv and decrypt, so a policy session does not hold a hook ticket; tests assert that refusal. The stream re-check applies to every ticket.
  • Listing: ownerAuthorityState shows revoked when a closure member is revoked and expired for a non-legacy bound authority past expiry.

  • Metrics: tinycloud_policy_owner_authority_denied_total{reason=revoked|expired|unknown}.

Stack and merge order

Tests

  • owner_authority_tests.rs (SQLite; PostgreSQL in CI): revoking the owner's grant, an ancestor, or a grant stored under an alias CID ends admitted sessions (read, decrypt, re-shared child, challenge, mint, signed-KV mint, re-share admission) with a fixed opaque code; all-of grants; typed expiry codes; legacy unknown is denied unless escaped; legacy rows are not expiry-capped; the escape does not cover post-cutover rows; escaped mints check that the owner held the capability; an admitted session is not re-imported after a revoke; concurrent first uses share one classification; hook streams close after a revoke and after repeated check errors; a ticket carries at most 16 authority IDs; a PostgreSQL latency gate (ignored by default).
  • policy_v3::tests::policy_owner_may_be_a_session_the_space_owner_delegated_to: once the space owner revokes the policy owner's delegation, the admitted session, a new challenge (403 policy-owner-authority-revoked), a mint with a challenge issued before the revoke (403 policy-owner-authority-revoked) and delivery are all refused.
  • Unit tests in owner_authority.rs (in-memory SQLite, counted queries): steady_state_checks_cost_one_query_or_none; final_classifications_cost_the_request_path_nothing; background_classifications_are_limited_without_backing_off (three slots, then none; a use without a free slot is served and stores nothing; a later use stores); non_final_classifications_back_off (a result that could not be stored is reused for the back-off window, with no query and no new classification); unknown_legacy_classifications_are_denied_unless_allowed; expired_bound_authority_is_denied_except_legacy; a_panicking_classification_releases_its_claim.
  • Mutation check: back-off set to 0 s and slots set to 4096 fail non_final_classifications_back_off and background_classifications_are_limited_without_backing_off.
  • Harness::classify_on_use checks the status of the use: 200 for bound, bound-uncapped and root-authority, 403 policy-owner-authority-unknown for unknown.

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 a few timing-sensitive lib tests that 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-1423

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.
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.
…-cap' into skgbafa/tc-1423-n3-owner-authority-liveness

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

Copy link
Copy Markdown
Contributor Author

Review at 80ef3af5b0884cef33672c53e4303157e837d4dd. The N3 code is unchanged since 46d04ae; mutations and the benchmark ran at 002642e.

Verdict: changes requested (minor). No blocker.

The check runs on every path that admits or serves a policy session: reads, decrypt, re-shared children, re-share admission, signed-KV and hook-ticket mints, challenge, mint plus the locked recheck, S0 admission, delivery, and the listing state. It is strict all-of and alias-aware; the escape flag is legacy-only and off by default; the codes are fixed strings.

Run locally on SQLite and native PostgreSQL 14:

  • Full tinycloud-node lib suite: 621 passed, 0 failed, 1 ignored. The N3 set is 86 ok.
  • fmt and clippy -D warnings: clean.
  • The head test file run on N2 65b5db6: 20 failures, all for the expected reasons.
  • Trial merge with fix(TC-1423): typed scope for revoke controls #356: the only conflict is CHANGELOG, and the suite is green.
  • Statement count: +1 statement per policy read (14 095 → 14 505 over 430 reads).

Requested:

  1. hook_streams_close_after_their_grant_is_revoked
    • It asserts !body.contains("event: write"), but Rocket emits event:write (no space), so the assertion always holds.
    • With the pre-yield re-check removed, the suite still passes. With the string fixed, the test fails under that mutation and passes on head.
    • Please match event:write, add a positive control (one event delivered before the revoke), and use a long idle interval in that half so only the pre-yield check can close the stream.
  2. Tests for sites whose mutation survives the suite:
    • S0 re-import after a revoke returns 403 policy-owner-authority-revoked.
    • Delivery authorize after a revoke returns 403 delivery-authorization-invalid.
    • legacy_unknown_allow = true doesn't serve a post-cutover unknown row.
    • An escaped legacy mint still runs the as-of-registration rescan (policy-owner-not-authorized).
  3. Stream re-check after an error. AuthorityRecheck::due() is true whenever consecutive_errors > 0, so three matching events in quick succession during a brief DB error close the stream. Please keep the interval (or a ≥1 s backoff) after an error, and withhold events without querying meanwhile.
  4. First-use classification runs once per concurrent request for the same policy; only the write is deduplicated. A per-policy single-flight would bound the work right after deploy.

Info:

  • policy-owner-authority-expired can't be reached for stored rows: bound implies root exp ≤ authority exp, and the root and session checks run first.
  • The debug-build benchmark can't resolve a one-query change (p50 is about 200 ms). A release build plus the statement count is better evidence.
  • The CHANGELOG lists delivery under policy-owner-authority-revoked; delivery actually returns delivery-authorization-invalid.

Details: TC-1423.

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

Copy link
Copy Markdown
Contributor Author

APPROVE: see TC-1423
9c65a0e

@samgbafa

Copy link
Copy Markdown
Contributor Author

APPROVE: see TC-1423 (merge-up only; PR change over its base unchanged vs 9c65a0e: patch-ids and added/removed lines identical for the 10 own files; head 5ae33ce)

dryrun added 14 commits October 23, 2026 22:44
…t-after-revoke check and the use-status check; neutral wording
@samgbafa

Copy link
Copy Markdown
Contributor Author

APPROVE: see TC-1423 (reviewed exact head 3adcabd)

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