feat: ambient tenant scoping, and move [ResourceFilter] to Abstractions (#157) - #159
Merged
swimmesberger merged 1 commit intoSep 20, 2026
Conversation
…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
added this pull request to stack #160
September 20, 2026 12:22
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #157. Stacked on #158 — targets
claude/xml-documentation-156, retarget tomainonce 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:
WhereAuthorizedis a silent cross-tenant read, not a widened list;What ships
An entity implements
ITenantScoped<TTenantId>(Guid/string/int/long) and gets both legs from that one marker.Reads —
ApplyElarionTenantScopingattaches 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
SaveChangesinterceptor 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
NULLand matches nothing. Comparing againstdefault(TKey)instead would quietly expose every row whose tenant column happens to carryGuid.Emptyor0.AddDbContextPoolis 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.mdxalready 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>,WhereAuthorizedandIQueryAuthorizer<T>.Matchesmove fromElarion.PagingtoElarion.Abstractions.Authorization, besideIQueryAuthorizer<T>. Breaking: a namespace is part of type identity, so no type-forward softens it. Migration isusing 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
WhereAuthorizedneeds 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, cleandotnet pack, website build, and the TypeScript generator suite.Docs
ADR-0075, a new
docs/capabilities/multi-tenancy.mdx, and cross-references fromresource-authorization.mdx(sharing vs. tenancy),archive-restore.mdx(its global-filter exception now links here),packages.mdx,attributes.mdxanddiagnostics.mdx(ELTEN001).