A missing filter looks exactly like a correct query
Most business software answers the same question a few thousand times a day: which rows is this person allowed to see? In a lot of systems the answer is positional — you see your branch, your region, your subtree of the organisation. The question is settled, the model is agreed, everybody knows the rule.
Then it comes down to a WHERE clause. And a WHERE clause that is not there looks like nothing at all.
// written when the feature was designed, reviewed carefully
List<Loan> findForOffice(String officePrefix, String status) {
// ... WHERE o.hierarchy LIKE :officePrefix AND l.status = :status
}
// added eight months later, for a dashboard, by someone in a hurry
List<Loan> findByStatus(String status) {
// ... WHERE l.status = :status
}
The second method compiles. It passes review — there is nothing in it to object to. It has a test, and the test passes, because the test data belongs to the test user. Static analysis has no opinion: Checkstyle, SpotBugs, Error Prone and Sonar all see a perfectly ordinary query. It goes to production and returns other people's rows to whoever calls that dashboard.
This is not a hypothetical failure mode. It is the normal one. A missing authorization filter has no shape — it is not a null check that was skipped or an exception that was swallowed, both of which a tool can look for. It is the absence of a clause that a tool has no way to know should be present.
I spent a few weeks last month reading open-source projects that all scope authorization by hierarchy, because I have been building one for a while and wanted to know what everyone else had settled on. The useful result was not the model. Everyone has more or less the same model. The useful result was that half of them can forget, and half of them cannot, and the difference has a pattern.
Seven projects, one question
The question I asked each codebase: if I add a new query tomorrow and do not think about visibility, what happens?
| Project | How visibility is enforced | Can a developer forget? |
|---|---|---|
| Apache Fineract | validateAccessRights at three call sites, plus roughly fifty hand-written hierarchy like ? fragments | Yes |
| OrgSec | a PrivilegeChecker that the developer injects and calls | Yes |
| Sz-Admin | try (var ignored = new DataScopeSession(X.class)) opened per query | Yes |
| AAuth (Laravel) | Eloquent global scopes, applied to every query on the model | No |
| multi-tenant-access-management-platform | a Hibernate @Filter enabled at session level | No |
| iam-zero-trust-reference | at the gateway, before any business logic runs | No |
| Esquire (mine) | resolved in the data path from the entity's stored path | No |
Java, PHP, Python. Different decades, different sizes, one architecture question, and a clean split down the middle.
The three that made it structural each explain why, and they say the same thing. The clearest is from the multi-tenant access-management project's own design notes:
"Manually adding
WHERE org_id = ?to every repository method depends entirely on developer discipline — it works until someone writes a new query and forgets the check, and that one missed call is a cross-tenant data leak... one enforcement point instead of one per method, and it can't be skipped by an oversight in a future endpoint."
Three independent projects, in three languages, reaching the same sentence is not a coincidence and it is not anybody's private taste. It is the finding.
The split is not about care
The obvious reading is that some teams are careful and some are not. That reading is wrong, and the actual pattern is more interesting.
| Side | Which | What they are |
|---|---|---|
| Can be forgotten | Fineract, OrgSec, Sz-Admin | products and libraries carrying a decade of real feature load |
| Cannot be forgotten | AAuth, mtamp, iam-zero-trust-reference | a focused package, and two projects that exist to demonstrate correct patterns |
Apache Fineract runs core banking for real institutions. It is not a careless codebase; it is a codebase with an enormous feature surface, and its fifty hand-written hierarchy fragments got there one reasonable decision at a time. Sz-Admin documents its scoping session as a known pitfall — they know, they wrote it down, and it is still per-query, because that is what fits a system with that much else going on.
Enforcement does not collapse. It erodes — one reasonable exception at a time. A batch job has no interactive user, so it needs a path that skips the check. A reporting query spans branches by design. A migration touches everything. Each exception is correct on the day it is made, and each one turns a rule into a convention.
Which is the whole argument for making it structural: discipline is exactly the thing that erodes under feature pressure. If the property has to survive ten years of features written by people who were not in the room when it was decided, it cannot be a thing to remember.
And note who is on the good side of that table. Two of the three are reference implementations. They got this right partly because nothing else was competing for attention. That is not a criticism of them — it is a warning to anyone whose system is on that side today, mine included.
Four ways to make it structural, and what each one costs
The projects that cannot forget do it in four different places. They are not equivalent.
At the ORM model (AAuth: Eloquent global scopes). Every query on the model gets the scope, silently, including the ones nobody has written yet. Cheapest to adopt if you are already on that ORM. The cost is that it is invisible in the other direction too: a developer reading the query cannot see why it returns what it returns, and every framework with global scopes also ships a way to switch them off — which becomes the new thing to forget.
At the session (Hibernate @Filter, enabled once per request). One enforcement point, and it covers methods written in the future. The cost is that someone still has to enable it, so the forgettable step moves from every query to one place. That is an enormous improvement and it is not zero — an unfiltered session obtained on a background thread is back to square one.
At the gateway (iam-zero-trust-reference). Nothing reaches business logic without a decision. This is the strongest position for endpoint authorization and the correct place for it. The cost is that a gateway sees requests, not rows: it can decide whether you may call /loans, but the row filter still has to happen somewhere behind it, so this composes with one of the others rather than replacing it.
In the data (what I built). The position is a column on the row. A caller carries their position, and every read is bounded by it as a prefix, so "which rows" is answered by where the row sits in the tree rather than by a clause somebody remembered to add. The cost is that the tree has to be the truth: the path must be maintained on create, on move, and on every reparent — and if the path is wrong, the permissions are wrong, quietly. That is a real cost. It moves the risk from "did you remember the filter" to "is the path correct", and the second one is testable by a job that walks the tree and compares.
There is no free option here. There is only a choice about where the mistake would have to be made — in one place you maintain deliberately, or in any of two hundred query methods.
The one my own system got wrong
I would not have believed this piece if somebody else wrote it without this section.
My framework resolves visibility from a path column, and I have said for two years that it therefore cannot be forgotten. In August I read the whole thing back method by method, and found a read that was not bounded: the call that resolves an entity's path for a breadcrumb took an id and returned the path, full stop. Give it an id from outside your subtree and it answered.
It was fixed on 2026-08-26 — a second, scoped lookup, and the caller-facing method now goes through it: a path outside the caller root path no longer answers. It is one line in a changelog and it had been sitting there for months.
Three things about that, all of them the point of this article:
- The compiler never mentioned it. Nothing did. The unscoped method was a perfectly ordinary method.
- No test failed, because every test asked for a path it was entitled to see.
- It was found by a person reading code with the question in mind, which is the only method I know that works, and it does not scale.
So the honest version of my own claim is narrower than the one I used to make: every caller-facing read takes the caller's position and filters on it, and there is no code path today that reads the tree without one. That is checkable, and I checked it. It is not the same sentence as "the language prevents it", because it does not. The structural part is that the scope is a required argument of every read method rather than an optional call inside it — so forgetting it is not a silent omission, it is a method that does not compile without an argument you have to make up. That is a lower fence than a global scope. It is a much higher one than remembering.
When the hierarchy should not carry authority at all
The strongest argument against everything above comes from the most operationally experienced project I read. Athenz — Yahoo, LY Corporation, Vespa — has a dotted domain namespace that looks hierarchical, and refuses to make it authority-bearing:
"there is no inheritance or other relation between them other than that implied by their names... This allows all domains to be completely partitioned from each other, and the ownership of the entities defined within a domain is clear."
They considered inheritance and rejected it, because coupling creates action at a distance: granting a role high up silently changes what is visible far below, and ownership stops being local.
They are right, for what they govern. Athenz governs services, and the relationship between media.news.frontend and media.news is an accident of naming. Inheriting authority through that is inheriting through noise.
The distinction I would draw is this. Where the hierarchy is real, coupling removes a synchronisation problem. Where it is incidental, coupling invents one. A regional manager genuinely does have authority over their branches whether or not the software models it; if you refuse to model it, you will implement it anyway, by hand, in fifty places — which is where this article started. But if your "hierarchy" is a naming convention for services, do what Athenz did.
A second axis, briefly: where does the scope come from
Related, and worth a paragraph because it decides how your batch jobs behave.
- Derived — the scope is read from stored user state on each request (
user.office_id, a lookup). Fineract, Sz-Admin, mtamp. - Carried — the scope arrives in the request as a token claim. OrgSec, mine.
The difference shows up where there is no interactive user. A derived system has nothing to derive from on a batch path, so the check either throws or — more often — passes for everything, and the batch path becomes the exception that erodes the rule. A carried system has the same shape on both paths, because the batch job carries a token like anything else.
Carried costs you a synchronisation problem instead: whatever issues the token has to agree with whatever stores the org chart. Neither approach escapes it. Both move it somewhere you can see it.
What to check in your own codebase
Half an hour, and you will know which side of that table you are on:
- Grep for the scoping clause — your
hierarchy like,org_id =,tenant_id =. Count the call sites. If the number is large and hand-written, that number is also the number of places the next developer can miss. - Find the escape hatch. Every structural mechanism has one —
withoutGlobalScopes, a disabled filter, an admin repository, a "system" user. Count its uses and read each one. This is where the erosion actually lives. - Follow a batch job. No interactive user: what is the scope, and who decided it?
- Write the query a new hire would write. One method, no scoping, on your real schema. Does anything at all go red — compiler, test, linter, review checklist? If nothing does, you are relying on attention, and attention is not a control.
- Ask what would have to be true for the scope to be wrong rather than missing. In a path-based system that is a stale path; in a claim-based system it is a stale token. Whatever the answer is, that is the thing that needs a reconciliation job, not a code review.
The system this came out of
For context, since the examples above are half from it.
Esquire is an application framework I have been building for backoffice systems — the kind where the org chart is the permission model. Every entity carries its position in the tree as a path; a signed-in person carries theirs as a token claim; a read is bounded by that path as a prefix. Roles say what you may do, position says over whom. It is Java and Spring Boot on the back, an Angular library on the front, Postgres or Oracle underneath, and identity synchronised to Keycloak.
It runs as eight processes, or as five, or as four, from the same code — the composition is chosen at deployment, not at build time.
If you want to check the section above rather than take my word for it, both files are public:
IBizTreeCacheRepository.java—findPath(String id)is the one with no scope argument;findPathScoped(id, rootPath)is the one added beside it. Every other read on that interface takes the caller's position.bizTree/changes.txt— the entry dated 08/26/2026, "a path outside the caller root path no longer answers".
What it does not do, since that list is more useful than the other one: no separate realms inside one deployment (it is one organisation tree, and a customer who needs isolation gets their own deployment); no per-subtree quotas; and the entity kinds are a closed set rather than an open type system. And, per the section above, its "cannot be forgotten" is a required-argument fence and a reconciliation job, not a compiler guarantee.
I am not looking for users today. I wrote this because the thing I found while reading everyone else's code is more useful than anything I could say about my own: hierarchical scoping is not what distinguishes these systems. Whether the scope can be forgotten is. If your system is on the forgettable side of that table, you already know which query you are going to go and look at.
The projects named here were read in August 2026, each against its own documentation and code.
The system is Esquire, a framework for backoffice systems — Java and Spring Boot, an Angular front end, Postgres or Oracle, identity synchronised to Keycloak. The four repositories are public at github.com/mir0n-pro. A deployment is running at esquire.mir0n.pro — sign in with mainadmin / q, it is a demonstration tree and it is seeded fresh with each release. Signed in as mainadmin you see the whole tree; sign in lower down and the same screens return less, with no code anywhere deciding that.