Authorization — Ownership and Roles
Authentication answers who is calling. Authorization answers whether they may do this to that, and the second question needs three things the first does not: the caller, the operation, and the resource. GET /orders/{public_id} in Stagedoor checked the first question and skipped the second for a year. It loaded the order by its UUID, found it, and returned it, to whoever asked. Any buyer who obtained another buyer's order id, from a shared screenshot, a support ticket or a forwarded confirmation email, could read the order, the seats, the total and the last four digits of the card.
The fix is not a line in a middleware. The check needs the order and the caller, which makes it a domain rule, and domain rules live in the domain layer of Chapter 4, are expressed once per operation, and are tested like any rule. This topic is the two models a service the size of Stagedoor needs, ownership and a handful of roles; where the check lives; the class of bug that comes from putting it anywhere else; and the two rules, deny by default and read the role now, that keep it correct as the code and the staff change.
Ownership
Most authorization in a service is one sentence: this resource belongs to this caller. The order's user_id equals the principal's user id. The event's organizer_id equals the principal's organizer. The hold's user_id is the buyer who placed it. Orders, holds, tickets, events, staff and API keys are all owned, and for every one of them the whole of authorization is that comparison, made before the resource is returned and never after.
async def get_order(repo: OrderRepo, principal: Principal, public_id: UUID) -> Order: order = await repo.find_for_user(public_id, principal.user_id) # SELECT … FROM orders WHERE public_id = $1 AND user_id = $2 if order is None: raise OrderNotFound(public_id) # 404 whether it does not exist or is not hers return order # the storage layer has no find(public_id) at all; every order read takes the owner
One function, and the check is not a separate step that a future edit could move or delete. The repository's only way to fetch an order takes the owner as a required argument and puts it in the WHERE clause, so an order that exists but belongs to someone else is, from this caller's point of view, an order that does not exist. The domain raises the same not-found error in both cases, and the transport layer maps it to the 404 that Chapter 3 chose for orders. A buyer who guesses a UUID learns nothing, because the response for "no such order" and "not your order" is byte-for-byte identical, including its timing, since both are the same query returning zero rows.
Roles Where Ownership Is Not Enough
An organizer's staff member may scan tickets at the door and may not refund an order. The organizer's owner may do both. A Stagedoor admin may refund any order for any organizer and may not scan anything, because admins are not at the door. Ownership does not express any of that, because all three callers stand in the same relation to the event. Roles do. Stagedoor has four, named in one enum: buyer, organizer:staff, organizer:owner, admin. A permission table maps each role to the operations it may perform, and one function, require(principal, permission), consults it: require(principal, "orders:refund") raises Forbidden unless the principal's current role grants it.
The roles are few on purpose. Four roles and 22 permissions fit on one page of the API document, and every reviewer can hold the whole table in their head. The moment the table needs a column for "except on the day of the event" or "only for events under 500 seats," the rules have outgrown a table, and the comparison box below says what comes next. Stagedoor has not reached that day, and most services never do.
The Check Lives in the Domain
Not in middleware. The rings of Chapter 4 run before the handler, with no domain object in hand, so a ring can decide "this caller is logged in" and cannot decide "this caller owns this order," because it does not know which order. The first implementation had a ring that matched /orders/* to "any authenticated buyer," which is precisely the check that was in production for a year. Not in the route either, or not only there: the route dependency that Chapter 4 put on the refund route is the first gate, it reads the principal and calls require, and it is right for the coarse permission check. But a route can be forgotten by the next route, and the worker's refund job of Chapter 8, which runs the same refund with no route at all, would run it with no check.
So the check that decides is inside the domain operation that does the work. refund_order(principal, public_id) calls require and loads the order with its owner in the query, and both the API's route and the worker's job call that one function. The route dependency stays as a cheap early refusal that saves a database round trip, and the domain function is the one that cannot be bypassed. The unit test of Chapter 12 calls the domain function with a staff principal and asserts Forbidden, with a buyer's principal and someone else's order and asserts OrderNotFound, with no HTTP anywhere in the test.
Insecure Direct Object Reference
The bug has a name. An insecure direct object reference is any endpoint where the id is checked for existence and not for ownership, and it has been at or near the top of the OWASP list under "broken access control" for a decade because it is easy to write, invisible in tests that use one user, and does not look like a bug in code review. SELECT ... WHERE public_id = $1 is a correct query. The check that should have followed it is simply absent, and absence is hard to see.
The structural fix is to make the absence impossible to write. Every load of a user-owned resource takes the principal and filters by it: WHERE public_id = $1 AND user_id = $2. The repository has no method that loads an order without an owner, so the handler that forgets cannot compile a call that forgets. And the not-owned case is a 404, per the decision Chapter 3 made for orders, so a correct guess is indistinguishable from a wrong one. The table that Chapter 3 promised is below, and it is short: the only resources that answer 403 are the ones whose existence is already public.
Deny by Default
A new operation has no permission until one is written into the table, and a call to require with a permission the table does not know raises, in every environment, on the first request. A new role has no permissions until they are granted. The permission table is therefore the documentation of what each role may do, and it is complete by construction, because an operation that is not in it does not work. The alternative, a table of denials with everything else allowed, is the design in which the new refund endpoint works for every buyer until someone notices, and someone noticed the order endpoint after a year.
The cost is real and small: every new operation is two changes, the function and the table row, and a forgotten row is a 403 in staging on the first click. The benefit is that the failure mode of forgetting is a closed door rather than an open one. The test suite of Chapter 12 has one parameterized test that walks every operation with every role and asserts the table, so a permission granted by accident shows up as a failing test rather than an incident.
Authorization Is Data, and It Changes
A staff member removed by the organizer at 19:45 must be unable to scan at 19:46. That is impossible if her role is a claim inside a token issued at 19:42 and valid until 19:57, because the verifier of Topic 26 trusts the claim and the claim was true when it was written. So the role is not in the token. require reads the principal's current role from the store at check time: one Redis read, cached for 30 seconds, from the same session store that Topic 25 already consults on every browser request. For the mobile app's access token, that read is the one lookup the token design was supposed to avoid, and it is spent on purpose, on the one fact that can change between issue and expiry.
The principal carries the user id and the organizer id, which do not change during a session, and the roles are resolved from the store when a permission is checked, which is once or twice per request on the routes that need it and never on the seat map. The removal takes effect within the 30-second cache, which the organizer's dashboard states next to the button. The session's cheap lookup is what makes this affordable; a design with no store to read from would have no choice but to trust the token, which is the mistake in the box below.
RBAC assigns roles to principals and permissions to roles: simple, coarse, a table on one page. Enough for Stagedoor's four roles and 22 permissions, and for most services with fewer than a dozen roles.
ABAC evaluates attributes at decision time, the hour, the resource's state, the caller's location, through a policy engine such as OPA or Cedar: expressive, and harder to reason about, because "who can refund" is no longer a table but a program.
ReBAC models relationships, "staff of the organizer that owns the event that this ticket belongs to," in a graph store such as OpenFGA or SpiceDB: precise, and a second system to run and keep consistent. Start with ownership plus a handful of roles; reach for a policy engine when the rules outgrow a function, not before.
- Existence checked, ownership not — any buyer with another buyer's UUID reads the order; the insecure direct object reference that was in production for a year and looked like a correct query.
- Authorization in middleware by URL pattern —
/orders/*is "logged-in users," the ring has no order to compare against, and which order is never checked. - The role in the token — the staff member removed at 19:45 scans tickets until 19:57, because the verifier trusts a claim that was true when the token was issued.
- Allow by default — the new refund endpoint works for every buyer until someone notices, and with a denial list the forgotten entry is an open door rather than a closed one.
- Authorization only in the API — the worker's refund job calls the storage layer directly, with no principal and no check, and refunds whatever the queue tells it to.
- A 403 on a resource whose existence is secret — the buyer who guessed an order id learns that the guess was right, which is the enumeration oracle of Chapter 3 with a status code.
- Load and authorize in one domain operation that takes the principal, and give the storage layer no method that reads a user-owned resource without its owner in the query.
- Keep a small role enum and a permission table, checked by a single
require(principal, permission)function that both the API and the worker call. - Read the current role from the store at check time, cached for 30 seconds, and put no role claim in any token.
- Deny by default: an operation missing from the permission table fails closed, and one parameterized test walks every operation with every role.
- Decide 404 versus 403 per resource from whether existence is the secret, write the table once, and return the same body and timing for not-found and not-owned.
Knowledge Check
For a year, GET /orders/{public_id} loaded the order by UUID and returned it to any logged-in buyer. What is the structural fix, as opposed to a patch?
- Add a comparison of order.user_id to the principal after the load, before returning
- Remove the ownerless load, so that every order query names its owner in the WHERE
- Add a middleware on /orders/* that verifies the caller's session before the handler runs at all
- Switch the public id from a UUID to a longer random string nobody could guess
Why does Stagedoor place the deciding refund check inside refund_order in the domain layer rather than only in the route dependency?
- The worker calls the same function with no route, so the route's check never runs
- The route dependency cannot read the principal, because the context is only visible in the domain
- Route dependencies run after the handler, so the refund would already be committed
- Only the domain layer can return a 403, since the transport layer maps every error to 404
An organizer removes a staff member at 19:45. Her mobile access token, issued at 19:42, is valid until 19:57. How does the book make the removal take effect at 19:46?
- By revoking the access token, since the verifier checks a denylist on every request
- By shortening access tokens to 60 seconds, so no role claim is ever more than a minute stale
- By reissuing her token with the new role, which the app fetches on its next refresh
- By keeping the role out of the token and reading it from the store when a permission is checked
A staff member of Riverside Hall tries to refund one of Riverside Hall's own orders. A buyer requests a stranger's order by UUID. Why do they get 403 and 404 respectively?
- Because refunds are writes and reads are reads, and a refused write always answers 403
- Because the staff member is authenticated and the buyer's session was rejected at the auth ring
- Because the staff member can already see the order, while the stranger's order must stay invisible
- Because the staff member holds an API key and the buyer holds a session, which map to different codes
You got correct