Lifecycle changes
- Lesson 16
- 55 min
- Module 5: Background work and administration
Lesson 16 of 19 · Authentication, Permissions and Multi-Tenant ApplicationsLifecycle changes
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
Find every place where a permission lives on after it should end, and prevent or bound each one. Remove a member, change a role, let an invitation expire and delete an organization, and measure how long old access lasts.
Before you startWhat to know and to have ready
What you need to know
- Token expiry and the 30-second leeway, from Token meaning, and session refresh, from Session lifecycle.
- The cache keys with the organization, from File and retrieval access.
- Jobs and support grants, from the last two lessons.
Your project
~/projects/ticket-apiat the snapshotend-of-m05, with the provider and the API running.
This lesson adds no files. It uses ticket_api/members.py, ticket_api/lifecycle.py and scripts/lifecycle.py, which you copied in Job identity context.
A key card and a paper day pass
Section titled “A key card and a paper day pass”Think of a hotel. When you check out, the front desk cancels your key card in its computer. Each door asks the computer when you touch it, so your card stops working at once.
The same hotel also prints paper day passes for the pool: “valid today until 18:00”. The pool guard only reads the paper. If the hotel bans you at 10:00, your pass still opens the pool until 18:00. Nobody can cancel a piece of paper.
So there are two kinds of permission. One asks the source each time, like the card. The other is a copy that lives until it expires, like the pass. When access must end, a card ends at once. A pass ends at the time printed on it.
The problem: access lives in more than one place
Section titled “The problem: access lives in more than one place”In the ticket API, the source of a permission is one row: the membership. But copies of it live in other places:
- the access token, which says who the person is, for 10 minutes;
- the browser session, which gets new tokens from the provider;
- cached answers, such as the similar tickets of Module 4, for 60 seconds;
- queued jobs, which the API allowed when the person asked;
- invitations, which give a membership to whoever has the code;
- a role cache, if a team adds one to save a query.
When Grace removes Camille, which of these still let Camille in, and for how long? The time between the change and the moment access really ends is the stale access window. Here are two constructed settings that make it long.
First, a team caches roles for 30 seconds to save one database query per request (MEMBERSHIP_CACHE_SECONDS=30). The course measured it: after Grace removed Camille, Camille kept reading Larkfield’s tickets for 30.3 s.
Second, the provider disables a person, but the API checks only their token. The API accepted a 5-second token for 34.4 s. That is 5 seconds of life, plus the API’s 30-second leeway for clocks that differ. With the normal life of 600 seconds, the same person keeps a working token for up to 630 seconds: more than ten minutes.
The idea: prevent the window, or bound it
Section titled “The idea: prevent the window, or bound it”For each place where a permission lives, choose one of two answers:
- Prevent: read the source every time, like the key card. The window is the time to the next request.
- Bound: give the copy a short life, like the day pass, and accept that window.
The name for ending a permission early is revocation. Every system revokes somewhere, and the question is always “how long until it takes effect?”. This project answers it for each place:
| Where the permission lives | Prevent or bound | When it ends after a change |
|---|---|---|
| membership (the source) | prevent: read on every request (MEMBERSHIP_CACHE_SECONDS=0) |
the next request |
| cached answers | prevent: the check runs before the cache, and changes clear the organization’s entries | the next request |
| queued jobs | prevent: the worker checks again | when the job runs |
| support grants | prevent: never cached; checked against the clock | the next request |
| organization status | prevent: every check needs active |
the next request |
| access token | bound: 600 s life + 30 s leeway | at most 630 s |
| browser session | bound: the API asks the provider again every 10 minutes | the next refresh |
| invitation | bound: 72 hours by default, 168 at most; used once; the owner can take it back | when it expires |
Notice the most important row: the token. A token says who a person is, and the API decides what they may do from the membership on every request. So when Grace removes Camille, Camille’s token still works, but it opens nothing at Larkfield. To cut a person off from one organization at once, change the membership in the API, not the provider.
A tiny worked example
Section titled “A tiny worked example”Camille signs in at 10:00:00. The token expires at 10:10:00. At 10:04:00, Grace removes Camille from Larkfield, and the provider also disables Camille. What happens next?
| Time | Camille tries | Answer | Why |
|---|---|---|---|
| 10:04:01 | read Larkfield’s T-30002 | 404 |
the membership row is gone, and it is read now |
| 10:04:01 | the cached similar tickets of T-30002 | 404 |
the check runs before the cache |
| 10:04:01 | GET /v1/me |
200 |
the token is still valid until 10:10:00 + 30 s |
| 10:04:01 | read Bramble’s T-40001 | 200 |
the other membership did not change |
| 10:05:00 | their export, queued at 10:03 | refused | the worker checks again when it runs |
| 10:10:31 | GET /v1/me |
401 expired |
600 s + 30 s leeway are over, and the provider refuses a new token |
The token’s window is 6.5 minutes, from 10:04:00 to 10:10:30. But in that time it opens only what the memberships allow. Because the API reads the membership, the change at Larkfield is immediate.
Text description of the diagram
Five groups. Changes to a member by an owner take effect at the next request. A removal answers 404 after 5 ms, a role change answers 403 at the next write, and an expired invitation answers 409. A person disabled at the provider keeps a working token until it expires. That was 34.4 s for a constructed 5-second token, and it is up to 630 s by default. Their browser session ends at the next refresh. The worker refuses a queued job when it runs. An administrator’s support grant ends at its expiry or at revoke. When Kwame deletes Bramble Books, Ines gets 404 after 62 ms, and the worker purges the data in 118 ms. A constructed 30-second role cache kept Camille reading for 30.3 s.
Members, roles and invitations in the API
Section titled “Members, roles and invitations in the API”Before you measure, look at the routes that make these changes. They are in ticket_api/members.py, and only an owner may change anything:
| Route | Who | What |
|---|---|---|
GET /v1/tenants/{tenant}/members |
owner, staff | list the members |
PATCH /v1/tenants/{tenant}/members/{user_id} |
owner | change a role |
DELETE /v1/tenants/{tenant}/members/{user_id} |
owner | remove a member |
POST /v1/tenants/{tenant}/invitations |
owner | invite an email address, with a role |
DELETE /v1/tenants/{tenant}/invitations/{id} |
owner | take an invitation back |
POST /v1/invitations/accept |
the invited person, signed in | join with the code |
Each change writes an audit event in the same transaction, and clears the organization’s cached answers. One rule protects the organization itself: it needs at least one owner. Grace tries to remove herself:
GRACE=$(python -m scripts.login --user usr-grace --print-access-token)A=http://127.0.0.1:8000/v1/tenants/larkfieldcurl -s -X DELETE -H "Authorization: Bearer $GRACE" $A/members/usr-grace$GRACE = python -m scripts.login --user usr-grace --print-access-token$A = "http://127.0.0.1:8000/v1/tenants/larkfield"curl.exe -s -X DELETE -H "Authorization: Bearer $GRACE" $A/members/usr-grace{"error":{"code":"last_owner","message":"The organization needs at least one owner.","request_id":"3e2866389ee1","fields":[]}}Measure it: one change at a time
Section titled “Measure it: one change at a time”scripts/lifecycle.py makes each change with the API code of your project, and measures what happens. Each command puts back what it changed, except the deletion at the end. The script also prints the API’s log lines (INFO ticket_api: …) on the error output. The lines below are the script’s own output; on macOS and Linux, add 2>/dev/null to hide the log lines, and on Windows, 2>$null.
A removal. Camille has a valid token, a queued export and a cached answer. Grace removes Camille from Larkfield:
python -m scripts.lifecycle removeCamille (staff at Larkfield) has a valid access token, a queued export, and a cachedanswer (similar tickets of T-30002). Grace removes them from Larkfield. next request with the same token: 404 (Not found.) the cached similar tickets: 404 (the check runs before the cache) the token itself is still valid: /v1/me answers, memberships ['bramble'] their queued export, run now: refused (usr-camille: no membership in this organization (checked when the job ran)) their other membership (read-only at Bramble Books): 200Every line is a “prevent” row of the table. The course measured the next request at 404 after 5 ms: the time of one request.
A role change. Sam writes as staff, Grace makes him read-only, and Sam writes again:
python -m scripts.lifecycle roleSam writes as staff: 201. Grace makes him read-only. Sam writes again: 403 (Your role may not do this (read_only may not ticket.write).)An invitation that expired. The script invites Tomás, then moves the invitation four days back in the database. That is a constructed case: nobody waits four days.
python -m scripts.lifecycle invitationGrace invites tomas@larkfield.example (read-only), valid until 2026-10-13T06:36:21.139245Z. (constructed: the clock moves four days on) Tomás accepts: 409 invitation_expired a new invitation: 200 {'tenant_id': 'larkfield', 'tenant_name': 'Larkfield', 'role': 'read_only'}; he reads T-30002: 200An invitation is a copy too: whoever has the code may join. So it expires, it works once, and the table keeps only the SHA-256 of the code. The invitation also names an email address. A test checks that Sam cannot use Tomás’s code.
A role cache. The same removal, with roles cached for 30 seconds. This takes about half a minute:
python -m scripts.lifecycle membership-cacheThe same removal, with MEMBERSHIP_CACHE_SECONDS=30 (constructed setting): Camille kept reading Larkfield's tickets for 30.4 s after their removal.A token after the provider disables the person. The script makes a 5-second token for Sam and disables him at the provider at once, as python -m idp disable usr-sam does. Then it asks /v1/me until the answer changes. This also takes about half a minute:
python -m scripts.lifecycle tokenAn access token that expires 5 s after it was made; the person is disabled atthe provider at once. The API checks only the token (leeway 30 s): accepted for 34.8 s (lifetime 5 s + leeway 30 s). With the default lifetime of 600 s, that is up to 630 s.Your two times differ a little from these, and from the course’s measurements of 30.3 s and 34.4 s. The shape is the same: the window is the life of the copy. The browser session does better than a token alone. Each refresh asks the provider, and a disabled person’s refresh fails. A test checks that the session then ends with provider_refused.
Now put the measured windows side by side:
- Measured
- Computed bound
Text description of the chart
A column chart in seconds. A member removed: 0.005 s. The same removal with a 30-second role cache: 30.3 s. A 5-second token after the provider disabled the person: 34.4 s. The course measured these three. A 600-second token: 630 s, computed. The first column is too small to see: access ends at the next request.
The “prevent” column is too small to see, and so is the deletion (0.062 s). The “bound” columns are as long as the copy’s life. That is the whole lesson in one picture. A short window costs a read of the source. A long window comes from every copy that you do not check.
Deleting an organization
Section titled “Deleting an organization”Bramble Books closes its account. Deleting a shop is the largest change of all: two memberships, 40 tickets, 58 messages, 14 files and more. It is also a privileged operation, so only Kwame, the platform administrator, may do it.
A deletion that takes seconds must not leave a window in which the shop is half there. So the project does it in two phases, like the export:
- At once, in the API: the status becomes
deleting. Every check needsactive, so from the next request nobody gets in. The API queues atenant_deletejob, with Kwame as its actor and his reason in the audit. - Later, in the worker:
ticket_api/lifecycle.pydeletes the rows in one transaction, and puts the files in the deletion outbox of the databases course. The AI runs keep their cost with no ticket and no text, as for a customer’s erasure in Recovery and retention. The audit events stay, so the record of who did what survives the shop. The organization’s row stays with the statusdeleted, so nobody can create a new shop with the IDbrambleand inherit something by mistake.
A forged deletion job does nothing. The worker purges only an organization whose status is already deleting, and only the administrator’s route sets that status.
Run it. The script deletes Bramble Books, asks for T-40001 as Ines, then runs the worker:
python -m scripts.lifecycle delete-tenantKwame deletes Bramble Books. Ines, 49.8 ms later: 404. the worker purged it in 98.4 ms: done {'tickets': 40, 'messages': 58, 'customers': 12, 'documents': 3, 'attachments': 14, 'memberships': 2, 'files_deleted': 14, 'ticket_embeddings': 40} Put the data back: python -m scripts.load --resetThe course measured 404 after 62 ms and a purge of 118 ms; your numbers differ. Look at what stays, in psql:
SELECT tenant_id, status FROM tenants;SELECT count(*) AS ai_runs, count(output) AS with_text FROM ai_runs WHERE tenant_id = 'bramble'; tenant_id | status-----------+--------- larkfield | active bramble | deleted(2 rows)
ai_runs | with_text---------+----------- 69 | 0(1 row)Ines’s token still works, but /v1/me now lists "memberships":[]. Put the shop back before you continue:
python -m scripts.load --resetWhen to use what, and what it costs
Section titled “When to use what, and what it costs”Prevent where you can, and bound the rest:
- Read memberships on every request. It costs one query, the
X-DB-Queriescount of Module 4. A role cache saves that query, but each second of cache is a second of stale access. If you add one, make it short, clear it on every change, and never cache support access. - Keep tokens short. Ten minutes is common. A shorter token narrows the window, but the browser session must refresh more often, and each refresh is a call to the provider.
- Know where you cannot prevent. In this project, nobody can take back a token from the provider. Some providers offer token introspection: the API asks the provider about each token. That narrows the window to zero and adds a network call to every request. Or the API keeps a list of disabled people. This course does neither; it relies on the membership.
- Check jobs when they run, and refuse the ones whose actor lost access.
- Make deletion two-phase: close at once, purge in the background, and keep the audit.
The cost of getting this wrong is quiet. Nothing fails; a person who left can still read for a while. Only a test or a measurement like this one finds it.
Your turn: explain the stale access for one change
Section titled “Your turn: explain the stale access for one change”Omar leaves Larkfield on Friday at 17:00. Grace removes his membership, and the IT team disables his account at the provider. For each place where Omar’s access lives, write one line: prevented or bounded, and how long it can last. Use the table and the measurements of this lesson. Then compare with the first knowledge check.
Common mistakes
Section titled “Common mistakes”Common mistake
Many INFO lines before the script's output
Symptom: python -m scripts.lifecycle remove prints lines such as INFO ticket_api: GET /v1/tenants/larkfield/tickets/T-30002/similar 200 … before its own lines.
Cause: the script runs the API code in the same process, and the API logs every request on the error output.
Fix: nothing is wrong. Add 2>/dev/null (macOS and Linux) or 2>$null (Windows) to hide them.
Common mistake
Every Bramble Books request answers 404
Symptom: after delete-tenant, Ines, Camille and Kwame get 404 at Bramble Books, and the tests fail.
Cause: the command deleted the shop. That is its purpose.
Fix: run python -m scripts.load --reset.
Common mistake
409 last_owner
Symptom: {"error":{"code":"last_owner","message":"The organization needs at least one owner.", …}}
Cause: the change would leave the organization without an owner.
Fix: make another member owner first, then change or remove the old owner.
Reset. python -m scripts.load --reset loads both shops and the people again.
Knowledge check
Section titled “Knowledge check”Check your understanding
Omar leaves Larkfield on Friday at 17:00. Grace removes his membership, and the provider disables him. List each place where his access lives, and say whether it is prevented or bounded, and for how long.
Show the answer
- Larkfield’s tickets, files and search: prevented. The membership is read on every request, so his next request gets
404(5 ms after the removal in the course’s run). - Cached answers: prevented. The check runs before the cache, and the removal cleared Larkfield’s entries.
- A queued export: prevented. Read-only members cannot export, but the worker would refuse any queued job of his when it runs.
- His access token: bounded. It answers
/v1/meuntil it expires: up to 600 s + 30 s leeway = 630 s. In that time it opens nothing at Larkfield, because he has no membership. - His browser session: bounded. It ends at the next refresh, within 10 minutes, because the provider refuses a disabled person.
- An invitation: none is open. If one were, Grace should take it back.
A good answer names “the next request” for every check of the source, and a time limit for every copy.
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 wants MEMBERSHIP_CACHE_SECONDS=300 to save one query per request. What does it cost, and what would you suggest?
Show the answer
A removed member keeps their old role for up to 300 seconds after the change. The course measured 30.3 s with a 30-second cache, so expect about five minutes. A test in the project (test_a_membership_cache_keeps_a_removed_member_in_until_it_expires) shows the effect with 300 seconds. Suggest keeping 0: one indexed query is cheap. If the query really is the bottleneck, use a very short cache, clear it on every membership change, and never cache support access.
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 deleting an organization happen in two phases, and what remains after the purge?
Show the answer
Phase one, in the API, sets the status to deleting at once. Every check needs active, so nobody gets in from the next request (Ines got 404 after 62 ms). Phase two, in the worker, deletes the rows and the files, which takes longer (118 ms for Bramble’s small data). So there is no window in which the shop is half deleted but still open. After the purge, the 69 AI runs stay with their cost and no text, and the audit events stay. The organization’s row stays with the status deleted, so no new shop can get its ID.
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
- The stale access window is the time between a change and the moment access really ends. Each copy of a permission has one.
- Prevent it by reading the source on every use: memberships, grants, the organization’s status and the check before the cache. The worker checks jobs when they run. Removal:
404after 5 ms. - Bound it where you cannot prevent: tokens (600 s + 30 s leeway), sessions (next refresh), invitations (72 hours, used once). A constructed 30-second role cache kept Camille in for 30.3 s, and a 5-second token lasted 34.4 s.
- Deleting an organization closes it at once (
404after 62 ms) and purges it in the background (118 ms). The audit and the costs stay.
Module 6 turns these rules into tests: every person, against every route, in both organizations.
Next: Negative testing