Correcting a price and opening a till are not the same act. They do not concern the same people, and one of the two touches the cash. What is left is deciding where that rule lives.
Two answers, both defensible
Coarse roles in the token, and the business rule in the code. A developer opening the project reads it. It can be tested without starting the identity server, it goes through code review, it lives in history, it ships with everything else.
Fine-grained authorization policies in the identity server. Keycloak can describe resources, scopes, policies and permissions. Changing a right then takes no deployment at all, rights sit where identities sit, and an audit reads on a single screen.
Two failure modes, neither of them theoretical
On one side, the role subdivides. ROLE_ADMIN, then ROLE_ADMIN_STORE, then ROLE_ADMIN_STORE_PRICE. The token grows with every nuance — and it grows inside an HTTP header.
This is not an abstract worry. On a platform whose multi-tenant migration I have written about elsewhere, the super administrator's token reached 21,903 bytes once the permission catalogue was aligned, eight times an ordinary user's. Web servers accept headers of 4 to 8 KB by default; beyond that they answer 400 Request Header Or Cookie Too Large. Seen from outside: exactly one account can no longer sign in, with no usable message, while every other account works.
On the other side, the business rule goes off to live in an administration console developers never open. It leaves the history — and therefore code review, tests, and reproducibility from one environment to the next. "Why has that accountant stopped seeing credit notes since Tuesday" becomes a question whose answer is nowhere in the repository.
What actually decides: the pace
The criterion that helps me settle it is not doctrinal. It is a frequency: does this right change at the pace of the business, or at the pace of the code?
A right that moves more often than you deploy has no business in an annotation. A right that has not changed in three years has no business in an administration console, where nobody reads it again and nothing tests it.
The possessive test
There is an even simpler clue. Write the rule out in plain language, and look for the possessive.
- "A cashier opens a till" — no possessive: that is a role.
- "A cashier opens their till" — possessive: that is no longer a role, it is data. No role will ever say which one.
A right that depends on a row is decided in front of the row, in the API, with the database. Never in a token, which is only a photograph taken at sign-in.
Five levels, and only one in the token
On a distribution platform — three applications in front of a single API — rights ended up stacked across five levels.
| Level | The question asked | Where it is decided |
|---|---|---|
| Group | which team do you belong to? | identity server, in the token |
| Menu | what do you see? | interface — comfort |
| Route | where may you enter? | API |
| Filter | which rows do you see? | API, with the database |
| Action | what may you do with them? | API, with the database |
Only one of those levels travels in the token. Keycloak says who you are and which group you belong to; the API decides what that opens. The token stays small, the rule stays readable, and a fine-grained right changes without a deployment — because it lives in the database.
What this does not excuse
Two reminders, because they always come up in the same conversation.
A menu that hides an action protects nothing: it is interface comfort. The rule is enforced by the API, or it is not enforced.
And a green test suite proves nothing about authorization until some test calls every route with an account that has no rights and expects a 403. That test is mechanical, it can be generated — and its absence shows up in no coverage report.
What I take from it
There is no universally right place for an authorization rule. There is a pace of change, and a place that matches it. Choosing the wrong place is not paid for on release day: it is paid for the day the right changes.