Skip to content

Final assignment

  • After the case studies
  • about 3 hr

What you will be able to do

Use every module of the course on a service that you did not build in it. You add sign-in and two-organization access control to Larkfield’s document assistant, so that Larkfield and Bramble Books can share it safely. Then you prove with tests and recorded runs that the service knows who calls, decides on the server what they may do in which organization, refuses every crossing, and ends access when it must. Last, you map the design to a real identity provider.

Before you startWhat to know and to have readyAbout 180 minutes
  • You completed Modules 1 to 6. Each part below names the lessons that it uses.
  • You do not need the case studies for this assignment. The certificate asks for at least one of them.
  • The service: your docs-assistant from the final assignment of the databases course. If you do not have it, build its small core first: documents with versions, an access level and a file; questions with answers and citations; and one search route. You do not need that assignment’s index tuning or restore drill here.
  • The course’s project, as a source of code: your ticket-api at the end of Module 6, or the snapshot end-of-m06 from the labs repository (Snapshots of ticket-api). You may copy code from it: the practice provider idp/, ticket_api/auth.py, access.py, tenancy.py, the scripts and the test helpers. Say in your report what you copied and what you changed. Do not copy its routes, data or tests and rename them: the reviewer looks for the decisions that this service needs.
  • Docker with PostgreSQL 18 and pgvector, and Azurite, as in the course. No account and no money are needed. A real identity provider is optional (Part 4).
  • Every attempt in this assignment targets your own practice service on your own computer, with made-up people and records.
  • You can do the assignment in more than one session. Run the tests after each change.

Bramble Books now uses Larkfield’s help desk, and it wants the document assistant too. Ines will upload Bramble’s own policies, and her staff will ask questions about them. Kwame asks his three questions about the new service: “Who is the caller? What may they do? In which organization?” Today docs-assistant answers none of them. Anybody who can reach it reads every document, including Larkfield’s staff-only refund limits.

The service keeps its own database, docs, and its own folder, ~/projects/docs-assistant. After this assignment:

  • Two organizations share it: larkfield and bramble. Each has its own documents, files, chunks, questions and answers.
  • The same made-up people sign in: Grace, Sam, Omar, Camille, Ines, Tomás and Kwame, with the memberships of data/identity/memberships.csv in ticket-api. Camille is staff at Larkfield and read-only at Bramble Books. Tomás has no membership. Kwame is a platform administrator and a member of no shop.
  • Three roles per membership, at least with these rules. A read_only member asks questions, reads public documents, and reads only their own questions. A staff member also reads staff-only documents, uploads documents and reads every question of the organization. An owner also withdraws documents, manages members and reads the audit. Write the full matrix yourself, and add a rule if your service needs one.
  • Its own audience. A token for docs-assistant is not a token for ticket-api, and the other way round.

Your data. Larkfield’s documents are the 36 Markdown files in data/source/documents/ of ticket-api; 6 of them are staff-only. Bramble’s are the 3 documents of data/bramble/documents.csv; supplier-terms is staff-only. Make your questions and answers with a seeded script, for both shops. All the people and records are made up: say so in your report.

Uses OAuth and OIDC, Application flows, Read a token, Token meaning and Provider configuration.

  1. Copy idp/ into your project. Change your copy so that its access tokens name docs-assistant as the audience, and register your own clients: a command-line client with PKCE, and a web client if you build a browser sign-in. Run it on port 8401, so that it does not clash with the ticket API’s provider. Write down that a real company runs one provider for both services, and say in Part 4 how a real provider tells the two audiences apart.
  2. Check every access token on the server: the signature with the published keys (JWKS), cached, with an allow-list of algorithms; the issuer; the audience docs-assistant; exp and nbf with a small leeway; and the token type. Name the check that refuses each bad token, as the course’s X-Token-Check header does.
  3. Add GET /v1/me. It answers the caller’s user ID, the token’s scopes and the memberships, which the service reads from its own table, never from the token.
  4. Choose your scopes, for example docs:read docs:write members:manage, and say which route needs which. A reporting script that asks only for docs:read cannot upload.
  5. The token carries no organization and no role. If you are tempted to add them, write one paragraph on what that would cost when a member is removed.

Part 2: server-enforced role and resource rules

Section titled “Part 2: server-enforced role and resource rules”

Uses Users and organizations, Authorization in the API, Tenant-safe queries, File and retrieval access, Job identity context and Privileged operations.

  1. Add tenant_id to every table that holds an organization’s records, with composite foreign keys, so that a citation cannot point to another organization’s chunk. A document’s key includes the organization: both shops may have a returns-policy.
  2. Move every route of an organization’s records under /v1/tenants/{tenant}/. Run the checks in the course’s order: identity (401), membership (404), role, then scope (403), then the lookup inside the organization (404). An outsider gets the same 404 for a real and a made-up organization.
  3. Write the access matrix twice: on paper, in docs/access-matrix.md, and in code, as one decide() function. Write a test that compares the two.
  4. Make the organization a required keyword argument of every repository function. Add row-level security as a second lock, with a role that is not a superuser, or write down why you did not.
  5. Carry the organization and the access level everywhere data leaves the tables: the document search and its vector query (a filter in the same statement), the citations of an answer, signed file links (check first, then sign), and every cache key.
  6. Add one background job, for example the chunking of an uploaded document or an export of an organization’s questions. Save the actor and the organization from the checked request, and check both again when the job runs.
  7. Record an audit event for every decision that changes data and for every refusal: the actor, the organization (only for a member’s refusal), the action, the result and the reason. Never a token or a secret.

