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/middleware1.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
| Parameter | Value |
|---|---|
| Routes in a generated project | ~200, growing with each resource |
| Permissions | 4 per resource plus ~40 system permissions |
| Roles | 3 built-in plus a handful of custom |
| Permissions per role | 10 to 200 |
| Requests needing a check | all authenticated traffic, ~926/second average |
Check cost
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
Small enough to carry, large enough that it is worth caching per role rather than rebuilding for every request.
Registry size
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
- 1The request arrives with an access token. Authorization has not happened yet and nothing downstream has run.
- 2The auth middleware verifies the token and puts an actor on the context: user id, role, administrator flag and permission set.
- 3Control passes to the authorization middleware, which is the only component that decides.
- 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.
- 5The required permission is tested against the actor’s set. An administrator passes without the test, and that bypass is recorded.
- 6A missing permission ends here with 403. The handler never runs, so it cannot leak the existence of the thing by its error message.
- 7On success the handler runs, with the same actor still on the context.
- 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
| Component | What it is |
|---|---|
| Model | role-based, with permissions as the unit |
| Permission naming | resource.verb, generated with the resource |
| Registry | a generated Go file, rebuilt when routes change |
| Enforcement point | middleware, before the handler |
| Default | deny; an unlisted route is refused |
| Role storage | the primary database, cached per role |
6.Data model
roles
| Column | Holds |
|---|---|
| id | UUIDv7 |
| name | ADMIN, EDITOR, USER, or a custom one |
| is_system | built-in roles cannot be deleted |
permissions
| Column | Holds |
|---|---|
| key | invoices.view, users.delete, system.view |
| description | what it allows, shown in the admin |
| feature | the 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.
| Column | Holds |
|---|---|
| role_id | the role |
| permission_key | the 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
| Method | Endpoint | What it does |
|---|---|---|
| GET | /api/v1/auth/permissions | The caller’s own permission set |
Administration
| Method | Endpoint | What it does |
|---|---|---|
| GET | /api/v1/roles | Every role and its permissions |
| POST | /api/v1/roles | Create a custom role |
| PUT | /api/v1/roles/:id | Change which permissions it grants |
| DELETE | /api/v1/roles/:id | Remove a custom role |
| GET | /api/v1/permissions | The 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
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.
ActorFromUserIDFromCanGenerated. 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.
The enforcement point. The only place a 403 originates for a permission reason.
Resolves a role to its permission set and caches it, invalidating on change.
PermissionsForInvalidateDesign 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
| Pattern | Where it is used |
|---|---|
| Role-based access control | permissions grouped into roles, roles assigned to users |
| Policy registry | one generated table from route to requirement |
| Middleware chain | authenticate, then authorize, then handle |
| Cache aside | permission 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.
