Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -9,6 +9,7 @@
- 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; registered policies are not re-checked. 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.

## [1.22.0] - 2026-10-23

Expand Down
1 change: 1 addition & 0 deletions tinycloud-node-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,7 @@ pub async fn app_with_control(
prometheus::observe_did_resolution_refused,
);
lazy_static::initialize(&prometheus::DID_RESOLUTION_REFUSED);
lazy_static::initialize(&prometheus::POLICY_SOURCE_NETWORK_REFUSED);

// Ensure local storage directories exist.
// SQLite file paths and local dirs are resources the server owns — auto-create them.
Expand Down
267 changes: 252 additions & 15 deletions tinycloud-node-server/src/policy_v3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,14 @@ use tinycloud_auth::{
resource::{ResourceId, SpaceId},
};
use tinycloud_core::{
encryption_network::{NetworkId, NetworkState},
events::{Delegation, SerializedEvent},
hash::hash,
keys::StaticSecret,
models::{
abilities, delegation as delegation_model, invocation as invocation_model,
policy_v3_challenge, policy_v3_registration, policy_v3_root, policy_v3_session, revocation,
share_invitation_authorization_jti, space,
abilities, delegation as delegation_model, encryption_network as encryption_network_model,
invocation as invocation_model, policy_v3_challenge, policy_v3_registration,
policy_v3_root, policy_v3_session, revocation, share_invitation_authorization_jti, space,
},
relationships::parent_delegations,
sea_orm::{
Expand Down Expand Up @@ -1856,6 +1857,13 @@ pub async fn register_policy(
{
return Err((Status::Forbidden, "policy-digest-mismatch".into()));
}
// Only signed, self-consistent requests reach the network lookups; the
// check runs before the owner-authority check, which would otherwise
// accept decrypt on a network that names the registrant.
if let Some(refusal) = source_network_refusal(&runtime.conn, &request.policy).await? {
crate::prometheus::observe_policy_source_network_refused(refusal.as_str());
return Err((Status::Forbidden, "content-source-network-invalid".into()));
}
let owner_did = request
.policy
.get("ownerDid")
Expand Down Expand Up @@ -4657,6 +4665,129 @@ fn validate_policy_encryption_resource(value: &str) -> Result<(), (Status, Strin
Ok(())
}

/// Why a new policy's resources were refused; the metric label.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SourceNetworkRefusal {
Unparsable,
LegacyResource,
ForeignOwner,
ForeignSpace,
Missing,
Inactive,
}

impl SourceNetworkRefusal {
const fn as_str(self) -> &'static str {
match self {
Self::Unparsable => "unparsable",
Self::LegacyResource => "legacy_resource",
Self::ForeignOwner => "foreign_owner",
Self::ForeignSpace => "foreign_space",
Self::Missing => "missing",
Self::Inactive => "inactive",
}
}
}

/// TC-1423: the encryption networks a policy names (the content source's
/// and every `encryption` ceiling entry), when every one parses and belongs to
/// the owner of the content source's space, and every `kv` ceiling entry
/// names a space of that owner; otherwise why not. Pure, so it applies to
/// stored registrations as well as new ones.
///
/// A network URN is rooted at the DID it names, whether or not the network
/// exists, and any DID may create a network that names itself. Every
/// resource a policy grants therefore has to belong to one owner: the
/// content source's network and every ceiling network are that owner's, and
/// every `kv` entry names that owner's space. A registrant other than that
/// owner holds decrypt on those networks only through a delegation rooted at
/// that owner.
///
/// The owner is the space's DID (`ResourceId::space().did()`), compared with
/// `did_principal_matches`: EIP-55 letter case is ignored, the chain id is
/// not. `tinycloud://` resources name no space owner, and no client emits
/// them. A ceiling carries only `kv` and `encryption` entries
/// (`registration_projections`); any other kind is refused as unparsable.
fn content_owner_networks(policy: &Value) -> Result<Vec<NetworkId>, SourceNetworkRefusal> {
let kv_owner = |resource: Option<&str>| {
let resource = resource.unwrap_or_default();
if resource.starts_with("tinycloud://") {
return Err(SourceNetworkRefusal::LegacyResource);
}
resource
.parse::<ResourceId>()
.map(|resource| resource.space().did().to_owned())
.map_err(|_| SourceNetworkRefusal::Unparsable)
};
let source = policy.get("contentSource");
let content_owner = kv_owner(
source
.and_then(|source| source.get("kvResource"))
.and_then(Value::as_str),
)?;
let mut networks = std::collections::BTreeSet::from([source
.and_then(|source| source.get("encryptionNetwork"))
.and_then(Value::as_str)]);
for capability in policy
.get("capabilityCeiling")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let resource = capability.get("resource").and_then(Value::as_str);
match capability.get("kind").and_then(Value::as_str) {
Some("kv") => {
if !did_principal_matches(kv_owner(resource)?.as_str(), content_owner.as_str()) {
return Err(SourceNetworkRefusal::ForeignSpace);
}
}
Some("encryption") => {
networks.insert(resource);
}
_ => return Err(SourceNetworkRefusal::Unparsable),
}
}
networks
.into_iter()
.map(|network| {
let network_id = network
.and_then(|network| network.parse::<NetworkId>().ok())
.ok_or(SourceNetworkRefusal::Unparsable)?;
if did_principal_matches(network_id.owner_did(), content_owner.as_str()) {
Ok(network_id)
} else {
Err(SourceNetworkRefusal::ForeignOwner)
}
})
.collect()
}

