Skip to content

Job identity context

  • Lesson 14
  • 50 min
  • Module 5: Background work and administration

What you will be able to do

Run work in the background as the person who asked for it, in their organization. Save who asked and where on the job, check both again when the job runs, and refuse a job whose details do not match.

Before you startWhat to know and to have readyAbout 50 minutes

What you need to know

Your project

  • ~/projects/ticket-api at the snapshot end-of-m04, with PostgreSQL and Azurite running, and the virtual environment active.
  • Two terminals, as in Module 2: one runs the practice provider (python -m idp), and one runs the API (fastapi dev). A third terminal runs the commands of this lesson.

This lesson copies all of Module 5’s files: background jobs, support access, the audit and member changes. The next two lessons use the rest of them. After the copy, your project matches the snapshot end-of-m05.

Think of a paper cheque. On Monday, Grace writes a cheque from Larkfield’s bank account and signs it. The supplier takes it to the bank the next week.

The bank does not pay just because the paper looks right. When the cheque arrives, the bank asks two questions again. Is this account still open? Is Grace still allowed to sign for it? If Larkfield removed Grace as a signer on Wednesday, the bank refuses the cheque, although it was valid on Monday.

The cheque also names its account. Nobody can write “pay this from Bramble Books’ account” on Grace’s cheque and get paid. The bank checks that the signer may sign for that account.

Background work in the ticket API has the same shape. Somebody asks today, the work happens later, and the permission must be true when the work happens.

The problem: the request ends, the work does not

Section titled “The problem: the request ends, the work does not”

Some work is too slow for one request. An export writes an organization’s tickets into a CSV file. With large data, that can take minutes. So the API does not do it while the person waits. It saves a job: a note that says “export these tickets”. It answers at once with 202 Accepted. A separate program, the worker, finds the job later and does the work.

This split is good for speed, but it breaks the checks of Module 4. Every check there runs during a request. The API reads the token, finds the organization in the path, and reads the membership. When the worker runs a job, there is no request, no token and no path. The person who asked may have signed out an hour ago.

So what does the worker know? Only what the job says. Here is a constructed case: a worker that trusts the job, with no second check. This code is not in the project.

A timeline with three moments. 10:00: Sam, who is staff, asks for an export. 10:05: Grace makes Sam read-only. 10:20: the worker runs the job. Two rows below show two workers. A worker that trusts the job (constructed) uses the row as it is and exports every Larkfield ticket for a read-only member, in red. This project's worker checks Sam again and refuses the job: read_only may not export.create, in green. A note says that both workers see the same row: actor usr-sam, tenant larkfield, kind export. The row was right at 10:00; at 10:20 it is old.
The same job, run twenty minutes later. The row was right when Sam asked; the permission changed before the work ran.
Text description of the diagram

Three moments: at 10:00 Sam, who is staff, asks for an export. At 10:05 Grace makes Sam read-only. At 10:20 the worker runs the job. A worker that trusts the job uses the row as it is. So it exports every Larkfield ticket for a person who may no longer export. The course constructed this case. The project’s worker checks Sam’s membership again at 10:20 and refuses the job, because a read-only member may not export.

Two things go wrong here. First, the permission is old: the API checked it at 10:00, and the worker used it at 10:20. Second, the job itself can be wrong. A bug, a hand-made database row or a changed queue message can name an organization that the person never belonged to. A worker that trusts the job then works in the wrong organization, and no signed-in person is there to stop it.

The idea: save who and where, then check again

Section titled “The idea: save who and where, then check again”

Back to the cheque. The cheque holds three facts: the signer, the account and the amount. When the cheque arrives, the bank checks the signer against the account as they are now.

A job needs the same three facts. Together they are the job’s identity context:

  • the actor: the person who asked, usr-sam;
  • the tenant: the organization the work is for, larkfield;
  • the action that the job does: an export needs export.create.

