All systems
Access

Authorization System Design

Deciding what a known caller may do, in a way that fails closed and can be listed route by route.

internal/authzinternal/accessinternal/middleware

1.Problem statement

Authentication answers who is calling. Authorization answers whether they may. The two get conflated constantly, and the result is an application where the only check is "is there a token", which means any signed-in user can call any endpoint.

The naive fix is a role check in each handler. It works until there are two hundred routes, at which point nobody can answer "who can delete an invoice" without reading two hundred functions, and a route added last week has no check at all because the person who added it did not know there was a convention.

What is wanted is a permission model fine enough to be useful, a place that knows every route and the permission it needs, and a default that refuses rather than allows when a route is not listed. The last part is the one that matters: an authorization system whose failure mode is "allow" is not an authorization system.

The system has to be able to:

  • Express permissions finely enough that "may read invoices" and "may delete invoices" are different answers.
  • Group permissions into roles, so people are assigned a job rather than forty checkboxes.
  • Attach a required permission to every route, in one place that can be listed and reviewed.
  • Refuse a route that nobody has classified, rather than allowing it.
  • Let an administrator bypass the lot, because somebody has to be able to fix things.
  • Answer the frontend’s question "what may this user see" without the frontend becoming the enforcement point.

2.System requirements

Functional requirements

  • Three built-in roles, ADMIN, EDITOR and USER, with permissions attached to each.
  • A permission per resource and verb, generated with the resource: invoices.view, invoices.create, invoices.update, invoices.delete.
  • Custom roles created at runtime from the admin, holding any set of permissions.
  • A generated registry naming every route and the permission that guards it.
  • Middleware that reads the identity from the request context and the requirement from the registry.
  • An endpoint the frontend calls to learn its own permissions, used to hide what cannot be used.
  • An administrator short-circuit, recorded in the audit log when it is exercised.

Non-functional requirements

  • Fails closed. a route with no entry in the registry is refused. The cost of forgetting is a 403 in testing, not an open endpoint in production.
  • Auditable as a list. the registry is a generated file. "Who can delete an invoice" is answered by reading one table, not by searching handlers.
  • Cheap. a permission check is a set membership test against data already loaded with the identity. It does not query.
  • Not the frontend’s job. the API enforces. The permission endpoint exists so the UI can hide a button, never so it can decide.
  • Separable from ownership. a permission says which kind of thing you may touch. Ownership says which rows. Conflating them produces a model that cannot express "every user may read their own invoices".

3.Capacity estimation

Numbers for a mid-sized deployment. They are here to size the thing, not to predict your traffic: change an assumption and the sums below move with it.

Assumptions

ParameterValue
Routes in a generated project~200, growing with each resource
Permissions4 per resource plus ~40 system permissions
Roles3 built-in plus a handful of custom
Permissions per role10 to 200
Requests needing a checkall authenticated traffic, ~926/second average

Check cost

permissions are loaded once with the identity, as a set
a check is one hash lookup, ~20 nanoseconds
926 checks/second x 20 ns = negligible

The cost is in loading the set, not testing it, which is why the set travels with the identity rather than being fetched per check.

Identity payload

200 permissions x ~24 bytes = ~5 KB per identity
loaded once per request from the role, cached per role

Small enough to carry, large enough that it is worth caching per role rather than rebuilding for every request.

Registry size

~200 routes x one line = a generated file of a few hundred lines

Regenerated whenever routes change, so it cannot drift from the routes it describes.

4.High level design

The identity arrives from authentication. The registry says what this route needs. The middleware compares the two. Nothing in a handler decides.

Core components

  • Actor. the identity on the request context: user id, role, whether they are an administrator, and the permission set. A value, not a database handle.
  • Access registry. a generated map from route to required permission, covering every route the router knows. Regenerated whenever routes change.
  • Authorization middleware. looks the route up, tests the actor’s set, and refuses with 403 if the permission is missing or the route is unlisted.
  • Role store. roles and their permissions, including custom roles made at runtime. Cached per role, invalidated when a role changes.
  • Permission endpoint. returns the caller’s own set so the interface can hide what will be refused. Advisory only.

Request flow

An authenticated request being authorized

12345678ClientAuth MiddlewareAuthz MiddlewareAccess RegistryActor on ContextRole Store403 ForbiddenHandlerService
  1. 1The request arrives with an access token. Authorization has not happened yet and nothing downstream has run.
  2. 2The auth middleware verifies the token and puts an actor on the context: user id, role, administrator flag and permission set.
  3. 3Control passes to the authorization middleware, which is the only component that decides.
  4. 4It looks up the matched route in the generated registry. A route with no entry is refused, because an unclassified route is a route nobody has thought about.
  5. 5The required permission is tested against the actor’s set. An administrator passes without the test, and that bypass is recorded.
  6. 6A missing permission ends here with 403. The handler never runs, so it cannot leak the existence of the thing by its error message.
  7. 7On success the handler runs, with the same actor still on the context.
  8. 8The service narrows the query by ownership where the resource is owned. Permission said which kind of thing; ownership says which rows.

