Skip to main content

Role is global-max, and the app is really binary

Two tacit facts about permissions, both dangerous.

1. The effective role is the max across all companies

useUserRole reduces highestRole over every company_memberships row the user has, using a ROLE_PRIORITY map — not scoped to the active company:

const highestRole = memberships.reduce((best, m) =>
(ROLE_PRIORITY[m.role] ?? 0) > (ROLE_PRIORITY[best] ?? 0) ? m.role : best, ...);

A user who is super_admin in company A and employee in company B is treated as super_admin everywhere. All the derived booleans (isSuperAdmin, isHrAdmin, isDecider, …) ignore which tenant you are acting in. Because the active company is in-memory only and RLS doesn't enforce roles (client-side integrity), this is a real privilege-bleed across tenants.

primaryCompanyId = companyIds[0] with no ORDER BY — the "primary" company is whichever row Postgres returns first. Non-super-admins are hard-pinned to it (effectiveCompanyId = isSuperAdmin ? activeCompanyId : primaryCompanyId, a ternary copy-pasted across AdminLayout, Dashboard, Cases, Settings, OrgAuditLog), with no company switcher — a multi-company regular user can only ever see their first company.

2. Despite a 12-value role enum, the UI is binary

Across the layout/nav/audit layer, isSuperAdmin is the only role signal used. There are two nav configurations, not per-role menus, and isHrAdmin (a super-admin-inclusive OR-gate) is reused as the single permission for archive, delete, audit visibility, and user management. Splitting it in a rebuild changes all of them at once.

3. Two role vocabularies are written by different screens

Onboarding/CompanySetup invite users as company_admin / case_handler / viewer (the legacy set), while UserManagement writes case_manager / hr_admin (the new set). useUserRole aliases legacy→new, but the same conceptual role ends up stored as two different strings depending on which admin surface created the user. Default invite role even differs by screen (case_manager vs case_handler).

What breaks: a rebuild that models roles as clean, tenant-scoped RBAC will (a) close a privilege-bleed the PoC silently allowed — verify no workflow depends on it — and (b) orphan users created under the other vocabulary. Needs a role-normalisation migration.

Rebuild implication

Role must be evaluated per active tenant, server-side. Normalise the legacy/new role strings. Model the real role matrix (instructor / legal reviewer / decider / …) that the domain needs, not a super-admin boolean.