A permission check that was always false, and why nobody noticed for months
A user whose access is limited to one branch requested a record belonging to that branch, by id, and got a 404. The same record appeared in the list view a moment earlier. So the system was simultaneously telling them the record existed and did not exist, depending on which screen they were looking at.
The scope information — which branches a user may see — is stored in a JSONB column on their membership record. JSONB holds strings. The application read the record’s branch identifier through the ORM, which returns it as a UUID object. The check was, in effect:
UUID("019f...") in ["019f...", "01a2..."]
That is always false. A UUID object and a string that happens to spell the same value are not equal in any language that distinguishes them. Every branch-scoped user was being denied access to every record, including their own.
Why it survived
Three things conspired to hide it, and each is worth understanding because each recurs.
The list view worked. The list filter built a database query rather than comparing in
application code, and the database happily casts the same strings inside an IN clause. So the
same conceptual check produced the correct answer in SQL and the wrong answer in Python. The
comparison was written twice, in two languages with different type semantics, and only one of them
was wrong.
It failed closed. This is the important one. The broken check denied access rather than granting it. There was no leak, no incident, no alert. From a security standpoint that is a mercy — a fail-open version of this bug would have been a cross-tenant data exposure. But it also meant the failure produced no signal that anyone monitors. Nobody instruments denials that should have been grants. The system was doing something wrong and the wrongness looked like the system doing its job.
It was praised in review. A set of endpoints carrying this check had been described in a review as stricter than their parent endpoints. They were stricter. They were denying everything. “Stricter than expected” and “broken” have the same appearance from the outside, and a reviewer looking for missing checks will not flag a check that is present and firing.
Then the same hand-rolled comparison turned up again in a completely different module, found in a later audit. Same shape, same silence, same result: a scoped user getting a 404 on their own record while the list showed it.
The two lessons
A comparison written twice will eventually disagree with itself. The fix was not to correct the comparison. It was to delete both hand-rolled copies and route everything through one shared scope helper — including the sync endpoint, explicitly, so that the two paths cannot drift apart later. Anywhere you find the same authorisation logic expressed in two places, you have a future divergence with a date on it, and the date is whenever someone fixes one of them.
A list view and a detail view must never disagree about whether a record exists. If a record appears in a list, fetching it by id must succeed; if it does not appear, fetching it by id must 404. This sounds obvious and is violated constantly, because list filters are written as query predicates and detail checks are written as procedural code, by different people at different times. It is worth stating as an explicit rule in a codebase, because once stated it becomes something a reviewer can actually check.
The related sweep, and the status code that matters
Fixing the comparison led to auditing every endpoint that takes a record id. The register named three that were missing a scope check. The audit found thirty-seven — ten reads and twenty-seven mutations, including confirming and cancelling orders, packing and delivering dispatches, issuing and voiding invoices, allocating payments, and renaming and deleting branches.
That ratio is the thing to take away. Whoever compiled the original list was not careless; they listed the ones they had encountered. Object-level authorisation is missed per endpoint, so the only reliable way to find the gaps is to enumerate every endpoint that accepts an identifier and check each one. There is no shortcut, and a sample tells you nothing about the population.
The shared check that replaced them raises 404, never 403. This is deliberate and it is a small but real information-disclosure decision: a 403 confirms the record exists and you are not allowed to see it. A 404 tells an attacker enumerating identifiers nothing at all. Use 403 only where the existence of the resource is not itself sensitive.
There was one endpoint that could not use 404, and it is an instructive exception. Setting a stock reorder threshold takes the target branch from the request body rather than the path. A branch-scoped operator could write a threshold into any depot in the organisation. Here the resource identified by the path is legitimately visible to the caller, and the thing being refused is the body value — so 403 is the honest answer. When the unauthorised part of a request is in the body rather than the path, the resource is not what you are hiding.
The failure mode worth internalising
Most discussion of authorisation bugs is about the fail-open case, because that is what causes breaches. But the fail-closed case has properties that make it harder to find:
- It generates no security alert, because nothing was leaked.
- It generates support tickets that look like user error — “I can’t see my own record” reads as a configuration problem, and will often be resolved by giving the user broader access, which hides the bug permanently and widens their permissions for real.
- It passes any test that asserts an unauthorised user is denied. It fails only tests that assert an authorised user is allowed, and those tests are written far less often.
That last point is the practical takeaway. For every authorisation rule, write two tests: one proving the wrong user is refused, and one proving the right user is admitted. Almost everyone writes the first. The second is what catches a check that has quietly become a constant.
And when the two tests exercise different code paths — a list query and a detail lookup — write them against both. The bug here lived precisely in the gap between a check that was tested one way and implemented two ways.