Files
paliad/docs/design-permissions-vs-roles.md
m b34500ad31 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'
2026-04-27 14:59:03 +02:00

20 KiB
Raw Blame History

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 rolepaliad.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.rolepaliad.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:

// 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:

-- 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_titlerole, 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:405if user.Role == "admin" short-circuit
  • internal/services/department_service.go:304requireAdmin
  • 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:417pt.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:

{ "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_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 (rolejob_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=0global_role='global_admin' AND job_title=<input> (or NULL if empty)
    • Create with count>0global_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.goUser.RoleUser.JobTitle (keep db:"job_title" json:"job_title"); add User.GlobalRole string \db:"global_role" json:"global_role"`. Make JobTitlea*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 (rolejob_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 rolejob_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.