Architecture Case Study

Making one app serve many companies, without a single default_scope

A five-year-old Rails application built for one company had to become a platform that could safely hold several. The hard part was not adding a company_id column — it was guaranteeing that no request, no background job and no admin tool could ever return one company's data to another, on a codebase that had never once had to ask the question.

Client
Book-inventory & resale operations platform
Scope
Row-level multi-tenancy retrofit, backend
Delivered
2026
Rails 6.1PostgreSQLSidekiqRedisRSpecPundit
Summary

At a glance

Tables under tenant isolation
99
Each either carries a company_id or reaches one through a parent association.
Controllers touched
~105
The boundary where the tenant is resolved, and where isolation is enforced.
Jobs made tenant-safe
45
Every Sidekiq job handed its tenant explicitly rather than inheriting one.
Per-controller scope specs
~50
Roughly one per controller: company A cannot read or mutate company B's rows.
Leak paths past the guard
0
A tenant-owned table queried without a company predicate fails the build.
Enforcement point
Boundary
The controller resolves one tenant per request. There is no model-level default_scope.

Every figure above describes the code as built. The one that matters is the last: isolation is enforced at the request boundary and proven by a test guard that fails any query touching a tenant table without a company filter, rather than relying on a model-level scope that quietly does not cover every path.

Context

An app that had only ever known one customer

The platform is a warehouse and marketplace operations system. Staff scan books, the system prices them against market data, routes each physical copy through a processing pipeline, and fulfils orders across several marketplaces. It had grown over five years as a single-company tool: 99 tables, around 105 controllers, 45 Sidekiq jobs and a large surface of admin screens, every one of them written on the quiet assumption that there was only ever one company in the database.

Turning that into something that could hold multiple companies is not primarily a schema task. Adding company_id to the tables is the easy hour. The real work is the guarantee: on a system this size, with background jobs, raw SQL, nested associations, three admin panels and report queries, how do you make it structurally impossible for company A to ever see company B's stock, orders, credentials or price history?

A tenancy bug here is not a rendering glitch. It is a data breach between two businesses.

Why the obvious answer was rejected

The standard Rails answer is default_scope: attach a global scope to every model so every query is silently filtered by the current company. It is one line per model and it demos well. It was deliberately not used here, and the reasoning is written into the codebase so the next engineer does not undo it.

A default_scope leaks in ways that are invisible until they hurt. It rides along into associations, so order.items is scoped — but the moment you touch raw SQL, an unscoped block, a find_by_sql, a bulk import, or a third-party admin gem that builds its own queries, the scope is simply not there. It also silently narrows writes, and can produce the worst outcome of all: code that looks correct, passes a casual read, and quietly returns the wrong rows in exactly the paths nobody scoped by hand. On a codebase with three admin panels and heavy background processing, default_scope gives you the feeling of safety without the coverage.

The decision was to enforce tenancy at the one place every request must pass through — the controller boundary — and then to prove that enforcement with tests rather than trust it.

Approach

Enforcement at the one boundary every request crosses

One resolved tenant per request, and it is never ambient

The current company is resolved once, at the start of every request, from the URL. Routes are nested under scope '/companies/:company_id', and ApplicationController sets the current company from that segment. A user who is not a member of that company gets a 404, not a 403. This is deliberate: a 403 confirms the company exists, and confirming existence is itself a small leak. A non-member should not be able to tell the difference between "you may not see this" and "this is not here."

The resolved company lives in Current.company, an ActiveSupport::CurrentAttributes value, so it is available anywhere in the request without being threaded through every method signature. That convenience is also the danger, addressed below.

Stamping and reading, without a global scope

A Tenantable concern gives tenant-owned models two things: it auto-stamps company_id on create, so application code cannot forget to set it, and it provides an explicit for_company scope that callers opt into. There is no default_scope. Reads are scoped because the controller resolved the tenant and the query goes through for_company or a tenant-owned association — not because a hidden global filter is doing it for them.

Models that do not carry their own company_id, such as order line items, serials and shipping labels, reach the tenant through a parent association, so the isolation still holds one hop up without duplicating the column everywhere.

The Sidekiq trap, and the middleware that closes it

This is the failure mode that would have caused the actual breach. Sidekiq reuses worker threads across jobs, and Current attributes are thread-local. So if job one runs for company A and sets Current.company, and job two for company B lands on the same thread and reads Current.company before setting it, job two operates as company A. That is a cross-customer data leak with no error, no exception and nothing in the logs.

The fix is a TenantResetMiddleware registered in the Sidekiq server chain that resets Current after every single job, and a TenantScopedJob base class that carries the tenant explicitly into the job rather than assuming it survives from whatever ran before. The rule that came out of it: `Current` is a request-scoped convenience only, and any background code must be handed its tenant, never inherit it.

URLs that carry the tenant without touching every link

With routes nested under /companies/:company_id, every path helper in the app would ordinarily need the company id passed in by hand — across a route file of several hundred lines and every view that links anywhere. Instead, default_url_options injects the current company_id into every generated URL, so the existing link helpers kept working and the tenant segment appeared everywhere without a find-and-replace across the view layer.

Credentials are per-tenant, with no environment fallback

The single-tenant app read its marketplace, storefront and carrier credentials from around thirteen global environment variables. That model cannot become multi-tenant: one set of env vars means one set of credentials for everyone. Those were replaced with a per-company Integration store, encrypted at rest.

