ADR-0018: Tenant Isolation Model¶
Status¶
Accepted. Implemented in src/eventsource/multitenancy/ (context.py, events.py,
repository.py, exceptions.py). This ADR describes the model as it exists today,
including the parts that are deliberately incomplete.
Amended by ADR 0038 (the four modules named above relocate to
domain/tenant_context.py, domain/tenant_events.py,
application/aggregates/tenant_repository.py, and a merge into
domain/exceptions.py; the isolation model and Decision described below
are unchanged).
Amended by ADR 0057 (§6's enforce_on_load is renamed
require_tenant_context, and §6's argument for why filtering was not
implemented cites an EventStore interface that no longer exists; the
conclusion -- no tenant filtering on the aggregate-load path, read isolation
delegated to the database per §7 -- is restated there on current grounds and
stands).
Context¶
What multi-tenancy has to solve in an event-sourced system¶
A multi-tenant event-sourced application has to answer three questions, and they are not the same question:
- Attribution — which tenant does this event belong to? In an append-only log,
attribution is permanent: an event written with the wrong
tenant_idis a permanent record of the wrong thing, and correcting it means writing a compensating event, not anUPDATE. - Write isolation — can a request acting for tenant A append events attributed to tenant B? This is the leak that corrupts the log.
- Read isolation — can a request acting for tenant A replay a stream belonging to tenant B? Aggregate ids are UUIDs, so this requires either guessing an id or holding a stale reference — but "hard to guess" is not an isolation boundary.
Attribution and write isolation are properties of the event and the write path, and the library can own them. Read isolation is a property of every query against the store, including queries the library never sees (projection rebuilds, operational SQL, ad-hoc reporting). That asymmetry drives most of what follows.
Constraints we accepted going in (async-first, backend-agnostic stores, optional tenancy on the base DomainEvent)¶
Three pre-existing commitments constrained the design:
- Async-first. All store, bus, and repository interfaces are
async. Any mechanism for carrying "who is this request for" has to surviveawaitboundaries and stay isolated between concurrently running tasks. - Backend-agnostic stores.
EventStorehas PostgreSQL, SQLite, and in-memory implementations, plus whatever users write. Any change to the read interface has to be implementable — and enforced — by all of them. The one tenant-aware seam that does exist,ReadOptions.tenant_idon the streaming read paths (read_stream,read_all) and thetenant_idparameter onget_events_by_type(), is a filter the caller opts into, not an isolation guarantee the store enforces. - Tenancy is optional.
DomainEvent.tenant_idis declared asUUID | None = Field(default=None, ...)inevents/base.py. Single-tenant users are the majority case and must not pay for multi-tenancy. This was settled before this ADR, and it is the constraint that makes several decisions below "silently permissive" rather than "strict".
Decision¶
1. Tenancy is ambient, carried by a ContextVar, not passed as a parameter¶
tenant_context: ContextVar[UUID | None] with default None¶
multitenancy/context.py declares a single module-level context variable:
The default of None means "no tenant context" is a valid, non-exceptional state.
Nothing about importing the multitenancy package changes the behavior of code that never
sets it.
Scoping via tenant_scope() / tenant_scope_sync() and Token-based reset¶
set_current_tenant(tenant_id) returns the Token[UUID | None] from
ContextVar.set(), and the two context managers — tenant_scope() (async) and
tenant_scope_sync() (sync) — set the variable on entry and reset(token) on exit.
Token-based reset rather than "set back to None" is what makes nesting correct: an
inner scope restores the outer tenant, not the absence of one. Reset happens in a
finally, so an exception inside the scope still restores the previous tenant.
clear_tenant_context() exists for manual request-boundary teardown, but it does
tenant_context.set(None) — it does not restore a previous value. Prefer the scope
managers.
Propagation semantics: inherited by tasks spawned inside a scope, isolated per concurrent task and per thread¶
This is standard contextvars behavior, and the unit tests in
tests/unit/multitenancy/test_context.py pin it:
- A task created with
asyncio.create_task()inside a scope copies the context at creation time and sees the tenant. - Concurrently running tasks that each enter their own scope do not observe each other's
tenant, even when interleaved by
await. - A tenant set inside a task does not leak back to the spawning context after the task completes.
The same isolation applies across threads: each thread has its own context, so a tenant
set on the event loop thread is not visible inside run_in_executor work.
get_current_tenant() (nullable) vs get_required_tenant() (raises TenantContextNotSetError)¶
Two accessors, deliberately:
get_current_tenant() -> UUID | Nonenever raises. It is for code that behaves differently with and without a tenant — for example a projection's dynamictenant_filter.get_required_tenant() -> UUIDraisesTenantContextNotSetErrorwhen unset. It is for code that has no correct behavior without a tenant. Every enforcement point in this design calls this one.
Having both means "tenancy is optional" and "this operation requires a tenant" are expressed at the call site rather than by convention.
2. Tenancy on events is opt-in at the type level via TenantDomainEvent¶
Base DomainEvent.tenant_id stays optional; TenantDomainEvent narrows it to a required UUID¶
TenantDomainEvent subclasses DomainEvent and redeclares the field:
Pydantic field overriding does the work: the type narrows from UUID | None to UUID
and the default disappears, so constructing the event without a tenant is a
ValidationError at the model boundary rather than a runtime surprise three layers
down. Single-tenant users keep the permissive base class; multi-tenant users get the
strict one by inheritance, with no configuration flag involved.
with_tenant_context() auto-populates from context; an explicit tenant_id kwarg always wins¶
The classmethod is intentionally minimal:
An explicit tenant_id always takes precedence over the ambient scope. This is the
escape hatch for admin and migration tooling that legitimately writes on behalf of a
tenant other than the one bound to the current scope — and it is also the hole that
Decision 4 exists to close on the write path, since an explicit mismatched tenant_id
will be rejected by TenantAwareRepository.save().
3. Enforcement lives in a composition wrapper, not in the store¶
TenantAwareRepository wraps AggregateRepository rather than subclassing or modifying EventStore¶
TenantAwareRepository[TAggregate] holds an AggregateRepository[TAggregate] and
delegates save, load, exists, load_or_create, and create_new. It is a plain
Generic, not a subclass of AggregateRepository, and it exposes the wrapped instance
via a repository property so callers can still reach snapshot and publishing
configuration.
Composition rather than inheritance means the enforcement layer is opt-in per repository,
is trivially removable, and cannot drift when AggregateRepository gains behavior.
It also means it is not a drop-in AggregateRepository for type-checking purposes —
code annotated against AggregateRepository will not accept the wrapper.
validate_on_save=True by default; enforce_on_load=False by default¶
Both flags are keyword-only. The defaults encode the honest asymmetry of this design: the write path is enforced because the library controls it, and the read path is not, because the library cannot enforce it (see Decision 6). Choosing the wrapper at all gets you write protection; read protection is something you configure elsewhere.
4. Validation happens on write only¶
_validate_tenant_consistency() compares each uncommitted event's tenant_id against get_required_tenant() before delegating to save()¶
When validate_on_save=True, save() calls _validate_tenant_consistency(aggregate)
before await self._repository.save(aggregate). That method resolves
get_required_tenant() first — so saving with no tenant context raises
TenantContextNotSetError even when every event is fine — then iterates
aggregate.uncommitted_events, reading each event's tenant with
getattr(event, "tenant_id", None).
Validation is pre-persistence and all-or-nothing: nothing is appended if any event mismatches.
Mismatches raise TenantMismatchError carrying expected, actual, and the offending event_ids¶
The scan collects all mismatched event_ids but reports only the first mismatched
tenant as actual. The raised TenantMismatchError exposes .expected, .actual, and
.event_ids as attributes, and formats the first five ids into the message with an
"... and N more" suffix. A logger.warning is emitted at the same point, so a mismatch
is visible in operational logs even if the caller swallows the exception.
5. Events without a tenant_id silently pass validation¶
Rationale: mixed tenant/non-tenant event streams, incremental adoption, and migration from single-tenant deployments¶
The loop skips events whose tenant is None:
Three cases make this the pragmatic choice. Aggregates may legitimately emit
system-level events with no tenant. Applications adopting multi-tenancy convert event
types one at a time and need the un-converted ones to keep working. And single-tenant
deployments migrating to multi-tenant have an entire history of tenant_id=None events
that must remain replayable and re-savable.
Consequence: a typo'd or plain DomainEvent subclass bypasses isolation entirely — use TenantDomainEvent to make the field non-optional¶
The cost is that permissiveness is the failure mode. An event class that inherits
plain DomainEvent and is constructed without a tenant_id will be written inside any
tenant scope without complaint. There is no warning, no log line, and no counter. The
only defense is the type system: derive multi-tenant event types from
TenantDomainEvent so the field cannot be omitted, and treat any multi-tenant event
class inheriting DomainEvent directly as a review finding.
6. enforce_on_load is deliberately incomplete: it asserts context, it does not filter¶
What it actually does on load(), exists(), and load_or_create() — calls get_required_tenant() and nothing more¶
With enforce_on_load=True, each of these three methods calls get_required_tenant()
(raising TenantContextNotSetError when unset), logs at debug in the load() case, and
then delegates unchanged. The resolved tenant is not passed to the repository, not
passed to the store, and not compared against the events that come back.
The guarantee is therefore precisely: this read happened inside a tenant scope. It is
not: this read returned only that tenant's events. Given an aggregate id belonging to
tenant B, a load() inside tenant A's scope succeeds and returns B's aggregate.
Why filtering was not implemented in the wrapper (would require a tenant_id parameter on EventStore read paths across every backend)¶
The wrapper cannot fix this by itself. AggregateRepository.load() reads through
EventStore.get_events(aggregate_id, aggregate_type=..., from_version=...), which has
no tenant parameter — tenant filtering exists on the newer streaming paths
(ReadOptions.tenant_id, consumed by read_stream/read_all, and get_events_by_type)
but not on the aggregate-load path. Adding it there would mean changing the EventStore
interface, implementing and testing the filter in PostgreSQL, SQLite, and in-memory
backends, and breaking every third-party implementation — and doing it in a way that
also covers snapshot loading, which restores state that was itself derived from events.
Filtering after the fact in the wrapper was rejected as worse than nothing: discarding mismatched events would silently produce a partially replayed aggregate, which is a more dangerous object than a correctly replayed one belonging to another tenant.
create_new() is unguarded by design (in-memory only, no persistence)¶
create_new() delegates straight through with no tenant check even when
enforce_on_load=True. It allocates an in-memory aggregate at version 0 and touches no
storage; the tenant check that matters happens when the resulting events are saved.
7. Read isolation is delegated to the database layer¶
PostgreSQL Row-Level Security as the intended mechanism¶
Because read isolation must hold for every query — including projection rebuilds,
maintenance scripts, and analytics that never pass through TenantAwareRepository — it
belongs below the library, at the database. PostgreSQL Row-Level Security enforces the
predicate inside the engine, so a query that forgets the WHERE clause returns nothing
rather than everything.
What operators must configure themselves; what the library does not do for you¶
The library ships no RLS support: no policy DDL in migrations/, no session-variable
wiring, no connection-level SET of a tenant GUC from tenant_context. Operators who
want read isolation must enable RLS on the events table themselves, author policies
against their own tenant column, ensure the application role is not BYPASSRLS (note
that table owners bypass RLS unless FORCE ROW LEVEL SECURITY is set), and propagate
the current tenant into each session or transaction. SQLite and in-memory stores have no
equivalent mechanism at all — treat them as development and test backends in
multi-tenant applications.
Security Consequences¶
Threat: cross-tenant read via a shared aggregate id when RLS is not configured¶
The default configuration (enforce_on_load=False) and the strict one
(enforce_on_load=True) provide the same read isolation: none. Any code path that can
supply an aggregate id belonging to another tenant — an IDOR in a route handler, a stale
id in a webhook payload, a foreign key copied between environments — reads that
aggregate successfully. If the database layer is not enforcing the boundary, no layer is.
Threat: unset context in background tasks, retries, and subscription runners¶
ContextVar inheritance is by task creation, not by logical relation. Work that is
enqueued and executed later — an outbox drain, a retry after a delay, a subscription
runner that consumes from a queue, anything handed to a thread pool — runs with an empty
tenant context unless the tenant is captured with the work item and re-entered via
tenant_scope() on the far side. Under validate_on_save=True this fails loudly with
TenantContextNotSetError, which is the good outcome. The dangerous variant is a
long-lived worker that entered a scope once and processes items for several tenants
inside it: every write then validates against the wrong, stale tenant. Enter the scope
per work item, not per worker.
Threat: non-tenant events written inside a tenant scope¶
Following directly from Decision 5: a DomainEvent subclass with no tenant_id written
inside tenant A's scope produces a permanent, unattributed record. It will pass a
projection's tenant filter as well (projections/base.py processes events with no
tenant_id regardless of filter, for the same legacy-compatibility reason), so it will
appear in every tenant's read model. Because the log is append-only, this cannot be
corrected by an update — only by a data migration or compensating events.
Non-goals: this model is not a substitute for authorization¶
The tenant context answers "on whose behalf is this running", not "is this actor allowed
to do this". Nothing here authenticates the tenant id, checks that the caller may act
for it, or distinguishes roles within a tenant. The value in tenant_context is exactly
as trustworthy as the code that called set_current_tenant() — typically middleware
reading a validated token. Authorization remains entirely the application's
responsibility.
Alternatives Considered¶
Explicit tenant_id parameter threaded through every store and repository call¶
Rejected. It is the most auditable option — the tenant is visible at every call site and
cannot be silently absent — but it changes every public read and write signature, forces
single-tenant users to pass None everywhere, and turns every intermediate helper into a
tenant-forwarding function. In an async codebase the ambient mechanism also composes with
middleware in a way parameters do not. The chosen model keeps this option available
locally: get_required_tenant() at a boundary converts ambient back to explicit.
Tenant-per-schema or tenant-per-database physical separation¶
Rejected as a library-level model. Physical separation gives the strongest isolation and would have made Decisions 4-7 unnecessary, but it pushes connection routing, per-tenant migration execution, and cross-tenant querying into the library, and it scales poorly past a few hundred tenants. It remains a perfectly good deployment choice — nothing here prevents running one event store per tenant, in which case the wrapper is redundant.
Enforcing filtering inside EventStore implementations¶
Rejected for now, and it is the strongest of the alternatives. Enforcement at the store
would cover every read the library performs, including projection and subscription
paths. The cost is the one described in Decision 6: an interface change across every
backend, a correctness question for snapshots, and a hard break for third-party
implementations. The partial version already exists in ReadOptions.tenant_id for the
streaming paths, which is filtering the caller opts into, not isolation the store
enforces. Should this be revisited, the interface change is the whole of the work.
Rejecting events that lack a tenant_id¶
Rejected as the default, for the migration and mixed-stream reasons in Decision 5. Note
there is no flag for it: validate_on_save toggles between "validate events that have a
tenant" and "validate nothing". Applications wanting strict rejection get it structurally
by deriving from TenantDomainEvent, which makes the omission impossible rather than
merely detectable.
Adoption Guidance¶
Recommended configuration for new multi-tenant applications¶
For a new application with no legacy history, use every layer of the model at once:
- Derive all domain events from
TenantDomainEventand construct them withwith_tenant_context(). - Wrap each
AggregateRepositoryinTenantAwareRepository(repo, enforce_on_load=True, validate_on_save=True). - Enter
tenant_scope()once per request in middleware, and again per work item in every background worker, retry path, and subscription handler. - Configure PostgreSQL RLS on the events table. Without it,
enforce_on_load=Truebuys you a missing-context assertion and nothing more.
Relaxed configuration during migration (validate_on_save=False)¶
When converting a single-tenant deployment, TenantAwareRepository(repo,
validate_on_save=False) delegates save() with no checks at all, letting you install
the wrapper before the events and the context plumbing are ready. Treat it as a
temporary state and sequence out of it: introduce tenant_scope() at request
boundaries, convert event types to TenantDomainEvent one aggregate at a time, then flip
validate_on_save back to True — at which point mismatches and missing context become
loud. Turning on enforce_on_load=True is the last step, and it is only meaningful once
RLS is in place.
Related Documents¶
Stores, subscriptions, and projections tenant-filter docs (cross-reference this ADR rather than restating the model)¶
docs/guides/multi-tenant.md— the task-oriented walkthrough of wiring context, events, and the repository wrapper together.docs/api/projections.mdandsrc/eventsource/projections/README.md— thetenant_filterparameter (staticUUIDor callable such asget_current_tenant) and its own legacy-event pass-through rule.src/eventsource/subscriptions/README.mdandsrc/eventsource/stores/—ReadOptions.tenant_idon the streaming read paths.
Those documents describe mechanisms; the reasoning for why enforcement is where it is, and why the read path is incomplete, lives here and should be linked rather than duplicated.
ADR-0002 (Pydantic Event Models), ADR-0005 (API Design Patterns)¶
ADR-0002 explains why DomainEvent is a frozen Pydantic model — the reason
TenantDomainEvent can narrow tenant_id from UUID | None to UUID by redeclaring
the field, and why validation of that field happens at construction rather than at save
time. ADR-0005 covers the API conventions this design follows: keyword-only configuration
flags with conservative defaults, composition wrappers over subclassing, and paired
nullable/raising accessors like get_current_tenant() / get_required_tenant().