← All writing

How do you keep one customer from seeing another's data?

One missing filter is all it takes, and it has no symptoms in development. The structure that makes correct scoping the default, not something you remember.

How do you keep one customer from seeing another customer's data?

Pick one boundary, put it on every record, and make every query go through it by default rather than by memory. The failure is always the same: one query somewhere that forgot to filter. It has no symptoms in development, no failing test unless you wrote one specifically, and no error in production. Somebody just sees somebody else's data.

The only reliable defense is structural. Here is the structure.

Pick the boundary first

Before any code, answer one question: what owns a piece of customer data?

Usually it is an account or workspace rather than a user, because eventually somebody wants to invite a colleague, and if your data belongs to individual users you will be rewriting everything to fix it.

Choosing account-as-boundary on day one costs nothing even if you never ship teams. Choosing user-as-boundary and changing your mind costs a migration of every table you have.

So: an account is the boundary. A user belongs to an account. Every piece of customer data belongs to an account.

The three rules

Write these down where your team and your coding agent will read them, because they only work if they are followed everywhere.

1. Every model has an account reference

Not most. Every model holding customer data, with a non-null constraint and a foreign key at the database level.

The database constraint matters. It means a row without an owner cannot exist, so "which account does this belong to?" always has an answer. A nullable column invites a code path that forgets to set it, and then you have orphan records that no scoped query will ever return and nobody will ever find.

2. Every query goes through the current account

This is the load-bearing rule. Compare:

Thing.find(params[:id])                  # wrong: any id, any account
Current.account.things.find(params[:id]) # right: only this account's ids

Those two lines look nearly identical, and they differ by whether a customer can read another customer's record by changing a number in the address bar. In the second version an id belonging to someone else simply is not found, so it returns a 404, which is also the correct thing to tell them: confirming that a record exists but is not theirs is itself a small leak.

The reason to make it a rule rather than a habit is that habits fail once and rules fail visibly. If every query in your codebase starts from Current.account, then one that does not stands out in review.

3. Permission checks deny by default

Every action asks a policy object whether it is allowed, and the base policy says no. A new action with no rule written is forbidden, not permitted.

This matters because the common failure is forgetting to add a check, not writing a wrong one. Deny by default turns forgetting into a visible error rather than an open door.

The default scope on those policies should filter to the current account too, so listing records is scoped by the same mechanism as fetching one.

The test that has to exist

Every resource you add ships with this test:

A signed-in user requests a record belonging to a different account. The response is a 404.

That is it. It is three lines and it is the difference between believing your scoping works and knowing it.

Write it for the first resource, then copy it for every one after. The value is not in the individual test, it is that the test is part of the shape of a resource, so an agent copying the pattern copies the test too.

Why development never catches this

Worth being explicit, because it explains why careful people ship this bug.

On your laptop you have one account. Everything you can see belongs to you. An unscoped query returns exactly the same result as a scoped one, on every screen, every time. The app is marginally faster without the filter. Nothing is red.

The bug becomes reachable the moment you have two customers, and it becomes visible the moment two customers' ids collide in someone's address bar. Between those two moments, it is present and silent.

This is why the category of failure matters more than the individual bug: you cannot find it by testing normally, because testing normally is what hides it.

The places it gets missed

Even with the rules, a few spots get forgotten. Check these specifically:

  • Background jobs. A job runs with no request and no current account. If it loads records directly, nothing scopes it. Pass the account in and scope explicitly.
  • Admin and support tooling. Legitimately crosses accounts, which makes it exactly where a mistake is most damaging. Keep it separate and obvious rather than a flag on a normal path.
  • Exports and reports. Often written quickly, often assembling data from several tables, often the one place someone wrote a raw query.
  • Anything with an id in the address. File downloads, share links, callbacks.
  • Nested lookups. Scoping the parent does not scope the child. If you find a comment by id without going through its post, the scoping stopped one level up.

What this buys you later

Two things, and they are the reason to do it on day one rather than when it hurts.

Teams become easy. If data already belongs to accounts and users already belong to accounts, then inviting a second person to an account is a small feature rather than a rewrite.

Deletion becomes possible. "Delete everything belonging to this customer" is a real obligation in several jurisdictions. If every record has an account reference, that is a query. If ownership is implied by relationships that vary per table, it is a project, and one you cannot be confident you finished.

The summary

  • One boundary. Make it the account.
  • An account reference on every model, non-null, with a foreign key.
  • Every query through the current account. A foreign id returns 404.
  • Permissions that deny by default, with the default scope filtered to the account.
  • A tenant-isolation test shipped with every resource.
  • Check jobs, exports, and admin tooling specifically, because those are where it gets missed.

Get the structure right and correct scoping becomes the path of least resistance, which is the only version of this that survives contact with a deadline. It is also the first item on the checklist that makes an app safe to launch.

Keep reading

security

What makes an app safe to launch?

Twelve checks that separate a demo from something you can put in front of strangers, ordered by how much damage each one prevents.

· 5 min read