# 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`:
```rust,ignore
#[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):
```rust,ignore
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 = "...")`:
```rust,ignore
#[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).
## 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 implicit id column:
```rust,ignore
#[derive(EsRepo)]
#[es_repo(
entity = "Partner",
columns(
id(scope),
name(ty = "String", list_by),
)
)]
pub struct Partners {
pool: PgPool,
}
```
The id column is macro-owned — its type comes from the repo-level `id`
attribute and `find_by`/`list_by` are always on — so the only accepted
entries are `id(scope)` and its variant-override form,
`id(scope(variant = "..."))`; anything else (`ty`, `find_by = ...`) is a
compile error.
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:
```rust,ignore
#[derive(EsRepo)]
#[es_repo(
entity = "Partner",
columns(
id(scope(variant = "Tenant")),
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.
```
```rust,ignore
// 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:
```rust,ignore
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`:
- `All` executes exactly the SQL an unscoped repo would.
- Each column's variant (e.g. `PartnerId(value)`) executes a variant with an
additional `partner_id = $n` conjunct in every `WHERE` clause.
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:
```rust,ignore
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:
```rust,ignore
#[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.
## 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`:
```rust,ignore
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:
| `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.
```rust,ignore
// 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 Rust types are not pairwise distinct (the generated
`From<T>` conversions dispatch on type, so two same-typed scope columns
would produce conflicting impls)
- scope columns whose variant names collide with each other or with the
reserved `All` variant — whether the name is the UpperCamel default or an
explicit `scope(variant = "...")` override
- a `scope(variant = "...")` value that isn't a valid Rust identifier
- an `Option<T>` or `nullable`-annotated scope column (nullable scope columns
are not supported — every row must belong to exactly one scope, in every
dimension)
- a `Forgettable<T>` scope column
- `scope` on 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:
```sql
-- 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`.