The three pieces: users, roles, permissions
A user is a person or a machine with an identity. A permission is one named thing that may be done: read this kind of record, approve this transition, export this report. A role is a named bundle of permissions. Users get roles, roles get permissions, and the system never asks whether this person is the manager. It asks whether this person holds the permission.
That indirection is the whole point. When a job changes, you change one role and everyone in it moves with it. When a new permission appears, you grant it to the roles that should have it instead of hunting for every place a job title was named in code. Roles and their grants live in the database, editable from a panel, not compiled into a deploy.
Where the check actually happens
A request passes through several layers. A session layer attaches whoever is signed in. A route guard decides whether a page renders. The interface hides the buttons a role cannot use. All three are useful and none of them is the boundary, because each one guards a path to the data rather than guarding the data itself.
Path matching is the weak part. Guards are written against patterns, and real routing has groups, rewrites, locale prefixes and catch-alls; the two drift apart the first time somebody adds a route. A guard that quietly stops matching fails open: the page renders, the query runs, and nothing in the system objects.
The check that holds runs inside the function that reads or writes the data. It re-derives the identity from the request, looks up the role, asks whether that role holds the permission this operation requires, and refuses when it does not. Because every caller goes through that function — the page, a phone, a scheduled job, an integration — there is no path around it.
Row-level access: which records, not just which screens
Holding a permission answers what kind of thing you may do. It does not answer which rows. A client account and a staff account may both hold read access to orders, and the client must only ever see its own, never somebody else's. That is a separate check, and it belongs in the same place: the query, filtered by the identity that asked, before anything is returned.
The tempting shortcut is to fetch everything and filter it in the interface. On screen it looks identical, and it is not the same system, because the data already left the boundary. Anything that can see the response — a browser console, a proxy, a saved network log — sees the rows that user was never entitled to. The filter has to be inside the query.
This is also how a channel with outside parties works. A supplier or a contractor gets a role whose reads are scoped to the cases they are on and the fields you listed, and nothing adjacent: no internal comments, no other clients, no thread from the case next door. Same mechanism, different scope: one role that may read every row, another that may read only its own.
What a working implementation looks like
In practice it is four things: a catalog of named permissions, a table of roles, a table that joins the two, and a table mapping each route to the permission it requires. All four are data, so an administrator edits them from a screen and the change takes effect without a deploy. Navigation reads the same grants, so nobody is shown a door they cannot open.
Two things make it auditable. Every change to a role or a grant is recorded with who made it and when, so a permission that appeared can be traced back. And removing access never removes history: you suspend an account, its sessions end, and what it did stays on the record. Access is the present tense; the audit trail is the past tense.