Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
0c17677
test(TC-1423): cover recording the owner authority of policy registra…
samgbafa Oct 18, 2026
0ccb45e
feat(TC-1423): record the owner authority of policy registrations
samgbafa Oct 18, 2026
c7003c1
refactor(TC-1423): drop an unused test failpoint from owner-authority…
samgbafa Oct 18, 2026
65df9d5
fix(TC-1423): classify owner authority off the request path
samgbafa Oct 18, 2026
b4eef99
test(TC-1423): policy source network must belong to the content owner…
samgbafa Oct 18, 2026
81560c6
test(TC-1423): source network owner and state cases at registration (…
samgbafa Oct 18, 2026
09d7e33
fix(TC-1423): bound background classifications and tighten their tests
samgbafa Oct 18, 2026
3cee657
fix(TC-1423): check the owner and existence of policy source networks…
samgbafa Oct 18, 2026
1bfa6cf
refactor(TC-1423): split the pure network owner predicate from the ex…
samgbafa Oct 18, 2026
4a8b42f
test(TC-1423): kv ceiling entries outside the content owner (RED)
samgbafa Oct 18, 2026
85fee4a
fix(TC-1423): every kv ceiling entry must name a space of the content…
samgbafa Oct 18, 2026
058eee0
test(TC-1423): pick the loop-back fixture's target with high_id
samgbafa Oct 18, 2026
9364237
Merge origin/main into skgbafa/tc-1423-n1-owner-authority-record
Oct 19, 2026
1534fc6
Merge remote-tracking branch 'origin/main' into tmp-n1-merge
Oct 21, 2026
73e0ad4
Ungate load_closure_edges for policy owner authority closure
Oct 21, 2026
abe7d5c
Merge origin/main into tc-1423/n1b-source-network-owner
Oct 23, 2026
2956262
Merge origin/main into skgbafa/tc-1423-n1-owner-authority-record
Oct 23, 2026
93ece4e
Merge remote-tracking branch 'origin/main' into skgbafa/tc-1423-n1-ow…
Oct 23, 2026
d8d3df0
Create the content owner's network in the Policy/v3 test harness
Oct 23, 2026
6934749
Allow the extra argument on register_policy_writes
Oct 23, 2026
cbaa55a
Merge origin/main into skgbafa/tc-1423-n1-owner-authority-record
Oct 23, 2026
566eb78
Merge origin/main into tc-1423/n1b-source-network-owner
Oct 23, 2026
d83e747
Merge origin/skgbafa/tc-1423-n1-owner-authority-record into skgbafa/t…
Oct 23, 2026
da8572b
Order the owner-authority migration last; load a config without sql_s…
Oct 23, 2026
c742ef2
Test that every optional Config section loads when omitted
Oct 23, 2026
05ea2b1
Test that every optional Config section loads when omitted (fix)
Oct 23, 2026
1440bcb
Test that every optional Config section loads when omitted (fix)
Oct 23, 2026
e2828a2
Merge origin/main into skgbafa/tc-1423-n1-owner-authority-record
Oct 24, 2026
7ecacdc
Merge origin/main into tc-1423/n1b-source-network-owner
Oct 24, 2026
cbe9354
Merge origin/main into skgbafa/tc-1423-n1-owner-authority-record
Oct 24, 2026
42d1506
Merge origin/main into skgbafa/tc-1423-n1-owner-authority-record
Oct 24, 2026
2b8be4a
Merge origin/main into tc-1423/n1b-source-network-owner
Oct 24, 2026
02f9419
Merge origin/main into tc-1423/n1b-source-network-owner
Oct 24, 2026
b11ee88
Merge origin/main into skgbafa/tc-1423-n1-owner-authority-record
Oct 24, 2026
41d2ad4
Merge origin/main into tc-1423/n1b-source-network-owner
Oct 24, 2026
f22cc91
Merge origin/main into skgbafa/tc-1423-n1-owner-authority-record
Oct 24, 2026
c88e837
Neutral wording for the source-network comments and changelog entry
Oct 24, 2026
c568d2c
Merge tc-1423/n1b-source-network-owner into skgbafa/tc-1423-n1-owner-…
Oct 24, 2026
d16cf6d
Classify stored registrations over foreign networks as unknown; creat…
Oct 24, 2026
86fc01b
Remove finding labels from source-network comments
Oct 24, 2026
63fb500
Merge tc-1423/n1b-source-network-owner into skgbafa/tc-1423-n1-owner-…
Oct 24, 2026
4df9331
Neutral wording in comments, docs and test names text
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
- SQL local durability and reservation before commit (`docs/sql-local-durability.md`). Postgres stays the master copy. File-backed SQL databases run `PRAGMA synchronous = FULL` (`[global.storage.sql] synchronous = "full"|"normal"`, env `TINYCLOUD_STORAGE__SQL__SYNCHRONOUS`; `normal` selects the earlier fsync behaviour), and the directories and renames that make a database file or a hydrated cache visible are fsynced. A write is checked before COMMIT and rolled back whole if it would pass the owner's remaining storage budget (`402`), and, when configured, the per-database cap (`max_database_bytes`, `507 sql_database_size_limit`) or the disk headroom (`min_free_bytes`, bounded by the WAL and checkpoint growth the transaction can cause and shared by all databases on the node, `507 sql_disk_headroom`). `max_database_bytes` and `min_free_bytes` default to `0` (off) and are set by config. A COMMIT that fails on disk answers `500 sql_commit_failed` and the node re-hydrates the database from Postgres. A write that fails because the disk is full answers `507 sql_disk_headroom`, whatever `min_free_bytes` is, not the owner's quota or the size limit. Every write request runs in one transaction (a single statement, a batch, a nested write (`WITH … INSERT`) and a request that carries a `PRAGMA` included), so such a request counts two more native SQL calls in request telemetry, and all of them get the same owner budget, size cap and disk gate. A request that wrote nothing is not gated and reserves nothing. The growth of the main file that the next checkpoint will cause stays held in the disk reservation from the commit until a checkpoint runs or the database's connection closes. No migration. Details: TC-1491.
- `MemorySink` caps its size hint at 64 MiB and treats a failed reservation as no hint. Details: TC-1491.
- 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.

## [1.22.0] - 2026-10-23

Expand Down
144 changes: 144 additions & 0 deletions docs/policy-owner-authority.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# Policy/v3 owner authority (TC-1423)

A Policy/v3 policy root is parentless and signed by the policy owner, so the
ordinary delegation graph cannot say which of the owner's delegations its
authority came from. The node records that at registration.

## What is recorded

Two tables, created by migration `m20261019_000000_policy_v3_owner_authority`:

- `policy_v3_owner_binding`, one row per policy: `state`, `authority_expires_at`
(minimum expiry over the recorded closure, RFC 3339 UTC, NULL when nothing
expires), `unknown_reason`, `classifier_version`, `registered_mode`,
`created_at`. A missing row means the policy has not been classified yet.
- `policy_v3_owner_authority`, `(policy_cid, delegation_id, depth)`: the whole
ancestor closure of the delegations the owner used, at most 64 nodes (the
core traversal limit). Indexed by `(delegation_id, policy_cid)`.

| state | meaning |
|---|---|
| `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` |

`registered_mode` is `record` for policies classified at registration, and
`backfill` for policies registered before the legacy cutover and classified
later.

## Selection

Both roots carry the same capabilities, so one selection covers both. For
each capability:

1. Root authority needs no delegation.
2. Owner-first: delegations to the owner issued by the owner of the
capability's space or encryption network (newest first, 64 rows). Only that
principal can create them, so delegations from other principals cannot
push them out of the window.
3. Otherwise the general window: the 256 newest delegations to the owner that
carry an ability on the capability's space or network (only holders of
authority derived from that space or network can create those), of which
at most 32 are tried.

