ModulesAvenues, Tiers & Scope

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?)
#GateDefaultBypass
1Avenue — which app surface can this role enter?deny any unlisted surface—
2Module tier — No view / View / Edit / Full per module (authz.Can)deny (default-deny)is_full_access roles
3Visible data — ownership-based row filterAll (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 keyNameRoutes / app groupEntry enforcement
web_orgWeb · Org console/org/*Middleware hard block + layout re-check
web_adminWeb · Project admin/admin/*, /dashboardMiddleware hard block + layout re-check
web_supplierWeb · Supplier portal/supplier/*Middleware hard block + layout re-check
web_clientWeb · Client portal/client/*Middleware hard block + layout re-check
mobile_officeMobile · OfficeExpo (office) groupGroup layout role check
mobile_fieldMobile · FieldExpo (field) groupGroup layout role check
mobile_clientMobile · ClientExpo (client) groupGroup layout role check
mobile_supplierMobile · SupplierExpo (supplier) groupGroup 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.

RoleDefault avenue set
master_admin (locked)web_org, web_admin, mobile_office
organisational_managerweb_org, web_admin, mobile_office
project_adminweb_admin, mobile_office
engineerweb_admin, mobile_office
senior_supervisorweb_admin, mobile_office
supervisorweb_admin, mobile_office
labourmobile_field
operatormobile_field
wet_hire_machinesmobile_field
dry_hire_machinesmobile_field
supplierweb_supplier, mobile_supplier
clientmobile_client, web_client
unassignedmobile_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.

ModuleTableAvailable sources
Diariesdiariescreated_by (created_by = $u) · supervisor (supervisor_id = $u) · engineer_supervisors (via engineer_supervisor_assignments)
Prestartsprestartscreated_by · supervisor · engineer_supervisors
Docketsdocketscreated_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)
Activitiesactivitiesunder_parent (parent diary/prestart supervisor_id = $u) · resource (EXISTS in activity_resources where user_id = $u)
Photosphotosuploaded (uploaded_by = $u) · from_parent (transitive via source_type/source_id)
Issuesissuesraised (raised_by_id = $u) · supervisor (supervisor_id = $u) · owner (owner_id = $u)
Plantplant_registercreated (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)

RoleDiariesPrestartsDocketsActivitiesPhotosPlant (module key plants)Issues
supervisorsupervisor, associated_supervisorsupervisor, associated_supervisorprimary_supervisor, associated_supervisorunder_parentuploaded, from_parentcreated, default_supervisorraised, supervisor, owner
senior_supervisorAllAllAllAllAllAllAll
engineerengineer_supervisorsengineer_supervisorsengineer_cost_codesAllAllAllAll
clientcreated_bycreated_bycreated_byresourceuploadedcreatedraised
All other presetsAllAllAllAllAllAllAll

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_manager
  • project_admin

Full-access roles:

  • Bypass gate 2 — treated as Full on every module; future permission keys are auto-included with no database update required.
  • Bypass gate 3 — always receive All rows on every module.
  • Do not bypass gate 1 — their avenue set still governs which surfaces they can enter. A project_admin cannot reach /org; a master_admin cannot enter /supplier unless its role_surfaces row includes web_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 is engineer_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 is engineer_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:

  1. Clears is_full_access on that role record.
  2. Materialises explicit role_permissions rows 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_surfaces rows 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 All scope on every module (except supervisor and, 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.sql removed the senior supervisor’s default rows, leaving any row an org admin had changed.
  • diaries.created_by and prestarts.created_by already exist. Only dockets.created_by is 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:

  1. Avenue gate: user’s effective surfaces include web_admin → diary list page loads.

  2. Module tier: Can(ctx, diaries.view) → granted (supervisor preset has View on Diaries).

  3. 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}.

  1. Avenue gate: user tries to navigate to /org/settings → web_org absent from their surfaces → middleware returns 403 “Not authorised for this surface.”
  2. On /admin/*: avenue gate passes → scope gate returns All (full-access bypass) → the user sees all rows on all modules.

  • Roles & Permissions — the base RBAC primitive (LM-63): permission keys, role editor, assignment, live propagation.