Yago Marinho
Back to the articles
  • Auth
  • Architecture
  • Python
  • Microservices

Hands off the permissions API: how I took authorization off the network

When every app asks the same API whether a user can act, its slowness becomes everyone's. I split the data from the decision in a rule engine in Python.

Photo of Yago MarinhoBy: Yago MarinhoLast updated on:
💡
This is the first of three articles about an authorization engine I created under a private contract, as a developer on one of the applications in a larger ecosystem. I cover the problem, the idea and the decisions; the original code and the client's details stay out, and the examples were written from scratch, in a made-up domain.
Every application with users has to answer, on every request, a simple question: can this person do this? The most common way to answer it is to ask another service. And that's where a simple question can become the weak spot of an entire ecosystem.
This article is about how I solved authorization for one application without being allowed to touch the service that answered that question for all the others, and why the solution ended up being a small rule language in Python.

The problem I was handed

I worked as a developer on one of the applications of an ecosystem with several systems and data spread across several databases. Permissions lived in a central API: before responding, each application asked it whether that user could do that.
The application I worked on needed its own authorization, with users who have different roles in different organizations and routes that require different things. The task came with a clear condition: solve it without touching the permissions API. It served the other applications, and changing its behavior was not up for discussion.
A constraint like that looks like just a limit. In this case, it ended up guiding the solution.

Why a central permissions API becomes a single point of failure

A central permissions API is the most natural way to start: one place to register who can do what, and one place to check. There's nothing wrong with that choice at first. The cost shows up as the number of applications and the volume of requests grow, and it comes in three forms.

Availability multiplies

If every response depends on two services being up, the availability of the whole is the product of each one's availability. With illustrative numbers: an application with 99.9% availability, calling a permissions API with 99.5%, ends up at 99.9% × 99.5% ≈ 99.4%. On its own, it would be down about 9 hours a year. Depending on the other one, that goes past 52.
And the math gets worse with every dependency added to the same chain.

Latency adds up

Every request gets a network round trip before it starts doing what matters. A screen that fires ten requests makes ten calls to the same API, and that API receives the sum of the traffic from every application.

Failure spreads

When the permissions API slows down, the applications don't fail right away: they wait. Connections get stuck, timeouts expire, and retries increase the load on exactly the service that's already overloaded. One service's slowness becomes everyone's slowness.

Separating the data from the decision

What unlocked it for me was realizing that "authorizing" bundles two different things:
  • the data: who has which role, in which scope;
  • the decision: does this grant cover this request?
The data changes rarely: someone gains a role, loses another, moves to a different organization. The decision happens on every request. A central API called on every request puts exactly the part that happens all the time on the other side of the network.
There's an old vocabulary for these pieces, from XACML, an access control standard:
  • the PEP (policy enforcement point) is what blocks the request;
  • the PDP (policy decision point) is what decides;
  • the PIP (policy information point) is where the facts for the decision come from.
In the design I found, the PEP was in each application, and the PDP and the PIP sat together, on the other side of the network. My way out was to bring both into the application, in two moves.

1. The data up close

The permission model moved into the application's own database: users, roles and grants. A grant is a role in a scope. The same person can be an administrator in one organization and a regular user in another: those are two grants, and one lends nothing to the other.

2. The decision inside the process

The PDP became a library, called by the route itself. Deciding stopped being a trip across the network: it's a function comparing data that's already in the service.
The central API stayed exactly as it was, serving the other applications. The constraint was respected, and the application I worked on no longer depended on it to respond.

Why a rule language, and not ifs

The most direct way to authorize inside the application would be to scatter conditions across the routes:
if user.role == "admin" or ( user.role == "editor" and document.organization in user.organizations ): ...
It works on the first route. By the twentieth, each route has its own interpretation of what being an admin means, knowing who can do what requires reading the entire codebase, and changing a rule means hunting down every place it was written.
I wanted the opposite: each route declaring what it requires, in one line, and an engine doing nothing but the comparison. Something like this:
@requires("user", "document", "read") async def view_document(request): ...
It reads: someone with user privilege, or higher, with permission to read documents. The decorator builds the requirement with the request's organization, fetches the user's grants from the application's database and asks the engine. The route doesn't know how the decision is made; it only says what it needs.
That's what I mean by DSL here: a small language, in the authorization domain, where the rule is data. And data can be listed, reviewed, tested, and can even leave the code and move into the database.

How the engine decides

