Skip to main content

Command Palette

Search for a command to run...

A Practical Specification for Roles, Permissions, and Policy Decisions

Published
4 min read
H

Software engineer | JavaScript/Python/Ruby lover | Focusing on system architecture and design | Database wizard

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.