A candidate whose ancestor closure (the candidate included) contains a
delegation issued by the policy owner is skipped, without using up a try.
Among candidates that grant the capability, the first, newest first, whose
closure outlives the policy root is chosen, else the newest. `starved` is
recorded only when both steps found nothing and more candidates exist than
were examined; unlike the other `unknown` reasons it is not final and is
classified again on a later use.

## Source networks

A registration is also held to the source-network rule that new registrations
must meet (`content_owner_networks`): every encryption network the policy
names, and every `kv` ceiling entry, must belong to the owner of the content
source's space. A policy registered before that rule, or by a binary that
predates it, that does not meet it is classified `unknown` with reason
`foreign_network`, in either mode, whatever delegations the owner holds. The
rule is a pure check of the stored policy: it does not look up whether the
network exists. `foreign_network` is final: the row is not classified again
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.

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

```toml
[policy_v3.owner_binding_backfill]
enabled = false # TINYCLOUD_POLICY_V3__OWNER_BINDING_BACKFILL__ENABLED
policies_per_second = 1 # TINYCLOUD_POLICY_V3__OWNER_BINDING_BACKFILL__POLICIES_PER_SECOND
```

It visits every pending policy once after boot, at most `policies_per_second`,
pausing after each policy at least as long as it took.

## Legacy cutover

