Skip to content

feat: ambient tenant scoping, and move [ResourceFilter] to Abstractions (#157) - #159

Merged
swimmesberger merged 1 commit into
claude/xml-documentation-156from
claude/ambient-tenant-scoping-157
Sep 20, 2026
Merged

swimmesberger merged 1 commit into
claude/xml-documentation-156from
claude/ambient-tenant-scoping-157

Conversation

@swimmesberger

Copy link
Copy Markdown
Owner

Closes #157. Stacked on #158 — targets claude/xml-documentation-156, retarget to main once that merges.

Why

ADR-0013 splits data-level security into a point check and a list filter, both opt-in per call site. That is right for sharing, where a row some users may see is a per-feature decision written at the query. It leaves tenancy unserved — every row belongs to exactly one tenant and cross-tenant visibility is never legitimate — so:

  • a forgotten WhereAuthorized is a silent cross-tenant read, not a widened list;
  • there is no write leg at all, so a forgotten stamp writes the row into the wrong tenant, or into none, where it fails the read filter forever and is invisible to whoever created it;
  • "spans every tenant" cannot be written down — a job that legitimately crosses tenants opts out by not calling the filter, textually identical to having forgotten it.

What ships

An entity implements ITenantScoped<TTenantId> (Guid/string/int/long) and gets both legs from that one marker.

Reads — ApplyElarionTenantScoping attaches a named EF Core query filter to every tenant-scoped root entity, emitted by [GenerateElarionTenantScoping] into the existing per-feature model-configuration seam.

Writes — a SaveChanges interceptor stamps the tenant on insert, verifies rather than trusts a hand-set one, and refuses an update or delete reaching outside the current tenant or moving a row between tenants (TenantScopeViolationException). It reads the tenant property from a model annotation the read pass wrote, so it costs a metadata lookup and no reflection.

Declared system scope — using var _ = tenant.SystemScope(); satisfies the filter unconditionally and turns the write leg off, so "every tenant" is greppable. tenant.Scope(tenantId) enters one explicitly — the entry point for asynchronous resolution and per-tenant workers.

Three design points worth a reviewer's attention

  • It fails closed. The filter compares against a nullable key, so an unresolved tenant compares against SQL NULL and matches nothing. Comparing against default(TKey) instead would quietly expose every row whose tenant column happens to carry Guid.Empty or 0.
  • Entity discovery happens at model-build time, not in the generator. The generator emits one call; which entities are tenant-scoped is decided from the finished model. Deciding it in the generator would mean re-deriving EF's own entity discovery and silently missing navigation-discovered children — exactly where the bug would hurt.
  • AddDbContextPool is unsupported. Pooled options build once, which would serve every scope the first scope's tenant. That is a silent cross-tenant leak, so the registration fails loudly rather than degrading quietly.

This is the repository's only global query filter — and it is the exception archive-restore.mdx already wrote: "A global filter earns its keep when the predicate is a security boundary that must hold even when a developer forgets — multi-tenancy is the classic case." The objections raised there still apply and are accepted in ADR-0075: raw SQL, the AOT SQL tier and bulk COPY bypass both legs, as they bypass [ResourceFilter] today.

Issue item 4

ResourceFilterAttribute<T>, WhereAuthorized and IQueryAuthorizer<T>.Matches move from Elarion.Paging to Elarion.Abstractions.Authorization, beside IQueryAuthorizer<T>. Breaking: a namespace is part of type identity, so no type-forward softens it. Migration is using Elarion.Paging; → using Elarion.Abstractions.Authorization; (keep both if the file also pages).

Issue item 3 — deliberately not implemented

An analyzer flagging a query over a tenant-ruled entity with no WhereAuthorized needs dataflow across arbitrary LINQ chains, locals and method boundaries, so the honest version either misses most cases or false-positives on legitimate system queries. And once the read filter exists there is nothing left for it to catch. ADR-0075 records the reasoning.

Verification

2075/2075 tests pass, none skipped — including 12 Testcontainers PostgreSQL tests covering the filtered read, the predicate reaching SQL, the fail-closed unresolved tenant, system scope, per-key IgnoreQueryFilters, the insert stamp, insert-for-another-tenant, update-of-another-tenant's-row (attached without being loaded, so the read filter cannot help), the tenant move, and per-tenant explicit scoping. Plus warning-free Release build, clean dotnet pack, website build, and the TypeScript generator suite.

Docs

ADR-0075, a new docs/capabilities/multi-tenancy.mdx, and cross-references from resource-authorization.mdx (sharing vs. tenancy), archive-restore.mdx (its global-filter exception now links here), packages.mdx, attributes.mdx and diagnostics.mdx (ELTEN001).

…ns (#157)

ADR-0013 splits data-level security into a point check and a list
filter, both opt-in per call site. That is right for *sharing*, where a
row some users may see is a per-feature decision. It leaves *tenancy*
unserved: every row belongs to exactly one tenant, cross-tenant
visibility is never legitimate, and the opt-in shape means a forgotten
WhereAuthorized is a silent cross-tenant read rather than a widened
list — with no write leg at all, so a forgotten stamp writes the row
into the wrong tenant, or into none, where it fails the read filter
forever and is invisible to whoever created it.

Ship ambient tenant scoping (ADR-0075). An entity implements
ITenantScoped<TTenantId> (Guid/string/int/long) and gets both legs from
that one marker:

- Reads: ApplyElarionTenantScoping attaches a named EF Core query filter
  to every tenant-scoped root entity, emitted by
  [GenerateElarionTenantScoping] into the existing per-feature
  model-configuration seam. Entity discovery happens at model-build time
  rather than in the generator, so navigation-discovered children and
  entities other seams added are covered too. The comparison is against
  a nullable key, so an unresolved tenant compares against SQL NULL and
  matches nothing — comparing against default(TKey) would expose every
  row carrying Guid.Empty or 0.
- Writes: a SaveChanges interceptor stamps the tenant on insert,
  verifies rather than trusts a hand-set one, and refuses an update or
  delete reaching outside the current tenant or moving a row between
  tenants (TenantScopeViolationException). It reads the tenant property
  from a model annotation the read pass wrote, so it costs a metadata
  lookup and no reflection.

Work that spans tenants declares it — `using var _ =
tenant.SystemScope();` — so "every tenant" is greppable instead of being
an absent call; tenant.Scope(tenantId) enters one explicitly, which is
how asynchronous resolution and per-tenant workers opt in. Resolution is
the ITenantResolver seam (ClaimsTenantResolver reads a configurable
claim; two tenant claims resolve to nothing rather than the first),
deliberately synchronous because a query filter cannot await.

This is the repository's only global query filter, and it is the
exception the archive/restore recipe already named: a global filter
earns its keep when the predicate is a security boundary that must hold
even when a developer forgets. Its costs are accepted and documented —
raw SQL, the AOT SQL tier and bulk COPY bypass both legs, and
AddDbContextPool is unsupported because pooled options would pin every
scope to the first one's tenant.

Also moves ResourceFilterAttribute<T>, WhereAuthorized and
IQueryAuthorizer<T>.Matches from Elarion.Paging to
Elarion.Abstractions.Authorization, beside IQueryAuthorizer<T> (issue
item 4, breaking). None of them touch EF Core or pagination, so an
application needing data-level authorization no longer takes the
pagination package for one attribute.

Item 3 of the issue — an analyzer flagging a query over a tenant-ruled
entity with no WhereAuthorized — is deliberately not implemented, and
ADR-0075 records why: catching it needs dataflow across arbitrary LINQ
chains, locals and method boundaries, so the honest version either
misses most cases or false-positives on legitimate system queries — and
once the read filter exists there is nothing left for it to catch.

Verified: 2075/2075 tests pass with none skipped, including twelve
Testcontainers PostgreSQL tests covering the filtered read, the
fail-closed unresolved tenant, system scope, per-key IgnoreQueryFilters,
the insert stamp, and each refused cross-tenant write. Warning-free
Release build, clean pack, website build, TypeScript generator suite.
@swimmesberger
swimmesberger added this pull request to stack #160 September 20, 2026 12:22
@swimmesberger
swimmesberger merged commit b8adf50 into main Sep 20, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No ambient tenant scoping: unconditional per-tenant isolation has to be rebuilt as EF query filters plus a SaveChanges override in every app

1 participant