read.mir0n.pro

A missing filter looks exactly like a correct query

Out of curiosity, I walked through open-source projects where access rights are governed by the org hierarchy to see which solutions their authors had chosen. Six of them, plus my own, Esquire.

The difference is not the model — that is more or less the same everywhere. The difference is whether a developer has to think about visibility at all.

Ideally, you add a method, or an SQL query, and the visibility rule applies automatically. In practice, it often does not, and nothing warns you. Not the compiler, not the review, not the tests — not until someone sees rows they should never have seen.

In backoffice systems, the answer is positional. A user's place in the org chart defines their scope — what they may see: accounts, orders, loans. In the code — that is a WHERE clause.

// 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
}

Both are ordinary methods. One is bounded by the user's position; the other is not. And nothing indicates this.

No general-purpose tool catches it. A missing authorization filter has no shape. It is not a null check skipped, not an exception swallowed — both of which tools can detect. An absent clause leaves no trace to find.

One question, seven answers

Two criteria for a project to count: 1) visibility is decided by the org chart, and 2) open source.

The question I asked each: if a developer building a feature adds a query tomorrow and does not think about visibility, could a leak occur?

There are two roles. 1) Someone adapts the project to a business domain: introduces the data model, writes the queries, and puts the filters in the right places. No project verifies that the filters are correct. 2) After adaptation, developers build features on top of what is already there.

Project Model Stack How visibility is enforced Can a new query leak
OrgSectreeJava, Spring Boota PrivilegeChecker that the developer injects and callsYes
Sz-AdminflatJava, Spring Boottry (var ignored = new DataScopeSession(X.class)) opened per queryYes
Apache FineracttreeJava, Spring BootvalidateAccessRights, called in four places, plus roughly fifty hand-written hierarchy like ? fragmentsYes
iam-zero-trust-referenceflatPython, FastAPIthe token's tenant_id, checked with validate_tenant_access at the start of each endpointYes
multi-tenant-access-management-platformflatJava, Spring Boota Hibernate @Filter, switched on once per requestNo
AAuthtreePHP, LaravelEloquent global scopes, applied to every query on the modelNo
EsquiretreeJava, Spring Bootreads take the user's position and match the stored path by prefixNo

A tree is a hierarchy, usually with no limit on levels. A flat model is a list of organizations, departments, or tenants — a tree with a single level and no inheritance. Every project implements its model slightly differently, but with the same purpose: to define the visibility scope.

The projects use different models, languages, and stacks. The architecture question is the same. In four of them, visibility can leak. Two of those warn the developer that there is a rule to remember.

Apache Fineract does not: the void validateAccessRights(String resourceOfficeHierarchy) signature is the only documentation. iam-zero-trust-reference does something different — it says "All resource endpoints are scoped to a tenant", which describes the URL, not the check. The requirement to call validate_tenant_access appears only as a comment inside a code example.

Where the rule applies itself, and what that costs

Three projects apply the rule automatically. Each does so in a different layer.

In the controller (multi-tenant-access-management-platform: Hibernate @Filter, enabled once per request). One enforcement point covers methods written in the future. The forgettable step moves from every query to a single place tied to the request — and that is the cost. When the filter is not enabled, the query reads everything.

In the ORM model (AAuth: Eloquent global scopes). Laravel adds the constraint to "all queries for a given model", so queries not yet written are covered too. The cost is that every caller gets the same filter. A read that is entitled to be broad has no way to say so, except to switch the protection off — and withoutGlobalScopes is one call. As the filter belongs to the model class, an out-of-model query like DB::table('loans') never uses it.

In the repository (Esquire). The user's position arrives as a claim in their token, and every read is bounded by it: WHERE ep_path LIKE :rootPath || '%'. The cost is that the path value must be correct — maintained on create and reparent.

In the database, nobody does it, though it is a good place for it. Postgres row-level security applies a policy to the table itself, so every statement is covered and the rule never has to pass through your application. The cost is a session variable. Someone has to set it for each transaction. Forget SET LOCAL, and a pooled connection acts with whatever the previous transaction left behind. Many database vendors have something similar. And, well, every one of them ties you to the vendor — while most of the listed projects are database-agnostic.

There is no free option.

A second axis: where the position comes from

The position has to come from somewhere. There are two ways.

