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

@@ -1120,11 +1120,25 @@ 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. **Per-env approval-policy gating shipped (`[project.environments]`, M5, 2026-07-05)** — the
> **Status (Phase 5): ✅ shipped — nested tree apply, atomic, with multi-repo single-owner
> 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. **Multi-repo single-owner
> ownership + takeover shipped (§7, M3, 2026-07-05)** — each repo mints a stable, gitignored project
> key in `.picloud/project.json`; the first `pic apply --dir` to touch a group **claims** it
> (`groups.owner_project` FK → the new `projects` table, migration 0066 promoting the inert 0047
> column). A second repo (a distinct key) applying an owned group is **refused** (`owned by another
> project; use --takeover`) unless it passes `--takeover`, which is **GroupAdmin-gated per contested
> node** — re-verified **in-tx** at the ownership decision (not only in the pre-tx pool read), so
> `--force` (which waives the staleness token) can't open a takeover-without-admin window. Ownership ⟂
> RBAC: owning the manifest never grants write — the actor still needs the usual group caps. The owner
> key folds into the bound-plan token (a claim/takeover by another repo between plan and apply trips
> `StateMoved`), and the attacker-supplied key is length/charset-validated server-side. `apply --prune`
> **structurally reaps** groups THIS project owns that the manifest no longer declares, leaf-first
> (`delete_group_tx` RESTRICT — a group still holding apps/subgroups aborts the apply, never orphans),
> and never touches another repo's or an unclaimed (UI-owned) group. `pic plan --dir` surfaces
> conflicts + prune candidates read-only. **Deferred:** the declarative **attach-point** / a single
> repo *creating* the group tree (groups still pre-exist — a manifest owns each node's *content*, not
> the tree *shape*). **Per-env approval-policy gating shipped (`[project.environments]`, M5, 2026-07-05)** — the
> root manifest declares which environments are confirm-required; `pic apply --dir --env <e>` to such
> an env needs an explicit `--approve <e>` (a blanket `--yes` does NOT cover it), the approval is
> admin-gated (the actor needs admin on every declared node, not just write) + audited, and the policy
@@ -1249,10 +1263,12 @@ 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 / 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. **Resolved: shipped as M3 (2026-07-05)** — single-owner
claim / conflict / `--takeover` (GroupAdmin-gated) + structural prune, over a `projects` table and
the promoted `groups.owner_project` FK. The declarative **attach-point** (a repo *creating* the
group tree, not just claiming pre-existing groups) remains deferred.
---