# Design: Separate Job Title from Global Permissions **Status:** Draft for review (m + head) **Author:** cronus (mai inventor) **Date:** 2026-04-27 **Task:** t-paliad-051 ## Problem Three orthogonal concepts share one column today: 1. **Job title** — Partner / Counsel / Associate / PA / Trainee / Sekretariat / "Counsel Knowledge Lawyer" / … . Free-text since migration 015. Display only. 2. **Global permissions** — currently "is the user a global admin?" piggy-backed on the same column with `paliad.users.role = 'admin'` checks across Go, SQL, and JS. 3. **Per-project role** — `paliad.project_teams.role` ∈ {lead, associate, pa, of_counsel, local_counsel, expert, observer, admin}. Already separated, fine, **out of scope**. The collision bites whenever someone tries to record their real job title without losing admin access. Concrete trigger: m's job title is "Counsel Knowledge Lawyer". If he sets that as his `role`, he loses every `role='admin'` gate in the codebase. Today the admin-team page (t-paliad-050) actually hard-rejects setting `role='admin'` from the UI (`AdminUpdateUser` raises `ErrAdminBootstrapOnly`), so any UI-driven edit of m's row would silently demote him. Live state confirmed 2026-04-27 14:25 against `100.99.98.201:11833`: | email | role | display_name | |---|---|---| | matthias.siebels@hoganlovells.com | admin | Matthias | | tester@hlc.de | admin | Test Tester | | 29 stub colleagues | associate | … | So: 31 rows total, 2 admins, 29 associates. No `partner` rows in production today even though the gate exists in code. ## Goal Split `paliad.users.role` into: - **`paliad.users.job_title`** — free text, display only. Replaces today's `role`. - **`paliad.users.global_role`** — enum-via-CHECK, currently `'standard' | 'global_admin'`. New column, drives every `role='admin'`-style permission check. Per-project `paliad.project_teams.role` is untouched. After the change m can carry `job_title='Counsel Knowledge Lawyer'` AND `global_role='global_admin'` simultaneously. ## Decisions ### 1. Naming - Rename `paliad.users.role` → `paliad.users.job_title`. - Add `paliad.users.global_role text NOT NULL DEFAULT 'standard'` with `CHECK (global_role IN ('standard','global_admin'))`. ### 2. Why enum-via-CHECK over a `permissions text[]` | | text-with-CHECK | text[] permissions | |---|---|---| | New permission | edit one CHECK constraint | none | | Code surface | `u.global_role = 'global_admin'` | `'global_admin' = ANY(u.permissions)` | | Multi-grant | impossible by construction | natural | | Validation | DB-level | service-level only | | Today's needs (1 permission) | trivial | over-engineered | We have one permission today and m's brief says "possibly more later, design with that in mind". An enum keeps every call site short and DB-validated; growing the CHECK to add `billing_admin` is a one-line migration and `IN (...)` checks compose fine. If we ever genuinely need to grant 2+ permissions to one user, swap the column type to `text[]` in a future migration — call sites change from `=` to `ANY(...)`, mechanical. Nothing about today's choice forecloses that. **Lean: enum-via-CHECK.** Matches m's stated lean. Ships smaller. Easy to widen later. ### 3. Why not "global_admin is just another job_title value" 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 — DROPPED entirely (m's three-axis principle) 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 - `internal/services/appointment_service.go:199` — appointment update/delete - `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_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" (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:** 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.GlobalRole != "global_admin" { return ErrForbidden } ``` 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 Single up migration (023). Idempotent. No backfill data shape worth keeping in code beyond: ```sql -- 023_split_job_title_and_global_role.up.sql BEGIN; -- Add new column with 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')); -- Promote anyone who currently has role='admin'. UPDATE paliad.users SET global_role = 'global_admin' WHERE role = 'admin'; -- Wipe role='admin' to NULL (admins no longer carry a job title — they didn't -- pick one, the column was overloaded). Real job titles for the 2 current -- admins (m + tester) get fixed up by a separate manual UPDATE inside the -- same transaction, since we know them and the migration ran end-to-end is -- the right place to do it. UPDATE paliad.users SET role = NULL WHERE role = 'admin'; UPDATE paliad.users SET role = 'Counsel Knowledge Lawyer' WHERE email = 'matthias.siebels@hoganlovells.com'; -- tester@hlc.de stays role=NULL — it's a synthetic admin account, no real -- job title. Admin-team UI will render NULL as "—". -- Rename the column. Doing this last so the explicit UPDATEs above stay -- readable; if the rename were first, every UPDATE would refer to job_title -- and the diff is harder to review. ALTER TABLE paliad.users RENAME COLUMN role TO job_title; -- The CHECK (role <> '') from migration 015 must come along to job_title, -- but with a tweak: NULL is now allowed (admins without a job title). ALTER TABLE paliad.users DROP CONSTRAINT IF EXISTS users_role_check; ALTER TABLE paliad.users ADD CONSTRAINT users_job_title_check CHECK (job_title IS NULL OR job_title <> ''); -- can_see_project must follow. 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 ); $$; -- Rebuild RLS policies that the CASCADE drops. Identical to migration 021 -- except every `u.role = 'admin'` becomes `u.global_role = 'global_admin'` -- and every `u.role IN ('partner','admin')` becomes -- `(u.job_title = 'partner' OR u.global_role = 'global_admin')`. -- (Full body in the migration file; not reprinted here for brevity.) COMMIT; ``` Down migration is the symmetric reverse: rename `job_title` → `role`, copy `global_admin` rows back to `role='admin'`, drop `global_role`, restore the original `can_see_project` body. Ugly but tractable; ~31 rows, no realistic reason to roll back. ### 6. Code surface — where every `'admin'` lives Inventoried `grep -rn "'admin'\\|\\\"admin\\\"\\|user.Role" --include="*.go" --include="*.sql" --include="*.ts" --include="*.tsx"` and bucketed: #### A. Global-admin gates (these MUST migrate) **Go services** - `internal/services/dashboard_service.go:154,211,241,270` — SQL `$2 = 'admin'`, callers pass `user.Role` as `$2` at lines 186, 219, 250, 279 - `internal/services/agenda_service.go:135,201` — same pattern - `internal/services/appointment_service.go:487` — same pattern, caller line 492 - `internal/services/project_service.go:725,736,747` — three `visibilityPredicate*` helpers - `internal/services/deadline_service.go:405` — `if user.Role == "admin"` short-circuit - `internal/services/department_service.go:304` — `requireAdmin` - `internal/services/user_service.go:149,232,346,388,500,638,641` — bootstrap + IsAdmin + assignment guards **Go handlers** - `internal/handlers/onboarding.go:63` — error message - `internal/handlers/users.go:91` — error message - `internal/handlers/admin_users.go:75,113` — error message (twice) **Auth** - `internal/auth/require_admin.go:19` — comment only **SQL migrations (existing — for awareness; only migration 023 touches these)** - `internal/db/migrations/006_visibility.up.sql:33` — historical, superseded - `internal/db/migrations/007_rls_policies.up.sql:44,58` — historical, superseded - `internal/db/migrations/018_projects_v2.up.sql:414,497,530,544,548,560,565` — historical, superseded - `internal/db/migrations/021_fix_function_bodies_after_rename.up.sql:46,126,155` — current live state of `can_see_project` + RLS; migration 023 replaces **Frontend** - `frontend/src/client/sidebar.ts:299,309` — sidebar admin-section reveal - `frontend/src/client/settings.ts:572,582` — admin-only controls on settings page - `frontend/src/client/admin-team.ts:117,156,177,179,220,221` — table render + sort - `frontend/src/client/deadlines-detail.ts:190,193,207` — admin/partner gate on UI - `frontend/src/client/projects-detail.ts:555,1206` — admin/partner gate on UI #### B. Job-title labels (these stay as `job_title`) - `frontend/src/admin-team.tsx:74,121` — table header / form label "Rolle" - `frontend/src/admin-team.tsx:122` — direct-add input still says "role" (rename to `job_title` server-side, label stays "Rolle / Job title") - `frontend/src/onboarding.tsx:48,52,53,57` — onboarding form label / field name - `frontend/src/client/admin-team.ts:170,179,309,365,372` — datalist + form handling - `frontend/src/client/i18n.ts` — every `admin.team.col.role` / `onboarding.role.*` string The form FIELD NAMES on the wire (`{display_name, office, role, ...}`) become `job_title` after the rename. Both client and server change in one commit. #### C. Per-project role (NOT touched) - `internal/services/deadline_service.go:417` — `pt.role IN ('admin', 'lead')` — this is `project_teams.role`, unrelated. - All other `pt.role` references in handlers/services. ### 7. API surface `/api/me` payload before: ```json { "id": "…", "email": "…", "role": "admin", … } ``` After: ```json { "id": "…", "email": "…", "job_title": "Counsel Knowledge Lawyer", "global_role": "global_admin", … } ``` `/api/admin/users` and `/api/admin/users/{id}` similarly expose both fields. `PATCH /api/me` accepts `{job_title}` (no `role`); `PATCH /api/admin/users/{id}` accepts `{job_title, global_role}` — server enforces that only existing global_admins can change `global_role`, and refuses to demote the last global_admin (mirror of the existing last-admin protection in `AdminDeleteUser`). `POST /api/admin/users` accepts `{email, display_name, office, job_title, dezernat, lang}` only — `global_role` defaults to `'standard'`. Promotion is a separate `PATCH` action so it can't be smuggled into create. Self-service `POST /api/onboarding` accepts `{display_name, office, job_title, dezernat}` — `global_role` defaults to `'standard'`. The bootstrap path (first row of `paliad.users`) flips `global_role='global_admin'` instead of setting `role='admin'`. Same `pg_advisory_xact_lock(7346298141)` guard. ### 8. UI surface #### Onboarding (`/onboarding`) - Field label: "Berufsbezeichnung / Job title" (was "Rolle") - Field name in DOM: `job_title` - Datalist suggestions stay (Partner / Associate / PA / Of Counsel / Referendar/in / Trainee / wiss. Mitarbeiter/in / Sekretariat). Add: Counsel, Knowledge Lawyer, Counsel Knowledge Lawyer. - No `global_role` field — that defaults to 'standard'. #### Settings (`/einstellungen`) - "Rolle" → "Berufsbezeichnung / Job title", same input. - New read-only "Berechtigung / Permission" line below: shows `Standard` or `Global Admin`. Not editable from settings (must use admin page). #### Admin team (`/admin/team`) - New column header (after Office, before Dezernat): "Berechtigung / Permission". - Cell content: badge — `Standard` (neutral) or `Global Admin` (lime, the brand accent). - Cell behavior: click toggles a dropdown with the two enum values. Saving issues `PATCH /api/admin/users/{id}` with `{global_role}`. - "Rolle" column heading + cell content stays — but the cell now renders `job_title` (free text, may be NULL → render as "—"). - Direct-add modal: rename "Rolle" input to "Berufsbezeichnung / Job title", drop the special "Associate" default (keep the placeholder), bind `name="job_title"`. - Sort: existing "admins first" sort key flips to `global_role='global_admin'` first. - Last-global_admin protection: dropdown disabled (with tooltip) when the row is the last surviving global_admin. #### Sidebar - The admin-section reveal in `sidebar.ts:initAdminGroup()` flips the predicate from `me.role === "admin"` to `me.global_role === "global_admin"`. #### Deadline / project detail pages - The `me.role === "admin" || me.role === "partner"` gates become `me.global_role === "global_admin" || me.job_title === "partner"` (preserving today's broken-but-harmless behavior; see §4). ### 9. Bootstrap rule Today: first `paliad.users` row may self-assign `role='admin'`, guarded by `pg_advisory_xact_lock(7346298141)`. After: first `paliad.users` row may set `global_role='global_admin'`. Same lock, same constant, new column. Onboarding payload includes no `global_role` — the service decides based on the row count and overrides the default 'standard' for the first inserter. ### 10. Backwards compat **Decision: clean rename, no compat shim.** Justification: - 31 production rows, all in our control. - Wire format changes (`role` → `job_title`, new `global_role`) cross client + server simultaneously in one merge. No staged rollout needed for an internal tool. - Old session cookies with cached `me.role` values get refreshed on the next `/api/me` call, which the client makes on every page load. - The `paliad.users.role` column stops existing after migration 023. Any ad-hoc query / report keyed on `role='admin'` breaks loudly — that's the point. If future me wants compat-during-deploy: add a generated column `role text GENERATED ALWAYS AS (job_title) STORED` for one release, drop in the next. Not doing that now. ### 11. Test plan **Unit** - `internal/services/user_service_test.go` - `Create` with `count=0` → `global_role='global_admin'` AND `job_title=` (or NULL if empty) - `Create` with `count>0` → `global_role='standard'` - `UpdateProfile` cannot set `global_role` (field absent from `UpdateProfileInput`) - `AdminUpdateUser` can set `global_role`; rejects when caller is not global_admin (handler-level test); rejects demotion of last global_admin (service-level test, mirror of `AdminDeleteUser`'s last-admin protection) - `IsAdmin` reads `global_role` - `internal/auth/require_admin_test.go` — already covers the `IsAdmin` surface; no changes needed beyond the swap of test fixture's seeded column. **Integration / smoke** - Manual: log in as tester@hlc.de — confirm sidebar `/admin/team` entry appears, page loads, table shows m as global_admin + "Counsel Knowledge Lawyer" job title. - Manual: set m's `job_title` via admin page to something else, confirm `global_role` is unchanged. - Manual: try to demote tester (last global_admin in this case if you've already demoted m) — expect rejection. - DB-level: `SELECT email, job_title, global_role FROM paliad.users` after migration. Expected: - 2 rows global_admin (m, tester), m.job_title='Counsel Knowledge Lawyer', tester.job_title IS NULL - 29 rows standard with job_title='associate' - `go build ./... && go vet ./... && go test ./...` clean. - `cd frontend && bun run build` clean. ## Out of scope (recap) - Fine-grained permissions (`partner`, `billing_admin`) — design leaves room (CHECK can grow; or migrate to `text[]` later) but ships only `global_admin`. - Cleaning up the "partner" gate conflation (§4) — gate stays job-title-driven for now. File follow-up. - Permission inheritance from `project_teams` to global — explicitly orthogonal. - Role-based UI customization beyond hide/show — defer. ## Open questions for m 1. **m's `job_title` value** — task brief says "Counsel Knowledge Lawyer". Confirmed? (Migration writes that exact string.) 2. **tester's `job_title`** — migration sets NULL. Alternative: 'Admin' literal. Lean: NULL — tester is a synthetic admin without a real title; "Admin" as a job title perpetuates the conflation we're solving. 3. **Default `global_role` on new sign-ups beyond the bootstrap** — confirmed `'standard'`. 4. **The 'partner' job-title gate** — leave as-is for this PR? (My recommendation: yes, file follow-up.) ## Implementation phase plan (after greenlight) Single mai/cronus/separate-job-title-from branch, single PR, single merge to main. 1. `internal/db/migrations/023_split_job_title_and_global_role.{up,down}.sql` — schema + data + can_see_project + RLS rebuild. 2. `internal/models/models.go` — `User.Role` → `User.JobTitle` (keep `db:"job_title"` `json:"job_title"`); add `User.GlobalRole string \`db:"global_role" json:"global_role"\``. Make `JobTitle` a `*string` since admins may have NULL. 3. `internal/services/user_service.go` — every `role` reference, including `userColumns`, the bootstrap branch (assign `global_role`), `IsAdmin` (reads `global_role`), `UpdateProfileInput`/`AdminUpdateInput` (drop `Role`, add `JobTitle *string` and `GlobalRole *string` for admin-only). 4. `internal/services/{dashboard,agenda,appointment,project,deadline,department,party,note,checklist_instance}_service.go` — swap the SQL `$N = 'admin'` → `$N = 'global_admin'` and the call sites pass `user.GlobalRole` instead of `user.Role`. Partner gates change to `user.JobTitle != "partner" && user.GlobalRole != "global_admin"` (per §4). 5. `internal/handlers/{onboarding,users,admin_users}.go` — update error messages; payload field renames. 6. `internal/auth/require_admin.go` — comment update only (the `AdminLookup.IsAdmin` interface is unchanged because it abstracts behind the boolean). 7. `frontend/src/admin-team.tsx`, `frontend/src/onboarding.tsx` — labels + field names (`role` → `job_title`); add Permission column on admin-team. 8. `frontend/src/client/admin-team.ts`, `frontend/src/client/onboarding.ts`, `frontend/src/client/sidebar.ts`, `frontend/src/client/settings.ts`, `frontend/src/client/deadlines-detail.ts`, `frontend/src/client/projects-detail.ts` — every `me.role === "admin"` → `me.global_role === "global_admin"`; every form-field `role` → `job_title`; add the global_role dropdown widget. 9. `frontend/src/client/i18n.ts` — DE+EN strings for "Berufsbezeichnung", "Berechtigung", "Standard", "Global Admin". 10. Tests — update fixtures + add the cases in §11. Self-merge to main authorized once `go build/vet/test ./...` and `bun run build` are clean and a smoke pass against ydb confirms acceptance §1–§9 of the task brief.