Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b9d2177
test(TC-1423): cover checking the owner authority on policy use
samgbafa Oct 18, 2026
2972b5e
feat(TC-1423): check owner authority on policy share use
samgbafa Oct 18, 2026
46d04ae
Merge remote-tracking branch 'origin/skgbafa/tc-1423-n2-root-lifetime…
samgbafa Oct 18, 2026
002642e
Merge remote-tracking branch 'origin/skgbafa/tc-1423-n2-root-lifetime…
samgbafa Oct 18, 2026
c7d587e
test(TC-1423): import re-shared children once they are valid
samgbafa Oct 18, 2026
80ef3af
Merge remote-tracking branch 'origin/skgbafa/tc-1423-n2-root-lifetime…
samgbafa Oct 18, 2026
9c65a0e
fix(TC-1423): close review gaps in owner-authority checks
samgbafa Oct 18, 2026
5ae33ce
Merge commit 'c0d033136b45bfaa198a64f869b2eadd3ee08ae2' into nstack/n3
Oct 19, 2026
5576fce
Merge skgbafa/tc-1423-n2-root-lifetime-cap into skgbafa/tc-1423-n3-ow…
Oct 23, 2026
f9235c0
Merge branch 'pol360' into pol362
Oct 23, 2026
ed49a5e
Merge branch 'pol360' into pol362
Oct 23, 2026
ad03e71
Merge skgbafa/tc-1423-n2-root-lifetime-cap into skgbafa/tc-1423-n3-ow…
Oct 23, 2026
b46d89c
Merge skgbafa/tc-1423-n2-root-lifetime-cap into skgbafa/tc-1423-n3-ow…
Oct 23, 2026
11895ec
Merge skgbafa/tc-1423-n2-root-lifetime-cap into skgbafa/tc-1423-n3-ow…
Oct 23, 2026
bb32c4c
Merge skgbafa/tc-1423-n2-root-lifetime-cap into skgbafa/tc-1423-n3-ow…
Oct 24, 2026
c260bac
Merge skgbafa/tc-1423-n2-root-lifetime-cap into skgbafa/tc-1423-n3-ow…
Oct 24, 2026
e8cd8bb
Merge skgbafa/tc-1423-n2-root-lifetime-cap into skgbafa/tc-1423-n3-ow…
Oct 24, 2026
1a86b92
Merge skgbafa/tc-1423-n2-root-lifetime-cap into skgbafa/tc-1423-n3-ow…
Oct 24, 2026
155bcfc
Merge skgbafa/tc-1423-n2-root-lifetime-cap into skgbafa/tc-1423-n3-ow…
Oct 24, 2026
d845a30
Merge skgbafa/tc-1423-n2-root-lifetime-cap into skgbafa/tc-1423-n3-ow…
Oct 24, 2026
23b5f31
Merge skgbafa/tc-1423-n2-root-lifetime-cap into skgbafa/tc-1423-n3-ow…
Oct 24, 2026
b3ffc9b
Restore unit coverage for classification limits and back-off, the min…
Oct 24, 2026
732bf19
Merge skgbafa/tc-1423-n2-root-lifetime-cap into skgbafa/tc-1423-n3-ow…
Oct 24, 2026
3adcabd
Neutral wording in comments
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 @@ -12,6 +12,7 @@
- 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.
- Policy/v3 uses check the recorded owner authority: once a recorded delegation or any ancestor is revoked (also under an alias CID), admitted sessions stop reading and decrypting, and re-share admission, signed-KV and hook-ticket mints, challenge, mint and S0 admission are refused (`403 policy-owner-authority-revoked`), and so is delivery (`403 delivery-authorization-invalid`); a non-legacy `bound` authority past its expiry gives `policy-owner-authority-expired`; a policy without authority gives `policy-owner-authority-unknown`, unless it is legacy and `policy_v3.legacy_unknown_allow` (default off, logged) serves it with root-only checks. The mint's registration-time rescan runs only for such escaped policies. An expired root returns `root-expired`; other roots that are not live return `root-not-live`. Hook tickets carry a policy session's authority closure (at most 16 IDs, else `403 hook-ticket-authority-too-large`), and open hook streams re-check their authority before delivering (at most every `hooks.authority_recheck_seconds`, 5) and while idle (`hooks.idle_authority_recheck_seconds`, 30), closing on a revocation or three consecutive failed checks spaced an interval apart (events are withheld meanwhile); concurrent first uses of an unclassified policy share one classification; `delegation_chains_unrevoked` takes two queries at any depth. New counters `tinycloud_policy_owner_authority_denied_total{reason}` and `tinycloud_policy_owner_authority_unknown_allowed_total{reason}`. Details: TC-1423.

