Scoped Repositories
Multi-tenant applications need data access that is impossible to misuse: a query that forgets to filter by the tenant column is a silent cross-tenant leak that compiles and passes all same-tenant tests. Scoped repositories make that mistake unrepresentable — on a scoped repo, every generated read function requires a scope argument, enforced by the compiler.
Declaring a scope column
Mark exactly one column with scope:
#[derive(EsRepo)]
#[es_repo(
entity = "Customer",
columns(
partner_id(ty = "PartnerId", scope),
email(ty = "String"),
)
)]
pub struct Customers {
pool: PgPool,
}
partner_id remains an ordinary persisted column — it is populated on
create from the NewCustomer’s field like any other column. The scope
marker additionally generates an entity-named scope enum, with one variant
per scope column (UpperCamel of the column name):
pub enum CustomerScope {
All, // no filter — reads across all scopes
PartnerId(PartnerId), // restricts every read to this scope value
}
impl From<PartnerId> for CustomerScope { /* => PartnerId */ }
impl From<&PartnerId> for CustomerScope { /* => PartnerId */ }
There is deliberately no From<Option<PartnerId>>: mapping None to
All would turn a stray None into silent all-scope access. All-scope reads
must be written explicitly — CustomerScope::All is greppable and auditable.
Overriding the variant name
The default variant name is UpperCamel of the column name. When that reads
awkwardly, or you’d rather the enum spell out the principal kind than the
column, override it with scope(variant = "..."):
#[derive(EsRepo)]
#[es_repo(
entity = "CreditFacility",
columns(
partner_id(ty = "PartnerId", scope(variant = "Partner")),
customer_id(ty = "CustomerId", scope),
status(ty = "String"),
)
)]
pub struct CreditFacilities {
pool: PgPool,
}
// generates:
pub enum CreditFacilityScope {
All,
Partner(PartnerId), // overridden — reads "as a partner", not "by partner_id"
CustomerId(CustomerId), // default — UpperCamel of the column name
}
The override only renames the enum variant and its From impl target — the
underlying SQL conjunct is still keyed off the real column (partner_id = $n).
Overridden and defaulted scope columns compose freely on the same repo.
Variant names, whether defaulted or overridden, must still be pairwise
distinct and may not collide with the reserved All variant (see
“Validation rules” below).
Nullable scope columns
A scope column’s Rust type may be Option<T> — the generated variant still
carries the bare inner type, never Option<T>:
#[derive(EsRepo)]
#[es_repo(
entity = "Party",
columns(
customer_id(ty = "Option<CustomerId>", scope(variant = "Customer")),
name(ty = "String"),
)
)]
pub struct Parties {
pool: PgPool,
}
// generates:
pub enum PartyScope {
All,
Customer(CustomerId), // not Customer(Option<CustomerId>)
}
The SQL conjunct is unchanged: customer_id = $n. Since SQL = never matches
NULL, a row whose customer_id is NULL is invisible to every scoped
variant — only All sees it — with no special-casing anywhere. Use this when
NULL genuinely means “this row is unowned”, not as a stand-in for “not yet
migrated”: a backfill that leaves rows NULL makes them silently disappear from
every scoped read. There is deliberately no From<Option<CustomerId>> — a
scope value is always concrete, so PartyScope::Customer(None) is unspellable
— and a column marked nullable (the opt-in flag for a non-Option Rust type
backed by a nullable SQL column) is still rejected as a scope column, since
there is no inner type to unwrap.
Tenancy roots: id(scope)
The tenancy-root entity itself — the Partner in a partner-scoped system —
has no separate tenant column: its rows’ scope value is their own id. For
that case, mark the id column as the scope with the repo-level id attribute:
#[derive(EsRepo)]
#[es_repo(
entity = "Partner", id(scope),
columns(
name(ty = "String", list_by),
)
)]
pub struct Partners {
pool: PgPool,
}
The id is never declared inside columns(...); see
id for the full id attribute.
The generated surface is the ordinary scoped one: a PartnerScope enum and a
leading scope argument on every read. Since the id column’s scope variant
defaults to Id, every query under Id(p) carries an id = p conjunct, so
reads collapse to self-or-nothing: find_by_id(Id(a), b) is NotFound
unless a == b, find_all intersects down to at most the own row, and every
list returns the own row or nothing. Unlike ordinary scope columns, the id
column keeps its point-read — under Id(_) it is simply double-specified,
composing exactly like a caller filter on the scope column (see below). No
scope-led composite index is needed: the primary key already serves the Id
arm.
Like an ordinary scope column, the default Id variant name can be
overridden:
#[derive(EsRepo)]
#[es_repo(
entity = "Partner", id(scope(variant = "Tenant")),
columns(
name(ty = "String", list_by),
)
)]
pub struct Partners {
pool: PgPool,
}
// generates PartnerScope::Tenant(PartnerId) instead of PartnerScope::Id(PartnerId);
// everything else (self-or-nothing collapse, no extra index needed, `All`) is unchanged.
// authz-derived scope: a tenant subject reads itself or nothing,
// an all-access subject reads any row
let scope: PartnerScope = self.authz.enforce_permission(sub, obj, act).await?.into();
self.partners.maybe_find_by_id(scope, id).await?
The scoped read surface
Every generated read function gains a leading scope: impl Into<{Entity}Scope>
argument:
customers.find_by_id(partner_id, id).await?; // Into => PartnerId
customers.find_by_id(CustomerScope::All, id).await?; // explicit escape hatch
customers.maybe_find_by_email(partner_id, email).await?;
customers.find_all::<Customer>(partner_id, &ids).await?;
customers.list_by_created_at(partner_id, args, direction).await?;
customers.list_for_filters(partner_id, filters, sort, args).await?;
customers.find_by_id(id).await?; // does not exist — compile error
At runtime each function dispatches between static, compile-time-checked SQL
variants — one per scope column plus All:
Allexecutes exactly the SQL an unscoped repo would.- Each column’s variant (e.g.
PartnerId(value)) executes a variant with an additionalpartner_id = $nconjunct in everyWHEREclause.
Every arm is a plain equality predicate — sargable against a scope-column-led
index (see below). Under any scoped variant, a row from another scope behaves
exactly like a missing row: find_by_* returns NotFound, maybe_find_by_*
returns None, find_all silently omits the id, and lists never contain the
row. Missing and not-yours look identical.
The bound view: repo.scoped(scope)
When a request performs several reads under one subject, threading the scope
into every call gets repetitive. Scoped repos additionally generate a
bound view — Scoped{Repo} — that captures the scope once:
let customers = self.customers.scoped(sub.scope()); // ScopedCustomers<'_>
customers.find_by_id(id).await?; // no per-call scope arg
customers.maybe_find_by_email(email).await?;
customers.list_by_created_at(args, direction).await?;
customers.find_by_id_in_op(&mut op, id).await?; // _in_op variants too
customers.scope(); // the bound CustomerScope
Every view method simply delegates to the corresponding scope-argument fn
with the bound scope — no new SQL, identical semantics. The view borrows
the repository (ScopedCustomers<'a> holds &'a Customers), so it is
naturally request-scoped: it cannot be stored beyond the repo borrow, which
keeps a bound all-access or tenant view from quietly outliving the request
that justified it.
Multiple scope dimensions
More than one column may be marked scope. This models systems with several
independent principal kinds reading the same entity — e.g. an admin API, a
partner API, and a customer API over the same CreditFacility rows:
#[derive(EsRepo)]
#[es_repo(
entity = "CreditFacility",
columns(
partner_id(ty = "PartnerId", scope),
customer_id(ty = "CustomerId", scope),
status(ty = "String", list_for(by(created_at))),
)
)]
pub struct CreditFacilities { pool: PgPool }
// generates:
pub enum CreditFacilityScope {
All, // admin — audited escape hatch
PartnerId(PartnerId), // partner API
CustomerId(CustomerId), // customer API
}
The dimensions are disjunctive: a single read is scoped by exactly one
column or by All — never by more than one column at once. Each dimension
gets its own dispatch arm carrying only that column’s conjunct; there is no
combined PartnerId-and-CustomerId variant. If a future consumer needs a
read scoped by both dimensions simultaneously (e.g. “a partner acting on
behalf of one specific customer”), that is out of scope for this feature and
would need a dedicated combined variant.
Every read fn’s dispatch grows linearly with the number of scope columns —
N scope columns plus All means N+1 static, compile-time-checked query
variants, all still plain sargable equalities.
Each scope dimension needs its own scope-led composite index; they are
matched against the physical index catalog independently (see “Index
requirements” below). A repo may have partner-led composite indexes but no
customer-led ones yet — the partner dimension’s list_for_filters arm
specializes while the customer dimension’s arm honestly falls back to the
non-sargable COALESCE query until its indexes exist.
Scope column Rust types must be pairwise distinct (the generated From<T>
conversions dispatch on type — two same-typed scope columns would produce
conflicting impls) and their UpperCamel variant names must not collide with
each other or with the reserved All variant.
Writes are custody-guarded
create, create_all, update, update_all and delete keep their
unscoped signatures. The reasoning: mutations operate on an entity value that
could only have been obtained through a scoped read (or built by domain logic
that stamped the scope column). Scope enforcement happens at the boundary that
turns ids and queries into entity data; once you hold the entity, custody of
the value is the guarantee.
create_or_find_in_op also keeps the unscoped signature, but it only returns
a stored entity in the new entity’s own scope; see
create_or_find_in_op.
Cursors carry no filter authority
Pagination cursors are position markers only. Every page executes with the
scope conjunct in its own WHERE clause, so a tampered, fabricated, or
foreign cursor can only reposition pagination within the caller’s own scoped
rows — it can never widen the result set, and cursor values are compared, not
dereferenced, so they cannot be used to probe for the existence of foreign
ids. Replaying a cursor minted under a different scope yields well-defined
(scoped) but position-shifted results.
Filtering on the scope column
By default the scope column generates no query surface of its own — every
read is already filtered by it, and per-scope listing is the ordinary
scoped list_by_*(PartnerId(value), ..). But some callers legitimately filter by
the scope column through the normal query surface: an all-access admin
listing that narrows to one tenant, for example. The scope value itself
(typically authz-derived) must never be touched by caller input — the
caller’s choice belongs in the Filters struct like any other filter.
For that, the scope column may opt into find_by = true, list_by or
list_for:
partner_id(ty = "PartnerId", scope, find_by = true, list_for(by(created_at))),
This generates the usual fns (find_by_partner_id, list_for_partner_id_by_*)
and includes partner_id: Option<PartnerId> in the generated Filters
struct. The caller value composes with the scope — it can narrow, never
widen:
| Scope | Caller value | Result |
|---|---|---|
All | none | unfiltered |
All | p | WHERE partner_id = p |
PartnerId(a) | none | WHERE partner_id = a |
PartnerId(a) | b | WHERE partner_id = b AND partner_id = a — empty unless a == b |
Under a scoped variant, the column is simply double-specified — once as the
caller’s filter, once as the scope conjunct, exactly like any other filter
column. A mismatching caller value is a contradictory predicate that honestly
returns an empty result (NotFound/None for find_by_*) instead of being
silently ignored — a caller filter can narrow but never widen the scope.
Both predicates are plain equalities, so the query stays sargable against a
scope-led index.
// admin listing: scope from authz, partner choice from the request
let scope = self.authz.enforce_permission(sub, obj, act).await?; // untouched
self.repo
.list_for_filters(
scope,
CustomerFilters { partner_id: request.partner_id, ..Default::default() },
sort,
args,
)
.await?
Validation rules
The macro rejects at compile time:
- scope columns whose types are not pairwise distinct, compared on the type
that lands in the generated enum — the inner type, for an
Option<T>scope column (the generatedFrom<T>conversions dispatch on that type, so two scope columns that resolve to the same type would produce conflicting impls) - scope columns whose variant names collide with each other or with the
reserved
Allvariant — whether the name is the UpperCamel default or an explicitscope(variant = "...")override - a
scope(variant = "...")value that isn’t a valid Rust identifier - a
nullable-annotated scope column whose Rust type is not syntacticallyOption<T>(see Nullable scope columns above — there is no inner type to unwrap, and scoping by the NULL-encoding value would silently match no rows) - a
Forgettable<T>scope column scopeon nested repos — children are custody-guarded via their (scoped) parent
Without an explicit opt-in (see above) the scope column generates no
find_by_partner_id accessors: scope flips the column’s find_by default
to false, and the scope argument replaces them.
Index requirements
Each scoped variant’s arm adds a leading equality on its scope column to every read, so composite indexes should lead with it:
-- list_by_created_at under PartnerId(p)
CREATE INDEX ON customers (partner_id, created_at DESC, id DESC);
-- list_for_status_by_created_at under PartnerId(p)
CREATE INDEX ON customers (partner_id, status, created_at DESC, id DESC);
-- find_by_email under PartnerId(p)
CREATE INDEX ON customers (partner_id, email);
With multiple scope dimensions, each one needs its own composites — a
customer-scoped read rides a (customer_id, ...)-led index, independent of
whatever partner-led indexes exist. Plain single-column indexes keep working
(Postgres can still apply the scope conjunct as an index qual or filter), but
scope-led composites let the paginated lists ride the index order with an
early-exit LIMIT.