Files
PiCloud/crates/manager-core/migrations/0052_owner_project.sql
MechaCat02 193336a8a6 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>
2026-06-26 23:04:53 +02:00

40 lines
2.1 KiB
SQL

-- Phase 5 / M3 (v1.2 Hierarchies): multi-repo single-owner ownership (§7).
--
-- A group node is authoritatively MANAGED by at most one project-root (the
-- repo whose manifest declares it as something it applies, not merely
-- references). `groups.owner_project` has carried this seam since 0047 as an
-- inert, FK-less UUID. M3 makes it a real reference: a `projects` table keyed
-- by a stable, gitignored project key the CLI mints in `.picloud/`, and a
-- foreign key from `groups.owner_project` to it.
--
-- Ownership is recorded at GROUP granularity; apps inherit their owning project
-- from their group (apps are never claimed directly). A NULL `owner_project`
-- means the node is UI/API-owned — no manifest fights it (§7.5). First apply
-- claims; a second project's apply to an owned node is refused unless
-- `--takeover` (group-admin gated). Ownership ⟂ RBAC: owning the manifest does
-- NOT grant write — the actor still needs the usual group capabilities (§7.4).
--
-- ON DELETE SET NULL: deleting a project row (not something the platform does
-- today) reverts its nodes to UI-owned rather than cascading away real groups.
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Stable, opaque key minted by `pic init` and persisted (gitignored) in the
-- repo's `.picloud/`. The CLI presents it on every tree apply; the server
-- maps it to this row (upsert-by-key), so the same repo keeps the same
-- project identity across clones/CI without committing any server id.
key TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Promote the inert `groups.owner_project` (0047:38) to a real FK now that the
-- referent exists. Existing rows are all NULL (no projects yet), so this adds
-- no validation burden.
ALTER TABLE groups
ADD CONSTRAINT groups_owner_project_fkey
FOREIGN KEY (owner_project) REFERENCES projects(id) ON DELETE SET NULL;
-- The ownership reconcile reads "which groups does project P own?" (to find
-- owned-but-undeclared nodes for structural prune) and "who owns group G?".
CREATE INDEX groups_owner_project_idx ON groups (owner_project);