## [1.22.0] - 2026-10-23

Expand Down
85 changes: 74 additions & 11 deletions docs/policy-owner-authority.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,17 +168,16 @@ unless `classifier_version` changes.

## Policies registered earlier

A policy with no binding row is classified in the background on first use,
off the request: the selection runs as of its registration time (delegations
issued after it are not considered; UCANs carry no issue time and stay
eligible), then one transaction inserts the binding row first and its closure
after it. A concurrent writer that stored a row first wins. One task runs per
policy, and at most three in the whole process; a use that finds no free slot
starts none (a later use does). If the write fails (lock timeout, SQLite busy) or the result
is `starved`, the row stays pending and the request path doesn't start another
attempt for 60 seconds. Policies whose stored classification is final are
remembered in process (bounded), so steady-state reads add no query. An
`unknown` row written by an older `classifier_version` is classified again.
A policy with no final binding row is classified on first use, because the
use is checked against the result: the selection runs as of its registration
time (delegations issued after it are not considered; UCANs carry no issue
time and stay eligible), the request is served from that result, and the
write runs in the background: one transaction inserts the binding row first
and its closure after it, and a concurrent writer that stored a row first
wins. One write runs per policy, and at most three in the whole process. A
result that isn't final (its write failed or found no free slot, or it is
`starved`) is reused for 60 seconds before it is computed again. An `unknown`
row written by an older `classifier_version` is classified again.

The opt-in sweep classifies pending policies off the request path:

Expand Down Expand Up @@ -246,3 +245,67 @@ Pages hold at most 100 policies in policy-CID order; `nextCursor` is set when a
page is full and passed back as `cursor`. There is no total count. Policies
not yet classified (legacy policies not used since the upgrade, with the sweep
off) are not listed. Database errors return `503 policy-listing-unavailable`.

## Checking at use

