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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user