Job identity context
- Lesson 14
- 50 min
- Module 5: Background work and administration
Lesson 14 of 19 · Authentication, Permissions and Multi-Tenant ApplicationsJob identity context
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
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 ready
What you need to know
- How the API finds the caller’s role on every request, from Authorization in the API: identity, then membership, then role.
- Row-level security as a second lock, from Tenant-safe queries.
Your project
~/projects/ticket-apiat the snapshotend-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.
A cheque that the bank pays next week
Section titled “A cheque that the bank pays next week”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.
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:
- 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. - The job row is the only source. The
jobstable keepsactor_idandtenant_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. - 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. - Do the work inside the tenant. Every query has the tenant, and row-level security is on for it, as in every request.
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.
A tiny worked example
Section titled “A tiny worked example”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.
Add the jobs to your project
Section titled “Add the jobs to your project”Now build it. The files come from the snapshot end-of-m05.
-
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-m05for d in contract docs migrations scripts tests ticket_api; do cp -R "$S/$d/." "$d/"; doneTerminal window $S = "..\nextia-learning-labs\C23\snapshots\end-of-m05"foreach ($d in "contract","docs","migrations","scripts","tests","ticket_api") { Copy-Item -Recurse -Force "$S\$d\*" $d } -
Open
migrations/018_jobs.sqlin VS Code. Find the two columnstenant_idandactor_id. Both areNOT NULL, and both are foreign keys. The last lines turn on row-level security forjobs, as for the tickets. -
Apply the two new migrations and load both shops again:
Terminal window python -m scripts.load --resetapplied 018_jobsapplied 019_audit_and_support_accessDatabase is up to date (19 migrations).Loaded the small data in 0.6 s:larkfield bramblecustomers 40 12tickets 200 40messages 331 58attachments 43 14documents 36 3ai_runs 294 69ticket_embeddings 200 4057 files uploaded to the object storage.3 messages have no ticket: they are in orphaned_messages. -
Run the tests:
Terminal window python -m pytestThe last line is
234 passed, 1 deselected: 16 more than at the end of Module 4. The new tests are intests/test_lifecycle.py, and this module explains all of them. -
Start the API again with
fastapi dev.
Sam’s export, step by step
Section titled “Sam’s export, step by step”In the third terminal, get a token for Sam and keep the address of Larkfield’s routes in a variable:
TOKEN=$(python -m scripts.login --user usr-sam --print-access-token)A=http://127.0.0.1:8000/v1/tenants/larkfield$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:
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:
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:
python -m scripts.worker --oncefe056555-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_atT-30002,C-0022,Charged twice for order LK-182074,billing,1,open,2026-09-25 08:20:19+00:00T-30005,C-0015,Verification code not arriving,login,2,open,2026-09-25 09:32:08+00:00The 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.
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}' $A/exportsGRACE=$(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-sampython -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:
curl -s -X PATCH -H "Authorization: Bearer $GRACE" -H "Content-Type: application/json" -d '{"role": "staff"}' $A/members/usr-samA forged job row
Section titled “A forged job row”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:
python -m scripts.worker --once9909ec95-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.
A message that names another organization
Section titled “A message that names another organization”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:
python -m pytest tests/test_lifecycle.py -k "job or export or message" -vtests/test_lifecycle.py::test_an_export_runs_as_the_person_who_asked PASSEDtests/test_lifecycle.py::test_a_job_is_refused_when_its_actor_was_removed_before_it_ran PASSEDtests/test_lifecycle.py::test_a_job_row_that_names_an_organization_its_actor_cannot_access_is_refused PASSEDtests/test_lifecycle.py::test_a_queue_message_with_another_organization_changes_nothing PASSEDtests/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.
Every job leaves a record
Section titled “Every job leaves a record”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.
When to use it, and what it costs
Section titled “When to use it, and what it costs”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
reasoncolumn 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 mistakes
Section titled “Common mistakes”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.
Knowledge check
Section titled “Knowledge check”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.
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
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.
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
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.
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
- 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