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

@@ -634,6 +634,26 @@ non-orphaning:
## 7. Ownership of shared nodes
> **Status: ✅ shipped (M3, 2026-06-26).** Migration `0052_owner_project.sql` adds a `projects(id, key
> UNIQUE)` table and promotes the inert `groups.owner_project` (0047) to a real FK. The 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. **First apply claims** each declared group for the
> project (NULL owner → stamped, in-tx, first-commit-wins under the per-node advisory lock). A second
> project applying an owned group is **refused** (`pic plan --dir` surfaces the conflict for CI;
> `pic apply --dir` returns 409) unless `pic apply --dir --takeover`, which is **GroupAdmin-gated** per
> contested node (ownership ⟂ RBAC: two independent gates, §7.4) and flips the owner. With ownership in
> place, `--prune` now also reaps **structural** nodes: a group this project owns that the manifest no
> longer declares is deleted **leaf-first** (RESTRICT-guarded, so a still-populated group aborts the
> apply rather than orphaning data) — scoped to project-owned nodes, so one repo never reaps another's
> or a UI-owned (unclaimed) node. The bound-plan token folds each declared group's owner key, so a
> claim/takeover by another repo between plan and apply trips `StateMoved`. **Attach-point ceiling**
> (§7.2) is implicit: a manifest only ever writes the nodes it declares; an ancestor parent is
> referenced read-only, never claimed or written. The GroupAdmin takeover gate is enforced **twice**:
> in `authz_tree` (pre-tx) and again **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 before use. Journey coverage: claim → conflict → takeover → flip
> → prune, plus a non-admin `--takeover` → 403.
**Single-owner-per-node, ceilinged by the attach point.**
1. Each group node is owned by exactly one **project-root** (the repo that *manages* it — contains
@@ -1007,14 +1027,13 @@ Resolved items now live inline next to their topic. What genuinely remains:
5. **Project tool maps onto groups.** Nested manifests, attach point, single-owner, server-computed
tree plan, per-env approval gating.
> **Status (Phase 5): ✅ shipped — single-repo nested tree apply, atomic.** A directory tree of
> `picloud.toml` manifests (each declaring an `[app]` or `[group]` node) applies as ONE
> server-computed plan in ONE Postgres transaction. Per the §11.1 review, the **multi-repo
> single-owner / attach-point / takeover** layer (§7) is **deferred** for the solo-dev / single-repo
> start, as is **per-env approval-policy gating** (`[project.environments]`); env overlays
> (`picloud.<env>.toml`) already exist. Groups must **pre-exist** (`pic groups create`) — a manifest
> owns each node's *content*, not the tree *shape* (declarative group create/reparent is a later
> add).
> **Status (Phase 5): ✅ shipped — nested tree apply, tree shape, AND multi-repo ownership.** A
> directory tree of `picloud.toml` manifests (each declaring an `[app]` or `[group]` node) applies as
> ONE server-computed plan in ONE Postgres transaction. **Declarative group create/reparent shipped
> (M2, 2026-06-26)** — a manifest now owns the tree *shape*, not just node content. **Multi-repo
> single-owner / takeover + structural prune shipped (§7, M3, 2026-06-26)** — see §7 below. Still
> deferred: **per-env approval-policy gating** (`[project.environments]`); env overlays
> (`picloud.<env>.toml`) already exist.
>
> Shipped surface:
> - **Engine:** the reconcile engine generalized from app-only to an `ApplyOwner { App | Group }`
@@ -1062,10 +1081,10 @@ to take, not yet taken:
- *Trigger/route templates (§4.5):* bundled into Phase 6 as "much later," but the per-app binding
tax bites in Phase 5 at high tenant cardinality (100 tenants × 5 triggers = 500 declarations).
Decide against **actual** target tenant counts before defaulting it late.
- *Multi-repo ownership (§7):* the single-owner / attach-point / takeover machinery is substantial
and only earns its keep with a **second** managing repo. For a solo-dev / single-repo start, a
"one repo owns the whole subtree" model covers the near term; confirm the multi-repo need exists
before building the ownership layer.
- *Multi-repo ownership (§7):* ~~the single-owner / attach-point / takeover machinery is substantial
and only earns its keep with a **second** managing repo~~ — **shipped (M3, 2026-06-26)**; see the
§7 status note. Project-keyed single-owner claim, GroupAdmin-gated `--takeover`, and project-scoped
structural prune all landed.
---