§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>
208 lines
7.3 KiB
Rust
208 lines
7.3 KiB
Rust
//! 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}"
|
|
);
|
|
}
|