/// [`content_owner_networks`], and each network exists on this node in state
/// `active`: the row key and the rule decrypt uses. A policy on any other
/// network could never be opened here.
async fn source_network_refusal<C: ConnectionTrait>(
conn: &C,
policy: &Value,
) -> Result<Option<SourceNetworkRefusal>, (Status, String)> {
let networks = match content_owner_networks(policy) {
Ok(networks) => networks,
Err(refusal) => return Ok(Some(refusal)),
};
for network_id in networks {
let Some(row) = encryption_network_model::Entity::find_by_id(network_id.to_string())
.one(conn)
.await
.map_err(db_error)?
else {
return Ok(Some(SourceNetworkRefusal::Missing));
};
if NetworkState::parse(&row.state) != Some(NetworkState::Active) {
return Ok(Some(SourceNetworkRefusal::Inactive));
}
}
Ok(None)
}

fn canonical_policy_actions(
value: Option<&Value>,
kv: bool,
Expand Down Expand Up @@ -8521,23 +8652,42 @@ mod tests {

#[tokio::test]
async fn policy_v2_admits_v3_account_and_v4_accountless_receivers() -> anyhow::Result<()> {
run_policy_v2_flow(PolicyOwner::SpaceOwner).await
Box::pin(run_policy_v2_flow(PolicyOwner::SpaceOwner)).await
}

/// The real Share and CLI shape: the policy owner is a session key the
/// space owner delegated to. It registers, mints and reads; once the
/// space owner revokes that delegation, no new session can be minted.
#[tokio::test]
async fn policy_owner_may_be_a_session_the_space_owner_delegated_to() -> anyhow::Result<()> {
run_policy_v2_flow(PolicyOwner::DelegatedSession).await
Box::pin(run_policy_v2_flow(PolicyOwner::DelegatedSession)).await
}

/// The policy owner signs a valid, self-consistent policy over content in
/// a space it holds no authority in (the victim hosts and seeds it).
/// Registration must refuse it, so no session can ever read the bytes.
#[tokio::test]
async fn policy_registration_requires_the_owner_to_hold_what_it_grants() -> anyhow::Result<()> {
run_policy_v2_flow(PolicyOwner::Unauthorized).await
Box::pin(run_policy_v2_flow(PolicyOwner::Unauthorized)).await
}

/// TC-1423: a policy owner that holds only `kv/get` and
/// `kv/metadata` on the content (no decrypt on the content owner's
/// network) registers a policy whose content source names a network
/// called after the policy owner itself. Registration refuses it: the
/// source network must exist and belong to the content space's owner.
#[tokio::test]
async fn policy_source_network_must_belong_to_the_content_owner() -> anyhow::Result<()> {
Box::pin(run_policy_v2_flow(PolicyOwner::KvOnlySelfNamedNetwork)).await
}

/// TC-1423: the same kv-only policy owner moves the content source
/// to its own space, on a network it created, and adds a second kv
/// ceiling entry on the space owner's content. Every kv entry must name
/// a space of the content source's owner, so registration refuses it.
#[tokio::test]
async fn policy_kv_entries_must_belong_to_the_content_owner() -> anyhow::Result<()> {
Box::pin(run_policy_v2_flow(PolicyOwner::KvOnlyOwnSpaceSource)).await
}

#[derive(Clone, Copy, PartialEq, Eq)]
Expand All @@ -8548,6 +8698,14 @@ mod tests {
DelegatedSession,
/// The policy owner holds nothing in the content space.
Unauthorized,
/// The policy owner holds only kv/get + kv/metadata through a
/// delegation from the space owner (no decrypt), and the policy's
/// source network is one named after the policy owner itself.
KvOnlySelfNamedNetwork,
/// As `KvOnlySelfNamedNetwork`, but the content source is the policy
/// owner's own space on its own (created) network, and a second kv
/// ceiling entry names the space owner's content.
KvOnlyOwnSpaceSource,
}

async fn run_policy_v2_flow(policy_owner: PolicyOwner) -> anyhow::Result<()> {
Expand Down Expand Up @@ -8602,6 +8760,14 @@ mod tests {
None,
None,
);
// `KvOnlyOwnSpaceSource`: the same path in the policy owner's space.
let own_source_resource =
SpaceId::new(owner_did.parse::<DIDBuf>()?, "applications".parse()?).to_resource(
"kv".parse()?,
Some("shares/tc-470/document.txt".parse()?),
None,
None,
);
let node_secret = StaticSecret::new(vec![29; 32]).unwrap();
let node_did = node_secret.node_did();
let issuer_did = projection["issuerDid"].as_str().unwrap();
Expand Down Expand Up @@ -8629,8 +8795,15 @@ mod tests {
"selector": "exact",
"actions": ["tinycloud.kv/get"]
})];
let encryption_resource = format!("urn:tinycloud:encryption:{content_owner_did}:mainnet");
let ceiling = vec![
let encryption_resource = if matches!(
policy_owner,
PolicyOwner::KvOnlySelfNamedNetwork | PolicyOwner::KvOnlyOwnSpaceSource
) {
format!("urn:tinycloud:encryption:{owner_did}:share")
} else {
format!("urn:tinycloud:encryption:{content_owner_did}:mainnet")
};
let mut ceiling = vec![
json!({
"kind": "kv",
"resource": content_resource.to_string(),
Expand All @@ -8643,9 +8816,20 @@ mod tests {
"action": "tinycloud.encryption/decrypt"
}),
];
let source_kv = if policy_owner == PolicyOwner::KvOnlyOwnSpaceSource {
ceiling.push(json!({
"kind": "kv",
"resource": own_source_resource.to_string(),
"selector": "exact",
"actions": ["tinycloud.kv/get", "tinycloud.kv/metadata"]
}));
own_source_resource.to_string()
} else {
content_resource.to_string()
};
let content_source = json!({
"shareId": "share-tc-470",
"kvResource": content_resource.to_string(),
"kvResource": source_kv,
"selector": "exact",
"encryptionNetwork": encryption_resource,
"encryptedSymmetricKeyDigestHex": "aa".repeat(32),
Expand Down Expand Up @@ -8995,6 +9179,28 @@ mod tests {
runtime.signer.node_keypair(),
encryption_backend,
);
// Registration requires the source network to exist on this node and
// to be the content owner's (TC-1423). The self-named network of
// `KvOnlySelfNamedNetwork` is deliberately never created.
encryption
.create_one_of_one_network(tinycloud_core::encryption_network::CreateNetworkRequest {
name: "mainnet".into(),
owner_did: content_owner_did.clone(),
threshold: tinycloud_core::encryption_network::Threshold::one_of_one(),
})
.await?;
if policy_owner == PolicyOwner::KvOnlyOwnSpaceSource {
// The policy owner's own network, which any DID may create.
encryption
.create_one_of_one_network(
tinycloud_core::encryption_network::CreateNetworkRequest {
name: "share".into(),
owner_did: owner_did.clone(),
threshold: tinycloud_core::encryption_network::Threshold::one_of_one(),
},
)
.await?;
}
let rocket = rocket::build()
.mount(
"/",
Expand Down Expand Up @@ -9106,7 +9312,12 @@ mod tests {

// A session the space owner delegated to may own a policy over what
// that delegation covers, as the Share and CLI owners do.
let owner_delegation_cid = if policy_owner == PolicyOwner::DelegatedSession {
let owner_delegation_cid = if matches!(
policy_owner,
PolicyOwner::DelegatedSession
| PolicyOwner::KvOnlySelfNamedNetwork
| PolicyOwner::KvOnlyOwnSpaceSource
) {
let mut owner_capabilities = Capabilities::<Value>::new();
for ability in ["tinycloud.kv/get", "tinycloud.kv/metadata"] {
owner_capabilities.with_action(
Expand All @@ -9115,11 +9326,13 @@ mod tests {
[std::collections::BTreeMap::<String, Value>::new()],
);
}
owner_capabilities.with_action(
encryption_resource.parse()?,
"tinycloud.encryption/decrypt".parse::<RecapAbility>()?,
[std::collections::BTreeMap::<String, Value>::new()],
);
if policy_owner == PolicyOwner::DelegatedSession {
owner_capabilities.with_action(
encryption_resource.parse()?,
"tinycloud.encryption/decrypt".parse::<RecapAbility>()?,
[std::collections::BTreeMap::<String, Value>::new()],
);
}
let authorization = TinyCloudDelegation::Ucan(Box::new(
Payload {
issuer: content_owner_vm.parse()?,
Expand Down Expand Up @@ -9326,6 +9539,26 @@ mod tests {
assert!(policy_v3_root::Entity::find().one(conn).await?.is_none());
return Ok(());
}
if matches!(
policy_owner,
PolicyOwner::KvOnlySelfNamedNetwork | PolicyOwner::KvOnlyOwnSpaceSource
) {
assert_eq!(
register_status,
Status::Forbidden,
"register: {register_body}"
);
assert_eq!(register_body, "content-source-network-invalid");
let conn = &client.rocket().state::<PolicyV3Runtime>().unwrap().conn;
assert!(
policy_v3_registration::Entity::find_by_id(policy_cid.clone())
.one(conn)
.await?
.is_none()
);
assert!(policy_v3_root::Entity::find().one(conn).await?.is_none());
return Ok(());
}
assert_eq!(register_status, Status::Ok, "register: {register_body}");
let expected_identity = tinycloud_core::request_telemetry::RequestContext::new(
uuid::Uuid::new_v4(),
Expand Down Expand Up @@ -10789,3 +11022,7 @@ mod root_revocation_tests;
#[cfg(test)]
#[path = "policy_v3/registration_probe_tests.rs"]
mod registration_probe_tests;

#[cfg(test)]
#[path = "policy_v3/source_network_tests.rs"]
mod source_network_tests;
Loading
Loading