The important decision here is what was deliberately left out: there is no environment-variable fallback. If a company has not configured an integration, the code raises Integration::NotConfigured and stops. A fallback would have been convenient and catastrophic, because it would silently serve one tenant's credentials to another whenever a row was missing. Failing closed is the whole point.

Per-company API clients, so concurrent jobs cannot cross credentials

The marketplace SP-API client is configured process-globally by default. In a multi-tenant worker running jobs for different companies on different threads at the same time, mutating a global client is a race that leaks credentials between tenants. Instead a fresh client is constructed per call from that company's stored credentials, and access tokens are cached under a key that includes the company id, so two companies' jobs running concurrently can never share or overwrite each other's token. Two code paths — the gem and some raw HTTP calls — deliberately share one cache key by reproducing the gem's own token derivation, so the token is cached once and both paths benefit without stepping on each other.

An immutable-slug constraint nobody would guess

The company's slug is part of every file path in object storage. That makes the slug effectively immutable after creation: renaming it would orphan every stored file for that tenant. This is not obvious from the schema; it is the kind of constraint you only find by tracing how storage keys are built, and it is now validated in the model so a well-meaning rename cannot quietly detach a company from all its files.

Verification

Proving it, not trusting it

The deliberate absence of default_scope leaves a real risk: nothing at the ORM level stops someone writing an unscoped query. That gap is filled in the test suite rather than papered over in the models.

A query guard that fails the build

A custom test helper subscribes to sql.active_record notifications and fails a block unless every SELECT it issues against a tenant-owned table carries a company_id predicate. It is opt-in via an assert_company_scoped helper, with an explicit allowlist for the handful of tables that are legitimately global.

This is the piece that makes the whole architecture defensible: it converts "we were careful to scope our queries" into "a test fails the build if a query is not scoped." It exists specifically to cover the hole that skipping default_scope opens, which is exactly the right trade — keep the honest, leak-free query behaviour of no global scope, and buy back the safety net in the test layer where it belongs.

Around fifty scope specs, one per controller

Rather than a handful of representative tests, roughly one scope spec per controller asserts that a member of company A cannot read or mutate company B's rows through that controller. Shared contexts and shared examples keep those fifty files from being fifty copy-pastes. A node-coverage spec asserts every page in the permission tree is actually exercised, so a new screen cannot be added without a tenancy test.

Coverage is deliberately concentrated rather than uniform, and the page says so: effort went to the paths where a leak is expensive — tenant scoping, permissions, reservations, credentials. The point was never a coverage percentage. It was to make the specific failure of returning another company's data a build failure.

Field Notes

Findings worth writing down

Things that cost real time or would have caused real damage, written down so the next person does not rediscover them.

Current attributes survive between Sidekiq jobs

Thread-local state plus reused worker threads means a job can inherit the previous job's tenant. Without a reset-after-every-job middleware this is a silent cross-customer leak — no exception, nothing in the logs. Reset Current in the server middleware chain, and hand every job its tenant explicitly rather than letting it inherit one.

default_scope does not cover the paths that leak

It rides into associations but is absent from raw SQL, unscoped, bulk imports and third-party admin gems that build their own queries — which is precisely where an unnoticed leak lives. Enforcing at the controller boundary and proving it with a query guard covers those paths; a model scope only appears to.

Return 404, not 403, for non-members

A 403 confirms the resource exists. For a tenant boundary, "you may not see this" and "this does not exist" should be indistinguishable to someone who is not a member.

No environment-variable fallback for tenant credentials

A missing integration must fail closed and raise, never fall back to a global value, because the fallback silently serves one tenant's credentials to another the moment a row is missing.

The tenant slug is load-bearing in storage paths

Because the slug is baked into every object-storage key, it cannot be renamed without orphaning every file. Constraints like this do not appear in the schema; they only surface by tracing how keys are constructed, and they need to be enforced in code once found.

Global API clients are a multi-tenant race

Anything configured process-globally, like a marketplace API client, must be constructed per call in a multi-tenant worker, with any token cache keyed by company id — or concurrent jobs for different companies will cross credentials.

Caveats

Reading this honestly

The figures on this page describe the codebase as built: table counts, controller counts, the number of scope specs. They are not throughput or performance claims, and there is nothing to benchmark here. The single meaningful assertion is structural — tenant isolation is enforced at the request boundary and verified by a test guard that fails the build when a tenant-owned table is queried without a company predicate.

Coverage is not uniform and is not claimed to be. It is concentrated where a leak is costly, a deliberate allocation of effort rather than an oversight.

The client is unnamed and the domain kept general on purpose, and no detail here describes a live deployment's topology or where its safety net is thinnest. A case study should not double as a disclosure of another business's architecture.

Closing

What actually mattered

The instinct on a job like this is to add company_id, attach a default_scope and call it multi-tenant. That produces something that demos as isolated and is not, because the scope is absent from exactly the paths that leak. The work that actually mattered was choosing enforcement at the one boundary every request crosses, closing the non-obvious holes — Sidekiq's reused threads, global API clients, credential fallbacks, an immutable storage slug — and then refusing to trust any of it, wiring a test guard that turns "we scoped our queries carefully" into a build that fails when we did not.

The result is a five-year-old single-tenant application that now safely holds many companies, where the guarantee is enforced by structure and proven by tests, not assumed.

Back to all work