The project follows four rules:

  1. Save the context from the checked request, never from the request body. The actor comes from the token’s sub. The tenant comes from the path, after the membership check. A field in the body cannot change either one.
  2. The job row is the only source. The jobs table keeps actor_id and tenant_id. If a message on a queue names the job, it carries the job’s ID. The worker compares any other field in the message with the row, and never uses it.
  3. Check again when the job runs. The worker reads the actor’s membership in the job’s tenant, as it is now. It asks the same function as the API, decide(role, action). If the answer is no, the worker refuses the job and records the reason.
  4. Do the work inside the tenant. Every query has the tenant, and row-level security is on for it, as in every request.
Five steps. 1. Sam asks: POST /v1/tenants/larkfield/exports with the body status open and his token. 2. The API checks now: who is usr-sam from the token, where is larkfield from the path, and staff may export.create. A note says request time: the person is signed in and checked. 3. The API saves the job row in the jobs table: tenant_id larkfield, actor_id usr-sam, kind export, params status open, status queued. Minutes or hours later: 4. The worker takes the oldest queued row. A note says run time: no request and no token; the worker reads who and where from the job row. 5. It checks again, with the membership as it is now: decide(role, export.create). If Sam is still staff: done, 39 rows in tenants/larkfield/exports/job.csv, in green. If he was removed or his role changed: refused, read_only may not export.create, checked when the job ran, in red. A dashed note says a queue message carries the job ID; any other field in it is only compared with the row, never used.
Check at the door, and check again at the desk. The API checks the request; the worker checks the job row against the membership as it is when the work runs.
Text description of the diagram

At request time, the API checks Sam’s token, his membership in Larkfield and his role. Then it saves a job row with the actor and the tenant from those checks. At run time there is no token. The worker takes the row, reads Sam’s membership in Larkfield again, and asks decide() whether his role may export.create. If yes, it writes the CSV file. If not, it refuses the job and records why. A queue message only names the job.

Here is one job and four ways that the run can go. Only the facts at run time change.

The job says At run time The worker does
actor usr-sam, tenant larkfield, export Sam is staff at Larkfield done: staff may export.create
actor usr-sam, tenant larkfield, export Grace made Sam read-only refused: read_only may not export.create
actor usr-sam, tenant bramble, export Sam has no membership at Bramble Books refused: no membership in this organization
actor usr-sam, tenant larkfield, export a queue message says tenant_id: bramble refused: the message names another organization than the job

The second and third rows are the two failures of the last section. The fourth row is the cheque with a changed account name. The worker does not believe the message; it compares the message with the row.

Now build it. The files come from the snapshot end-of-m05.

  1. Stop the API with Ctrl+C. Leave the provider running. In ~/projects/ticket-api, copy Module 5’s files from the snapshot:

    Terminal window
    S=../nextia-learning-labs/C23/snapshots/end-of-m05
    for d in contract docs migrations scripts tests ticket_api; do cp -R "$S/$d/." "$d/"; done
  2. Open migrations/018_jobs.sql in VS Code. Find the two columns tenant_id and actor_id. Both are NOT NULL, and both are foreign keys. The last lines turn on row-level security for jobs, as for the tickets.

  3. Apply the two new migrations and load both shops again:

    Terminal window
    python -m scripts.load --reset
    applied 018_jobs
    applied 019_audit_and_support_access
    Database is up to date (19 migrations).
    Loaded the small data in 0.6 s:
    larkfield bramble
    customers 40 12
    tickets 200 40
    messages 331 58
    attachments 43 14
    documents 36 3
    ai_runs 294 69
    ticket_embeddings 200 40
    57 files uploaded to the object storage.
    3 messages have no ticket: they are in orphaned_messages.
  4. Run the tests:

    Terminal window
    python -m pytest

    The last line is 234 passed, 1 deselected: 16 more than at the end of Module 4. The new tests are in tests/test_lifecycle.py, and this module explains all of them.

  5. Start the API again with fastapi dev.

In the third terminal, get a token for Sam and keep the address of Larkfield’s routes in a variable:

Terminal window
TOKEN=$(python -m scripts.login --user usr-sam --print-access-token)
A=http://127.0.0.1:8000/v1/tenants/larkfield

