Notes

27 Sept 2026

Keeping one business's data away from another's in a multi-tenant app

Rivo runs many independent salons and clinics on one platform. Each of them keeps customer names, phone numbers, visit history and revenue in it. The most important promise we make is simple: one business can never see another’s data.

There are two common ways to keep that promise. Give every customer their own database, or share the database and tag every record with the business it belongs to. We share the database and tag the records. That is cheaper to run and easier to change, and it moves the risk from infrastructure into code. So the question becomes how to make that code hard to get wrong.

Make the tenant impossible to forget

The most common tenant leak is not a clever attack. It is a query where someone forgot the filter. So we do not rely on remembering.

Every record belongs to an organisation, and data access goes through a repository layer. The organisation is the first argument of every method. The base repository adds the organisation filter, and a filter that excludes soft-deleted records, to whatever query the caller wrote. A developer who writes a new query gets the filter without asking for it.

Reading a single record shows why this matters. Looking up a record by its ID also applies the organisation filter. If the ID belongs to another business, the result is the same as if it did not exist: not found. Guessing or leaking an ID does not turn into a way to read someone else’s data.

Keep roles separate from tenants

Isolation between businesses is one problem. Inside a business, different people should see different things: a front desk does not need payroll, and a stylist does not need the whole customer list. Access is role-based, with separate roles for owners, managers, front desk and staff, and privileged actions are recorded in an audit trail so a change can be traced to a person and a time.

Keeping these two ideas separate has helped. The tenant boundary is enforced by the data layer and does not depend on anyone’s role. Roles decide what people inside the boundary may do.

Where the risk remains

A shared database is only as safe as its least careful query. In practice the places that need the most attention are the ones that step around the repository layer:

  • hand-written aggregation pipelines for reports, which have to apply the organisation filter themselves;
  • background jobs, which run without a signed-in user and so have to be given the organisation explicitly;
  • bulk exports and imports.

We treat each of these as a place to review carefully, and we would recommend the same to anyone building a similar system.

What we would do again

  • Put the tenant in the function signature so leaving it out is a compile error, not a code review comment.
  • Do the filtering once, in a base class, not in every query.
  • Make a wrong-tenant lookup look like a missing record.
  • Keep tenant isolation and role permissions as separate mechanisms.

None of this makes a shared system as isolated as a database per customer. It makes the safe path the easy one, and that is the most we think any design can honestly claim.