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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user