Every use of a policy checks its recorded owner authority, after the root
checks: reads and decrypts by admitted sessions (and their re-shared
children), re-share admission, signed-KV and hook-ticket mints, challenge,
mint (again on the mint's locked transaction), S0 admission, and delivery
authorization.

| state | at use |
|---|---|
| `bound`, `bound-uncapped` | denied if any recorded delegation or ancestor is revoked, directly or under an alias CID (`403 policy-owner-authority-revoked`); a `bound` authority past its expiry is denied (`403 policy-owner-authority-expired`), except for legacy (`backfill`) rows, which are never expiry-checked |
| `root-authority` | nothing more |
| `unknown` | denied (`403 policy-owner-authority-unknown`) |

The mint does not rescan the owner's delegations as of registration for
policies with a recorded authority.

What to check is cached in process once a classification is final (bounded);
liveness is not cached. A steady-state use costs one indexed revocation query
for a recorded closure and none for root authority. A policy without a final
classification is classified in the request, served from that result, and
stored in the background; a result that isn't final is reused for 60 seconds.
Concurrent first uses of one policy share a single classification.

A revocation takes effect on commit; a request already past the check may
finish. Signed-KV tickets minted before a revocation last at most 60 seconds.

Other codes: an expired root gives `root-expired` (other non-live status
gives `root-not-live`); S0 admission returns the owner-authority code itself;
delivery keeps `delivery-authorization-invalid`. No code carries a CID or DID.
`policy-owner-authority-expired` is not reached by stored rows: a `bound`
root expires no later than its authority, and the root and session expiry
checks run first (`policy-session-expired` on reads, `root-expired` on
challenge and mint). It stays as a defence in depth.

### Legacy escape

```toml
[policy_v3]
legacy_unknown_allow = false # TINYCLOUD_POLICY_V3__LEGACY_UNKNOWN_ALLOW
```

When true, legacy (`backfill`) policies classified `unknown` are served with
the root-only checks (and the mint's registration-time rescan); when false they
are denied. The node logs a warning at boot, and per policy at most once an
hour when it serves one; uses are counted in
`tinycloud_policy_owner_authority_unknown_allowed_total{reason}`. Denials are
counted in `tinycloud_policy_owner_authority_denied_total{reason=revoked|expired|unknown}`.

### Hook streams

A hook ticket carries the delegations that authorized it, plus, for a policy
session, the recorded owner-authority closure; a ticket with more than 16 is
refused (`403 hook-ticket-authority-too-large`). An open `/hooks/events`
stream re-checks them (one closure query plus one revocation query) before
delivering an event, at most every `hooks.authority_recheck_seconds` (5), and
every `hooks.idle_authority_recheck_seconds` (30) while idle. It closes on a
revocation, or after three consecutive failed checks, each at least an
interval after the last; until a check succeeds, events are withheld (dropped)
without querying, so a burst of events during a brief database error doesn't
close the stream. Streams don't see a JSON-route
`RootRevocation` (it writes no core revocation) or an authority expiry; those
end at the ticket deadline (at most 300 seconds), as before.
12 changes: 4 additions & 8 deletions tinycloud-core/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -708,15 +708,11 @@ where
if roots.is_empty() {
return Ok(true);
}
let chain_ids = revocation::ancestor_chain_ids_for_roots(&self.conn, &roots)
// TC-1423: one closure query plus one alias-aware revocation query,
// whatever the depth; hook streams re-check this while open.
crate::policy_owner_authority::chains_unrevoked(&self.conn, &roots)
.await
.map_err(|error| DbErr::Custom(error.to_string()))?;
for id in chain_ids {
if revocation::is_revoked(&self.conn, &id).await? {
return Ok(false);
}
}
Ok(true)
.map_err(|error| DbErr::Custom(error.to_string()))
}

/// Load and reparse a delegation from its exact signed Authorization
Expand Down
89 changes: 89 additions & 0 deletions tinycloud-core/src/policy_owner_authority.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,92 @@ pub async fn migration_applied_at<C: ConnectionTrait>(
.await?
.map(|row| row.applied_at))
}

/// Whether nothing in the ancestor closure of `roots` is revoked (directly
/// or under an alias CID): one recursive closure query plus one revocation
/// query, whatever the depth. Fails closed past the traversal budget.
pub async fn chains_unrevoked<C: ConnectionTrait>(
db: &C,
roots: &[Hash],
) -> Result<bool, ChainTraversalError> {
if roots.is_empty() {
return Ok(true);
}
let (ids, _) = crate::auth_graph::load_closure_edges(db, roots).await?;
Ok(revoked_ids(db, &ids).await?.is_empty())
}

