Python code review exercise: an authenticated export leaks tenant data
In January 2021, archivists scraped 70 terabytes of data from Parler through its API. The endpoints were live, the servers were running, and nobody needed to break in because the API simply handed out records to anyone who asked for them in the right order. No stolen credentials, no SQL injection, no zero-day. Just an API that checked whether a request existed and never checked whether it was allowed to see what it was asking for. That pattern has a name: Broken Object Level Authorization, the #1 entry on the OWASP API Security Top 10. And the version of it you will see in this exercise is subtler than Parler's, because the endpoint does authenticate the caller. It just never asks which org's data they're supposed to see.
This is a Python code review exercise for anyone who reviews FastAPI or Django pull requests in a multi-tenant codebase. The PR adds a CSV export endpoint. It imports the right auth dependency, it streams the response correctly, it even handles an optional query parameter. The bug is that it authenticates the user without scoping the query to their organization, which means any logged-in user downloads every tenant's invoices. The exercise teaches the difference between authentication ("who are you?") and authorization ("what are you allowed to see?"), which is the single most common security flaw in API code and the one most likely to survive both automated tests and a casual review.
What this PR is supposed to do
The requirements are short:
- Add
GET /exports/invoices.csv: a signed-in user downloads their org's invoices (number, issue date, total, status) as a CSV file, optionally filtered by status. - Every endpoint that returns org-owned data must scope its query to the caller's org through the documented tenant-isolation dependency (
current_org_member) and filter onmember.org_id. Authentication alone is not scoping. current_org_memberis the tenant-isolation boundary: an org-scoped query that does not filter onmember.org_idreturns other tenants' rows and is a defect even if the caller is authenticated.- The export streams a real CSV (header row + one row per invoice) and reuses the same invoice fields the list endpoint exposes.
The codebase is a multi-tenant SaaS. Users can belong to several orgs, and every request carries an X-Org-Id header. current_org_member in app/deps.py is the audited boundary: it maps an authenticated user plus that header to a Membership, and the returned member.org_id is what every org-scoped query filters on. get_current_user only proves the request is authenticated, not which org's data it may see. Invoice rows from all orgs live in one invoices table keyed by org_id.
The PR
Three files in the diff. Two are unchanged context; the third is the new endpoint. Read all three before you decide anything, because the bug is the gap between what the context files establish and what the new file does.
app/deps.py (unchanged)
app/deps.py
unchanged
app/routers/invoices.py (unchanged)
app/routers/invoices.py
unchanged
app/routers/exports.py (new file)
app/routers/exports.py
+32
Thirty-two lines, clean imports, correct use of StreamingResponse and csv.writer. The docstring even says "the caller's invoices." If you skim this diff between two meetings, it reads like a finished feature. Now look more carefully.
Try it yourself first
The same PR is a hands-on exercise on DiffDojo.
Leave your comments, pick your verdict, and see the canonical review after you submit. No signup required.
Review this PR cold →Walkthrough starts below. If you want to form your own opinion first, stop here and try the exercise above.
The docstring says "the caller's." The query says "everyone's."
Look at line 22 of the new file:
That's db.query(Invoice) with no filter. It selects every invoice in the database, across every organization. The optional status filter on line 24 narrows by payment status, not by tenant. The user variable from get_current_user is injected on line 18, which proves the caller is logged in, but nobody ever uses it to restrict which rows come back. The user object does not even carry an org_id in this codebase; the org binding lives in current_org_member, which this endpoint does not call.
Now compare with the existing list_invoices in app/routers/invoices.py, lines 15 and 19:
That endpoint depends on current_org_member, which resolves the caller's membership and gives back member.org_id. The very first thing the query does is .filter(Invoice.org_id == member.org_id). The new export endpoint skips both of those steps. It has authentication (who are you?) but not authorization (what org's data may you see?).
What actually happens on Monday morning
A user from Org A, logged in with a perfectly valid session, hits GET /exports/invoices.csv. The endpoint checks their session token, confirms they are a real user, and then runs db.query(Invoice).all(). The CSV that comes back contains every invoice from every organization in the system: Org A's, Org B's, Org C's, everyone's. Invoice numbers, amounts, dates, payment statuses. The user opens it in a spreadsheet and sees customers they have never heard of, because those customers belong to a different tenant.
Nothing errors. Nothing 401s. The response is a perfectly formatted CSV. If the user only has a few invoices of their own, they might not even notice the extra rows at first, or they might assume the export is "just showing everything" and report it as a cosmetic bug. Meanwhile, the data has left the building.
This is not a theoretical risk. The status filter makes it worse: GET /exports/invoices.csv?status=overdue returns every overdue invoice from every org. An attacker who knows this endpoint exists does not need to guess IDs or manipulate headers. They log in to any account, call the endpoint, and receive a full export of every tenant's billing data.
The comment you would actually leave
This depends on get_current_user (authentication) but not current_org_member (tenant scoping). The query on line 22 is db.query(Invoice) with no org_id filter, so it returns invoices from every org in the system. The existing list_invoices endpoint does this correctly: it takes Depends(current_org_member) and filters on member.org_id. This endpoint needs the same pattern. As written, any authenticated user can download every tenant's billing data.
Verdict: request changes
This is a blocker. The endpoint leaks cross-tenant data to any authenticated user. The CSV streaming, the status filter, the response headers are all fine. The only problem is that the query is unscoped, and the fix is to use the same dependency and filter that the list endpoint already uses.
The fix
Replace get_current_user with current_org_member and add the org filter to the query:
Four lines change. The import swaps User and get_current_user for Membership and current_org_member. The function signature takes member instead of user. The query starts with .filter(Invoice.org_id == member.org_id). Everything else stays the same.
The test that catches it
Set up two orgs with their own invoices. Authenticate as a user in Org A. Call GET /exports/invoices.csv. Parse the CSV. Assert that every invoice in the response belongs to Org A and none belong to Org B. Then test with the status filter: GET /exports/invoices.csv?status=paid should return only Org A's paid invoices, not Org B's.
In pytest, with two fixtures for two orgs:
On the buggy code, the first test fails: len(rows) includes Org B's invoices, so the count is wrong and the set of invoice numbers contains entries that don't belong to Org A. On the fixed code, both tests pass. The key is that the test has data from two orgs and asserts that only one org's rows appear. A test with a single org cannot catch this bug, because db.query(Invoice).all() and db.query(Invoice).filter(Invoice.org_id == member.org_id).all() return the same rows when there is only one org in the database.
The habit: trace the query to the tenant boundary
Every time a PR adds or modifies an endpoint that returns tenant-owned data, ask one question: what binds this query to the caller's tenant? Then trace backwards from the WHERE clause (or .filter()) to the value it filters on, and from that value to wherever it was resolved. If the chain goes: HTTP header or token, through an audited dependency that verifies membership, into the query filter, you are looking at a scoped endpoint. If the chain stops at "the user is logged in" and never reaches the tenant, you are looking at a cross-tenant data leak that will pass every test anyone writes with a single-tenant fixture.
Authentication answers "who are you?" Authorization answers "what are you allowed to see?" The first is never a substitute for the second, and the PR that mixes them up will always look clean in review, because the auth import is right there in the signature and the endpoint does not crash.
Read next Python code review exercise: a search query becomes SQL