Menu
Start a project

Architecture

Enforcing multi-tenant permissions at the API layer

Tenant isolation is easy to describe and easy to get wrong in a way that stays invisible until a customer sees a row belonging to someone else.

Published

Every multi-tenant product is one query away from an incident. Not a dramatic one: no alarm sounds when a list contains four rows where three belonged. The customer sees a name they recognise, or a record they should not have, and the trust is gone before anyone reads a log.

Tenancy is not a column added later

The tempting version adds an organisation identifier to the tables that obviously need it and lets the application filter on it. That holds until the first join that forgets, the first report written in a hurry, the first background job running as nobody in particular.

The version that holds treats the organisation as part of the access path rather than as an attribute. Every read starts from the organisation and works down. A query that cannot name its organisation does not run. The constraint is irritating on day one and load-bearing on day four hundred.

Three questions every request has to answer

Before a request reaches data, three answers must already exist.

  • Which organisation is this request acting inside? Not the one the user usually belongs to, the one this request names.
  • What role does the caller hold in that organisation? Roles are per organisation, so a person can administer one and only read another.
  • Is this specific record inside that organisation? The check is on the row as well as the collection, because the identifier arrived from the client.

Where the check belongs

At the API boundary, before the data layer, and never only in the interface.

A hidden button is not a permission. It is a hint about one, rendered by code running on a machine nobody controls, in a browser where the developer tools are one keystroke away. The interface should read the permission state and reflect it. It should never be the thing that decides it.

Putting the check at the boundary also makes it cheap to test. A rule living inside a component needs a browser and a rendered page to exercise. The same rule behind an endpoint needs one request.

Test the rules, not the screens

The tests that matter are short and dull: for each role and each endpoint, assert what happens when the record belongs to another organisation. The expected answer is almost always the same, which is what makes the suite easy to keep complete.

Two habits keep it honest. Write the negative case first, because a test that only proves the happy path proves nothing about isolation. And make the permission tests the ones that block a merge, so a leak fails the build rather than reaching production and then a customer.

The migration nobody plans for

Eventually a customer asks to be split in two, or two customers merge, or a user needs access to a second organisation. If membership is a column, all three are data-repair jobs with a spreadsheet and a held breath.

If membership is its own record, with a role attached to the pair of user and organisation, all three are ordinary writes. That model costs one extra table at the start and has never been the thing to regret.

All notes

Tell us what you are building.

Write in English, French, Romanian or Arabic. Every message gets a written answer within one business day.