The curl commands below are the same on Windows, with curl.exe instead of curl. In Windows PowerShell 5.1, write the quotes in a JSON body as \", for example -d '{\"status\": \"open\"}'. Ask for an export of the open tickets:

Terminal window
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"status": "open"}' $A/exports
{"job_id":"fe056555-413a-4272-a211-ae362a4bd4c5","tenant_id":"larkfield","actor_id":"usr-sam","kind":"export","status":"queued","reason":null,"result":null,"created_at":"2026-10-10T06:35:37.212348Z","finished_at":null,"download_url":null}

Your job ID and times differ. The status of the answer is 202 Accepted: “I have it, and it is not done yet”. Look at the row in psql (docker compose exec db psql -U tickets -d tickets):

SELECT job_id, tenant_id, actor_id, kind, params, status FROM jobs;
job_id | tenant_id | actor_id | kind | params | status
--------------------------------------+-----------+----------+--------+----------------------------------+--------
fe056555-413a-4272-a211-ae362a4bd4c5 | larkfield | usr-sam | export | {"team": null, "status": "open"} | queued
(1 row)

Sam sent only {"status": "open"}. The API wrote larkfield and usr-sam from its own checks. Could Sam send the organization in the body instead, as Tomás suggests? Try it. The model refuses unknown fields:

Terminal window
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"tenant_id": "bramble"}' $A/exports
{"error":{"code":"invalid_request","message":"The request is not valid.","request_id":"f68ecd04788f","fields":["tenant_id"]}}

Now run the worker once. It takes every queued job, checks it and runs it:

Terminal window
python -m scripts.worker --once
fe056555-413a-4272-a211-ae362a4bd4c5 export for usr-sam in larkfield: done {'rows': 39, 'object_key': 'tenants/larkfield/exports/fe056555-413a-4272-a211-ae362a4bd4c5.csv'}

The worker wrote 39 open tickets into a file under tenants/larkfield/. Ask the API for the job again: curl -s -H "Authorization: Bearer $TOKEN" $A/exports/<your job ID>. The answer now says "status":"done", with a download_url. It is a signed link for five minutes, as for the files in Module 4. Open it in your browser. The first lines of the file:

ticket_id,customer_id,subject,team,priority,status,created_at
T-30002,C-0022,Charged twice for order LK-182074,billing,1,open,2026-09-25 08:20:19+00:00
T-30005,C-0015,Verification code not arriving,login,2,open,2026-09-25 09:32:08+00:00

The worker in ticket_api/worker.py is short. The important part is the check:

with db.connection() as conn:
role = repository.membership_role(conn, job["tenant_id"], job["actor_id"])
decision = decide(role, ACTION[job["kind"]])
if not decision.allowed:
finish(db, job, "refused", f"{job['actor_id']}: {decision.rule} (checked when the job ran)")
return "refused"

It reads the role from the database, now, and it uses the same decide() as every request. Then it does the work with db.connection(job["tenant_id"]), so row-level security limits it to Larkfield’s rows.

The permission changes before the job runs

Section titled “The permission changes before the job runs”

Now repeat the timeline of the diagram. Sam asks for a second export, this time of every ticket. Before the worker runs, Grace makes Sam read-only. The PATCH route is new in this module, and Lifecycle changes explains it. On Windows, set $GRACE as you set $TOKEN.

Terminal window
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}' $A/exports
GRACE=$(python -m scripts.login --user usr-grace --print-access-token)
curl -s -X PATCH -H "Authorization: Bearer $GRACE" -H "Content-Type: application/json" -d '{"role": "read_only"}' $A/members/usr-sam
python -m scripts.worker --once
{"job_id":"adeeb78b-3f7b-431e-99e9-856a44883429","tenant_id":"larkfield","actor_id":"usr-sam","kind":"export","status":"queued","reason":null,"result":null,"created_at":"2026-10-10T06:35:37.673063Z","finished_at":null,"download_url":null}
{"tenant_id":"larkfield","tenant_name":"Larkfield","role":"read_only"}
adeeb78b-3f7b-431e-99e9-856a44883429 export for usr-sam in larkfield: refused (usr-sam: read_only may not export.create (checked when the job ran))

