Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  • 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:

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:

ScopeCaller valueResult
Allnoneunfiltered
AllpWHERE partner_id = p
PartnerId(a)noneWHERE partner_id = a
PartnerId(a)bWHERE 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 generated From<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 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
  • a nullable-annotated scope column whose Rust type is not syntactically Option<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
  • 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:

-- 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.