The engine doesn't know what a document, an organization or an admin is. It compares two pieces of data, the route's requirement and each of the user's grants, field by field, according to a schema that says which comparator to use for each field:
LEVELS = {"user": 1, "admin": 2, "root": 3} def equal(required, granted): return required is not None and required == granted def equal_or_wildcard(required, granted): if required is None or granted is None: return False # a missing field never authorizes return granted == "*" or required == granted def min_level(required, granted): if required not in LEVELS or granted not in LEVELS: return False return LEVELS[granted] >= LEVELS[required] COMPARATORS = { "equal": equal, "equal_or_wildcard": equal_or_wildcard, "min_level": min_level, } SCHEMA = { "privilege": "min_level", "resource": "equal_or_wildcard", "action": "equal_or_wildcard", "organization": "equal", } def authorize(requirement, grants, schema=SCHEMA, comparators=COMPARATORS): return [ grant for grant in grants if all( comparators[name](requirement.get(field), grant.get(field)) for field, name in schema.items() ) ]
Three ideas are in that snippet:
  • Privilege is a level, not a name. It works as "at least": an admin meets a user requirement, and not the other way around. An unknown level meets nothing.
  • The wildcard is explicit and only counts on the grant side. A * in the grant covers any resource; a * in the requirement opens nothing.
  • A missing field never authorizes. It looks like a detail, and it isn't. I'll come back to it in the third article.
With one requirement and two grants, the engine returns only the one that covers the request:
requirement = { "privilege": "user", "resource": "document", "action": "read", "organization": "org-a", } grants = [ {"privilege": "user", "resource": "document", "action": "read", "organization": "org-a"}, {"privilege": "admin", "resource": "*", "action": "*", "organization": "org-b"}, ] authorize(requirement, grants) # [{"privilege": "user", "resource": "document", "action": "read", "organization": "org-a"}]
The administrator of org-b, even with wildcards everywhere, can't reach a document from org-a. Isolation between organizations is just one more compared field, and it has no exceptions.

A new dimension without touching the engine

The part I like most about this design is what happens when a new rule shows up. Say some grants start applying only to certain regions. There's no need to touch authorize: a new comparator and one more field in the schema are enough.
def belongs_to(required, granted): if required is None or granted is None: return False return required in granted COMPARATORS["belongs_to"] = belongs_to SCHEMA_WITH_REGION = {**SCHEMA, "region": "belongs_to"}
A grant with "region": ["north", "northeast"] covers a request from the north and rejects one from the south, and the routes that use the old schema stay the same. It's the open/closed principle in the most literal sense: the engine stays closed for modification and open for extension.

The answer is a list, not a yes

Notice that authorize doesn't return True or False. It returns the grants that authorized. The route learns which grant the request passed through, and in which scope.
That looks like an implementation detail, but it changes the role of authorization: it stops answering only "can they?" and starts answering "can they, over what?". That's the topic of the next article.

Testing without a database or a network

Since the engine is a pure function, testing it means writing data and checking the result. No database, no network, no mocks, and every rule of the engine gets a test that describes it:
def test_admin_of_one_organization_cannot_reach_another(): grants = [ {"privilege": "admin", "resource": "*", "action": "*", "organization": "org-b"}, ] requirement = { "privilege": "user", "resource": "document", "action": "read", "organization": "org-a", } assert authorize(requirement, grants) == [] def test_higher_privilege_covers_lower_and_not_the_reverse(): needs_user = { "privilege": "user", "resource": "document", "action": "read", "organization": "org-a", } needs_admin = {**needs_user, "privilege": "admin"} admin = [{**needs_user, "privilege": "admin"}] user = [needs_user] assert authorize(needs_user, admin) == admin assert authorize(needs_admin, user) == []
The decorator, which handles the part with side effects (reading the user from the request and fetching the grants from the database), receives those sources as parameters. In the tests, they become fake functions that return fixed data, and the route's entire path is tested without starting anything up.

What was outside my scope

The solution applied to the application I worked on. The central API kept serving the others, as the task required, and the ecosystem's architecture wasn't my decision.
If it had been, I'd have proposed the same principle for the rest: the central API as the place where grants are registered and managed, and the engine as a library in each service, deciding with a local copy of the grants it cares about. Management stays in one place; the decision stays close to whoever needs it.
That choice has a price, and it's real: the application started keeping its own users and grants. Two sources of truth call for care, and that's what the third article is about.

What I take from this project

  • Separate the data from the decision. The data can live far away and change rarely; the decision happens on every request and needs to be close.
  • Read the constraint as a requirement. "Don't touch the API" looked like an obstacle and ended up guiding the design.
  • Rules as data age better than scattered rules. One line per route can be read, tested and changed in a single place.
  • The engine shouldn't know the domain. The less it knows, the more rules fit in it without changing it.
In the next article, I'll cover what this engine delivered, especially what changes when authorization starts telling you which data a person can act on.
Photo of Yago Marinho
Yago MarinhoSenior Software Engineer. I build AI-powered products, from design to deploy.
See portfolio ↗