Safeguard
Application Security

Permission Models Are Not Designed, They Accumulate

An is_admin boolean, then a role column, then a special case for one customer. Three years later nobody can say what a given user can do without reading the code, and an auditor is asking.

Aman Khan
AppSec Engineer
6 min read

Permission models are not usually designed. They accumulate. Someone adds an is_admin boolean, then a role column, then a special case for the one customer who needs read-only billing access, and three years later nobody can answer what a given user can do without reading the code.

The cost arrives all at once, when an enterprise prospect asks for custom roles, or an auditor asks you to demonstrate that support staff cannot read customer data. This post is the small number of decisions that determine whether your model stays answerable. For whoever is about to add the third special case.

Decide what a permission is attached to

The first fork, and the one that is expensive to change later.

Role-based: permissions attach to named roles, users get roles. Simple, familiar, and what most systems should start with. It breaks down when customers want roles you did not define, or when access depends on the specific record rather than its type.

Attribute-based: a decision function over attributes of the user, the resource, and the context. Expressive enough for anything, and expensive: every check is a small computation, and answering "who can read this document" requires evaluating the policy against every user rather than reading a table.

Relationship-based: permissions follow edges in a graph. A user can edit a document because they are an editor on the folder that contains it. This is what Google's Zanzibar model describes, and it handles nesting and sharing well, which is where the other two get ugly.

Most products need role-based with one or two relationship-shaped exceptions, usually ownership and team membership. The failure is starting with roles, meeting a case roles cannot express, and encoding it as a special case in application code rather than extending the model. Three special cases in, the model is no longer the model.

Separate the three questions

Most confusion comes from conflating things that have different answers and different lifetimes.

Authentication: who is this. Authorization: what may they do. Tenancy: which data are they scoped to.

Tenancy is the one that gets merged into authorization and should not be. Tenant scoping is a filter that applies to every query regardless of role, and treating it as just another permission means a bug in role evaluation becomes a cross-tenant data leak. Enforce tenancy separately and unconditionally, ideally in a layer that a query cannot skip, such as row-level security or a repository that refuses an unscoped query.

The test: if you removed all role checks, could a user still only see their own tenant's data? The answer should be yes.

Name permissions after operations, not screens

can_view_billing_page ties your permission model to your current user interface. Redesign the page and the permission becomes wrong, or worse, ambiguous.

Name the operation on the resource: invoice:read, invoice:issue, member:invite, apikey:create. This survives UI changes, it maps cleanly to API endpoints, and it is the vocabulary an enterprise customer expects when they ask for custom roles.

Keep the list short enough to enumerate on one page. A model with four hundred fine-grained permissions is not more secure than one with forty; it is less, because nobody understands what a role grants and administrators assign broadly to make things work.

Centralise the decision, distribute the enforcement

One place decides, many places ask.

# one function, one place, testable in isolation
def can(user, action, resource) -> bool: ...

# every call site asks, none of them reasons
if not can(current_user, "invoice:issue", invoice):
    raise Forbidden()

The failure mode this prevents is authorization logic scattered through controllers, where the same rule is implemented four times and one of them is subtly different. That fourth one is your vulnerability, and it will be in the endpoint written last, in a hurry, by someone who copied the wrong example.

Two things that make this hold:

Fail closed. An unknown action, an unknown resource type, an error during evaluation, all return false. Authorization is one of the few places where an exception should never be caught and allowed through.

Deny by default for new endpoints. A route with no permission declared should be inaccessible, not public. Enforce it structurally: a test that enumerates every route and fails if one has no authorization annotation. This catches the endpoint somebody adds at 18:00 on a Friday, which is reliably the one that gets forgotten.

Make it answerable

The questions you will be asked, and which most models cannot answer without reading code:

  • What can this user do, right now?
  • Who can access this record?
  • What changed about anyone's access last month?

Design for these from the start, because retrofitting them is much harder than building them. That means: permission grants stored as data rather than expressed in code, an endpoint or admin view that answers the first question for any user, and an append-only log of grants and revocations with who made them.

That log is also your audit evidence, and having it means an access review can be driven from real data rather than from a spreadsheet somebody exported by hand.

Where the bugs actually are

In practice, authorization vulnerabilities cluster in a short list of places, none of them the main permission check:

  • Object-level checks on nested resources. The user may read invoices, and this particular invoice belongs to another tenant. The role check passes, the ownership check was never written.
  • Bulk endpoints. Individual access is checked, the batch variant filters afterwards or not at all.
  • Search and export. Built against a different data path that skips the repository layer holding the scoping.
  • Anything added for support or admin tooling, which is usually exempted from the normal checks because it is internal.
  • State transitions. A user may edit a record, which does not mean they may move it from draft to approved.

Test these specifically, and test with the wrong user rather than the right one. A test suite that only asserts permitted actions succeed proves nothing about what is forbidden.

The concession

Everything here argues for structure, and structure is premature for a product with two roles and one customer segment. A boolean is genuinely the right model for a while, and building a policy engine before you have a policy is a good way to spend a quarter on nothing.

The signal to invest is not size, it is the third special case. One exception is a feature. Two is a pattern. Three means the model no longer describes the system, and every subsequent change is being made to code rather than to configuration. That is the point where the cost of restructuring is lowest relative to what it saves.

The implication

The measure of a permission model is not whether it is expressive. It is whether someone can answer what a user can do without reading the source.

If answering that requires an engineer and an afternoon, you do not have a permission model. You have permission logic, spread across the codebase, and the next authorization bug is already in it.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.