Skip to main content

Permissions Model

TuffOps uses permissions, not roles, as the unit of access control. A user can hold any combination of permissions, and the UI shows or hides actions based on what they hold. Roles in TuffOps are just convenient bundles of permissions — they're not magic.

The key pattern

Every permission key follows the form area.action. Examples:

  • customers.view, customers.create, customers.edit, customers.delete
  • units.view, units.create, units.edit, units.delete
  • work_orders.view, work_orders.create, work_orders.edit, work_orders.supervise, work_orders.delete
  • quotations.view, quotations.create, quotations.edit, quotations.delete, quotations.send
  • customer_requests.view, customer_requests.create, customer_requests.edit, customer_requests.delete
  • invoices.view, invoices.create, invoices.edit
  • checklists.view, checklists.create, checklists.edit, checklists.delete
  • settings.view, settings.edit
  • users.view, users.create, users.edit, users.delete

Areas line up with TuffOps modules; actions are the verbs you'd expect.

A few non-CRUD actions show up where the area needs more nuance:

  • quotations.send — sending a quotation to the customer (which also locks it). Separate from quotations.edit.
  • work_orders.supervise — broader transition rights (cancel, approve, send back) plus visibility of work orders not assigned to the current user. Without supervise, a user only sees their own work orders.
  • work_orders.createalso the gate for converting an accepted quotation into work orders. There is no separate quotations.convert permission.
  • invoices.edit — the gate for cancelling an invoice. There is no separate invoices.cancel permission; cancellation is the only "edit" the invoice surface allows.
  • compliance.* — Part 84 compliance actions (see Compliance).

If the action you want isn't visible, check the permission first.

Typical role profiles

Most operations end up with a small set of roles. These aren't built in — you assemble them from permissions — but they're the patterns that work:

RoleTypical permissions
Technician*.view on customers/units/work orders/checklists, work_orders.edit (start, pause, submit only). No work_orders.supervise, so the tech only sees their own work orders.
OfficeAll view, all create, all edit, plus quotations.send and invoices.edit. No work_orders.supervise, no delete.
SupervisorOffice bundle plus work_orders.supervise. With supervise, this user can cancel, approve, send-back, and see every work order — and can convert accepted quotations into work orders (gated by work_orders.create).
AdminEverything, including *.delete and users.*.

The seeded office role in config/permissions.php is wider than this “least privilege” sketch (it includes full work_orders.*, which implies work_orders.supervise and work_orders.delete). Treat the table as the shape many operators aim for after tuning config, not a byte-for-byte match to the repo defaults.

The reason the technician role shouldn't get work_orders.supervise in a tight deployment is the same reason work orders go through waiting_approval — techs shouldn't approve their own work, close jobs without office review, or see other techs' jobs. See Work Order Lifecycle.

How permission checks happen

Permissions are checked in three places:

  1. Routes — middleware on the route blocks unauthorized requests with a 403 before the controller runs.
  2. UI — buttons, menu items, and form fields are hidden if the user lacks the relevant permission. A user without units.delete doesn't see the Delete button.
  3. Backend logic — for sensitive transitions (work order status changes, quotation submission), the service layer also checks permissions before mutating state. This is defense-in-depth — even if a request slips through the UI and the route, the backend will refuse.

If you can see a button but the action fails with a 403, the route or backend check caught something the UI missed. That's a bug — please report it.

Assigning access in the UI

There is no "Roles" screen in Settings. Day-to-day access is managed on Settings → Users:

  • Create user — pick a Role from the dropdown (one role per user). That role’s permission bundle was defined in config/permissions.php and applied by SettingsPermissionsSeeder (or adjusted by a developer in the database).
  • Edit user — change the role, reset the password, and use Show permissions to grant extra direct permissions on top of the role. Permissions inherited from the role show as locked in the matrix; you remove them only by changing or removing the role.

To add a new named role or change what office / technician / admin include by default, edit config/permissions.php (roles section) and re-run SettingsPermissionsSeeder — that is a deploy / developer task, not something the Settings form exposes.

Custom access patterns

To design access for a person:

  1. List the screens they need. Each screen maps to a *.view permission.
  2. List the actions they need. Each maps to *.create, *.edit, *.delete, or a specific verb (quotations.send, work_orders.supervise, …).
  3. Pick the closest built-in role on Users → Edit, then add extra checkboxes for one-off permissions, or ask a developer to extend config/permissions.php if you need a new role name.

A few rules of thumb:

  • Always grant view for every area you grant any other action on. A user with units.edit but no units.view can't see what they're editing.
  • Don't mix delete permissions with view-only roles. A read-only role with delete buried in it is a footgun.
  • Audit supervise and send carefully. work_orders.supervise controls who can approve, cancel, and see other techs' work; quotations.send controls who can lock a quotation and put it in front of a customer.

What permissions don't do

Permissions don't control which fields on a record a user can edit. A user with units.edit can edit every field on a unit. Field-level access control isn't part of the permission model.

Scoping (which rows a user sees) is mostly tied to a permission rather than configured separately. The clearest example is work orders: without work_orders.supervise, the controller automatically filters the work orders list to only those assigned to the current user. So the act of restricting a tech to their own work is a side effect of not granting supervise — there's no separate "limit to own" toggle.