Changelog
The changes in each release of Tenantry Core 0.5, newest first.
0.5.0 - 2026-10-03
Upgrading from 0.4
0.5 reshapes the public API once, before 1.0. Registration code needs no Tenantry using directive any more;
entity and handler code needs using Tenantry; (and using Tenantry.EfCore; for the EF Core types).
| 0.4 | 0.5 |
|---|---|
AddTenantryCore<TKey>(…) (Core) and AddTenantry<TKey>(…) (AspNetCore) | AddTenantry<TKey>(…), in Core, for every host |
IAspNetCoreTenantBuilder<TKey> | ITenantBuilder<TKey>; the ASP.NET Core methods are extension methods on it |
namespaces Tenantry.Core, .Core.Exceptions, .Core.Stores | Tenantry |
Tenantry.AspNetCore.Attributes, .Resolution | Tenantry.AspNetCore |
Tenantry.Core.Extensions, Tenantry.AspNetCore.Extensions, Tenantry.EfCore.Extensions | Microsoft.Extensions.DependencyInjection, Microsoft.AspNetCore.Builder, Microsoft.EntityFrameworkCore (no using needed) |
ITenantScope<TKey>.BeginScope(tenant) | ITenantContextSetter<TKey>.Use(tenant) |
ITenantServiceScope<TKey> (what ITenantScopeFactory creates) | ITenantScope<TKey> |
ITenantScoped<TKey> / TenantScoped<TKey> | ITenantEntity<TKey> (get-only TenantId) / TenantEntity<TKey> |
ITenantStoreAccessor<TKey> | ITenantLookup<TKey> |
ITenantConnectionStringResolver<TKey>.Resolve(tenant) / ResolveAsync(tenant) | ITenantConnectionStringProvider<TKey>.Get(tenant) / GetAsync(tenant) |
ITenantConnectionStringResolver<TKey>.Resolve() / ResolveAsync() (current tenant) | CurrentTenantConnectionString<TKey>.Get() / GetAsync() |
services.AddTenantConnectionStrings<TKey>(…) | tenant.UseConnectionStrings(…) inside AddTenantry |
Tenantry.Core.MissingTenantBehavior (Allow, Warn, Reject, Skip) | Tenantry.EfCore.MissingTenantBehavior (Reject, Warn, Allow) |
EfCoreIsolationOptions.DetectSpoofedWrites | removed: a new entity that names another tenant is always rejected |
Tenantry.Core.Exceptions.TenantIsolationViolationException.EntityTypeName | Tenantry.EfCore.TenantIsolationViolationException.TypeName, plus Kind |
MultiTenantDbContext<TKey>, or ITenantAwareDbContext<TKey> with modelBuilder.ApplyTenantFilters<TKey, TContext>(this), and options.AddTenantInterceptors(sp) | options.UseTenantry() on a plain DbContext |
tenant.AddEfCoreIsolation(o => …) | optional: tenant.ConfigureEfCoreIsolation(o => …) |
services.AddTenantDbContextPool<TContext, TKey>((sp, o) => o.UseSqlServer().AddTenantInterceptors(sp)) | tenant.AddDbContextPerTenantDatabase<TContext>((sp, o) => o.UseSqlServer(), pooled: true) |
AddDbContext reading the current tenant's connection string in its options callback | tenant.AddDbContextPerTenantDatabase<TContext>((sp, o) => o.UseSqlServer()) |
the EF Core 10 tenant filter named __TenantryFilter__ | named TenantryQueryFilters.Tenant ("Tenantry.Tenant") |
Added
- CI builds every
csharp block in the README and docs against the packed packages (`scripts/check-doc-snippets.sh`), so a guide can no longer show code that does not compile; a block that is not meant to compile is markedcsharp no-compile. CI also starts every sample in Development, where the host validates its registrations, and sends a tenant request to the web ones (scripts/smoke-samples.sh). - An
.editorconfig, checked in CI withdotnet format --verify-no-changes. options.UseTenantry(), one call that isolates anyDbContext, pooled or not, with no base class or interface: it adds the tenant query filters while EF Core builds the model, afterOnModelCreating(so the order of your own configuration no longer matters), and the save interceptor and bulk-update guard. It reads the tenant through the context's application service provider, and a Tenantry error names the missingAddTenantry<TKey>on the first query or save.tenant.AddDbContextPerTenantDatabase<TContext>(…, pooled)registers a context for a database per tenant, pooled or not, connecting each one to the current tenant's database, and checks at registration thatUseConnectionStringswas called. It replacesAddTenantDbContextPooland the hand-written non-pooled recipe; the pooled variant uses EF Core's publicPooledDbContextFactoryinstead of rebuilding EF Core's registration. It appliesUseTenantry()before your configuration, so your interceptors (an audit log) see new entities stamped, and a context that is not pooled has its scope as its application service provider, as withAddDbContext.- On EF Core 10 the tenant filter is named
TenantryQueryFilters.Tenant, soIgnoreQueryFilters([TenantryQueryFilters.Tenant])removes it alone. EF Core does not allow a named filter beside an unnamed one, so an entity's unnamed filter of its own is namedTenantryQueryFilters.Application: each can be ignored alone, andReload()andGetDatabaseValues()ignore it, as EF Core documents. EfCoreIsolationOptions.OnSaveWithoutTransaction(SaveWithoutTransactionBehavior.UseTransaction, the default, orReject, which throwsTenantIsolationViolationExceptionof the new kindSaveWithoutTransaction), for a save that must succeed or fail as a whole whileDatabase.AutoTransactionBehaviorisNever; the kindTransactionRolledBack, for a transaction Tenantry rolls back instead of committing; log events 2004 (TransactionNotCommitted) and 2005 (SaveInTransaction).ITenantDbContextOptionsContributorandITenantModelContributor, so packages that build on Tenantry can add to the options and model of every context that usesUseTenantry().- Contexts that use
UseTenantry()check each model on their first query and first save, and throwTenantIsolationViolationExceptioninstead of running either when a tenant-scoped entity type has lost its tenant query filter or itsTenantIdconcurrency token after Tenantry built the model (a convention, a model customizer in a custom internal service provider, a compiled model). - Conformance tests for each package: a host that registers the package's features through its public methods, with a scoped store, validates scopes and every registration on build, resolves every Tenantry service and starts.
TenantNotFoundException, aTenantNotResolvedExceptionthat carries theTenantIdthat was looked up.RunInScopeAsyncthrows it for an id the store does not have, so a queue consumer can tell a message for a deleted tenant from code that runs without a tenant.TenantResolutionOptions<TKey>is public, configured withtenant.ConfigureResolution(o => …): the status codes of each rejection (MissingTenantStatusCode,TenantNotFoundStatusCode,AccessDeniedStatusCode),RequireTenantByDefault, and two events:OnResolved, when a request's tenant is made current, andOnRejected, when a request is rejected, which is told the reason (Missing,NotFound,AccessDenied), the identifier and the refused tenant, and can change the status code or write its own response (a redirect) withHandleResponse().- Rejections are written as problem details (
application/problem+json) when anIProblemDetailsServiceis registered (builder.Services.AddProblemDetails()). ResolveFromSubdomain(o => …)takesBaseDomains(only<tenant>.<base domain>resolves, andacme.localhostworks in development withlocalhost) andIgnoredSubdomains(wwwby default); a host that is an IP address resolves nothing, and the subdomain is returned in lower case.- Identifiers: a resolver returns an identifier, and
ITenantStore<TKey>.FindByIdentifierAsyncfinds the tenant it names. By default it parses the identifier as the key type, with the invariant culture, and callsGetTenantAsync, so existing stores need no change; a store implements it to map slugs or custom domains toGuidorinttenants.ITenantLookup<TKey>.FindByIdentifierAsynccalls it from a scope of its own, and the middleware finds request tenants through it.ResolveFromHost(o => o.ExcludedDomains.Add(…))resolves the request's host name, for tenants with domains of their own (localhostis excluded by default). Both host resolvers compare and return international domain names in their ASCII form. tenant.CacheTenants(o => o.Duration = …)caches the tenants Tenantry reads (the middleware's andITenantLookup's lookups, by id and by identifier) in memory, 5 minutes by default, with no new dependency;ITenantStoreCache<TKey>.Invalidate(id)andInvalidateAll()remove them when a tenant changes (AddTenantryalways registers it, as Tenantry.Pro doesIConnectionStringCache). A lookup that finds no tenant is not cached. It reads the time from a registeredTimeProvider.ITenantAccessValidator<TKey>andtenant.ValidateTenantAccess<TValidator>(): an access validator created in each request's scope, so it can use aDbContext. The delegate overloads remain; all run in the order they were added.- Your own tenant type:
tenant.As<AppTenant>()reads a tenant as the type your store returns, in any delegate that receives one (connection strings, access validators, Tenantry.Pro's selectors), andITenantContext<TKey>.GetCurrentTenant<AppTenant>()reads the current one. Both throw an error naming both types when the store returns another type. - Diagnostics (
docs/diagnostics.md): the middleware's and the EF Core isolation's log messages have stable event ids (1001–1008 underTenantry.AspNetCore, 2001–2005 underTenantry.EfCore; 2001 is an isolation violation), written with[LoggerMessage]. While a request's tenant is current, its trace span is taggedtenant.idand a log scope withTenantIdis open (the names Tenantry.Pro's jobs and messages use). TheTenantry.AspNetCoreactivity source has aTenantry.ResolveTenantspan, and its meter counts requests by result intenantry.resolutions. - When routing chooses an endpoint with Tenantry's metadata after the middleware ran, or the authentication
middleware runs after it and signs in a user whose claim
ResolveFromClaimreads, the middleware logs a warning once. CurrentTenantConnectionString<TKey>, the current tenant's connection string.TenantIsolationViolationException.Kind(EntityWrite,BulkUpdate,TenantDatabaseMismatch,ModelConfiguration), and the tenant ids on a database-per-tenant mismatch, which were empty.ITenantBuilder, the builder without its key type, andITenantRegistration, for packages whose builder methods take a type parameter of their own. Both are trimming- and Native AOT-safe (noMakeGenericType).- The
Aotsample uses every ASP.NET Core builder method, problem details and connection strings, so CI publishes all of them with Native AOT.
Changed
-
Breaking: the API reshape in Upgrading from 0.4. There is one entry point and one builder, so every builder method chains (
tenant => tenant.ResolveFromHeader(…).UseStore<T>().UseConnectionStrings(…)), andUseResolver<TResolver>()returns the builder without its key type (call it last). It creates the resolver in each request's scope, so it can depend on scoped services. -
Breaking: an application registers one store and one tenant key type. A second
UseStore/UseInMemoryStore(or anITenantStore<TKey>registered directly), andAddTenantrywith another key type, throw. -
Breaking:
app.UseTenantry()checks the registration when the pipeline is built (a resolver and a store, and a Tenantry-worded error whenAddTenantryregistered no request resolution), in place of the hosted service that checked at startup. CreatingITenantLookupwithout a store throws, so a worker's hosted service that depends on it fails as the host starts. -
Breaking: the resolution middleware no longer echoes the request's identifier, and a rejection's body is empty (or problem details, above) instead of plain text. An endpoint that does not require a tenant is never rejected: a request whose identifier names no tenant, or names one an access validator refuses, continues without a tenant, so
www.hosts and health probes no longer get404. With access validators, a tenant that does not exist gets the access-denied response, so a caller cannot tell which tenants exist. An identifier that does not parse as the key type names no tenant (404, not400), and a resolver that returns an empty string has no identifier, so the next resolver is tried. The store is read throughITenantLookup, in a scope of its own, not the request's. -
Breaking: a web application that registers request resolution but does not call
app.UseTenantry()fails to start, instead of running every endpoint, those that require a tenant included, without one. -
Breaking:
ITenantDescriptor<TKey>derives from a new non-genericITenantDescriptor, which holdsName(As<TTenant>()extends it), so an explicit implementation is writtenstring ITenantDescriptor.Name.ITenantLookup<TKey>has a new member,FindByIdentifierAsync, which a hand-written implementation or fake must add.ITenantContext<TKey>is no longer covariant inTKey(variance never applied: the constraints rule out every conversion), so it can declareGetCurrentTenant<TTenant>(). -
Breaking: the request's log scope holds only
TenantId(formatted with the invariant culture), notTenantName, and the middleware and the EF Core isolation log under the categoriesTenantry.AspNetCoreandTenantry.EfCoreinstead of their internal type names. -
Breaking: a new entity that names another tenant is always rejected with
TenantIsolationViolationException(it was silently moved to the current tenant unlessDetectSpoofedWriteswas on). The stamp goes through EF Core, soITenantEntity.TenantIdneeds only a getter. -
Breaking: the key type's default (
Guid.Empty,0, an empty string) is reserved for "no tenant":ITenantContextSetter.Use,ITenantScopeFactory.CreateScopeandRunInScopeAsyncthrowArgumentExceptionfor it, and an identifier that parses to it names no tenant. It could write but never read its own rows. -
TenantNotResolvedException's default message names every way to make a tenant current, not onlyapp.UseTenantry(), and the EF Core error for a write without a tenant namesRunInScopeAsync. -
The packages depend on each other from this release up to the next minor (
[0.5.0, 0.6.0)) instead of exactly: they no longer share internals, so one can be updated within the minor without the others. -
Breaking: EF Core isolation is
options.UseTenantry()(see Upgrading), which always applies the query filters and the interceptors together. A context that attached the interceptors but left the filters out, for example to read across tenants, used to save with a warning and read every tenant's rows; useIgnoreQueryFilters()for deliberate cross-tenant reads. -
Breaking: a model that Tenantry cannot isolate fails to build, instead of being left unisolated: entities that implement
ITenantEntitywith a key type other than the registered one (0.4 skipped them silently), with more than one key type, a tenant-scoped type whose base type or owner is not tenant-scoped, aTenantIdthat is not a mapped public property (an explicit interface implementation failed with anArgumentException), and, on EF Core 10, a filter of your own namedTenantryQueryFilters.Tenant. Creating a context that also replacesIModelCustomizer, which Tenantry's own customizer would otherwise override, or usesUseInternalServiceProvider, which never gets Tenantry's customizer, throws. -
Tenantry.EfCoredepends onMicrosoft.EntityFrameworkCore.Relationalonly (it bringsMicrosoft.EntityFrameworkCore), and has no API annotated for dynamic code of its own. -
ExecuteUpdatefails closed on setters the guard cannot read. Their expression shape is undocumented and changes between EF Core versions, so a version whose shape Tenantry does not know is now rejected instead of its setters going unchecked. Tests pin the shape of each supported version (8, 9, 10, and the 11 release candidate). -
The interceptor no longer logs a warning when
TenantIdis not a concurrency token: the model check throws instead. -
A tenant store returns every tenant that exists, suspended ones included; whether a tenant may be served is decided by an access validator for HTTP requests and by your own code for background work. The docs and the
EfCoreWebsample hid inactive tenants from the store (a404), which made tools that maintain every tenant's database, such as Tenantry.Pro's migrations, skip them. The sample now keeps them in the store and refuses them with a validator (403); see "Suspended and inactive tenants" indocs/tenant-stores.md. Nothing checks a tenant's status for you in background work. -
Tenantry.EfCoreno longer depends onMicrosoft.Extensions.DependencyInjection.Abstractionsitself: EF Core andTenantry.Corebring it, EF Core at the same minimum as before. -
The packages carry a README of their own, with links that work on NuGet.org, an icon, the project URL (tenantry.dev) and a copyright notice, and their descriptions match what each package holds. Packing checks that each target framework's assembly keeps the API of the lower ones (package validation).
-
A GitHub release's notes are its section of this changelog.
Fixed
- A tenant-scoped entity mapped to more than one table (table-per-type inheritance, entity splitting) could be
changed in another tenant's row through a stub with a forged
TenantId: EF Core updates only the tables whose columns changed, andTenantId's concurrency token is checked only in its own table, so a change to another table's columns matched the row by its key alone. ItsTenantIdis now written back to its table too, in EF Core's transaction, so the database checks it (and, when EF Core does not saveTenantIdafter an insert, its stored row is read before the save); one keyed by itsTenantIdneeds neither. The same went for a stub deleted and added again under the same key, which EF Core saves as anUPDATEof what differs, table by table: the deleted one's stored row is now read. Owners and these pairs were also not found for a byte array key, which Tenantry compared by reference: keys are now compared as EF Core compares them. - Owned rows in a table of their own, and the rows of an entity mapped to more than one table outside the table with
TenantId, rely on another statement of the save for their tenant check: the owner's, or the one onTenantId's table. A forged write of them stayed written wherever a failed save was not undone as a whole: withDatabase.AutoTransactionBehaviorset toNever(on a provider that batches statements, whatever their order), in a transaction without savepoints (SQL Server with MARS, orAutoSavepointsEnabledoff) or aTransactionScopethat the application committed after catching the exception, and, in any setup, when an interceptor suppressed the concurrency failure. A deleted entity over more than one table relied on this alone. Tenantry now makes such a save succeed or fail as a whole: the check's failure cannot be suppressed; without a transaction, EF Core runs the save in one (or, withOnSaveWithoutTransaction = Reject, it throws before anything is sent); savepoints are turned on for it; and a transaction without them, or aTransactionScope, in which such a save failed after sending some of its statements is rolled back instead of committed, as the save may have failed before its check was read. - The stored row Tenantry reads before some saves (an owner, or an entity over more than one table, whose
TenantIdis not written back, and a deleted and added pair over more than one table) was read through the tenant filter, and on EF Core 8 and 9, or for an unnamed filter of the application's on EF Core 10, through that filter too, so the current tenant's row that it hid (a soft-deleted one) could not be changed. The read now names the tenant itself and ignores every query filter. - A tenant-scoped owned type mapped to JSON crashed a save that read its stored row (EF Core 8 and 9), or failed to
build the model with EF Core's own error (EF Core 10). It now fails to build the model on every version, saying
why: its owner's row holds it, under the owner's
TenantId. - An entity type that is not tenant-scoped could share a tenant-scoped entity's table (table splitting), with no tenant
filter or
TenantId, and read or change every tenant's rows of it. Such a model now fails on its first query or save. Entry(…).Reload()andGetDatabaseValues()read a row by its key without query filters (EF Core's behaviour), so an entity attached with another tenant's key, as in a forged write that fails withDbUpdateConcurrencyException, got that tenant's values. Tenantry now keeps the tenant filter on that query: another tenant's row reads as deleted (GetDatabaseValues()returnsnull,Reload()detaches the entity). On EF Core 8 and 9 the entity's own filter, merged with the tenant filter, applies to these reads as well.- An owned type that does not implement
ITenantEntity<TKey>(an owned value object in its own table, or in its owner's row) was not checked through its owner: through an attached stub of another tenant's owner, a tenant could add, change or delete that tenant's owned rows, and an owned entity attached without its owner, whatever its type, was saved unchecked; without a tenant,Rejectlet such writes through. Every owned entity is now checked through its owner (its owner'sTenantIdis written back with its concurrency token, so an audit log sees an update of the owner), an owned entity saved without its owner is rejected, andOnMissingTenanttreats owned entities of a tenant-scoped owner as tenant-scoped. Such a type keyed without its owner (OwnsMany(…, b => b.HasKey(x => x.Id))) fails to build the model: its rows carry no tenant, so an update or delete by its key would reach another tenant's row whatever owner it was attached under. - The middleware and
ValidateTenantAccessByClaimparsed identifiers and claim values with the current culture, so a numeric id could parse differently, or not at all, on a server with another culture. They use the invariant culture, as Tenantry.Pro's jobs and messages do. - The API reference repeated a type parameter's variance in its constraints (
where TKey : IEquatable<out TKey>), which is not C#. - The documented asynchronous access validator used a field (
_entitlements) that a top-levelProgram.cscannot have; the docs now show a validator class with its own dependencies, and a delegate that resolves its service from the request. - A tenant could add rows to another tenant's owned collection (
OwnsMany): attaching a stub of the other tenant's owner, with its own or noTenantId, and adding an owned entity saved it, and the owner's tenant then read the row, because EF Core reads owned rows through their owner without a tenant filter and does not write an owner that is only attached. When a save adds an owned entity to such an owner, the owner is now rejected if it was attached as another tenant, and otherwise itsTenantIdis written back with its concurrency token in the same transaction, so a forged one matches no row and nothing is saved. An audit log sees that as an update of the owner. The same check covers an owned entity with a key of its own moved to another owner by changing its foreign key, the nearest tenant-scoped owner of a nested owned entity, an owner marked modified with nothing EF Core writes (no UPDATE carries its token), and an owner deleted and added again under the same key in one save (EF Core sends one UPDATE of what differs, which can be nothing; its stored row is read). An owner whoseTenantIdis part of the key its owned types are owned through needs no write, as their foreign key names the tenant; one whoseTenantIdEF Core does not write after an insert (in another key, such as an alternate key, or configured so) is read before the save instead. Owning a type through a key of a tenant-scoped owner that neither includes nor is part of its primary key, nor includesTenantId, fails to build the model. - The documented order,
base.OnModelCreating(and soApplyTenantFilters) first, lost the tenant filter: on EF Core 8 and 9 a laterHasQueryFilterreplaced it, so the tenant's queries returned every tenant's rows, and an entity type configured later got no filter; on EF Core 10 the model failed to build.UseTenantry()adds the tenant filter afterOnModelCreating. - A tenant-scoped inheritance hierarchy failed to build its model, because Tenantry gave the derived types a
filter of their own, which EF Core allows only on the root. The root's filter now covers the
hierarchy, and a tenant-scoped type whose base type is not tenant-scoped throws a Tenantry error. A
tenant-scoped owned type, which also failed to build, gets its
TenantIdconcurrency token and is filtered through its owner, which must be tenant-scoped too (otherwise it throws). - Calling
AddTenantrytwice registered a second set of resolution options, so the first call's access validators andRequireTenantByDefaultwere silently dropped. CallingAddEfCoreIsolation(nowConfigureEfCoreIsolation) twice ignored the second call's options. Both now configure the options already registered. MissingTenantBehavior.Allowsaid EF Core saves writes without a stampedTenantId, and troubleshooting said the same ofWarn; both let updates and deletes through, but a new entity without aTenantIdstill throws.docs/access-control.mddocumentedValidateTenantAccessAny, which was removed before 0.4.0. It now shows OR logic written in one validator.- The getting-started
DbContextdid not compile withGuidkeys (Guid? CurrentTenantId), and several guides left out the using directives their code needs. - The README suggested the interceptor alone isolates a plain
DbContext; it stamps and checks writes but does not filter reads, which also needs the query filters. - Behaviour the docs misstated: without a tenant,
SaveChangesthrows by default (Reject) rather than logging a warning;MissingTenantBehavior's documentation named the wrong default and said EF Core acceptsSkip. - The
EfCoreWebsample did not start: its migration hasTenantIdindexes that its model never declared (the model now declares them, as the EF Core guide recommends), and its seed data referenced categories by an id they did not have yet. The sample.httpfiles carried the old product name.
Earlier releases are in the full changelog.