feat(groups): schema + root-group backfill (migration 0047)
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) <noreply@anthropic.com>
This commit is contained in:
82
crates/manager-core/migrations/0047_groups.sql
Normal file
82
crates/manager-core/migrations/0047_groups.sql
Normal file
@@ -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);
|
||||
Reference in New Issue
Block a user