Authorization: roles, grants, plans and API tokens

How a multi-tenant product decides what each caller may do: RBAC, ABAC, ReBAC and per-record grants, the order a policy is evaluated in, tenant checks, why some refusals are 404, plans and limits as data, quotas charged without races, and API tokens and service accounts behind deny-by-default routes - with pictures and examples.

In short

Authentication answers who is calling; authorization answers what they may do, on every single request. In {{brand}} the server decides in a fixed order: is the caller really in the tenant they name, may this kind of caller use this route, does the plan include the feature, do their role and grants open this record, and is there quota left. The web app hides buttons you cannot use, but that is only a convenience - the server checks again, every time.

After this lesson you can Compare RBAC, ABAC and ReBAC and pick one for a product; build a tenant check that never trusts the client and choose between 404 and 403; model plans and quotas as data and charge them without race conditions; design API tokens and service accounts with scopes, expiry, rotation and deny-by-default routes.

Words you need

Four ways to model authorization

Every authorization system answers one question: may this principal do this action on this resource? The models differ in where the rule lives. Here is the same question - may Aynur edit the board Platform? - in each of them.

Real products mix them Pure RBAC is too coarse and pure ACLs are too many to manage. Most products combine a role that sets the most a person can ever do with grants or relationships that open single records. {{brand}} does exactly that - the next section shows the rule.

Roles and grants together: the ceiling rule

{{brand}} uses two layers. The role a person holds in the organization is the ceiling: the most they may ever hold on any record of a module. Inside that ceiling the record decides: how it is shared - open to everyone, to a team, or private - and which grants name the person, a team of theirs or a role of theirs. Why a ceiling at all? Sharing is done by many people, and mistakes happen: someone grants Manage to a Viewer. With a ceiling, the role is the one place that caps the damage, so no share, however generous, can lift a person above what their organization role allows.

Three rules that keep it safe No role opens everything: a private record stays closed even to an administrator until they open it on purpose, and that act is recorded on the record. Teams never make managers: a team's sharing lets its members work on a record, but managing it takes a grant. Write the order down: compare levels through one table, so adding a level later cannot silently shift every answer.

The same rule is needed in two forms. A check on one record runs it in memory. A list must page in the database, so the rule is also written as a query filter. Two forms of one rule can drift apart, so a test writes thousands of combinations of sharing, teams and grants and demands that both forms give the same answer.

Policy evaluation order: which question comes first

Order matters for speed, for safety and for what a refusal reveals. Cheap, broad questions go first; questions about one record go last, once the caller is known to belong. At every step the rule is deny by default: if a step cannot say yes, the answer is no.

Decide in one place, enforce in many Security people call the place that decides the policy decision point and every place that asks it a policy enforcement point. {{brand}} has one decision point per kind of question - one evaluator for features, plans and switches, one calculator for records - and every route, list and background job asks them. No handler writes the rule a second time.

Tenant checks: the server decides which organization you are in

In a multi-tenant product every customer shares the same servers and the same database tables. The one thing that must never happen is a person seeing another customer's rows. The defence is simple to say: the tenant always comes from a server-side check, never from what the client claims.

Soft delete (DeletedAt) rides in the same filter, so a deleted row disappears from every query the same way another tenant's rows do.

Someone in two organizations A person who belongs to two organizations sees only the one they are working in. Everything that reads data - lists, search, reports - must take the tenant from the verified context, never from a parameter. One forgotten code path leaks.

Staff access is not a back door Platform staff accounts must not be a back door. Inside a customer's tenant, staff are treated like anyone else - a stranger unless they are a member - and tests call every route that way. Decisions about someone else - who is notified, who may see a mention - always use that person's own rights, never the caller's.

404 or 403: what a refusal may reveal

A refusal is information. If one board id answers 403 Forbidden and another answers 404 Not Found, an attacker has learned that the first board exists. Trying ids one after another to learn this is called enumeration. The fix is to answer a closed record exactly like a missing one.

Leaks hide in other places too Different response times, different error texts, totals in a list header, or a uniqueness error such as that name is already taken can each reveal what a 404 hid. Keep the body, the timing and the side effects the same.

The options in the market

What {{brand}} chose, and why

{{brand}} is a modular monolith with one PostgreSQL database, so every fact a decision needs - memberships, roles, teams, grants - sits beside the records. An in-process calculator reads those facts live on each request, and the same rule becomes a query filter for lists. There is no second store to keep in step, so a removed grant counts from the very next request.

Plans and limits as data

A plan is a row, not an if. Code never asks whether the caller is on Pro; it asks whether the workspace's plan includes a feature key, and what the limit of a metric is. Prices and limits then change in an admin screen, with no deploy.

Caps are counted where the rows live. Each module reports how many of a thing a workspace holds through a small meter interface, and the limits service adds every meter up. Storage lives in several modules but is one limit - and the limits code never depends on any module. This is dependency inversion applied to billing.

Downgrades never delete When a plan stops including something, what the workspace holds stays readable, exportable and deletable; only adding and changing wait. Losing customer data over billing is never acceptable.

Quotas and metering: charge atomically

The classic bug: two requests arrive together, both read 9 of 10 used, both decide there is room, both write. The workspace now holds 11. This is a race condition, and any check followed by a separate write has one. The check and the charge must be one atomic step.

Some work only knows its cost afterwards: an AI answer is priced by the tokens it produced. The safe pattern is to reserve the most it can cost before the call, refuse when the budget cannot cover that, then settle to the real cost and release what was over. A failed call gives everything back, so calls made at the same moment can never spend past the budget together.

API tokens and service accounts

A program cannot type a password or click a sign-in page, so it uses an API token: a long random secret sent in the HTTP Authorization header. A token is a key that works without its owner present, so it must be narrow, short-lived, revocable and visible in an audit trail.

Audit and change: who sees an access change

Access is only trustworthy if changes to it leave a trace. Every change to a role, a grant or a token should be written to an audit trail that says who did what and when - and the person affected should usually be told.

Events carry the news Notifications about access are best driven by events: the code that changes a grant announces a fact, and the notification code listens. Narrowing, ending and removal then notify the same way from every screen.

Deny by default: every route must say who may call it

The most common authorization bug is not a wrong rule but a missing one: a new endpoint nobody thought about. The cure is to make the safe answer the default - platform routes refuse tokens unless they opt in, module routes take a safe default by method, and an approved route list fails the build until someone reviews any new or changed route.

Approval testing Comparing a generated list with a checked-in approved file is called approval or snapshot testing. It turns a silent change - a new route, a new permission - into a visible line in a pull request that a reviewer must read.

Try it yourself: a tiny authorization layer

In an afternoon, with Node.js or .NET and SQLite or PostgreSQL, you can build - and break - the main ideas of this lesson. Expect the race test to print 1 and then 7 once the fix is in.

Common mistakes

Interview questions

Related