Review who may perform which action on which resource in which tenant, at the time of the request. Start with a written policy and a small set of accounts with known relationships. Follow each request from authenticated identity and selected tenant through the resource's actual ownership, the decision point, and every place the result can leave the system. Then check allowed and denied cases in an authorized environment. A login check, a tenant ID in a token, a filtered list page, or a difficult-to-guess resource ID does not settle access to an individual object. The answer changes when a user belongs to several tenants, a resource is deliberately shared, or an asynchronous job acts after permissions change: write those exceptions into the policy before calling the behavior a bug.
This review produces a decision record, not a claim that every route has been proved safe. OWASP's Authorization Cheat Sheet recommends denying unmatched access and checking permissions on every request. Its multi-tenant guidance treats client-supplied tenant identifiers as selectors that must be checked against current authority. The practical work is finding where your application actually makes those decisions.
Define the rule before reading the handler
An access decision has at least five inputs:
| Input | Question the reviewer must answer |
|---|---|
| Principal | Which authenticated person or service is acting? What current membership or delegation supports that identity? |
| Action | Is this a read, edit, export, invitation, reassignment, download, or job start? A broad “project access” label hides differences. |
| Resource | Which persisted record or object will be returned or changed, and who owns it? |
| Context | Which tenant was selected, and are the session, grant, role, assignment and parent relationships still current? |
| Output | Which fields, related records, signed links or side effects will this operation expose? |
Write the rule as a sentence a service owner can challenge: “A Cedar analyst may download a Cedar report only while they have active Cedar membership, report-read capability, access to its parent project and permission for that report's section.” A tenant administrator may have account-billing rights without report-read rights. An auditor may read a deliberately shared summary without seeing technical attachments. Your actual policy may differ; the important point is to record its exceptions instead of inventing them after observing a response.
This avoids two common shortcuts. First, authentication answers who signed in, not whether this report belongs to the selected account. Second, a role such as analyst is usually too coarse to prove access to every object with an analyst-facing route. OWASP's API1:2023 object-level authorization guidance makes the object check central to an API request.
Illustrative case: locate the missing boundary in a report download
Illustrative case, not a customer incident: Northstar Metrics has two customer accounts, Cedar and Harbor. Cedar owns project C-17 with report R-40, and project C-19 with report R-41. Harbor owns project H-9 and report R-82. Priya is a Cedar analyst with report access only to C-17; Leo is a Harbor analyst; Morgan belongs to both accounts but can read reports only in Cedar. An endpoint receives GET /accounts/{account}/projects/{project}/reports/{report}/download.
The project list filters by the selected account. The download handler, however, fetches a report by report ID and asks a document service for its file. The first code read shows no comparison between the loaded report, its parent project, and the authenticated user's current account. That is a candidate path, not yet a confirmed exposure: the document service might enforce ownership, and the route might not be deployed in the assessed environment.
Before any controlled request, write a matrix using only synthetic records and authorized accounts:
| Principal and selected account | Requested object | Expected decision | Reason |
|---|---|---|---|
| Priya in Cedar | C-17/R-40 |
Allow | Current membership, project and report rights. |
| Priya in Cedar | H-9/R-82 |
Deny | Foreign tenant and parent. |
| Leo in Harbor | C-17/R-40 |
Deny | Foreign tenant. |
| Morgan in Cedar | C-17/R-40 |
Allow | Cedar report-read grant. |
| Morgan in Harbor | H-9/R-82 |
Deny | Harbor membership alone does not grant report-read. |
| Removed Priya in Cedar | C-17/R-40 |
Deny on a new operation | Membership has been revoked. |
The matrix prevents a misleading test: Morgan's successful login to Harbor proves membership there, but says nothing about Harbor report access. It also supplies a positive own-object case. If every request returns an error because the test setup is broken, a foreign-object denial is not meaningful.
Follow one file through four different decisions
Continue the synthetic example by opening the document service. These code fragments and expected outcomes are illustrative, not executed results. The service has this interface:
report = reports.get_by_id(request.report_id)
gateway.require_capability(actor, selected_account, "report.read")
return documents.download(selected_account, report.file_key)
The gateway really does enforce Cedar membership and report.read. The document service also really does reject Harbor storage keys. That combination defeats the original Cedar-to-Harbor path if the storage check is mandatory. It does not settle access inside Cedar.
Now examine Cedar's C-19/R-41, which Priya is not authorized to read. R-41 passes the gateway and the document service because both checks stop at the account boundary. The missing decision is whether Priya may read the report's actual project. The useful finding becomes narrower and more precise: a Cedar analyst may download another Cedar project's private report through this route. Calling it a cross-tenant disclosure would misdescribe the control that failed.
| Layer | Facts it actually checks | Fact it cannot establish in this design |
|---|---|---|
| Gateway | Current actor, selected account, report-read capability | Whether the requested report belongs to an allowed project |
| Report service | Loads report and its persisted parent | Currently makes no project decision: the defect |
| Document service | File key belongs to selected account | Which Cedar project Priya may read |
| Storage policy | Caller may read Cedar's object partition | Business permission for a particular report or section |
This is why “authorization exists in middleware” and “the database is tenant scoped” are incomplete answers. Each can be true while the application still releases the wrong object.
Repair the decision without inventing a second owner
The report service owns the report-to-project relationship. Give it the action decision before it asks for content:
context = gateway.verified_context() # authenticated internal provenance
report = reports.get_metadata(context.account, request.report_id)
require report.account == context.account
project = projects.get_metadata(context.account, report.project_id)
require project.account == report.account
require request.project_id == report.project_id # nested-route contract
require current_policy.permits(context.actor, "report.download", project, report)
return documents.download_authorized(context, report, section="summary")
The last method is an interface requirement, not a magic authorization function. Its implementation must bind the requested file and section to that report, reject caller-substituted keys, and enforce the delivery policy. If a network service receives this context, authenticate the sending service and protect its integrity; a client-supplied JSON object named verified_context conveys no authority.
The repair uses the persisted parent, not project=C-17 supplied beside report=R-41. A mismatch is denied; it is not silently repaired during a read. If reports can move while work is in progress, the relationship used for authorization must remain valid at the protected operation. The parent-and-child guide works through that separate race.
Now inspect delivery. Suppose the document service responds with a reusable file URL. The service decision protects issuing that URL, but it does not necessarily protect later use. The architecture must choose a documented link lifetime or a delivery path that checks current access. The correct boundary statement is “Priya cannot obtain a new link through this route after removal” unless the later download is also revocable. Do not quietly promote it to “Priya can no longer download anything.”
Resolve the example, then widen the review
For the fixture above, the expected outcomes after repair are:
| Request | Expected result | What it demonstrates if observed |
|---|---|---|
Priya downloads C-17/R-40 summary |
Allowed summary only | Legitimate path still works |
Priya downloads Harbor H-9/R-82 |
Denied without foreign metadata | Tenant boundary for this route |
Priya supplies C-17 with Cedar report R-41 |
Denied | Caller cannot substitute a permitted parent |
Priya requests R-41 directly |
Denied | Project boundary below the account check |
Cedar reader authorized for both projects supplies C-17/R-41 |
Denied | Nested route must match stored parent even when the actor may read both |
| Morgan selects Harbor without report-read | Denied | Membership does not imply the action grant |
These are expected outcomes, not observations. A real reviewer binds them to the deployed build, policy version and final response, including file delivery. A universal error caused by a broken fixture is inconclusive; the permitted R-40 case must work.
The resulting developer note is small: “The download route enforces account access but omits the stored project's report-download decision. Preserve the Cedar storage restriction, add the resource decision before file issuance, and verify the listed cases above.” Leadership receives the business consequence and scope; the developer receives the missing comparison.
Only then widen to list, export, search, batch and background delivery. Use a route inventory to find independent implementations, not to make an exhaustive checklist look completed. A safe document service is useful counterevidence for the cross-tenant claim; it does not excuse the demonstrated gap at the narrower project boundary.
What a clean result cannot tell you
A passing matrix covers only the actors, actions, resources, revisions and delivery paths listed. It does not prove that a later route, privileged support job, cache key or export worker uses the same decision. Source review can show a plausible actor-to-effect path but does not show a live exploit. A negative controlled request can miss an alternate object state or a race around a permission change. Record exclusions and unresolved paths as work, not as “secure.”
Choose the next route by its decision owner
After the download repair, Northstar should not copy the same handler check into every endpoint without inspecting where each decision belongs. The list route may already ask the report service for an authorized projection. The export worker may load reports through a different interface. The search service may return titles from an independent index. These are distinct places where the project policy could be enforced or lost.
Create a small route map for this one report resource. For each route, record which component knows the stored project, which component decides the action, and which component delivers the result. A row is complete only when the handoff between those components preserves the same report identity. The map can reveal a shared service decision worth reusing, as well as an exceptional path that needs its own review.
For example, a search result that exposes only a report title still needs a policy decision about that title. It does not automatically inherit the file-download restriction, and its smaller output does not automatically make it public. Ask the product owner whether discovering a restricted project's existence is permitted. That answer determines the search projection and the denied-case evidence to collect.
Keep the resulting work bounded. The first follow-up might inspect export because it returns the same report through a different store constructor. The search review can have its own scope and expected output. This preserves the useful account-level counterevidence while pursuing the actual project-level gap, without calling the entire application broken or repaired from one route.
For a first pass this week, choose one sensitive object and one action, write six matrix rows like those above, and trace the decision through its final delivery path. If the main uncertainty is an object ID in a request, use investigating an IDOR report. If access changes after a role edit, use reviewing stale access.