Authorization in the API
- Lesson 11
- 55 min
- Module 4: Enforce resource access
Lesson 11 of 19 · Authentication, Permissions and Multi-Tenant ApplicationsAuthorization in the API
1. Identity and permission basics
2. Understand login and tokens
3. Sessions and application integration
4. Enforce resource access
5. Background work and administration
6. Validate and document the design
What you will be able to do
Make the API answer the three questions for every ticket request: who is calling, in which organization, and what they may do there. A record of another organization then looks exactly like a record that does not exist.
Before you startWhat to know and to have ready
What you need to know
- The three questions and the access matrix, from Three separate questions and Users and organizations. The five attempts of Threat examples.
- Access tokens, scopes and
/v1/me, from Token meaning. Browser sessions, from Browser session choices.
Your project
~/projects/ticket-apiat the snapshotend-of-m03:python -m pytestends with193 passed, 1 deselected.- The labs repository next to the project, in
~/projects/nextia-learning-labs. Rungit pullin it to get the snapshotend-of-m04. - Three terminals, each with the virtual environment active: the practice provider (
python -m idp), the API, and one for your commands.
This lesson copies the files of the whole module. After it, your project matches the snapshot end-of-m04. The next two lessons explain the database and file parts of the same copy.
An office building with many companies
Section titled “An office building with many companies”Think of an office building with ten companies on ten floors. At the entrance, the receptionist checks your ID card. That answers one question: who you are.
Then the lift takes you only to the floor of your own company. Your card does not open the other floors. Inside your company, a cabinet says “Managers only”, and a lock keeps it shut. Your card opens it only if you are a manager there.
A careful receptionist also guards information. A visitor asks, “Is a company called Acme on floor 7?” If the visitor does not work there, the receptionist says, “I cannot help you with that.” The answer is the same whether Acme exists or not. So nobody can learn who rents the building by asking.
The ticket API is now this building. Larkfield and Bramble Books are two companies on two floors. In this lesson, the API gets its lift and its locked cabinets.
The problem: the token knows Sam, the tickets do not
Section titled “The problem: the token knows Sam, the tickets do not”After Module 3, the API can name the caller. Start the provider and the API, then ask who Sam is. On Windows, type curl.exe, and set the token with $T = python -m scripts.login --user usr-sam --print-access-token.
T=$(python -m scripts.login --user usr-sam --print-access-token)curl -H "Authorization: Bearer $T" http://127.0.0.1:8000/v1/me{"user_id":"usr-sam","kind":"user","name":"Sam","platform_role":null,"client_id":"ticket-cli", "scopes":["members:manage","tickets:read","tickets:write"],"token_expires_at":"2026-10-10T06:38:37Z", "memberships":[{"tenant_id":"larkfield","tenant_name":"Larkfield","role":"staff"}]}The answer is complete: Sam, staff, at Larkfield. Now send the same token to a ticket route:
curl -H "Authorization: Bearer $T" http://127.0.0.1:8000/v1/tickets/T-40001{"error":{"code":"unauthorized","message":"A valid X-API-Key header is required.","request_id":"290c816753c5","fields":[]}}The ticket routes still ask for the shared key, and nothing else. With the key, Sam gets Bramble’s ticket, as in Threat examples: 5 of 5 attempts got what they should not. The API knows who Sam is, but it does not use that knowledge where it matters.
The idea: the organization is in the path, and the server checks it
Section titled “The idea: the organization is in the path, and the server checks it”Kwame writes the rule on the whiteboard: “Every request names its organization. The server checks that the caller belongs to it, every time, before it reads anything.”
So every route of an organization’s records moves under the organization’s name:
/v1/tickets/T-30002 before: no organization/v1/tenants/larkfield/tickets/T-30002 now: the organization is in the pathThe path says which organization the caller wants. The API then decides whether the caller may use it. It decides from its own membership table, never from the request. This decision is authorization: what an identified caller may do. It runs on the server, because the server is the only part that a caller cannot change.
The API checks four things, in a fixed order, and stops at the first “no”:
- Who is the caller? A checked token or session, as in Modules 2 and 3. If there is none: 401.
- Is the caller a member of the organization in the path? One row in
memberships. If there is none: 404. - Do the role and the token’s scopes allow the action? The
decide()function of the access matrix. If not: 403. - Is the record in this organization? The query has
tenant_id = 'larkfield'in it. If no row matches: 404.
Text description of the diagram
A request names its organization in the path and carries a token. Check 1 asks who the caller is; without a checked identity the answer is 401. Check 2 looks for a membership row of the caller in that organization; without one the answer is 404 “Not found.”, the same for a real and a made-up organization. Check 3 asks the access matrix whether the role and the token’s scopes allow the action; if not, 403. Check 4 is the query itself, with the organization in it; a record of another organization is not found, 404. Only a request that passes all four gets 200.
Why does check 2 come before check 3? An outsider has no role at all. If the API said “403: your role may not”, it would also say “this organization exists, and you are not in it”. A 404 says nothing.
A tiny example: one decision, four inputs
Section titled “A tiny example: one decision, four inputs”Check 3 is the pure function decide() from Module 1. In Module 1 you gave it two inputs, a role and an action. The API also gives it a third: the token’s scopes. Try it in Python, in the project folder:
python -c "from ticket_api.access import decide; print(decide('read_only', 'ticket.write', frozenset({'tickets:read', 'tickets:write'})))"Decision(allowed=False, status=403, code='role_cannot', rule='read_only may not ticket.write')Change one input at a time, and you get each answer of the matrix:
| Role in the organization | Action | Token’s scopes | Answer | Rule |
|---|---|---|---|---|
read_only |
ticket.write |
read and write | 403 | read_only may not ticket.write |
staff |
ticket.write |
read and write | 200 | staff may ticket.write |
| none | ticket.read |
read and write | 404 | no membership in this organization |
staff |
ticket.write |
tickets:read only |
403 | the token has no tickets:write scope |
The last row is a reminder from Module 2. A role says what the person may do. A scope says what the person allowed this application to do for them. Both must allow the action. A reporting script that asked only for tickets:read cannot write, even for an owner.
Build it
Section titled “Build it”All of this module’s files come from the snapshot end-of-m04. The copy also brings the files of the next two lessons.
-
Stop the API with Ctrl+C. Leave the provider running.
-
In
~/projects/ticket-api, copy the module’s files from the snapshot:Terminal window S=../nextia-learning-labs/C23/snapshots/end-of-m04for d in contract migrations scripts tests ticket_api; do cp -R "$S/$d/." "$d/"; donecp "$S/.env.example" .Terminal window $S = "..\nextia-learning-labs\C23\snapshots\end-of-m04"foreach ($d in "contract","migrations","scripts","tests","ticket_api") { Copy-Item -Recurse -Force "$S\$d\*" $d }Copy-Item -Force "$S\.env.example" .Your
.envdoes not change. The two new settings,DB_ROW_SECURITYandCACHE_SECONDS, have defaults. -
Apply the two new migrations. Your data stays, so you do not need
--reset:Terminal window python -m ticket_api.migrateapplied 016_tenant_indexesapplied 017_row_level_securityDatabase is up to date (17 migrations). -
Run the tests:
Terminal window python -m pytestThe last line is
218 passed, 1 deselected. The databases course’s tests now use the organization’s paths. -
Start the API again. The API key still protects
/v1/classifyand/v1/history:Terminal window API_KEY=local-dev-key fastapi devOn Windows, set
$env:API_KEY = "local-dev-key"first, then runfastapi dev.
Read the check
Section titled “Read the check”Open ticket_api/tenancy.py. The function require(action) makes a FastAPI dependency: a function that FastAPI runs before the route. The heart of it is short:
def check(request, db, identity, tenant_id, action) -> Caller: with db.connection() as conn: role = repository.membership_role(conn, tenant_id, identity.user_id) decision = decide(role, action, identity.scopes) if not decision.allowed: raise AccessDenied(decision, tenant_id, action) ...identity comes from the token or the session cookie, so check 1 has already run. membership_role() reads one row of memberships for this organization and this user. Then decide() answers.
Now open ticket_api/tickets.py. Each route asks for a Caller, and the router’s prefix has the organization:
router = APIRouter(prefix="/v1/tenants/{tenant}/tickets", tags=["tickets"])Read = Annotated[Caller, Depends(require("ticket.read"))]
@router.get("/{ticket_id}", ...)def get_ticket(ticket_id: TicketId, caller: Read, db: DB) -> TicketDetail: with db.connection(caller.tenant_id) as conn: ticket = repository.get_ticket(conn, ticket_id, tenant_id=caller.tenant_id)Two details matter. First, the route needs a Caller to run, and only the check can make one. Second, the organization in the query comes from caller.tenant_id: the organization that the check accepted. It never comes from a header or the body.
The old routes /v1/tickets… and /v1/attachments… are gone. A shared key cannot say which person calls, so it cannot pass check 2. The key stays only for /v1/classify and /v1/history, which hold no organization’s records. They are service-to-service routes, as in the API course.
Try it: four people, four answers
Section titled “Try it: four people, four answers”Get a token for each person. Each one lasts 10 minutes. On Windows, use $S = python -m scripts.login --user usr-sam --print-access-token, and so on.
S=$(python -m scripts.login --user usr-sam --print-access-token)O=$(python -m scripts.login --user usr-omar --print-access-token)TO=$(python -m scripts.login --user usr-tomas --print-access-token)-
Sam reads a ticket of his own organization:
Terminal window curl -i -H "Authorization: Bearer $S" http://127.0.0.1:8000/v1/tenants/larkfield/tickets/T-30002HTTP/1.1 200 OKcontent-type: application/jsoncache-control: no-store{"ticket_id":"T-30002","customer_id":"C-0022","customer_name":"Hugo Garcia","subject":"Charged twice for order LK-182074", …}Some headers and most of the body are left out here.
-
Sam guesses Bramble’s
T-40001through his own organization:Terminal window curl -H "Authorization: Bearer $S" http://127.0.0.1:8000/v1/tenants/larkfield/tickets/T-40001{"error":{"code":"not_found","message":"Ticket T-40001 does not exist.","request_id":"d084d5ebc331","fields":[]}}Inside Larkfield,
T-40001does not exist. Ask forT-49999too: the message is the same, with the other ID. -
Sam tries Bramble’s path:
Terminal window curl -H "Authorization: Bearer $S" http://127.0.0.1:8000/v1/tenants/bramble/tickets/T-40001{"error":{"code":"not_found","message":"Not found.","request_id":"8f8d37f43163","fields":[]}}Sam is not a member of Bramble Books. Check 2 stops him before any ticket query.
-
Omar, read-only, tries to write. Send the same body as in Module 1:
Terminal window curl -X POST -H "Authorization: Bearer $O" -H "Content-Type: application/json" -d '{"author": "agent", "body": "Refund approved."}' http://127.0.0.1:8000/v1/tenants/larkfield/tickets/T-30002/messages{"error":{"code":"forbidden","message":"Your role may not do this (read_only may not ticket.write).","request_id":"6d55bd54c0d5","fields":[]}}In PowerShell, put the body in
msg.jsonand use-d "@msg.json". The status is 403, and the API saves nothing. -
Send a request with no token:
Terminal window curl -i http://127.0.0.1:8000/v1/tenants/larkfield/tickets/T-30002HTTP/1.1 401 Unauthorizedwww-authenticate: Bearer realm="ticket-api"{"error":{"code":"not_signed_in","message":"Send an access token: Authorization: Bearer <token>.","request_id":"927221212ae2","fields":[]}} -
Make a token with
tickets:readonly. The helper signs it with the practice provider’s keys, as a reporting script would get it:Terminal window R=$(python -c "from scripts.practice import token; print(token('usr-sam', 'tickets:read'))")curl -i -X POST -H "Authorization: Bearer $R" -H "Content-Type: application/json" -d '{"author": "agent", "body": "We refunded one payment."}' http://127.0.0.1:8000/v1/tenants/larkfield/tickets/T-30002/messagesHTTP/1.1 403 Forbiddenwww-authenticate: Bearer realm="ticket-api", error="insufficient_scope"{"error":{"code":"insufficient_scope","message":"The token has no tickets:write scope.","request_id":"17c4a6e6318e","fields":[]}}Sam’s role allows the write. The token does not.
The API terminal shows each refusal with its reason. The reason stays in the log, not in the answer:
INFO ticket_api: access denied: role_cannot usr-omar ticket.write in larkfield id=6d55bd54c0d5INFO ticket_api: POST /v1/tenants/larkfield/tickets/T-30002/messages 403 2ms queries=1 id=6d55bd54c0d5Why 404 for outsiders, and 403 for members
Section titled “Why 404 for outsiders, and 403 for members”Tomás has no membership. He asks for Larkfield, a real organization, and for acme, which does not exist:
curl -H "Authorization: Bearer $TO" http://127.0.0.1:8000/v1/tenants/larkfield/tickets/T-30002curl -H "Authorization: Bearer $TO" http://127.0.0.1:8000/v1/tenants/acme/tickets/T-30002{"error":{"code":"not_found","message":"Not found.","request_id":"d2f7009169b7","fields":[]}}{"error":{"code":"not_found","message":"Not found.","request_id":"c4539997ef8d","fields":[]}}Only the request_id differs. On macOS and Linux, remove it and compare the bytes:
curl -s -H "Authorization: Bearer $TO" http://127.0.0.1:8000/v1/tenants/larkfield/tickets/T-30002 | sed 's/"request_id":"[0-9a-f]*",//' > real.txtcurl -s -H "Authorization: Bearer $TO" http://127.0.0.1:8000/v1/tenants/acme/tickets/T-30002 | sed 's/"request_id":"[0-9a-f]*",//' > made-up.txtcmp real.txt made-up.txt && echo identicalidenticalThe test test_an_outsider_gets_the_same_404_as_for_an_organization_that_does_not_exist makes the same comparison on every run.
Text description of the diagram
Tomás’s two requests, one for a real organization and one for a made-up one, get the same 404 body apart from the request ID. Sam sends two requests through Larkfield: one with a real Bramble ID, one with a missing ID. Both get the same message, “Ticket … does not exist.” So neither person learns that a record or an organization exists. The response times were the same within noise: 2.03 ms and 2.02 ms in the course’s measurement.
So an outsider cannot use the API to list the organizations, or to test which ticket IDs are real. A member is different. Omar is a Larkfield member, and he can already read T-30002. A 403 tells him nothing new, and it tells him the useful truth: “your role may not do this”. So members get 403, and outsiders get 404.
There is one honest limit. The course measured the time of each answer: 200 requests, five passes, on a computer with little else running. Tomás’s 404 for the real organization took a median of 2.03 ms. For the made-up one, it took 2.02 ms. Here, the difference is lost in the noise. But a route that does more work for a real record than for a missing one can answer more slowly. A patient attacker can repeat a request many times and compare the times. This is a timing leak. Some systems add random delays, or do the same work for every case. This course does not try. It removes the leak from the content of the answer, and it says that the time can still leak.
Run the attempts again
Section titled “Run the attempts again”The script of Threat examples now has ten attempts. Each person sends their own token, made with the practice provider’s keys.
python -m scripts.attempts 2>/dev/nullMade-up attempts against your own practice API. Each person sends their own token.
1. Sam (staff at Larkfield) tries to read Bramble Books' ticket T-40001 through Larkfield's path. GET /v1/tenants/larkfield/tickets/T-40001 -> 404 (should get: 404) OK: not_found: Ticket T-40001 does not exist.2. Sam (staff at Larkfield) tries to read T-40001 through Bramble Books' path. GET /v1/tenants/bramble/tickets/T-40001 -> 404 (should get: 404) OK: not_found: Not found.3. Ines (owner at Bramble Books) tries to list the tickets of her own shop. GET /v1/tenants/bramble/tickets?limit=100 -> 200 (should get: 200) OK: 40 tickets, all bramble's4. Omar (read-only at Larkfield) tries to add a message to T-30002 (the page hides the button; he sends the request himself). POST /v1/tenants/larkfield/tickets/T-30002/messages -> 403 (should get: 403) OK: forbidden: Your role may not do this (read_only may not ticket.write).5. Tomás (no membership) tries to read Larkfield's ticket T-30002. GET /v1/tenants/larkfield/tickets/T-30002 -> 404 (should get: 404) OK: not_found: Not found.6. Ines (owner at Bramble Books) tries to find tickets similar to her ticket T-40001. GET /v1/tenants/bramble/tickets/T-40001/similar?k=5 -> 200 (should get: 200) OK: 5 tickets, all bramble's7. Sam (staff at Larkfield) tries to choose Bramble Books with a header (X-Tenant-ID: bramble) on Larkfield's list. GET /v1/tenants/larkfield/tickets?limit=100 -> 200 (should get: 200) OK: 100 tickets, all larkfield's8. Camille (read-only at Bramble Books) tries to add a message to Bramble Books' T-40001. POST /v1/tenants/bramble/tickets/T-40001/messages -> 403 (should get: 403) OK: forbidden: Your role may not do this (read_only may not ticket.write).9. Sam (staff at Larkfield) tries to get a download link for a Bramble Books receipt, with its real ID. GET /v1/tenants/larkfield/attachments/e7e54964-6167-47fa-8c59-b0b14b2d7e4a/download -> 404 (should get: 404) OK: not_found: Attachment e7e54964-6167-47fa-8c59-b0b14b2d7e4a does not exist.10. Omar (read-only at Larkfield) tries to search the documents for the staff-only refund procedure. GET /v1/tenants/larkfield/documents?q=refund approval -> 200 (should get: 200) OK: 2 public documents, no staff-only one
0 of 10 attempts got what they should not get.In PowerShell, use 2>$null. Attempts 1 to 5 are the attempts of Module 1, which all succeeded then. Attempts 6, 9 and 10 also depend on the next two lessons: the search filter, the file lookup and the document access level.
When to use it, and what it costs
Section titled “When to use it, and what it costs”Use an organization in the path, and a check on the server, for every route that touches an organization’s records. That is the default for any application that more than one organization shares. Another common design takes the organization from the host name, for example bramble.example.com. The rule is the same: the request names it, and the server checks the membership.
The cost is small but real. Each request now reads one membership row first: the log shows queries=1 for Omar’s refused request. Every route and every test changed paths, and the API’s contract has a new version. Some teams put the memberships in the token to save that query. This course does not, because a removed member would keep access until the token expires. Lifecycle changes measures exactly that.
Common mistakes
Section titled “Common mistakes”Common mistake
Tomás: "Send the tenant ID from the browser"
Symptom: a design where the page sends X-Tenant-ID: bramble, and the API filters by it. It works in every demo, because every demo user picks their own organization.
Cause: the header says what the caller wants. Any caller can change it with one curl option. Try it: Sam sends the header on Larkfield’s list.
curl -H "Authorization: Bearer $S" -H "X-Tenant-ID: bramble" "http://127.0.0.1:8000/v1/tenants/larkfield/tickets?limit=3"The three items are T-30200, T-30199 and T-30198: Larkfield’s tickets. The API ignores the header.
Fix: take the organization only from the place that the server checks: the path, compared with the caller’s own membership. ticket_api/tenancy.py never reads an organization from a header, a query parameter or the body. A test, test_a_tenant_header_from_the_browser_changes_nothing, keeps it that way.
Common mistake
Tomás: "We hid the delete button"
Symptom: the page at /app/ now lists the tickets of your organization. Sign in as Omar: the list has no “Reply” button. Tomás thinks that this enforces the read-only rule.
Cause: the page is in the browser, and the person controls the browser. In step 4 above, Omar sent the request without the page.
Fix: keep the rule in require("ticket.write") on the server. Hide the button too, as a kindness: the page says so under the list. Omar’s request gets 403 either way.
Common mistake
Every request answers 401 after a few minutes
Symptom: commands that worked now answer 401 with "code":"invalid_token","message":"The token has expired.".
Cause: the provider’s access tokens last 10 minutes, as in Token meaning.
Fix: get new tokens with the python -m scripts.login lines above.
Common mistake
The API answers 500 after the copy
Symptom: every ticket request answers 500 internal_error. The API terminal shows psycopg.errors.InvalidParameterValue: role "ticket_app" does not exist.
Cause: the code is new, but migration 017 did not run. The API switches to the role ticket_app, which the migration creates. The next lesson explains why.
Fix: run python -m ticket_api.migrate, then send the request again.
Reset. python -m scripts.load --reset loads both shops and the people again. If your project is broken, copy the snapshot end-of-m04 with your .env, then run python -m scripts.load --reset and python -m idp init.
Knowledge check
Section titled “Knowledge check”Check your understanding
Explain why the API answers 404, not 403, when Tomás asks for a ticket of Larkfield. Why is 403 right for Omar's write?
Show the answer
Tomás is not a member of Larkfield. A 403 would tell him that the organization exists. A 404 is the same answer as for a made-up organization, so he learns nothing. Omar is a member who already reads the ticket, so a 403 reveals nothing new. It also tells him the true reason: his role may not write.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Predict: Camille is staff at Larkfield and read-only at Bramble Books. What does each request answer? (a) GET /v1/tenants/bramble/tickets/T-40001; (b) POST a message to /v1/tenants/bramble/tickets/T-40001/messages; (c) GET /v1/tenants/larkfield/tickets/T-40001; (d) any request without a token.
Show the answer
(a) 200: they are a member, and read_only may read. (b) 403 forbidden: read_only may not ticket.write (attempt 8). (c) 404 “Ticket T-40001 does not exist.”: they pass checks 1 to 3 at Larkfield, but the query inside Larkfield finds no such ticket. (d) 401 not_signed_in: check 1 stops every request without an identity.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Practical check: show that the two 404 answers of an outsider are byte-identical, apart from request_id. Which commands do you run, and what do they print?
Show the answer
Get Tomás’s token, request /v1/tenants/larkfield/tickets/T-30002 and /v1/tenants/acme/tickets/T-30002, and remove request_id with sed. cmp real.txt made-up.txt && echo identical prints identical. Both bodies are {"error":{"code":"not_found","message":"Not found.","fields":[]}}.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Recap
- Every route of an organization’s records is under
/v1/tenants/{tenant}/. The old routes are gone; the API key stays only for/v1/classifyand/v1/history. - Four checks, in order: identity (401), membership (404), role and scope (403), the record in this organization (404).
- An outsider gets the same 404 for a real and a made-up organization. A member gets 403 when the role or the scope is too small. The time of an answer can still leak; the course measured no difference here (2.03 and 2.02 ms).
- The organization comes from the path and the caller’s membership, never from a header. A hidden button is not a rule.
python -m scripts.attempts: 0 of 10 attempts got what they should not.
Next, you make the database part safe: the organization becomes a required argument of every query, and row-level security adds a second lock.
Next: Tenant-safe queries