Permissions: Who Can Press Which Button — We Rebuilt It Three Times

· tech

#war-story#live-commerce#authorization

📑 Contents

Permissions are something I’ll certify as one of the parts we didn’t get right: rebuilt three times, each costing real time, each only half right. What’s interesting is that laid end to end, those three rebuilds walk the classic evolution of a permission system — so this chapter is less a confession than planting signposts for whoever comes next.

A three-act play: every step was locally reasonable

Act I Django group + permission model-level CRUD a machine's granularity Act II (+1 week) permissions stuffed into the JWT wrong granularity, in concrete a change = a re-login Act III move to role-based semantics finally right but an orthogonal axis appears and roles start exploding Landing stackable capability roles cost monitor multiply → add Every step was locally reasonable: use the built-in (saves time) → stuff it in the JWT (matches statelessness) → switch to roles (needs semantics) The error is in none of the steps — it's never having asked what this system's natural unit of permission is
Three rebuilds, each half right: granularity, carrier, semantics, composition — permission's four questions, walked into one at a time.

Act I: Django’s built-in groups + permissions. Saves time, has documentation, integrates with admin — an entirely reasonable opening. But a built-in permission is model-level add/change/delete/view: it’s a machine’s granularity, describing tables. And this system’s real semantics are tasks: “the assistant can open bidding”, “support can clear a cart”, “operations can close a round” — one task spans several tables, one table is touched by several tasks. Two coordinate systems that don’t line up, so every authorisation becomes a human translation, and a mistranslation is a missing or an excess permission.

Act II: stuff the permissions into the JWT, one week’s work. The motive was reasonable too — as the opening move said, this system’s auth is a stateless JWT, so permissions travelling with the token means every server verifies independently. Beautiful. But what got stuffed in was still that wrongly-grained permission set: a bloated token is the small problem, and the real cost is the wrong model getting poured in concrete — after that, every attempt to change the granularity carried an extra layer of “what do we do about the old permissions inside existing tokens?”. The propagation problem was solved pragmatically: a permission change takes effect on re-login, because every subject here is an internal employee you can call and who can wait. A permission system’s latency requirement follows its subjects — coarse is fine for insiders; only outsiders need fine.

Act III: move to role-based, and then roles explode. The semantics were finally right — who you are decides what you can do. But it hit another wall fast: orthogonal axes. The most sensitive data in this system is cost — what the company pays the supplier — and who may see it has nothing to do with function: some operators need it, some don’t; engineers normally don’t. Encode “can see cost” into the role name and the role count doubles immediately: operator, operator-who-sees-cost, assistant, assistant-who-sees-cost… and every additional axis of that kind doubles it again.

Where it landed: a new role called cost monitor. A role representing a single capability, stackable onto any function role. At the time I joked that “we’ve sort of come back to permission granularity” — now I’d give it its proper name: this is role composition, and it’s the correct landing.

Multiplication becomes addition — the third time this saves the day

✗ Axes multiplied into names: M × 2ᴺ operator operator + cost assistant assistant + cost support support + cost each orthogonal axis doubles everything add "can see PII" and you have 12 roles ✓ Axes stack independently: M + N function roles operator assistant support capability roles (stackable) cost monitor (future: pii viewer …) a person = a function role + zero or more capability roles roles add up, they don't multiply
Don't multiply orthogonal things into a name — let them exist independently and stack freely.

If you’ve followed from the first post, this arithmetic should look familiar: converging multi-platform comments through adapters is M×N→M+N, the observability collector is M×N→M+N — and so are permissions. Multiply orthogonal things into a name and the combinations explode; let them exist independently and stack freely and the total merely adds. Incidentally, Kubernetes RBAC walks the same road: roles only stack, there is no deny — the industry converged on addition because multiplication loses on the maths alone.

The contractor boundary: they can see the code, never the data

One cut in this chapter was made decisively: contractors got GitHub permissions for the repo, and nothing at all on GCP — no Production, no data. Collaboration needs code; and the commercial secrets (cost, customers, orders) all live in the data. That line is the same thinking as the shipping chapter‘s information/physical split: draw the boundary along the cross-section of breach cost. Leaked code is backstopped by licences and lawyers; leaked data damages the business directly — so cut it clean, with no grey zone at all.

What a rebuild would do

