diff --git a/CHANGELOG.md b/CHANGELOG.md index 298f7e9f..d4326c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - A local SQL commit failure (answered 500) records the same diagnostic failure note as other SQL 500 responses. Details: TC-1491. - Policy/v3 registration checks the owner and existence of the policy's encryption networks. Every network the policy names (`contentSource.encryptionNetwork` and each `encryption` ceiling entry) must be owned by the owner of the content source's space, compared as principals (a `did:pkh` matches regardless of EIP-55 letter case; the chain id must match), and must exist on this node in state `active`. Every `kv` ceiling entry must name a space of that same owner. Otherwise registration returns `403 content-source-network-invalid` and writes nothing. A new registration whose `kvResource` or any `kv` ceiling entry uses the legacy `tinycloud://` form gets the same refusal; no client emits it. Share and CLI registrations carry one `kv` entry, equal to the source, and name the space owner's `:default` network, which the SDK creates or finds at sign-in, so they are unaffected. Stored registrations are not refused; they are classified with the same ownership rule (see the next entry). Refusals are counted as `tinycloud_policy_source_network_refused_total{reason}`, with reasons `unparsable`, `legacy_resource`, `foreign_owner`, `foreign_space`, `missing` and `inactive`. Details: TC-1423. - 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). A stored policy that names an encryption network or a `kv` space that does not belong to the owner of its content source is classified `unknown` with reason `foreign_network`. 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`. See `docs/policy-owner-authority.md`. Details: 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`). Registrations made before the legacy cutover are not capped; roots over 31 days among them 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`. Details: TC-1423. ## [1.22.0] - 2026-10-23 diff --git a/docs/policy-owner-authority.md b/docs/policy-owner-authority.md index 62025e96..68957693 100644 --- a/docs/policy-owner-authority.md +++ b/docs/policy-owner-authority.md @@ -21,11 +21,115 @@ 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`, `starved` or `foreign_network` | +| `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 `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 is 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 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] +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 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` (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 | + +### 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). A +`bound-uncapped` row always has an authority expiry: + +```sql +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) +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 d86a3659..43870ba7 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -1308,10 +1308,34 @@ pub struct PolicyV3Config { /// `TINYCLOUD_POLICY_V3__LEGACY_CUTOVER`. #[serde(default)] pub legacy_cutover: Option, + /// 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`. + #[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 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 36e0f09c..a5746105 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -1875,19 +1875,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(|_| { @@ -1899,6 +1909,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); @@ -2000,6 +2019,7 @@ pub async fn register_policy( registration, &request.policy_cid, &authority, + registered_mode, now, roots, ) @@ -2011,6 +2031,12 @@ pub async fn register_policy( return Err(err); } } + // 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_expires_at), + ); + } Ok(Json(RegisterResponse { policy_cid: request.policy_cid, policy_root_cid, @@ -2029,6 +2055,7 @@ async fn register_policy_writes( registration: policy_v3_registration::ActiveModel, policy_cid: &str, authority: &owner_authority::Classification, + registered_mode: owner_authority::RegisteredMode, now: OffsetDateTime, roots: [(String, &'static str, Vec, Vec); 2], ) -> Result<(), (Status, String)> { @@ -2045,15 +2072,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, - owner_authority::RegisteredMode::Record, - 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/policy_v3/owner_authority.rs b/tinycloud-node-server/src/policy_v3/owner_authority.rs index 825c8a28..bcd2605a 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::{ @@ -103,6 +104,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, /// The stored policy names a network or kv space that does not belong to /// the owner of its content source (the registration-time source-network /// check, applied to a registration made before that check). @@ -115,6 +122,8 @@ impl UnknownReason { Self::Revoked => "revoked", Self::Missing => "missing", Self::Starved => "starved", + Self::LifetimeExceeded => "lifetime-exceeded", + Self::OutlivesAuthority => "outlives-authority", Self::ForeignNetwork => "foreign_network", } } @@ -124,6 +133,8 @@ impl UnknownReason { "revoked" => Self::Revoked, "missing" => Self::Missing, "starved" => Self::Starved, + "lifetime-exceeded" => Self::LifetimeExceeded, + "outlives-authority" => Self::OutlivesAuthority, "foreign_network" => Self::ForeignNetwork, _ => return None, }) @@ -135,15 +146,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", } } @@ -219,6 +241,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")?, @@ -242,6 +268,41 @@ 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, 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: OffsetDateTime, +) -> &'static str { + 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" + } +} + fn min_expiry(closure: &[ClosureNode]) -> Option { closure.iter().filter_map(|node| node.expiry).min() } @@ -634,6 +695,7 @@ impl CutoverSource { #[derive(Default)] pub(super) struct OwnerAuthorityState { configured_cutover: Option, + expiry: OwnerAuthorityExpiry, migration_cutover: tokio::sync::OnceCell, /// Policies whose stored classification is final. final_policies: std::sync::Mutex>, @@ -768,11 +830,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> { @@ -812,6 +881,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" ) } @@ -819,10 +889,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" ), } @@ -855,6 +927,14 @@ impl PolicyV3Runtime { .await { 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", + }, + ); let classification = Classification { persisted: true, ..classification @@ -871,7 +951,10 @@ impl PolicyV3Runtime { .remember_final(®istration.policy_cid, classification.state); Ok(classification) } - LazyWrite::Failed => Ok(classification), + LazyWrite::Failed => { + crate::prometheus::observe_policy_owner_binding_backfill("write_failed_served"); + Ok(classification) + } } } @@ -916,8 +999,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()) }; // A registration made before the source-network check is held to the // same predicate: a policy over a network or kv space that is not its @@ -937,9 +1021,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, @@ -949,6 +1041,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)) } @@ -1452,6 +1554,47 @@ mod tests { 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}" + ); + } + } + /// A stored registration over a signed policy that names `network` as its /// source, as a registration made before the source-network check could /// have been stored. @@ -1588,6 +1731,8 @@ mod tests { UnknownReason::Missing, UnknownReason::Starved, UnknownReason::ForeignNetwork, + UnknownReason::LifetimeExceeded, + UnknownReason::OutlivesAuthority, ] { assert_eq!(UnknownReason::parse(reason.as_str()), Some(reason)); } 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 d14ad157..326b1f32 100644 --- a/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs +++ b/tinycloud-node-server/src/policy_v3/owner_authority_tests.rs @@ -2228,3 +2228,333 @@ async fn insert_synthetic( } Ok(()) } + +// --------------------------------------------------------------------------- +// 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() +} + +/// 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}"); + 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`. + 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); + +/// 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); + +/// 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, + 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")], + ); + let binding = h.classify_on_use(&policy).await?; + 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); + +/// 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?; + let binding = h.classify_on_use(&policy).await?; + 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); + +/// 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?; + let binding = h.classify_on_use(&policy).await?; + 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); diff --git a/tinycloud-node-server/src/prometheus.rs b/tinycloud-node-server/src/prometheus.rs index 78035a6b..2d9a22c2 100644 --- a/tinycloud-node-server/src/prometheus.rs +++ b/tinycloud-node-server/src/prometheus.rs @@ -453,3 +453,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) or `gt_30d`. +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 da081fa4..bcc79411 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -308,6 +308,7 @@ fn build_info( encryption: &State, config: &State, database: Option<&tinycloud_core::RecoveringConnection>, + owner_authority: Option, ) -> NodeInfo { #[allow(unused_mut)] let mut features = vec!["kv", "delegation", "sharing", "sql"]; @@ -322,6 +323,17 @@ 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. 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 { + crate::config::OwnerAuthorityExpiry::Record => "policy-owner-authority-record", + crate::config::OwnerAuthorityExpiry::Enforce => "policy-owner-authority-enforce", + }); + } if config.encryption_network.named_keys_v1 { features.push("encryption-named-keys-v1"); } @@ -352,6 +364,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 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>); @@ -378,8 +410,16 @@ pub fn info( encryption: &State, config: &State, pool: NodePool<'_>, + owner_authority: OwnerAuthorityMode, ) -> Json { - Json(build_info(tee, quota_cache, encryption, config, pool.0)) + Json(build_info( + tee, + quota_cache, + encryption, + config, + pool.0, + owner_authority.0, + )) } #[get("/version")] @@ -389,8 +429,16 @@ pub fn version( encryption: &State, config: &State, pool: NodePool<'_>, + owner_authority: OwnerAuthorityMode, ) -> Json { - Json(build_info(tee, quota_cache, encryption, config, pool.0)) + Json(build_info( + tee, + quota_cache, + encryption, + config, + pool.0, + owner_authority.0, + )) } #[allow(clippy::let_unit_value)]