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

@@ -191,3 +191,75 @@ fn creating_a_group_requires_group_admin_on_the_parent() {
"a denied create must leave no group behind:\n{ls}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn tree_apply_denies_group_content_write_without_the_cap() {
// A tree apply that writes content (here a `[vars]` entry) into a
// PRE-EXISTING group requires the caller hold that group's write cap. This
// pins the invariant the in-tx content-write re-check hardens: a group that
// isn't structurally created by this apply must pass content authz. (The
// race the re-check closes — a group appearing between the API-layer check
// and reconcile — isn't reproducible at the journey level; end to end a
// member without the cap is refused and the var is not written.)
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let base = common::unique_slug("gcw-base");
let grp = common::unique_slug("gcw-grp");
let project = common::unique_slug("gcw-proj");
let _b = GroupGuard::new(&env.url, &env.token, &base);
let _g = GroupGuard::new(&env.url, &env.token, &grp);
// `base` is the attach point; `grp` pre-exists directly under it.
common::pic_as(&env)
.args(["groups", "create", &base])
.assert()
.success();
common::pic_as(&env)
.args(["groups", "create", &grp, "--parent", &base])
.assert()
.success();
// A member with NO membership on `grp` cannot write its vars.
let mem = common::member::member_user(fx, &common::unique_slug("gcw-mem"));
let menv = common::custom_env(&env.url, &mem.token);
common::seed_credentials(&menv, &mem.username);
// A repo that declares the pre-existing `grp` (attached under `base`) with a
// var — a group content write.
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("picloud.toml"),
format!(
"[project]\nslug = \"{project}\"\nparent_group = \"{base}\"\n\n\
[group]\nslug = \"{grp}\"\nname = \"Grp\"\n\n[vars]\nregion = \"eu\"\n"
),
)
.unwrap();
let out = common::pic_as(&menv)
.args(["apply", "--dir"])
.arg(dir.path())
.output()
.expect("member apply");
assert!(
!out.status.success(),
"a member without the group's write cap must be refused a content write"
);
// The var was NOT written (the tx rolled back / never ran).
let vars = String::from_utf8(
common::pic_as(&env)
.args(["vars", "ls", "--group", &grp])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
!vars.contains("region"),
"a denied content write must leave no var behind:\n{vars}"
);
}