Avenues, Module Tiers & Ownership Scope (RBAC v2)
LM-64 extends the Roles & Permissions primitive from a single permission-key dimension into three layered gates. Every request passes all three in order — outermost gate wins, so a user who can’t enter a surface at all never reaches the module or row check.
The three gates
Avenue (which surface?)
└── Module tier (what actions on this module?)
└── Visible data (which rows?)| # | Gate | Default | Bypass |
|---|---|---|---|
| 1 | Avenue — which app surface can this role enter? | deny any unlisted surface | — |
| 2 | Module tier — No view / View / Edit / Full per module (authz.Can) | deny (default-deny) | is_full_access roles |
| 3 | Visible data — ownership-based row filter | All (no restriction) | is_full_access roles |
Full-access roles (master_admin, organisational_manager, project_admin) skip gates 2 and 3
entirely — they see every permitted row on every module they can reach.
They do not bypass gate 1. A project_admin still cannot reach /org; a master_admin still
cannot enter the supplier portal unless web_supplier appears in its surface list.
Gate 1 — Avenue (surface taxonomy)
A role’s avenue set is the list of app surfaces its members may enter. Login routing, web middleware, and mobile group layouts all read the same resolved set.
Surface taxonomy
| Surface key | Name | Routes / app group | Entry enforcement |
|---|---|---|---|
web_org | Web · Org console | /org/* | Middleware hard block + layout re-check |
web_admin | Web · Project admin | /admin/*, /dashboard | Middleware hard block + layout re-check |
web_supplier | Web · Supplier portal | /supplier/* | Middleware hard block + layout re-check |
web_client | Web · Client portal | /client/* | Middleware hard block + layout re-check |
mobile_office | Mobile · Office | Expo (office) group | Group layout role check |
mobile_field | Mobile · Field | Expo (field) group | Group layout role check |
mobile_client | Mobile · Client | Expo (client) group | Group layout role check |
mobile_supplier | Mobile · Supplier | Expo (supplier) group | Group layout role check |
Per-role avenue defaults
The migration seeds these defaults to reproduce each role’s current day-one behaviour — no user loses access to a surface they can reach today.
| Role | Default avenue set |
|---|---|
master_admin (locked) | web_org, web_admin, mobile_office |
organisational_manager | web_org, web_admin, mobile_office |
project_admin | web_admin, mobile_office |
engineer | web_admin, mobile_office |
senior_supervisor | web_admin, mobile_office |
supervisor | web_admin, mobile_office |
labour | mobile_field |
operator | mobile_field |
wet_hire_machines | mobile_field |
dry_hire_machines | mobile_field |
supplier | web_supplier, mobile_supplier |
client | mobile_client, web_client |
unassigned | mobile_office |
Avenues are stored in
role_surfaces(role_id, org_id, surface_key). A user’s effective surfaces are the union of their assigned roles’ surface sets (org-default ∪ active-project override, with REPLACE semantics per LM-63). Full-access does not auto-expand to all surfaces — the avenue gate always applies.
Login routing
At login the server derives the user’s preferred landing surface from their effective avenue set, in
priority order: web_org → web_admin → web_supplier → web_client → (mobile-only → web
“no_web_access” page). This replaces the hard-coded roleToPath() map that existed before LM-64.
web_client sits last among the web surfaces on purpose. An account that also holds an office
surface keeps landing on its office console; only an account whose sole web surface is web_client
is routed into the client portal.
Avenue changes take effect immediately
Changing a role’s avenues bumps organizations.authz_version, which fires a NOTIFY org_changes
event. The Go API invalidates its in-process cache, broadcasts over WebSocket, and the client
refetches — effective within seconds (same live-revoke path as permission changes).
Gate 2 — Module tier
The module-tier gate is the LM-63 per-module action matrix (No view / View / Edit / Full).
Every module defaults to No view (default-deny); a user must have an explicit grant or belong to
a full-access role to act on a module.
See Roles & Permissions for the enforcement pattern (authz.Can),
permission key format, and how to add new keys.
Full-access roles bypass this gate and behave as Full on every module.
Gate 3 — Visible data (ownership scope)
Even after a user’s module tier is resolved, which rows they can see is filtered by a
per-module scope configuration. The default is All (no filter). When a scope is configured, it
applies to both list and get-by-id requests — opening a non-owned row via a direct URL
returns 404 (no IDOR possible).
CREATE is never scoped — ownership columns (created_by, supervisor_id, etc.) are populated
from the verified session, so the author automatically owns the new record.
Scope sources per module
Each module exposes a menu of ownership sources. A role can be restricted to any OR-combination of
that module’s sources. $u = the verified session user_id.
| Module | Table | Available sources |
|---|---|---|
| Diaries | diaries | created_by (created_by = $u) · supervisor (supervisor_id = $u) · engineer_supervisors (via engineer_supervisor_assignments) |
| Prestarts | prestarts | created_by · supervisor · engineer_supervisors |
| Dockets | dockets | created_by · primary_supervisor (primary_supervisor_id = $u) · associated_supervisor (EXISTS in docket_associated_supervisors) · worker (EXISTS in docket_workers where worker_user_id = $u) · under_my_diary (diary_id IN diaries where supervisor_id = $u) · engineer_cost_codes (via engineer_cost_code_assignments) |
| Activities | activities | under_parent (parent diary/prestart supervisor_id = $u) · resource (EXISTS in activity_resources where user_id = $u) |
| Photos | photos | uploaded (uploaded_by = $u) · from_parent (transitive via source_type/source_id) |
| Issues | issues | raised (raised_by_id = $u) · supervisor (supervisor_id = $u) · owner (owner_id = $u) |
| Plant | plant_register | created (created_by = $u) · default_supervisor (default_supervisor_user_id = $u) · default_operator (default_operator_user_id = $u) |
Non-scopable modules (All only — no per-user owner concept): Induction Sign-Ons, Cost Codes, Contracts, Suppliers, Business Units, Org Settings, Roles, ERP.
Scope sources are OR-combined
If a role is configured with {supervisor, associated_supervisor} on Dockets, a user sees every
docket where they are either the primary supervisor or an associated supervisor.
Default preset scopes (seed)
| Role | Diaries | Prestarts | Dockets | Activities | Photos | Plant (module key plants) | Issues |
|---|---|---|---|---|---|---|---|
supervisor | supervisor, associated_supervisor | supervisor, associated_supervisor | primary_supervisor, associated_supervisor | under_parent | uploaded, from_parent | created, default_supervisor | raised, supervisor, owner |
senior_supervisor | All | All | All | All | All | All | All |
engineer | engineer_supervisors | engineer_supervisors | engineer_cost_codes | All | All | All | All |
client | created_by | created_by | created_by | resource | uploaded | created | raised |
| All other presets | All | All | All | All | All | All | All |
A senior supervisor sees every supervisor’s data on the projects they are a member of (owner
ruling 2026-10-06), so the preset has no scope row. It still counts as a supervisor for the
supervisor pickers: the eligibility check admits the senior_supervisor role key as well as a
supervisor source on Diaries or Prestarts.
The client preset is sealed to sources that resolve to zero rows for a client — they create,
supervise, upload and are assigned nothing — so the role sees nothing by default instead of
everything. Its plant module key is plants (the registry key), not the plant_register table
name shown in the source table above. Day reports resolve through the diaries module.
Full-access roles (master_admin, organisational_manager, project_admin) bypass scope
entirely — they always receive All regardless of any role_module_scopes row.
Storage
Scopes are stored in role_module_scopes(role_id, org_id, module_key, sources text[]). A row
present means the role is restricted to those sources (OR). No row means All.
Full-access semantics
Three presets carry is_full_access = true:
master_admin(locked — the only truly immutable preset)organisational_managerproject_admin
Full-access roles:
- Bypass gate 2 — treated as
Fullon every module; future permission keys are auto-included with no database update required. - Bypass gate 3 — always receive
Allrows on every module. - Do not bypass gate 1 — their avenue set still governs which surfaces they can enter. A
project_admincannot reach/org; amaster_admincannot enter/supplierunless itsrole_surfacesrow includesweb_supplier.
Engineer is NOT full-access
engineer keeps is_full_access = false.
Without assignments, an engineer sees everything on the project. Assignments narrow it.
- Diaries / Prestarts: with supervisors assigned on the project, engineers see the records
of those supervisors (via
engineer_supervisor_assignments). With none assigned there, they see every diary and prestart on the project. The scope source key isengineer_supervisors. - Dockets: with cost codes assigned on the project, engineers see the dockets under those
cost codes, plus dockets with no cost-coded hours (via
engineer_cost_code_assignments). With none assigned there, they see every docket on the project. The scope source key isengineer_cost_codes.
Each kind is decided on its own, per project: supervisor assignments never narrow dockets, cost codes never narrow diaries, and assignments on one project never narrow another. Membership still gates everything: an engineer sees nothing on a project they are not a member of.
The rule has one home, backend/pkg/authz/engineer_scope.go. Scope(InProject),
ScopeAcrossProjects and ReadAcrossProjects apply it, so every list, detail, export, report
and the objects API follow it. GET …/engineer-assignments/my-scope builds its answer from the
same decisions, so the web filters and the server always agree. The get-by-id endpoint applies
the same predicate, so a direct URL never shows an engineer a record outside their scope.
Who can be assigned. The two assignment writes (PUT …/engineer-assignments/supervisors and
…/cost-codes) check every person against the project before writing. The engineer must be
someone eligible-members?capability=engineer lists. Each supervisor must be someone
eligible-members?capability=supervise lists: a member of the project, in the caller’s org,
whose role there can supervise. A request that names anyone else is refused as a whole with a
400 (engineer-not-on-project / supervisors-not-on-project), and nothing is written. Cost
codes are free text that can only match dockets on the same project, so they are not checked
against the register.
Preset editability
Every preset is editable in the role editor at /org/roles except master_admin, which is
permanently locked and read-only.
When you edit a full-access preset (organisational_manager or project_admin) and remove any
module grant, the system automatically:
- Clears
is_full_accesson that role record. - Materialises explicit
role_permissionsrows for every previously-implicit grant.
Reverting the role back to all-Full checkboxes restores is_full_access = true.
Rollout behaviour
The LM-64 migration is fully additive — it adds new tables and seeds defaults; no existing rows are modified in a way that removes access. Specifically:
- Every existing role receives its seeded
role_surfacesrows to reproduce current day-one behaviour. Turning weak redirect-only web enforcement into a hard middleware block does not lock anyone out. - Every existing role receives
Allscope on every module (exceptsupervisorand, until 2026-10-06,senior_supervisor, which received the supervisor-scoped seed above) — so no row vanishes for any user on the day of deployment.20261006125307_senior_supervisor_sees_all.sqlremoved the senior supervisor’s default rows, leaving any row an org admin had changed. diaries.created_byandprestarts.created_byalready exist. Onlydockets.created_byis new (nullable; back-filled as null for existing rows; populated going forward). “Created by me” on dockets matches fewer rows until future dockets are created.
Live revoke
Avenue and scope changes flow through the same live-revoke pipeline as permission changes:
org admin edits role → DB write
→ NOTIFY org_changes trigger (bumps authz_version)
→ Go API cache invalidated
→ WebSocket broadcast
→ usePermissions() refetches
→ nav + affordances update (~seconds)A revoke is effective within ≤ 60s even if a WebSocket notification is missed (the cache TTL is 60s, fail-closed).
Worked examples
A — Supervisor sees only their own diaries
Role supervisor is seeded with Diaries → [supervisor].
At request time:
-
Avenue gate: user’s effective surfaces include
web_admin→ diary list page loads. -
Module tier:
Can(ctx, diaries.view)→ granted (supervisor preset has View on Diaries). -
Scope gate:
Scope(ctx, "diaries")→{All: false, Sources: ["supervisor"]}. Query becomes:WHERE project_id = $project AND tenant_id = $org AND (supervisor_id = $u) -- scope predicate
A supervisor navigating to /admin/diaries?project=X sees only the diaries they supervise.
Opening another supervisor’s diary URL directly returns 404.
B — Engineer assigned to supervisors A and B
Role engineer is seeded with Diaries → [engineer_supervisors].
The engineer is assigned supervisors A and B in engineer_supervisor_assignments. At request time
the scope gate resolves to:
WHERE project_id = $project AND tenant_id = $org
AND supervisor_id = ANY($supervisor_ids) -- [A.id, B.id]A diary supervised by C is invisible, even via a direct URL. An engineer with no supervisors assigned on the project has no supervisor filter there: they see every diary on the project.
C — Project Admin sees all rows, but only on /admin (not /org)
Role project_admin has is_full_access = true and avenue set {web_admin, mobile_office}.
- Avenue gate: user tries to navigate to
/org/settings→web_orgabsent from their surfaces → middleware returns 403 “Not authorised for this surface.” - On
/admin/*: avenue gate passes → scope gate returnsAll(full-access bypass) → the user sees all rows on all modules.
Related pages
- Roles & Permissions — the base RBAC primitive (LM-63): permission keys, role editor, assignment, live propagation.