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
|
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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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">· ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""}</span></td>
|
<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="${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) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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.
|
// 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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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, ¤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
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user