feat(t-paliad-051): split paliad.users.role into job_title + global_role

Conflation: paliad.users.role was simultaneously job title (display only)
and global permission ('role=admin' checks across Go/SQL/JS). m wanted
to set his real job title ('Counsel Knowledge Lawyer') without losing
admin access — the t-paliad-050 admin-team UI even rejected role='admin'
on edit, so any UI-driven update silently demoted m.

Per m's three-axis principle ("firm roles are not project roles are not
tool roles"), this lands TWO orthogonal columns:

* paliad.users.job_title — free text, NULL allowed, display only.
  NEVER gates anything in code or SQL.
* paliad.users.global_role — CHECK ('standard'|'global_admin'),
  default 'standard'. The only thing that gates ops.

Migration 023:
* Drops NOT NULL + 'associate' default off the legacy role column
* Promotes role='admin' rows to global_role='global_admin'; clears
  their role text; sets m's job_title='Counsel Knowledge Lawyer'
* Renames role -> job_title with CHECK (job_title IS NULL OR <> '')
* Replaces can_see_project body with global_role='global_admin'
* CASCADE-rebuilds every RLS policy under canonical English names —
  with the historic u.role IN ('partner','admin') gates simplified
  to u.global_role='global_admin' only (job_title NEVER gates)

Code surface:
* internal/models/models.go: User.Role -> User.JobTitle (*string) +
  User.GlobalRole (string)
* internal/services/user_service.go: bootstrap (first row promoted to
  global_admin via pg_advisory_xact_lock(7346298141), unchanged constant);
  UpdateProfile drops role, accepts job_title only; AdminUpdateUser adds
  global_role with last-admin demotion guard (ErrLastGlobalAdmin);
  IsAdmin reads global_role
* Other services (dashboard/agenda/appointment/project/deadline/
  department/party/note/checklist_instance): pass user.GlobalRole into
  visibility predicates; partner-or-admin gates simplified to
  global_admin only
* Handlers: drop now-impossible ErrAdminBootstrapOnly cases;
  admin_users handles ErrLastGlobalAdmin -> 409
* department_service: SQL u.role -> u.job_title, DepartmentMember.Role
  -> JobTitle (*string)

Frontend:
* /api/me + Me interfaces ship {job_title, global_role}
* Onboarding form: 'Berufsbezeichnung / Job title' (job_title)
* Settings + admin-team forms: same renames + i18n updates
* Admin-team: new 'Berechtigung / Permission' column with
  'Standard'|'Global Admin' badge + dropdown editor; last-admin
  demotion guard at the UI layer
* Sidebar admin-section reveal: me.global_role==='global_admin'
* deadlines/deadlines-detail/projects-detail/notes: partner-as-permission
  gates dropped, only global_admin grants those operations

Tests:
* user_service_test: bootstrap promotes first user to global_admin,
  subsequent default to standard; AdminUpdateUser refuses to demote
  the last global_admin; IsAdmin reads global_role

Migration applied to ydb 2026-04-27. Live state verified:
* m: job_title='Counsel Knowledge Lawyer', global_role='global_admin'
* tester: job_title=NULL, global_role='global_admin'
* 29 stub colleagues: job_title='associate', global_role='standard'
This commit is contained in:
m
2026-04-27 14:59:03 +02:00
parent aec150f1cd
commit b34500ad31
30 changed files with 947 additions and 258 deletions

View File

@@ -61,9 +61,9 @@ We have one permission today and m's brief says "possibly more later, design wit
Considered: keep `role` free-text, just normalize so `job_title='global_admin'` means both the title and the permission. Rejected because (a) the title `Counsel Knowledge Lawyer` and the permission `global_admin` are independent — a user can have one without the other (m wants both); (b) the admin-team UI restriction (`ErrAdminBootstrapOnly`) is exactly the symptom of trying to overload one column for two concerns. We're solving the conflation, not preserving it under a new name.
### 4. The "partner" gate problem (out of scope, flagged)
### 4. The "partner" gate — DROPPED entirely (m's three-axis principle)
Several places gate on `user.Role IN ('partner','admin')`:
Mid-implementation m clarified the principle: "firm roles are not project roles are not tool roles". Several places gated on `user.Role IN ('partner','admin')`:
- `internal/services/party_service.go:100` — delete party
- `internal/services/note_service.go:195` — note ops
@@ -71,22 +71,24 @@ Several places gate on `user.Role IN ('partner','admin')`:
- `internal/services/project_service.go:617` — project ops
- `internal/services/checklist_instance_service.go:301` — checklist ops
- `internal/services/deadline_service.go:437` — delete deadline
- migrations 018 / 021 RLS policies — `users.role IN ('partner','admin')` on `projects`, `project_teams`, `notes` deletes
- `frontend/src/client/projects-detail.ts:555,1206`, `deadlines-detail.ts:190,193,207` — UI gates
- migrations 018 / 021 RLS policies — `users.role IN ('partner','admin')` on `projects_delete`, `project_teams_delete`
- `frontend/src/client/projects-detail.ts:555,1206`, `deadlines-detail.ts:194,208`, `deadlines.ts:69`, `notes.ts:104` — UI gates
These conflate `partner` (job title) with "partner-level permissions". Today this is **already broken** in production: the onboarding form's datalist suggests "Partner" with a capital P, but the gate checks lowercase `'partner'`, so a user typing "Partner" stores `role='Partner'` and silently fails the gate. No prod row currently uses `role='partner'`, so nothing trips over it.
These conflate "Partner" (a firm role / job title) with permission-to-mutate (a tool role). m: firm role and tool role must be orthogonal; the firm role is **display only** and **must never gate ops**.
**Decision for t-paliad-051:** out of scope per m's brief. Migrate the `'admin'` half of these gates to `global_role='global_admin'`; leave the `'partner'` half pointing at `job_title='partner'` (still broken, still unused). Document as a known limitation, file follow-up. Cleaning up "partner" is its own design (likely a `permissions` array or a second enum value). NOT shipping that now.
**Decision:** drop the partner half of every gate. Each of these checks becomes "global_admin only". Production impact is zero — no prod row has `role='partner'` today, so nobody loses a capability they actually had.
After-rename gate shape:
```go
// before
if user.Role != "partner" && user.Role != "admin" { return ErrForbidden }
// after
if user.JobTitle != "partner" && user.GlobalRole != "global_admin" { return ErrForbidden }
if user.GlobalRole != "global_admin" { return ErrForbidden }
```
This preserves prod behavior 1:1: nothing in prod has `role='partner'`, nothing in prod will have `job_title='partner'`, only the `global_admin` branch matters in practice. m and tester both keep full access.
If a future tool-role system grants partner-level mutations to specific users, it adds a fresh dimension cleanly (text[] permissions or named enums) without touching `job_title`. YAGNI for now — there are no rows that need it.
**Helper deleted:** the design's first draft kept an `IsPartnerOrGlobalAdmin(u)` helper. It's gone — the gate is just `user.GlobalRole == "global_admin"` everywhere.
### 5. Data migration

View File

