Privileged operations
- Lesson 15
- 50 min
- Module 5: Background work and administration
Lesson 15 of 19 · Authentication, Permissions and Multi-Tenant ApplicationsPrivileged operations
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
Let a support person help one organization without a key that opens every organization. Store the platform role in the database. Give time-limited, read-only access with a reason, and record every use where the organization’s owner can see it.
Before you startWhat to know and to have ready
What you need to know
- Background jobs and the copy of Module 5’s files, from Job identity context.
- Why an outsider gets
404and a member with too small a role gets403, from Authorization in the API.
Your project
~/projects/ticket-apiat the snapshotend-of-m05, with the provider and the API running, as in the last lesson.
This lesson adds no files. It uses ticket_api/admin.py, ticket_api/audit.py and migration 019_audit_and_support_access.sql, which you copied in the last lesson.
A visitor badge, not a master key
Section titled “A visitor badge, not a master key”Think of an office building with many companies. A lift engineer must check a lift on the fourth floor. The building could give the engineer a master key. That key opens every office, at any time, for as long as the engineer keeps it. Nobody knows which doors they opened.
Most buildings do something else. At the front desk, the engineer writes their name, the company they visit and the reason. They get a visitor badge for that floor, until 17:00 today. The visit is in the visitor book, and the company can read it. Tomorrow the badge opens nothing.
The badge gives less than the key, but it is enough for the work. And everybody can check afterwards what happened.
The problem: Kwame cannot help, and the quick fixes are worse
Section titled “The problem: Kwame cannot help, and the quick fixes are worse”Ines calls the help desk of the service. The shop charged a customer twice, ticket T-40001, and the ticket page looks wrong to her. Kwame runs the service for every shop. He is a platform administrator: a person who operates the whole platform, not a member of any shop. Module 1 gave him no membership. So when he opens the ticket, he gets the outsider’s answer. This is the author’s run of GET /v1/tenants/bramble/tickets/T-40001 with Kwame’s token. You send the same request below, and get the same 404 after his support access ends:
HTTP/1.1 404 Not Found{"error":{"code":"not_found","message":"Not found.","request_id":"bd6e3f55175c","fields":[]}}That is correct, but it does not help Ines. Amira lists three quick fixes, and each one fails:
- Ines shares her sign-in. Then Kwame acts as the owner of Bramble Books. He can change members and tickets, and every record says “Ines did it”.
- Make Kwame an owner of every shop. Now he can read and change everything, everywhere, forever. Nobody remembers to remove the memberships.
- A master key, or
is_admin: truein his token. One value opens every organization. Module 1’s threat examples showed what one shared key did: 5 of 5 made-up attempts got what they should not get. A claim in a token also lives until the token expires, so nobody can take it back quickly.
Text description of the diagram
A master key (or an is_admin claim in a token) opens all organizations, for all actions, until someone removes it. It records no reason, and nobody sees its use. The master key is a constructed comparison: the project has none. A support grant in this project opens one organization, Bramble Books, for ticket.read only, for at most 60 minutes. Kwame can end it early. It stores a reason of at least 10 characters, and Ines can see the grant and every use of it.
The idea: a separate role, and a small grant with a reason
Section titled “The idea: a separate role, and a small grant with a reason”The badge has two parts, and the project copies both.
First, the platform role is separate from the shop roles, and the database holds it. users.platform_role is platform_admin for Kwame and empty for everybody else. It is not in the token. The provider signs who Kwame is; the ticket API decides what he may do, as for every member. So a change takes effect at the next request, and the token never carries a power that nobody can take back. The platform role alone opens no shop: it only lets Kwame ask for a grant.
Second, access to a shop is a support grant. A support grant is a row in support_grants that says: this administrator may read this one organization’s tickets, for this reason, until this time. Security teams call this idea least privilege (only what the work needs) and just-in-time access (only when the work happens). The rules are in the code and in the database:
- One organization. The grant names
bramble. At Larkfield, Kwame is still an outsider. - A reason of at least 10 characters. The model and a
CHECKin the table both refuse a shorter one. The owner reads it. - At most 60 minutes. The model allows 1 to 60, and a
CHECKrefuses anything longer, even from psql.POST …/revokeends a grant early. ticket.readonly. Inticket_api/access.py, the rolesupporthas one action. No files, no writes, no members.- Every use leaves a record. Each request that uses the grant writes an audit event
support_access.used, allowed or denied. The API never caches a grant, so it checks every use against the clock. - The owner sees it. Ines can list the grants for Bramble Books and read the audit.
A tiny worked example
Section titled “A tiny worked example”Kwame’s grant has the reason “Ines asked for help with T-40001 (call on 9 October)”. The project made it at 06:45:37 for 15 minutes, so it expires at 07:00:37. Here is what role_of() and decide() in the project return for five requests:
| Time | Request | Role found | Answer |
|---|---|---|---|
| 06:45 | read bramble T-40001, before the grant | none | 404 |
| 06:50 | read bramble T-40001 | support |
200, event allowed |
| 06:51 | add a message to T-40001 | support |
403, event denied |
| 06:52 | read larkfield T-30002 | none (the grant is for bramble) | 404 |
| 07:01 | read bramble T-40001 | none (expired) | 404 |
The membership comes first. Only a person with no membership gets the support role, and only while a grant for that organization is active.
Audit events: the visitor book
Section titled “Audit events: the visitor book”The visitor book needs a table too. An audit event is one row that says who did, or tried, what, in which organization, and the result. Migration 019 makes audit_events with the columns at, actor_id, tenant_id, action, target, result (allowed, denied, done or failed), reason, request_id and details.
An audit event never holds a token, a cookie, a password or a message’s text. ticket_api/audit.py drops any details key that names a secret, and it replaces anything that looks like a JWT. The API writes a change and its event in the same transaction. If the change fails, the event does not appear, and the other way round.
A visitor book that a visitor can tear pages from is worth nothing. So the table is append-only: a trigger refuses every UPDATE and DELETE, for every database role. You test that below.
Kwame helps Ines, step by step
Section titled “Kwame helps Ines, step by step”Get a token for each of the three people. On Windows, use $KWAME = python -m scripts.login --user usr-kwame --print-access-token, the same for the others, $V = "http://127.0.0.1:8000/v1", and curl.exe.
KWAME=$(python -m scripts.login --user usr-kwame --print-access-token)INES=$(python -m scripts.login --user usr-ines --print-access-token)GRACE=$(python -m scripts.login --user usr-grace --print-access-token)V=http://127.0.0.1:8000/v1First, look inside Kwame’s token with python -m scripts.token decode --user usr-kwame. Some of the claims:
sub "usr-kwame" aud "ticket-api" client_id "ticket-cli" scope "tickets:read tickets:write members:manage"There is no role and no is_admin. The API reads his platform role from the database. curl -s -H "Authorization: Bearer $KWAME" $V/me shows it:
{"user_id":"usr-kwame","kind":"user","name":"Kwame","platform_role":"platform_admin","client_id":"ticket-cli","scopes":["members:manage","tickets:read","tickets:write"],"token_expires_at":"2026-10-10T06:55:37Z","memberships":[]}memberships is empty: no shop. Now follow the diagram below, one request at a time.
Text description of the diagram
Before the grant, Kwame’s read gets 404. He asks for support access with a reason and 15 minutes: 201, and a support_access.granted event. With the grant, he reads T-40001: 200, and an event with the result allowed. He tries to add a message: 403, and an event with the result denied. At Larkfield he still gets 404, because the grant names Bramble Books only. He revokes the grant: 200, and a support_access.revoked event. After that, his read gets 404 again.
Not anybody can ask. Grace is an owner, but not a platform administrator:
curl -s -X POST -H "Authorization: Bearer $GRACE" -H "Content-Type: application/json" -d '{"reason": "I want to see Bramble Books tickets"}' $V/admin/tenants/bramble/support-access{"error":{"code":"forbidden","message":"Only a platform administrator may do this.","request_id":"49f06fe9b262","fields":[]}}The API records this refusal too: an event admin POST /v1/admin/tenants/bramble/support-access for usr-grace, with the result denied and no organization.
A reason, and a short time. Kwame tries the reason “help”, then 90 minutes. Both get 422, and fields names the problem:
{"error":{"code":"invalid_request","message":"The request is not valid.","request_id":"c638fb5b5611","fields":["reason"]}}{"error":{"code":"invalid_request","message":"The request is not valid.","request_id":"414e71cad421","fields":["minutes"]}}The grant. A real reason and 15 minutes:
curl -s -X POST -H "Authorization: Bearer $KWAME" -H "Content-Type: application/json" -d '{"reason": "Ines asked for help with T-40001 (call on 9 October)", "minutes": 15}' $V/admin/tenants/bramble/support-access{"grant_id":"cab29c4e-36b0-44d0-9f73-1cd3170d5271","tenant_id":"bramble","user_id":"usr-kwame","reason":"Ines asked for help with T-40001 (call on 9 October)","created_at":"2026-10-10T06:45:37.820639Z","expires_at":"2026-10-10T07:00:37.820639Z","revoked_at":null}Read, yes. Write, no. Another shop, no. Run these three:
curl -s $V/tenants/bramble/tickets/T-40001 -H "Authorization: Bearer $KWAME"curl -s -X POST -H "Authorization: Bearer $KWAME" -H "Content-Type: application/json" -d '{"author": "agent", "body": "Kwame from support here."}' $V/tenants/bramble/tickets/T-40001/messagescurl -s $V/tenants/larkfield/tickets/T-30002 -H "Authorization: Bearer $KWAME"The first answer is the whole ticket: T-40001, Eleanor Price, “Charged twice for order BB-310729”. The other two:
{"error":{"code":"forbidden","message":"Your role may not do this (support may not ticket.write).","request_id":"5490405fc39f","fields":[]}}{"error":{"code":"not_found","message":"Not found.","request_id":"895df84d2427","fields":[]}}The write gets 403, not 404. With the grant, Kwame may know that Bramble Books’ ticket exists; he may only not change it.
What Ines sees
Section titled “What Ines sees”Now switch to Ines. She lists the support access to her shop with curl -s -H "Authorization: Bearer $INES" $V/tenants/bramble/support-access:
{"items":[{"grant_id":"cab29c4e-36b0-44d0-9f73-1cd3170d5271","tenant_id":"bramble","user_id":"usr-kwame","reason":"Ines asked for help with T-40001 (call on 9 October)","created_at":"2026-10-10T06:45:37.820639Z","expires_at":"2026-10-10T07:00:37.820639Z","revoked_at":null}]}And the newest event in her audit, with curl -s -H "Authorization: Bearer $INES" "$V/tenants/bramble/audit?limit=1":
{"items":[{"event_id":4,"at":"2026-10-10T06:45:37.871754Z","actor_id":"usr-kwame","actor_kind":"user","tenant_id":"bramble","action":"support_access.used","target":"POST /v1/tenants/bramble/tickets/T-40001/messages","result":"denied","reason":"Ines asked for help with T-40001 (call on 9 October)","request_id":"5490405fc39f","details":{"action":"ticket.write","grant_id":"cab29c4e-36b0-44d0-9f73-1cd3170d5271"}}]}It is Kwame’s refused write. Your event_id is higher if the audit already has the events of the last lesson. The event has the who (usr-kwame), the where (bramble), the what (POST …/messages, ticket.write), the result and the grant’s reason. Its request_id is the one in the 403 answer above and in the API’s log line. So a person can follow one request from the answer to the log and the audit. There is no token in it. Without limit=1, Ines also sees the allowed read and the support_access.granted event.
When he has finished, Kwame ends the grant early. Use the grant_id of your grant:
curl -s -X POST -H "Authorization: Bearer $KWAME" $V/admin/support-access/<grant_id>/revokecurl -s $V/tenants/bramble/tickets/T-40001 -H "Authorization: Bearer $KWAME"The first answer shows the grant with revoked_at set. The second is 404 again: the next request found no active grant.
Nobody can change the audit
Section titled “Nobody can change the audit”Open psql (docker compose exec db psql -U tickets -d tickets). Try to make a refusal look like a success, then try to delete Kwame’s events:
UPDATE audit_events SET result = 'allowed' WHERE result = 'denied';DELETE FROM audit_events WHERE actor_id = 'usr-kwame';ERROR: audit events cannot be changed or deletedCONTEXT: PL/pgSQL function audit_events_append_only() line 3 at RAISEERROR: audit events cannot be changed or deletedCONTEXT: PL/pgSQL function audit_events_append_only() line 3 at RAISEYour psql user, tickets, is a superuser, and the trigger still refuses. The API’s own database role, ticket_app, has even less:
SELECT p AS privilege, has_table_privilege('ticket_app', 'audit_events', p) AS ticket_app FROM unnest(ARRAY['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE']) AS p; privilege | ticket_app-----------+------------ SELECT | t INSERT | t UPDATE | f DELETE | f TRUNCATE | f(5 rows)The time limit is in the database too. A grant for eight hours, written by hand, fails:
INSERT INTO support_grants (grant_id, tenant_id, user_id, reason, expires_at) VALUES (gen_random_uuid(), 'bramble', 'usr-kwame', 'Just looking', now() + interval '8 hours');ERROR: new row for relation "support_grants" violates check constraint "support_grants_at_most_one_hour"An honest limit. A row trigger does not run for TRUNCATE, which empties a whole table at once. python -m scripts.load --reset uses TRUNCATE as the superuser, so it still empties the audit for your practice. The API’s role cannot do that. In production, keep the superuser away from daily work, and copy the events to a second store that the application cannot write to. Auditability continues there.
When to use it, and what it costs
Section titled “When to use it, and what it costs”Use a separate platform role and grants whenever your own staff need to see customers’ data: support, operations, incident response. Make the grant as small as the task, and let the customer see it.
It has costs:
- Friction. Kwame must stop and write a reason before he can look. Some teams add a second person who approves the grant. That is more work, and this course does not build it.
- Reasons need readers. A reason is only useful if the owner, or a reviewer, reads it. Unread logs protect nobody.
- Short grants end in the middle of work. Sixty minutes is the limit here. Kwame asks again, and that is a second record.
- Read-only is not always enough. If support must change data, make a separate, smaller action for it, with its own events. Do not widen
supportto every action. - The administrator role itself is powerful. Kwame can also delete an organization, which the next lesson runs. The number of platform administrators should be very small, and their actions audited the same way.
Common mistakes
Section titled “Common mistakes”Common mistake
422 with fields reason or minutes
Symptom: {"error":{"code":"invalid_request", … "fields":["reason"]}} or "fields":["minutes"].
Cause: the reason has fewer than 10 characters, or the time is outside 1 to 60 minutes.
Fix: write the real reason, such as the ticket and the call, and ask for 60 minutes at most.
Common mistake
403: Only a platform administrator may do this
Symptom: the support-access route answers 403 with this message.
Cause: the token is not Kwame’s. Only a user whose users.platform_role is platform_admin may ask.
Fix: use --user usr-kwame when you get the token.
Common mistake
Kwame gets 404 although he made a grant
Symptom: a read at Bramble Books gets {"error":{"code":"not_found","message":"Not found.", …}}.
Cause: the grant expired, or he revoked it. Or the path names another organization than the grant.
Fix: list the grants as Ines (…/tenants/bramble/support-access) and check expires_at and revoked_at. Ask for a new grant if needed.
Common mistake
token decode: No such file or directory: '.tokens/usr-kwame.json'
Symptom: python -m scripts.token decode --user usr-kwame stops with a FileNotFoundError.
Cause: the script decodes the token that scripts.login saved. Kwame has not signed in yet.
Fix: run python -m scripts.login --user usr-kwame first.
Reset. python -m scripts.load --reset empties the grants and the audit and loads both shops again.
Knowledge check
Section titled “Knowledge check”Check your understanding
Why is Kwame's platform role a column in the users table and not a claim in his access token? Give two reasons.
Show the answer
First, a change takes effect at the next request: the API reads the column every time. A claim stays in a token until it expires (10 minutes here), and nobody can take it back sooner. Second, the token says who the person is, and the API decides what they may do, as for memberships. The provider, which may belong to someone else, should not decide powers in the ticket API. Also, the platform role alone opens no shop: it only allows Kwame to ask for a grant.
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
Kwame has an active grant for Bramble Books. Predict the answer and the audit event for: (a) GET bramble/tickets/T-40001, (b) POST a message to T-40001, (c) GET larkfield/tickets/T-30002.
Show the answer
(a) 200, with an event support_access.used, result allowed. (b) 403 (support may not ticket.write), with an event support_access.used, result denied. (c) 404: the grant names Bramble Books only, so at Larkfield Kwame is an outsider. The API writes no support event, because Kwame used no grant.
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
Your teammate says: 'The audit table is append-only, so nobody can ever remove an event.' Is that exactly true in this project? What would you add in production?
Show the answer
Not exactly. The trigger refuses UPDATE and DELETE for every role, and the API’s role ticket_app may only SELECT and INSERT. But a row trigger does not run for TRUNCATE. A superuser (the practice user tickets) can truncate the table or drop the trigger, and scripts.load --reset truncates it on purpose. In production, keep superuser access out of daily work, and copy the events to a separate store that the application cannot change.
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 platform administrator runs the service. The role is
users.platform_rolein the database, not a token claim, and it opens no shop by itself. - A support grant gives one organization,
ticket.readonly, with a reason of at least 10 characters, for at most 60 minutes. Revoke ends it at the next request. - Every use writes an audit event
support_access.used, allowed or denied. The owner reads the grants and the audit. audit_eventsis append-only: a trigger refusesUPDATEandDELETE, even for a superuser.TRUNCATEstill works, andload --resetuses it.
Next, people leave, roles change and a whole shop closes. You measure how long old access lasts after each change.
Next: Lifecycle changes