Permissions are the kind of thing that’s very hard to “get right on day one” — full RBAC+ABAC on day one is over-engineering, and patching it on day N is three rebuilds. In a rebuild I wouldn’t chase getting it right in one shot, I’d chase making the rebuilds cheap:

  1. The home of a permission check is the use case’s entrance. In Clean Architecture’s language, authorisation is an application-layer business rule — “the assistant can open bidding” describes neither a table (so not in the entity/ORM) nor HTTP (so not in the controller), it’s who this task itself allows — and a task’s incarnation in code is a use case. So the enforcement point converges on the use case boundary: each use case declares who may call it, and at the door you ask can(user, this_use_case, resource). That choice interlocks with this chapter’s conclusion — the correct granularity of a permission is a task is a use case, so a role is a set of use cases, and the permission model and the program structure finally speak the same language. An outer middleware doing a coarse filter (logged in? has a basic role?) is fine as defence in depth, but it’s only the politeness of failing fast; the front end hiding buttons is only experience — the use case entrance is always what counts, because it’s the only thing that stops “hit the same operation through a different entrance”. Two special cases: resource ownership (you may only touch your own cart) needs the entity loaded before it can be judged, so judge inside the use case after fetching; and field visibility (cost) is decided in the use case and enforced in the presenter — unauthorised fields get stripped before the data leaves. Half the cost of those three rebuilds went on permission checks scattered everywhere; converged on the use case boundary, swapping permissions for roles and roles for composition each touches one layer.
  2. Dual-track roles — function plus capability — from day one. Function roles align with the five interfaces (as the last chapter said: how you cut interfaces is the permission model, since both describe “who is doing what”); sensitive capabilities (cost, personal data) are independent, stackable roles from day one — not predicting the future, but admitting the regularity that an orthogonal axis will certainly appear.
  3. Put only role names in the JWT. A few strings, no bloat; expanding roles into permissions happens server-side, so how fast a permission change takes effect isn’t hostage to the token — a re-login is only needed when the roles themselves change.
  4. Audit access to sensitive data. Who looked at cost, and when — not distrust, but making “we fenced the most expensive thing” something you can evidence.

Let’s land all four on the most common scenario — a get-product API where cost is only for cost monitors. First the shape of the response: the intuitive move is inheritance (ProductWithCostOut(ProductOut)), but as axes multiply so does the schema count, 2^N — multiplying capabilities into type names is the same mistake as multiplying axes into role names. The additive version is a base plus stackable sections, isomorphic to capability roles.

The permission model itself first — it’s small enough to be a few types, one table and one function:

from dataclasses import dataclass
from enum import StrEnum


class Role(StrEnum):
    OPERATOR = "operator"                  # function role
    ASSISTANT = "assistant"
    COST_MONITOR = "cost_monitor"          # capability role, stackable


class Capability(StrEnum):
    SEE_COST = "see_cost"                  # an output entitlement; the view knows only this


@dataclass(frozen=True)
class Principal:
    account_id: int
    roles: frozenset[Role]                 # everything decoded from the JWT — this small

    def has_role(self, role: Role) -> bool:
        return role in self.roles


class PermissionDenied(Exception):
    pass


# "a role is a set of use cases" — the sentence, grown directly into a data structure.
# The whole system's authorisation truth is this one table (lives at the composition
# root, importing every use case).
ROLE_USE_CASES: dict[Role, frozenset[type]] = {
    Role.OPERATOR:     frozenset({GetProductUseCase, EndPeriodUseCase}),
    Role.ASSISTANT:    frozenset({GetProductUseCase, StartBiddingUseCase}),
    Role.COST_MONITOR: frozenset(),        # a capability role opens no doors, it grants entitlement
}


def require(principal: Principal, use_case: type) -> None:
    if not any(use_case in ROLE_USE_CASES[r] for r in principal.roles):
        raise PermissionDenied(f"{principal.account_id} cannot {use_case.__name__}")

Notice there’s no AuthorizationService here — a permission check doesn’t need a service: the role list sits on Principal (the entity’s own data), the role-to-use-case mapping is a constant table, and require is a pure function. The urge to wrap it in a service mostly comes from the era when permission checks were scattered everywhere; converged on the use case boundary, it’s too small to deserve a class.

The use case itself depends on two ports, injected with injector (which was also the codebase’s real dialect back then):

from typing import Protocol

from injector import inject


class ProductRepository(Protocol):         # port: the use case depends on an interface
    def get(self, product_id: int) -> Product: ...


class AuditLog(Protocol):
    def viewed_cost(self, principal: Principal, product_id: int) -> None: ...


@dataclass(frozen=True)
class GetProductResult:
    product: Product
    capabilities: frozenset[Capability]


class GetProductUseCase:
    @inject
    def __init__(self, products: ProductRepository, audit: AuditLog) -> None:
        self._products = products
        self._audit = audit

    def execute(self, principal: Principal, product_id: int) -> GetProductResult:
        require(principal, type(self))                      # enforcement point: check at the door

        product = self._products.get(product_id)

        caps: set[Capability] = set()
        if principal.has_role(Role.COST_MONITOR):           # role → entitlement, translated only here
            caps.add(Capability.SEE_COST)
            self._audit.viewed_cost(principal, product_id)  # sensitive access leaves a trace (rule 4)

        return GetProductResult(product=product, capabilities=frozenset(caps))

