A request can pass a correct project-permission check and still expose the wrong report. The missing question is whether the report actually belongs to the project that was authorized. For a nested route, check the stored parent relationship and the caller's action-specific permission together. For a mutation, preserve that relationship through the write: an authorization decision made before a report moves is not permission to edit it in its new project.
The URL is a proposed combination of identifiers, not proof of ownership. /projects/C-17/reports/R-41 does not establish that R-41 belongs to C-17. A deliberately shared report may follow a different rule, but that exception needs an explicit grant rather than an assumption that everything in one tenant is equally accessible. This is the object-specific decision described in OWASP API1:2023.
The parent check is real—and still insufficient
The following application, records, code and event sequences are synthetic teaching examples. They have not been executed or independently reviewed. Northstar Metrics stores reports under projects. Priya may read and edit reports in Cedar project C-17; she has no access to Cedar's restricted project C-19. Another customer, Harbor, owns project H-9.
Here is the entire relevant fixture:
| Report | Stored tenant | Stored project | Priya's right to its project |
|---|---|---|---|
R-40 |
Cedar | C-17 |
Read and edit |
R-41 |
Cedar | C-19 |
None |
R-82 |
Harbor | H-9 |
None |
Now inspect the handler's control flow:
GET /accounts/cedar/projects/C-17/reports/R-41
require_current_member(actor, "cedar")
require_can_read_project(actor, "C-17")
report = get_report_by_id("R-41")
return report.summary
Both checks can return the correct answer. Priya is a Cedar member and can read C-17. The lookup nevertheless selects R-41, whose stored parent is C-19. If the lookup and response path add no further check, the authorized parent has become a ticket to an unrelated child.
An unguessable report ID does not repair that missing relationship. Nor does a list page that only displays C-17 reports: the direct handler receives an identifier without necessarily going through the list. OWASP's IDOR prevention guidance recommends constraining lookups to the objects the user is permitted to access.
The useful counterexample is within Cedar. A review using only R-40 and Harbor's R-82 could establish tenant separation while missing the restricted-project boundary entirely.
Make the relationship part of retrieval
First establish the selected tenant from the authenticated actor's current authority. The caller may select an account; they cannot supply the fact that they belong to it. That distinction follows OWASP's multi-tenant guidance.
Then retrieve the child with its actual relationship. This SQL illustrates the query shape; the named placeholders stand for parameters bound through the application's database adapter:
SELECT r.id, r.project_id, r.tenant_id, r.relationship_version
FROM reports AS r
JOIN projects AS p ON p.id = r.project_id AND p.tenant_id = r.tenant_id
WHERE r.id = :requested_report
AND r.project_id = :requested_project
AND r.tenant_id = :verified_tenant
AND p.id = :requested_project
AND p.tenant_id = :verified_tenant;
With requested_project=C-17, requested_report=R-41 and verified_tenant=Cedar, this query has no matching row in the synthetic fixture. R-41 does not become authorized merely because its tenant matches. With R-40, the relationship lookup succeeds, after which the service must still establish Priya's permission for the requested action and fields.
This query proves a relationship, not a grant. A caller with no report-read permission must not receive the result just because the keys line up. Keep the action decision at the service that knows the object and business rule, as described in OWASP's authorization patterns guidance. Fetch the content through that same authorized scope; do not use a safe metadata lookup followed by an unrelated broad content lookup.
For the example, projects never move between tenants, and the report's tenant is immutable. Both facts are schema and write-path assumptions to inspect in a real service. If those relationships can also change, the consistency design must cover them too.
The read was correct. The later write is not.
Suppose Priya opens an edit form for R-40. At that moment it belongs to C-17, and the relationship version is 8. An authorized project administrator then moves it to C-19. Priya submits the form she already has open.
A later write by ID alone would reuse a decision about a relationship that no longer exists:
-- Insufficient: an earlier parent check does not constrain this write.
UPDATE reports
SET title = :new_title
WHERE id = :report_id;
The stale browser form is an ordinary way to encounter the race. No malicious timing is required. The security consequence comes from permitting a write after the report has crossed a boundary Priya may not access.
The intended event order is explicit:
| Step | Operation | Report relationship |
|---|---|---|
| 1 | Service authorizes Priya's edit of R-40 in C-17 |
Cedar / C-17 / version 8 |
| 2 | Administrator's move commits | Cedar / C-19 / version 9 |
| 3 | Priya's pending title update reaches the database | Must still require Cedar / C-17 / version 8 |
| 4 | Conditional update finds no matching authorized relationship | No title change; no success notification |
The move operation must update the parent and increment relationship_version atomically. Every path that changes a relationship—including migrations and administrative repair—must preserve that invariant. The version is server-controlled; a browser-supplied version is an expected-version condition, never authority to edit.
A narrowed mutation carries the relationship it was authorized against:
-- PostgreSQL-shaped example; application parameters are bound by the adapter.
UPDATE reports
SET title = :new_title
WHERE id = :report_id
AND tenant_id = :verified_tenant
AND project_id = :authorized_project
AND relationship_version = :authorized_relationship_version
RETURNING id, project_id, relationship_version;
For step 3, the supplied conditions are R-40, Cedar, C-17 and 8. The stored row is now in C-19 at version 9, so the expected result is zero updated rows. PostgreSQL's UPDATE documentation specifies that RETURNING describes rows actually updated and that updating zero rows is not a database error. The application must interpret that result; a generic “query succeeded” branch would incorrectly announce success.
In PostgreSQL Read Committed mode, an update encountering a concurrently updated target row can wait and then re-evaluate its WHERE condition against the updated row. That behavior supports this single-row relationship fence; it is not a blanket concurrency guarantee for policies spread across several tables. See PostgreSQL's transaction-isolation explanation.
Why include a version as well as the parent? Imagine an administrator moves R-40 to C-19 and later back to C-17. Checking only the current parent would accept the old edit. Versions 8 → 9 → 10 let Northstar's chosen policy require a fresh authorization decision after any move, even when the final parent name matches again. Another application might deliberately allow that edit; decide the rule before choosing the mechanism.
A failed condition must not become a broader retry
Handle the result according to what the database actually changed:
one returned row:
complete the edit and publish its success event
zero returned rows:
do not publish an edit event
reload through an authorized lookup
re-evaluate permission against the current relationship
if no access remains, deny without revealing the new project
otherwise ask the caller to reconcile the changed object
Do not retry with WHERE id = :report_id after the conditional write fails. That fallback removes the very fact that made the first attempt safe. Do not announce success or send a notification before the write commits, either.
A zero-row result can also mean the report was deleted or the expected version was stale for another reason. Internally, those cases may need different diagnostics. Externally, the response must not disclose a restricted project's name just to explain a denial. The reviewer should inspect both the stored title and any event or notification, not only the HTTP response.
This example fences report movement. It does not solve simultaneous membership revocation, changes to section-level grants, or an external document service publishing from an older decision. If the product promises those changes take effect before a mutation, their authoritative state must participate in an appropriate transactional or coordinated decision. A report version cannot stand in for a permission version. See reviewing stale access for that separate promise.
When the stored relationships disagree
Consider one bad migrated record:
report R-99: tenant=Cedar, project=H-9
project H-9: tenant=Harbor
The joined lookup rejects it. Do not repair the response by selecting whichever tenant agrees with the request, and do not relabel the row inside a read handler. Withhold content and create an integrity investigation referencing the conflicting record IDs in restricted operational evidence. An authorized data-repair process must establish the intended owner.
This is different from a legitimate share. A share should be a recorded grant with defined action and audience, not inconsistent ownership that happens to make a request work. The Authorization Cheat Sheet recommends explicit permission decisions rather than assuming access from a broad role.
Finish the review with outcomes, not another open checklist
The synthetic case now has enough detail to predict specific outcomes. These are expected results from the example, not observations of a running system:
| Case | Decisive evidence | Expected conclusion |
|---|---|---|
Priya reads R-40 under C-17 |
Stored relationship matches; current read grant applies | Allow the permitted projection |
Priya requests R-41 under C-17 |
Stored parent is C-19 |
Reject before content delivery |
Priya edits R-40 after its move |
Conditional write requires old parent/version | No mutation; require a new decision |
| A moved report returns to its old parent | Version changed despite matching parent | Old edit remains stale under this policy |
R-99 has conflicting tenant and parent |
Joined ownership evidence disagrees | Withhold and investigate integrity |
For an actual review, obtain the lookup, the final mutation, every relationship writer and the deployment binding. Check the expected cases in an authorized environment with synthetic records, including a legitimate edit that still succeeds. Record source reasoning separately from any observed database and response behavior. The examples here do not qualify an implementation or demonstrate exploitation.
Review the move operation as part of the same policy
The conditional edit depends on every legitimate movement of a report maintaining the relationship invariant. Inspect the move operation alongside the read and update paths. In Northstar's example, a move should establish authority over the source report and the destination project according to the product's policy, then update the relationship and version together. A user permitted to edit a report is not automatically permitted to move it into a restricted project.
A useful synthetic review question is whether the destination is selected from a server-authorized set or merely copied from the request. Another is whether a partially completed move can leave the report's metadata pointing to one project while a derived export still uses the previous project's delivery policy. These questions extend the relationship review; they do not establish that either defect exists.
For acceptance evidence, record the permitted move, the denied destination, and the stale edit that follows a completed move. Include any secondary records whose ownership is expected to change. If the design intentionally leaves old exports under a separate immutable policy, say so rather than assuming the report's new parent retroactively changes them.
The review remains bounded to the chosen invariant. A correct move protocol and conditional title update do not establish every permission-revocation guarantee. They show that this report relationship cannot be silently substituted between the checked state and the protected write under the stated design assumptions.
The repair brief is compact: bind retrieval to the stored parent; keep action permission separate; carry the authorized relationship through the mutation; treat a failed condition as a new decision, not permission to widen the write. Start with one endpoint containing both parent and child IDs and inspect the last operation that returns content or changes state. For adjudicating the original alert, use the IDOR investigation guide; for mixed-object requests, use batch authorization.