fix(apply): gate group content writes in-tx to close a create-race authz hole

Review #1 (HIGH). M1's `authz_tree` skips the group content-write capability
check (`require_group_node_writes`) for a group node absent at API-request
time — a to-create group whose authorization is the service create-gate. But
that gate only fires when the group is STILL absent at reconcile, and the
content writer performs no authz. So a group created by a racer in the window
between the API check and the in-tx reconcile could have its scripts/vars
written by a principal with zero rights on it (the bound-plan token doesn't
save a hand-rolled request that omits `expected_token`).

Close it with an in-tx content-write re-check in `apply_tree` for every group
NOT structurally created/reparented by this apply: a group WE created is
covered by create-RBAC (`GroupAdmin(parent)` cascades) and its uncommitted row
is invisible to the pool-based authz cascade anyway, so it must not be
re-checked; a pre-existing (incl. racer-created) group is committed, so its
ancestry resolves and a missing cap denies. `require_group_node_writes` moves
from `apply_api` onto `ApplyService` (up the existing dependency edge) so the
API pre-check and the in-tx re-check share one implementation.

Pinned by `tests/group_create.rs::tree_apply_denies_group_content_write_without_the_cap`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-07 21:47:59 +02:00
parent 8d71620e11
commit 2e3263002a
3 changed files with 129 additions and 32 deletions

View File

@@ -296,7 +296,8 @@ async fn group_apply_handler(
Json(req): Json<ApplyRequest>,
) -> Result<Json<ApplyReport>, ApplyError> {
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
require_group_node_writes(&svc, &principal, group_id, &req.bundle, req.prune).await?;
svc.require_group_node_writes(&principal, group_id, &req.bundle, req.prune)
.await?;
let claim = OwnershipClaim {
project: req.project,
takeover: req.takeover,
@@ -421,7 +422,7 @@ async fn authz_tree(
let group_id = group.id;
match apply_prune {
Some(prune) => {
require_group_node_writes(svc, principal, group_id, &node.bundle, prune)
svc.require_group_node_writes(principal, group_id, &node.bundle, prune)
.await?;
}
None => {
@@ -504,36 +505,6 @@ async fn require_app_node_writes(
Ok(())
}
/// Group-node write caps (editor-tier): scripts → `GroupScriptsWrite`, vars →
/// `GroupVarsWrite`, both on prune.
async fn require_group_node_writes(
svc: &ApplyService,
principal: &Principal,
group_id: GroupId,
bundle: &Bundle,
prune: bool,
) -> Result<(), ApplyError> {
if prune || !bundle.scripts.is_empty() {
require(
svc.authz.as_ref(),
principal,
Capability::GroupScriptsWrite(group_id),
)
.await
.map_err(map_authz)?;
}
if prune || !bundle.vars.is_empty() {
require(
svc.authz.as_ref(),
principal,
Capability::GroupVarsWrite(group_id),
)
.await
.map_err(map_authz)?;
}
Ok(())
}
/// Resolve a slug-or-id path param to a `GroupId`, mapping miss → 404.
async fn resolve_group_id(
groups: &dyn GroupRepository,

View File

@@ -1795,6 +1795,27 @@ impl ApplyService {
}
}
// §6 content-write authz, in-tx defense-in-depth. `authz_tree` (API
// layer) SKIPS a group node absent at that time (it is to-create),
// deferring to the create-gate in `reconcile_group_structure_tx`. That
// gate only fires when the group is STILL absent at reconcile — so a
// group created by a racer in the window would otherwise have its
// content written with no authz. Re-check content-write caps here for
// every group NOT structurally created/reparented by this apply: a group
// WE created is covered by create-RBAC (`GroupAdmin(parent)` cascades)
// and its uncommitted row is invisible to the pool-based authz cascade
// anyway, so it must not be re-checked; a pre-existing (incl.
// racer-created) group IS committed, so its ancestry resolves and a
// missing cap denies. Runs before the write loops; a denial rolls back.
for p in &prepared {
if let ApplyOwner::Group(gid) = p.owner {
if !structurally_changed.contains(&gid) {
self.require_group_node_writes(claim.principal, gid, &p.node.bundle, prune)
.await?;
}
}
}
// Lock every node's apply key, in sorted order, so concurrent applies
// touching any shared node serialize and the ordering can't deadlock.
let mut lock_keys: Vec<i64> = prepared.iter().map(|p| apply_lock_key(p.owner)).collect();
@@ -2410,6 +2431,39 @@ impl ApplyService {
Ok(out)
}
/// Group-node write caps (editor-tier): scripts → `GroupScriptsWrite`, vars
/// → `GroupVarsWrite`, both required on prune. The single-group and tree
/// apply authorization (`apply_api::authz_tree`) call this at the API layer;
/// the tree apply ALSO re-checks it in-tx (see `apply_tree`) to close the
/// window where a group absent at authz time appears before reconcile.
pub(crate) async fn require_group_node_writes(
&self,
principal: &Principal,
group_id: GroupId,
bundle: &Bundle,
prune: bool,
) -> Result<(), ApplyError> {
if prune || !bundle.scripts.is_empty() {
require(
self.authz.as_ref(),
principal,
Capability::GroupScriptsWrite(group_id),
)
.await
.map_err(map_authz)?;
}
if prune || !bundle.vars.is_empty() {
require(
self.authz.as_ref(),
principal,
Capability::GroupVarsWrite(group_id),
)
.await
.map_err(map_authz)?;
}
Ok(())
}
// ------------------------------------------------------------------------
// §7 ownership claim — the ApplyService side (executes the pure verdict).
// ------------------------------------------------------------------------