And the view — it’s the presenter, and it only picks the exits:

from django.http import HttpRequest
from ninja import Router, Schema


class CostSection(Schema):
    cost: int
    margin: float


class ProductOut(Schema):
    id: int
    name: str
    price: int
    cost_info: CostSection | None = None   # a capability section: one capability, one block


router = Router()


@router.get("/products/{product_id}", response=ProductOut, exclude_none=True)
def get_product(request: HttpRequest, product_id: int) -> ProductOut:
    use_case = request.injector.get(GetProductUseCase)   # bound by django-injector
    result = use_case.execute(request.auth, product_id)

    out = ProductOut.from_orm(result.product)
    if Capability.SEE_COST in result.capabilities:       # no permission reasoning, just pick the exit
        out.cost_info = CostSection.from_orm(result.product)
    return out

A few details are deliberate:

  • The truth of authorisation is one table. ROLE_USE_CASES is the single fact of the whole system’s permissions: reviewing permissions means reviewing one table, a new joiner asking “what can operations do?” reads one line, and generating permission documentation means iterating a dict. In the era of three rebuilds, answering those questions meant grepping the whole codebase.
  • Sections are additive. One more sensitive axis (personal data, margin) is one more xxx_info: Section | None — schema counts add rather than multiply. Capability roles stack, response sections stack; the same arithmetic reaches all the way out to the shape of the API.
  • exclude_none=True means an unauthorised section doesn’t even appear as a key. "cost_info": null is a leak too — it tells the caller the field exists.
  • The view knows no roles at all. What it receives is an entitlement translated by the use case (SEE_COST); rename, split, merge or replace the role model and the view doesn’t move a line. StrEnum gives those identifiers types and completion, so a typo blows up in tests instead of surviving to production as a comparison that’s permanently false.
  • The repo goes through injector. The use case depends on the ProductRepository interface — a unit test of the permission check swaps in a fake repo and runs without touching a database; and a use case class having exactly one public execute is what stops it sliding into a service junk drawer.

Finally, there’s a more thorough version: make the sensitive field its own sub-resourceGET /products/{id}/cost, with permissions attached to the resource, no schema games at all, audit naturally separated and public data safely cacheable. But be careful about the test for it: it is not “cost belongs to another domain” — cost as a fact is born in purchasing, but a host absolutely has to know it to quote a price; it’s a necessity at the point of sale, and forcing it into the purchasing domain is a paper taxonomy that contradicts business reality. The real test for a sub-resource is the access boundary: for the same data, when who reads it, how often, and how sensitive it is differ enough from the parent, it deserves its own door — one door, one permission, one audit trail, one caching policy. A section is the additive answer when the field must be embedded; a sub-resource is the thorough answer when the access boundary is clear enough.

Reflections

Friction debt has no collections department

Overselling explodes, payments explode — their debt carries interest and a collections department, and failing to pay causes an incident. Getting permissions wrong doesn’t explode, it merely rubs: five extra minutes per authorisation, one extra round of questions per new joiner, one extra detour per requirement. No alert, no incident report, and never a place in a sprint — until one day somebody snaps and everyone discovers they’d all been quietly suffering for a long time. The test I use now: when an internal process everybody complains about never gets scheduled for a fix, that’s friction debt — it won’t surface by itself, so someone has to go fishing for it on a schedule.

The standard for granularity isn’t security, it’s semantics

All three rebuilds circled the same question: how big should a unit of permission be. Too fine (model level) has no semantics and authorisation becomes translation; too coarse (a single is_admin) has no boundary and authorisation becomes a gamble. There’s only one standard for the right granularity: the unit the people using it speak in. Operations says “she needs to be able to close a round”; the boss says “he can’t see cost” — roles and capabilities are the nouns of those sentences. Testing whether a permission model is any good is simple: say an authorisation requirement out loud in plain language and see whether the system can translate it one-to-one — if the translation needs a footnote, the model is wrong.

We didn’t get it right, but nothing went wrong — why

Having been honest about the detours, it’s fair to answer one more question: with a permission system rebuilt three times, why did nothing ever go wrong? Because the most expensive thing was fenced from day one — cost visibility had dedicated control, contractors couldn’t touch data, and every subject was an internal employee. How elegant your permission model is affects friction; whether your assets are fenced is what affects survival. The ordering lesson for me: fence the most expensive data in the dumbest possible way first, then improve the model’s elegance at leisure — teams that do it the other way around have a beautiful model, and then hand the entire cost table over during one contractor handover.