A permission called "read your own certificates" does not know whose certificates you are reading
This system has a staff portal, and the portal has its own permission family: read your own profile, edit your own profile, read your own certificates, upload a certificate, see your own training.
Every one of those routes is gated on the matching permission. The gate works. It checks the user’s granted permissions, and it returns a 403 naming the permission that was missing.
And it does nothing whatsoever about the word “own”.
The gate answers a different question
A permission check answers “may this user perform this kind of action”. It cannot answer “on which rows”, because it has never been told which rows the request is about. Those are two different questions and they are resolved at two different points in the request.
In this codebase the second question is answered by a nineteen-line helper that finds the staff record linked to the logged-in user, plus an eight-line preamble repeated inside each of the eight portal handlers: call the helper, return a specific error if there is no linked staff record, then add an equality on the staff identifier to the query’s filter.
Eight copies. All eight are correct today.
The ninth handler is the problem. Somebody adds an endpoint to the portal — a summary, an export, a new list — gates it on the right permission, writes the query, and does not include the equality, because the equality is not in the gate and there is nothing in the shape of the code to remind them. That endpoint then returns the whole organisation’s credentials to any member of staff, and it will pass review, because the route declares a permission whose name contains the word “own”.
The most dangerous kind of convention is one where the safe version and the unsafe version look
identical at the call site. Adding a permission to a route is visible. Forgetting a where clause is
invisible.
What it should have been
The helper should not be a helper. It should be a gate of its own, running before the handler, which resolves the caller’s staff record and attaches it to the request — and fails the request outright if there is no linked record.
Then the handler cannot run without a resolved subject. The identifier it filters on comes from the request object, not from a call the author had to remember to make, and the failure mode of forgetting is a missing variable rather than a missing predicate.
Better still, where the data access layer allows it, the scope becomes part of how the query is built rather than something added to it: a repository that only ever constructs queries for a given subject, so a handler cannot express “all staff” at all. That is the difference between a rule you follow and a rule you cannot break.
The general form: authorisation has two halves — the verb and the subject — and both halves need a mechanism. If only one of them has a mechanism, the other one is a habit.
The other end: gates that are too coarse
The same system has the opposite problem elsewhere, and it comes from the same confusion.
The endpoint that lists every employee’s credential records — names, expiry dates, verification outcomes, rejection reasons — is gated on the permission for reading the certification catalogue. The catalogue is the list of course types the organisation recognises. It is reference data. Its permission’s display name says “view certification catalogue”.
A read-only role that was granted the catalogue permission so that its holders could see what a given credential means therefore also sees every named person’s compliance record. There is no separate permission for reading staff credentials, so there is no way to grant one without the other.
The same pattern shows up on the dashboard, which is gated on the permission for reading organisation settings. That permission’s display name says “view organisation settings”. It is used across the dashboard, the calendar and the action centre, and the frontend uses it to decide which page a user lands on after signing in. It has quietly become a role flag meaning “is a back-office user”, and it is now load-bearing in four places — the database seed, the shared constants, the route definitions and the client.
When a permission gates something outside the noun in its own name, it has stopped being a permission. The cost of that is not felt on the day it is done. It is felt on the day somebody asks for a role that can see the dashboard but not the settings, and the answer is that the two cannot be separated without a coordinated change across four places.
The test is simple and worth running on your own permission list: read each permission’s name aloud, then list every endpoint it gates. If the list contains something the name would not have led you to expect, you have found one.
The grant that was too generous
One more, because it produces a visible symptom that gets patched in the wrong layer.
The administrator role is granted every permission by a query that selects all of them. That includes the five portal permissions, so an administrator holds “read your own certificates”.
Administrators do not have staff records. So the portal navigation appears for them, they click it, and the endpoint returns the error meaning “you have no linked staff record”. The fix applied was a flag in the frontend that hides the portal navigation for those users.
That is patching the symptom on the far side of the system from the cause. The cause is a grant that says “all permissions” when it means “all administrative permissions”, and the portal family is categorically different: those permissions are about a person acting as themselves, not about a person administering an organisation.
Granting “everything” is a decision to grant every permission that will ever be added, including the ones that do not make sense for that role. Enumerate the grant, or at minimum exclude the family that has a different subject.
The one thing that was right
Worth ending on, because it shows the same author thinking clearly about the same problem.
The portal permission for adding a credential is called upload, not write. That is deliberate. Staff may create a submission and edit it while it is pending, and may not touch it once a manager has verified it — enforced in the route, which refuses the edit with a specific error once the record has left the pending state.
The name encodes the intent and the route enforces it. That is exactly the right relationship between a permission’s vocabulary and its enforcement, and it is the model the rest of the list should have followed.
The rules
Never let “own” live only in the query. Resolve the subject in a gate, attach it to the request, and make the handler unable to run without it.
One permission, one noun. If you cannot describe what a permission protects in the words of its own name, split it before somebody builds a role around the overlap.
Enumerate broad grants. “All permissions” is a grant to permissions that do not exist yet.
The honest limit: the eight copies are still eight copies. Turning them into a gate is a small change and it touches the file that serves every self-service screen, which is the file you least want to be wrong. It is scheduled and it has not happened, and until it does, the protection on those endpoints is that eight people got eight lines right.