From 2b27012f56be04540c9f2418cd5b844f2064a0b7 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 24 Jun 2026 19:58:48 +0200 Subject: [PATCH] feat(groups): schema + root-group backfill (migration 0047) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 (blueprint §5): groups form a single-parent org tree ABOVE apps. This migration adds the tree + membership tables and gives every app a parent (§9 adoption): - groups: id, parent_id (self-FK ON DELETE RESTRICT), slug (instance- global UNIQUE, frozen at creation), name, description, structure_version (bumped on structural mutation), owner_project (inert §7 seam). - group_members: (group_id, user_id, role) with the SAME three role literals as app_members so AppRole round-trips and the authz rank table covers both. - apps.group_id: nullable add → backfill every app under a seeded 'root' group → promote to NOT NULL + FK RESTRICT. No group-owned resources yet (scripts/secrets stay app-owned — Phase 3). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../manager-core/migrations/0047_groups.sql | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 crates/manager-core/migrations/0047_groups.sql diff --git a/crates/manager-core/migrations/0047_groups.sql b/crates/manager-core/migrations/0047_groups.sql new file mode 100644 index 0000000..979fa69 --- /dev/null +++ b/crates/manager-core/migrations/0047_groups.sql @@ -0,0 +1,82 @@ +-- Phase 2: groups as a pure org / RBAC / UI container — see +-- docs/design/groups-and-project-tool.md §5, §9. +-- +-- Groups form a GitLab-like, single-parent tree ABOVE apps. Phase 2 adds +-- the tree, hierarchy-aware membership, and structural-mutation safety — +-- but NO group-owned resources yet (scripts/vars/secrets stay app-owned; +-- that is Phase 3). The only data-plane touch is apps gaining a parent +-- pointer. +-- +-- Adoption (§9): every existing app must have a parent from day one so +-- resolution always terminates. This migration seeds a single instance +-- root group and reparents every app under it, then promotes +-- apps.group_id to NOT NULL. + +CREATE TABLE groups ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- Single parent keeps inheritance acyclic and resolution + -- deterministic. NULL parent = a root node. RESTRICT (never CASCADE): + -- deleting a non-empty group is refused so descendant apps and their + -- isolated data can't be destroyed implicitly (§5.6). The ancestor-walk + -- cycle guard that keeps this acyclic lives in manager-core (a SQL + -- CHECK can't express it). + parent_id UUID REFERENCES groups(id) ON DELETE RESTRICT, + -- Instance-global identifier, frozen at creation. A rename/reparent + -- updates the display name/path but NEVER rewrites the slug, so the + -- deployment key stays stable and external references don't break. + -- Format validation lives in Rust handlers (same rule as app slugs). + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + -- Per-subtree structure version (§6): bumped on every structural + -- mutation of this node (reparent/rename/delete) so a future CLI/ + -- orchestrator can detect structural drift. NOT an authz input — + -- authorization is resolved live every request. + structure_version BIGINT NOT NULL DEFAULT 1, + -- §7 ownership seam — the project-root that manages this node. Inert + -- in Phase 2 (no projects table yet); nullable, no FK. + owner_project UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX groups_parent_id_idx ON groups (parent_id); + +-- Per-(user, group) explicit grant, mirroring app_members. Inherited +-- membership (GitLab-style) is resolved in code by walking ancestors: a +-- group_admin on any ancestor is implicitly app_admin on every app and +-- subgroup beneath it. Roles reuse the SAME three literals as app_members +-- so AppRole round-trips with zero mapping and the authz rank table +-- covers both tables. +CREATE TABLE group_members ( + group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('app_admin', 'editor', 'viewer')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (group_id, user_id) +); + +-- Hot path is the authz ancestor walk "what groups does this user have a +-- role on?" plus the per-group member list. +CREATE INDEX group_members_user_id_idx ON group_members (user_id); + +-- Add the parent pointer to apps (nullable for the backfill window). +ALTER TABLE apps ADD COLUMN group_id UUID; + +-- Seed a single instance root group and reparent every existing app under +-- it. App slugs are already instance-global, so no slug rewrite is needed +-- — the parent pointer is new metadata layered on top. +WITH root_group AS ( + INSERT INTO groups (slug, name, description) + VALUES ('root', 'Root', 'The instance root group — parent of all apps created before groups landed.') + RETURNING id +) +UPDATE apps SET group_id = (SELECT id FROM root_group); + +-- Every app now has a parent; promote to NOT NULL + FK. RESTRICT so a +-- group with apps can't be deleted out from under them (§5.6). +ALTER TABLE apps ALTER COLUMN group_id SET NOT NULL; +ALTER TABLE apps + ADD CONSTRAINT apps_group_id_fk FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT; + +CREATE INDEX apps_group_id_idx ON apps (group_id);