Build a role and tenant grid
Use four users: viewer and operator in tenant A, and viewer and operator in tenant B. Give each tenant one account, two payouts, and one report. Use payout IDs A-101, A-102, B-201, and B-202 with known amounts and states. For every API operation, write the expected cells in the grid before testing. An A viewer has read access to A records. An A operator has write access to A records. Neither role has access to B records. This separates role denial from tenant denial, which helps locate the failed check.
For a list route, count and search results matter as much as the record body. A response that hides B rows but reports the combined total still leaks tenant activity. For a bulk route, check atomicity: when one request contains A and B IDs, decide whether the server rejects the whole request or only B items. Record that rule and check the final stored state. Never infer success from the HTTP status alone.
Test four paths, not one
Start with a read of A-101 by an A viewer. Then send the same request with B-201 while keeping the A token. Next, ask for a list sorted by date and check that B-201 is absent from rows, total count, and next-page cursor. Send a bulk update with A-101 and B-201. Compare both stored rows after the call. The exact HTTP code is less important than the data and state: the caller must neither see nor change B-201. Run the same grid for operator and viewer roles so a role error does not hide a tenant error.
Test cases and proof
| Case | Expected result | Proof to keep |
|---|---|---|
| Direct object read | Return no tenant B record | Response and audit log |
| List filter changed to B | Ignore or reject foreign tenant | Result IDs |
| Bulk update mixing A and B | Reject B items without changing them | Before and after records |
Synthetic example
Payout B-201 belongs to tenant B. A user from tenant A changes their own payout URL from A-101 to B-201. The API must decide from the authenticated tenant, not from a tenant field in the URL. Repeat the check in search results and exports; those routes often use different query code.
What a failed test looks like
A 200 response containing B-201 is a clear data leak. After a 403 response, read B-201 through the authorized B operator account and compare its fields with the saved baseline. A changed row proves a write leak. An audit entry alone does not prove a stored change; use it to trace the request. A list whose rows are filtered but count includes B is still a disclosure. Record these outcomes separately; a fix to one query path leaves the others untested. The safest server design derives tenant scope from verified identity and applies it inside the data query, then checks role before returning or changing a row. A filter supplied by the browser can narrow that scope, but must not widen it.
Fix the failed case
Scope every record query to the tenant from the verified session. For bulk writes, check each ID before any change and decide whether the full batch fails or valid items proceed. Apply the same rule to counts, exports, and jobs. Add tests for a direct ID swap and a mixed-tenant batch. Review both the response and stored rows after the fix.
When a case fails, label the exact route and operation. A missing scope in one report query calls for a different fix from a missing role check on a bulk update. Retest with A and B users in both directions; check that the filter also protects newly created tenant B. Add a test where the record owner changes or the user loses a role between page requests. Keep the fixture small enough that every returned ID can be checked by hand. No production customer data is needed.
Try a cursor after a tenant switch
Create A-101 and A-102 at the same time, then B-201 and B-202 at that time too. Sort by creation time and ID so ties have a fixed order. Fetch page one as tenant A with size one and verify it returns A-101 with a non-empty next-page cursor. Use that cursor as A and confirm page two returns A-102. Then change to the B-only token and submit the saved A cursor. The server must derive scope from B’s token and reject a cursor bound to A, or return only B rows under a documented cursor rule. Repeat with a report export started under A, then attempt download as B. Keep response IDs, row totals, job owner, and before-and-after reads from authorized accounts. A cursor is an input, not proof of permission. (OWASP authorization guidance).
Test a user with membership in both tenants
Add user M with viewer access in A and operator access in B. Select A as the active tenant through the product’s normal flow, then try to update A-101 using M’s B operator role. Deny the update: a role in B cannot grant write access in A. Select B and confirm a permitted B update succeeds. Change the tenant selector in a request to an unjoined tenant C and deny access. Verify membership and role for the selected tenant on every request, including exports and job downloads.
Remove M’s B membership while M keeps an existing session. Repeat a B read and write and record when the server applies revocation under its session policy. Confirm the final B state through a separate authorized B account. This distinguishes a valid tenant switch from a caller who forges a tenant field.
Related reading
Why these checks matter
OWASP API1 says an endpoint that uses a user-supplied object ID needs an object access check. OWASP also calls for permission checks on every request. In this test, those rules apply to both the direct payout URL and the routes that list, count, or export payouts. A safe direct read cannot excuse a leaking list.