# 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:
```rust,ignore
pub enum CustomerScope {
All, // no filter — reads across all scopes
Only(PartnerId), // restricts every read to this scope value
}
impl From<PartnerId> for CustomerScope { /* => Only */ }
impl From<&PartnerId> for CustomerScope { /* => Only */ }
```
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.
## 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 `id(scope)` is the only
accepted entry; 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. Under `Only(p)` every query carries an
`id = p` conjunct, so reads collapse to **self-or-nothing**:
`find_by_id(Only(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
`Only` 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 `Only` arm.
```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 => Only
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 two static, compile-time-checked
SQL variants:
- `All` executes exactly the SQL an unscoped repo would.
- `Only(value)` executes a variant with an additional `partner_id = $n`
conjunct in every `WHERE` clause.
Both arms are plain equality predicates — sargable against a scope-column-led
index (see below). Under `Only`, 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.
## 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_*(Only(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` |
| `Only(a)` | none | `WHERE partner_id = a` |
| `Only(a)` | `b` | `WHERE partner_id = b AND partner_id = a` — **empty unless `a == b`** |
Under `Only`, 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:
- more than one `scope` column per repo
- an `Option<T>` or `nullable`-annotated scope column (nullable scope columns
are not supported — every row must belong to exactly one scope)
- 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
The `Only` arm adds a leading equality on the scope column to every read, so
composite indexes should lead with it:
```sql
-- list_by_created_at under Only(p)
CREATE INDEX ON customers (partner_id, created_at DESC, id DESC);
-- list_for_status_by_created_at under Only(p)
CREATE INDEX ON customers (partner_id, status, created_at DESC, id DESC);
-- find_by_email under Only(p)
CREATE INDEX ON customers (partner_id, email);
```
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`.