Let's talk
engineering

Read-only sight of money is not authority over it

The system had two roles: owner and employee. Every domain route was mounted behind a check that required owner. Which meant that in practice, employees could not call any core business API at all.

That is the endpoint of every fixed-role model. It starts as a reasonable simplification, the roles turn out not to match how the organisation actually works, and the resolution is to give more people the higher role — because the alternative is that they cannot do their jobs. The permission model becomes a formality, and everyone is an administrator.

The rebuild replaced it with roles composed from around 77 granular permissions across 19 modules, named as module.verb. A user can hold multiple roles, and their effective permission set is the union.

Verbs are not just CRUD

The detail that makes this work is that the verbs include domain actions, not only create, read, update and delete.

Converting a proposal into a booking. Recording a payment against an invoice. Computing a partner payout. Issuing a purchase order.

Each of these is a state transition with real business meaning, and each is something an organisation might genuinely want to grant separately. Someone who can edit an invoice is not necessarily someone who may mark it as paid. Someone who can prepare a payout run is not necessarily someone who may release it.

If your permission verbs are only CRUD, your model cannot express the distinctions the business actually makes, and you will end up encoding those distinctions somewhere else — in a workflow status, in an approval table, in a conditional in a service class — where they are invisible to the permission system and cannot be configured.

Permissions are code, roles are data

The permission catalogue is defined in source, not in a database table. Roles are records that reference those permission keys, and roles are what administrators create and edit.

The rule stated: you never invent permissions at runtime, only roles.

The reason is that a permission is a claim about a code path. It means something only because some handler checks it. A permission created in an administration screen that no code references is a control that does nothing, and it will be granted to someone who then believes they have access they do not have. Roles, by contrast, are pure composition — new combinations of existing meanings — and inventing those at runtime is exactly what an administrator should be able to do.

The obvious alternative, making the role field a foreign key to a roles table, was considered and rejected. It forces one role per user, which fails the first time somebody covers two functions, and it still requires a permissions table and a join table, so it buys nothing.

Do not put permissions in the token

Permissions are resolved server-side per request, from a short-lived cache keyed on the user and organisation, invalidated by bumping a version counter for that organisation.

They are deliberately not embedded in the authentication token. The token is long-lived — a month — and a permission set inside it would go stale the moment an administrator changed a role. In the worst case, revoking someone’s access would not take effect until their token expired, which is the opposite of what revocation means.

This is a trade people make casually for the performance benefit, and the cost is that your authorisation data has the lifetime of your token rather than the lifetime of your decision. A cache with an explicit invalidation gives you most of the performance and none of that problem.

Scope is a property of the pair, not the role

The other system in this group added a dimension worth borrowing. Each grant carries a scope: records the user created, records belonging to their branch, or everything.

The important refinement is that scope belongs to the (role, permission) pair, not to the role. A branch manager might read organisation-wide figures and export only their own branch’s rows. Those are different risks and a role-level scope cannot express the difference.

The effective permission set is then the intersection of three things: what the role grants, which modules are enabled for that customer, and which plane the user belongs to. The narrowest wins.

Two failures worth learning from

The screen that used the wrong test. The mobile application chose between a manager view and a field view by asking whether the user could view invoices or proposals. Reasonable-looking. But the operations role legitimately held both — operations staff need to see bookings and their associated paperwork — so field staff would have been shown the director’s dashboard.

The lesson recorded, and it is a good one: read-only sight of money is not authority over it.

If a screen represents a level of responsibility, gate it on a permission that means that responsibility, not on a combination of read permissions that happens to correlate with it today. Those correlations break the first time someone reasonably grants a read permission to a new role. Where the right permission does not exist, add it — a permission whose only purpose is to describe a role’s standing is a legitimate thing to have.

The catalogue that made every checkbox identical. The permission catalogue endpoint returned objects with one set of field names, and the administration interface read a different set. Every checkbox therefore had an undefined key, all of them shared it, and ticking one ticked them all.

Not a security hole — the server checked properly — but a permission editor that cannot express a selection is a permission model nobody can configure, which produces the same outcome as not having one. It was fixed by normalising the catalogue at the service boundary, which is the right place: one translation, at the edge, rather than every consumer knowing the wire format.

Omit, do not hide

A last decision worth noting, because it differs between two systems in this group and both positions are defensible.

One approach: the server omits from the response any section the caller is not permitted to see, so a user without financial permissions never receives the figures at all. Client-side hiding is a presentation choice; omission is a security control, and only omission actually prevents the data reaching the device.

The other approach: render all navigation, disable what is not permitted, and show a clear refusal on the route. This is better for discoverability — users can see the product has capabilities they might ask to be granted — and it is a legitimate trade for non-sensitive modules.

The rule that reconciles them: hide the data, not necessarily the menu. A visible menu item that refuses politely is a usability decision. A screen that receives sensitive figures and declines to draw them is a leak.

Working on something like this?

We build this kind of software, and we staff the teams that do.

Get in touch