feat(project-tool): multi-repo single-owner ownership + takeover (§7 M3)

Ports the M3 milestone from the superseded feat/hierarchies branch onto main's
evolved apply engine. A group node is authoritatively managed by at most one
project-root; `pic apply --dir` claims, conflicts, takes over, and (with
--prune) structurally reaps owned nodes.

- Migration 0066: `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; `pic plan --dir` surfaces
  ownership conflicts + structural-prune candidates read-only.
- Server: prepare_tree resolves ownership read-only (token folds each group's
  owner key, so a claim/takeover by another repo between plan and apply trips
  StateMoved). apply_tree upserts the project in-tx, claims unclaimed declared
  groups, and takes over owned ones under `--takeover`. --prune reaps
  owned-but-undeclared groups leaf-first (delete_group_tx 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.

Adaptation to main (which lacks the superseded branch's M2 declarative group
create/reparent): groups pre-exist, so ownership stamps existing declared
nodes rather than claim-on-create; the declarative attach-point is deferred.

Review fixes (adversarial pass, 3 findings, all closed):
- HIGH: an empty `[group]` node was claimed capability-free — require a
  baseline GroupScriptsWrite (editor) on every group apply node, so claiming
  ownership needs write authority (Ownership ⟂ RBAC).
- MEDIUM: structural prune would silently CASCADE a group's §11.6 shared
  collections + secrets (which postdate M3's original design). Guard: a prune
  candidate holding shared data/secrets is KEPT with a warning (plus its
  candidate ancestors, so a preserved child never aborts a parent delete).
- LOW: a corrupt `.picloud/project.json` silently re-minted a new key
  (orphaning ownership) — now fails loudly if the file exists but won't parse.

Tests: tests/ownership.rs (claim → conflict → takeover → flip; non-admin
takeover → 403; prune-owned-only; prune-refuses-to-cascade-shared-data);
format_conflicts + validate_project_key unit tests; schema golden reblessed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-05 20:29:12 +02:00
parent 654e38752d
commit 5c33b7490b
15 changed files with 1183 additions and 43 deletions

View File

@@ -36,6 +36,7 @@ mod init;
mod invoke;
mod logs;
mod output;
mod ownership;
mod plan;
mod prune;
mod pull;

View File

@@ -0,0 +1,288 @@
//! M3 multi-repo single-owner ownership (§7) via `pic apply --dir`.
//!
//! On this build groups pre-exist (created via `pic groups create`; the tree
//! apply reconciles a node's CONTENT and CLAIMS its ownership, it does not
//! create the group). So each test first creates the group(s), then:
//! * the first repo to `apply` a group CLAIMS it (its `.picloud/` project key
//! becomes the group's `owner_project`),
//! * a SECOND repo (a distinct dir → a distinct project key) applying the
//! same group is REJECTED with an ownership conflict,
//! * `--takeover` lets the second repo seize it — admin-gated, so the fixture
//! instance-owner succeeds, flipping ownership (proven by the first repo now
//! being the one rejected), but a group `editor` is 403'd (ownership ⟂ RBAC,
//! §7.4),
//! * `apply --prune` deletes an owned, now-undeclared, empty group; a group
//! this project never claimed is never touched.
//!
//! Each project lives in its own `TempDir`, so `pic` mints a distinct project
//! key per repo (`.picloud/project.json`) — 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 declaring the (pre-existing) group `slug`. No
/// scripts, so a structural prune can delete it (a group holding scripts/apps
/// is RESTRICT-pinned). `extra` is appended to the `[group]` table verbatim.
fn group_dir(slug: &str, extra: &str) -> TempDir {
let dir = TempDir::new().expect("tempdir");
fs::write(
dir.path().join("picloud.toml"),
format!("[group]\nslug = \"{slug}\"\nname = \"Owned Group\"\n{extra}"),
)
.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 create_group(env: &common::TestEnv, slug: &str, parent: Option<&str>) {
let mut cmd = common::pic_as(env);
cmd.args(["groups", "create", slug]);
if let Some(p) = parent {
cmd.args(["--parent", p]);
}
let out = cmd.output().expect("groups create");
assert!(
out.status.success(),
"groups create {slug} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
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);
create_group(&env, &slug, None);
// Repo A claims the group on first apply (owner NULL → project A).
let repo_a = group_dir(&slug, "");
let a1 = apply(&env, repo_a.path(), &[]);
assert!(
a1.status.success(),
"repo A claim apply failed: {}",
String::from_utf8_lossy(&a1.stderr)
);
// Repo B (a different dir → a different project key) is rejected: the group
// is owned by project A.
let repo_b = group_dir(&slug, "");
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 group `editor` (who CAN write group
// vars) still cannot seize the group 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);
create_group(&env, &slug, None);
// Admin (repo A) claims the group.
let repo_a = group_dir(&slug, "");
assert!(
apply(&env, repo_a.path(), &[]).status.success(),
"admin claim apply should succeed"
);
// A member granted `editor` (not app_admin/group_admin) on the group.
let m = member::member_user(fx, &common::unique_username("ta"));
common::pic_as(&env)
.args(["groups", "members", "add", &slug, &m.id, "--role", "editor"])
.output()
.expect("add group member");
let member_env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&member_env, &m.username);
// The member's repo B (a distinct project key) declares a group var (so its
// write cap is satisfied) and attempts a takeover → 403 at the GroupAdmin
// gate, before any write lands.
let repo_b = group_dir(&slug, "\n[vars]\nregion = \"eu\"\n");
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);
create_group(&env, &parent, None);
create_group(&env, &child, Some(&parent));
// Repo declares parent (root manifest) + child (a nested dir); apply claims
// both for this project.
let dir = TempDir::new().expect("tempdir");
fs::write(
dir.path().join("picloud.toml"),
format!("[group]\nslug = \"{parent}\"\nname = \"Parent\"\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\"\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 after claim"
);
// 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), "declared parent must be kept");
assert!(
!groups.contains(&child),
"owned-but-undeclared child must be pruned"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn prune_refuses_to_cascade_a_groups_shared_data() {
// §7 M3 data-loss guard: an owned-but-undeclared group is NOT structurally
// pruned if it holds §11.6 shared collections/secrets (those CASCADE) — it
// is kept with a warning instead of being silently vaporized.
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let parent = common::unique_slug("own-dpar");
let child = common::unique_slug("own-dchild");
let _gp = GroupGuard::new(&env.url, &env.token, &parent);
let _gc = GroupGuard::new(&env.url, &env.token, &child);
create_group(&env, &parent, None);
create_group(&env, &child, Some(&parent));
// Declare parent + child; the child declares a shared KV collection, so the
// first apply claims both AND creates a group-owned collection marker.
let dir = TempDir::new().expect("tempdir");
fs::write(
dir.path().join("picloud.toml"),
format!("[group]\nslug = \"{parent}\"\nname = \"Parent\"\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\"\n\ncollections = [\"catalog\"]\n"),
)
.unwrap();
assert!(
apply(&env, dir.path(), &[]).status.success(),
"initial claim+collection apply should succeed"
);
// Drop the child node, then prune: the child owns a shared collection, so it
// must be KEPT (with a warning), not cascaded away.
fs::remove_dir_all(dir.path().join("sub")).unwrap();
let out = apply(&env, dir.path(), &["--prune", "--yes"]);
assert!(
out.status.success(),
"prune apply should succeed (skipping the protected group): {}",
String::from_utf8_lossy(&out.stderr)
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("shared collections"),
"prune should warn it skipped a group owning shared data:\n{combined}"
);
assert!(
ls_groups(&env).contains(&child),
"a group owning shared collections must NOT be pruned"
);
}