Only policies registered before the cutover are `backfill`. The cutover is the
applied-at time of the migration above, unless configured:

```toml
[policy_v3]
legacy_cutover = "2026-10-20T00:00:00Z" # TINYCLOUD_POLICY_V3__LEGACY_CUTOVER
```

The node logs the effective cutover and its source (`config` or `migration`)
at boot: at `warn` when the source is the migration and its applied-at time is
less than a day before boot (a fresh migration, or a re-upgrade that needs the
override), otherwise at `info`.

## Rollback

The migration is unknown to the previous release image (1.22.0), which refuses to boot while `seaql_migrations` lists it. To roll back, run on the database: `DROP TABLE IF EXISTS policy_v3_owner_authority; DROP TABLE IF EXISTS policy_v3_owner_binding; DELETE FROM seaql_migrations WHERE version = 'm20261019_000000_policy_v3_owner_authority';` (the same as the migration's `down`), then deploy 1.22.0. If a migration released after this one has also been applied, delete its row too, as its release notes say. Policies registered while this release ran stay registered and get only the checks 1.22.0 has. On the re-upgrade, `policy_v3.legacy_cutover` must be set to this release's first deploy time; otherwise every policy registered during the rollback window, including roots longer than 31 days, is treated as legacy.

Checklist (tick each step):

1. [ ] Run the three statements above on the database.
2. [ ] If a migration released after this one has been applied, delete its `seaql_migrations` row as well, as its release notes say.
3. [ ] Deploy 1.22.0 and check that the node boots.
4. [ ] Before the re-upgrade starts, set `policy_v3.legacy_cutover` (`TINYCLOUD_POLICY_V3__LEGACY_CUTOVER`) to this release's first deploy time, as an RFC 3339 UTC timestamp. This is the time of the first deploy of this release, not the time of the re-upgrade.
5. [ ] After the re-upgrade, check that the boot log shows the effective cutover with source `config` and the value from step 4.

## Listing

`POST /policy/v3/authorized` with body `{"cid": "<delegation CID>", "cursor": null}`
lists the policies whose recorded closure contains that delegation (or a CID
persisted as the same delegation). It is authorized like `/delegation/status`
on `urn:cid:<cid>`: one `tinycloud.delegation/status` capability on that
resource, from the delegation's issuer or delegatee, a space owner it targets,
or the holder of a status control. Anyone else gets `403`.

```json
{
"policies": [{
"policyCid": "...",
"policyRootCid": "...",
"enforcementRootCid": "...",
"registeredAt": "...",
"expiresAt": "...",
"rootState": "active | expired | revoked",
"ownerAuthorityState": "bound | bound-uncapped | root-authority | unknown"
}],
"nextCursor": "<opaque>"
}
```

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`.
4 changes: 2 additions & 2 deletions tinycloud-core/src/auth_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ use crate::models::revocation::MAX_CHAIN_TRAVERSAL_NODES;
/// fetches all reachable edges in one query rather than walking one node at a
/// time. The in-memory pass enforces the same fail-closed node budget as the
/// per-node traversal used by the delegate/revoke paths. Production locks use
/// `load_chain_lock_ids`; tests keep this as the sequential edge oracle.
#[cfg(test)]
/// `load_chain_lock_ids`; `policy_owner_authority::load_closure` also needs the
/// raw (non-canonical) parent edges from this loader.
pub(crate) async fn load_closure_edges<C: ConnectionTrait>(
db: &C,
roots: &[Hash],
Expand Down
1 change: 1 addition & 0 deletions tinycloud-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub mod migrations;
pub mod models;
pub mod policy_authority;
pub mod policy_capability;
pub mod policy_owner_authority;
mod read_audit;
pub mod relationships;
pub mod request_cancel;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//! TC-1423: record which delegations a Policy/v3 owner held as authority.
//!
//! Two new, empty tables and one index; no ALTER of `policy_v3_registration`
//! and no foreign keys (as in m20261018), so the migration is O(1) and an
//! older binary simply ignores the tables. Existing registrations are
//! classified lazily on first use, or by the node's opt-in sweep.
//!
//! Rollback: run `down` (two `DROP TABLE`s), then deploy the previous binary.
//! The applied-at time of this migration is the default legacy cutover; after
//! a rollback and re-upgrade, set `policy_v3.legacy_cutover` to the first
//! deploy time.

use sea_orm_migration::prelude::*;

use crate::models::{policy_v3_owner_authority, policy_v3_owner_binding};

#[derive(DeriveMigrationName)]
pub struct Migration;

const AUTHORITY_BY_DELEGATION_INDEX: &str = "idx_policy_v3_owner_authority_delegation";

#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(policy_v3_owner_binding::Entity)
.if_not_exists()
.col(
ColumnDef::new(policy_v3_owner_binding::Column::PolicyCid)
.string()
.not_null()
.primary_key(),
)
.col(
ColumnDef::new(policy_v3_owner_binding::Column::State)
.string()
.not_null(),
)
.col(
ColumnDef::new(policy_v3_owner_binding::Column::AuthorityExpiresAt)
.string(),
)
.col(ColumnDef::new(policy_v3_owner_binding::Column::UnknownReason).string())
.col(
ColumnDef::new(policy_v3_owner_binding::Column::ClassifierVersion)
.integer()
.not_null(),
)
.col(
ColumnDef::new(policy_v3_owner_binding::Column::RegisteredMode)
.string()
.not_null(),
)
.col(
ColumnDef::new(policy_v3_owner_binding::Column::CreatedAt)
.string()
.not_null(),
)
.to_owned(),
)
.await?;

let mut delegation_id = ColumnDef::new(policy_v3_owner_authority::Column::DelegationId);
if manager.get_database_backend() == sea_orm::DbBackend::MySql {
// As in m20261014: MySQL's bare BINARY is one byte and fixed-width
// BINARY would pad the multihash.
delegation_id.var_binary(255);
} else {
delegation_id.binary();
}
manager
.create_table(
Table::create()
.table(policy_v3_owner_authority::Entity)
.if_not_exists()
.col(
ColumnDef::new(policy_v3_owner_authority::Column::PolicyCid)
.string()
.not_null(),
)
.col(delegation_id.not_null())
.col(
ColumnDef::new(policy_v3_owner_authority::Column::Depth)
.integer()
.not_null(),
)
.primary_key(
Index::create()
.col(policy_v3_owner_authority::Column::PolicyCid)
.col(policy_v3_owner_authority::Column::DelegationId),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name(AUTHORITY_BY_DELEGATION_INDEX)
.table(policy_v3_owner_authority::Entity)
.col(policy_v3_owner_authority::Column::DelegationId)
.col(policy_v3_owner_authority::Column::PolicyCid)
.if_not_exists()
.to_owned(),
)
.await
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Dropping a table drops its indexes on every supported backend.
manager
.drop_table(
Table::drop()
.table(policy_v3_owner_authority::Entity)
.if_exists()
.to_owned(),
)
.await?;
manager
.drop_table(
Table::drop()
.table(policy_v3_owner_binding::Entity)
.if_exists()
.to_owned(),
)
.await
}
}
4 changes: 4 additions & 0 deletions tinycloud-core/src/migrations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub mod m20261014_000000_delegation_cid_alias;
pub mod m20261015_000000_delegation_cursor_index;
pub mod m20261017_000000_epoch_order_space_index;
pub mod m20261018_000000_epoch_head;
pub mod m20261019_000000_policy_v3_owner_authority;
pub mod m20261020_000000_current_kv_key_c_index;
pub mod m20261021_000000_sql_store_manifest;
pub mod m20261022_000000_encryption_network_key;
Expand Down Expand Up @@ -70,6 +71,9 @@ impl MigratorTrait for Migrator {
Box::new(m20261020_000000_current_kv_key_c_index::Migration),
Box::new(m20261021_000000_sql_store_manifest::Migration),
Box::new(m20261022_000000_encryption_network_key::Migration),
// Last in the list: `Migrator::down` reverts in list order, so a
// rollback of this migration reverts it, not main's later ones.
Box::new(m20261019_000000_policy_v3_owner_authority::Migration),
]
}
}
Expand Down
2 changes: 2 additions & 0 deletions tinycloud-core/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ pub mod policy_delegation;
pub mod policy_edge;
pub mod policy_issuance_audit;
pub mod policy_v3_challenge;
pub mod policy_v3_owner_authority;
pub mod policy_v3_owner_binding;
pub mod policy_v3_registration;
pub mod policy_v3_root;
pub mod policy_v3_session;
Expand Down
23 changes: 23 additions & 0 deletions tinycloud-core/src/models/policy_v3_owner_authority.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//! TC-1423: the ancestor closure of the delegations a Policy/v3 owner used as
//! authority for a registration. The closure is immutable (edges are signed
//! parents); CID aliases are resolved when it is queried.

use crate::hash::Hash;
use sea_orm::entity::prelude::*;

#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "policy_v3_owner_authority")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub policy_cid: String,
#[sea_orm(primary_key, auto_increment = false)]
pub delegation_id: Hash,
/// Distance from the nearest recorded delegation (0 for the delegation
/// itself).
pub depth: i32,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}
Loading
Loading