The row still says usr-sam and larkfield, exactly as before. The facts around it changed, and the worker saw that. Grace is the owner, so she can read the result with curl -s -H "Authorization: Bearer $GRACE" $A/exports/<job ID>. It says "status":"refused", with the same reason. Sam cannot read it: a read-only member may not use the export routes, so he gets 403.

Make Sam staff again before you continue:

Terminal window
curl -s -X PATCH -H "Authorization: Bearer $GRACE" -H "Content-Type: application/json" -d '{"role": "staff"}' $A/members/usr-sam

The API never writes a job for an organization that the actor cannot access. But the worker does not rely on that. Here is a constructed case: someone with database access inserts a job for Sam at Bramble Books, without the API. In psql:

INSERT INTO jobs (job_id, tenant_id, actor_id, kind) VALUES (gen_random_uuid(), 'bramble', 'usr-sam', 'export');

Then run the worker:

Terminal window
python -m scripts.worker --once
9909ec95-4970-43e5-8d2e-d33e82155a5f export for usr-sam in bramble: refused (usr-sam: no membership in this organization (checked when the job ran))

The row named an organization where Sam has no membership, so the check failed. No Bramble ticket left the database.

Larger systems put a message on a queue for each job, and workers read the queue. The course Scaling APIs and AI Workloads builds one. This project’s worker reads the jobs table directly. But run_job() also accepts the message that named the job, to show the rule:

if message and message.get("tenant_id") not in (None, job["tenant_id"]):
finish(db, job, "refused", "the message names another organization than the job")
return "refused"

The worker never uses the message’s tenant_id to choose the organization. It only compares it with the row. A mismatch means a bug or an attack, so the job stops. A test sends exactly that message. Run the job tests:

Terminal window
python -m pytest tests/test_lifecycle.py -k "job or export or message" -v
tests/test_lifecycle.py::test_an_export_runs_as_the_person_who_asked PASSED
tests/test_lifecycle.py::test_a_job_is_refused_when_its_actor_was_removed_before_it_ran PASSED
tests/test_lifecycle.py::test_a_job_row_that_names_an_organization_its_actor_cannot_access_is_refused PASSED
tests/test_lifecycle.py::test_a_queue_message_with_another_organization_changes_nothing PASSED
tests/test_lifecycle.py::test_another_members_export_is_not_found_but_the_owner_sees_it PASSED
======================= 5 passed, 11 deselected in 1.35s =======================

The last test shows one more rule. Camille cannot read Sam’s export (404), but Grace, the owner, can.

The worker writes an audit event for each run, next to the API’s events. Privileged operations explains the audit table. For now, look at the events of this lesson:

SELECT event_id, action, actor_id, tenant_id, result FROM audit_events ORDER BY event_id;
event_id | action | actor_id | tenant_id | result
----------+---------------------+-----------+-----------+--------
1 | export.queued | usr-sam | larkfield | done
2 | export.run | usr-sam | larkfield | done
3 | export.queued | usr-sam | larkfield | done
4 | member.role_changed | usr-grace | larkfield | done
5 | export.run | usr-sam | larkfield | denied
6 | member.role_changed | usr-grace | larkfield | done
7 | export.run | usr-sam | bramble | denied
(7 rows)

Each refused run has the actor and the organization from the row. So an owner can see later what somebody tried.

Why not give the worker its own powerful identity?

Section titled “Why not give the worker its own powerful identity?”

Amira asks the obvious question: “The worker is our own program. Why not give it a service account that can read every organization, and skip the checks?”

Kwame answers with the cheque. A bank clerk can open every account, but the clerk does not decide whose cheque to pay. The signer’s authority decides. If the worker uses its own power, every job runs with that power. Then a forged row or a stale job reads anything.

So the project keeps the worker’s own power small. The worker connects to the database directly. To take a job (claim()), it reads the jobs table across organizations, because it does not know the organization yet. Everything after that runs as the actor, inside the job’s organization. The provider also has a service client, export-worker. A token for it is not a member of any organization. A test from Module 4, test_a_service_token_is_not_a_member_of_any_organization, checks that it gets 404.

