The full specification is available here: Authorization Model Specification.
Authorization logic often starts simple.
At first, a role like admin, editor, or viewer is enough. Then the product grows. You add organizations, projects, machine clients, service accounts, field-level rules, exceptions, audit logs, and suddenly the question "can this principal do this action?" is answered differently in different parts of the codebase.
This specification is my attempt to make that question explicit, portable, and easy to evaluate.
The goal is not to define authentication, token issuance, identity provisioning, or transport security. Those are separate concerns. This spec focuses only on authorization decisions: how roles, permissions, scopes, and policy evaluation should work together.
The Core Idea
The model is built around a small chain:
Principal -> Role -> Permission -> allow | deny
A principal can be a human user, a service account, or a machine-to-machine client. A role is a named bundle of permission statements, such as auditor, editor, or billing-admin.
Roles are not magical. Their names do not grant authority by themselves. A role only matters because of the permission statements it contains, and a principal only receives authority through an explicit binding:
(principal, role, scope)
This is important because it keeps the model auditable. If someone has access, there should be a concrete binding and a concrete permission statement that explains why.
The Design Principles
The specification is based on five principles:
Default-deny: if nothing explicitly allows an action, the answer is deny.
Deny-overrides: if both an allow and a deny match the same request, the deny wins.
Explicit over implicit: authority is never inferred from role names, group names, or conventions.
Decision and enforcement separation: application code should ask a Policy Decision Point (PDP) for a decision. It should not spread authorization rules across controllers, services, and handlers.
Auditability: decisions should be loggable with enough context to reconstruct what happened.
These principles make the model predictable. There is no hidden privilege, no role-name guessing, and no ambiguous conflict resolution.
Permission Strings
Permission statements are serialized as compact strings:
<organization>:<service>/<resource>[:<field>[:<resource_id>]]/<effect>/<action>
For example:
acme:api/suppliers/allow/update
This means: in the acme organization, for the api service, allow updating suppliers.
The format also supports field-level and instance-level rules:
acme:api/contacts:email/allow/read
acme:api/suppliers:*:12345/deny/read
The first statement allows reading only the email field of contacts. The second denies reading supplier 12345.
Wildcards are supported too:
acme:api/suppliers/allow/*
acme:api/suppliers/deny/delete
Together, these allow every action on suppliers except deletion.
The string format is intentionally compact enough to be transported in places like JWT claims, while still being readable for operators and auditors.
How Evaluation Works
The evaluator receives a request:
(principal, action, resource_uri)
Then it evaluates the principal's effective permission set in three steps.
First, it keeps only the permission statements that match the request. A segment matches if it is exactly equal to the request segment or if it is *.
Second, if any matching statement has effect = deny, the result is deny.
Third, if at least one matching statement has effect = allow and no deny matched, the result is allow. Otherwise, the result is deny.
In pseudocode:
def evaluate(request, permissions):
applicable = [p for p in permissions if matches(p, request)]
if any(p.effect == "deny" for p in applicable):
return Decision.DENY
if any(p.effect == "allow" for p in applicable):
return Decision.ALLOW
return Decision.DENY
One important detail: the evaluator is specificity-agnostic. A specific deny does not beat a wildcard allow because it is more specific. It wins because all denies override all allows.
For example:
acme:api/suppliers/allow/read
acme:api/suppliers:*:12345/deny/read
Reading suppliers is generally allowed, but reading supplier 12345 is denied.
What This Spec Is For
This specification is useful for teams building a Policy Decision Point, integrating a Policy Enforcement Point, or simply trying to make authorization rules easier to audit.
It gives you:
a portable permission string format
clear role and binding semantics
default-deny behavior
deny-overrides conflict handling
a small evaluation algorithm
decision logging requirements
The main benefit is consistency. Instead of each service inventing its own interpretation of roles and permissions, services can ask the same kind of question and receive the same kind of answer.
Authorization should be boring, predictable, and explainable. That is what this spec is designed to support.
You can read the complete spec, including the grammar, validation rules, examples, and versioning notes, at mahdavipanah.github.io/authorization-model.
Top comments (0)