XRAPH/Writing/Where tenant resolution belongs

Where tenant resolution belongs

I put it in the resolver first, which was wrong. On moving the tenant boundary far enough down that a forgotten clause cannot leak data.

Published
Aug 2020
Length
2 min read
Systems
1

#The first design

In the earliest version, each GraphQL resolver read the tenant from the request context and passed it to the repository. This is the obvious design, it is easy to explain, and it puts the safety property in the hands of whoever writes the next resolver.

It survived until somebody added an administrative report. The report queried across organisations on purpose, then a later change reused its repository method somewhere that should have been scoped, and for a while one customer's aggregate counts included another's.

Nobody was careless. The correct call and the incorrect call looked almost identical, which is the property that makes this class of bug persistent.

#Moving the boundary down

The second design put the tenant on the request context at the edge, set once from validated claims, and made the data access layer refuse to build a query without it. A method that genuinely needed to cross tenants had to say so explicitly, with a differently named entry point that was easy to grep for and awkward to call by accident.

The principle here is old. Saltzer and Schroeder call it complete mediation: every access goes through the check, with no path around it . Fail-safe defaults belong in the same list, and the two together say that the default behaviour of a query with no tenant should be to return nothing rather than everything.

#Why not push it all the way to the database

The strongest version of this puts the boundary in the database, so that a query missing its predicate returns an empty set no matter what the application does. I did not do that in 2020, and I have used it since with row-level security, which is genuinely better.

The end-to-end argument is relevant here . A check in the application is not sufficient if something else can talk to the database, and a check in the database is not sufficient either if a superuser role bypasses it. The useful question is which layer can enforce the property completely, and for tenancy that layer is the storage engine.

#Tenancy is not authorisation

These get conflated and they answer different questions. Tenancy asks which customer's data this request is allowed to see at all. Authorisation asks whether this particular user may perform this particular action on this particular object, which is a role and attribute question with a large literature behind it .

Keeping them separate in the code mattered more than I expected. When one function answered both, its signature grew until nobody could say what it guaranteed.

#The test that made me trust it

Assert the negative case at the lowest level you can. Write data as one tenant, switch to another, run the broadest query the layer allows with no filter at all, and assert that the result is empty. Not filtered correctly. Empty, because the other tenant has written nothing yet.

If that assertion ever returns rows, the boundary is not where you think it is.

References

  1. [1]Jerome H. Saltzer, Michael D. Schroeder, The Protection of Information in Computer Systems, Proceedings of the IEEE, vol. 63, no. 9, pp. 1278-1308, 1975doi:10.1109/PROC.1975.9939
  2. [2]J. H. Saltzer, D. P. Reed, D. D. Clark, End-to-End Arguments in System Design, ACM Transactions on Computer Systems, vol. 2, no. 4, pp. 277-288, 1984doi:10.1145/357401.357402
  3. [3]Ravi S. Sandhu, Edward J. Coyne, Hal L. Feinstein, Charles E. Youman, Role-Based Access Control Models, Computer, vol. 29, no. 2, pp. 38-47, 1996doi:10.1109/2.485845