Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

- `POST /revoke` authorizes the delegation's issuer, the owner of a space its abilities target, or the holder of a revoke control grant issued by one of them. The delegatee alone is no longer an authorized revoker, directly or through a control grant it issued; an intermediate delegator still revokes the delegations it issued. Delegation status visibility is unchanged (TC-1436).
- Revoke and status control grants may name an encryption network (`urn:tinycloud:encryption:<owner>:<name>`) owned by the control's grantor. Only the network's owner can issue such a control without a parent, because the owner is already the network's root authority (`/delegate` accepted this before); naming the network in a control grants no decrypt or other network ability, so this is not an escalation. Each resource of a control is typed as an exact `urn:cid:<target>`, a `<space>/delegation` root, or a network; any other resource is ignored. A control covers a target only when every one of the target's resources is covered: a space resource by a named space, a network resource by the same parsed network id (owner DID including chain id, and name), so lowercase and EIP-55 checksummed spellings of an address name the same network, and a resource that does not parse as a network is never covered. A control that names a network never counts as an exact-CID control, and a network named by a control issued by someone other than its owner is ignored. A control such as `<applications>/delegation` + `<default>/delegation` + `<owner>:share` therefore revokes or reads the status of share roots that carry `:share` decrypt, but not grants on `:default` or any other network (TC-1423).
- An owner can activate a parentless `tinycloud.delegation/revoke` grant on exactly `urn:cid:<target>` (for example the CLI's raw-CID revoke permission) when the stored target's abilities name a space the owner owns. Every non-root capability of such a grant must be one of these. Unknown targets, targets without abilities, and signers who are only the target's delegator or delegatee are refused with `Cannot find parent delegation` (TC-821).
- Revocation and delegation status accept a canonical CID that names a stored historical encoding. An exact-CID revoke or status grant covers every stored encoding of its target. The signed CID is kept as signed, and authorization uses the stored row (TC-821).

## [1.21.1] - 2026-10-19

Expand Down
54 changes: 37 additions & 17 deletions tinycloud-core/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1896,18 +1896,20 @@ where
if proofs.len() > 1 {
return Ok(None);
}
let Some(delegation) = delegation::Entity::find_by_id(target)
.one(&self.conn)
.await?
else {
// A canonical CID may name a historical stored row; the requested CID
// keeps its own authority scope, the stored row supplies the grant.
let Some(delegation) = revocation::target_for_cid(&self.conn, target).await? else {
return Ok(None);
};
let abilities = abilities::Entity::find()
.filter(abilities::Column::Delegation.eq(target))
.filter(abilities::Column::Delegation.eq(delegation.id))
.all(&self.conn)
.await?;

let mut roots = vec![target];
if delegation.id != target {
roots.push(delegation.id);
}
roots.extend(proofs.iter().copied().map(Hash::from));
let _chain_guards = match self.acquire_chain_guards(&roots).await {
Ok(guards) => guards,
Expand Down Expand Up @@ -1948,7 +1950,7 @@ where
if revocation::is_revoked(&self.conn, &target).await? {
return Ok(Some(DelegationStatus::Revoked));
}
match revocation::first_revoked_ancestor(&self.conn, &target).await {
match revocation::first_revoked_ancestor(&self.conn, &delegation.id).await {
Ok(Some(_)) => return Ok(Some(DelegationStatus::Revoked)),
Ok(None) => {}
Err(revocation::ChainTraversalError::LimitExceeded) => {
Expand Down Expand Up @@ -3092,8 +3094,12 @@ async fn sequence_spaces<C: ConnectionTrait>(
}
}
if !revoked.is_empty() {
let mut stored = Vec::with_capacity(revoked.len());
for target in revoked {
stored.push(stored_revocation_target(db, target).await?);
}
for row in event_order::Entity::find()
.filter(event_order::Column::Event.is_in(revoked))
.filter(event_order::Column::Event.is_in(stored))
.all(db)
.await?
{
Expand All @@ -3103,19 +3109,29 @@ async fn sequence_spaces<C: ConnectionTrait>(
Ok(spaces.into_iter().collect())
}

/// The stored delegation a revocation's signed target names: the target itself,
/// or the stored historical encoding a canonical CID resolves to. `event_order`
/// records the stored row, so the revocation is sequenced in its spaces.
async fn stored_revocation_target<C: ConnectionTrait>(db: &C, target: Hash) -> Result<Hash, DbErr> {
Ok(revocation::target_for_cid(db, target)
.await?
.map(|delegation| delegation.id)
.unwrap_or(target))
}

async fn event_spaces<'a, C: ConnectionTrait>(
db: &C,
ev: &'a [(Hash, Event)],
) -> Result<HashMap<SpaceId, Vec<&'a (Hash, Event)>>, DbErr> {
// get orderings of events listed as revoked by events in the ev list
let mut spaces = HashMap::<SpaceId, Vec<&'a (Hash, Event)>>::new();
let revoked = ev
.iter()
.filter_map(|(_, e)| match e {
Event::Revocation(r) => Some(Hash::from(r.0.revoked)),
_ => None,
})
.collect::<Vec<_>>();
let mut revoked = Vec::new();
for (_, e) in ev {
if let Event::Revocation(r) = e {
let signed = Hash::from(r.0.revoked);
revoked.push((signed, stored_revocation_target(db, signed).await?));
}
}
// TC-732: `transact` now calls this before any write. Issue no read when
// there is nothing to look up: on SQLite a read that opens the
// transaction's snapshot ahead of its first write turns a concurrent
Expand All @@ -3124,7 +3140,7 @@ async fn event_spaces<'a, C: ConnectionTrait>(
Vec::new()
} else {
event_order::Entity::find()
.filter(event_order::Column::Event.is_in(revoked))
.filter(event_order::Column::Event.is_in(revoked.iter().map(|(_, stored)| *stored)))
.all(db)
.await?
};
Expand Down Expand Up @@ -3163,9 +3179,13 @@ async fn event_spaces<'a, C: ConnectionTrait>(
}
}
Event::Revocation(r) => {
let r_hash = Hash::from(r.0.revoked);
let signed = Hash::from(r.0.revoked);
let Some(&(_, stored)) = revoked.iter().find(|(target, _)| *target == signed)
else {
continue;
};
for revoked in &revoked_events {
if r_hash == revoked.event {
if stored == revoked.event {
let entry = spaces.entry(revoked.space.0.clone()).or_default();
if !entry.iter().any(|(h, _)| h == &e.0) {
entry.push(e);
Expand Down
14 changes: 12 additions & 2 deletions tinycloud-core/src/models/delegation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,18 @@ async fn validate<C: ConnectionTrait>(
// ordered sibling pair. Keeping the graph row here is what lets the
// ordinary transaction validate an S0's proof CIDs.
(false, true) if is_policy_root(&delegation.delegation) => Ok(()),
// dependant caps, no parents, invalid
(false, true) => Err(DelegationError::MissingParents.into()),
// An owner may grant revocation of one exact stored delegation that
// targets their space, without holding the target's signed bytes:
// every non-root capability must be such an exact-CID revoke grant.
// The target's delegatee or delegator alone cannot issue one.
(false, true) => {
for cap in &dependant_caps {
if !revocation::owner_controls_target(db, &delegation.delegator, cap).await? {
return Err(DelegationError::MissingParents.into());
}
}
Ok(())
}
// dependant caps, parents, check parents
(false, false) => {
// Policy-session UCANs intentionally carry two sibling roots. The
Expand Down
169 changes: 129 additions & 40 deletions tinycloud-core/src/models/revocation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,18 @@ pub enum ChainTraversalError {
}

/// Resolve revocation targets through the persisted canonical identity. The
/// exact-target branch also handles graph rows created directly by unit tests.
/// exact-target branch also handles graph rows created directly by unit tests;
/// matching the canonical column covers a canonical CID with no stored row.
fn target_condition(id: SimpleExpr) -> Condition {
use delegation_cid_alias::{Column as AliasColumn, Entity as AliasEntity};
let canonical = Query::select()
.column(AliasColumn::Canonical)
.from(AliasEntity)
.and_where(Expr::col(AliasColumn::Id).eq(id.clone()))
.cond_where(
Condition::any()
.add(Expr::col(AliasColumn::Id).eq(id.clone()))
.add(Expr::col(AliasColumn::Canonical).eq(id.clone())),
)
.to_owned();
let equivalents = Query::select()
.column(AliasColumn::Id)
Expand Down Expand Up @@ -299,15 +304,16 @@ enum ControlScope {
}

impl ControlScope {
/// `target_resource` is `urn:cid:<target>`.
/// `names_target` is whether `resource` is an exact `urn:cid:` naming a
/// stored encoding of `target`.
fn parse(
resource: &Resource,
target: &Hash,
target_resource: &str,
names_target: bool,
grantor: &str,
) -> Option<Self> {
match resource {
Resource::Other(uri) if uri.as_str() == target_resource => Some(Self::Cid(*target)),
Resource::Other(_) if names_target => Some(Self::Cid(*target)),
Resource::Other(uri) => uri
.as_str()
.parse::<NetworkId>()
Expand Down Expand Up @@ -441,22 +447,32 @@ pub(crate) async fn control_proof_decision<C: ConnectionTrait>(
}
}

let target_resource = format!("urn:cid:{}", target.to_cid(0x55));
let scopes: Vec<ControlScope> = abilities::Entity::find()
let target_canonical = canonical_identity(db, *target).await?;
let mut scopes: Vec<ControlScope> = Vec::new();
for ability in abilities::Entity::find()
.filter(abilities::Column::Delegation.eq(proof_id))
.filter(abilities::Column::Ability.eq(action.ability()))
.all(db)
.await?
.iter()
.filter_map(|ability| {
ControlScope::parse(
&ability.resource,
target,
&target_resource,
&parent.delegator,
)
})
.collect();
{
// An exact-CID grant covers every stored encoding of its target.
let names_target = match &ability.resource {
Resource::Other(resource) => match resource
.as_str()
.strip_prefix("urn:cid:")
.and_then(|cid| cid.parse::<Cid>().ok())
{
Some(cid) => canonical_identity(db, Hash::from(cid)).await? == target_canonical,
None => false,
},
Resource::TinyCloud(_) => false,
};
if let Some(scope) =
ControlScope::parse(&ability.resource, target, names_target, &parent.delegator)
{
scopes.push(scope);
}
}
if scopes.is_empty() {
return Ok(ControlProofDecision::Denied);
}
Expand All @@ -468,6 +484,77 @@ pub(crate) async fn control_proof_decision<C: ConnectionTrait>(
}))
}

/// The persisted canonical identity of a delegation CID: its alias row's
/// canonical hash, or the CID itself when it has no alias row (a canonical CID,
/// or a graph row created directly by unit tests).
async fn canonical_identity<C: ConnectionTrait>(db: &C, id: Hash) -> Result<Hash, DbErr> {
Ok(delegation_cid_alias::Entity::find_by_id(id)
.one(db)
.await?
.map(|alias| alias.canonical)
.unwrap_or(id))
}

/// The stored delegation a CID names: the row with that ID, or the stored
/// historical encoding whose canonical identity it is. Signed CIDs stay as
/// signed; authorization uses the stored row. When several stored encodings
/// share the canonical identity, the lowest stored ID is chosen so the result
/// is the same on every call and every database.
pub(crate) async fn target_for_cid<C: ConnectionTrait>(
db: &C,
target: Hash,
) -> Result<Option<delegation::Model>, DbErr> {
if let Some(row) = delegation::Entity::find_by_id(target).one(db).await? {
return Ok(Some(row));
}
let Some(alias) = delegation_cid_alias::Entity::find()
.filter(delegation_cid_alias::Column::Canonical.eq(target))
.order_by_asc(delegation_cid_alias::Column::Id)
.one(db)
.await?
else {
return Ok(None);
};
delegation::Entity::find_by_id(alias.id).one(db).await
}

/// Whether `owner` may issue a parentless `tinycloud.delegation/revoke` grant
/// on exactly `urn:cid:<target>`: the target is stored and one of its
/// persisted abilities names a space `owner` owns. Being the target's
/// delegator or delegatee is not enough.
pub(crate) async fn owner_controls_target<C: ConnectionTrait>(
db: &C,
owner: &str,
cap: &crate::util::Capability,
) -> Result<bool, DbErr> {
if cap.ability.as_ref().as_ref() != ControlAction::Revoke.ability() {
return Ok(false);
}
let Resource::Other(resource) = &cap.resource else {
return Ok(false);
};
let Some(target) = resource
.as_str()
.strip_prefix("urn:cid:")
.and_then(|cid| cid.parse::<Cid>().ok())
else {
return Ok(false);
};
let Some(target) = target_for_cid(db, Hash::from(target)).await? else {
return Ok(false);
};
Ok(abilities::Entity::find()
.filter(abilities::Column::Delegation.eq(target.id))
.all(db)
.await?
.iter()
.any(|row| {
row.resource
.space()
.is_some_and(|space| did_principal_matches(space.did().as_str(), owner))
}))
}

#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Expand Down Expand Up @@ -532,8 +619,8 @@ pub(crate) async fn process<C: ConnectionTrait>(
};

let hash: Hash = hash(&serialization);
let delegation = delegation::Entity::find_by_id(Hash::from(r.revoked))
.one(db)
let requested = Hash::from(r.revoked);
let delegation = target_for_cid(db, requested)
.await?
.ok_or(RevocationError::MissingParents)?;

Expand All @@ -546,7 +633,7 @@ pub(crate) async fn process<C: ConnectionTrait>(
// the above.
// The delegatee alone is never authorized: it may hold a key that
// others share, and revoking would cut them off as well.
if !revoker_is_authorized(db, &delegation, &r.revoker, &r.parents).await? {
if !revoker_is_authorized(db, &delegation, &requested, &r.revoker, &r.parents).await? {
return Err(RevocationError::UnauthorizedRevoker(r.revoker).into());
};

Expand Down Expand Up @@ -595,30 +682,32 @@ pub(crate) async fn process<C: ConnectionTrait>(
async fn revoker_is_authorized<C: ConnectionTrait>(
db: &C,
delegation: &delegation::Model,
requested: &Hash,
revoker: &str,
proofs: &[Cid],
) -> Result<bool, DbErr> {
let principal =
match control_proof_decision(db, revoker, proofs, ControlAction::Revoke, &delegation.id)
.await?
{
ControlProofDecision::DirectSigner(principal) => principal,
ControlProofDecision::Scoped(control) => {
if control.directly_authorizes_cid(&delegation.id, delegation) {
return Ok(true);
}
let target_abilities = abilities::Entity::find()
.filter(abilities::Column::Delegation.eq(delegation.id))
.all(db)
.await?;
return Ok(control.authorizes_target(
&delegation.id,
delegation,
&target_abilities,
));
let principal = match control_proof_decision(
db,
revoker,
proofs,
ControlAction::Revoke,
requested,
)
.await?
{
ControlProofDecision::DirectSigner(principal) => principal,
ControlProofDecision::Scoped(control) => {
if control.directly_authorizes_cid(requested, delegation) {
return Ok(true);
}
ControlProofDecision::Denied => return Ok(false),
};
let target_abilities = abilities::Entity::find()
.filter(abilities::Column::Delegation.eq(delegation.id))
.all(db)
.await?;
return Ok(control.authorizes_target(requested, delegation, &target_abilities));
}
ControlProofDecision::Denied => return Ok(false),
};
if ControlAction::Revoke.party_may_act(delegation, &principal) {
return Ok(true);
}
Expand Down
Loading
Loading