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:
MechaCat02
2026-06-26 23:04:53 +02:00
parent c18ce7c2c4
commit 193336a8a6
17 changed files with 1031 additions and 53 deletions

View File

@@ -1242,27 +1242,43 @@ impl Client {
}
/// `POST /api/v1/admin/tree/plan` — diff a whole project tree (Phase 5).
pub async fn plan_tree(&self, bundle: &serde_json::Value) -> Result<TreePlanDto> {
/// `project_key` (§7, M3) lets the server report ownership conflicts and
/// structural-prune candidates for this repo.
pub async fn plan_tree(
&self,
bundle: &serde_json::Value,
project_key: Option<&str>,
) -> Result<TreePlanDto> {
let body = serde_json::json!({
"bundle": bundle,
"project_key": project_key,
});
let resp = self
.request(Method::POST, "/api/v1/admin/tree/plan")
.json(bundle)
.json(&body)
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/tree/apply` — reconcile a whole project tree in one
/// transaction (Phase 5).
/// transaction (Phase 5). `project_key`/`allow_takeover` drive ownership
/// (§7, M3): the apply claims unclaimed declared groups for this project and
/// refuses (or, with `allow_takeover`, seizes) ones another project owns.
pub async fn apply_tree(
&self,
bundle: &serde_json::Value,
prune: bool,
expected_token: Option<&str>,
project_key: Option<&str>,
allow_takeover: bool,
) -> Result<ApplyReportDto> {
let body = serde_json::json!({
"bundle": bundle,
"prune": prune,
"expected_token": expected_token,
"project_key": project_key,
"allow_takeover": allow_takeover,
});
let resp = self
.request(Method::POST, "/api/v1/admin/tree/apply")
@@ -1270,12 +1286,11 @@ impl Client {
.send()
.await?;
if resp.status() == reqwest::StatusCode::CONFLICT {
// 409 covers both bound-plan staleness AND an ownership conflict; the
// server's message is self-explanatory for each, so surface it raw.
let body = resp.text().await.unwrap_or_default();
let msg = parse_error_body(&body).unwrap_or(body);
return Err(anyhow!(
"{msg}\nThe project tree changed since your last `pic plan`. Re-run \
`pic plan --dir` to review, then `pic apply --dir` — or add `--force`."
));
return Err(anyhow!("{msg}"));
}
decode(resp).await
}
@@ -1365,6 +1380,20 @@ pub struct TreePlanDto {
pub nodes: Vec<NodePlanDto>,
#[serde(default)]
pub state_token: String,
/// Declared groups owned by another project (§7, M3) — surfaced so CI sees
/// the conflict before `apply` refuses it.
#[serde(default)]
pub conflicts: Vec<OwnershipConflictDto>,
/// Slugs of owned groups absent from the manifest — what `--prune` deletes.
#[serde(default)]
pub structural_prunes: Vec<String>,
}
/// A single ownership conflict (`pic plan --dir`).
#[derive(Debug, Deserialize)]
pub struct OwnershipConflictDto {
pub slug: String,
pub owner_key: String,
}
#[derive(Debug, Deserialize)]
@@ -1421,6 +1450,12 @@ pub struct ApplyReportDto {
#[serde(default)]
pub groups_reparented: u32,
#[serde(default)]
pub groups_claimed: u32,
#[serde(default)]
pub groups_taken_over: u32,
#[serde(default)]
pub groups_pruned: u32,
#[serde(default)]
pub warnings: Vec<String>,
}

View File

@@ -118,6 +118,7 @@ pub async fn run_tree(
prune: bool,
yes: bool,
force: bool,
takeover: bool,
env: Option<&str>,
mode: OutputMode,
) -> Result<()> {
@@ -138,8 +139,19 @@ pub async fn run_tree(
.map(|l| l.state_token)
};
// This repo's stable project key (§7, M3): the server claims declared
// groups for it and refuses ones another project owns (unless --takeover).
let project_key = crate::linkstate::ensure_project_key(dir)
.context("establishing project identity in .picloud/")?;
let report = client
.apply_tree(&bundle, prune, expected_token.as_deref())
.apply_tree(
&bundle,
prune,
expected_token.as_deref(),
Some(&project_key),
takeover,
)
.await?;
crate::linkstate::clear_plan(dir, token_key);
@@ -181,14 +193,28 @@ pub async fn run_tree(
),
);
// Structural changes only happen on a tree apply; omit the line otherwise.
if report.groups_created > 0 || report.groups_reparented > 0 {
if report.groups_created > 0
|| report.groups_reparented > 0
|| report.groups_pruned > 0
|| report.groups_claimed > 0
|| report.groups_taken_over > 0
{
block.field(
"groups",
format!(
"+{} reparented {}",
report.groups_created, report.groups_reparented
"+{} reparented {} pruned {}",
report.groups_created, report.groups_reparented, report.groups_pruned
),
);
if report.groups_claimed > 0 || report.groups_taken_over > 0 {
block.field(
"ownership",
format!(
"claimed {} taken-over {}",
report.groups_claimed, report.groups_taken_over
),
);
}
}
for w in &report.warnings {
block.field("warning", w.clone());
@@ -211,7 +237,8 @@ fn confirm_prune(slug: &str) -> Result<()> {
}
eprint!(
"apply --prune will DELETE live scripts/routes/triggers on `{slug}` that are \
absent from the manifest. This cannot be undone. Continue? [y/N] "
absent from the manifest — and, for a tree apply, entire owned groups absent \
from the manifest. This cannot be undone. Continue? [y/N] "
);
std::io::stderr().flush().ok();
let mut answer = String::new();

View File

@@ -87,6 +87,9 @@ pub fn run(
}
fs::write(&manifest_path, body).with_context(|| format!("writing {MANIFEST_FILE}"))?;
ensure_gitignored(dir)?;
// Mint the repo's stable project identity (§7, M3) so the first tree apply
// claims its groups under a key that survives clones/CI. Gitignored.
crate::linkstate::ensure_project_key(dir).context("minting project key in .picloud/")?;
let mut block = KvBlock::new();
block

View File

@@ -50,7 +50,10 @@ pub async fn run_tree(dir: &Path, env: Option<&str>, mode: OutputMode) -> Result
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let (bundle, _count) = crate::discover::build_tree(dir, env)?;
let plan = client.plan_tree(&bundle).await?;
// Mint/read this repo's project key so the server can report ownership
// (§7, M3). Best-effort: a read-only dir still plans (ownership unreported).
let project_key = crate::linkstate::ensure_project_key(dir).ok();
let plan = client.plan_tree(&bundle, project_key.as_deref()).await?;
if !plan.state_token.is_empty() {
if let Err(e) = crate::linkstate::write_plan(dir, TREE_TOKEN_KEY, &plan.state_token) {
eprintln!("warning: could not record plan state for `pic apply`: {e}");
@@ -85,6 +88,23 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
}
}
table.print(mode);
// Ownership diagnostics (§7, M3) — surfaced to stderr in non-JSON output
// (JSON consumers read `conflicts`/`structural_prunes` from the payload).
if mode != OutputMode::Json {
if !plan.conflicts.is_empty() {
eprintln!("\nownership conflicts (apply blocked without --takeover):");
for c in &plan.conflicts {
eprintln!(" - {} is owned by project {}", c.slug, c.owner_key);
}
}
if !plan.structural_prunes.is_empty() {
eprintln!("\ngroups absent from the manifest (deleted only with --prune):");
for slug in &plan.structural_prunes {
eprintln!(" - {slug}");
}
}
}
}
/// Assemble the wire bundle: scripts carry inlined source (read from

View File

@@ -13,6 +13,59 @@ use serde::{Deserialize, Serialize};
const DIR: &str = ".picloud";
const PLAN_FILE: &str = "plan.json";
const PROJECT_FILE: &str = "project.json";
/// The repo's stable project identity (§7, M3): a random, opaque key minted on
/// first need and persisted (gitignored) in `.picloud/`. It is the server-side
/// ownership handle — the same repo keeps the same project across clones/CI
/// because the key travels in the working tree, never in a committed file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectLink {
pub key: String,
}
fn project_path(base: &Path) -> PathBuf {
base.join(DIR).join(PROJECT_FILE)
}
/// Read the project key, if one has been minted under `base/.picloud/`.
#[must_use]
pub fn read_project_key(base: &Path) -> Option<String> {
let body = fs::read(project_path(base)).ok()?;
serde_json::from_slice::<ProjectLink>(&body)
.ok()
.map(|l| l.key)
}
/// Read the project key, minting and persisting a fresh one if absent. Used by
/// the tree `plan`/`apply` paths so a repo that never ran `pic init` still gets
/// a stable ownership identity on first use. The new key is a random v4 UUID.
pub fn ensure_project_key(base: &Path) -> Result<String> {
if let Some(k) = read_project_key(base) {
return Ok(k);
}
let key = uuid::Uuid::new_v4().to_string();
write_project_key(base, &key)?;
Ok(key)
}
/// Persist `key` as the project identity, creating `.picloud/` (self-ignored)
/// if needed. Idempotent overwrite — callers usually go through
/// [`ensure_project_key`].
pub fn write_project_key(base: &Path, key: &str) -> Result<()> {
let dir = base.join(DIR);
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
let ignore = dir.join(".gitignore");
if !ignore.exists() {
fs::write(&ignore, "*\n").context("writing .picloud/.gitignore")?;
}
let body = serde_json::to_vec_pretty(&ProjectLink {
key: key.to_string(),
})
.context("encoding .picloud/project.json")?;
fs::write(project_path(base), body).context("writing .picloud/project.json")?;
Ok(())
}
/// The recorded result of the last `pic plan`, scoped to the app it was for.
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -220,6 +220,10 @@ struct ApplyArgs {
/// since the last `pic plan`).
#[arg(long)]
force: bool,
/// Tree apply only (`--dir`): seize declared groups currently owned by
/// another project (§7). Requires group-admin on each contested node.
#[arg(long, requires = "dir")]
takeover: bool,
/// Merge the `picloud.<env>.toml` overlay (per-env slug + secrets) on
/// top of the base manifest before applying.
#[arg(long)]
@@ -1346,6 +1350,7 @@ async fn main() -> ExitCode {
args.prune,
args.yes,
args.force,
args.takeover,
args.env.as_deref(),
mode,
)