§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>
124 lines
4.5 KiB
Rust
124 lines
4.5 KiB
Rust
//! M2 declarative tree SHAPE via `pic apply --dir`:
|
|
//! * a group manifest for a group that does NOT exist yet is CREATED under
|
|
//! its declared parent, with its content, in one atomic apply,
|
|
//! * re-applying the same tree is a no-op (the group now exists),
|
|
//! * changing the declared parent REPARENTS the group on the next apply.
|
|
|
|
use std::fs;
|
|
|
|
use tempfile::TempDir;
|
|
|
|
use crate::common;
|
|
use crate::common::cleanup::{GroupGuard, ScriptGuard};
|
|
|
|
/// Write a single-group project dir declaring group `slug` under `parent` with
|
|
/// one endpoint script.
|
|
fn group_dir(slug: &str, parent: &str) -> TempDir {
|
|
let dir = TempDir::new().expect("tempdir");
|
|
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
|
fs::write(dir.path().join("scripts/hello.rhai"), r#""hi""#).unwrap();
|
|
fs::write(
|
|
dir.path().join("picloud.toml"),
|
|
format!(
|
|
"[group]\nslug = \"{slug}\"\nname = \"Child Group\"\nparent = \"{parent}\"\n\n\
|
|
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
dir
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn tree_apply_creates_then_reparents_a_group() {
|
|
let Some(fx) = common::fixture_or_skip() else {
|
|
return;
|
|
};
|
|
let env = common::admin_env(fx);
|
|
let parent = common::unique_slug("ts-par");
|
|
let child = common::unique_slug("ts-child");
|
|
|
|
// Guards drop in reverse order: parent (last) ← child (mid) ← script (first).
|
|
let _gp = GroupGuard::new(&env.url, &env.token, &parent);
|
|
let _gc = GroupGuard::new(&env.url, &env.token, &child);
|
|
common::pic_as(&env)
|
|
.args(["groups", "create", &parent])
|
|
.assert()
|
|
.success();
|
|
|
|
// --- Create: the child group does NOT exist; apply --dir creates it. ---
|
|
let dir = group_dir(&child, &parent);
|
|
let out = common::pic_as(&env)
|
|
.args(["apply", "--dir"])
|
|
.arg(dir.path())
|
|
.output()
|
|
.expect("apply --dir");
|
|
assert!(
|
|
out.status.success(),
|
|
"tree apply (create) failed: {}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
|
|
// The group now exists with its script.
|
|
let groups = ls(&env, &["groups", "ls"]);
|
|
assert!(groups.contains(&child), "created group missing:\n{groups}");
|
|
let scripts = ls(&env, &["scripts", "ls", "--group", &child]);
|
|
let script_id = scripts
|
|
.lines()
|
|
.map(common::cells)
|
|
.find(|c| c.get(2) == Some(&"hello"))
|
|
.and_then(|c| c.first().map(|s| (*s).to_string()))
|
|
.unwrap_or_else(|| panic!("group script `hello` should exist:\n{scripts}"));
|
|
let _gs = ScriptGuard::new(&env.url, &env.token, &script_id);
|
|
|
|
// Re-applying the same tree is a no-op (group + content unchanged).
|
|
let plan_out = common::pic_as(&env)
|
|
.args(["plan", "--dir"])
|
|
.arg(dir.path())
|
|
.output()
|
|
.expect("plan --dir");
|
|
let plan_txt = String::from_utf8_lossy(&plan_out.stdout).to_lowercase();
|
|
assert!(
|
|
!plan_txt.contains("create") && !plan_txt.contains("delete"),
|
|
"re-plan of an applied tree must be a no-op:\n{plan_txt}"
|
|
);
|
|
|
|
// --- 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())
|
|
.output()
|
|
.expect("apply --dir reparent");
|
|
assert!(
|
|
out.status.success(),
|
|
"tree apply (reparent) failed: {}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
// Re-plan is a no-op now that the parent matches.
|
|
let plan_out = common::pic_as(&env)
|
|
.args(["plan", "--dir"])
|
|
.arg(dir2.path())
|
|
.output()
|
|
.expect("plan --dir after reparent");
|
|
let plan_txt = String::from_utf8_lossy(&plan_out.stdout).to_lowercase();
|
|
assert!(
|
|
!plan_txt.contains("create") && !plan_txt.contains("delete"),
|
|
"re-plan after reparent must be a no-op:\n{plan_txt}"
|
|
);
|
|
}
|
|
|
|
fn ls(env: &common::TestEnv, args: &[&str]) -> String {
|
|
String::from_utf8(common::pic_as(env).args(args).output().expect("ls").stdout).unwrap()
|
|
}
|