Uses Threat examples, Negative testing and Auditability.

  1. Write a test matrix: every person, in both organizations, on every protected route, with the status that decide() expects. Add the bad identities on every route: no token, expired, wrong audience, an ID token and too small a scope.
  2. Add the object-level cases: a member uses their own path with a real ID of the other shop, for a document, a file, a question and a job. Each answer must be the “does not exist” of a missing ID.
  3. Add hand-written tests for the rules that depend on the data, not only on the role. For example, Omar reads his own questions but not Sam’s, and a read-only search never cites a staff-only document.
  4. Write an attempts script, like scripts.attempts, with at least 8 made-up attempts. Include a forged organization header, a ticket API token, and Camille using their staff role at Bramble Books. It must end with 0 of N attempts got what they should not get.
  5. Generate the test report from pytest’s JUnit results, as scripts.matrix_report does. A skipped check is never counted as passed.
  6. Break your own service on purpose, three times, and show what catches each bug: a route that asks for the wrong action, a wrong rule in decide(), and a query without its organization. Remove each bug after its run, and label all three as constructed.

Part 4: token and session lifecycle design, mapped to a real provider

Section titled “Part 4: token and session lifecycle design, mapped to a real provider”

Uses Browser session choices, Session lifecycle, Provider configuration, Lifecycle changes and Handoff.

  1. Write the lifetimes: the access token, the leeway, the refresh token and its rotation, and the browser session’s idle and absolute limits. Give a reason for each number. If your service has no browser sign-in, design the session anyway, and say that it is not built.
  2. For each place where a permission lives (the membership, the token, the session, cached answers, queued jobs, and invitations or support access if you have them), say whether you prevent or bound the stale access, and for how long. Measure at least one: remove a member, and record when their next request gets 404, and what their token still answers.
  3. Write the key rotation steps for your provider: publish the new key first, then sign with it, then retire the old one. Say what happens to a token signed with a key that the service has not fetched yet.
  4. Map the design to one real provider in docs/identity-config.md. Use Keycloak in Docker, as in Provider configuration, or Microsoft Entra ID from its documentation. For each setting, give the provider’s name for it: the issuer, the audience, the scope names, the token type, the claim that is the person’s stable ID, and logout. Write the evidence level next to each provider: tested, tested once or described, not tested.
  5. Write docs/handoff.md: the open choices, each with the person who must decide it. Examples: who owns the memberships when two services need them, how long the audit is kept, and whether a removed member’s questions stay.

A working happy path is not enough. Show each of these with a test or a recorded run, and say which. Every attempt goes to your own practice service, with made-up records.

  1. No token, an expired token, a token for ticket-api, an ID token, alg: none and a token signed with another key each get 401, with the name of the refusing check. None of them appears in your audit with the token in it.
  2. Tomás asks for a real organization and for a made-up one. The two 404 bodies are identical, apart from the request ID (cmp or a test).
  3. Sam asks for a real Bramble document ID through Larkfield’s path. The answer equals the answer for an ID that does not exist.
  4. Omar uploads a document (403), and searches for Larkfield’s refund approval limits: no staff-only document in the results or the citations. Camille gets Bramble’s public documents only.
  5. A forged X-Tenant-ID header, or an organization in the request body, changes nothing.
  6. A signed file link is made only after the checks, and the same link fails after it expires.
  7. A member’s role changes, or the member is removed, before their job runs: the worker refuses the job and records why. A forged job row for another organization (constructed) is refused too.
  8. A query that forgets its organization (constructed) returns only the caller’s rows, because of row-level security; or, without it, a test fails. Say which.
  9. Grace removes Camille from Larkfield. Camille’s next request there gets 404, their Bramble access still works, and their token still answers /v1/me.
  10. With PostgreSQL stopped, your test report says that the checks did not run, and it exits with an error.

Before you submit, prove that the project works from nothing, on your computer:

  1. Copy the project to a new folder, without .venv, .env, .idp, .tokens and reports.
  2. Delete the database and the files with docker compose down --volumes, then start them again.
  3. Make a new virtual environment, install from requirements.txt, and make .env from .env.example. Make the provider’s keys and secrets (python -m idp init in your copy).
  4. Apply the migrations, load both shops, and run every test.
  5. Start the provider and the service. Run your attempts script, generate the test report, and run your audit check for tokens and secrets.

