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:
mAi
2026-05-20 14:40:56 +02:00
parent a5ae2148fa
commit 2ed0ef3177
14 changed files with 613 additions and 21 deletions

View File

@@ -1,6 +1,3 @@
# Project-specific mai configuration
# Auto-generated by 'mai init' — run 'mai setup' to customize
provider: claude provider: claude
providers: providers:
claude: claude:
@@ -47,21 +44,13 @@ worker:
name_scheme: role name_scheme: role
default_level: standard default_level: standard
auto_discard: false auto_discard: false
max_workers: 5 max_workers: 7
persistent: true persistent: true
head: head:
name: "paliadin" name: paliadin
max_loops: 50
infinity_mode: false
max_idle_duration: 2h0m0s
backoff_intervals:
- 5
- 10
- 15
- 30
capacity: capacity:
global: global:
max_workers: 5 max_workers: 7
max_heads: 3 max_heads: 3
per_worker: per_worker:
max_tasks_lifetime: 0 max_tasks_lifetime: 0

View File

@@ -1415,10 +1415,15 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.team.role.local_counsel": "Local Counsel", "projects.team.role.local_counsel": "Local Counsel",
"projects.team.role.expert": "Experte", "projects.team.role.expert": "Experte",
"projects.team.role.observer": "Beobachter", "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.lead": "Leitung",
"projects.team.responsibility.member": "Mitglied", "projects.team.responsibility.member": "Mitglied",
"projects.team.responsibility.observer": "Beobachter", "projects.team.responsibility.observer": "Beobachter",
"projects.team.responsibility.external": "Extern", "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.partner": "Partner",
"projects.team.profession.of_counsel": "Of Counsel", "projects.team.profession.of_counsel": "Of Counsel",
"projects.team.profession.associate": "Associate", "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.local_counsel": "Local Counsel",
"projects.team.role.expert": "Expert", "projects.team.role.expert": "Expert",
"projects.team.role.observer": "Observer", "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.lead": "Lead",
"projects.team.responsibility.member": "Member", "projects.team.responsibility.member": "Member",
"projects.team.responsibility.observer": "Observer", "projects.team.responsibility.observer": "Observer",
"projects.team.responsibility.external": "External", "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.partner": "Partner",
"projects.team.profession.of_counsel": "Of Counsel", "projects.team.profession.of_counsel": "Of Counsel",
"projects.team.profession.associate": "Associate", "projects.team.profession.associate": "Associate",

View File