#[cfg(test)]
mod tests {
use super::*;
use crate::hash::hash;
use crate::migrations::Migrator;
use crate::models::actor;
use crate::relationships::parent_delegations;
use sea_orm::{ActiveModelTrait, ActiveValue::Set, ConnectOptions, Database};
use sea_orm_migration::MigratorTrait;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

#[tokio::test]
async fn chain_check_costs_two_queries_at_any_depth() {
let mut db = Database::connect(ConnectOptions::new("sqlite::memory:".to_string()))
.await
.unwrap();
Migrator::up(&db, None).await.unwrap();
actor::ActiveModel {
id: Set("did:key:actor".to_string()),
}
.insert(&db)
.await
.unwrap();
let ids: Vec<Hash> = (0..6)
.map(|i| hash(format!("chain-{i}").as_bytes()))
.collect();
for id in &ids {
delegation::ActiveModel {
id: Set(*id),
delegator: Set("did:key:actor".to_string()),
delegatee: Set("did:key:actor".to_string()),
expiry: Set(None),
issued_at: Set(None),
not_before: Set(None),
facts: Set(None),
serialization: Set(id.as_ref().to_vec()),
}
.insert(&db)
.await
.unwrap();
}
for pair in ids.windows(2) {
parent_delegations::ActiveModel {
child: Set(pair[0]),
parent: Set(pair[1]),
}
.insert(&db)
.await
.unwrap();
}
let queries = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&queries);
db.set_metric_callback(move |_| {
counter.fetch_add(1, Ordering::SeqCst);
});
assert!(chains_unrevoked(&db, &ids[..1]).await.unwrap());
assert_eq!(queries.load(Ordering::SeqCst), 2);

revocation::ActiveModel {
id: Set(hash(b"revoke-chain-top")),
revoker: Set("did:key:actor".to_string()),
revoked: Set(ids[5]),
serialization: Set(b"revocation".to_vec()),
revoked_at: Set(None),
}
.insert(&db)
.await
.unwrap();
queries.store(0, Ordering::SeqCst);
assert!(!chains_unrevoked(&db, &ids[..1]).await.unwrap());
assert_eq!(queries.load(Ordering::SeqCst), 2);
}
}
27 changes: 27 additions & 0 deletions tinycloud-node-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -921,6 +921,22 @@ pub struct HooksConfig {
pub webhook_timeout_seconds: u64,
#[serde(default = "default_hooks_webhook_max_attempts")]
pub webhook_max_attempts: usize,
/// An open event stream re-checks its ticket's authority before
/// delivering an event, at most this often.
#[serde(default = "default_hooks_authority_recheck_seconds")]
pub authority_recheck_seconds: u64,
/// An open event stream re-checks its ticket's authority this often
/// even when no event arrives.
#[serde(default = "default_hooks_idle_authority_recheck_seconds")]
pub idle_authority_recheck_seconds: u64,
}

fn default_hooks_authority_recheck_seconds() -> u64 {
5
}

fn default_hooks_idle_authority_recheck_seconds() -> u64 {
30
}

fn default_hooks_max_ticket_ttl_seconds() -> u64 {
Expand Down Expand Up @@ -962,6 +978,8 @@ impl Default for HooksConfig {
),
webhook_timeout_seconds: default_hooks_webhook_timeout_seconds(),
webhook_max_attempts: default_hooks_webhook_max_attempts(),
authority_recheck_seconds: default_hooks_authority_recheck_seconds(),
idle_authority_recheck_seconds: default_hooks_idle_authority_recheck_seconds(),
}
}
}
Expand All @@ -972,6 +990,9 @@ impl HooksConfig {
if self.webhook_max_attempts > 10 {
return Err("hooks.webhook_max_attempts must not exceed 10");
}
if self.authority_recheck_seconds == 0 || self.idle_authority_recheck_seconds == 0 {
return Err("hooks authority re-check intervals must be at least 1 second");
}
Ok(())
}
}
Expand Down Expand Up @@ -1314,6 +1335,12 @@ pub struct PolicyV3Config {
/// `TINYCLOUD_POLICY_V3__OWNER_AUTHORITY_EXPIRY`.
#[serde(default)]
pub owner_authority_expiry: OwnerAuthorityExpiry,
/// Operator escape: serve policies registered before the legacy cutover
/// for which no owner authority is recorded with the root-only checks;
/// when off they are denied. Off by default; logged at boot and per
/// policy when used. Env `TINYCLOUD_POLICY_V3__LEGACY_UNKNOWN_ALLOW`.
#[serde(default)]
pub legacy_unknown_allow: bool,
#[serde(default)]
pub owner_binding_backfill: OwnerBindingBackfillConfig,
}
Expand Down
Loading