Skip to content

Authorization in the API

  • Lesson 11
  • 55 min
  • Module 4: Enforce resource access

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 readyAbout 55 minutes

What you need to know

Your project

  • ~/projects/ticket-api at the snapshot end-of-m03: python -m pytest ends with 193 passed, 1 deselected.
  • The labs repository next to the project, in ~/projects/nextia-learning-labs. Run git pull in it to get the snapshot end-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.

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.

Terminal window
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:

Terminal window
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 path

The 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”:

  1. Who is the caller? A checked token or session, as in Modules 2 and 3. If there is none: 401.
  2. Is the caller a member of the organization in the path? One row in memberships. If there is none: 404.
  3. Do the role and the token’s scopes allow the action? The decide() function of the access matrix. If not: 403.
  4. Is the record in this organization? The query has tenant_id = 'larkfield' in it. If no row matches: 404.
A request GET /v1/tenants/larkfield/tickets/T-30002 with a bearer token goes through four checks in a row. 1. Who is the caller? A checked token or session; no: 401 not_signed_in, for example no token. 2. A member of the organization in the path? A memberships row for the tenant and user; no: 404 Not found., for Tomás, for Sam at bramble, or a made-up organization. 3. Do the role and the token's scopes allow it? decide(role, action, scopes); no: 403 forbidden, when Omar writes or a token is read-only. 4. Is the record in this organization? WHERE tenant_id and ticket_id; no: 404 Ticket T-40001 does not exist, when Sam asks for T-40001 through larkfield. Yes after all four: 200 OK, Sam reads T-30002.
The four checks of every route under /v1/tenants/{tenant}/, in order. Each 'no' has its own answer, and the checks stop there.
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.

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:

Terminal window
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.

All of this module’s files come from the snapshot end-of-m04. The copy also brings the files of the next two lessons.

  1. Stop the API with Ctrl+C. Leave the provider running.

  2. In ~/projects/ticket-api, copy the module’s files from the snapshot:

    Terminal window
    S=../nextia-learning-labs/C23/snapshots/end-of-m04
    for d in contract migrations scripts tests ticket_api; do cp -R "$S/$d/." "$d/"; done
    cp "$S/.env.example" .

    Your .env does not change. The two new settings, DB_ROW_SECURITY and CACHE_SECONDS, have defaults.

  3. Apply the two new migrations. Your data stays, so you do not need --reset:

    Terminal window
    python -m ticket_api.migrate
    applied 016_tenant_indexes
    applied 017_row_level_security
    Database is up to date (17 migrations).
  4. Run the tests:

    Terminal window
    python -m pytest

    The last line is 218 passed, 1 deselected. The databases course’s tests now use the organization’s paths.

  5. Start the API again. The API key still protects /v1/classify and /v1/history:

    Terminal window
    API_KEY=local-dev-key fastapi dev

    On Windows, set $env:API_KEY = "local-dev-key" first, then run fastapi dev.

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.

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.

Terminal window
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)
  1. 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-30002
    HTTP/1.1 200 OK
    content-type: application/json
    cache-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.

  2. Sam guesses Bramble’s T-40001 through 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-40001 does not exist. Ask for T-49999 too: the message is the same, with the other ID.

  3. 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.

  4. 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.json and use -d "@msg.json". The status is 403, and the API saves nothing.

  5. Send a request with no token:

    Terminal window
    curl -i http://127.0.0.1:8000/v1/tenants/larkfield/tickets/T-30002
    HTTP/1.1 401 Unauthorized
    www-authenticate: Bearer realm="ticket-api"
    {"error":{"code":"not_signed_in","message":"Send an access token: Authorization: Bearer <token>.","request_id":"927221212ae2","fields":[]}}
  6. Make a token with tickets:read only. 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/messages
    HTTP/1.1 403 Forbidden
    www-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=6d55bd54c0d5
INFO ticket_api: POST /v1/tenants/larkfield/tickets/T-30002/messages 403 2ms queries=1 id=6d55bd54c0d5

Why 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:

Terminal window
curl -H "Authorization: Bearer $TO" http://127.0.0.1:8000/v1/tenants/larkfield/tickets/T-30002
curl -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:

Terminal window
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.txt
curl -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.txt
cmp real.txt made-up.txt && echo identical
identical

The test test_an_outsider_gets_the_same_404_as_for_an_organization_that_does_not_exist makes the same comparison on every run.

Top: Tomás, with no membership, asks for GET /v1/tenants/larkfield/tickets/T-30002 (a real organization and ticket) and for GET /v1/tenants/acme/tickets/T-30002 (no such organization). Both answers are 404 with code not_found and message Not found.; only the request_id differs. Bottom: Sam, staff at Larkfield, asks through Larkfield for T-40001 (a real ID of Bramble Books) and T-49999 (no such ticket). Both answers are 404 with the message Ticket ... does not exist., with the ID that he asked for. A red line below gives the time: Tomás's two 404s took 2.03 ms and 2.02 ms (medians), the same within noise. Timing can still leak; this course does not try to remove it.
What a 404 tells an outsider and a member: nothing about another organization. Only the time of an answer could still differ; the course measured no difference, and does not try to remove one.
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.

The script of Threat examples now has ten attempts. Each person sends their own token, made with the practice provider’s keys.

Terminal window
python -m scripts.attempts 2>/dev/null
Made-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's
4. 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's
7. 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's
8. 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.

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 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.

Terminal window
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.

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.

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.

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":[]}}.

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/classify and /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