Two Aliases, One Batch: Tenant Isolation in a GraphQL Gateway
A project-management SaaS platform hosts 3,000 tenants and exposes a GraphQL gateway at /graphql. Tenant isolation is implemented in middleware that attaches the caller's tenantId to each resolver invocation. The gateway batches same-field selections within a single document into one DataLoader call per request, and the tenant filter is applied to the batch rather than to each key. Responses are cached for 30 seconds, keyed on field name. You have test accounts in tenant T-A and tenant T-B, both provisioned for this engagement. A document with a single alias, project(id:"<T-B id>"), returns null while authenticated to T-A. A document with two aliases - a: project(id:"<T-A id>") and b: project(id:"<T-B id>") - returns both projects in full, including member names and email addresses. The client's risk team has read your note and replied that this is a cache artifact that no real attacker could trigger.
1. Design the test that settles whether this is an authorization flaw or only a caching artifact.
Read the worked answer
Cold cache first. Wait 31 seconds, then send the two-alias document as the very first request with a fresh T-A token. If both objects come back on a cold cache, caching is not the mechanism and the risk team's explanation is dead. If it leaks only after a prior request warmed the key, then caching is the delivery mechanism - but the flaw is still authorization, because a cache must never be able to return another tenant's object to your session.
Then test alias dependency. After warming, send a single alias to the T-B id. If that now returns T-B's project, the cache is a standalone vector and the finding is worse, since no aliasing is required. If it returns null, aliasing is required, which points squarely at the batch-level tenant filter.
Then test who can warm it. You legitimately hold a T-B account. Use it to warm the key, then read as T-A. That is a complete in-scope proof built only from tenants the client gave you, and it does not depend on a real customer's traffic arriving at the right moment.
Interpretation. A pure cache artifact would be non-deterministic and would not require a second alias in the same document. What you are seeing is the middleware attaching tenantId to the DataLoader batch instead of to each key, so one key inherits another's scope. The 30-second cache only widens the window in which the bad result is served. Report it as broken tenant isolation with a cache amplifier - not as a caching issue, which would let it be triaged as a performance bug.
2. How far do you go to establish real exposure without reading more of T-B's data than necessary?
Read the worked answer
Bound it deliberately. I hold one T-B object id and have already seen its name and member emails. The questions that actually drive severity are: (a) how many aliases a request can carry and whether a complexity limit exists; (b) whether ids are guessable; (c) whether the leak returns all fields or only selected ones; (d) whether the batch is per-request or shared across users.
- (a) Send a document with five T-A aliases and the one known T-B alias and observe whether all six resolve. Five demonstrates multiplicity; a thousand is a load test on production.
- (b) Inspect the id format on your own T-A objects. UUIDv4 means enumeration is not viable and exposure is limited to ids an attacker can learn from referrers, exports or integrations - serious but bounded. Sequential integers mean every tenant's records are reachable, and the rating becomes Critical.
- (c) Do not request extra T-B fields to see what returns. Infer field coverage from the schema and from your own T-A objects instead.
- (d) Send the same two-alias document from a second T-A account and check whether it receives the cached T-B object. That separates per-request batching from a cross-user cache, which is the difference between High and Critical.
What I will not do. Iterate ids against T-B or any third tenant to count affected records. That is exactly the line between demonstrating a flaw and browsing data I was not given. Until the client authorises a bounded run against synthetic tenants, I report: cross-tenant read of at least one object confirmed, mechanism permits batching, blast radius not measured - and here is the measurement plan.
3. What remediation do you recommend, and what regression test would catch a recurrence?
Read the worked answer
Fix at the data layer, not at the gateway. The middleware approach fails structurally: authorization applied once per batch cannot express per-key rules when the batch contains keys from different tenants by construction.
- Make the loader key composite -
(tenantId, id)- and require tenantId as a mandatory argument on the repository method, so a caller cannot even express a cross-tenant read. - Enforce it again in Postgres with row-level security and
SET LOCAL app.tenant_idper transaction, so a future resolver that forgets the argument returns zero rows rather than another tenant's row. Defence in depth matters here because the gateway is not the only future caller. - Tenant-scope the response cache - key on
(tenantId, subject, operation, variables)- and setCache-Control: private, no-storeon authenticated GraphQL responses. - Add a request cost limit, which also caps alias-based amplification independent of the isolation bug.
Regression tests. In CI, authenticate as tenant A and submit a document whose aliases mix A and B ids; assert every B alias resolves to null. Run the same document with the B id first, since batch ordering may change which key inherits the scope. Add a second test that warms the cache as tenant B and then reads as tenant A, asserting null. Both tests must fail on the current build and pass afterwards - a test that passes before the fix proves nothing about the bug.
Also worth flagging. The same batching middleware likely serves other node types; list every field that uses it and require the composite key everywhere, not just on project.