Skip to content

cap policy root lifetime and add authority mode - #360

Open
samgbafa wants to merge 25 commits into
skgbafa/tc-1423-n1-owner-authority-recordfrom
skgbafa/tc-1423-n2-root-lifetime-cap
Open

samgbafa wants to merge 25 commits into
skgbafa/tc-1423-n1-owner-authority-recordfrom
skgbafa/tc-1423-n2-root-lifetime-cap

Conversation

@samgbafa

@samgbafa samgbafa commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Linear: TC-1423 (part 2 of 3)

Adds a registration-time cap on policy root lifetime, a record/enforce mode for roots that outlive their owner authority, and the mode advertised in GET /info. Stacked on #358.

Changes

  • Lifetime cap, in both modes: registration refuses a root that expires later than registered_at + 31 days + 5 minutes. The response is 403 policy-root-lifetime-exceeded, counted in tinycloud_policy_root_lifetime_refused_total.
    • The cap checks registration only. Policies already registered keep their checks at use.
    • Released SDK roots (nbf set at signing, exp - nbf <= 31 d) pass.
    • A root whose nbf is in the future is refused before the cap runs, by the existing signed time check (policy-root-signature-invalid).
  • policy_v3.owner_authority_expiry (record | enforce, default record; env TINYCLOUD_POLICY_V3__OWNER_AUTHORITY_EXPIRY):
    • record: a root that outlives the recorded authority is stored as bound-uncapped and counted in tinycloud_policy_owner_authority_expiry_exceeded_total{authority_ttl_bucket=le_1h|le_30d|gt_30d}. It is counted once, after commit.
    • enforce: the same root gets 403 policy-root-outlives-owner-authority.
    • registered_mode on the binding row records which mode applied.
  • Unclassified registrations:
    • Before the cutover (legacy): not capped. A root longer than 31 days increments tinycloud_policy_legacy_root_lifetime_exceeded_total.
    • After the cutover with no binding row (written by an older binary): the current mode's rules apply, giving unknown with reason lifetime-exceeded or, under enforce, outlives-authority.
    • tinycloud_policy_owner_binding_backfill_total{outcome} counts the stored classifications and write_failed_served.
  • GET /info / /version: features lists policy-owner-authority-v1 plus exactly one of policy-owner-authority-record and policy-owner-authority-enforce, before the features that are gated by settings. The mode is read through an infallible guard on the policy runtime. No cache header is added.
  • Counters: these are always incremented; they are rare and cheap. They are exported on the node's Prometheus port, which runs only when telemetry.enabled is set. Without it, use the Q1-Q4 counting queries in the docs.
  • docs/policy-owner-authority.md has the mode, the cap, the metrics, the Q1-Q4 read-only counting queries and the criteria for switching to enforce. CHANGELOG entry.

Stack and merge order

Tests

Added to src/policy_v3/owner_authority_tests.rs; each runs on SQLite and on PostgreSQL (CI-only) through the production app.

test covers
root_lifetime_is_capped_in_both_modes refusals for a 32-day root, nbf a year out with a 30-day window, and nbf > exp, in record and enforce; a released-SDK-shaped root (nbf = signing time, exp = nbf + 31 d) is accepted
enforce_refuses_roots_outliving_their_authority 90 s authority with a 1 d root: enforce gives 403; record stores bound-uncapped, and the le_1h counter rises
enforce_chooses_the_grant_that_outlives_the_root under enforce, an older long grant is chosen over a newer short one, so registration succeeds
info_advertises_the_owner_authority_mode default config: -v1 and -record, no -enforce; enforce: -v1 and -enforce, no -record; no Cache-Control
legacy_long_roots_are_counted_not_refused a 41-day legacy root is classified bound/backfill; legacy and backfill counters rise
post_cutover_unclassified_rows_get_the_lifetime_cap unknown/lifetime-exceeded under record
post_cutover_rows_outliving_their_authority_are_unknown_under_enforce unknown/outlives-authority

Unit tests in owner_authority.rs: root_lifetime_cap_boundaries_are_exact (registration + 31 d + 5 min is accepted, one second more is refused; nbf == exp is accepted, nbf one second past exp is refused) and authority_ttl_bucket_edges_are_exact (exactly 1 h is le_1h, 1 h + 1 s is le_30d; exactly 30 d is le_30d, 30 d + 1 s is gt_30d).

The first-use assertions of earlier tests wait for the background classification (classify_on_use). sql_delegated_caveats_feature_is_advertised_only_behind_its_setting passes as written: the owner-authority features are listed before the setting-gated ones.

Notes

  • The cap and the enforce check run before the idempotent re-POST shortcut, so re-posting an existing registration that breaks the current rules gets the same 403.
  • The boot log line carries owner_authority_expiry alongside the cutover and its source.

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-1423

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

# Conflicts:
#	CHANGELOG.md
#	tinycloud-node-server/src/policy_v3/owner_authority.rs
@samgbafa

Copy link
Copy Markdown
Contributor Author

