feat(hierarchies): multi-repo single-owner ownership + structural prune (M3)
§7 of the groups/project-tool design. A group node is now authoritatively managed by exactly one project-root; `pic apply --dir` claims, conflicts, takes over, and (with --prune) structurally reaps owned nodes. - Migration 0052: `projects(id, key UNIQUE)` + promote the inert `groups.owner_project` (0047) to a real FK (ON DELETE SET NULL) + index. - CLI mints a stable, gitignored project key in `.picloud/project.json` (`pic init`, or lazily on first tree plan/apply) and presents it on every tree request; `pic apply --dir --takeover` flag. - Server: prepare_tree resolves ownership read-only (plan surfaces conflicts + prune candidates; token folds each group's owner key). apply_tree upserts the project in-tx, claims created groups on insert, reconciles existing-group ownership under the per-node advisory lock (first-commit-wins), and prunes owned-but-undeclared groups leaf-first (delete=RESTRICT, never another repo's or a UI-owned node). - Authz (§7.4, ownership ⟂ RBAC): takeover requires GroupAdmin per contested node — enforced in authz_tree (pre-tx) AND re-verified in-tx at the ownership decision, so --force (which waives the staleness token) can't open a takeover-without-admin window. The attacker-supplied project key is length/charset-validated server-side. - Tests: tests/ownership.rs (claim → conflict → takeover → flip; non-admin takeover → 403; prune-owned-only); format_conflicts unit test; schema golden reblessed. tree_shape M2 journey updated to reparent in-place (a fresh dir is now a distinct project and would correctly conflict). Closes the M3 milestone; reviewed (4 findings: 1 authz-bypass via --force + key validation + 2 LOW, all fixed). M4 (trigger/route templates) remains. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,7 @@ mod init;
|
||||
mod invoke;
|
||||
mod logs;
|
||||
mod output;
|
||||
mod ownership;
|
||||
mod plan;
|
||||
mod prune;
|
||||
mod pull;
|
||||
|
||||
@@ -78,6 +78,26 @@ pub fn grant_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str)
|
||||
);
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/groups/{slug}/members` — grant `role` on a group.
|
||||
pub fn grant_group_membership(fx: &Fixture, group_slug: &str, user_id: &str, role: &str) {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/api/v1/admin/groups/{}/members",
|
||||
fx.url, group_slug
|
||||
))
|
||||
.bearer_auth(&fx.admin_token)
|
||||
.json(&json!({ "user_id": user_id, "role": role }))
|
||||
.send()
|
||||
.expect("grant group membership");
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"grant group membership failed: {} {}",
|
||||
resp.status(),
|
||||
resp.text().unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
|
||||
/// `PATCH /api/v1/admin/apps/{slug}/members/{user_id}` — promote/demote.
|
||||
pub fn update_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str) {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
|
||||
207
crates/picloud-cli/tests/ownership.rs
Normal file
207
crates/picloud-cli/tests/ownership.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
//! M3 multi-repo single-owner ownership (§7) via `pic apply --dir`:
|
||||
//! * the first repo to apply a group CLAIMS it (its `.picloud/` project key
|
||||
//! becomes the owner),
|
||||
//! * a SECOND repo (different project key) applying the same group is
|
||||
//! REJECTED with an ownership conflict,
|
||||
//! * `--takeover` lets the second repo seize it (admin-gated; the fixture
|
||||
//! token is instance owner), flipping ownership — proven by the first repo
|
||||
//! now being the one rejected,
|
||||
//! * `--prune` deletes an owned, now-undeclared, empty group; a group owned
|
||||
//! by another repo is never touched.
|
||||
//!
|
||||
//! Each project lives in its own `TempDir`, so `pic` mints a distinct project
|
||||
//! key per repo (`.picloud/project.json`) — exactly the two-repo topology §7
|
||||
//! governs.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::GroupGuard;
|
||||
use crate::common::member;
|
||||
|
||||
/// A single-group project dir: group `slug` under `parent`, no scripts (so a
|
||||
/// structural prune can delete it — a group holding scripts is RESTRICT-pinned).
|
||||
fn group_dir(slug: &str, parent: &str) -> TempDir {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!("[group]\nslug = \"{slug}\"\nname = \"Owned Group\"\nparent = \"{parent}\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn apply(env: &common::TestEnv, dir: &Path, extra: &[&str]) -> std::process::Output {
|
||||
let mut cmd = common::pic_as(env);
|
||||
cmd.args(["apply", "--dir"]).arg(dir).args(extra);
|
||||
cmd.output().expect("apply --dir")
|
||||
}
|
||||
|
||||
fn ls_groups(env: &common::TestEnv) -> String {
|
||||
String::from_utf8(
|
||||
common::pic_as(env)
|
||||
.args(["groups", "ls"])
|
||||
.output()
|
||||
.expect("groups ls")
|
||||
.stdout,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn first_repo_claims_second_is_rejected_then_takeover_flips_ownership() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("own-g");
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
// Repo A claims the group on first apply (it does not pre-exist → created
|
||||
// AND claimed for project A in one tx).
|
||||
let repo_a = group_dir(&slug, "root");
|
||||
let a1 = apply(&env, repo_a.path(), &[]);
|
||||
assert!(
|
||||
a1.status.success(),
|
||||
"repo A claim apply failed: {}",
|
||||
String::from_utf8_lossy(&a1.stderr)
|
||||
);
|
||||
assert!(
|
||||
ls_groups(&env).contains(&slug),
|
||||
"group should exist after claim"
|
||||
);
|
||||
|
||||
// Repo B (a different dir → a different project key) is rejected: the group
|
||||
// is owned by project A.
|
||||
let repo_b = group_dir(&slug, "root");
|
||||
let b1 = apply(&env, repo_b.path(), &[]);
|
||||
assert!(
|
||||
!b1.status.success(),
|
||||
"repo B apply must be rejected (group owned by A)"
|
||||
);
|
||||
let b1_err = String::from_utf8_lossy(&b1.stderr).to_lowercase();
|
||||
assert!(
|
||||
b1_err.contains("owned by another project") || b1_err.contains("takeover"),
|
||||
"conflict message should name the ownership clash:\n{b1_err}"
|
||||
);
|
||||
|
||||
// Repo B with --takeover succeeds (the fixture token is instance owner, so
|
||||
// it holds GroupAdmin on every node) and flips ownership to project B.
|
||||
let b2 = apply(&env, repo_b.path(), &["--takeover"]);
|
||||
assert!(
|
||||
b2.status.success(),
|
||||
"repo B --takeover should succeed: {}",
|
||||
String::from_utf8_lossy(&b2.stderr)
|
||||
);
|
||||
|
||||
// Proof the flip took: repo A — formerly the owner — is now the one
|
||||
// rejected without --takeover.
|
||||
let a2 = apply(&env, repo_a.path(), &[]);
|
||||
assert!(
|
||||
!a2.status.success(),
|
||||
"after takeover, repo A must now be rejected (B owns the group)"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn takeover_without_group_admin_is_forbidden() {
|
||||
// §7.4 — ownership ⟂ RBAC: `--takeover` needs GroupAdmin specifically, a
|
||||
// second gate beyond the write caps. A non-admin member (editor) of the
|
||||
// contested group cannot seize it for their repo.
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("own-ta");
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
// Admin (repo A) claims the group.
|
||||
let repo_a = group_dir(&slug, "root");
|
||||
assert!(
|
||||
apply(&env, repo_a.path(), &[]).status.success(),
|
||||
"admin claim apply should succeed"
|
||||
);
|
||||
|
||||
// A member with `editor` (not group_admin) on the group.
|
||||
let m = member::member_user(fx, &common::unique_username("ta"));
|
||||
member::grant_group_membership(fx, &slug, &m.id, "editor");
|
||||
let member_env = common::custom_env(&fx.url, &m.token);
|
||||
common::seed_credentials(&member_env, &m.username);
|
||||
|
||||
// The member's repo B (different project key) attempts a takeover → 403.
|
||||
let repo_b = group_dir(&slug, "root");
|
||||
let out = apply(&member_env, repo_b.path(), &["--takeover"]);
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"non-admin --takeover must be forbidden"
|
||||
);
|
||||
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
|
||||
assert!(
|
||||
err.contains("forbidden") || err.contains("403"),
|
||||
"takeover denial should be an authz error:\n{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn prune_deletes_owned_undeclared_group_only() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let parent = common::unique_slug("own-par");
|
||||
let child = common::unique_slug("own-child");
|
||||
// Drop order: child (registered last → dropped first), then parent.
|
||||
let _gp = GroupGuard::new(&env.url, &env.token, &parent);
|
||||
let _gc = GroupGuard::new(&env.url, &env.token, &child);
|
||||
|
||||
// Repo declares parent + child (both empty); apply claims both.
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!("[group]\nslug = \"{parent}\"\nname = \"Parent\"\nparent = \"root\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(dir.path().join("sub")).unwrap();
|
||||
fs::write(
|
||||
dir.path().join("sub/picloud.toml"),
|
||||
format!("[group]\nslug = \"{child}\"\nname = \"Child\"\nparent = \"{parent}\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
let out = apply(&env, dir.path(), &[]);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"initial claim apply failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let groups = ls_groups(&env);
|
||||
assert!(
|
||||
groups.contains(&parent) && groups.contains(&child),
|
||||
"both groups should exist"
|
||||
);
|
||||
|
||||
// Drop the child manifest from the SAME repo (keeping its project key) so
|
||||
// the child becomes owned-but-undeclared, then apply --prune: the empty
|
||||
// child is deleted; the parent (still declared) is kept.
|
||||
fs::remove_dir_all(dir.path().join("sub")).unwrap();
|
||||
let out2 = apply(&env, dir.path(), &["--prune", "--yes"]);
|
||||
assert!(
|
||||
out2.status.success(),
|
||||
"prune apply failed: {}",
|
||||
String::from_utf8_lossy(&out2.stderr)
|
||||
);
|
||||
let groups = ls_groups(&env);
|
||||
assert!(
|
||||
groups.contains(&parent),
|
||||
"parent must survive prune:\n{groups}"
|
||||
);
|
||||
assert!(
|
||||
!groups.contains(&child),
|
||||
"owned, undeclared, empty child must be pruned:\n{groups}"
|
||||
);
|
||||
}
|
||||
@@ -83,8 +83,18 @@ fn tree_apply_creates_then_reparents_a_group() {
|
||||
"re-plan of an applied tree must be a no-op:\n{plan_txt}"
|
||||
);
|
||||
|
||||
// --- Reparent: move the child under the instance root. ---
|
||||
let dir2 = group_dir(&child, "root");
|
||||
// --- Reparent: move the child under the instance root. Rewrite the SAME
|
||||
// repo's manifest (M3: a stable `.picloud/` project key per repo — a fresh
|
||||
// dir would be a different project and conflict on the owned group). ---
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[group]\nslug = \"{child}\"\nname = \"Child Group\"\nparent = \"root\"\n\n\
|
||||
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let dir2 = &dir;
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir2.path())
|
||||
|
||||
Reference in New Issue
Block a user