Roles & Permissions (RBAC)
Lacroo’s authorization is a single, default-deny permission primitive. Org admins create and
manage their own roles and configure access per feature, live — replacing the old fixed
role enum and the scattered isAdmin() checks.
The model
- Permission keys — every gateable action is a
module.actionkey (e.g.dockets.approve,roles.manage), declared in the canonicalpermissions.yamlregistry. - Roles — named bundles of permission keys, created/edited by org admins at runtime (no deploy).
The 13 legacy roles ship as editable presets;
master_adminis locked + always-full. - Assignment — a user gets an org-default role (or several — effective = the union) plus optional per-project overrides that replace the org baseline for that project.
- Resolution —
effective = override(project) ?? orgDefault, unioned; any role flaggedis_full_access(master_admin) short-circuits to ALL. Anything not explicitly granted is denied.
Who sees what
Senior Supervisors see everything on their projects. Supervisors see their own and their associated work.
- Senior Supervisor: every diary, prestart, docket, issue, photo, plant and activity on the projects they are a member of, whoever supervises it, migrated records included. Their Diaries list opens on All. They can still be picked as a diary, prestart or docket supervisor.
- Supervisor: the records they supervise, raised, uploaded or created, and those they are an associated supervisor on.
- Projects: both see only the projects they are a member of.
- What they can do is set by the role’s permissions, which are the same for both. Visibility is the only difference.
An org admin can narrow a Senior Supervisor in the Roles Editor under Visible data. The default is All records. See Avenues & Scope.
Enforcement
The Go API gates every mutation centrally:
if ok, err := authz.Can(ctx, authz.KeyDocketsApprove, authz.InProject(projectID)); err != nil {
// 500 — fail closed
} else if !ok {
// 403 — not permitted
}Permissions resolve server-side from the DB, cached per (org, authz_version) with a 60s
fail-closed TTL. The JWT carries identity only. Plain reads stay tenant-scoped (open within the org);
only mutations and the few historically role-gated reads (dockets.view_pricing, reports.view)
are Can-gated.
Project roles count everywhere (BUG-0041)
A role given to someone on one project counts in every decision about that project (owner ruling 2026-10-14). Someone who is a Supervisor company-wide and Project Admin on one project manages field users, members, stages, suppliers, contracts and the rest on that project, and stays a Supervisor everywhere else. There are three shapes of check:
| The route | How it decides |
|---|---|
Names a project (/projects/{projectId}/…) | authz.Can(key, authz.InProject(projectID)) — the project is parsed before the check. |
| Acts on an object that belongs to one project (a contract, a supplier’s link to a project, an induction form) | authz.CanOnObject(ctx, key): the object’s own project, recorded by the router’s middleware (WithObjectProjectResolver / WithObjectProject). An object on no project is decided company-wide. |
| Acts on something that spans projects (a field worker by id, the company-wide field users list) | authz.AccessibleProjectIDsWithKey: allowed when the key is held company-wide or on at least one of the caller’s projects, and every read and write is narrowed to exactly those projects. |
Org-level settings (org settings, roles, billing, ERP configuration, business units, integrations) stay company-wide.
A role given on a project also decides what its holder may give there: a per-project role is
judged against the giver’s authority on that project (roleassign.ResolveAuthorityInProject), a
company-wide role against their company-wide authority. Locked and org-wide stay company-wide.
A project named by a query parameter (?project_id=) counts as the route’s project: the org users
roster filtered to one project and the cost allocation report decide there. A contract’s allocation
calls decide in the contract’s own project; a contract on no project is decided company-wide, never in
the ?project_id the caller sends. The roles list answers anyone who holds roles.manage company-wide, or roles.manage /
users.assign_roles on one of their projects, so the project role picker works for a Project Admin
of that project.
Rows follow the same scope everywhere they appear. The field user detail (recent dockets, sign-ons,
refusals) and the cost allocation report filter dockets and prestarts with the caller’s row scope
(pkg/rowscope, the same mapping the docket and prestart reads use), so a narrowed Engineer sees
there exactly what the docket and prestart screens show them.
A contract’s overtime rules, penalty rates, allowances and equipment rates, and an induction role’s custom fields, are changed only through their own contract or role: a child id from another contract or role matches nothing.
The guard: backend/api/internal/projectauthzguard fails CI when a handler on a project route
(a {projectId} path or a project_id query parameter) checks a permission without
authz.InProject — directly or through a same-package helper — and when a handler deciding with
authz.CanOnObject is registered anywhere without its project-lookup middleware. A planted-violation
test proves each rule fails. A check that must stay company-wide goes on its allowlist with a reason
(today: none).
Live propagation (no re-login)
Any role/permission/assignment change bumps organizations.authz_version, fires
NOTIFY org_changes, invalidates the in-process cache, and broadcasts over WebSocket. The web client
refetches usePermissions() — nav and affordances update within seconds, no logout. A revoke is
effective within ≤60s even if a notification is missed.
The menu
The left menu and the command palette (⌘K) show only the sections the role can open. A section shows when:
- the role holds one of the section’s permissions that is not always on, or
- the section’s View is always on, so every admin can open it.
So Plant Register, Drug & Alcohol, Sign On Workbench and Copilot show only to roles with a permission in them. Reports shows when the role can run at least one report. A full-access role sees every section.
An address with no page shows Page not found, with the menu and Logout still there. The organisation console and the supplier and client portals do the same.
Engineer = Project Admin (owner ruling 2026-10-14)
“For all orgs and everywhere, engineers should be the same in permissions as the project admins.”
- Permissions: the Engineer preset holds every permission the Project Admin preset holds
(
presets.go: every bundle withproject_adminalso hasengineer). Existing orgs were brought in line by20261014085052_engineer_matches_project_admin.sql, which gave each org’s Engineer every permission that org’s own Project Admin holds, so an org’s own matrix edits carried over. Nothing was taken away from anyone. - Full access (Q1): “This full access should only refer to the full access to the modules and
options inside of the projects they are assigned to, not all projects or org level … keep the full
access like the admins just if a supervisor is assigned to the engineer - it narrows to those
supervisors”. The Project Admin preset is full access, so it holds every key. The Engineer holds
every key explicitly, never the full-access flag (the flag would switch off the supervisor
narrowing). The keys only full access held before are
roles.manage,users.assign_roles,fieldusers.power,clients.power,integrations.view,integrations.manage,copilot.view_usageanddockets.field_add_contract_item(EngineerJoinedFullAccessKeys). The same migration gives every key to the Engineer of each org whose Project Admin is full access. A test fails if a new key reaches the Project Admin’s defaults without the Engineer. Company-wide Engineers keep company settings, users and ERP, like Project Admins (Q2). - What stays the same:
- Engineers reach only the projects they are a member of, like Project Admins.
- An Engineer with supervisor assignments on a project still sees only those supervisors’ diaries and prestarts there; cost-code assignments still narrow dockets.
- An Engineer is never full access.
- An Engineer cannot change their own engineer assignments. Someone else on the project does.
- Supervisors and Senior Supervisors are unchanged.
- Giving field roles:
dockets.edit_pricingcovers the field worker’s narrowdockets.field_add_contract_itemwhen giving a role, so an Engineer can give the Labour or Operator role like a Project Admin.
Governance & guardrails
- Manage roles needs
roles.manage: Master Admin, Org Manager, the full-access Project Admin, and the Engineer (BUG-0041 Q1). master_adminis permanently full-access + locked (uneditable/undeletable).- No privilege escalation — an admin can’t grant a permission they don’t themselves hold, nor create a full-access/locked role unless they already hold full access (enforced at the API).
- Nobody changes their own role — no one can change their own role assignments, company-wide or
on a project, or change the permissions, avenues or visible data of a role they hold (saving it
unchanged, a rename, passes). Another admin does it. This keeps an Engineer narrowed to their
supervisors from lifting the narrowing. Every assignment path checks it
(
roleassign/self_guard.go): user roles, project roles, project members, the org users editor and field users. Exempt: anyone with full access company-wide (most orgs have one such admin, who must still be able to change their own role); only a Master Admin can make someone Master Admin. - No role that sees more than you — in each module where your own rows are narrowed (an Engineer
with supervisor or cost-code assignments, a role with visible-data limits), you cannot give anyone a
role that sees more rows there: not Project Admin, not Senior Supervisor, not a custom role
without the limit. Roles limited to the holder’s own records (Supervisor, Labour and the other
field roles) and your own role stay givable. This closes creating a second login with a wider role.
Every grant path checks it (
roleassign/scope_guard.go: create user, assign roles, project members, field users, invites). - Anonymise & delete needs
fieldusers.powerheld company-wide (owner ruling 2026-10-14, Q3); a role on one project does not count. - Last-full-admin protection — the system blocks removing the final full admin (transaction-safe).
- Duplicate copies a role’s permissions, avenues, visible data and full access (owner ruling 2026-09-26). The copy of a full-access role is full access too. It is a custom role: never locked, never a preset, and it can be edited or deleted. Copying Master Admin gives an unlocked full-access role, not a second Master Admin. Only an admin with full access can copy a full-access role, and a role that reaches org-wide still needs an org-wide admin. The Duplicate button only shows on the roles the admin can copy.
- Every role/assignment change is written to
audit_logs.
Supplier Master Admin (system-owned)
Every supplier organisation’s admin holds one role the product sets itself: Supplier Master
Admin (key supplier_master_admin).
- Where they land: the supplier portal, on web (
/supplier) and mobile. It is system, locked and full access inside its own org. - What they keep: everything a supplier org’s Master Admin could do. The role holds every
Master Admin avenue (
web_org,web_admin,admin_copilot,mobile_office) plusweb_supplierandmobile_supplier. The portal sidebar links to the org console, and the org console links back to the portal (web). On mobile, Profile opens the office app, and a project’s Settings opens the supplier screens. - Who gets it: whoever becomes a supplier org’s admin — supplier self-registration, a head contractor adding a supplier with an invite email, Invite admin, and accepting that invite. A head contractor org linked as someone’s supplier keeps a normal Master Admin.
- When the role is made: the product creates it the first time it seats a supplier org’s admin. A one-off correction (2026-09-26) created it in the 40 supplier orgs that had none because no admin had been seated yet. Shear Power Contracting, which runs its own project and keeps Master Admin, was left as it is.
- Not a matrix role: it never appears in the Summary Matrix or the Roles Editor, and no one can create, copy, edit or delete it.
- The org’s admin role: in an org that holds it, it is the admin role that Users and Manage roles offer, so a Supplier Master Admin can make a colleague one. Master Admin is neither offered nor accepted there. It is an org role only, never a project override. Head contractor orgs, and a supplier org that still uses Master Admin because it runs its own projects, keep Master Admin.
- Supplier portal avenues are system-owned (
system_owned: trueinpermissions.yaml): no matrix or custom role carries them. The editor hides them, and the API refuses them with 400. The legacysupplierpreset is retired (see below). - Sign-in order: because only this role holds a supplier avenue, the supplier portal comes first when choosing where to land (web and mobile). A field avenue still wins on mobile.
- Not a field worker: holders never appear in field-user lists, crew pickers, emergency-details lists or field counts, and field endpoints refuse the role.
users.rolemirrors it asmaster_admin.
Retired: the Supplier role
The old Supplier preset (key supplier) is retired. It opened nothing once the supplier portal
moved to the Supplier Master Admin.
- Not offered anywhere: no role picker, filter or editor lists it, on web or mobile. The API contract does not list it either, so the generated client and the agent tools never offer it. New orgs are never given it, and the dev seed seats nobody on it.
- Never granted: every write path refuses it, including the field-user, self-registration,
supplier-portal and Entra paths, the org’s auto-add roles setting and the legacy
role: "supplier"string. A supplier-portal worker request for it becomes Labour. - Its holders were moved by a one-off correction: supplier company contacts became the Supplier Master Admin of their supplier org, and a registered field worker became Labour. Their user id did not change, so their history stays with them.
- Unused rows were soft-deleted. The invariant
org_presets_15reports any live row left. - The
suppliervalue stays in theusers.roleenum, but is never written again.
Exports
| Permission | What it allows | Default |
|---|---|---|
| Exports › Export whole project | A report includes every record on the project, not only the records the role can open | On for Engineers |
| Issues › Export issues | Run the Issues Log and export the issue register | On for master admin, organisational manager, project admin, engineer, senior supervisor and supervisor |
| Diaries › Export diaries | Run the Daily Diaries report | On for master admin, organisational manager, project admin, engineer, senior supervisor and supervisor |
| Prestarts › Export prestarts | Run the Prestart Report and the Alerts Report | On for master admin, organisational manager, project admin, engineer, senior supervisor and supervisor |
See Reports and Issues Log Report.
Adding a new permission
- Add the action to
permissions.yaml(with itstier+enforcedflag). - Run
pnpm authz:codegen→ regenerates the TS registry + Go key consts. - Gate the handler:
authz.Can(ctx, authz.KeyYourModuleYourAction, …). - Two CI gates enforce this: key-validity (every
Cankey is registered) and enforcement-coverage (everyenforced:truekey is gated by ≥1 handler) — a feature can’t ship unregistered or unenforced.
UI
/org/roles — the roles management page. It contains two views toggled by the Summary Matrix ↔ Roles Editor tab strip at the top:
Summary Matrix
A read-at-a-glance (and inline-editable) grid: every avenue and module (rows) × every role in the org (columns). Use it to compare access across roles or to make targeted edits without opening each role individually.
- Toggle: Click Summary Matrix or Roles Editor in the tab strip. The workspace opens in Summary Matrix by default.
- Expand a module row to reveal per-action checkboxes (click the ▸ chevron or Expand all). Checking or unchecking an action updates the role’s permission set immediately (optimistic, with rollback on error).
- Avenue switches (upper group) can be toggled per role to grant or revoke surface access.
- Visible data (scope) — for scoped modules the scope cell shows All or Owned · N. On editable roles an ▾ dropdown opens the source picker to restrict which records the role can see (e.g. created_by, supervisor).
- Full-access roles (
master_admin) show full access and their tier/scope cells are read-only. Converting a full-access role to an explicit grant set (by unchecking any action) is one-way — it cannot be reversed from the UI; contact an admin with API access. - Read-only cells — locked roles and roles you don’t have authority to edit show static labels; out-of-authority checkboxes are pre-disabled.
Roles Editor
The per-role detail view — the original hybrid matrix editor with per-module tier dropdowns and expandable per-action checkboxes. Assignment lives in the Users / Field Users workbench (org-default + per-project override, with the guardrails surfaced inline).
Where the role shows (owner ruling 2026-09-24):
- A project’s member list shows the role on that project (override, else org-default). A role change there sets the override only.
- In the org admin, a project’s member list also shows the company-wide role when it is different, and the Users lists show each project where the role is different.
- Every list that asks what somebody is on a project (supervisor and engineer pickers, the Alerts report, field-user lists) reads the role on that project too, the same way permissions do.
- Removing somebody from a project also removes any role set for them there.
- Office and field roles never swap between the company-wide role and a project role (owner ruling H, 2026-09-25). Changing the company-wide role to the other kind removes project roles of the old kind.
- A project role equal to the company-wide role is not stored (owner ruling I), for a project’s own people and for people from another company alike. One-off corrections removed the stored copies: 105 on 2026-09-25, and the last 8, held by people from another company, on 2026-09-26.
- The rule is
roleassign.RoleOnProject. See Project Members.
Three-gate RBAC (LM-64)
LM-64 layers two additional gates on top of this primitive: an Avenue gate (which app surfaces a role may enter) and an Ownership Scope gate (which rows a role can see within a module). Full details — surface taxonomy, per-module scope sources, full-access semantics, engineer scoping, and worked examples — are documented in Avenues, Tiers & Scope.