-- t-paliad-223 Slice A: Project Admin role on project_teams.responsibility + -- inheritable role-edit gate. -- -- Design: docs/design-team-admin-rework-2026-05-20.md (gauss, m-locked -- 2026-05-20 via head's "all R approved"). -- -- Adds a fifth 'admin' value to the project_teams.responsibility enum -- (orthogonal to the profession-driven approval ladder — admin does NOT -- open the 4-Augen gate by itself). Introduces paliad.effective_project_admin -- which mirrors paliad.can_see_project's shape and walks the ltree path -- to compute inheritance. Replaces the three write-side RLS policies on -- paliad.project_teams so role edits are gated on the new predicate -- instead of "anyone with visibility". -- -- Day-1 deploy = no behaviour change for callers who never use the admin -- value: existing lead/member/observer/external rows keep their meaning, -- and the global_admin shortcut + self-join INSERT / self-DELETE remain -- intact. -- -- Sections: -- 1. ALTER project_teams.responsibility CHECK to include 'admin'. -- 2. CREATE paliad.effective_project_admin(uuid, uuid). -- 3. Replace project_teams_update policy: gated on effective_project_admin. -- 4. Replace project_teams_insert policy: self-join OR effective_project_admin. -- 5. Replace project_teams_delete policy: self / global_admin / effective_project_admin. -- ============================================================================ -- 1. Extend responsibility CHECK to include 'admin'. -- -- 'admin' inherits down the project tree (see effective_project_admin in §2). -- A user marked admin on a Mandant-level project is implicitly admin on -- every Litigation / Patent / Case descendant — same shape as how 'lead' -- already inherits. -- ============================================================================ ALTER TABLE paliad.project_teams DROP CONSTRAINT IF EXISTS project_teams_responsibility_check; ALTER TABLE paliad.project_teams ADD CONSTRAINT project_teams_responsibility_check CHECK (responsibility IN ('admin', 'lead', 'member', 'observer', 'external')); COMMENT ON COLUMN paliad.project_teams.responsibility IS 'Per-project responsibility. admin = can manage team + roles on this ' 'project and descendants (inherited via paliad.effective_project_admin). ' 'lead/member open the 4-Augen approval gate; observer/external close it. ' 'admin is orthogonal to the approval gate — it does NOT open it by itself.'; -- ============================================================================ -- 2. paliad.effective_project_admin(_user_id, _project_id) -- -- Mirrors paliad.can_see_project: STABLE SECURITY DEFINER, ltree path-walk -- against projects.path. Two branches: -- (a) global_admin short-circuit — firm-wide admins are always admin. -- (b) ancestor-or-self project_teams row with responsibility='admin'. -- -- Used by the project_teams_update / _insert / _delete policies below -- and by ProjectService for the effective_admin payload field. -- -- The ltree-array cast is the same pattern can_see_project uses; the -- existing GiST index on projects.path is the load-bearing index. No new -- index needed. -- ============================================================================ CREATE OR REPLACE FUNCTION paliad.effective_project_admin(_user_id uuid, _project_id uuid) RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'paliad', 'public' AS $$ SELECT EXISTS ( SELECT 1 FROM paliad.users u WHERE u.id = _user_id AND u.global_role = 'global_admin' ) OR EXISTS ( SELECT 1 FROM paliad.projects target JOIN paliad.project_teams pt ON pt.user_id = _user_id AND pt.responsibility = 'admin' AND pt.project_id = ANY(string_to_array(target.path, '.')::uuid[]) WHERE target.id = _project_id ); $$; COMMENT ON FUNCTION paliad.effective_project_admin(uuid, uuid) IS 'True iff the user is global_admin OR has responsibility=admin on the ' 'project itself or any ancestor in the materialised ltree path. ' 'Drives the role-edit gate on project_teams (UPDATE/INSERT/DELETE RLS).'; -- ============================================================================ -- 3. project_teams_update policy: gated on effective_project_admin. -- -- Before: USING + CHECK = can_see_project (anyone with visibility could -- edit anyone's responsibility — the load-bearing gap that t-paliad-223 -- closes). -- After: USING + CHECK = effective_project_admin (only project-admins -- and global_admins can change roles). -- ============================================================================ DROP POLICY IF EXISTS project_teams_update ON paliad.project_teams; CREATE POLICY project_teams_update ON paliad.project_teams FOR UPDATE USING (paliad.effective_project_admin(auth.uid(), project_id)) WITH CHECK (paliad.effective_project_admin(auth.uid(), project_id)); -- ============================================================================ -- 4. project_teams_insert policy: self-join OR effective_project_admin. -- -- The self-join branch (user_id = auth.uid()) preserves the legacy -- creator-as-lead INSERT in ProjectService.Create: the project creator -- auto-joins their own project with responsibility='lead' before any -- admin exists. Without this branch, the first-ever team row on a new -- project would fail because no admin has been granted yet. -- -- For all other inserts (adding other users), the caller must be an -- effective_project_admin on the target project. -- ============================================================================ DROP POLICY IF EXISTS project_teams_insert ON paliad.project_teams; CREATE POLICY project_teams_insert ON paliad.project_teams FOR INSERT WITH CHECK ( user_id = auth.uid() OR paliad.effective_project_admin(auth.uid(), project_id) ); -- ============================================================================ -- 5. project_teams_delete policy: self / global_admin / effective_project_admin. -- -- Additive: self-remove + global_admin still work; project-admin can now -- also remove members. -- ============================================================================ DROP POLICY IF EXISTS project_teams_delete ON paliad.project_teams; CREATE POLICY project_teams_delete ON paliad.project_teams FOR DELETE USING ( paliad.can_see_project(project_id) AND ( user_id = auth.uid() OR EXISTS ( SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin' ) OR paliad.effective_project_admin(auth.uid(), project_id) ) );