Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions book/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
- [delete](./repo-delete.md)
- [Hooks](./repo-hooks.md)
- [Error Types](./repo-errors.md)
- [Scoped Repositories](./scoped-repositories.md)

- [Transactions](./transactions.md)
- [Connection Traits](./connection-traits.md)
Expand Down
128 changes: 128 additions & 0 deletions book/src/scoped-repositories.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# 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.

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

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

## 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
- `find_by`, `list_by` or `list_for` on the scope column itself — every read
is already filtered by it; per-scope listing *is* the ordinary scoped
`list_by_*(Only(value), ..)`
- `scope` on nested repos — children are custody-guarded via their (scoped)
parent

The scope column also generates no `find_by_partner_id` accessors: 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`.
68 changes: 49 additions & 19 deletions es-entity-macros/src/repo/find_all_fn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use darling::ToTokens;
use proc_macro2::TokenStream;
use quote::{TokenStreamExt, quote};

use super::options::*;
use super::{options::*, scope::ScopeInfo};

pub struct FindAllFn<'a> {
prefix: Option<&'a syn::LitStr>,
Expand All @@ -13,6 +13,7 @@ pub struct FindAllFn<'a> {
any_nested: bool,
post_hydrate_error: Option<&'a syn::Type>,
forgettable_table_name: Option<&'a str>,
scope: Option<ScopeInfo<'a>>,
#[cfg(feature = "instrument")]
repo_name_snake: String,
}
Expand All @@ -28,6 +29,7 @@ impl<'a> From<&'a RepositoryOptions> for FindAllFn<'a> {
any_nested: opts.any_nested(),
post_hydrate_error: opts.post_hydrate_hook.as_ref().map(|h| &h.error),
forgettable_table_name: opts.forgettable_table_name(),
scope: ScopeInfo::from_opts(opts),
#[cfg(feature = "instrument")]
repo_name_snake: opts.repo_name_snake_case(),
}
Expand Down Expand Up @@ -56,24 +58,48 @@ impl ToTokens for FindAllFn<'_> {
quote! {}
};

let es_query_call = if let Some(prefix) = self.prefix {
quote! {
es_entity::es_query!(
tbl_prefix = #prefix,
#forgettable_tbl_arg
#query,
ids as &[#id],
)
let make_es_query = |query: &str, extra_args: &TokenStream| -> TokenStream {
if let Some(prefix) = self.prefix {
quote! {
es_entity::es_query!(
tbl_prefix = #prefix,
#forgettable_tbl_arg
#query,
ids as &[#id],
#extra_args
)
}
} else {
quote! {
es_entity::es_query!(
entity = #entity,
#forgettable_tbl_arg
#query,
ids as &[#id],
#extra_args
)
}
}
};
let es_query_call = make_es_query(&query, &quote! {});

let (scope_fn_arg, scope_fn_pass, scope_convert) = match &self.scope {
Some(scope) => (scope.fn_arg(), scope.fn_pass(), scope.convert()),
None => (quote! {}, quote! {}, quote! {}),
};
let fetch_call = if let Some(scope) = &self.scope {
let scoped_query = format!(
"SELECT id FROM {} WHERE id = ANY($1) AND {}",
self.table_name,
scope.predicate(2),
);
let scoped_es_query_call = make_es_query(&scoped_query, &scope.arg_tokens());
scope.dispatch(
quote! { #es_query_call.fetch_n(op, ids.len()).await? },
quote! { #scoped_es_query_call.fetch_n(op, ids.len()).await? },
)
} else {
quote! {
es_entity::es_query!(
entity = #entity,
#forgettable_tbl_arg
#query,
ids as &[#id],
)
}
quote! { #es_query_call.fetch_n(op, ids.len()).await? }
};

let op_param = if self.any_nested {
Expand Down Expand Up @@ -107,18 +133,21 @@ impl ToTokens for FindAllFn<'_> {
tokens.append_all(quote! {
pub async fn find_all<Out: From<#entity>>(
&self,
#scope_fn_arg
ids: &[#id]
) -> Result<std::collections::HashMap<#id, Out>, #query_error> {
self.find_all_in_op(#query_fn_get_op, ids).await
self.find_all_in_op(#query_fn_get_op, #scope_fn_pass ids).await
}

#instrument_attr
pub async fn find_all_in_op #generics(
&self,
#op_param,
#scope_fn_arg
ids: &[#id]
) -> Result<std::collections::HashMap<#id, Out>, #query_error> {
let (entities, _) = #es_query_call.fetch_n(op, ids.len()).await?;
#scope_convert
let (entities, _) = #fetch_call;
#post_hydrate_check
Ok(entities.into_iter().map(|u| (u.id.clone(), Out::from(u))).collect())
}
Expand Down Expand Up @@ -147,6 +176,7 @@ mod tests {
any_nested: false,
post_hydrate_error: None,
forgettable_table_name: None,
scope: None,
#[cfg(feature = "instrument")]
repo_name_snake: "test_repo".to_string(),
};
Expand Down
Loading
Loading