Data flow

  • The permission set is resolved from the role once and travels with the identity. A handler asking twice costs nothing.
  • The registry is generated from the route definitions, so it cannot describe a route that does not exist or miss one that does.
  • Role changes invalidate the cached permission set for that role. A user whose role is revoked loses access on their next request, not at their next sign-in.
  • The permission endpoint reads the same set the middleware tests, so the interface and the enforcement cannot disagree.

5.Technology stack

ComponentWhat it is
Modelrole-based, with permissions as the unit
Permission namingresource.verb, generated with the resource
Registrya generated Go file, rebuilt when routes change
Enforcement pointmiddleware, before the handler
Defaultdeny; an unlisted route is refused
Role storagethe primary database, cached per role

6.Data model

roles

ColumnHolds
idUUIDv7
nameADMIN, EDITOR, USER, or a custom one
is_systembuilt-in roles cannot be deleted

permissions

ColumnHolds
keyinvoices.view, users.delete, system.view
descriptionwhat it allows, shown in the admin
featurethe group it belongs to, for the permission picker

role_permissions

The join. Changing a row here changes what a role may do on the next request.

ColumnHolds
role_idthe role
permission_keythe permission it grants

Where it lives

  • Roles and permissions are small, slow-changing and read constantly, which is the textbook case for caching by role rather than by user.
  • The registry is not in the database. It is generated code, so it is reviewed in a pull request alongside the routes it guards.

7.API design

Introspection

MethodEndpointWhat it does
GET/api/v1/auth/permissionsThe caller’s own permission set

Administration

MethodEndpointWhat it does
GET/api/v1/rolesEvery role and its permissions
POST/api/v1/rolesCreate a custom role
PUT/api/v1/roles/:idChange which permissions it grants
DELETE/api/v1/roles/:idRemove a custom role
GET/api/v1/permissionsThe catalogue, grouped by feature

What the frontend asks for

{
"data": {
"role": "EDITOR",
"is_admin": false,
"permissions": [
"invoices.view",
"invoices.create",
"invoices.update",
"contacts.view",
"contacts.create"
]
}
}

A refusal says nothing about what exists

{
"error": {
"code": "FORBIDDEN",
"message": "You do not have permission to access this resource"
}
}

8.Low level design

Core types

Actorinternal/authz/actor.go

The identity as a value: user id, role, administrator flag, permission set. Taking a context rather than a request is what lets a background job carry the same identity.

ActorFromUserIDFromCan
access.Registryinternal/access/registry.go

Generated. One entry per route, naming the permission it needs. The file is the answer to "who can do what", in a form a reviewer can read.

RequirePermissioninternal/middleware/authz.go

The enforcement point. The only place a 403 originates for a permission reason.

RoleService

Resolves a role to its permission set and caches it, invalidating on change.

PermissionsForInvalidate

Design principles applied

  • Single point of enforcement. handlers do not check permissions. If they did, the registry would be a description rather than the truth, and the two would drift.
  • Deny by default. the registry is exhaustive and an unlisted route is refused. Forgetting produces a loud failure in development rather than a quiet hole in production.
  • Separate concerns that look alike. permissions are about kinds of things, ownership is about rows. Keeping them apart is what allows "any user may read their own invoices" to be expressible.
  • Generate what must not drift. the registry is derived from the routes rather than maintained beside them, so a new route cannot be missed.

Patterns

PatternWhere it is used
Role-based access controlpermissions grouped into roles, roles assigned to users
Policy registryone generated table from route to requirement
Middleware chainauthenticate, then authorize, then handle
Cache asidepermission sets by role, invalidated on write

9.Scalability and performance

  • Checks are in-memory set tests against data already loaded, so authorization adds no queries to the request path.
  • Permission sets are cached per role, not per user, so a million users with three roles means three cached sets.
  • A role change invalidates one cache entry and takes effect on the next request, without signing anybody out.
  • The registry is compiled in. Looking a route up is a map read and does not touch storage at any traffic level.
  • Growth is in routes and resources, not in traffic. A project with a thousand routes has a larger registry and the same per-request cost.

10.Bottlenecks and improvements

What breaks first

  • Role explosion. custom roles multiply until there is one per person, at which point the model has become per-user permissions with extra indirection.
  • The administrator bypass. an account that passes every check is a single credential that opens everything, and it is usually the account with the weakest operational hygiene.
  • Stale permission sets. a cached set that is not invalidated leaves a revoked user with access until the entry expires.
  • Frontend drift. a UI that hides buttons by its own rules rather than by the permission endpoint will eventually hide something that is allowed, or show something that is not.
  • Permission without ownership. granting invoices.view to every user, on a resource that is not owner-scoped, means every user can read every invoice. The two systems have to be used together.

What to do about it

  • Review roles, not users. the admin shows which users hold each role, so the question at review time is "should these twelve people be editors", which is answerable.
  • Audit the bypass. every administrator action is recorded with the fact that it bypassed a check, so the privileged path is visible rather than assumed.
  • Invalidate on write. the role store invalidates its own cache when a role changes, rather than relying on an expiry nobody tuned.
  • One source for the UI. the interface hides on the permission endpoint and nothing else, so a change to a role is reflected without a frontend deploy.

Read next