Tenant isolation
Concept
A tenant is a OpenLoam::Tenant row — one customer/organization/account in a
multi-tenant OpenLoam app. Every business record belongs to exactly one tenant,
and every business model is tenant-owned: it inherits OpenLoam::TenantRecord
(lib/open_loam/tenant_record.rb), which puts a default_scope on
OpenLoam::Current.tenant onto every query, assigns the current tenant
automatically on create, and validates tenant_id presence.
Isolation is row-level, not schema-per-tenant: one database, one
connection, tenant_id on every scoped table. The tradeoff and the
alternative considered are in ADR 0001.
Example
class Equipment < OpenLoam::TenantRecord
include OpenLoam::Auditable
include OpenLoam::Eventful
end
OpenLoam.as_tenant(acme) do
Equipment.create!(name: "Excavator")
Equipment.count # => 1 — scoped to acme
end
OpenLoam.as_tenant(globex) do
Equipment.count # => 0 — a different tenant, a different world
end
OpenLoam.as_tenant(tenant, actor: nil) { } (lib/open_loam.rb) is the one blessed
way to establish or switch tenant context — it sets OpenLoam::Current.tenant
(and actor, if given), yields, and restores the previous context afterward,
even on exception. A controller sets it once per request (typically from the
signed-in user’s chosen membership); a background job sets it explicitly,
since there’s no request to inherit it from.
Failure mode
Touch a tenant-scoped model with no tenant in context, and it raises immediately — it does not return an empty relation, and it does not widen to every tenant’s rows:
OpenLoam::Current.reset
Equipment.count
# => OpenLoam::MissingTenantError:
# No tenant set in OpenLoam::Current — wrap this call in OpenLoam.as_tenant(tenant) { ... }
That’s OpenLoam.tenant! (lib/open_loam.rb) under the hood — Current.tenant or raise
MissingTenantError — which every tenant-scoped code path calls. There is no
silent fallback. A write path fails the same way: TenantRecord’s before_save
callback checks tenant_id != OpenLoam.tenant!.id and raises MissingTenantError
again if a record is ever about to be saved into a foreign tenant.
Background jobs don’t get a request to inherit tenant context from, so a
job body must wrap its work in OpenLoam.as_tenant(tenant, actor:) explicitly —
omitting it doesn’t leak, it just raises the moment the job touches a
tenant-scoped model, which surfaces the bug in a job-failure alert instead of
a cross-tenant read.
Safe system-level access
Model.unscoped is the escape hatch — deliberately the standard Rails
spelling, so it’s trivially greppable in review. It is reserved for a short,
named list of vetted gem-internal call sites that have a real cross-tenant
reason to exist (documented in lib/open_loam/tenant_record.rb and the guardrail
test below): token authentication, SSO home-realm discovery by email domain,
and the scheduler’s tick runner (which has no tenant of its own — it scans
across tenants to find due jobs). Host-app code never needs it: business logic
runs inside OpenLoam.as_tenant.
Prohibited bypasses
- Never call
.unscopedon a tenant-scoped model from app code. The guardrail testtest/open_loam_guardrails_test.rb(installed byopen_loam:install) grepsapp/**/*.rbfor\bunscoped\band fails the build if it finds one outside the gem’s own vetted call sites. - Never rescue
OpenLoam::MissingTenantError. It firing means a bug upstream — a missingOpenLoam.as_tenantwrapper — not a condition to handle gracefully. AGENTS.md states this as an invariant an agent must not break. - Every business model must inherit
OpenLoam::TenantRecord. The same guardrail test eager-loads the app and asserts everyActiveRecord::Basedescendant is either abstract, framework plumbing (ActiveStorage, ActionText, …), on a short explicit allowlist (OpenLoam::Config,OpenLoam::MfaCredential,User— things that are legitimately global or cross-tenant by design), or<= OpenLoam::TenantRecord.
Why OpenLoam behaves this way
A missing tenant context is the single most dangerous failure mode in a multi-tenant app — silently widening a query means one tenant’s data leaks into another’s page, API response, or export. OpenLoam’s structural answer: make that failure loud and immediate (a raised exception, caught by a test) rather than quiet and gradual (a query that happens to work in dev because there’s only one tenant, and leaks in production because there’s two). See ADR 0001 for the full reasoning and the schema-per-tenant alternative it rejects.
This is also the property an internal benchmark measured directly: across ten AI-agent-implemented tasks, tenant isolation held on all ten OpenLoam apps and on one of ten hand-rolled vanilla-Rails apps given the identical prompts. (Same model family built both sides — see the benchmark page for the full caveats.)
Agent guidance
- Business models:
class Thing < OpenLoam::TenantRecord, neverApplicationRecord. - Don’t write raw SQL against a tenant table without a
tenant_idpredicate. - In a background job, wrap tenant-scoped work in
OpenLoam.as_tenant(tenant, actor:)— there’s no request to inherit context from. - If a guardrail test fails on
.unscopedor a non-TenantRecordmodel, that is the intended outcome of stepping outside the convention — fix the model, don’t loosen the test. - In tests,
with_tenant(tenant, actor:) { }(fromOpenLoam::TestHelpers, wired in byopen_loam:install) is the test-suite equivalent ofOpenLoam.as_tenant.
Related pages
- Authorization — a different question: not whose data, but who may act on it.
- Guardrails — how these invariants are enforced as tests, not just documentation.
- ADR 0001: Row-level tenancy
- Golden tasks — the measured result.