Use a job identity context for all work that runs outside the request: exports, imports, scheduled reports, an AI summary of many tickets, emails. If the work acts for a person in an organization, save the person and the organization, and check them when the work runs.

The costs are small but real:

  • One more query per job: the membership read. For a job that runs for minutes, that is nothing.
  • Refused work: if a person’s role changes, their queued job fails, and they must ask again. That is the right result, but the person needs a clear reason. The reason column gives it.
  • Long jobs: the check runs when the job starts. A job that runs for an hour should check again between batches, or the permission can change in the middle. This project’s exports are short, so one check is enough.
  • Retries and duplicates: a queue can deliver a message twice, and a second run must not do the work twice. The course Scaling APIs and AI Workloads covers job states, retries and duplicate messages.

The deletion of an organization is a job too, with an administrator as its actor. Lifecycle changes runs it.

Common mistake

The load stops: the tables already have data

Symptom: python -m scripts.load prints The help-desk tables already have data. Run again with --reset to delete it and load again. It prints no applied lines.

Cause: without --reset, the loader stops before it applies 018 and 019, so the jobs table does not exist yet.

Fix: run python -m scripts.load --reset.

Common mistake

The worker prints nothing

Symptom: python -m scripts.worker --once stops at once and prints no line.

Cause: the queue is empty. Each job runs once, so a second run of the worker finds nothing.

Fix: ask for a new export first. To see older jobs, use SELECT job_id, status, reason FROM jobs; in psql.

Common mistake

403 when Omar asks for an export

Symptom: {"error":{"code":"forbidden","message":"Your role may not do this (read_only may not export.create).", …}}

Cause: this is correct. The access matrix gives export.create to owner and staff only.

Fix: use Sam’s or Grace’s token.

Reset. python -m scripts.load --reset deletes every job and loads both shops again. If your project is broken, copy the snapshot end-of-m05 with your .env file and your .idp folder.

Check your understanding

At 10:00, Camille (staff at Larkfield) asks for an export. At 10:10, Grace removes them from Larkfield. At 10:30, the worker runs the job. What happens, and which line of the worker decides it?

Show the answer

The worker refuses the job: usr-camille: no membership in this organization (checked when the job ran). The job row still says usr-camille and larkfield. But at 10:30 the worker reads their membership with repository.membership_role() and finds none, so decide(None, "export.create") says no. The worker writes no file, and it records an export.run audit event with the result denied.

Check your understanding

A teammate proposes to put tenant_id and user_id into the queue message, so the worker does not need to read the jobs table. What goes wrong?

Show the answer

The message becomes the source of who and where. Then anyone who can write to the queue can make the worker act in another organization or as another person. A bug that changes a field can do the same. In this project, the job row is the only source. The message carries the job ID, and the worker only compares any other field with the row; a mismatch refuses the job. The worker also still checks the membership at run time, and a message cannot do that for it.

Check your understanding

Why does the API take the tenant from the path and the actor from the token, and not from the request body? Use the 422 answer from this lesson.

Show the answer

The API checked the path’s tenant: it read the membership and the role for exactly that organization. The token’s sub is the person that the provider signed for. A body field is whatever the caller types. The export model forbids unknown fields, so {"tenant_id": "bramble"} gets 422 with "fields":["tenant_id"]. Even without that rule, the API would not read the field.

Recap

  • A background job runs after the request has ended: no token, no path, maybe no signed-in person.
  • Its identity context is the actor, the tenant and the action. The API saves the actor and the tenant from its own checks, never from the body.
  • The worker checks the actor’s membership again when the job runs, with the same decide(). It refused Sam’s export after Grace made him read-only, and it refused a forged Bramble job.
  • A queue message names the job. The worker compares its other fields with the row, and never uses them.
  • Your project matches end-of-m05: 234 passed, 1 deselected.

Next, a person who is in no organization needs to help one: Kwame, the platform administrator.

Next: Privileged operations