- Auth
- Architecture
- Python
- Functional Programming
Authorization that also filters data: the gains of an embedded engine
I moved the authorization decision into the application and got more than availability: the grants started telling which data each person sees.
This is the second of three articles about an authorization engine I created under a private contract. The first one covers the problem and the idea; this one covers what the engine delivered. The original code and the client's details stay out, and the examples were written from scratch, in a made-up domain.
“Can they or can't they?” — that's the question almost every authorization system answers. It seems enough, and in most tutorials it is. But once the application starts dealing with several organizations, with people who hold different roles in each one and with screens that list data from more than one of them at the same time, a plain yes or no no longer answers what really matters.
In the previous article, I showed how I solved authorization for an application without being allowed to touch the central permissions API: I moved the grants into the application's own database and the decision into the process, with an engine that compares declarative rules. Now I want to talk about the other side of that decision — what it delivered.
And, for me, the most important gain wasn't the most obvious one.
First things first: what was deliveredGain 1: the application no longer depended on another service to respondGain 2: each route's requirement became readableGain 3: authorization started saying “over what”Gain 4: the same role, in different scopesGain 5: new rules without touching the engineGain 6: fast tests, with no database and no networkAnd what did it cost?Roadmap and next stepsConclusion
First things first: what was delivered
For those just arriving, a quick summary of what was built:
- The grants live in the application's database: each grant is a role in a scope, like “administrator in organization A”.
- Each route declares what it requires: in one line, with the minimum privilege, the resource and the action.
- An engine compares the requirement with the grants: field by field, according to a schema, and returns the grants that authorized — not a plain true or false.
It's also worth recalling the context: I was one of the developers on the application, I was handed the task with the condition of not touching the central API, and the solution was mine. Everything I describe here applies to that application and to the moment it was delivered.
Let's get to the gains.
Gain 1: the application no longer depended on another service to respond
The first gain is the most direct one, and it was precisely the reason for the task. Before, every request had to wait for another service's answer before doing anything. Now, deciding is a function comparing data that's already inside the application.
In other words: if the central API slows down or goes down, the application keeps responding. Its availability is no longer multiplied by another service's, and every request saves a round trip across the network.
One caveat is worth highlighting: this gain comes from the design, not from a measurement. I don't have production numbers to show here — and I'd rather not make any up. What I can state is what the architecture guarantees by construction: no authorization decision in that application goes through the network.
Gain 2: each route's requirement became readable
With rules scattered across ifs, knowing who can do what requires reading the entire codebase. With the requirement declared on the route itself, the answer fits in one line:
@requires("user", "document", "read") async def list_documents(request): ... @requires("admin", "document", "remove") async def remove_document(request): ...
But why does that matter? Because when the rule is data, it can be listed, reviewed and audited. Anyone on the team can read what each route requires without understanding how the engine is implemented, and a rule change happens in a single place, not in every place it was copied to.
Gain 3: authorization started saying “over what”
This is, for me, the most important gain — and the least obvious.
Think of a screen that lists documents. The question “can this person read documents?” isn't enough, because they may be able to read the documents of some organizations and not of others. With an engine that only answers yes or no, authorization says “yes”, and the filter for which documents to show gets written somewhere else — usually in a query, sometimes by someone else, with a different interpretation of the same rule.
Since the engine I built returns the grants that authorized, the same decision already delivers the filter. Take a look below, using the engine from the previous article:
anas_grants = [ {"privilege": "admin", "resource": "*", "action": "*", "organization": "org-a"}, {"privilege": "user", "resource": "document", "action": "read", "organization": "org-b"}, {"privilege": "user", "resource": "report", "action": "read", "organization": "org-c"}, ] # the listing asks to "read documents" without fixing an organization LISTING_SCHEMA = { field: comparator for field, comparator in SCHEMA.items() if field != "organization" } requirement = {"privilege": "user", "resource": "document", "action": "read"} authorized_by = authorize(requirement, anas_grants, LISTING_SCHEMA) def visible_organizations(grants): organizations = {grant["organization"] for grant in grants} # a grant with a wildcard organization unlocks all of them return None if "*" in organizations else organizations visible_organizations(authorized_by) # {"org-a", "org-b"}
What's happening here?
- The org-a grant covers reading documents, because the resource and action wildcards apply to everything in that organization.
- The org-b grant covers exactly what was requested.
- The org-c grant is left out: it allows reading reports, not documents.
The query receives that set and returns only the documents from organizations A and B. In other words: authorization and data filtering can no longer diverge, because both come from the same decision.
The same reasoning applies outside the API. In one of the application's dashboards, the reports came from a BI tool embedded in the page. Tools like Power BI accept, when you embed a report, identities for row-level security (RLS) — the report only shows the rows those identities can see. The grants that authorized became those identities, and each person saw in the report exactly what authorization allowed, without a second rule written inside the BI tool.
Gain 4: the same role, in different scopes
Another gain that showed up early was being able to give different roles to the same person, each one in its own scope, without creating new tables or columns for it. Take Bruno, an administrator in organization A and a support agent in organization B:
brunos_grants = [ { "role": "admin", "privilege": "admin", "resource": "*", "action": "*", "organization": "org-a", }, { "role": "support_agent", "privilege": "user", "resource": "document", "action": "read", "organization": "org-b", }, ] remove_in_org_b = { "privilege": "admin", "resource": "document", "action": "remove", "organization": "org-b", } authorize(remove_in_org_b, brunos_grants) # []
Being an administrator in organization A lends nothing in organization B. To the engine, each grant is independent, and isolation between organizations is just one more compared field. For whoever manages access, giving or removing a role in an organization means creating or deleting a row — and nothing more.
Gain 5: new rules without touching the engine
In the previous article, I showed how a new dimension comes in as a field and a comparator. For me, that changed the way I looked at a new requirement. When a context rule showed up — a group within the organization, for example —, the question stopped being “how much of the code will I have to change?” and became “which comparator does this rule need?”.
The engine stays closed for modification and open for extension. It's the open/closed principle applied where it helps the most: in the part of the system that changes the most with the business.
Gain 6: fast tests, with no database and no network
Since the engine is a pure function, each rule got a test that just writes data and checks the result. There's no database to start and no service to simulate. Besides being fast, these tests work as living documentation: to know whether an administrator of one organization can reach another, you just look for the test with that name.
And what did it cost?
Obviously, it's not an approach with only upsides. Three costs showed up, and I'd rather make them clear:
- Extra generality: the engine became more flexible than the application needed at that moment. A schema with pluggable fields and comparators takes more care to read and maintain than half a dozen ifs. I believe it pays off when the rules grow, but it's an investment, not a free gain.
- A second source of truth: the application started keeping its own users and grants, while the others kept using the central API.
- Data up close calls for care: either the grants are read on every request, or they go into a cache — and, in the cache, a revocation has to take effect immediately.
The last two points deserve an article of their own, and that's exactly the next one.
Roadmap and next steps
At delivery, a few steps were recorded as the natural evolution of the engine:
- Caching the grants, with a revocation clearing that person's cache immediately.
- Validating the rules when the application starts, so a configuration error shows up before the first request, and not during it.
- The third article in the series, about the challenges: the legacy, the cache and the risks of a generic engine.
Conclusion
I started this article with the question “can they or can't they?”. After building this engine, I believe it's incomplete. The question an application with several organizations really needs to answer is “can they, over what?” — and when authorization answers both at the same time, data filtering stops being a parallel rule and becomes a consequence of the decision itself.
The other gains — independence from the central API, readable rules, tests without infrastructure — matter too, but they all come from the same choice: treating the rule as data and the decision as a function. That choice is what made it possible to deliver more than the task asked for, without touching what wasn't mine to change.
In the end, the lesson I take from it is this:
a good design decision doesn't just solve the problem that motivated it; it makes room to solve the next ones.
Yago MarinhoSenior Software Engineer. I build AI-powered products, from design to deploy.