@@ -34,6 +34,12 @@ interface Project {
grant_date?: string | null; grant_date?: string | null;
court?: string | null; court?: string | null;
case_number?: 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; updated_at: string;
created_at: string; created_at: string;
} }
@@ -2494,6 +2500,11 @@ function renderTeam() {
} }
empty.style.display = "none"; 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 body.innerHTML = teamMembers
.map((m) => { .map((m) => {
// t-paliad-148: profession is firm-wide (read-only badge) and // 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 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"; 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> return `<tr>
<td><strong>${esc(m.user_display_name || m.user_email)}</strong> <td><strong>${esc(m.user_display_name || m.user_email)}</strong>
<span class="form-hint">&middot; ${esc(m.user_email)}${officeLabel ? " &middot; " + esc(officeLabel) : ""}</span></td> <span class="form-hint">&middot; ${esc(m.user_email)}${officeLabel ? " &middot; " + esc(officeLabel) : ""}</span></td>
<td><span class="${profCls}" title="${escAttr(professionTitle)}">${esc(professionLabel)}</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>${source}</td>
<td>${removeBtn}</td> <td>${removeBtn}</td>
</tr>`; </tr>`;
@@ -2542,6 +2562,47 @@ function renderTeam() {
if (resp.ok) { if (resp.ok) {
await loadTeam(project.id); await loadTeam(project.id);
renderTeam(); 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 { function canRemoveTeamMember(m: ProjectTeamMember): boolean {
if (!me) return false; if (!me) return false;
if (m.user_id === me.id) return true; 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) { function initTeamForm(id: string) {

View File

@@ -2233,6 +2233,9 @@ export type I18nKey =
| "projects.team.derived.from" | "projects.team.derived.from"
| "projects.team.derived.visibility" | "projects.team.derived.visibility"
| "projects.team.direct" | "projects.team.direct"
| "projects.team.error.forbidden"
| "projects.team.error.generic"
| "projects.team.error.last_admin"
| "projects.team.inherited.hint" | "projects.team.inherited.hint"
| "projects.team.profession.associate" | "projects.team.profession.associate"
| "projects.team.profession.hint" | "projects.team.profession.hint"
@@ -2243,6 +2246,8 @@ export type I18nKey =
| "projects.team.profession.paralegal" | "projects.team.profession.paralegal"
| "projects.team.profession.partner" | "projects.team.profession.partner"
| "projects.team.profession.senior_pa" | "projects.team.profession.senior_pa"
| "projects.team.responsibility.admin"
| "projects.team.responsibility.admin.hint"
| "projects.team.responsibility.external" | "projects.team.responsibility.external"
| "projects.team.responsibility.lead" | "projects.team.responsibility.lead"
| "projects.team.responsibility.member" | "projects.team.responsibility.member"

View File

@@ -262,6 +262,7 @@ export function renderProjectsDetail(): string {
<div className="form-field"> <div className="form-field">
<label htmlFor="team-responsibility" data-i18n="projects.detail.team.form.responsibility">Rolle im Projekt</label> <label htmlFor="team-responsibility" data-i18n="projects.detail.team.form.responsibility">Rolle im Projekt</label>
<select id="team-responsibility"> <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="lead" data-i18n="projects.team.responsibility.lead">Lead</option>
<option value="member" selected data-i18n="projects.team.responsibility.member">Mitglied</option> <option value="member" selected data-i18n="projects.team.responsibility.member">Mitglied</option>
<option value="observer" data-i18n="projects.team.responsibility.observer">Beobachter</option> <option value="observer" data-i18n="projects.team.responsibility.observer">Beobachter</option>

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

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

View File

@@ -325,6 +325,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// Team membership endpoints for Project detail "Team" tab. // Team membership endpoints for Project detail "Team" tab.
protected.HandleFunc("GET /api/projects/{id}/team", handleListProjectTeam) protected.HandleFunc("GET /api/projects/{id}/team", handleListProjectTeam)
protected.HandleFunc("POST /api/projects/{id}/team", handleAddProjectTeamMember) 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) protected.HandleFunc("DELETE /api/projects/{id}/team/{user_id}", handleRemoveProjectTeamMember)
// t-paliad-139 — sub-team aggregation surfaces for the Team tab. // t-paliad-139 — sub-team aggregation surfaces for the Team tab.
protected.HandleFunc("GET /api/projects/{id}/team/derived", handleListDerivedTeam) protected.HandleFunc("GET /api/projects/{id}/team/derived", handleListDerivedTeam)

View File

@@ -11,6 +11,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/auth" "mgit.msbls.de/m/paliad/internal/auth"
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/internal/services" "mgit.msbls.de/m/paliad/internal/services"
) )
@@ -104,6 +105,8 @@ func writeServiceError(w http.ResponseWriter, err error) {
}) })
case errors.Is(err, services.ErrEventTypeSlugTaken): case errors.Is(err, services.ErrEventTypeSlugTaken):
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) 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: default:
log.Printf("ERROR service: %v", err) log.Printf("ERROR service: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) 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) writeServiceError(w, err)
return 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. // GET /api/projects/{id}/children — direct children.

View File

@@ -93,6 +93,53 @@ func handleListMembershipsIndex(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, rows) 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. // DELETE /api/projects/{id}/team/{user_id} — remove a direct member.
// Inherited memberships can't be removed at the child level. // Inherited memberships can't be removed at the child level.
func handleRemoveProjectTeamMember(w http.ResponseWriter, r *http.Request) { func handleRemoveProjectTeamMember(w http.ResponseWriter, r *http.Request) {

View File

@@ -25,7 +25,14 @@ const (
// Project-level responsibility values on paliad.project_teams.responsibility. // Project-level responsibility values on paliad.project_teams.responsibility.
// Open the ladder gate (lead/member) or close it (observer/external). // 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 ( const (
ResponsibilityAdmin = "admin"
ResponsibilityLead = "lead" ResponsibilityLead = "lead"
ResponsibilityMember = "member" ResponsibilityMember = "member"
ResponsibilityObserver = "observer" ResponsibilityObserver = "observer"
@@ -143,7 +150,7 @@ func IsValidProfession(p string) bool {
// recognised project-responsibility enum values. Used by TeamService. // recognised project-responsibility enum values. Used by TeamService.
func IsValidResponsibility(r string) bool { func IsValidResponsibility(r string) bool {
switch r { switch r {
case ResponsibilityLead, ResponsibilityMember, case ResponsibilityAdmin, ResponsibilityLead, ResponsibilityMember,
ResponsibilityObserver, ResponsibilityExternal: ResponsibilityObserver, ResponsibilityExternal:
return true return true
} }

View File

@@ -190,7 +190,8 @@ func TestIsValidProfession(t *testing.T) {
} }
func TestIsValidResponsibility(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) { t.Run(r, func(t *testing.T) {
if !IsValidResponsibility(r) { if !IsValidResponsibility(r) {
t.Errorf("IsValidResponsibility(%q) must be true", 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) { func TestApprovalEventType(t *testing.T) {
cases := []struct { cases := []struct {
entity, step, want string entity, step, want string

View File

@@ -44,6 +44,12 @@ var (
ErrForbidden = errors.New("forbidden") ErrForbidden = errors.New("forbidden")
// ErrInvalidInput signals a bad request (empty required field etc.). // ErrInvalidInput signals a bad request (empty required field etc.).
ErrInvalidInput = errors.New("invalid input") 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 // ErrInvalidProceedingTypeCategory signals that the caller supplied
// a proceeding_type_id pointing at a non-fristenrechner-category row. // a proceeding_type_id pointing at a non-fristenrechner-category row.
// Phase 3 Slice 5 soft-merge (t-paliad-186, design §3.F): only // Phase 3 Slice 5 soft-merge (t-paliad-186, design §3.F): only

View File

@@ -13,6 +13,7 @@ package services
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"strings" "strings"
@@ -80,9 +81,13 @@ func (s *TeamService) AddMember(ctx context.Context, callerID, projectID, userID
// column. external → 'local_counsel' is intentionally narrower than the // column. external → 'local_counsel' is intentionally narrower than the
// new enum (loses the expert distinction); we accept that for the short // new enum (loses the expert distinction); we accept that for the short
// transition window. // 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 { func legacyRoleFromResponsibility(r string) string {
switch r { switch r {
case ResponsibilityLead: case ResponsibilityAdmin, ResponsibilityLead:
return "lead" return "lead"
case ResponsibilityObserver: case ResponsibilityObserver:
return "observer" return "observer"
@@ -99,11 +104,43 @@ func legacyRoleFromResponsibility(r string) string {
// RemoveMember deletes a direct team membership. Inherited memberships (from // RemoveMember deletes a direct team membership. Inherited memberships (from
// ancestors) can't be removed at the child level — the caller must remove // ancestors) can't be removed at the child level — the caller must remove
// the ancestor row to break the inheritance. // 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 { func (s *TeamService) RemoveMember(ctx context.Context, callerID, projectID, userID uuid.UUID) error {
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil { if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
return err 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 `DELETE FROM paliad.project_teams
WHERE project_id = $1 AND user_id = $2 AND inherited = false`, WHERE project_id = $1 AND user_id = $2 AND inherited = false`,
projectID, userID) projectID, userID)
@@ -113,6 +150,104 @@ func (s *TeamService) RemoveMember(ctx context.Context, callerID, projectID, use
if rows, _ := res.RowsAffected(); rows == 0 { if rows, _ := res.RowsAffected(); rows == 0 {
return sql.ErrNoRows 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, &current,
`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 &current, 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 return nil
} }
@@ -259,6 +394,27 @@ func (s *TeamService) ListMembershipsIndex(ctx context.Context, callerID uuid.UU
return out, nil 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, // pathToIDStrings splits a materialised path into its UUID labels as strings,