From 3df0584391cb74cf150d36eedc83244990514029 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 15:15:40 +0000 Subject: [PATCH 1/7] 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 e2f33f1e2399e6694f57687e720f3f0b89976529 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 15:26:22 +0000 Subject: [PATCH 2/7] 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 65b5db6e105c82f83107ea69dea0f4a2b990e02e Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 16:50:39 +0000 Subject: [PATCH 3/7] 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 b0c4900ca693ac8c1b2d88e166684e282e7be0eb Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 17:15:34 +0000 Subject: [PATCH 4/7] 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 facff5e92542195cae698c59b52bc6d7ab51c457 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 00:25:34 +0000 Subject: [PATCH 5/7] 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 2068e65091fdc79133f07a73e5ee5dc5c4adac3d Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 07:45:26 +0000 Subject: [PATCH 6/7] 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 0684d2fb8a5083756ba02dadc588e9e99591fc19 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 10:50:51 +0000 Subject: [PATCH 7/7] 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]