Review at 256f77715acbe326b061ae98085a1f3867bf625a (N2 delta over #358 65df9d5): changes requested (minor).

What I ran (local; SQLite plus a native PostgreSQL 14):

  • GREEN: owner_authority tests 56/56.
  • RED: the N1 head with this test file gives 42 pass / 14 fail. All 14 are the N2 tests, each failing on the missing behaviour.
  • Full tinycloud-node suite: lib 592/592, and every integration binary passes.
  • fmt and clippy -D warnings: clean.
  • Live binary: the default and record configs list policy-owner-authority-v1 and -record. enforce lists -v1 and -enforce. Neither sets Cache-Control. An unknown mode value refuses to boot.

Mutations: removing the cap (both modes, or in either mode alone), flipping the default to enforce, and adding a second mode marker or dropping -v1 are all caught. So are the post-cutover, legacy-counter and flip-counter changes.

  1. Cap and bucket boundaries are not pinned. Four mutations pass all 56 tests: the 5-minute allowance set to 23 h, the allowance set to 0, <= changed to < at the cap, and the le_1h edge moved to 2 h. The HTTP tests can't reach the boundary, because the registration time is "now". Suggested fix: a unit test for root_lifetime_allowed and authority_ttl_bucket:
    • exactly 31 d + 5 min is allowed, +1 s is refused, and nbf > exp is refused;
    • the bucket edges: exactly 1 h, 1 h + 1 s, exactly 30 d, 30 d + 1 s, and none.
  2. Docs, metrics: the counters are always counted, but the Prometheus listener only runs when telemetry.enabled (main.rs:89-101). Please say so, and point to Q1–Q4 for when telemetry is off.
  3. Boot log: the cutover is logged, but the active owner_authority_expiry is not. Suggest adding it as a field on the existing policy_owner_authority boot event.

Nits, no change required:

  • The nbf <= exp term and the two not_before/expiry ok_or checks at registration can't be reached: validate_root_pair and the signed time check already guarantee them. Consider leaving nbf <= exp out of the CHANGELOG and docs wording.
  • The none bucket can't occur for bound-uncapped.
  • The legacy counter counts classification attempts; Q3 gives the exact number.
  • The cap and the enforce check run before the idempotent re-POST return, so re-POSTing an existing row that breaks the current rules now gets 403.
  • Q1–Q4 are sequential scans. The runbook could add statement_timeout and max_parallel_workers_per_gather = 0.

Released SDK roots can't hit the new refusal. Every released signer caps exp - nbf at 31 d, and the signed time check requires nbf <= now. The features parsing in sdk-core checkNodeInfo and in js-sdk #584's readPolicyOwnerAuthorityMode matches these strings.

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

Copy link
Copy Markdown
Contributor Author

Delta check at 65b5db6e105c82f83107ea69dea0f4a2b990e02e: changes requested, text only. The code is fine.

Verified:

  • R1–R3:
    • R1: the two new unit tests catch the skew set to 23 h, the skew set to 0, <= changed to < at the cap, and the le_1h edge moved to 2 h. Removing the cap in either mode, defaulting to enforce, and adding or dropping a mode marker are still caught. My own boundary test passes next to yours.
    • R2: the docs now give the metrics-port condition.
    • R3: the boot line now ends with owner_authority_expiry="record" or "enforce", seen live.
  • Tests:
    • GREEN: 62/62 on SQLite and PostgreSQL.
    • RED: the N1 head 09d7e33 with this test file gives 46 pass / 14 fail, the same 14 N2 tests. 09d7e33 adds N1 tests, so the earlier 42 becomes 46.
    • Full tinycloud-node suite: lib 598/598 on SQLite and 598/598 on PostgreSQL, with all integration binaries green. Under load, 3 account_alias_tests failed once; they passed alone and on the full rerun.
    • fmt and clippy -D warnings: clean.
  • Live /info and /version: default and record list -v1 and -record; enforce lists -v1 and -enforce. There is no Cache-Control header, and a bad mode value refuses to boot.
  • Re-POST and release-cut notes: both are present.

Remaining:

  1. Dead none references:
    • The docs' Q1 still starts with CASE WHEN b.authority_expires_at IS NULL THEN 'none'. That can't match bound-uncapped rows, and the metric no longer has that bucket.
    • The doc comment on observe_policy_owner_authority_expiry_exceeded (prometheus.rs:403) still says "or none".
    • The PR body still lists authority_ttl_bucket=…|none.
  2. nbf <= exp wording: the PR body's "Lifetime cap" bullet still reads "unless exp <= registered_at + 31 days + 5 minutes and nbf <= exp". Suggest the CHANGELOG wording instead.
  3. Nit: the PR body's "Counters" bullet says they don't depend on telemetry.enabled. It should mention the metrics-port condition the docs now give.

@samgbafa

Copy link
Copy Markdown
Contributor Author

APPROVE: see TC-1423
b0c4900

…ty-record' into skgbafa/tc-1423-n2-root-lifetime-cap
@samgbafa

Copy link
Copy Markdown
Contributor Author

APPROVE: see TC-1423
79a2be5

Code-identical to the approved b0c4900 apart from the N1 test-line fix (tree equals merge of b0c4900 + 058eee0; sole diff owner_authority_tests.rs, 1 line).

@samgbafa

Copy link
Copy Markdown
Contributor Author

APPROVE: see TC-1423 (merge-up only; PR change over its base unchanged vs 79a2be5: patch-ids and added/removed lines identical for the 7 own files; head c0d0331)

@samgbafa

Copy link
Copy Markdown
Contributor Author

APPROVE: see TC-1423 (reviewed exact head 0684d2f)

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