Conflation today: paliad.users.role is simultaneously job title (display only),
global permission (`role='admin'` checks across Go/SQL/JS), and not-quite-but-
sort-of project_teams.role (already separated). m wants to record his real job
title ("Counsel Knowledge Lawyer") without losing admin access — the existing
admin-team UI even rejects role='admin' on edit, so any UI-driven update
silently demotes him.
Design proposes:
- Rename paliad.users.role -> paliad.users.job_title (free text, NULL allowed)
- Add paliad.users.global_role (CHECK IN ('standard','global_admin'),
default 'standard')
- Single migration 023 does the rename, populates global_role from the old
role, fixes m to job_title='Counsel Knowledge Lawyer', updates
can_see_project, rebuilds RLS policies
- Inventory of every role='admin' call site across services/handlers/
migrations/frontend bucketed by what migrates vs. what stays
- Keeps the existing 'partner' gate as job_title-driven (already broken in
prod — "Partner" capital-P vs lowercase 'partner' check; documented as
out-of-scope follow-up)
- Bootstrap rule (first user becomes admin) keeps the same advisory lock,
flips global_role instead of role
- API surface: /api/me returns both fields; admin-team UI gets a Permission
column with a global_role dropdown + last-admin demotion guard
Awaiting m greenlight before implementation phase.
20 KiB
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:
- Job title — Partner / Counsel / Associate / PA / Trainee / Sekretariat / "Counsel Knowledge Lawyer" / … . Free-text since migration 015. Display only.
- 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. - 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:
| 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'srole.paliad.users.global_role— enum-via-CHECK, currently'standard' | 'global_admin'. New column, drives everyrole='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'withCHECK (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 problem (out of scope, flagged)
Several places gate on user.Role IN ('partner','admin'):
internal/services/party_service.go:100— delete partyinternal/services/note_service.go:195— note opsinternal/services/appointment_service.go:199— appointment update/deleteinternal/services/project_service.go:617— project opsinternal/services/checklist_instance_service.go:301— checklist opsinternal/services/deadline_service.go:437— delete deadline- migrations 018 / 021 RLS policies —
users.role IN ('partner','admin')onprojects,project_teams,notesdeletes frontend/src/client/projects-detail.ts:555,1206,deadlines-detail.ts:190,193,207— 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.
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.
After-rename gate shape:
// before
if user.Role != "partner" && user.Role != "admin" { return ErrForbidden }
// after
if user.JobTitle != "partner" && 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.
5. Data migration
Single up migration (023). Idempotent. No backfill data shape worth keeping in code beyond:
-- 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 passuser.Roleas$2at lines 186, 219, 250, 279internal/services/agenda_service.go:135,201— same patterninternal/services/appointment_service.go:487— same pattern, caller line 492internal/services/project_service.go:725,736,747— threevisibilityPredicate*helpersinternal/services/deadline_service.go:405—if user.Role == "admin"short-circuitinternal/services/department_service.go:304—requireAdmininternal/services/user_service.go:149,232,346,388,500,638,641— bootstrap + IsAdmin + assignment guards
Go handlers
internal/handlers/onboarding.go:63— error messageinternal/handlers/users.go:91— error messageinternal/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, supersededinternal/db/migrations/007_rls_policies.up.sql:44,58— historical, supersededinternal/db/migrations/018_projects_v2.up.sql:414,497,530,544,548,560,565— historical, supersededinternal/db/migrations/021_fix_function_bodies_after_rename.up.sql:46,126,155— current live state ofcan_see_project+ RLS; migration 023 replaces
Frontend
frontend/src/client/sidebar.ts:299,309— sidebar admin-section revealfrontend/src/client/settings.ts:572,582— admin-only controls on settings pagefrontend/src/client/admin-team.ts:117,156,177,179,220,221— table render + sortfrontend/src/client/deadlines-detail.ts:190,193,207— admin/partner gate on UIfrontend/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 tojob_titleserver-side, label stays "Rolle / Job title")frontend/src/onboarding.tsx:48,52,53,57— onboarding form label / field namefrontend/src/client/admin-team.ts:170,179,309,365,372— datalist + form handlingfrontend/src/client/i18n.ts— everyadmin.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 isproject_teams.role, unrelated.- All other
pt.rolereferences in handlers/services.
7. API surface
/api/me payload before:
{ "id": "…", "email": "…", "role": "admin", … }
After:
{ "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_rolefield — that defaults to 'standard'.
Settings (/einstellungen)
- "Rolle" → "Berufsbezeichnung / Job title", same input.
- New read-only "Berechtigung / Permission" line below: shows
StandardorGlobal 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) orGlobal 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 fromme.role === "admin"tome.global_role === "global_admin".
Deadline / project detail pages
- The
me.role === "admin" || me.role === "partner"gates becomeme.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, newglobal_role) cross client + server simultaneously in one merge. No staged rollout needed for an internal tool. - Old session cookies with cached
me.rolevalues get refreshed on the next/api/mecall, which the client makes on every page load. - The
paliad.users.rolecolumn stops existing after migration 023. Any ad-hoc query / report keyed onrole='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.goCreatewithcount=0→global_role='global_admin'ANDjob_title=<input>(or NULL if empty)Createwithcount>0→global_role='standard'UpdateProfilecannot setglobal_role(field absent fromUpdateProfileInput)AdminUpdateUsercan setglobal_role; rejects when caller is not global_admin (handler-level test); rejects demotion of last global_admin (service-level test, mirror ofAdminDeleteUser's last-admin protection)IsAdminreadsglobal_role
internal/auth/require_admin_test.go— already covers theIsAdminsurface; no changes needed beyond the swap of test fixture's seeded column.
Integration / smoke
- Manual: log in as tester@hlc.de — confirm sidebar
/admin/teamentry appears, page loads, table shows m as global_admin + "Counsel Knowledge Lawyer" job title. - Manual: set m's
job_titlevia admin page to something else, confirmglobal_roleis 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.usersafter 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 buildclean.
Out of scope (recap)
- Fine-grained permissions (
partner,billing_admin) — design leaves room (CHECK can grow; or migrate totext[]later) but ships onlyglobal_admin. - Cleaning up the "partner" gate conflation (§4) — gate stays job-title-driven for now. File follow-up.
- Permission inheritance from
project_teamsto global — explicitly orthogonal. - Role-based UI customization beyond hide/show — defer.
Open questions for m
- m's
job_titlevalue — task brief says "Counsel Knowledge Lawyer". Confirmed? (Migration writes that exact string.) - 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. - Default
global_roleon new sign-ups beyond the bootstrap — confirmed'standard'. - 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.
internal/db/migrations/023_split_job_title_and_global_role.{up,down}.sql— schema + data + can_see_project + RLS rebuild.internal/models/models.go—User.Role→User.JobTitle(keepdb:"job_title"json:"job_title"); addUser.GlobalRole string \db:"global_role" json:"global_role"`. MakeJobTitlea*string` since admins may have NULL.internal/services/user_service.go— everyrolereference, includinguserColumns, the bootstrap branch (assignglobal_role),IsAdmin(readsglobal_role),UpdateProfileInput/AdminUpdateInput(dropRole, addJobTitle *stringandGlobalRole *stringfor admin-only).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 passuser.GlobalRoleinstead ofuser.Role. Partner gates change touser.JobTitle != "partner" && user.GlobalRole != "global_admin"(per §4).internal/handlers/{onboarding,users,admin_users}.go— update error messages; payload field renames.internal/auth/require_admin.go— comment update only (theAdminLookup.IsAdmininterface is unchanged because it abstracts behind the boolean).frontend/src/admin-team.tsx,frontend/src/onboarding.tsx— labels + field names (role→job_title); add Permission column on admin-team.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— everyme.role === "admin"→me.global_role === "global_admin"; every form-fieldrole→job_title; add the global_role dropdown widget.frontend/src/client/i18n.ts— DE+EN strings for "Berufsbezeichnung", "Berechtigung", "Standard", "Global Admin".- 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.