atombit
← All insights

Architecture

Role-based access control that stops at the record, not the screen

Hiding a button is not access control. Why permission checks belong at the query, what per-record scoping actually requires, and where an audit log earns its keep.

atombit · 7 min read · 23 August 2026

Most access control that ships is screen-level: a role decides which menu items and buttons render, and that is treated as the security boundary. It is not one. Hiding a button stops a user clicking it — it does not stop the API endpoint behind that button being called directly, and it does nothing at all for the record a user was never supposed to see in the first place.

The gap shows up in a specific, common shape: a receptionist role that should only see records for clients they created, or a clinician role scoped to their own patients, gets built as "receptionist sees the client list" — which is a screen permission, not a record permission. Every receptionist sees every client, because nobody encoded the actual rule: this record, not this screen.

Two different questions, usually collapsed into one

"Can this role use this feature" and "can this user see this specific row" are different checks with different failure costs. Getting the first one wrong shows a menu item that then 403s — annoying, visible, cheap to fix. Getting the second one wrong leaks another person's data silently, because the query ran successfully and returned rows nobody should have handed back.

Both checks matter, but only the second one is actually security. The first is UX.

Where the record-level check has to live

Not in the controller, and not as an if statement a developer has to remember to add to every endpoint that touches the entity. The same argument that applies to multi-tenant isolation applies here: a rule enforced by memory is a rule that eventually gets forgotten. The scoping predicate — "own records only", "assigned clients only", whatever the role's actual rule is — belongs in the data-access layer, applied the same way regardless of which endpoint is asking.

In practice that means a query filter keyed on the authenticated principal's role and their ownership relationship to the entity, not just their role in isolation. "Receptionist" alone is not enough information to scope a query — "this specific receptionist, and the client records they are the assigned owner of" is.

The audit log is the other half of the answer

Prevention will occasionally be wrong — a scoping rule that was correct on day one stops matching how the business actually works, a role gets a permission it should not have, an edge case nobody modelled. When that happens, the question changes from "did we prevent this" to "can we tell what happened", and that only works if every write is logged before the incident, not reconstructed after it.

A useful audit log records, per write: who, what changed (before and after, not just "record updated"), when, and through which action. It has to be append-only from the application's perspective — a table a normal role cannot edit or delete from — or it is not evidence, it is a suggestion. And it has to be queryable by someone with the right permission, not just captured and forgotten in a log file nobody opens until there is already a problem.

What this looks like end to end

On a system built this way — a veterinary practice's clinical and commercial platform is the clearest example here, where clinicians, front-desk staff and management all work against the same records with genuinely different scopes — the pattern holds across every entity: role decides the shape of the query, ownership decides the rows it can touch, and every write lands in a log that answers "who did this" without anyone having to ask the person who did it.

None of the individual pieces are unusual. What is easy to get wrong is treating the screen-level check as if it were the whole system, and finding out otherwise only once real data is in the database.