feat(team-admin): t-paliad-223 Slice A — Project Admin role + inheritable role-edit gate
#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.
This commit is contained in:
@@ -1,6 +1,3 @@
|
||||
# Project-specific mai configuration
|
||||
# Auto-generated by 'mai init' — run 'mai setup' to customize
|
||||
|
||||
provider: claude
|
||||
providers:
|
||||
claude:
|
||||
@@ -47,21 +44,13 @@ worker:
|
||||
name_scheme: role
|
||||
default_level: standard
|
||||
auto_discard: false
|
||||
max_workers: 5
|
||||
max_workers: 7
|
||||
persistent: true
|
||||
head:
|
||||
name: "paliadin"
|
||||
max_loops: 50
|
||||
infinity_mode: false
|
||||
max_idle_duration: 2h0m0s
|
||||
backoff_intervals:
|
||||
- 5
|
||||
- 10
|
||||
- 15
|
||||
- 30
|
||||
name: paliadin
|
||||
capacity:
|
||||
global:
|
||||
max_workers: 5
|
||||
max_workers: 7
|
||||
max_heads: 3
|
||||
per_worker:
|
||||
max_tasks_lifetime: 0
|
||||
|
||||
@@ -1415,10 +1415,15 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.team.role.local_counsel": "Local Counsel",
|
||||
"projects.team.role.expert": "Experte",
|
||||
"projects.team.role.observer": "Beobachter",
|
||||
"projects.team.responsibility.admin": "Admin",
|
||||
"projects.team.responsibility.admin.hint": "Kann Team und Rollen auf diesem Projekt und Unterprojekten verwalten",
|
||||
"projects.team.responsibility.lead": "Leitung",
|
||||
"projects.team.responsibility.member": "Mitglied",
|
||||
"projects.team.responsibility.observer": "Beobachter",
|
||||
"projects.team.responsibility.external": "Extern",
|
||||
"projects.team.error.last_admin": "Mindestens ein Admin muss auf diesem Projekt oder einem übergeordneten verbleiben.",
|
||||
"projects.team.error.forbidden": "Diese Aktion ist nicht erlaubt.",
|
||||
"projects.team.error.generic": "Aktion fehlgeschlagen.",
|
||||
"projects.team.profession.partner": "Partner",
|
||||
"projects.team.profession.of_counsel": "Of Counsel",
|
||||
"projects.team.profession.associate": "Associate",
|
||||
@@ -4094,10 +4099,15 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.team.role.local_counsel": "Local Counsel",
|
||||
"projects.team.role.expert": "Expert",
|
||||
"projects.team.role.observer": "Observer",
|
||||
"projects.team.responsibility.admin": "Admin",
|
||||
"projects.team.responsibility.admin.hint": "Can manage team and roles on this project and its sub-projects",
|
||||
"projects.team.responsibility.lead": "Lead",
|
||||
"projects.team.responsibility.member": "Member",
|
||||
"projects.team.responsibility.observer": "Observer",
|
||||
"projects.team.responsibility.external": "External",
|
||||
"projects.team.error.last_admin": "At least one admin must remain on this project or an ancestor.",
|
||||
"projects.team.error.forbidden": "This action is not permitted.",
|
||||
"projects.team.error.generic": "Action failed.",
|
||||
"projects.team.profession.partner": "Partner",
|
||||
"projects.team.profession.of_counsel": "Of Counsel",
|
||||
"projects.team.profession.associate": "Associate",
|
||||
|
||||
@@ -34,6 +34,12 @@ interface Project {
|
||||
grant_date?: string | null;
|
||||
court?: string | null;
|
||||
case_number?: string | null;
|
||||
// t-paliad-223: piggybacked onto the GET /api/projects/{id} payload so
|
||||
// the team panel can render an inline <select> for callers who can
|
||||
// change responsibilities (global_admin or effective_project_admin on
|
||||
// this project / ancestor). Optional for back-compat with cached
|
||||
// payloads.
|
||||
effective_admin?: boolean;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -2494,6 +2500,11 @@ function renderTeam() {
|
||||
}
|
||||
empty.style.display = "none";
|
||||
|
||||
// t-paliad-223: callers with effective_project_admin authority see an
|
||||
// inline <select> on the Rolle cell. Everyone else sees the read-only
|
||||
// <span>. The bool comes from the GET /api/projects/{id} payload.
|
||||
const canEditResponsibility = !!project?.effective_admin;
|
||||
|
||||
body.innerHTML = teamMembers
|
||||
.map((m) => {
|
||||
// t-paliad-148: profession is firm-wide (read-only badge) and
|
||||
@@ -2519,11 +2530,20 @@ function renderTeam() {
|
||||
: "";
|
||||
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
|
||||
const profCls = m.user_profession ? "projekt-team-profession" : "projekt-team-profession projekt-team-profession--none";
|
||||
|
||||
// Inline-select only on direct rows where the caller can edit.
|
||||
// Inherited rows stay read-only — the edit must happen at the
|
||||
// ancestor where the row is direct.
|
||||
const responsibilityCell =
|
||||
canEditResponsibility && !m.inherited
|
||||
? renderResponsibilitySelect(m.user_id, responsibility)
|
||||
: `<span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span>`;
|
||||
|
||||
return `<tr>
|
||||
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
|
||||
<span class="form-hint">· ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""}</span></td>
|
||||
<td><span class="${profCls}" title="${escAttr(professionTitle)}">${esc(professionLabel)}</span></td>
|
||||
<td><span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span></td>
|
||||
<td>${responsibilityCell}</td>
|
||||
<td>${source}</td>
|
||||
<td>${removeBtn}</td>
|
||||
</tr>`;
|
||||
@@ -2542,6 +2562,47 @@ function renderTeam() {
|
||||
if (resp.ok) {
|
||||
await loadTeam(project.id);
|
||||
renderTeam();
|
||||
} else {
|
||||
await showTeamErrorToast(resp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
body.querySelectorAll<HTMLSelectElement>(".team-responsibility-select").forEach((sel) => {
|
||||
// Capture the pre-change value on focus so we can roll back the
|
||||
// <select> if the PATCH fails (e.g. last-admin guard).
|
||||
sel.dataset.previous = sel.value;
|
||||
sel.addEventListener("focus", () => {
|
||||
sel.dataset.previous = sel.value;
|
||||
});
|
||||
sel.addEventListener("change", async () => {
|
||||
if (!project) return;
|
||||
const userID = sel.dataset.userId!;
|
||||
const previous = sel.dataset.previous || "member";
|
||||
const next = sel.value;
|
||||
if (next === previous) return;
|
||||
sel.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${project.id}/team/${encodeURIComponent(userID)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ responsibility: next }),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
sel.value = previous;
|
||||
await showTeamErrorToast(resp);
|
||||
return;
|
||||
}
|
||||
sel.dataset.previous = next;
|
||||
// Refresh the team list so derived/descendant sections re-render
|
||||
// with the new authority shape.
|
||||
await loadTeam(project.id);
|
||||
renderTeam();
|
||||
} finally {
|
||||
sel.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2725,7 +2786,54 @@ function wireExportButton(projectID: string): void {
|
||||
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
|
||||
if (!me) return false;
|
||||
if (m.user_id === me.id) return true;
|
||||
return me.global_role === "global_admin";
|
||||
if (me.global_role === "global_admin") return true;
|
||||
// t-paliad-223: effective_project_admin (from the project payload)
|
||||
// also covers remove. RLS makes the request fail anyway if the bit is
|
||||
// stale; this just hides the affordance.
|
||||
return !!project?.effective_admin;
|
||||
}
|
||||
|
||||
// t-paliad-223: build the inline <select> for the responsibility cell.
|
||||
// Options mirror the IsValidResponsibility set in approval_levels.go.
|
||||
function renderResponsibilitySelect(userID: string, current: string): string {
|
||||
const options = ["admin", "lead", "member", "observer", "external"]
|
||||
.map((v) => {
|
||||
const label = tDyn(`projects.team.responsibility.${v}`) || v;
|
||||
const sel = v === current ? " selected" : "";
|
||||
return `<option value="${esc(v)}"${sel}>${esc(label)}</option>`;
|
||||
})
|
||||
.join("");
|
||||
return `<select class="team-responsibility-select projekt-team-responsibility" data-user-id="${esc(userID)}">${options}</select>`;
|
||||
}
|
||||
|
||||
// t-paliad-223: surface backend error responses (last-admin guard / 403
|
||||
// from RLS / etc.) as a transient toast. We have no global toast service
|
||||
// yet on this page, so write into #team-msg.
|
||||
async function showTeamErrorToast(resp: Response): Promise<void> {
|
||||
const msg = document.getElementById("team-msg") as HTMLParagraphElement | null;
|
||||
if (!msg) return;
|
||||
let text = "";
|
||||
try {
|
||||
const data = (await resp.json()) as { error?: string };
|
||||
text = data?.error || "";
|
||||
} catch {
|
||||
text = "";
|
||||
}
|
||||
if (!text) {
|
||||
if (resp.status === 409) text = t("projects.team.error.last_admin") || "Mindestens ein Admin muss auf diesem Projekt oder einem übergeordneten verbleiben.";
|
||||
else if (resp.status === 403 || resp.status === 404) text = t("projects.team.error.forbidden") || "Diese Aktion ist nicht erlaubt.";
|
||||
else text = t("projects.team.error.generic") || "Aktion fehlgeschlagen.";
|
||||
}
|
||||
msg.textContent = text;
|
||||
msg.classList.add("form-msg--error");
|
||||
// Auto-clear after 5s so a stale error doesn't linger past the next
|
||||
// successful action.
|
||||
window.setTimeout(() => {
|
||||
if (msg.textContent === text) {
|
||||
msg.textContent = "";
|
||||
msg.classList.remove("form-msg--error");
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function initTeamForm(id: string) {
|
||||
|
||||
@@ -2233,6 +2233,9 @@ export type I18nKey =
|
||||
| "projects.team.derived.from"
|
||||
| "projects.team.derived.visibility"
|
||||
| "projects.team.direct"
|
||||
| "projects.team.error.forbidden"
|
||||
| "projects.team.error.generic"
|
||||
| "projects.team.error.last_admin"
|
||||
| "projects.team.inherited.hint"
|
||||
| "projects.team.profession.associate"
|
||||
| "projects.team.profession.hint"
|
||||
@@ -2243,6 +2246,8 @@ export type I18nKey =
|
||||
| "projects.team.profession.paralegal"
|
||||
| "projects.team.profession.partner"
|
||||
| "projects.team.profession.senior_pa"
|
||||
| "projects.team.responsibility.admin"
|
||||
| "projects.team.responsibility.admin.hint"
|
||||
| "projects.team.responsibility.external"
|
||||
| "projects.team.responsibility.lead"
|
||||
| "projects.team.responsibility.member"
|
||||
|
||||
@@ -262,6 +262,7 @@ export function renderProjectsDetail(): string {
|
||||
<div className="form-field">
|
||||
<label htmlFor="team-responsibility" data-i18n="projects.detail.team.form.responsibility">Rolle im Projekt</label>
|
||||
<select id="team-responsibility">
|
||||
<option value="admin" data-i18n="projects.team.responsibility.admin">Admin</option>
|
||||
<option value="lead" data-i18n="projects.team.responsibility.lead">Lead</option>
|
||||
<option value="member" selected data-i18n="projects.team.responsibility.member">Mitglied</option>
|
||||
<option value="observer" data-i18n="projects.team.responsibility.observer">Beobachter</option>
|
||||
|
||||
65
internal/db/migrations/111_project_admin_and_select.down.sql
Normal file
65
internal/db/migrations/111_project_admin_and_select.down.sql
Normal file
@@ -0,0 +1,65 @@
|
||||
-- Reverse of 111_project_admin_and_select.up.sql.
|
||||
--
|
||||
-- Drops effective_project_admin, restores the original RLS policies,
|
||||
-- and shrinks the responsibility CHECK back to four values. Any rows
|
||||
-- still carrying responsibility='admin' would violate the restored
|
||||
-- CHECK; the down-migration backfills them to 'lead' (the closest
|
||||
-- existing role) before re-adding the constraint.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. Backfill any responsibility='admin' rows to 'lead'.
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE paliad.project_teams
|
||||
SET responsibility = 'lead'
|
||||
WHERE responsibility = 'admin';
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Restore the original CHECK (lead/member/observer/external).
|
||||
-- ============================================================================
|
||||
|
||||
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 ('lead', 'member', 'observer', 'external'));
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. Restore the pre-110 RLS policies.
|
||||
-- ============================================================================
|
||||
|
||||
DROP POLICY IF EXISTS project_teams_update ON paliad.project_teams;
|
||||
CREATE POLICY project_teams_update
|
||||
ON paliad.project_teams FOR UPDATE
|
||||
USING (paliad.can_see_project(project_id))
|
||||
WITH CHECK (paliad.can_see_project(project_id));
|
||||
|
||||
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.can_see_project(project_id)
|
||||
);
|
||||
|
||||
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'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. Drop the predicate function.
|
||||
-- ============================================================================
|
||||
|
||||
DROP FUNCTION IF EXISTS paliad.effective_project_admin(uuid, uuid);
|
||||
152
internal/db/migrations/111_project_admin_and_select.up.sql
Normal file
152
internal/db/migrations/111_project_admin_and_select.up.sql
Normal file
@@ -0,0 +1,152 @@
|
||||
-- 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)
|
||||
)
|
||||
);
|
||||
@@ -325,6 +325,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// Team membership endpoints for Project detail "Team" tab.
|
||||
protected.HandleFunc("GET /api/projects/{id}/team", handleListProjectTeam)
|
||||
protected.HandleFunc("POST /api/projects/{id}/team", handleAddProjectTeamMember)
|
||||
protected.HandleFunc("PATCH /api/projects/{id}/team/{user_id}", handleChangeProjectTeamMemberResponsibility)
|
||||
protected.HandleFunc("DELETE /api/projects/{id}/team/{user_id}", handleRemoveProjectTeamMember)
|
||||
// t-paliad-139 — sub-team aggregation surfaces for the Team tab.
|
||||
protected.HandleFunc("GET /api/projects/{id}/team/derived", handleListDerivedTeam)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/auth"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
@@ -104,6 +105,8 @@ func writeServiceError(w http.ResponseWriter, err error) {
|
||||
})
|
||||
case errors.Is(err, services.ErrEventTypeSlugTaken):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrLastProjectAdmin):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
||||
default:
|
||||
log.Printf("ERROR service: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
||||
@@ -319,7 +322,24 @@ func handleGetProject(w http.ResponseWriter, r *http.Request) {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
// t-paliad-223: piggyback effective_project_admin onto the project
|
||||
// payload so the frontend can drive the inline role-edit affordance
|
||||
// without a second round-trip. JSON-merge via a small wrapper that
|
||||
// embeds the existing Project shape — every existing caller keeps
|
||||
// reading the same fields and gains effective_admin as additive.
|
||||
effAdmin, err := dbSvc.team.IsEffectiveProjectAdmin(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
type projectWithPermissions struct {
|
||||
*models.Project
|
||||
EffectiveAdmin bool `json:"effective_admin"`
|
||||
}
|
||||
writeJSON(w, http.StatusOK, projectWithPermissions{
|
||||
Project: p,
|
||||
EffectiveAdmin: effAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/projects/{id}/children — direct children.
|
||||
|
||||
@@ -93,6 +93,53 @@ func handleListMembershipsIndex(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// PATCH /api/projects/{id}/team/{user_id} — change a direct member's
|
||||
// responsibility. Body: {"responsibility": "<admin|lead|member|observer|external>"}.
|
||||
//
|
||||
// Authorisation is RLS-enforced (project_teams_update gated on
|
||||
// effective_project_admin in mig 111). Non-admins get a pq permission
|
||||
// error from the UPDATE; we surface that as 404 to avoid leaking that
|
||||
// the row exists. The last-admin guard runs inside the service tx and
|
||||
// returns ErrLastProjectAdmin (mapped to 409 by writeServiceError).
|
||||
func handleChangeProjectTeamMemberResponsibility(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
userID, err := uuid.Parse(r.PathValue("user_id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Responsibility string `json:"responsibility"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
m, err := dbSvc.team.ChangeResponsibility(r.Context(), uid, projectID, userID, body.Responsibility)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{
|
||||
"error": "no direct membership found",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, m)
|
||||
}
|
||||
|
||||
// DELETE /api/projects/{id}/team/{user_id} — remove a direct member.
|
||||
// Inherited memberships can't be removed at the child level.
|
||||
func handleRemoveProjectTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -25,7 +25,14 @@ const (
|
||||
|
||||
// Project-level responsibility values on paliad.project_teams.responsibility.
|
||||
// Open the ladder gate (lead/member) or close it (observer/external).
|
||||
//
|
||||
// ResponsibilityAdmin (t-paliad-223) is orthogonal to the approval gate —
|
||||
// it grants role-edit authority on the project + descendants via the
|
||||
// paliad.effective_project_admin predicate, but does NOT by itself open
|
||||
// the 4-Augen approval gate. An Admin who has no profession set is still
|
||||
// not an approver. Use responsibilityOpensGate to test the approval axis.
|
||||
const (
|
||||
ResponsibilityAdmin = "admin"
|
||||
ResponsibilityLead = "lead"
|
||||
ResponsibilityMember = "member"
|
||||
ResponsibilityObserver = "observer"
|
||||
@@ -143,7 +150,7 @@ func IsValidProfession(p string) bool {
|
||||
// recognised project-responsibility enum values. Used by TeamService.
|
||||
func IsValidResponsibility(r string) bool {
|
||||
switch r {
|
||||
case ResponsibilityLead, ResponsibilityMember,
|
||||
case ResponsibilityAdmin, ResponsibilityLead, ResponsibilityMember,
|
||||
ResponsibilityObserver, ResponsibilityExternal:
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -190,7 +190,8 @@ func TestIsValidProfession(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsValidResponsibility(t *testing.T) {
|
||||
for _, r := range []string{"lead", "member", "observer", "external"} {
|
||||
// t-paliad-223 added 'admin'; the four legacy values stay valid.
|
||||
for _, r := range []string{"admin", "lead", "member", "observer", "external"} {
|
||||
t.Run(r, func(t *testing.T) {
|
||||
if !IsValidResponsibility(r) {
|
||||
t.Errorf("IsValidResponsibility(%q) must be true", r)
|
||||
@@ -206,6 +207,30 @@ func TestIsValidResponsibility(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-223: admin maps to legacy 'lead' for the deprecated shadow
|
||||
// column. The other mappings are unchanged from t-paliad-148. Pin them
|
||||
// so a future refactor doesn't silently flip them.
|
||||
func TestLegacyRoleFromResponsibility(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{ResponsibilityAdmin, "lead"},
|
||||
{ResponsibilityLead, "lead"},
|
||||
{ResponsibilityObserver, "observer"},
|
||||
{ResponsibilityExternal, "local_counsel"},
|
||||
{ResponsibilityMember, "associate"},
|
||||
{"", "associate"}, // unknown / empty falls through to associate
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.in, func(t *testing.T) {
|
||||
got := legacyRoleFromResponsibility(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("legacyRoleFromResponsibility(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalEventType(t *testing.T) {
|
||||
cases := []struct {
|
||||
entity, step, want string
|
||||
|
||||
@@ -44,6 +44,12 @@ var (
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
// ErrInvalidInput signals a bad request (empty required field etc.).
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
// ErrLastProjectAdmin guards demoting / removing the last remaining
|
||||
// effective_project_admin from a project + its ancestor chain. t-paliad-223
|
||||
// invariant: every project should keep at least one admin somewhere in
|
||||
// its ancestor chain so a non-global-admin can still manage the team.
|
||||
// Handlers map to 409 Conflict.
|
||||
ErrLastProjectAdmin = errors.New("cannot remove last project admin from project + ancestors")
|
||||
// ErrInvalidProceedingTypeCategory signals that the caller supplied
|
||||
// a proceeding_type_id pointing at a non-fristenrechner-category row.
|
||||
// Phase 3 Slice 5 soft-merge (t-paliad-186, design §3.F): only
|
||||
|
||||
@@ -13,6 +13,7 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -80,9 +81,13 @@ func (s *TeamService) AddMember(ctx context.Context, callerID, projectID, userID
|
||||
// column. external → 'local_counsel' is intentionally narrower than the
|
||||
// new enum (loses the expert distinction); we accept that for the short
|
||||
// transition window.
|
||||
//
|
||||
// ResponsibilityAdmin (t-paliad-223) maps to legacy 'lead' — the closest
|
||||
// legacy match. The legacy column is dead either way; the mapping is
|
||||
// purely cosmetic until the column is dropped.
|
||||
func legacyRoleFromResponsibility(r string) string {
|
||||
switch r {
|
||||
case ResponsibilityLead:
|
||||
case ResponsibilityAdmin, ResponsibilityLead:
|
||||
return "lead"
|
||||
case ResponsibilityObserver:
|
||||
return "observer"
|
||||
@@ -99,11 +104,43 @@ func legacyRoleFromResponsibility(r string) string {
|
||||
// RemoveMember deletes a direct team membership. Inherited memberships (from
|
||||
// ancestors) can't be removed at the child level — the caller must remove
|
||||
// the ancestor row to break the inheritance.
|
||||
//
|
||||
// t-paliad-223 last-admin guard: if the row being removed carries
|
||||
// responsibility='admin', refuse when it would leave the project + its
|
||||
// ancestor chain with zero admins. Wrapped in a tx so the count + delete
|
||||
// are atomic; ErrLastProjectAdmin bubbles up unchanged for the handler
|
||||
// to map to 409.
|
||||
func (s *TeamService) RemoveMember(ctx context.Context, callerID, projectID, userID uuid.UUID) error {
|
||||
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Look up the row first so we know whether to run the guard.
|
||||
var existing models.ProjectTeamMember
|
||||
if err := tx.GetContext(ctx, &existing,
|
||||
`SELECT id, project_id, user_id, role, responsibility, inherited, added_by, created_at
|
||||
FROM paliad.project_teams
|
||||
WHERE project_id = $1 AND user_id = $2 AND inherited = false`,
|
||||
projectID, userID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return fmt.Errorf("lookup team member: %w", err)
|
||||
}
|
||||
|
||||
if existing.Responsibility == ResponsibilityAdmin {
|
||||
if err := assertProjectKeepsAdmin(ctx, tx, projectID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.project_teams
|
||||
WHERE project_id = $1 AND user_id = $2 AND inherited = false`,
|
||||
projectID, userID)
|
||||
@@ -113,6 +150,104 @@ func (s *TeamService) RemoveMember(ctx context.Context, callerID, projectID, use
|
||||
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit remove team member: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangeResponsibility updates a direct team member's responsibility.
|
||||
// RLS enforces the authorisation (only effective_project_admin can pass
|
||||
// the project_teams_update WITH CHECK); this method handles validation
|
||||
// + the last-admin guard when the change is AWAY from admin.
|
||||
//
|
||||
// Inherited rows can't be edited here — the caller must change the
|
||||
// ancestor row. Trying to update an inherited row returns sql.ErrNoRows.
|
||||
func (s *TeamService) ChangeResponsibility(ctx context.Context, callerID, projectID, userID uuid.UUID, newResponsibility string) (*models.ProjectTeamMember, error) {
|
||||
if !IsValidResponsibility(newResponsibility) {
|
||||
return nil, fmt.Errorf("%w: invalid responsibility %q", ErrInvalidInput, newResponsibility)
|
||||
}
|
||||
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Read current row so we know whether the guard needs to fire and so
|
||||
// we can short-circuit no-op writes.
|
||||
var current models.ProjectTeamMember
|
||||
if err := tx.GetContext(ctx, ¤t,
|
||||
`SELECT id, project_id, user_id, role, responsibility, inherited, added_by, created_at
|
||||
FROM paliad.project_teams
|
||||
WHERE project_id = $1 AND user_id = $2 AND inherited = false`,
|
||||
projectID, userID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
return nil, fmt.Errorf("lookup team member: %w", err)
|
||||
}
|
||||
if current.Responsibility == newResponsibility {
|
||||
// No-op; commit the empty tx so caller still gets a typed result.
|
||||
_ = tx.Commit()
|
||||
return ¤t, nil
|
||||
}
|
||||
|
||||
if current.Responsibility == ResponsibilityAdmin && newResponsibility != ResponsibilityAdmin {
|
||||
if err := assertProjectKeepsAdmin(ctx, tx, projectID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
legacyRole := legacyRoleFromResponsibility(newResponsibility)
|
||||
|
||||
var updated models.ProjectTeamMember
|
||||
if err := tx.GetContext(ctx, &updated,
|
||||
`UPDATE paliad.project_teams
|
||||
SET responsibility = $3, role = $4
|
||||
WHERE project_id = $1 AND user_id = $2 AND inherited = false
|
||||
RETURNING id, project_id, user_id, role, responsibility, inherited, added_by, created_at`,
|
||||
projectID, userID, newResponsibility, legacyRole); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
return nil, fmt.Errorf("change responsibility: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit change responsibility: %w", err)
|
||||
}
|
||||
return &updated, nil
|
||||
}
|
||||
|
||||
// assertProjectKeepsAdmin returns ErrLastProjectAdmin iff removing the
|
||||
// (projectID, excludeUserID) admin row would leave the project's ancestor
|
||||
// chain (project + every ancestor up to the root) with zero admins.
|
||||
//
|
||||
// Counts admin rows on every row in the ancestor chain, excluding the row
|
||||
// being changed. Uses the same ltree path-walk as paliad.can_see_project.
|
||||
//
|
||||
// This is a service-layer guard; we don't put it in an RLS WITH CHECK
|
||||
// because the count happens post-mutation in a typical WITH CHECK, and
|
||||
// the natural place to express it is here where we already hold the tx.
|
||||
func assertProjectKeepsAdmin(ctx context.Context, tx *sqlx.Tx, projectID, excludeUserID uuid.UUID) error {
|
||||
var remaining int
|
||||
if err := tx.GetContext(ctx, &remaining, `
|
||||
SELECT count(*)
|
||||
FROM paliad.projects p
|
||||
JOIN paliad.project_teams pt
|
||||
ON pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
|
||||
AND pt.responsibility = 'admin'
|
||||
WHERE p.id = $1
|
||||
AND NOT (pt.project_id = $1 AND pt.user_id = $2)
|
||||
`, projectID, excludeUserID); err != nil {
|
||||
return fmt.Errorf("count remaining admins: %w", err)
|
||||
}
|
||||
if remaining == 0 {
|
||||
return ErrLastProjectAdmin
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -259,6 +394,27 @@ func (s *TeamService) ListMembershipsIndex(ctx context.Context, callerID uuid.UU
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IsEffectiveProjectAdmin reports whether the user is global_admin OR has
|
||||
// responsibility='admin' on the project itself or any ancestor in the
|
||||
// materialised ltree path.
|
||||
//
|
||||
// Delegates to paliad.effective_project_admin SQL (t-paliad-223 mig 111).
|
||||
// The function is STABLE SECURITY DEFINER so it sees rows regardless of
|
||||
// the caller's RLS context — the boolean answer doesn't leak data.
|
||||
//
|
||||
// Used by the project-detail handler to drive the inline-select affordance
|
||||
// in the team panel: only effective_project_admins see the editable
|
||||
// <select>; everyone else sees a read-only <span>.
|
||||
func (s *TeamService) IsEffectiveProjectAdmin(ctx context.Context, userID, projectID uuid.UUID) (bool, error) {
|
||||
var b bool
|
||||
if err := s.db.GetContext(ctx, &b,
|
||||
`SELECT paliad.effective_project_admin($1, $2)`,
|
||||
userID, projectID); err != nil {
|
||||
return false, fmt.Errorf("effective_project_admin: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// pathToIDStrings splits a materialised path into its UUID labels as strings,
|
||||
|
||||
Reference in New Issue
Block a user