@@ -71,7 +71,8 @@ export function renderAdminTeam(): string {
<th data-i18n="admin.team.col.name">Name</th>
<th data-i18n="admin.team.col.email">E-Mail</th>
<th data-i18n="admin.team.col.office">Standort</th>
<th data-i18n="admin.team.col.role">Rolle</th>
<th data-i18n="admin.team.col.job_title">Berufsbezeichnung</th>
<th data-i18n="admin.team.col.permission">Berechtigung</th>
<th data-i18n="admin.team.col.dezernat">Dezernat</th>
<th data-i18n="admin.team.col.additional">Weitere Standorte</th>
<th data-i18n="admin.team.col.lang">Sprache</th>
@@ -80,7 +81,7 @@ export function renderAdminTeam(): string {
</tr>
</thead>
<tbody id="admin-team-tbody">
<tr><td colspan={9} className="admin-team-loading" data-i18n="admin.team.loading">Lade...</td></tr>
<tr><td colspan={10} className="admin-team-loading" data-i18n="admin.team.loading">Lade...</td></tr>
</tbody>
</table>
</div>
@@ -118,8 +119,8 @@ export function renderAdminTeam(): string {
<select id="admin-da-office" name="office" required />
</div>
<div className="form-field">
<label htmlFor="admin-da-role" data-i18n="admin.team.direct_add.role">Rolle</label>
<input type="text" id="admin-da-role" name="role" placeholder="Associate" />
<label htmlFor="admin-da-role" data-i18n="admin.team.direct_add.job_title">Berufsbezeichnung</label>
<input type="text" id="admin-da-role" name="job_title" placeholder="Associate" />
</div>
<div className="form-field">
<label htmlFor="admin-da-dezernat" data-i18n="admin.team.direct_add.dezernat">Dezernat (optional)</label>

View File

@@ -7,7 +7,8 @@ interface User {
display_name: string;
office: string;
additional_offices?: string[];
role: string;
job_title: string | null;
global_role: string;
dezernat?: string;
lang: string;
reminder_morning_time?: string;
@@ -29,7 +30,19 @@ interface Unonboarded {
}
const OFFICE_ORDER = ["munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan"];
const ROLE_SUGGESTIONS = ["Partner", "Associate", "PA", "Of Counsel", "Referendar/in", "Trainee", "wiss. Mitarbeiter/in", "Sekretariat"];
const JOB_TITLE_SUGGESTIONS = [
"Partner",
"Associate",
"PA",
"Of Counsel",
"Counsel",
"Counsel Knowledge Lawyer",
"Knowledge Lawyer",
"Referendar/in",
"Trainee",
"wiss. Mitarbeiter/in",
"Sekretariat",
];
let users: User[] = [];
let offices: Office[] = [];
@@ -57,6 +70,11 @@ function fmtDate(iso: string): string {
return d.toLocaleDateString();
}
function permissionLabel(globalRole: string): string {
if (globalRole === "global_admin") return t("admin.team.permission.global_admin") || "Global Admin";
return t("admin.team.permission.standard") || "Standard";
}
async function loadAll() {
const [usersResp, officesResp] = await Promise.all([
fetch("/api/admin/users"),
@@ -114,7 +132,7 @@ function userMatchesSearch(u: User): boolean {
u.display_name.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
(u.dezernat ?? "").toLowerCase().includes(q) ||
u.role.toLowerCase().includes(q)
(u.job_title ?? "").toLowerCase().includes(q)
);
}
@@ -145,15 +163,36 @@ function langOptions(selected: string): string {
.join("");
}
function globalAdminCount(): number {
return users.reduce((acc, u) => acc + (u.global_role === "global_admin" ? 1 : 0), 0);
}
function permissionCell(u: User): string {
const cls = u.global_role === "global_admin" ? "admin-team-perm admin-team-perm-admin" : "admin-team-perm";
return `<span class="${cls}">${esc(permissionLabel(u.global_role))}</span>`;
}
function permissionEditor(u: User): string {
// Disable demoting the only remaining global_admin.
const isLastAdmin = u.global_role === "global_admin" && globalAdminCount() <= 1;
const standardOpt = `<option value="standard"${u.global_role === "standard" ? " selected" : ""}>${esc(permissionLabel("standard"))}</option>`;
const adminOpt = `<option value="global_admin"${u.global_role === "global_admin" ? " selected" : ""}>${esc(permissionLabel("global_admin"))}</option>`;
const disabled = isLastAdmin ? " disabled" : "";
const title = isLastAdmin ? ` title="${esc(t("admin.team.permission.last_admin") || "Letzter Admin kann nicht degradiert werden.")}"` : "";
return `<select class="admin-team-input" data-field="global_role"${disabled}${title}>${standardOpt}${adminOpt}</select>`;
}
function renderRow(u: User): string {
if (editingId === u.id) return renderEditRow(u);
const additional = (u.additional_offices ?? []).filter((o) => o !== u.office);
const jobTitle = u.job_title ?? "";
return `
<tr data-user-id="${esc(u.id)}">
<td class="akten-col-title">${esc(u.display_name)}</td>
<td><a href="mailto:${esc(u.email)}">${esc(u.email)}</a></td>
<td><span class="akten-office-chip akten-office-${esc(u.office)}">${esc(officeLabel(u.office))}</span></td>
<td>${u.role === "admin" ? `<strong>${esc(u.role)}</strong>` : esc(u.role)}</td>
<td>${jobTitle ? esc(jobTitle) : "<span class=\"admin-team-muted\">—</span>"}</td>
<td>${permissionCell(u)}</td>
<td>${esc(u.dezernat ?? "")}</td>
<td>${additional.length ? additional.map((o) => esc(officeLabel(o))).join(", ") : "<span class=\"admin-team-muted\">—</span>"}</td>
<td>${esc(u.lang.toUpperCase())}</td>
@@ -167,18 +206,18 @@ function renderRow(u: User): string {
function renderEditRow(u: User): string {
const additional = u.additional_offices ?? [];
const roleList = ROLE_SUGGESTIONS.map((r) => `<option value="${esc(r)}" />`).join("");
const jobTitleList = JOB_TITLE_SUGGESTIONS.map((r) => `<option value="${esc(r)}" />`).join("");
const jobTitle = u.job_title ?? "";
return `
<tr data-user-id="${esc(u.id)}" class="admin-team-edit-row">
<td><input type="text" class="admin-team-input" data-field="display_name" value="${esc(u.display_name)}" /></td>
<td><span class="admin-team-muted" title="E-Mail kann nicht ge&auml;ndert werden">${esc(u.email)}</span></td>
<td><select class="admin-team-input" data-field="office">${officeOptions(u.office)}</select></td>
<td>
${u.role === "admin"
? `<span class="admin-team-muted">admin</span>`
: `<input type="text" class="admin-team-input" data-field="role" value="${esc(u.role)}" list="admin-team-role-suggest-${esc(u.id)}" />
<datalist id="admin-team-role-suggest-${esc(u.id)}">${roleList}</datalist>`}
<input type="text" class="admin-team-input" data-field="job_title" value="${esc(jobTitle)}" list="admin-team-job-title-suggest-${esc(u.id)}" />
<datalist id="admin-team-job-title-suggest-${esc(u.id)}">${jobTitleList}</datalist>
</td>
<td>${permissionEditor(u)}</td>
<td><input type="text" class="admin-team-input" data-field="dezernat" value="${esc(u.dezernat ?? "")}" /></td>
<td class="admin-team-multi">${additionalOfficesEditor(additional)}</td>
<td><select class="admin-team-input" data-field="lang">${langOptions(u.lang)}</select></td>
@@ -215,10 +254,12 @@ function render() {
}
empty.style.display = "none";
// Stable sort: admins first, then by display_name.
// Stable sort: global admins first, then by display_name.
const sorted = filtered.slice().sort((a, b) => {
if (a.role === "admin" && b.role !== "admin") return -1;
if (b.role === "admin" && a.role !== "admin") return 1;
const aAdmin = a.global_role === "global_admin";
const bAdmin = b.global_role === "global_admin";
if (aAdmin && !bAdmin) return -1;
if (bAdmin && !aAdmin) return 1;
return a.display_name.localeCompare(b.display_name);
});
tbody.innerHTML = sorted.map(renderRow).join("");
@@ -306,12 +347,12 @@ function openDirectAddModal() {
const officeSel = document.getElementById("admin-da-office") as HTMLSelectElement;
const fb = document.getElementById("admin-da-feedback")!;
const nameField = document.getElementById("admin-da-name") as HTMLInputElement;
const roleField = document.getElementById("admin-da-role") as HTMLInputElement;
const jobTitleField = document.getElementById("admin-da-role") as HTMLInputElement;
const dezField = document.getElementById("admin-da-dezernat") as HTMLInputElement;
fb.style.display = "none";
nameField.value = "";
roleField.value = "";
jobTitleField.value = "";
dezField.value = "";
officeSel.innerHTML = officeOptions("munich");
@@ -362,14 +403,14 @@ function initDirectAddModal() {
fb.style.display = "none";
const officeSel = document.getElementById("admin-da-office") as HTMLSelectElement;
const roleField = document.getElementById("admin-da-role") as HTMLInputElement;
const jobTitleField = document.getElementById("admin-da-role") as HTMLInputElement;
const dezField = document.getElementById("admin-da-dezernat") as HTMLInputElement;
const payload: Record<string, unknown> = {
email: emailSel.value,
display_name: nameField.value.trim(),
office: officeSel.value,
role: roleField.value.trim() || "Associate",
job_title: jobTitleField.value.trim() || "Associate",
lang: "de",
};
if (dezField.value.trim()) payload.dezernat = dezField.value.trim();

View File

@@ -31,7 +31,8 @@ interface DeadlineRule {
interface Me {
id: string;
role: string;
job_title: string | null;
global_role: string;
}
let deadline: Deadline | null = null;
@@ -190,7 +191,7 @@ function render() {
// global admin/partner role won't see the inline button — they get a 403
// only if they try, but the button itself stays hidden. They can still
// PATCH the endpoint directly.
if (me && (me.role === "admin" || me.role === "partner")) {
if (me && (me.global_role === "global_admin")) {
reopenBtn.style.display = "";
reopenBtn.disabled = false;
} else {
@@ -204,7 +205,7 @@ function render() {
}
const deleteWrap = document.getElementById("deadline-delete-wrap")!;
if (me && (me.role === "partner" || me.role === "admin")) {
if (me && (me.global_role === "global_admin")) {
deleteWrap.style.display = "";
} else {
deleteWrap.style.display = "none";

View File

@@ -30,7 +30,8 @@ interface Summary {
interface Me {
id: string;
role: string;
job_title: string | null;
global_role: string;
}
let allDeadlines: Deadline[] = [];
@@ -66,7 +67,7 @@ function canReopen(): boolean {
// Server enforces global-admin OR project-lead. Client mirrors a subset:
// global admins/partners see the inline reopen icon. Project leads without
// a global admin/partner role can still reopen via the detail page.
return !!me && (me.role === "admin" || me.role === "partner");
return !!me && (me.global_role === "global_admin");
}
async function loadSummary() {

View File

@@ -737,15 +737,15 @@ const translations: Record<Lang, Record<string, string>> = {
"onboarding.display_name.placeholder": "Vor- und Nachname",
"onboarding.office": "B\u00fcro",
"onboarding.office.placeholder": "Bitte ausw\u00e4hlen",
"onboarding.role": "Rolle",
"onboarding.role.placeholder": "z.B. Associate, Partner, PA",
"onboarding.job_title": "Berufsbezeichnung",
"onboarding.job_title.placeholder": "z.B. Associate, Partner, PA",
"onboarding.dezernat": "Dezernat / Partner",
"onboarding.dezernat.placeholder": "z.B. Dr. M\u00fcller, Team Schmidt",
"onboarding.optional": "(optional)",
"onboarding.submit": "Profil anlegen",
"onboarding.error.display_name": "Bitte Anzeigename eingeben.",
"onboarding.error.office": "Bitte B\u00fcro ausw\u00e4hlen.",
"onboarding.error.role": "Bitte Rolle eingeben.",
"onboarding.error.job_title": "Bitte Berufsbezeichnung eingeben.",
"onboarding.error.generic": "Profil konnte nicht angelegt werden.",
"onboarding.error.connection": "Verbindungsfehler. Bitte versuchen Sie es erneut.",
@@ -975,8 +975,8 @@ const translations: Record<Lang, Record<string, string>> = {
"einstellungen.profil.display_name": "Anzeigename",
"einstellungen.profil.display_name.placeholder": "Vor- und Nachname",
"einstellungen.profil.office": "B\u00fcro",
"einstellungen.profil.role": "Rolle",
"einstellungen.profil.role.placeholder": "z.B. Associate, Partner, PA",
"einstellungen.profil.job_title": "Berufsbezeichnung",
"einstellungen.profil.job_title.placeholder": "z.B. Associate, Partner, PA",
"einstellungen.profil.dezernat": "Dezernat / Partner",
"einstellungen.profil.dezernat.placeholder": "z.B. Dr. M\u00fcller, Team Schmidt",
"einstellungen.profil.lang": "Sprache",
@@ -985,7 +985,7 @@ const translations: Record<Lang, Record<string, string>> = {
"einstellungen.profil.lang.hint": "Wird f\u00fcr Oberfl\u00e4che und Benachrichtigungs-E-Mails verwendet.",
"einstellungen.profil.error.display_name": "Bitte Anzeigename eingeben.",
"einstellungen.profil.error.office": "Bitte B\u00fcro ausw\u00e4hlen.",
"einstellungen.profil.error.role": "Bitte Rolle eingeben.",
"einstellungen.profil.error.job_title": "Bitte Berufsbezeichnung eingeben.",
"einstellungen.prefs.reminders.heading": "Frist-Erinnerungen",
"einstellungen.prefs.reminders.hint": "Paliad sendet Erinnerungen an Ihre E-Mail, wenn Fristen f\u00e4llig werden.",
"einstellungen.prefs.reminders.master": "Frist-Erinnerungen aktiv",
@@ -1193,7 +1193,8 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.team.col.name": "Name",
"admin.team.col.email": "E-Mail",
"admin.team.col.office": "Standort",
"admin.team.col.role": "Rolle",
"admin.team.col.job_title": "Berufsbezeichnung",
"admin.team.col.permission": "Berechtigung",
"admin.team.col.dezernat": "Dezernat",
"admin.team.col.additional": "Weitere Standorte",
"admin.team.col.lang": "Sprache",
@@ -1214,8 +1215,11 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.team.direct_add.empty": "Keine offenen Konten.",
"admin.team.direct_add.name": "Anzeigename",
"admin.team.direct_add.office": "Standort",
"admin.team.direct_add.role": "Rolle",
"admin.team.direct_add.job_title": "Berufsbezeichnung",
"admin.team.direct_add.dezernat": "Dezernat (optional)",
"admin.team.permission.standard": "Standard",
"admin.team.permission.global_admin": "Global Admin",
"admin.team.permission.last_admin": "Der letzte Global Admin kann nicht degradiert werden.",
"admin.team.direct_add.cancel": "Abbrechen",
"admin.team.direct_add.submit": "Anlegen",
@@ -1953,15 +1957,15 @@ const translations: Record<Lang, Record<string, string>> = {
"onboarding.display_name.placeholder": "First and last name",
"onboarding.office": "Office",
"onboarding.office.placeholder": "Please select",
"onboarding.role": "Role",
"onboarding.role.placeholder": "e.g. Associate, Partner, PA",
"onboarding.job_title": "Job title",
"onboarding.job_title.placeholder": "e.g. Associate, Partner, PA",
"onboarding.dezernat": "Department / Partner",
"onboarding.dezernat.placeholder": "e.g. Dr. M\u00fcller, Team Schmidt",
"onboarding.optional": "(optional)",
"onboarding.submit": "Create profile",
"onboarding.error.display_name": "Please enter a display name.",
"onboarding.error.office": "Please select an office.",
"onboarding.error.role": "Please enter a role.",
"onboarding.error.job_title": "Please enter a job title.",
"onboarding.error.generic": "Could not create profile.",
"onboarding.error.connection": "Connection error. Please try again.",
@@ -2191,8 +2195,8 @@ const translations: Record<Lang, Record<string, string>> = {
"einstellungen.profil.display_name": "Display name",
"einstellungen.profil.display_name.placeholder": "First and last name",
"einstellungen.profil.office": "Office",
"einstellungen.profil.role": "Role",
"einstellungen.profil.role.placeholder": "e.g. Associate, Partner, PA",
"einstellungen.profil.job_title": "Job title",
"einstellungen.profil.job_title.placeholder": "e.g. Associate, Partner, PA",
"einstellungen.profil.dezernat": "Department / Partner",
"einstellungen.profil.dezernat.placeholder": "e.g. Dr. M\u00fcller, Team Schmidt",
"einstellungen.profil.lang": "Language",
@@ -2201,7 +2205,7 @@ const translations: Record<Lang, Record<string, string>> = {
"einstellungen.profil.lang.hint": "Used for the UI and notification emails.",
"einstellungen.profil.error.display_name": "Please enter a display name.",
"einstellungen.profil.error.office": "Please select an office.",
"einstellungen.profil.error.role": "Please enter a role.",
"einstellungen.profil.error.job_title": "Please enter a job title.",
"einstellungen.prefs.reminders.heading": "Deadline reminders",
"einstellungen.prefs.reminders.hint": "Paliad emails you when deadlines approach.",
"einstellungen.prefs.reminders.master": "Deadline reminders enabled",
@@ -2409,7 +2413,8 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.team.col.name": "Name",
"admin.team.col.email": "Email",
"admin.team.col.office": "Office",
"admin.team.col.role": "Role",
"admin.team.col.job_title": "Job title",
"admin.team.col.permission": "Permission",
"admin.team.col.dezernat": "Department",
"admin.team.col.additional": "Additional offices",
"admin.team.col.lang": "Lang",
@@ -2430,8 +2435,11 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.team.direct_add.empty": "No pending accounts.",
"admin.team.direct_add.name": "Display name",
"admin.team.direct_add.office": "Office",
"admin.team.direct_add.role": "Role",
"admin.team.direct_add.job_title": "Job title",
"admin.team.direct_add.dezernat": "Department (optional)",
"admin.team.permission.standard": "Standard",
"admin.team.permission.global_admin": "Global Admin",
"admin.team.permission.last_admin": "The last global admin cannot be demoted.",
"admin.team.direct_add.cancel": "Cancel",
"admin.team.direct_add.submit": "Create",

View File

@@ -28,7 +28,8 @@ export interface Note {
interface Me {
id: string;
role: string;
job_title: string | null;
global_role: string;
}
interface NotesState {
@@ -101,7 +102,7 @@ function canEdit(state: NotesState, n: Note): boolean {
function canDelete(state: NotesState, n: Note): boolean {
if (!state.me) return false;
if (n.created_by === state.me.id) return true;
return state.me.role === "partner" || state.me.role === "admin";
return state.me.global_role === "global_admin";
}
function render(state: NotesState) {

View File

@@ -85,7 +85,7 @@ async function submitForm(e: Event): Promise<void> {
const displayName = (data.get("display_name") as string || "").trim();
const office = (data.get("office") as string || "").trim();
const role = (data.get("role") as string || "").trim();
const jobTitle = (data.get("job_title") as string || "").trim();
const dezernat = (data.get("dezernat") as string || "").trim();
if (!displayName) {
@@ -96,15 +96,15 @@ async function submitForm(e: Event): Promise<void> {
showMessage(t("onboarding.error.office"), "login-error");
return;
}
if (!role) {
showMessage(t("onboarding.error.role"), "login-error");
if (!jobTitle) {
showMessage(t("onboarding.error.job_title"), "login-error");
return;
}
const payload: Record<string, unknown> = {
display_name: displayName,
office,
role,
job_title: jobTitle,
};
if (dezernat) payload.dezernat = dezernat;

View File

@@ -93,7 +93,8 @@ interface Appointment {
interface Me {
id: string;
role: string;
job_title: string | null;
global_role: string;
office: string;
}
@@ -552,7 +553,7 @@ function renderHeader() {
// Delete visibility: partner/admin only
const deleteWrap = document.getElementById("project-delete-wrap")!;
if (me && (me.role === "partner" || me.role === "admin")) {
if (me && (me.global_role === "global_admin")) {
deleteWrap.style.display = "";
} else {
deleteWrap.style.display = "none";
@@ -1203,7 +1204,7 @@ function renderTeam() {
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
if (!me) return false;
if (m.user_id === me.id) return true;
return me.role === "partner" || me.role === "admin";
return me.global_role === "global_admin";
}
function initTeamForm(id: string) {

View File

@@ -16,7 +16,8 @@ interface Me {
email: string;
display_name: string;
office: string;
role: string;
job_title: string | null;
global_role: string;
dezernat?: string;
lang: Lang;
email_preferences: Record<string, unknown>;
@@ -184,7 +185,7 @@ function fillProfilForm() {
(document.getElementById("profil-email") as HTMLInputElement).value = me.email;
(document.getElementById("profil-display-name") as HTMLInputElement).value = me.display_name;
(document.getElementById("profil-office") as HTMLSelectElement).value = me.office;
(document.getElementById("profil-role") as HTMLInputElement).value = me.role;
(document.getElementById("profil-role") as HTMLInputElement).value = me.job_title ?? "";
(document.getElementById("profil-dezernat") as HTMLInputElement).value = me.dezernat ?? "";
(document.getElementById("profil-lang") as HTMLSelectElement).value = me.lang || "de";
}
@@ -197,7 +198,7 @@ async function saveProfil(ev: Event) {
const displayName = (document.getElementById("profil-display-name") as HTMLInputElement).value.trim();
const office = (document.getElementById("profil-office") as HTMLSelectElement).value;
const role = (document.getElementById("profil-role") as HTMLInputElement).value.trim();
const jobTitle = (document.getElementById("profil-role") as HTMLInputElement).value.trim();
const dezernat = (document.getElementById("profil-dezernat") as HTMLInputElement).value.trim();
const lang = (document.getElementById("profil-lang") as HTMLSelectElement).value as Lang;
@@ -211,8 +212,8 @@ async function saveProfil(ev: Event) {
msg.className = "form-msg form-msg-error";
return;
}
if (!role) {
msg.textContent = t("einstellungen.profil.error.role");
if (!jobTitle) {
msg.textContent = t("einstellungen.profil.error.job_title");
msg.className = "form-msg form-msg-error";
return;
}
@@ -220,7 +221,7 @@ async function saveProfil(ev: Event) {
const payload = {
display_name: displayName,
office,
role,
job_title: jobTitle,
dezernat,
lang,
};
@@ -563,7 +564,7 @@ interface DezernatMember {
display_name: string;
email: string;
office: string;
role: string;
job_title: string | null;
}
let allDezernate: Dezernat[] = [];
@@ -579,7 +580,7 @@ async function loadDezernatTab(): Promise<void> {
}
}
const adminSection = document.getElementById("dezernat-admin-section")!;
if (me && me.role === "admin") {
if (me && me.global_role === "global_admin") {
adminSection.style.display = "";
}

View File

@@ -296,23 +296,23 @@ function initChangelogBadge(): void {
}
// initAdminGroup reveals the Admin section in the sidebar when the caller's
// /api/me lookup confirms role='admin'. The markup is in the DOM with
// display:none for everyone — flipping it on after the fetch lands keeps
// non-admin pageloads cheap (no flash, no second render) and avoids a
// /api/me lookup confirms global_role='global_admin'. The markup is in the
// DOM with display:none for everyone — flipping it on after the fetch lands
// keeps non-admin pageloads cheap (no flash, no second render) and avoids a
// privilege flash for admins on cached pages.
function initAdminGroup(): void {
const group = document.getElementById("sidebar-admin-group") as HTMLElement | null;
if (!group) return;
fetch("/api/me", { credentials: "same-origin" })
.then((r) => (r.ok ? r.json() : null))
.then((me: { role?: string } | null) => {
if (me && me.role === "admin") {
.then((me: { global_role?: string } | null) => {
if (me && me.global_role === "global_admin") {
group.style.display = "";
}
})
.catch(() => {
// silent: not being able to check role just means we keep the section
// hidden, which fails closed.
// silent: not being able to check the permission just means we keep
// the section hidden, which fails closed.
});
}

View File

@@ -45,23 +45,26 @@ export function renderOnboarding(): string {
{/* Options populated from /api/offices at init. */}
</select>
<label htmlFor="onb-role" className="login-label" data-i18n="onboarding.role">Rolle</label>
<label htmlFor="onb-job-title" className="login-label" data-i18n="onboarding.job_title">Berufsbezeichnung</label>
<input
type="text"
id="onb-role"
name="role"
list="onb-role-suggestions"
id="onb-job-title"
name="job_title"
list="onb-job-title-suggestions"
required
autocomplete="off"
className="login-input"
data-i18n-placeholder="onboarding.role.placeholder"
data-i18n-placeholder="onboarding.job_title.placeholder"
placeholder="z.B. Associate, Partner, PA"
/>
<datalist id="onb-role-suggestions">
<datalist id="onb-job-title-suggestions">
<option value="Partner"></option>
<option value="Associate"></option>
<option value="PA"></option>
<option value="Of Counsel"></option>
<option value="Counsel"></option>
<option value="Counsel Knowledge Lawyer"></option>
<option value="Knowledge Lawyer"></option>
<option value="Referendar/in"></option>
<option value="Trainee"></option>
<option value="wiss. Mitarbeiter/in"></option>

View File

@@ -75,14 +75,14 @@ export function renderSettings(): string {
</div>
<div className="form-field">
<label htmlFor="profil-role" data-i18n="einstellungen.profil.role">Rolle</label>
<label htmlFor="profil-role" data-i18n="einstellungen.profil.job_title">Berufsbezeichnung</label>
<input
type="text"
id="profil-role"
list="profil-role-suggestions"
required
autocomplete="off"
data-i18n-placeholder="einstellungen.profil.role.placeholder"
data-i18n-placeholder="einstellungen.profil.job_title.placeholder"
placeholder="z.B. Associate, Partner, PA"
/>
<datalist id="profil-role-suggestions">
@@ -90,6 +90,9 @@ export function renderSettings(): string {
<option value="Associate"></option>
<option value="PA"></option>
<option value="Of Counsel"></option>
<option value="Counsel"></option>
<option value="Counsel Knowledge Lawyer"></option>
<option value="Knowledge Lawyer"></option>
<option value="Referendar/in"></option>
<option value="Trainee"></option>
<option value="wiss. Mitarbeiter/in"></option>

View File

@@ -0,0 +1,228 @@
-- Symmetric reverse of 023.up.sql. Restores paliad.users.role + drops
-- global_role + restores can_see_project + RLS to the migration-021 shape.
--
-- WARNING: this loses any job_title that was NULL after the up migration
-- (admins lose their NULL slot — they get role='admin' restored from
-- global_role='global_admin'). Real job titles from non-admins (e.g.
-- "Counsel Knowledge Lawyer") are preserved by the role rename. tester
-- whose job_title is NULL gets role='admin' back from global_role.
ALTER TABLE paliad.users
DROP CONSTRAINT IF EXISTS users_job_title_check;
ALTER TABLE paliad.users
RENAME COLUMN job_title TO role;
-- Restore role='admin' for global_admins whose role is currently NULL.
UPDATE paliad.users
SET role = 'admin'
WHERE global_role = 'global_admin' AND role IS NULL;
-- Re-add the original CHECK (role <> '') from migration 015.
ALTER TABLE paliad.users
ADD CONSTRAINT users_role_check CHECK (role <> '');
ALTER TABLE paliad.users
DROP CONSTRAINT IF EXISTS users_global_role_check;
ALTER TABLE paliad.users
DROP COLUMN IF EXISTS global_role;
-- Restore the migration-021 visibility functions + RLS policies.
DROP FUNCTION IF EXISTS paliad.note_is_visible(uuid, uuid, uuid, uuid) CASCADE;
DROP FUNCTION IF EXISTS paliad.can_see_project(uuid) CASCADE;
CREATE FUNCTION paliad.can_see_project(_project_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = paliad, public
AS $$
SELECT EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.role = 'admin'
)
OR EXISTS (
SELECT 1
FROM paliad.projects target
JOIN paliad.project_teams pt
ON pt.user_id = auth.uid()
AND pt.project_id = ANY(string_to_array(target.path, '.')::uuid[])
WHERE target.id = _project_id
);
$$;
CREATE FUNCTION paliad.note_is_visible(
_project_id uuid,
_deadline_id uuid,
_appointment_id uuid,
_project_event_id uuid
) RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = paliad, public
AS $$
SELECT CASE
WHEN _project_id IS NOT NULL THEN paliad.can_see_project(_project_id)
WHEN _deadline_id IS NOT NULL THEN paliad.can_see_project(
(SELECT project_id FROM paliad.deadlines WHERE id = _deadline_id))
WHEN _appointment_id IS NOT NULL THEN
CASE
WHEN (SELECT project_id FROM paliad.appointments WHERE id = _appointment_id) IS NULL
THEN (SELECT created_by FROM paliad.appointments WHERE id = _appointment_id) = auth.uid()
ELSE paliad.can_see_project(
(SELECT project_id FROM paliad.appointments WHERE id = _appointment_id))
END
WHEN _project_event_id IS NOT NULL THEN paliad.can_see_project(
(SELECT project_id FROM paliad.project_events WHERE id = _project_event_id))
ELSE false
END;
$$;
-- projects ------------------------------------------------------------------
CREATE POLICY projects_select ON paliad.projects
FOR SELECT TO authenticated
USING (paliad.can_see_project(id));
CREATE POLICY projects_insert ON paliad.projects
FOR INSERT TO authenticated
WITH CHECK (
parent_id IS NULL
OR paliad.can_see_project(parent_id)
);
CREATE POLICY projects_update ON paliad.projects
FOR UPDATE TO authenticated
USING (paliad.can_see_project(id))
WITH CHECK (paliad.can_see_project(id));
CREATE POLICY projects_delete ON paliad.projects
FOR DELETE TO authenticated
USING (
paliad.can_see_project(id)
AND EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.role IN ('partner','admin')
)
);
-- project_teams -------------------------------------------------------------
CREATE POLICY project_teams_select ON paliad.project_teams
FOR SELECT TO authenticated
USING (paliad.can_see_project(project_id));
CREATE POLICY project_teams_insert ON paliad.project_teams
FOR INSERT TO authenticated
WITH CHECK (
user_id = auth.uid()
OR paliad.can_see_project(project_id)
);
CREATE POLICY project_teams_update ON paliad.project_teams
FOR UPDATE TO authenticated
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
CREATE POLICY project_teams_delete ON paliad.project_teams
FOR DELETE TO authenticated
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.role IN ('partner','admin')
)
)
);
CREATE POLICY parties_all ON paliad.parties
FOR ALL TO authenticated
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
CREATE POLICY deadlines_all ON paliad.deadlines
FOR ALL TO authenticated
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
CREATE POLICY documents_all ON paliad.documents
FOR ALL TO authenticated
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
CREATE POLICY project_events_all ON paliad.project_events
FOR ALL TO authenticated
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
CREATE POLICY appointments_select ON paliad.appointments
FOR SELECT TO authenticated
USING (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
);
CREATE POLICY appointments_insert ON paliad.appointments
FOR INSERT TO authenticated
WITH CHECK (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
);
CREATE POLICY appointments_update ON paliad.appointments
FOR UPDATE TO authenticated
USING (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
)
WITH CHECK (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
);
CREATE POLICY appointments_delete ON paliad.appointments
FOR DELETE TO authenticated
USING (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
);
CREATE POLICY notes_all ON paliad.notes
FOR ALL TO authenticated
USING (paliad.note_is_visible(project_id, deadline_id, appointment_id, project_event_id))
WITH CHECK (paliad.note_is_visible(project_id, deadline_id, appointment_id, project_event_id));
CREATE POLICY checklist_instances_select ON paliad.checklist_instances
FOR SELECT TO authenticated
USING (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
);
CREATE POLICY checklist_instances_insert ON paliad.checklist_instances
FOR INSERT TO authenticated
WITH CHECK (
created_by = auth.uid()
AND (project_id IS NULL OR paliad.can_see_project(project_id))
);
CREATE POLICY checklist_instances_update ON paliad.checklist_instances
FOR UPDATE TO authenticated
USING (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
)
WITH CHECK (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
);
CREATE POLICY checklist_instances_delete ON paliad.checklist_instances
FOR DELETE TO authenticated
USING (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
);

View File

@@ -0,0 +1,300 @@
-- t-paliad-051: separate job title from global permissions.
--
-- Until now, paliad.users.role conflated three concepts: free-text job title
-- (Partner / Counsel / PA / Trainee / Sekretariat / "Counsel Knowledge
-- Lawyer"), the global-admin permission ('admin'), and — by accident —
-- a half-broken "partner" capability gate. The collision broke as soon as
-- m wanted to record his real job title without losing admin access: the
-- only way to be a global admin was to set role='admin', which by definition
-- destroyed the job-title slot. The /admin/team UI from t-paliad-050 made
-- the bug visible by hard-rejecting role='admin' on edit.
--
-- This migration splits the column:
-- * paliad.users.job_title — free text, NULL allowed, display only
-- * paliad.users.global_role — 'standard' | 'global_admin', drives every
-- permission check that used to look at
-- role='admin'
--
-- Per-project paliad.project_teams.role is unrelated and untouched.
--
-- Idempotent on a fresh DB: the ADD COLUMN IF NOT EXISTS / DROP CONSTRAINT
-- IF EXISTS guards mean re-running this on a partially-applied DB doesn't
-- explode, and the UPDATEs are no-ops if global_role is already populated.
-- ---------------------------------------------------------------------------
-- 1. Add global_role with a default so existing rows pick up 'standard'.
-- ---------------------------------------------------------------------------
ALTER TABLE paliad.users
ADD COLUMN IF NOT EXISTS global_role text NOT NULL DEFAULT 'standard';
ALTER TABLE paliad.users
DROP CONSTRAINT IF EXISTS users_global_role_check;
ALTER TABLE paliad.users
ADD CONSTRAINT users_global_role_check
CHECK (global_role IN ('standard', 'global_admin'));
-- ---------------------------------------------------------------------------
-- 2. Drop the legacy NOT NULL + DEFAULT 'associate' on role. The new model
-- allows NULL job_title (admins without a real firm role legitimately
-- store NULL), so the column-level constraint must go before the data
-- UPDATEs that NULL out admin rows; otherwise those UPDATEs would fail
-- silently under psql auto-commit.
-- ---------------------------------------------------------------------------
ALTER TABLE paliad.users
ALTER COLUMN role DROP NOT NULL;
ALTER TABLE paliad.users
ALTER COLUMN role DROP DEFAULT;
-- ---------------------------------------------------------------------------
-- 3. Promote anyone with the legacy role='admin' to global_admin, then
-- clear out their role text so it stops masquerading as a job title.
-- m gets a real job title; tester stays NULL (synthetic admin, no title).
-- ---------------------------------------------------------------------------
UPDATE paliad.users SET global_role = 'global_admin' WHERE role = 'admin';
UPDATE paliad.users SET role = NULL WHERE role = 'admin';
UPDATE paliad.users
SET role = 'Counsel Knowledge Lawyer'
WHERE email = 'matthias.siebels@hoganlovells.com';
-- ---------------------------------------------------------------------------
-- 3. Rename role -> job_title and adjust the non-empty CHECK so NULL is
-- allowed (admins without a job title legitimately store NULL now).
-- ---------------------------------------------------------------------------
ALTER TABLE paliad.users
DROP CONSTRAINT IF EXISTS users_role_check;
ALTER TABLE paliad.users
RENAME COLUMN role TO job_title;
ALTER TABLE paliad.users
ADD CONSTRAINT users_job_title_check
CHECK (job_title IS NULL OR job_title <> '');
-- ---------------------------------------------------------------------------
-- 4. Rebuild can_see_project() with the global_role predicate. CASCADE
-- drops the dependent RLS policies; we recreate them below to match
-- the canonical English set from migration 021 — with two changes:
-- * every u.role='admin' becomes u.global_role='global_admin'
-- * the historic u.role IN ('partner','admin') gate (projects_delete,
-- project_teams_delete) becomes u.global_role='global_admin' only.
-- Per m: firm roles ≠ project roles ≠ tool roles. job_title (a
-- firm role / display) must NEVER gate ops. Only tool-level
-- permissions (here: global_admin) authorise mutations. No prod
-- rows have job_title='partner', so this drops zero capability.
-- ---------------------------------------------------------------------------
DROP FUNCTION IF EXISTS paliad.note_is_visible(uuid, uuid, uuid, uuid) CASCADE;
DROP FUNCTION IF EXISTS paliad.can_see_project(uuid) CASCADE;
CREATE FUNCTION paliad.can_see_project(_project_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = paliad, public
AS $$
SELECT EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
OR EXISTS (
SELECT 1
FROM paliad.projects target
JOIN paliad.project_teams pt
ON pt.user_id = auth.uid()
AND pt.project_id = ANY(string_to_array(target.path, '.')::uuid[])
WHERE target.id = _project_id
);
$$;
COMMENT ON FUNCTION paliad.can_see_project(uuid) IS
'Team-based visibility predicate for paliad.projects. Direct or inherited '
'(ancestor) membership grants access. Global admins see all.';
CREATE FUNCTION paliad.note_is_visible(
_project_id uuid,
_deadline_id uuid,
_appointment_id uuid,
_project_event_id uuid
) RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = paliad, public
AS $$
SELECT CASE
WHEN _project_id IS NOT NULL THEN paliad.can_see_project(_project_id)
WHEN _deadline_id IS NOT NULL THEN paliad.can_see_project(
(SELECT project_id FROM paliad.deadlines WHERE id = _deadline_id))
WHEN _appointment_id IS NOT NULL THEN
CASE
WHEN (SELECT project_id FROM paliad.appointments WHERE id = _appointment_id) IS NULL
THEN (SELECT created_by FROM paliad.appointments WHERE id = _appointment_id) = auth.uid()
ELSE paliad.can_see_project(
(SELECT project_id FROM paliad.appointments WHERE id = _appointment_id))
END
WHEN _project_event_id IS NOT NULL THEN paliad.can_see_project(
(SELECT project_id FROM paliad.project_events WHERE id = _project_event_id))
ELSE false
END;
$$;
-- ---------------------------------------------------------------------------
-- 5. Rebuild every RLS policy CASCADE just dropped. Identical shape to
-- migration 021, with the two predicate swaps noted above.
-- ---------------------------------------------------------------------------
-- projects ------------------------------------------------------------------
CREATE POLICY projects_select ON paliad.projects
FOR SELECT TO authenticated
USING (paliad.can_see_project(id));
CREATE POLICY projects_insert ON paliad.projects
FOR INSERT TO authenticated
WITH CHECK (
parent_id IS NULL
OR paliad.can_see_project(parent_id)
);
CREATE POLICY projects_update ON paliad.projects
FOR UPDATE TO authenticated
USING (paliad.can_see_project(id))
WITH CHECK (paliad.can_see_project(id));
CREATE POLICY projects_delete ON paliad.projects
FOR DELETE TO authenticated
USING (
paliad.can_see_project(id)
AND EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid()
AND u.global_role = 'global_admin'
)
);
-- project_teams -------------------------------------------------------------
CREATE POLICY project_teams_select ON paliad.project_teams
FOR SELECT TO authenticated
USING (paliad.can_see_project(project_id));
CREATE POLICY project_teams_insert ON paliad.project_teams
FOR INSERT TO authenticated
WITH CHECK (
user_id = auth.uid()
OR paliad.can_see_project(project_id)
);
CREATE POLICY project_teams_update ON paliad.project_teams
FOR UPDATE TO authenticated
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
CREATE POLICY project_teams_delete ON paliad.project_teams
FOR DELETE TO authenticated
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'
)
)
);
-- parties / deadlines / documents / project_events --------------------------
CREATE POLICY parties_all ON paliad.parties
FOR ALL TO authenticated
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
CREATE POLICY deadlines_all ON paliad.deadlines
FOR ALL TO authenticated
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
CREATE POLICY documents_all ON paliad.documents
FOR ALL TO authenticated
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
CREATE POLICY project_events_all ON paliad.project_events
FOR ALL TO authenticated
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
-- appointments (project_id nullable; personal appts stay creator-only) -----
CREATE POLICY appointments_select ON paliad.appointments
FOR SELECT TO authenticated
USING (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
);
CREATE POLICY appointments_insert ON paliad.appointments
FOR INSERT TO authenticated
WITH CHECK (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
);
CREATE POLICY appointments_update ON paliad.appointments
FOR UPDATE TO authenticated
USING (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
)
WITH CHECK (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
);
CREATE POLICY appointments_delete ON paliad.appointments
FOR DELETE TO authenticated
USING (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
);
-- notes (polymorphic parent dispatch) --------------------------------------
CREATE POLICY notes_all ON paliad.notes
FOR ALL TO authenticated
USING (paliad.note_is_visible(project_id, deadline_id, appointment_id, project_event_id))
WITH CHECK (paliad.note_is_visible(project_id, deadline_id, appointment_id, project_event_id));
-- checklist_instances (project_id nullable; personal stays creator-only) ---
CREATE POLICY checklist_instances_select ON paliad.checklist_instances
FOR SELECT TO authenticated
USING (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
);
CREATE POLICY checklist_instances_insert ON paliad.checklist_instances
FOR INSERT TO authenticated
WITH CHECK (
created_by = auth.uid()
AND (project_id IS NULL OR paliad.can_see_project(project_id))
);
CREATE POLICY checklist_instances_update ON paliad.checklist_instances
FOR UPDATE TO authenticated
USING (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
)
WITH CHECK (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
);
CREATE POLICY checklist_instances_delete ON paliad.checklist_instances
FOR DELETE TO authenticated
USING (
(project_id IS NULL AND created_by = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
);

View File

@@ -70,10 +70,6 @@ func handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusConflict, map[string]string{
"error": "user already onboarded",
})
case errors.Is(err, services.ErrAdminBootstrapOnly):
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "admin role cannot be assigned via the admin UI",
})
case errors.Is(err, services.ErrInvalidInput):
// AdminCreateUser uses ErrInvalidInput for both bad-shape inputs
// and the "no auth.users row for this email" case. Surfacing the
@@ -108,9 +104,9 @@ func handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
switch {
case errors.Is(err, services.ErrUserNotOnboarded):
writeJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"})
case errors.Is(err, services.ErrAdminBootstrapOnly):
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "admin role cannot be assigned via the admin UI",
case errors.Is(err, services.ErrLastGlobalAdmin):
writeJSON(w, http.StatusConflict, map[string]string{
"error": "cannot demote the last remaining global admin",
})
case errors.Is(err, services.ErrInvalidInput):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
@@ -138,6 +134,10 @@ func handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
switch {
case errors.Is(err, services.ErrUserNotOnboarded):
writeJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"})
case errors.Is(err, services.ErrLastGlobalAdmin):
writeJSON(w, http.StatusConflict, map[string]string{
"error": "cannot delete the last remaining global admin",
})
case errors.Is(err, services.ErrInvalidInput):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
default:

View File

@@ -58,14 +58,10 @@ func handleCreateOnboarding(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusConflict, map[string]string{
"error": "onboarding already completed",
})
case errors.Is(err, services.ErrAdminBootstrapOnly):
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "admin role cannot be self-assigned — please choose another role",
})
default:
// Validation errors from the service (bad office, bad role, empty
// display_name) come through as generic errors; surface them so
// the form can show a precise message.
// Validation errors from the service (bad office, missing
// job_title, empty display_name) come through as generic errors;
// surface them so the form can show a precise message.
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return

View File

@@ -86,10 +86,6 @@ func handleUpdateMe(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "no paliad.users row — onboarding required",
})
case errors.Is(err, services.ErrAdminBootstrapOnly):
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "admin role cannot be self-assigned",
})
default:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
}

View File

@@ -23,8 +23,16 @@ type User struct {
// data model (t-paliad-024).
AdditionalOffices pq.StringArray `db:"additional_offices" json:"additional_offices"`
PracticeGroup *string `db:"practice_group" json:"practice_group,omitempty"`
Role string `db:"role" json:"role"`
Dezernat *string `db:"dezernat" json:"dezernat,omitempty"`
// JobTitle is free-text display only ("Partner", "Counsel", "PA",
// "Counsel Knowledge Lawyer", …). NULL is allowed for users who never
// picked a title — typically global admins promoted via SQL.
JobTitle *string `db:"job_title" json:"job_title"`
// GlobalRole is the global-permissions enum: 'standard' | 'global_admin'.
// Drives every permission gate that used to look at the legacy
// role='admin'. Per-project authority is on paliad.project_teams.role and
// is unrelated.
GlobalRole string `db:"global_role" json:"global_role"`
Dezernat *string `db:"dezernat" json:"dezernat,omitempty"`
Lang string `db:"lang" json:"lang"`
EmailPreferences json.RawMessage `db:"email_preferences" json:"email_preferences"`
// ReminderMorningTime / ReminderEveningTime are stored as Postgres TIME and

View File

@@ -81,14 +81,14 @@ func (s *AgendaService) List(ctx context.Context, userID uuid.UUID, f AgendaFilt
items := make([]AgendaItem, 0, 64)
if f.IncludeDeadlines {
rows, err := s.loadDeadlines(ctx, userID, user.Role, f.From, f.To)
rows, err := s.loadDeadlines(ctx, userID, user.GlobalRole, f.From, f.To)
if err != nil {
return nil, err
}
items = append(items, rows...)
}
if f.IncludeAppointments {
rows, err := s.loadAppointments(ctx, userID, user.Role, f.From, f.To)
rows, err := s.loadAppointments(ctx, userID, user.GlobalRole, f.From, f.To)
if err != nil {
return nil, err
}
@@ -132,7 +132,7 @@ SELECT f.id,
WHERE f.status = 'pending'
AND f.due_date >= $3::date
AND f.due_date < $4::date
AND ($2 = 'admin' OR EXISTS (
AND ($2 = 'global_admin' OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
@@ -198,7 +198,7 @@ SELECT t.id,
AND t.start_at < $4
AND (
(t.project_id IS NULL AND t.created_by = $1)
OR (t.project_id IS NOT NULL AND ($2 = 'admin' OR EXISTS (
OR (t.project_id IS NOT NULL AND ($2 = 'global_admin' OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])

View File

@@ -101,7 +101,7 @@ func (s *AppointmentService) ListVisibleForUser(ctx context.Context, userID uuid
conds := []string{visibility}
args := map[string]any{
"user_id": userID,
"role": user.Role,
"role": user.GlobalRole,
}
if filter.ProjectID != nil {
@@ -196,7 +196,7 @@ func (s *AppointmentService) requireMutationRole(ctx context.Context, userID uui
if user == nil {
return ErrNotVisible
}
if user.Role != "partner" && user.Role != "admin" {
if user.GlobalRole != "global_admin" {
return fmt.Errorf("%w: only partners/admins can modify Appointments on a Project", ErrForbidden)
}
return nil
@@ -448,7 +448,7 @@ func (s *AppointmentService) SummaryCounts(ctx context.Context, userID uuid.UUID
"tomorrow": tomorrow,
"endweek": endOfWeek,
"user_id": userID,
"role": user.Role,
"role": user.GlobalRole,
}); err != nil {
return nil, fmt.Errorf("appointment summary: %w", err)
}
@@ -484,12 +484,12 @@ func (s *AppointmentService) AllForUser(ctx context.Context, userID uuid.UUID) (
LEFT JOIN paliad.projects p ON p.id = t.project_id
WHERE
(t.project_id IS NULL AND t.created_by = $1)
OR (t.project_id IS NOT NULL AND ($2 = 'admin' OR EXISTS (
OR (t.project_id IS NOT NULL AND ($2 = 'global_admin' OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
)))`
if err := s.db.SelectContext(ctx, &rows, query, userID, user.Role); err != nil {
if err := s.db.SelectContext(ctx, &rows, query, userID, user.GlobalRole); err != nil {
return nil, fmt.Errorf("all appointments for user: %w", err)
}
return rows, nil

View File

@@ -75,7 +75,7 @@ func (s *ChecklistInstanceService) ListForTemplate(ctx context.Context, userID u
args := map[string]any{
"slug": slug,
"user_id": userID,
"role": user.Role,
"role": user.GlobalRole,
}
return s.listWithProjekt(ctx, query, args)
}
@@ -298,7 +298,7 @@ func (s *ChecklistInstanceService) Delete(ctx context.Context, userID, id uuid.U
if err != nil {
return err
}
if user == nil || (user.Role != "partner" && user.Role != "admin") {
if user.GlobalRole != "global_admin" {
return fmt.Errorf("%w: only the creator or a partner/admin can delete a checklist instance", ErrForbidden)
}
}

View File

@@ -43,7 +43,8 @@ type DashboardUser struct {
Email string `json:"email"`
DisplayName string `json:"display_name"`
Office string `json:"office"`
Role string `json:"role"`
JobTitle *string `json:"job_title"`
GlobalRole string `json:"global_role"`
}
type DeadlineSummary struct {
@@ -117,7 +118,8 @@ func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*Dashboar
Email: user.Email,
DisplayName: user.DisplayName,
Office: user.Office,
Role: user.Role,
JobTitle: user.JobTitle,
GlobalRole: user.GlobalRole,
}
now := time.Now()
@@ -151,7 +153,7 @@ func (s *DashboardService) loadSummary(ctx context.Context, data *DashboardData,
WITH visible_projekte AS (
SELECT p.id, p.status
FROM paliad.projects p
WHERE $2 = 'admin'
WHERE $2 = 'global_admin'
OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $1
@@ -183,7 +185,7 @@ SELECT ds.overdue, ds.this_week, ds.upcoming, ds.completed_this_week,
MatterSummary
}
err := s.db.GetContext(ctx, &row, query,
user.ID, user.Role, today, endOfWeek, sevenDaysAgo)
user.ID, user.GlobalRole, today, endOfWeek, sevenDaysAgo)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
@@ -208,7 +210,7 @@ SELECT f.id,
WHERE f.status = 'pending'
AND f.due_date >= $3::date
AND f.due_date <= $4::date
AND ($2 = 'admin' OR EXISTS (
AND ($2 = 'global_admin' OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
@@ -216,7 +218,7 @@ SELECT f.id,
ORDER BY f.due_date ASC
LIMIT 10`
if err := s.db.SelectContext(ctx, &data.UpcomingDeadlines, query,
user.ID, user.Role, today, endOfWeek); err != nil {
user.ID, user.GlobalRole, today, endOfWeek); err != nil {
return fmt.Errorf("dashboard upcoming deadlines: %w", err)
}
return nil
@@ -238,7 +240,7 @@ SELECT t.id,
AND t.start_at < ($3 + interval '7 days')
AND (
(t.project_id IS NULL AND t.created_by = $1)
OR (t.project_id IS NOT NULL AND ($2 = 'admin' OR EXISTS (
OR (t.project_id IS NOT NULL AND ($2 = 'global_admin' OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
@@ -247,7 +249,7 @@ SELECT t.id,
ORDER BY t.start_at ASC
LIMIT 10`
if err := s.db.SelectContext(ctx, &data.UpcomingAppointments, query,
user.ID, user.Role, now); err != nil {
user.ID, user.GlobalRole, now); err != nil {
return fmt.Errorf("dashboard upcoming appointments: %w", err)
}
return nil
@@ -267,7 +269,7 @@ SELECT COALESCE(e.event_date, e.created_at) AS timestamp,
FROM paliad.project_events e
JOIN paliad.projects p ON p.id = e.project_id
LEFT JOIN paliad.users u ON u.id = e.created_by
WHERE $2 = 'admin'
WHERE $2 = 'global_admin'
OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $1
@@ -276,7 +278,7 @@ SELECT COALESCE(e.event_date, e.created_at) AS timestamp,
ORDER BY COALESCE(e.event_date, e.created_at) DESC
LIMIT 10`
if err := s.db.SelectContext(ctx, &data.RecentActivity, query,
user.ID, user.Role); err != nil {
user.ID, user.GlobalRole); err != nil {
return fmt.Errorf("dashboard recent activity: %w", err)
}
return nil

View File

@@ -86,7 +86,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
conds := []string{visibilityPredicate("p")}
args := map[string]any{
"user_id": userID,
"role": user.Role,
"role": user.GlobalRole,
}
if filter.ProjectID != nil {
conds = append(conds, `f.project_id = :project_id`)
@@ -402,7 +402,7 @@ func (s *DeadlineService) assertCanAdminProject(ctx context.Context, userID, pro
if user == nil {
return ErrNotVisible
}
if user.Role == "admin" {
if user.GlobalRole == "global_admin" {
return nil
}
var ok bool
@@ -434,7 +434,7 @@ func (s *DeadlineService) Delete(ctx context.Context, userID, fristID uuid.UUID)
if user == nil {
return ErrNotVisible
}
if user.Role != "partner" && user.Role != "admin" {
if user.GlobalRole != "global_admin" {
return fmt.Errorf("%w: only partners/admins can delete Deadlines", ErrForbidden)
}
current, err := s.GetByID(ctx, userID, fristID)
@@ -488,7 +488,7 @@ func (s *DeadlineService) SummaryCounts(ctx context.Context, userID uuid.UUID, p
conds := []string{visibilityPredicate("p")}
args := map[string]any{
"user_id": userID,
"role": user.Role,
"role": user.GlobalRole,
"today": today,
"tomorrow": tomorrow,
"endweek": endWeek,

View File

@@ -183,7 +183,7 @@ type DepartmentMember struct {
Email string `db:"email" json:"email"`
DisplayName string `db:"display_name" json:"display_name"`
Office string `db:"office" json:"office"`
Role string `db:"role" json:"role"`
JobTitle *string `db:"job_title" json:"job_title"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
@@ -198,7 +198,7 @@ func (s *DepartmentService) ListMembers(ctx context.Context, departmentID uuid.U
var rows []DepartmentMember
err := s.db.SelectContext(ctx, &rows,
`SELECT dm.user_id, dm.created_at,
u.email, u.display_name, u.office, u.role
u.email, u.display_name, u.office, u.job_title
FROM paliad.department_members dm
JOIN paliad.users u ON u.id = dm.user_id
WHERE dm.department_id = $1
@@ -250,7 +250,7 @@ func (s *DepartmentService) ListWithMembers(ctx context.Context) ([]DepartmentWi
// (auth row present, paliad.users row missing) must be excluded.
err = s.db.SelectContext(ctx, &members,
`SELECT dm.department_id, dm.user_id, dm.created_at,
u.email, u.display_name, u.office, u.role
u.email, u.display_name, u.office, u.job_title
FROM paliad.department_members dm
JOIN paliad.users u ON u.id = dm.user_id
ORDER BY u.display_name`)
@@ -301,8 +301,8 @@ func (s *DepartmentService) requireAdmin(ctx context.Context, userID uuid.UUID)
if err != nil {
return err
}
if u == nil || u.Role != "admin" {
return fmt.Errorf("%w: admin required", ErrForbidden)
if u == nil || u.GlobalRole != "global_admin" {
return fmt.Errorf("%w: global admin required", ErrForbidden)
}
return nil
}

View File

@@ -192,7 +192,7 @@ func (s *NoteService) Delete(ctx context.Context, userID, id uuid.UUID) error {
if err != nil {
return err
}
if user == nil || (user.Role != "partner" && user.Role != "admin") {
if user.GlobalRole != "global_admin" {
return fmt.Errorf("%w: only the author or a partner/admin can delete a Note", ErrForbidden)
}
}

View File

@@ -97,7 +97,7 @@ func (s *PartyService) Delete(ctx context.Context, userID, parteiID uuid.UUID) e
if user == nil {
return ErrNotVisible
}
if user.Role != "partner" && user.Role != "admin" {
if user.GlobalRole != "global_admin" {
return fmt.Errorf("%w: only partners/admins can delete Parties", ErrForbidden)
}

View File

@@ -159,7 +159,7 @@ func (s *ProjectService) List(ctx context.Context, userID uuid.UUID, f ProjectFi
conds := []string{visibilityPredicate("p")}
args := map[string]any{
"user_id": userID,
"role": user.Role,
"role": user.GlobalRole,
}
if f.Type != "" {
@@ -212,7 +212,7 @@ func (s *ProjectService) GetByID(ctx context.Context, userID, id uuid.UUID) (*mo
var p models.Project
query := `SELECT ` + projektColumns + ` FROM paliad.projects p
WHERE p.id = $1 AND ` + visibilityPredicatePositional("p", 2, 3)
err = s.db.GetContext(ctx, &p, query, id, userID, user.Role)
err = s.db.GetContext(ctx, &p, query, id, userID, user.GlobalRole)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible
}
@@ -270,7 +270,7 @@ func (s *ProjectService) ListAncestors(ctx context.Context, userID, id uuid.UUID
idStrs[i] = u.String()
}
rows := []models.Project{}
if err := s.db.SelectContext(ctx, &rows, query, pq.StringArray(idStrs), userID, user.Role); err != nil {
if err := s.db.SelectContext(ctx, &rows, query, pq.StringArray(idStrs), userID, user.GlobalRole); err != nil {
return nil, fmt.Errorf("list ancestors: %w", err)
}
// Re-order to match path order (root first).
@@ -310,7 +310,7 @@ func (s *ProjectService) BuildTree(ctx context.Context, userID uuid.UUID) ([]*Pr
WHERE ` + visibilityPredicatePositional("p", 1, 2) + `
ORDER BY p.path`
rows := []models.Project{}
if err := s.db.SelectContext(ctx, &rows, query, userID, user.Role); err != nil {
if err := s.db.SelectContext(ctx, &rows, query, userID, user.GlobalRole); err != nil {
return nil, fmt.Errorf("build tree list: %w", err)
}
@@ -329,7 +329,7 @@ func (s *ProjectService) BuildTree(ctx context.Context, userID uuid.UUID) ([]*Pr
FROM paliad.deadlines f
JOIN paliad.projects p ON p.id = f.project_id
WHERE `+visibilityPredicatePositional("p", 1, 2)+`
GROUP BY f.project_id`, userID, user.Role, today); err != nil {
GROUP BY f.project_id`, userID, user.GlobalRole, today); err != nil {
return nil, fmt.Errorf("build tree deadline counts: %w", err)
}
countByID := make(map[uuid.UUID]deadlineCount, len(counts))
@@ -402,7 +402,7 @@ func (s *ProjectService) GetTree(ctx context.Context, userID, id uuid.UUID) ([]m
AND ` + visibilityPredicatePositional("p", 3, 4) + `
ORDER BY p.path`
rows := []models.Project{}
if err := s.db.SelectContext(ctx, &rows, query, root.Path, prefix, userID, user.Role); err != nil {
if err := s.db.SelectContext(ctx, &rows, query, root.Path, prefix, userID, user.GlobalRole); err != nil {
return nil, fmt.Errorf("get tree: %w", err)
}
return rows, nil
@@ -614,7 +614,7 @@ func (s *ProjectService) Delete(ctx context.Context, userID, id uuid.UUID) error
if user == nil {
return ErrNotVisible
}
if user.Role != "partner" && user.Role != "admin" {
if user.GlobalRole != "global_admin" {
return fmt.Errorf("%w: only partners/admins can archive Projects", ErrForbidden)
}
if _, err := s.GetByID(ctx, userID, id); err != nil {
@@ -722,7 +722,7 @@ func (s *ProjectService) ResolveClientNumber(ctx context.Context, userID, id uui
// `:user_id`/`:role` placeholders, producing invalid SQL `:uuid[]` that
// Postgres rejects with `syntax error at or near ":"`.
func visibilityPredicate(alias string) string {
return `(:role = 'admin' OR EXISTS (
return `(:role = 'global_admin' OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = :user_id
AND pt.project_id = ANY(CAST(string_to_array(` + alias + `.path, '.') AS uuid[]))
@@ -744,7 +744,7 @@ func visibilityPredicatePositional(alias string, userArg, roleArg int) string {
// (for sqlx.In-compatible queries). The caller appends (userID, role) to
// args in that order.
func visibilityPredicatePlaceholder(alias string) string {
return `(? = 'admin' OR EXISTS (
return `(? = 'global_admin' OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = ?
AND pt.project_id = ANY(string_to_array(` + alias + `.path, '.')::uuid[])

View File

@@ -38,10 +38,16 @@ var (
// ErrUserAlreadyOnboarded is returned when POST /api/onboarding is called
// for a paliad.users row that already exists (409 Conflict on the wire).
ErrUserAlreadyOnboarded = errors.New("user already onboarded")
// ErrAdminBootstrapOnly signals an attempt to self-assign the 'admin' role
// when other paliad.users rows already exist. Only the very first user can
// bootstrap themselves as admin (403 Forbidden on the wire).
ErrAdminBootstrapOnly = errors.New("admin role reserved for the first user")
// ErrLastGlobalAdmin guards demoting / deleting the last global_admin so
// the firm can't lock itself out of its own admin UI.
ErrLastGlobalAdmin = errors.New("cannot remove the last remaining global admin")
// ErrGlobalAdminAssignment signals a non-global-admin trying to write
// global_role through a path that doesn't permit it (e.g. /api/onboarding,
// /api/me, the create form on /admin/team). Promotion to global_admin is
// only legal via PATCH /api/admin/users/{id} from an existing global_admin
// — and the bootstrap path, where the first paliad.users row may flip
// itself.
ErrGlobalAdminAssignment = errors.New("global_admin must be granted by an existing global admin")
// ErrUserNotOnboarded is returned when an endpoint that requires an
// existing paliad.users row is called by a user who hasn't onboarded yet
// (404 Not Found on the wire — callers should redirect to /onboarding).
@@ -59,7 +65,8 @@ func NewUserService(db *sqlx.DB) *UserService {
return &UserService{db: db}
}
const userColumns = `id, email, display_name, office, additional_offices, practice_group, role, dezernat,
const userColumns = `id, email, display_name, office, additional_offices, practice_group,
job_title, global_role, dezernat,
lang, email_preferences,
reminder_morning_time::text AS reminder_morning_time,
reminder_evening_time::text AS reminder_evening_time,
@@ -85,7 +92,7 @@ func (s *UserService) GetByID(ctx context.Context, id uuid.UUID) (*models.User,
type CreateUserInput struct {
DisplayName string `json:"display_name"`
Office string `json:"office"`
Role string `json:"role"`
JobTitle string `json:"job_title"`
Dezernat *string `json:"dezernat,omitempty"`
}
@@ -94,11 +101,14 @@ type CreateUserInput struct {
// from the request body, which prevents a user from creating a row for a
// different auth.uid().
//
// Role is free-form text (German firms have many titles beyond the original
// four-value enum). The DB CHECK only requires non-empty. The one exception
// is 'admin', which is reserved: only allowed when the paliad.users table is
// empty (bootstrap admin). Subsequent users who ask for 'admin' are rejected
// — an existing admin must promote them via SQL / future admin UI.
// JobTitle is free-form text (Partner / Counsel / PA / Trainee / Sekretariat /
// "Counsel Knowledge Lawyer" / …). The DB CHECK only requires non-empty.
//
// global_role is decided server-side: every new row defaults to 'standard',
// EXCEPT the bootstrap path — when paliad.users is otherwise empty, the
// inserter is promoted to 'global_admin' so the firm has at least one admin.
// The pg_advisory_xact_lock serialises concurrent first-logins so only one
// can win the bootstrap; the lock auto-releases on commit/rollback.
//
// Returns ErrUserAlreadyOnboarded if the row exists (callers map to 409).
func (s *UserService) Create(ctx context.Context, id uuid.UUID, email string, input CreateUserInput) (*models.User, error) {
@@ -109,9 +119,9 @@ func (s *UserService) Create(ctx context.Context, id uuid.UUID, email string, in
if !offices.IsValid(input.Office) {
return nil, fmt.Errorf("invalid office %q", input.Office)
}
role := strings.TrimSpace(input.Role)
if role == "" {
return nil, fmt.Errorf("role is required")
jobTitle := strings.TrimSpace(input.JobTitle)
if jobTitle == "" {
return nil, fmt.Errorf("job_title is required")
}
var dezernat *string
@@ -139,35 +149,34 @@ func (s *UserService) Create(ctx context.Context, id uuid.UUID, email string, in
return nil, ErrUserAlreadyOnboarded
}
// Admin bootstrap gate: only allow 'admin' when no other users exist yet.
// Under Postgres' default READ COMMITTED isolation, two concurrent
// first-logins both asking for 'admin' could both see count=0 and both
// succeed. A transaction-scoped advisory lock serialises the check+insert
// so only one bootstrap can win; the lock is auto-released on commit or
// rollback. The constant is arbitrary but stable — every admin-bootstrap
// tx takes the same lock.
if role == "admin" {
if _, err := tx.ExecContext(ctx,
`SELECT pg_advisory_xact_lock(7346298141)`); err != nil {
return nil, fmt.Errorf("lock for admin bootstrap: %w", err)
}
var count int
if err := tx.GetContext(ctx, &count,
`SELECT count(*) FROM paliad.users`); err != nil {
return nil, fmt.Errorf("count users: %w", err)
}
if count > 0 {
return nil, ErrAdminBootstrapOnly
}
// Bootstrap gate: the very first paliad.users row is promoted to
// global_admin so the firm has at least one admin from day one. Under
// Postgres' default READ COMMITTED isolation two concurrent first-logins
// could both see count=0; the advisory lock serialises the check + insert
// so only one bootstrap can win. The lock auto-releases on commit/rollback.
// The constant is arbitrary but stable — every bootstrap tx takes the
// same lock.
if _, err := tx.ExecContext(ctx,
`SELECT pg_advisory_xact_lock(7346298141)`); err != nil {
return nil, fmt.Errorf("lock for bootstrap: %w", err)
}
var existingCount int
if err := tx.GetContext(ctx, &existingCount,
`SELECT count(*) FROM paliad.users`); err != nil {
return nil, fmt.Errorf("count users: %w", err)
}
globalRole := "standard"
if existingCount == 0 {
globalRole = "global_admin"
}
// practice_group is intentionally left NULL — the column is retained for
// future use but no longer collected at onboarding (m, 2026-04-18: every
// Paliad user is in patent practice, so the field carried no signal).
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, role, dezernat)
VALUES ($1, $2, $3, $4, $5, $6)`,
id, email, displayName, input.Office, role, dezernat,
`INSERT INTO paliad.users (id, email, display_name, office, job_title, global_role, dezernat)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
id, email, displayName, input.Office, jobTitle, globalRole, dezernat,
); err != nil {
return nil, fmt.Errorf("insert user: %w", err)
}
@@ -183,11 +192,13 @@ func (s *UserService) Create(ctx context.Context, id uuid.UUID, email string, in
// pointer so callers can omit keys they don't want to touch — the settings
// page sends only the fields the user changed. Email is deliberately absent:
// auth.users.email is the source of truth and the handler rejects any attempt
// to mutate it via this endpoint.
// to mutate it via this endpoint. global_role is also deliberately absent:
// promotion/demotion is a privileged operation that goes through
// PATCH /api/admin/users/{id}, never through self-service settings.
type UpdateProfileInput struct {
DisplayName *string `json:"display_name,omitempty"`
Office *string `json:"office,omitempty"`
Role *string `json:"role,omitempty"`
JobTitle *string `json:"job_title,omitempty"`
Dezernat *string `json:"dezernat,omitempty"`
Lang *string `json:"lang,omitempty"`
EmailPreferences *json.RawMessage `json:"email_preferences,omitempty"`
@@ -199,9 +210,7 @@ type UpdateProfileInput struct {
// UpdateProfile mutates the paliad.users row for the authenticated user.
// Returns the fresh row.
//
// The 'admin' role is never assignable via this endpoint — role changes
// downgrading an admin, or promoting a non-admin to admin, must go through
// SQL / a future admin UI (mirrors the onboarding restriction).
// global_role is intentionally NOT writable here — see UpdateProfileInput.
func (s *UserService) UpdateProfile(ctx context.Context, id uuid.UUID, input UpdateProfileInput) (*models.User, error) {
sets := []string{}
args := []any{}
@@ -224,16 +233,13 @@ func (s *UserService) UpdateProfile(ctx context.Context, id uuid.UUID, input Upd
args = append(args, *input.Office)
i++
}
if input.Role != nil {
role := strings.TrimSpace(*input.Role)
if role == "" {
return nil, fmt.Errorf("role cannot be empty")
if input.JobTitle != nil {
jt := strings.TrimSpace(*input.JobTitle)
if jt == "" {
return nil, fmt.Errorf("job_title cannot be empty")
}
if role == "admin" {
return nil, ErrAdminBootstrapOnly
}
sets = append(sets, fmt.Sprintf("role = $%d", i))
args = append(args, role)
sets = append(sets, fmt.Sprintf("job_title = $%d", i))
args = append(args, jt)
i++
}
if input.Dezernat != nil {
@@ -334,16 +340,16 @@ func (s *UserService) List(ctx context.Context) ([]models.User, error) {
return users, nil
}
// IsAdmin reports whether the given user is an admin. Implements
// auth.AdminLookup so the requireAdmin middleware can stay in package auth
// without importing services. Returns (false, nil) for an unknown / unonboarded
// user — which is what we want: a missing paliad.users row is not an admin.
// IsAdmin reports whether the given user has the global_admin permission.
// Implements auth.AdminLookup so the requireAdmin middleware can stay in
// package auth without importing services. Returns (false, nil) for an
// unknown / unonboarded user — a missing paliad.users row is not an admin.
func (s *UserService) IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) {
u, err := s.GetByID(ctx, id)
if err != nil {
return false, err
}
return u != nil && u.Role == "admin", nil
return u != nil && u.GlobalRole == "global_admin", nil
}
// AdminCreateInput is the payload an admin uses to onboard a colleague who
@@ -353,7 +359,7 @@ type AdminCreateInput struct {
Email string `json:"email"`
DisplayName string `json:"display_name"`
Office string `json:"office"`
Role string `json:"role,omitempty"` // defaults to 'associate'
JobTitle string `json:"job_title,omitempty"` // defaults to 'Associate'
Dezernat *string `json:"dezernat,omitempty"`
Lang string `json:"lang,omitempty"` // defaults to 'de'
}
@@ -366,9 +372,8 @@ type AdminCreateInput struct {
// the given email's auth.users id. Returns a wrapped ErrInvalidInput when the
// email isn't in auth.users at all (so the handler can map to 404).
//
// 'admin' is rejected here for the same reason it's rejected in UpdateProfile:
// promotions to admin must go through SQL, not the admin UI. This keeps the
// blast radius of an admin's leaked session contained.
// global_role is always 'standard' on this path. Promotion to global_admin
// is a separate AdminUpdateUser call so it can't be smuggled into create.
func (s *UserService) AdminCreateUser(ctx context.Context, input AdminCreateInput) (*models.User, error) {
email := strings.ToLower(strings.TrimSpace(input.Email))
if email == "" {
@@ -381,12 +386,9 @@ func (s *UserService) AdminCreateUser(ctx context.Context, input AdminCreateInpu
if !offices.IsValid(input.Office) {
return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, input.Office)
}
role := strings.TrimSpace(input.Role)
if role == "" {
role = "associate"
}
if role == "admin" {
return nil, ErrAdminBootstrapOnly
jobTitle := strings.TrimSpace(input.JobTitle)
if jobTitle == "" {
jobTitle = "Associate"
}
lang := strings.ToLower(strings.TrimSpace(input.Lang))
if lang == "" {
@@ -433,9 +435,9 @@ func (s *UserService) AdminCreateUser(ctx context.Context, input AdminCreateInpu
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, role, dezernat, lang)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
authID, email, displayName, input.Office, role, dezernat, lang,
`INSERT INTO paliad.users (id, email, display_name, office, job_title, global_role, dezernat, lang)
VALUES ($1, $2, $3, $4, $5, 'standard', $6, $7)`,
authID, email, displayName, input.Office, jobTitle, dezernat, lang,
); err != nil {
return nil, fmt.Errorf("insert user: %w", err)
}
@@ -452,7 +454,8 @@ func (s *UserService) AdminCreateUser(ctx context.Context, input AdminCreateInpu
type AdminUpdateInput struct {
DisplayName *string `json:"display_name,omitempty"`
Office *string `json:"office,omitempty"`
Role *string `json:"role,omitempty"`
JobTitle *string `json:"job_title,omitempty"`
GlobalRole *string `json:"global_role,omitempty"`
Dezernat *string `json:"dezernat,omitempty"`
AdditionalOffices *[]string `json:"additional_offices,omitempty"`
Lang *string `json:"lang,omitempty"`
@@ -463,13 +466,18 @@ type AdminUpdateInput struct {
}
// AdminUpdateUser mutates any paliad.users row. Same validation rules as
// UpdateProfile (no role='admin' assignment, valid office/lang/timezone).
// Returns ErrUserNotOnboarded when the target row is missing.
// UpdateProfile, plus: AdminUpdate may write additional_offices and
// global_role (the privileged fields that self-service must not touch).
// Returns ErrUserNotOnboarded when the target row is missing. Returns
// ErrLastGlobalAdmin when the call would demote the last global_admin.
//
// Why a separate method instead of routing through UpdateProfile: the self
// service path is intentionally narrow (the user touches their own row), and
// AdminUpdate also writes additional_offices, which we do not want exposed on
// PATCH /api/me.
// Note: this method assumes the caller already passed
// auth.RequireAdmin — the handler enforces "only existing global admins
// may call this endpoint". The last-admin guard runs unconditionally here
// regardless, as a belt-and-braces safety net.
//
// JobTitle of "" (empty after trim) clears job_title to NULL — admins
// without a real job title legitimately store NULL.
func (s *UserService) AdminUpdateUser(ctx context.Context, id uuid.UUID, input AdminUpdateInput) (*models.User, error) {
sets := []string{}
args := []any{}
@@ -492,16 +500,48 @@ func (s *UserService) AdminUpdateUser(ctx context.Context, id uuid.UUID, input A
args = append(args, *input.Office)
i++
}
if input.Role != nil {
role := strings.TrimSpace(*input.Role)
if role == "" {
return nil, fmt.Errorf("%w: role cannot be empty", ErrInvalidInput)
if input.JobTitle != nil {
jt := strings.TrimSpace(*input.JobTitle)
var val any
if jt == "" {
val = nil
} else {
val = jt
}
if role == "admin" {
return nil, ErrAdminBootstrapOnly
sets = append(sets, fmt.Sprintf("job_title = $%d", i))
args = append(args, val)
i++
}
if input.GlobalRole != nil {
gr := strings.TrimSpace(*input.GlobalRole)
if gr != "standard" && gr != "global_admin" {
return nil, fmt.Errorf("%w: invalid global_role %q (expected 'standard' or 'global_admin')", ErrInvalidInput, gr)
}
sets = append(sets, fmt.Sprintf("role = $%d", i))
args = append(args, role)
// Last-admin guard: refuse to demote the only remaining global_admin
// so the firm can't lock itself out of /admin/team.
if gr == "standard" {
var current string
if err := s.db.GetContext(ctx, &current,
`SELECT global_role FROM paliad.users WHERE id = $1`, id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotOnboarded
}
return nil, fmt.Errorf("lookup global_role: %w", err)
}
if current == "global_admin" {
var others int
if err := s.db.GetContext(ctx, &others,
`SELECT count(*) FROM paliad.users
WHERE global_role = 'global_admin' AND id <> $1`, id); err != nil {
return nil, fmt.Errorf("count admins: %w", err)
}
if others == 0 {
return nil, ErrLastGlobalAdmin
}
}
}
sets = append(sets, fmt.Sprintf("global_role = $%d", i))
args = append(args, gr)
i++
}
if input.Dezernat != nil {
@@ -627,22 +667,22 @@ func (s *UserService) AdminDeleteUser(ctx context.Context, id uuid.UUID) error {
}
defer tx.Rollback()
var role string
if err := tx.GetContext(ctx, &role,
`SELECT role FROM paliad.users WHERE id = $1`, id); err != nil {
var globalRole string
if err := tx.GetContext(ctx, &globalRole,
`SELECT global_role FROM paliad.users WHERE id = $1`, id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrUserNotOnboarded
}
return fmt.Errorf("lookup user: %w", err)
}
if role == "admin" {
if globalRole == "global_admin" {
var others int
if err := tx.GetContext(ctx, &others,
`SELECT count(*) FROM paliad.users WHERE role = 'admin' AND id <> $1`, id); err != nil {
`SELECT count(*) FROM paliad.users WHERE global_role = 'global_admin' AND id <> $1`, id); err != nil {
return fmt.Errorf("count admins: %w", err)
}
if others == 0 {
return fmt.Errorf("%w: cannot delete the last remaining admin", ErrInvalidInput)
return ErrLastGlobalAdmin
}
}

View File

@@ -56,18 +56,20 @@ func TestUserService_Create_Valid(t *testing.T) {
users, pool, done := setupUserTest(t)
defer done()
// Ensure the table is empty so the bootstrap-admin gate is deterministic.
// Ensure the table is empty so the bootstrap path is deterministic. The
// first inserter becomes global_admin; this test just exercises the rest
// of the Create shape and isn't asserting on global_role.
pool.ExecContext(context.Background(), `DELETE FROM paliad.users`)
id := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000001")
seedAuthUser(t, pool, id, "first@hlc.com")
defer cleanupUsers(t, pool, id)
dezernat := " Team M\u00fcller "
dezernat := " Team Müller "
u, err := users.Create(context.Background(), id, "first@hlc.com", CreateUserInput{
DisplayName: " First User ",
Office: "munich",
Role: "Trainee",
JobTitle: "Trainee",
Dezernat: &dezernat,
})
if err != nil {
@@ -79,10 +81,10 @@ func TestUserService_Create_Valid(t *testing.T) {
if u.DisplayName != "First User" {
t.Errorf("display_name not trimmed: %q", u.DisplayName)
}
if u.Office != "munich" || u.Role != "Trainee" || u.Email != "first@hlc.com" {
if u.Office != "munich" || u.JobTitle == nil || *u.JobTitle != "Trainee" || u.Email != "first@hlc.com" {
t.Errorf("field mismatch: %+v", u)
}
if u.Dezernat == nil || *u.Dezernat != "Team M\u00fcller" {
if u.Dezernat == nil || *u.Dezernat != "Team Müller" {
t.Errorf("dezernat not trimmed/persisted: %+v", u.Dezernat)
}
}
@@ -99,9 +101,9 @@ func TestUserService_Create_InvalidInput(t *testing.T) {
name string
input CreateUserInput
}{
{"missing display_name", CreateUserInput{Office: "munich", Role: "associate"}},
{"invalid office", CreateUserInput{DisplayName: "X", Office: "tokyo", Role: "associate"}},
{"missing role", CreateUserInput{DisplayName: "X", Office: "munich", Role: " "}},
{"missing display_name", CreateUserInput{Office: "munich", JobTitle: "associate"}},
{"invalid office", CreateUserInput{DisplayName: "X", Office: "tokyo", JobTitle: "associate"}},
{"missing job_title", CreateUserInput{DisplayName: "X", Office: "munich", JobTitle: " "}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -121,7 +123,7 @@ func TestUserService_Create_DuplicateReturns409(t *testing.T) {
defer cleanupUsers(t, pool, id)
ctx := context.Background()
in := CreateUserInput{DisplayName: "Dup", Office: "munich", Role: "associate"}
in := CreateUserInput{DisplayName: "Dup", Office: "munich", JobTitle: "associate"}
if _, err := users.Create(ctx, id, "dup@hlc.com", in); err != nil {
t.Fatalf("first create: %v", err)
}
@@ -131,12 +133,14 @@ func TestUserService_Create_DuplicateReturns409(t *testing.T) {
}
}
func TestUserService_Create_AdminBootstrapRespected(t *testing.T) {
// TestUserService_Create_BootstrapPromotesFirstUser asserts the bootstrap
// rule: when paliad.users is empty, the first inserter is silently promoted
// to global_admin. Subsequent inserters default to standard.
func TestUserService_Create_BootstrapPromotesFirstUser(t *testing.T) {
users, pool, done := setupUserTest(t)
defer done()
ctx := context.Background()
// Start with an empty table so the first admin is the bootstrap admin.
pool.ExecContext(ctx, `DELETE FROM paliad.users`)
first := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000011")
@@ -145,23 +149,75 @@ func TestUserService_Create_AdminBootstrapRespected(t *testing.T) {
seedAuthUser(t, pool, second, "second@hlc.com")
defer cleanupUsers(t, pool, first, second)
if _, err := users.Create(ctx, first, "first@hlc.com", CreateUserInput{
DisplayName: "First", Office: "munich", Role: "admin",
}); err != nil {
t.Fatalf("bootstrap admin: %v", err)
}
_, err := users.Create(ctx, second, "second@hlc.com", CreateUserInput{
DisplayName: "Second", Office: "munich", Role: "admin",
u1, err := users.Create(ctx, first, "first@hlc.com", CreateUserInput{
DisplayName: "First", Office: "munich", JobTitle: "Partner",
})
if !errors.Is(err, ErrAdminBootstrapOnly) {
t.Fatalf("expected ErrAdminBootstrapOnly, got %v", err)
if err != nil {
t.Fatalf("bootstrap user: %v", err)
}
if u1.GlobalRole != "global_admin" {
t.Fatalf("first user should be global_admin, got %q", u1.GlobalRole)
}
// Non-admin role still works for the second user.
if _, err := users.Create(ctx, second, "second@hlc.com", CreateUserInput{
DisplayName: "Second", Office: "munich", Role: "associate",
}); err != nil {
t.Fatalf("second user as associate: %v", err)
u2, err := users.Create(ctx, second, "second@hlc.com", CreateUserInput{
DisplayName: "Second", Office: "munich", JobTitle: "Associate",
})
if err != nil {
t.Fatalf("second user: %v", err)
}
if u2.GlobalRole != "standard" {
t.Fatalf("second user should be standard, got %q", u2.GlobalRole)
}
}
// TestUserService_AdminUpdateUser_LastGlobalAdmin asserts the demotion guard.
func TestUserService_AdminUpdateUser_LastGlobalAdmin(t *testing.T) {
users, pool, done := setupUserTest(t)
defer done()
ctx := context.Background()
pool.ExecContext(ctx, `DELETE FROM paliad.users`)
first := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000021")
seedAuthUser(t, pool, first, "lonely@hlc.com")
defer cleanupUsers(t, pool, first)
if _, err := users.Create(ctx, first, "lonely@hlc.com", CreateUserInput{
DisplayName: "Lonely", Office: "munich", JobTitle: "Counsel",
}); err != nil {
t.Fatalf("bootstrap: %v", err)
}
standard := "standard"
_, err := users.AdminUpdateUser(ctx, first, AdminUpdateInput{GlobalRole: &standard})
if !errors.Is(err, ErrLastGlobalAdmin) {
t.Fatalf("expected ErrLastGlobalAdmin, got %v", err)
}
}
// TestUserService_IsAdmin asserts IsAdmin reads the global_role column.
func TestUserService_IsAdmin(t *testing.T) {
users, pool, done := setupUserTest(t)
defer done()
ctx := context.Background()
pool.ExecContext(ctx, `DELETE FROM paliad.users`)
id := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000031")
seedAuthUser(t, pool, id, "first@hlc.com")
defer cleanupUsers(t, pool, id)
if _, err := users.Create(ctx, id, "first@hlc.com", CreateUserInput{
DisplayName: "First", Office: "munich", JobTitle: "Counsel",
}); err != nil {
t.Fatalf("create: %v", err)
}
got, err := users.IsAdmin(ctx, id)
if err != nil {
t.Fatalf("IsAdmin: %v", err)
}
if !got {
t.Fatalf("bootstrap user should pass IsAdmin")
}
}