#48 — adds 'admin' as fifth project_teams.responsibility value, plumbs an inheritable role-edit gate via the materialised ltree path. - migration 110: ALTER responsibility CHECK, CREATE paliad.effective_project_admin(uuid,uuid) STABLE SECURITY DEFINER (mirrors can_see_project shape), REPLACE project_teams_update / _insert / _delete RLS policies. Idempotent + down-mig provided. Dry-run BEGIN..ROLLBACK clean on live supabase. - services/approval_levels.go: ResponsibilityAdmin const + IsValidResponsibility extension. responsibilityOpensGate UNCHANGED — admin is orthogonal to the 4-Augen approval gate. - services/team_service.go: ChangeResponsibility() with last-admin guard inside tx (counts admins on project + ancestor chain, excludes the row being changed). RemoveMember() also runs the guard when removing an admin row. New IsEffectiveProjectAdmin() driving the frontend affordance. legacyRoleFromResponsibility: admin → 'lead' (deprecated shadow column). - services/project_service.go: ErrLastProjectAdmin sentinel mapped to 409 in writeServiceError. - handlers/teams.go: new PATCH /api/projects/{id}/team/{user_id}. RLS-enforced; non-admins get 404 to avoid existence leakage. - handlers/projects.go: GET /api/projects/{id} now wraps the payload with effective_admin bool so the frontend drives the inline-select affordance without a second round-trip. - frontend/src/projects-detail.tsx + client/projects-detail.ts: admin appears as 5th option in 'Mitglied hinzufügen' dropdown. Team-list Rolle cell switches to an inline <select> for callers with effective_admin (read-only span otherwise). Optimistic PATCH with rollback on error (last-admin guard / 403 from RLS / etc.) surfaced as transient toast in #team-msg. - i18n: +6 keys (admin label + admin.hint + 3 error toasts × 2 langs). - tests: TestIsValidResponsibility now covers admin; new TestLegacyRoleFromResponsibility pins the mapping table. go build && go test -short ./internal/... && bun run build all clean.
153 lines
6.6 KiB
PL/PgSQL
153 lines
6.6 KiB
PL/PgSQL
-- 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)
|
|
)
|
|
);
|