Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3df0584
test(TC-1423): cover the policy root lifetime cap and authority mode
samgbafa Oct 18, 2026
e2f33f1
feat(TC-1423): cap policy root lifetime and add the authority mode
samgbafa Oct 18, 2026
dea62a4
Merge remote-tracking branch 'origin/skgbafa/tc-1423-n1-owner-authori…
samgbafa Oct 18, 2026
256f777
Merge remote-tracking branch 'origin/skgbafa/tc-1423-n1-owner-authori…
samgbafa Oct 18, 2026
066546d
Merge remote-tracking branch 'origin/skgbafa/tc-1423-n1-owner-authori…
samgbafa Oct 18, 2026
65b5db6
fix(TC-1423): pin the lifetime cap and flip-signal edges
samgbafa Oct 18, 2026
b0c4900
docs(TC-1423): drop the unreachable none bucket from the flip-signal …
samgbafa Oct 18, 2026
79a2be5
Merge remote-tracking branch 'origin/skgbafa/tc-1423-n1-owner-authori…
samgbafa Oct 18, 2026
c0d0331
Merge commit '93642371f8d84f527d249bdc4c07c58cb49ffac7' into nstack/n2
Oct 19, 2026
5e0f671
Merge skgbafa/tc-1423-n1-owner-authority-record into skgbafa/tc-1423-…
Oct 23, 2026
4cb8a9b
Merge branch 'pol358' into pol360
Oct 23, 2026
bb6f835
Merge branch 'pol358' into pol360
Oct 23, 2026
e7c6ae0
Merge skgbafa/tc-1423-n1-owner-authority-record into skgbafa/tc-1423-…
Oct 23, 2026
e52fdcf
Merge skgbafa/tc-1423-n1-owner-authority-record into skgbafa/tc-1423-…
Oct 23, 2026
c689034
Merge skgbafa/tc-1423-n1-owner-authority-record into skgbafa/tc-1423-…
Oct 23, 2026
c6f1a58
Merge skgbafa/tc-1423-n1-owner-authority-record into skgbafa/tc-1423-…
Oct 24, 2026
facff5e
Advertise the owner-authority features before the setting-gated ones;…
Oct 24, 2026
163ecc6
Merge skgbafa/tc-1423-n1-owner-authority-record into skgbafa/tc-1423-…
Oct 24, 2026
47b86a6
Merge skgbafa/tc-1423-n1-owner-authority-record into skgbafa/tc-1423-…
Oct 24, 2026
81c5f4a
Merge skgbafa/tc-1423-n1-owner-authority-record into skgbafa/tc-1423-…
Oct 24, 2026
610c60d
Merge skgbafa/tc-1423-n1-owner-authority-record into skgbafa/tc-1423-…
Oct 24, 2026
86dee6a
Merge skgbafa/tc-1423-n1-owner-authority-record into skgbafa/tc-1423-…
Oct 24, 2026
2068e65
Drop finding labels from test comments
Oct 24, 2026
40dbcbb
Merge skgbafa/tc-1423-n1-owner-authority-record into skgbafa/tc-1423-…
Oct 24, 2026
0684d2f
Neutral wording in comments and docs
Oct 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
112 changes: 108 additions & 4 deletions docs/policy-owner-authority.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 24 additions & 0 deletions tinycloud-node-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1308,10 +1308,34 @@ pub struct PolicyV3Config {
/// `TINYCLOUD_POLICY_V3__LEGACY_CUTOVER`.
#[serde(default)]
pub legacy_cutover: Option<String>,
/// 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 {
Expand Down
55 changes: 38 additions & 17 deletions tinycloud-node-server/src/policy_v3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(|_| {
Expand All @@ -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);
Expand Down Expand Up @@ -2000,6 +2019,7 @@ pub async fn register_policy(
registration,
&request.policy_cid,
&authority,
registered_mode,
now,
roots,
)
Expand All @@ -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,
Expand All @@ -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<u8>, Vec<u8>); 2],
) -> Result<(), (Status, String)> {
Expand All @@ -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()))?;
Expand Down
Loading