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:
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user