-- 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);