OrgSec supports both: it can come from an in-memory store or Redis for derived, and from a claim for carried.

For a signed-in user, the position is always available: it arrives in the token or is read from stored user state. The difference appears with system users such as batch jobs, schedulers, and message consumers. They read and write the same rows and need a position.

In a derived system, a batch job has user state only if someone created it. Without it, the visibility check either fails or grants visibility to everything, making the batch job an exception to the rule.

A carried system has no separate route: the batch job gets a token the same way any other user does. The cost is synchronization: the position claim in the token must match the org tree.

What my own system got wrong

The breadcrumb lookup in my framework's tree cache took an entity id and returned that entity's path within the org tree. The method had no position argument, so a user could read entities outside their scope.

That was a bug in the framework itself. It sat there for months.

That is what sparked the curiosity behind this article.

After the fix, that method goes through a scoped lookup and no longer answers for ids outside the user's scope.

The interface is public: IBizTreeCacheRepository.java. findPath(id) takes no position and serves the cache's own upkeep; findPathScoped(id, rootPath) is the lookup a caller uses.

What to check in your own codebase

Five checks. It takes about an hour to figure out whether your visibility can leak.

  1. Find the scoping clause. Grep for hierarchy like, org_id =, tenant_id =. Count the places it is written by hand. That would be the number of places the next developer can miss the rule.
  2. Find the escape hatch. Every automatic mechanism has ways to switch the filter off: withoutGlobalScopes, an admin repository, a "system" user. Read every use of these.
  3. Follow a batch job. No signed-in user behind it. What position does it run with, and where is that defined?
  4. Write the query a new developer would write. One method, no position, on your real schema. Does anything go red — compiler, test, linter, review?
  5. Ask how the position could be wrong, not missing. A stale path, a stale token. That one needs a periodic check against the org tree, not a code review.

If any check finds a gap, you rely on attention, and attention is not a control.

What I found: other people's code taught me more than my own did. The model is not what makes the difference; whether the visibility rule applies itself automatically is.


Appendix: The projects

The projects named here were read against their own documentation and code.

OrgSec (Nomendi6) — tree: companies and organizations, stored as COMPANY_PATH / ORG_PATH on the row. "Spring Boot library for organizational role-based access control." Java, Spring Boot. It keeps no database of its own: its backends are in-memory, Redis, or a JWT. Identity read from a JWT; it ships a Keycloak mapper.
Sz-Admin (feiyuchuixue) — flat: departments; a row carries dept_scope, the list of departments allowed to see it. The backend of an admin scaffold: roles, menus, data scope, code generation. Java, Spring Boot, MySQL or PostgreSQL through dialect modules and Liquibase, Sa-Token for sign-in.
Apache Fineract — tree: offices, in a hierarchy. The Apache Software Foundation's core banking platform. Java, Spring Boot, PostgreSQL; MariaDB and MySQL still run but are deprecated. Sign-in by HTTP Basic, or OAuth2 in a separate mode.
iam-zero-trust-reference (laugiov) — flat: tenants; the tenant id travels in the token. A reference for zero-trust identity with role-based authorization: Python, FastAPI, Postgres through SQLAlchemy, OIDC through Keycloak. Mostly documentation: guides, decision records, a threat model.
multi-tenant-access-management-platform (aatircodes) — flat: organizations; one organization id per row. In its author's words, a solo portfolio build: Java, Spring Boot, React frontend, MySQL, Redis, self-issued JWT.
AAuth (AuroraWebSoftware; Packagist, source archive) — tree: organization nodes, stored as materialized paths. A Laravel package combining three access-control models: organization tree, attributes, and roles. PHP, Laravel, database-agnostic, with Laravel's own authentication. Its GitHub organization is gone; the source is archived.
Esquire — tree: entity nodes, with a path on every row. An application framework for backoffice systems, where the org chart decides visibility: roles say what you may do, position says over whom. Java, Spring Boot, Angular frontend, Postgres or Oracle, identity in Keycloak. One org tree structure per deployment: a company that needs isolation from another runs its own. Running at esquire.mir0n.pro: sign in with mainadmin / q and you see the whole tree; sign in lower down, as departmentadmin / q for example, and the same screens return less. Same build, same screens, same queries. It is a demonstration tree, and its data is reseeded from time to time.