Skip to content

Privileged operations

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

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

What you need to know

Your project

  • ~/projects/ticket-api at the snapshot end-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.

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:

Output (the author's run)
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:

  1. 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”.
  2. Make Kwame an owner of every shop. Now he can read and change everything, everywhere, forever. Nobody remembers to remove the memberships.
  3. A master key, or is_admin: true in 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.
Two columns compared on five rows. A support master key, or is_admin in the token, in red: which organizations, all of them; which actions, all of them; how long, until someone remembers to remove it; why, not recorded; who can see it, nobody. A support grant in this project, in green: which organizations, one, bramble; which actions, ticket.read only; how long, at most 60 minutes, and revoke ends it at once; why, a reason of at least 10 characters, stored; who can see it, Ines: the grant and every use in the audit.
A master key and a support grant both let Kwame read T-40001. Only the grant limits where, what and how long, and records why.
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 CHECK in the table both refuse a shorter one. The owner reads it.
  • At most 60 minutes. The model allows 1 to 60, and a CHECK refuses anything longer, even from psql. POST …/revoke ends a grant early.
  • ticket.read only. In ticket_api/access.py, the role support has 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.

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.

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.

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.

Terminal window
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/v1

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

Seven requests by Kwame, with the answer and the audit event that Ines can read. 1. GET bramble/tickets/T-40001: 404, no event, Kwame is not a member. 2. POST admin/tenants/bramble/support-access with a reason and 15 minutes: 201, support_access.granted. 3. GET bramble/tickets/T-40001: 200, support_access.used allowed. 4. POST bramble/tickets/T-40001/messages: 403, support_access.used denied. 5. GET larkfield/tickets/T-30002: 404, no event, the grant is for bramble. 6. POST admin/support-access/grant/revoke: 200, support_access.revoked. 7. GET bramble/tickets/T-40001: 404, no event, the grant has ended.
One support grant from start to end, as the project answered it. Every use of the grant leaves an event in Bramble Books' audit.
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:

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

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

Terminal window
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/messages
curl -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.

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:

Terminal window
curl -s -X POST -H "Authorization: Bearer $KWAME" $V/admin/support-access/<grant_id>/revoke
curl -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.

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 deleted
CONTEXT: PL/pgSQL function audit_events_append_only() line 3 at RAISE
ERROR: audit events cannot be changed or deleted
CONTEXT: PL/pgSQL function audit_events_append_only() line 3 at RAISE

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

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

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.

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.

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.

Recap

  • A platform administrator runs the service. The role is users.platform_role in the database, not a token claim, and it opens no shop by itself.
  • A support grant gives one organization, ticket.read only, 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_events is append-only: a trigger refuses UPDATE and DELETE, even for a superuser. TRUNCATE still works, and load --reset uses it.

Next, people leave, roles change and a whole shop closes. You measure how long old access lasts after each change.

Next: Lifecycle changes