Paste the commands and their outputs into your report. If a step needed something that is not in the README, fix the README, and run the steps again.

Your docs-assistant folder (without .env, .venv, .idp, .tokens and reports), with your migrations, code, tests, scripts, the generated docs/test-report.md, and REPORT.md:

# Larkfield document assistant: sign-in and two organizations
## The model (people, organizations, memberships, roles; the access matrix on paper)
## Identity (the provider copy, the clients, the audience, each token check, /v1/me)
## Server rules (the check order, 404 or 403, the repository, row-level security or why not,
files, search, citations, cache keys, the job, the audit)
## Negative tests (the matrix, the object-level cases, the data rules, the attempts, the report,
the three constructed bugs and what caught each one)
## Lifecycle design (lifetimes, prevent or bound for each place, the measured removal, key rotation)
## Real provider (the mapping, the evidence level, what a first real run must check)
## Failure paths (each of the ten, with the test or the run that shows it)
## Fresh-environment run (the commands and their outputs)
## Limits (made-up data, your computer, what you copied, what you did not test)

A reviewer marks each required item demonstrated, partially demonstrated or not yet demonstrated, with a concrete reason. To complete the course, all four must be demonstrated. You can improve an item and submit it again.

Required item Demonstrated Partially demonstrated, for example Not yet demonstrated, for example
Validated identity context Every route checks the signature, issuer, audience, expiry and algorithm, and the tests name the check for each bad token. A ticket-api token and an ID token get 401. /v1/me reads the memberships from the service’s own table. The checks work, but the keys are fetched on every request. Or the token type is checked, but the audience is not. The audience is still ticket-api, so a token of the other service works. Or the organization or the role comes from a token claim or a header.
Server-enforced role and resource rules Every route of an organization’s records runs the checks in order. Every repository function needs the organization. Files, search, citations, cache keys and the job carry the organization and the access level. Every refusal leaves an audit event without a token. The routes are right, but one statement or one cache key has no organization, and only row-level security saves it. Or the job is checked when it is queued, but not when it runs. A read-only member can upload, or can see a staff-only document in a citation. Or a file link is signed before the check.
Cross-organization negative tests The matrix covers every person, organization and route, plus bad identities and object-level cases. A policy test checks decide() against the paper. The report is generated, counts skipped checks as not passed, and all checks pass. The three constructed bugs are each caught, and the report says by what. The matrix covers the roles, but not the other shop’s real IDs. Or the report is typed by hand. Only happy-path tests. Or a cross-organization check fails.
Token and session lifecycle design Every lifetime has a number and a reason. Every place where a permission lives is prevented or bounded, with a time, and the removal is measured. Key rotation is in the right order. The real provider’s settings are mapped, with an honest evidence level. The lifetimes are given, but cached answers or queued jobs are missing. Or Entra ID is described as “works” without a run. No design for removal or expiry. Or the design says that a removed member keeps access until the token expires, and calls that acceptable without a measurement or a reason.

Whatever else is strong, the work is not complete while any cross-organization or privilege-escalation check fails. A happy path does not count if one person can read, change or cite another organization’s record, or if a role can do more than its row of the matrix allows. A leak with a nice front is still a leak.

The course’s own results can help you judge yours, but they are not targets. On the course’s computer, the ticket API’s matrix passed 213 of 213 checks, the attempts ended 0 of 10, and a removed member got 404 at the next request, 5 ms later. A 30-second membership cache kept a removed member reading for 30.3 s. Your service, its routes and your computer are different.

Common mistake

Your service accepts the ticket API's tokens

Symptom: a token from python -m scripts.login --user usr-sam --print-access-token in ticket-api gets 200 at your service, or your token gets 200 at ticket-api.

Cause: the audience check still expects ticket-api, the default of the copied auth.py, or your provider copy still writes ticket-api into aud. Two services that trust the same keys and the same audience accept each other’s tokens.

Fix: set the audience to docs-assistant in the provider copy and in the service’s settings, and run the provider on its own port and issuer. Add the ticket API’s token to your bad identities, so that the matrix catches it if it comes back.

Recap

In this course, you made an API safe for more than one organization:

  • Three separate questions: who calls (a checked token or session), in which organization (the path and the caller’s own membership), and what they may do there (the role and the token’s scopes).
  • Tokens and sessions: OIDC with PKCE, signed tokens checked on the server, a backend session with an HttpOnly cookie, and lifetimes with reasons.
  • Rules on the server: 404 for outsiders, 403 for members, the organization in every query, file link, search, citation, cache key and job, and row-level security as a second lock.
  • Change over time: removals and role changes that take effect at the next request, jobs checked when they run, support access with a reason and a time limit, and an audit of every refusal.
  • Evidence: a test matrix that tries every person against every route, a generated report, and a handoff that says what was tested and what is still open.

If you have not done one yet, choose a case study for the certificate: Audit access control in an open-source multi-tenant app or Add sign-in and roles to an existing API.