From a39034c912c6933532081c91cf6d792bb061ca86 Mon Sep 17 00:00:00 2001 From: Reza Heidari Date: Thu, 8 Jan 2026 11:11:12 +0300 Subject: [PATCH 1/6] fix(multitenancy): harden resolution and correlation scopes Ignore default fallback candidates during consensus unless they are the only source, and share correlation scopes between middleware and MediatR to prevent duplicate scopes. Switch resolution strategy to scoped and add regression tests for consensus fallback and HTTP scope handling. --- .../Middleware/TenantResolutionMiddleware.cs | 35 +++++++++- .../AssemblyInfo.cs | 5 ++ .../Behaviors/TenantCorrelationBehavior.cs | 20 +++--- .../TenantCorrelationPostProcessor.cs | 13 +++- .../TenantCorrelationPreProcessor.cs | 9 ++- .../Behaviors/TenantCorrelationScope.cs | 22 ------- .../TenantCorrelationScopeAccessor.cs | 66 +++++++++++++++++++ .../DependencyInjectionExtensions.cs | 3 +- .../CompositeTenantResolutionStrategy.cs | 16 +++-- .../TenantResolutionMiddlewareTests.cs | 4 +- .../CompositeTenantResolutionStrategyTests.cs | 46 +++++++++++++ .../TenantBehaviorTests.cs | 8 ++- 12 files changed, 197 insertions(+), 50 deletions(-) create mode 100644 src/CleanArchitecture.Extensions.Multitenancy/AssemblyInfo.cs delete mode 100644 src/CleanArchitecture.Extensions.Multitenancy/Behaviors/TenantCorrelationScope.cs create mode 100644 src/CleanArchitecture.Extensions.Multitenancy/Behaviors/TenantCorrelationScopeAccessor.cs diff --git a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Middleware/TenantResolutionMiddleware.cs b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Middleware/TenantResolutionMiddleware.cs index 26b00db..6fd2e70 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Middleware/TenantResolutionMiddleware.cs +++ b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Middleware/TenantResolutionMiddleware.cs @@ -2,6 +2,7 @@ using CleanArchitecture.Extensions.Multitenancy.Abstractions; using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Context; using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Options; +using CleanArchitecture.Extensions.Multitenancy.Behaviors; using CleanArchitecture.Extensions.Multitenancy.Configuration; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -21,6 +22,7 @@ public sealed class TenantResolutionMiddleware private readonly AspNetCoreMultitenancyOptions _aspNetCoreOptions; private readonly MultitenancyOptions _options; private readonly ILogger _logger; + private readonly ITenantCorrelationScopeAccessor _scopeAccessor; /// /// Initializes a new instance of the class. @@ -32,6 +34,7 @@ public sealed class TenantResolutionMiddleware /// ASP.NET Core multitenancy options. /// Core multitenancy options. /// Logger. + /// Correlation scope accessor. public TenantResolutionMiddleware( RequestDelegate next, ITenantResolver resolver, @@ -39,7 +42,8 @@ public TenantResolutionMiddleware( ITenantResolutionContextFactory contextFactory, IOptions aspNetCoreOptions, IOptions options, - ILogger logger) + ILogger logger, + ITenantCorrelationScopeAccessor scopeAccessor) { _next = next ?? throw new ArgumentNullException(nameof(next)); _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); @@ -48,6 +52,7 @@ public TenantResolutionMiddleware( _aspNetCoreOptions = aspNetCoreOptions?.Value ?? throw new ArgumentNullException(nameof(aspNetCoreOptions)); _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _scopeAccessor = scopeAccessor ?? throw new ArgumentNullException(nameof(scopeAccessor)); } /// @@ -67,9 +72,33 @@ public async Task InvokeAsync(HttpContext httpContext) } using var tenantScope = _accessor.BeginScope(tenantContext); - using var logScope = BeginLoggingScope(tenantContext?.TenantId); - await _next(httpContext).ConfigureAwait(false); + IDisposable? logScope = null; + var shouldClearScope = _scopeAccessor.CurrentScope is null; + if (shouldClearScope) + { + logScope = BeginLoggingScope(tenantContext?.TenantId); + _scopeAccessor.SetScope(logScope, owned: false); + } + + try + { + await _next(httpContext).ConfigureAwait(false); + } + finally + { + if (shouldClearScope) + { + if (ReferenceEquals(_scopeAccessor.CurrentScope, logScope)) + { + _scopeAccessor.ClearScope()?.Dispose(); + } + else + { + logScope?.Dispose(); + } + } + } } private IDisposable? BeginLoggingScope(string? tenantId) diff --git a/src/CleanArchitecture.Extensions.Multitenancy/AssemblyInfo.cs b/src/CleanArchitecture.Extensions.Multitenancy/AssemblyInfo.cs new file mode 100644 index 0000000..a06fe9e --- /dev/null +++ b/src/CleanArchitecture.Extensions.Multitenancy/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CleanArchitecture.Extensions.Multitenancy.AspNetCore")] +[assembly: InternalsVisibleTo("CleanArchitecture.Extensions.Multitenancy.Tests")] +[assembly: InternalsVisibleTo("CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests")] diff --git a/src/CleanArchitecture.Extensions.Multitenancy/Behaviors/TenantCorrelationBehavior.cs b/src/CleanArchitecture.Extensions.Multitenancy/Behaviors/TenantCorrelationBehavior.cs index 81fc981..a4be13d 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy/Behaviors/TenantCorrelationBehavior.cs +++ b/src/CleanArchitecture.Extensions.Multitenancy/Behaviors/TenantCorrelationBehavior.cs @@ -16,6 +16,7 @@ public sealed class TenantCorrelationBehavior : IPipelineBe private readonly ICurrentTenant _currentTenant; private readonly MultitenancyOptions _options; private readonly ILogger> _logger; + private readonly ITenantCorrelationScopeAccessor _scopeAccessor; /// /// Initializes a new instance of the class. @@ -23,11 +24,13 @@ public sealed class TenantCorrelationBehavior : IPipelineBe public TenantCorrelationBehavior( ICurrentTenant currentTenant, IOptions options, - ILogger> logger) + ILogger> logger, + ITenantCorrelationScopeAccessor scopeAccessor) { _currentTenant = currentTenant ?? throw new ArgumentNullException(nameof(currentTenant)); _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _scopeAccessor = scopeAccessor ?? throw new ArgumentNullException(nameof(scopeAccessor)); } /// @@ -35,17 +38,10 @@ public async Task Handle(TRequest request, RequestHandlerDelegate Handle(TRequest request, RequestHandlerDelegate { [scopeKey] = tenantId }); - TenantCorrelationScope.Set(scope); + _scopeAccessor.SetScope(scope, owned: true); try { @@ -72,7 +68,7 @@ public async Task Handle(TRequest request, RequestHandlerDelegate : IRequestPostProcessor where TRequest : notnull { + private readonly ITenantCorrelationScopeAccessor _scopeAccessor; + + /// + /// Initializes a new instance of the class. + /// + /// Correlation scope accessor. + public TenantCorrelationPostProcessor(ITenantCorrelationScopeAccessor scopeAccessor) + { + _scopeAccessor = scopeAccessor ?? throw new ArgumentNullException(nameof(scopeAccessor)); + } + /// public Task Process(TRequest request, TResponse response, CancellationToken cancellationToken) { - TenantCorrelationScope.Clear()?.Dispose(); + _scopeAccessor.ClearScope(onlyIfOwned: true)?.Dispose(); cancellationToken.ThrowIfCancellationRequested(); return Task.CompletedTask; } diff --git a/src/CleanArchitecture.Extensions.Multitenancy/Behaviors/TenantCorrelationPreProcessor.cs b/src/CleanArchitecture.Extensions.Multitenancy/Behaviors/TenantCorrelationPreProcessor.cs index 41fc1d2..816925a 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy/Behaviors/TenantCorrelationPreProcessor.cs +++ b/src/CleanArchitecture.Extensions.Multitenancy/Behaviors/TenantCorrelationPreProcessor.cs @@ -16,6 +16,7 @@ public sealed class TenantCorrelationPreProcessor : IRequestPreProcess private readonly ICurrentTenant _currentTenant; private readonly MultitenancyOptions _options; private readonly ILogger> _logger; + private readonly ITenantCorrelationScopeAccessor _scopeAccessor; /// /// Initializes a new instance of the class. @@ -23,11 +24,13 @@ public sealed class TenantCorrelationPreProcessor : IRequestPreProcess public TenantCorrelationPreProcessor( ICurrentTenant currentTenant, IOptions options, - ILogger> logger) + ILogger> logger, + ITenantCorrelationScopeAccessor scopeAccessor) { _currentTenant = currentTenant ?? throw new ArgumentNullException(nameof(currentTenant)); _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _scopeAccessor = scopeAccessor ?? throw new ArgumentNullException(nameof(scopeAccessor)); } /// @@ -35,7 +38,7 @@ public Task Process(TRequest request, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - if (TenantCorrelationScope.Current is not null) + if (_scopeAccessor.CurrentScope is not null) { return Task.CompletedTask; } @@ -56,7 +59,7 @@ public Task Process(TRequest request, CancellationToken cancellationToken) } var scope = _logger.BeginScope(new Dictionary { [scopeKey] = tenantId }); - TenantCorrelationScope.Set(scope); + _scopeAccessor.SetScope(scope, owned: true); return Task.CompletedTask; } diff --git a/src/CleanArchitecture.Extensions.Multitenancy/Behaviors/TenantCorrelationScope.cs b/src/CleanArchitecture.Extensions.Multitenancy/Behaviors/TenantCorrelationScope.cs deleted file mode 100644 index a70dbdc..0000000 --- a/src/CleanArchitecture.Extensions.Multitenancy/Behaviors/TenantCorrelationScope.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Threading; - -namespace CleanArchitecture.Extensions.Multitenancy.Behaviors; - -internal static class TenantCorrelationScope -{ - private static readonly AsyncLocal CurrentScope = new(); - - public static IDisposable? Current => CurrentScope.Value; - - public static void Set(IDisposable? scope) - { - CurrentScope.Value = scope; - } - - public static IDisposable? Clear() - { - var scope = CurrentScope.Value; - CurrentScope.Value = null; - return scope; - } -} diff --git a/src/CleanArchitecture.Extensions.Multitenancy/Behaviors/TenantCorrelationScopeAccessor.cs b/src/CleanArchitecture.Extensions.Multitenancy/Behaviors/TenantCorrelationScopeAccessor.cs new file mode 100644 index 0000000..1ed6d06 --- /dev/null +++ b/src/CleanArchitecture.Extensions.Multitenancy/Behaviors/TenantCorrelationScopeAccessor.cs @@ -0,0 +1,66 @@ +using System.Threading; + +namespace CleanArchitecture.Extensions.Multitenancy.Behaviors; + +/// +/// Provides access to the current tenant correlation logging scope. +/// +public interface ITenantCorrelationScopeAccessor +{ + /// + /// Gets the current logging scope, if any. + /// + IDisposable? CurrentScope { get; } + + /// + /// Gets a value indicating whether the current scope is owned by this component. + /// + bool IsOwned { get; } + + /// + /// Sets the current logging scope and ownership flag. + /// + /// Scope instance. + /// Whether the scope should be disposed by this accessor. + void SetScope(IDisposable? scope, bool owned); + + /// + /// Clears the current scope, optionally only when it is owned. + /// + /// True to clear only when the scope is owned. + /// The cleared scope, if any. + IDisposable? ClearScope(bool onlyIfOwned = false); +} + +internal sealed class TenantCorrelationScopeAccessor : ITenantCorrelationScopeAccessor +{ + private static readonly AsyncLocal CurrentState = new(); + + public IDisposable? CurrentScope => CurrentState.Value?.Scope; + + public bool IsOwned => CurrentState.Value?.Owned ?? false; + + public void SetScope(IDisposable? scope, bool owned) + { + CurrentState.Value = new TenantCorrelationScopeState(scope, owned); + } + + public IDisposable? ClearScope(bool onlyIfOwned = false) + { + var state = CurrentState.Value; + if (state is null) + { + return null; + } + + if (onlyIfOwned && !state.Owned) + { + return null; + } + + CurrentState.Value = null; + return state.Scope; + } + + private sealed record TenantCorrelationScopeState(IDisposable? Scope, bool Owned); +} diff --git a/src/CleanArchitecture.Extensions.Multitenancy/DependencyInjectionExtensions.cs b/src/CleanArchitecture.Extensions.Multitenancy/DependencyInjectionExtensions.cs index f25d4b2..7c037dd 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy/DependencyInjectionExtensions.cs +++ b/src/CleanArchitecture.Extensions.Multitenancy/DependencyInjectionExtensions.cs @@ -36,8 +36,9 @@ public static IServiceCollection AddCleanArchitectureMultitenancy( services.TryAddSingleton(sp => sp.GetRequiredService()); services.TryAddSingleton(sp => sp.GetRequiredService()); services.TryAddSingleton(); + services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddEnumerable(ServiceDescriptor.Singleton()); diff --git a/src/CleanArchitecture.Extensions.Multitenancy/Providers/CompositeTenantResolutionStrategy.cs b/src/CleanArchitecture.Extensions.Multitenancy/Providers/CompositeTenantResolutionStrategy.cs index 1045765..a03f7d9 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy/Providers/CompositeTenantResolutionStrategy.cs +++ b/src/CleanArchitecture.Extensions.Multitenancy/Providers/CompositeTenantResolutionStrategy.cs @@ -68,30 +68,36 @@ private async Task ResolveWithConsensusAsync( CancellationToken cancellationToken) { var candidates = new HashSet(StringComparer.OrdinalIgnoreCase); + var fallbackCandidates = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var provider in orderedProviders) { cancellationToken.ThrowIfCancellationRequested(); var result = await provider.ResolveAsync(context, cancellationToken).ConfigureAwait(false); + var target = provider.Source == TenantResolutionSource.Default + ? fallbackCandidates + : candidates; foreach (var candidate in result.Candidates) { - candidates.Add(candidate); + target.Add(candidate); } } - if (candidates.Count == 0) + var resolvedCandidates = candidates.Count > 0 ? candidates : fallbackCandidates; + + if (resolvedCandidates.Count == 0) { return TenantResolutionResult.NotFound(TenantResolutionSource.Composite); } - if (candidates.Count == 1) + if (resolvedCandidates.Count == 1) { - var tenantId = candidates.First(); + var tenantId = resolvedCandidates.First(); return TenantResolutionResult.Resolved(tenantId, TenantResolutionSource.Composite, TenantResolutionConfidence.Medium); } - return TenantResolutionResult.FromCandidates(candidates, TenantResolutionSource.Composite, TenantResolutionConfidence.Low); + return TenantResolutionResult.FromCandidates(resolvedCandidates, TenantResolutionSource.Composite, TenantResolutionConfidence.Low); } private IReadOnlyList OrderProviders() diff --git a/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/TenantResolutionMiddlewareTests.cs b/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/TenantResolutionMiddlewareTests.cs index 997f6d3..f6b45d2 100644 --- a/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/TenantResolutionMiddlewareTests.cs +++ b/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/TenantResolutionMiddlewareTests.cs @@ -3,6 +3,7 @@ using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Context; using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Middleware; using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Options; +using CleanArchitecture.Extensions.Multitenancy.Behaviors; using CleanArchitecture.Extensions.Multitenancy.Configuration; using CleanArchitecture.Extensions.Multitenancy.Context; using Microsoft.AspNetCore.Http; @@ -40,7 +41,8 @@ public async Task InvokeAsync_sets_current_tenant_and_http_context_items() factory, aspNetOptions, coreOptions, - logger); + logger, + new TenantCorrelationScopeAccessor()); var httpContext = new DefaultHttpContext(); httpContext.Request.Headers["X-Tenant-ID"] = "tenant-1"; diff --git a/tests/CleanArchitecture.Extensions.Multitenancy.Tests/CompositeTenantResolutionStrategyTests.cs b/tests/CleanArchitecture.Extensions.Multitenancy.Tests/CompositeTenantResolutionStrategyTests.cs index 8db58d7..ab08ee7 100644 --- a/tests/CleanArchitecture.Extensions.Multitenancy.Tests/CompositeTenantResolutionStrategyTests.cs +++ b/tests/CleanArchitecture.Extensions.Multitenancy.Tests/CompositeTenantResolutionStrategyTests.cs @@ -89,6 +89,52 @@ public async Task ResolveAsync_consensus_returns_single_candidate() Assert.Equal(TenantResolutionSource.Composite, result.Source); } + [Fact] + public async Task ResolveAsync_consensus_ignores_default_when_other_candidates_exist() + { + var providers = new ITenantProvider[] + { + new StubProvider(TenantResolutionSource.Default, _ => + TenantResolutionResult.Resolved("fallback", TenantResolutionSource.Default)), + new StubProvider(TenantResolutionSource.Header, _ => + TenantResolutionResult.Resolved("tenant-1", TenantResolutionSource.Header)) + }; + + var options = Options.Create(new MultitenancyOptions + { + RequireMatchAcrossSources = true + }); + + var strategy = new CompositeTenantResolutionStrategy(providers, options); + + var result = await strategy.ResolveAsync(new TenantResolutionContext()); + + Assert.Equal("tenant-1", result.TenantId); + Assert.Equal(TenantResolutionSource.Composite, result.Source); + } + + [Fact] + public async Task ResolveAsync_consensus_uses_default_when_only_source() + { + var providers = new ITenantProvider[] + { + new StubProvider(TenantResolutionSource.Default, _ => + TenantResolutionResult.Resolved("fallback", TenantResolutionSource.Default)) + }; + + var options = Options.Create(new MultitenancyOptions + { + RequireMatchAcrossSources = true + }); + + var strategy = new CompositeTenantResolutionStrategy(providers, options); + + var result = await strategy.ResolveAsync(new TenantResolutionContext()); + + Assert.Equal("fallback", result.TenantId); + Assert.Equal(TenantResolutionSource.Composite, result.Source); + } + private sealed class StubProvider : ITenantProvider { private readonly Func _resolver; diff --git a/tests/CleanArchitecture.Extensions.Multitenancy.Tests/TenantBehaviorTests.cs b/tests/CleanArchitecture.Extensions.Multitenancy.Tests/TenantBehaviorTests.cs index c521038..aaea942 100644 --- a/tests/CleanArchitecture.Extensions.Multitenancy.Tests/TenantBehaviorTests.cs +++ b/tests/CleanArchitecture.Extensions.Multitenancy.Tests/TenantBehaviorTests.cs @@ -208,10 +208,12 @@ public async Task Handle_sets_activity_when_log_scope_disabled() AddTenantToLogScope = false, AddTenantToActivity = true }); + var scopeAccessor = new TenantCorrelationScopeAccessor(); var behavior = new TenantCorrelationBehavior( currentTenant, options, - NullLogger>.Instance); + NullLogger>.Instance, + scopeAccessor); var activity = new Activity("test"); activity.Start(); @@ -235,10 +237,12 @@ public async Task Handle_sets_activity_baggage_when_enabled() AddTenantToActivity = true, LogScopeKey = "tenant" }); + var scopeAccessor = new TenantCorrelationScopeAccessor(); var behavior = new TenantCorrelationBehavior( currentTenant, options, - NullLogger>.Instance); + NullLogger>.Instance, + scopeAccessor); var activity = new Activity("test"); activity.Start(); From c093bcef70375237d7a86b510ac829ef8031014e Mon Sep 17 00:00:00 2001 From: Reza Heidari Date: Thu, 8 Jan 2026 11:11:27 +0300 Subject: [PATCH 2/6] fix!(multitenancy-efcore): enforce tenant property in shared db Bind tenant filters to the concrete DbContext for safer model caching and throw when tenant-scoped entities lack the tenant property in shared database mode. BREAKING CHANGE: shared database setups without a TenantId property and UseShadowTenantId=false now throw during model building; add the property, enable UseShadowTenantId, or mark entities as global. --- .../Filters/TenantModelCustomizer.cs | 27 +++++++++- .../TenantFilteringTests.cs | 50 ++++++++++++++++++- .../TenantModelCustomizerTests.cs | 12 +++-- 3 files changed, 80 insertions(+), 9 deletions(-) diff --git a/src/CleanArchitecture.Extensions.Multitenancy.EFCore/Filters/TenantModelCustomizer.cs b/src/CleanArchitecture.Extensions.Multitenancy.EFCore/Filters/TenantModelCustomizer.cs index 8425ef7..1d1fe2f 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy.EFCore/Filters/TenantModelCustomizer.cs +++ b/src/CleanArchitecture.Extensions.Multitenancy.EFCore/Filters/TenantModelCustomizer.cs @@ -49,6 +49,12 @@ public void Customize(ModelBuilder modelBuilder, ITenantDbContext context, EfCor builder.Property(options.TenantIdPropertyName); tenantProperty = entityType.FindProperty(options.TenantIdPropertyName); } + else if (options.Mode == TenantIsolationMode.SharedDatabase) + { + throw new InvalidOperationException( + $"Tenant property '{options.TenantIdPropertyName}' was not found on '{entityType.ClrType!.Name}'. " + + "Add the property, enable UseShadowTenantId, or mark the entity as global."); + } else { continue; @@ -81,12 +87,29 @@ private static LambdaExpression BuildTenantFilter(Type entityType, ITenantDbCont parameter, Expression.Constant(options.TenantIdPropertyName)); - var contextExpression = Expression.Constant(context, typeof(ITenantDbContext)); - var tenantId = Expression.Property(contextExpression, nameof(ITenantDbContext.CurrentTenantId)); + var tenantId = BuildTenantIdExpression(context); + if (tenantId.Type != typeof(string)) + { + tenantId = Expression.Convert(tenantId, typeof(string)); + } + var equals = Expression.Equal(propertyAccess, tenantId); return Expression.Lambda(equals, parameter); } + private static Expression BuildTenantIdExpression(ITenantDbContext context) + { + var contextType = context.GetType(); + var tenantProperty = contextType.GetProperty(nameof(ITenantDbContext.CurrentTenantId)); + if (tenantProperty is not null) + { + return Expression.Property(Expression.Constant(context), tenantProperty); + } + + var interfaceProperty = typeof(ITenantDbContext).GetProperty(nameof(ITenantDbContext.CurrentTenantId)); + return Expression.Property(Expression.Constant(context, typeof(ITenantDbContext)), interfaceProperty!); + } + private static LambdaExpression CombineFilters(LambdaExpression existing, LambdaExpression added) { var parameter = existing.Parameters[0]; diff --git a/tests/CleanArchitecture.Extensions.Multitenancy.EFCore.Tests/TenantFilteringTests.cs b/tests/CleanArchitecture.Extensions.Multitenancy.EFCore.Tests/TenantFilteringTests.cs index 50186a6..470a577 100644 --- a/tests/CleanArchitecture.Extensions.Multitenancy.EFCore.Tests/TenantFilteringTests.cs +++ b/tests/CleanArchitecture.Extensions.Multitenancy.EFCore.Tests/TenantFilteringTests.cs @@ -44,6 +44,51 @@ public void Query_filter_returns_current_tenant_only() Assert.Equal("tenant-1", results[0].TenantId); } + [Fact] + public void Query_filter_uses_current_context_tenant() + { + var options = new EfCoreMultitenancyOptions + { + Mode = TenantIsolationMode.SharedDatabase, + EnableQueryFilters = true, + UseShadowTenantId = false, + EnableSaveChangesEnforcement = false + }; + var databaseName = Guid.NewGuid().ToString(); + + var firstTenant = new CurrentTenantAccessor + { + Current = CreateTenantContext("tenant-1") + }; + + using (var context = CreateContext(firstTenant, options, databaseName: databaseName)) + { + context.Records.AddRange( + new TenantRecord { TenantId = "tenant-1", Name = "One" }, + new TenantRecord { TenantId = "tenant-2", Name = "Two" }); + + context.SaveChanges(); + + var results = context.Records.ToList(); + + Assert.Single(results); + Assert.Equal("tenant-1", results[0].TenantId); + } + + var secondTenant = new CurrentTenantAccessor + { + Current = CreateTenantContext("tenant-2") + }; + + using (var context = CreateContext(secondTenant, options, databaseName: databaseName)) + { + var results = context.Records.ToList(); + + Assert.Single(results); + Assert.Equal("tenant-2", results[0].TenantId); + } + } + [Fact] public void SaveChanges_sets_tenant_id_for_added_entities() { @@ -112,10 +157,11 @@ public void SaveChanges_throws_when_tenant_mismatch() private static TestTenantDbContext CreateContext( CurrentTenantAccessor currentTenant, EfCoreMultitenancyOptions options, - bool useInterceptor = false) + bool useInterceptor = false, + string? databaseName = null) { var builder = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()); + .UseInMemoryDatabase(databaseName ?? Guid.NewGuid().ToString()); if (useInterceptor) { diff --git a/tests/CleanArchitecture.Extensions.Multitenancy.EFCore.Tests/TenantModelCustomizerTests.cs b/tests/CleanArchitecture.Extensions.Multitenancy.EFCore.Tests/TenantModelCustomizerTests.cs index 4c24a65..8f0b881 100644 --- a/tests/CleanArchitecture.Extensions.Multitenancy.EFCore.Tests/TenantModelCustomizerTests.cs +++ b/tests/CleanArchitecture.Extensions.Multitenancy.EFCore.Tests/TenantModelCustomizerTests.cs @@ -55,7 +55,7 @@ public void Customize_skips_global_entities() } [Fact] - public void Customize_skips_shadow_property_when_disabled() + public void Customize_throws_when_tenant_property_missing_in_shared_database() { var accessor = new CurrentTenantAccessor(); using var scope = accessor.BeginScope(TestTenant.Create("alpha")); @@ -66,11 +66,13 @@ public void Customize_skips_shadow_property_when_disabled() EnableQueryFilters = true }; - using var dbContext = ShadowDisabledTestDbContextFactory.Create(accessor, options); - var entityType = dbContext.Model.FindEntityType(typeof(ShadowWidget)); + var exception = Assert.Throws(() => + { + using var dbContext = ShadowDisabledTestDbContextFactory.Create(accessor, options); + _ = dbContext.Model.FindEntityType(typeof(ShadowWidget)); + }); - Assert.NotNull(entityType); - Assert.Empty(entityType!.GetDeclaredQueryFilters()); + Assert.Contains("Tenant property", exception.Message); } [Fact] From e7f05525edd505e7e344bb736a02007537cc853d Mon Sep 17 00:00:00 2001 From: Reza Heidari Date: Thu, 8 Jan 2026 12:02:15 +0300 Subject: [PATCH 3/6] fix(multitenancy): preserve fallback metadata in consensus Return Default as the source when only fallback candidates are present so the resolver applies configured fallback tenant details. Add regression coverage for fallback-only consensus. --- .../Providers/CompositeTenantResolutionStrategy.cs | 8 ++++++-- .../CompositeTenantResolutionStrategyTests.cs | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/CleanArchitecture.Extensions.Multitenancy/Providers/CompositeTenantResolutionStrategy.cs b/src/CleanArchitecture.Extensions.Multitenancy/Providers/CompositeTenantResolutionStrategy.cs index a03f7d9..e96dfb1 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy/Providers/CompositeTenantResolutionStrategy.cs +++ b/src/CleanArchitecture.Extensions.Multitenancy/Providers/CompositeTenantResolutionStrategy.cs @@ -85,19 +85,23 @@ private async Task ResolveWithConsensusAsync( } var resolvedCandidates = candidates.Count > 0 ? candidates : fallbackCandidates; + var resolvedFromFallback = candidates.Count == 0 && fallbackCandidates.Count > 0; if (resolvedCandidates.Count == 0) { return TenantResolutionResult.NotFound(TenantResolutionSource.Composite); } + var source = resolvedFromFallback ? TenantResolutionSource.Default : TenantResolutionSource.Composite; + if (resolvedCandidates.Count == 1) { var tenantId = resolvedCandidates.First(); - return TenantResolutionResult.Resolved(tenantId, TenantResolutionSource.Composite, TenantResolutionConfidence.Medium); + var confidence = resolvedFromFallback ? TenantResolutionConfidence.Low : TenantResolutionConfidence.Medium; + return TenantResolutionResult.Resolved(tenantId, source, confidence); } - return TenantResolutionResult.FromCandidates(resolvedCandidates, TenantResolutionSource.Composite, TenantResolutionConfidence.Low); + return TenantResolutionResult.FromCandidates(resolvedCandidates, source, TenantResolutionConfidence.Low); } private IReadOnlyList OrderProviders() diff --git a/tests/CleanArchitecture.Extensions.Multitenancy.Tests/CompositeTenantResolutionStrategyTests.cs b/tests/CleanArchitecture.Extensions.Multitenancy.Tests/CompositeTenantResolutionStrategyTests.cs index ab08ee7..1ac5464 100644 --- a/tests/CleanArchitecture.Extensions.Multitenancy.Tests/CompositeTenantResolutionStrategyTests.cs +++ b/tests/CleanArchitecture.Extensions.Multitenancy.Tests/CompositeTenantResolutionStrategyTests.cs @@ -132,7 +132,7 @@ public async Task ResolveAsync_consensus_uses_default_when_only_source() var result = await strategy.ResolveAsync(new TenantResolutionContext()); Assert.Equal("fallback", result.TenantId); - Assert.Equal(TenantResolutionSource.Composite, result.Source); + Assert.Equal(TenantResolutionSource.Default, result.Source); } private sealed class StubProvider : ITenantProvider From af90f7ee05a25f7947a72de13aeeba517508611b Mon Sep 17 00:00:00 2001 From: Reza Heidari Date: Thu, 8 Jan 2026 12:02:31 +0300 Subject: [PATCH 4/6] fix(multitenancy): gate optional requests on allow-anonymous Treat optional requirements as required when AllowAnonymous is false, while keeping RequireTenantByDefault for unannotated requests. Update tests and clarify the option intent. --- .../Filters/TenantRequirementResolver.cs | 13 +++++++-- .../Behaviors/TenantEnforcementBehavior.cs | 27 +++++++++++++------ .../Configuration/MultitenancyOptions.cs | 1 + .../TenantBehaviorTests.cs | 17 +++++++++++- 4 files changed, 47 insertions(+), 11 deletions(-) diff --git a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Filters/TenantRequirementResolver.cs b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Filters/TenantRequirementResolver.cs index da77358..1396034 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Filters/TenantRequirementResolver.cs +++ b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Filters/TenantRequirementResolver.cs @@ -9,18 +9,27 @@ public static TenantRequirementMode Resolve(IEnumerable? metadata, Multi { ArgumentNullException.ThrowIfNull(options); + TenantRequirementMode? requirement = null; + if (metadata is not null) { var requirements = metadata.OfType().ToList(); if (requirements.Count > 0) { - return requirements.Any(requirement => requirement.Requirement == TenantRequirementMode.Required) + requirement = requirements.Any(item => item.Requirement == TenantRequirementMode.Required) ? TenantRequirementMode.Required : TenantRequirementMode.Optional; } } - return options.RequireTenantByDefault && !options.AllowAnonymous + if (requirement.HasValue) + { + return requirement == TenantRequirementMode.Optional && !options.AllowAnonymous + ? TenantRequirementMode.Required + : requirement.Value; + } + + return options.RequireTenantByDefault ? TenantRequirementMode.Required : TenantRequirementMode.Optional; } diff --git a/src/CleanArchitecture.Extensions.Multitenancy/Behaviors/TenantEnforcementBehavior.cs b/src/CleanArchitecture.Extensions.Multitenancy/Behaviors/TenantEnforcementBehavior.cs index 7a8c80a..4c1c400 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy/Behaviors/TenantEnforcementBehavior.cs +++ b/src/CleanArchitecture.Extensions.Multitenancy/Behaviors/TenantEnforcementBehavior.cs @@ -68,21 +68,32 @@ public async Task Handle(TRequest request, RequestHandlerDelegate().ToList(); + if (attributes.Count > 0) + { + requirement = attributes.Any(attribute => attribute.Requirement == TenantRequirementMode.Required) + ? TenantRequirementMode.Required + : TenantRequirementMode.Optional; + } } - var requestType = request.GetType(); - var attributes = requestType.GetCustomAttributes(true).OfType().ToList(); - if (attributes.Count > 0) + if (requirement.HasValue) { - return attributes.Any(attribute => attribute.Requirement == TenantRequirementMode.Required) + return requirement == TenantRequirementMode.Optional && !_options.AllowAnonymous ? TenantRequirementMode.Required - : TenantRequirementMode.Optional; + : requirement.Value; } - return _options.RequireTenantByDefault && !_options.AllowAnonymous + return _options.RequireTenantByDefault ? TenantRequirementMode.Required : TenantRequirementMode.Optional; } diff --git a/src/CleanArchitecture.Extensions.Multitenancy/Configuration/MultitenancyOptions.cs b/src/CleanArchitecture.Extensions.Multitenancy/Configuration/MultitenancyOptions.cs index d8ddc63..11b3c1d 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy/Configuration/MultitenancyOptions.cs +++ b/src/CleanArchitecture.Extensions.Multitenancy/Configuration/MultitenancyOptions.cs @@ -12,6 +12,7 @@ public sealed class MultitenancyOptions /// /// Gets or sets a value indicating whether tenant-less requests are allowed when explicitly marked optional. + /// When false, optional requirements are treated as required. /// public bool AllowAnonymous { get; set; } diff --git a/tests/CleanArchitecture.Extensions.Multitenancy.Tests/TenantBehaviorTests.cs b/tests/CleanArchitecture.Extensions.Multitenancy.Tests/TenantBehaviorTests.cs index aaea942..12f0936 100644 --- a/tests/CleanArchitecture.Extensions.Multitenancy.Tests/TenantBehaviorTests.cs +++ b/tests/CleanArchitecture.Extensions.Multitenancy.Tests/TenantBehaviorTests.cs @@ -14,7 +14,7 @@ namespace CleanArchitecture.Extensions.Multitenancy.Tests; public class TenantEnforcementBehaviorTests { [Fact] - public async Task Handle_allows_optional_attribute_without_tenant() + public async Task Handle_disallows_optional_attribute_when_anonymous_not_allowed() { var currentTenant = new CurrentTenantAccessor(); var options = Options.Create(new MultitenancyOptions @@ -24,6 +24,21 @@ public async Task Handle_allows_optional_attribute_without_tenant() }); var behavior = new TenantEnforcementBehavior(currentTenant, options); + await Assert.ThrowsAsync(() => + behavior.Handle(new OptionalAttributeRequest(), _ => Task.FromResult("ok"), CancellationToken.None)); + } + + [Fact] + public async Task Handle_allows_optional_attribute_when_anonymous_allowed() + { + var currentTenant = new CurrentTenantAccessor(); + var options = Options.Create(new MultitenancyOptions + { + RequireTenantByDefault = true, + AllowAnonymous = true + }); + var behavior = new TenantEnforcementBehavior(currentTenant, options); + var response = await behavior.Handle(new OptionalAttributeRequest(), _ => Task.FromResult("ok"), CancellationToken.None); Assert.Equal("ok", response); From 15900540a53ac8a1ab911ba37e3fd74c69e6b791 Mon Sep 17 00:00:00 2001 From: Reza Heidari Date: Thu, 8 Jan 2026 12:02:46 +0300 Subject: [PATCH 5/6] fix(multitenancy-aspnetcore): concatenate duplicate claims Aggregate same-type claims so the core resolver can detect ambiguous tenant candidates. Add regression coverage for multiple claim values. --- .../DefaultTenantResolutionContextFactory.cs | 13 ++++++++++++- ...faultTenantResolutionContextFactoryTests.cs | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Context/DefaultTenantResolutionContextFactory.cs b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Context/DefaultTenantResolutionContextFactory.cs index 465c167..6443898 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Context/DefaultTenantResolutionContextFactory.cs +++ b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Context/DefaultTenantResolutionContextFactory.cs @@ -51,7 +51,18 @@ public TenantResolutionContext Create(HttpContext httpContext) { foreach (var claim in httpContext.User.Claims) { - if (!context.Claims.ContainsKey(claim.Type)) + if (string.IsNullOrWhiteSpace(claim.Type) || string.IsNullOrWhiteSpace(claim.Value)) + { + continue; + } + + if (context.Claims.TryGetValue(claim.Type, out var existing)) + { + context.Claims[claim.Type] = string.IsNullOrWhiteSpace(existing) + ? claim.Value + : string.Concat(existing, ";", claim.Value); + } + else { context.Claims[claim.Type] = claim.Value; } diff --git a/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/DefaultTenantResolutionContextFactoryTests.cs b/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/DefaultTenantResolutionContextFactoryTests.cs index 65aac43..5f1b4c4 100644 --- a/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/DefaultTenantResolutionContextFactoryTests.cs +++ b/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/DefaultTenantResolutionContextFactoryTests.cs @@ -38,4 +38,22 @@ public void Create_populates_context_from_http_request() Assert.Equal("tenant-1", context.Query["tenantId"]); Assert.Equal("tenant-1", context.Claims["tenant_id"]); } + + [Fact] + public void Create_concatenates_multiple_claims_of_same_type() + { + var options = OptionsFactory.Create(new AspNetCoreMultitenancyOptions()); + var factory = new DefaultTenantResolutionContextFactory(options); + + var httpContext = new DefaultHttpContext(); + httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("tenant_id", "alpha"), + new Claim("tenant_id", "beta") + }, "test")); + + var context = factory.Create(httpContext); + + Assert.Equal("alpha;beta", context.Claims["tenant_id"]); + } } From 602a1d772aea0834b626119877e5654c2fc39d78 Mon Sep 17 00:00:00 2001 From: Reza Heidari Date: Thu, 8 Jan 2026 12:03:00 +0300 Subject: [PATCH 6/6] docs(multitenancy-aspnetcore): clarify middleware ordering Note that auto wiring is intended for host/header resolution and manual ordering is required for route/claim and exception handling. --- .../DependencyInjectionExtensions.cs | 5 ++++- .../README.md | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/DependencyInjectionExtensions.cs b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/DependencyInjectionExtensions.cs index a65b5f8..7bbc4fe 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/DependencyInjectionExtensions.cs +++ b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/DependencyInjectionExtensions.cs @@ -24,7 +24,10 @@ public static class DependencyInjectionExtensions /// Service collection. /// Optional callback to configure . /// Optional callback to configure . - /// Whether to add the multitenancy middleware automatically. + /// + /// Whether to add the multitenancy middleware automatically. Use only for host/header resolution. + /// Route/claim resolution requires manual ordering after routing/authentication. + /// public static IServiceCollection AddCleanArchitectureMultitenancyAspNetCore( this IServiceCollection services, Action? configureCore = null, diff --git a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/README.md b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/README.md index 886a225..9535c4e 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/README.md +++ b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/README.md @@ -28,7 +28,7 @@ using CleanArchitecture.Extensions.Multitenancy.AspNetCore; builder.Services.AddCleanArchitectureMultitenancyAspNetCore(autoUseMiddleware: true); ``` -Use `autoUseMiddleware` when header or host resolution is enough. For claim- or route-based resolution, disable it and place `app.UseCleanArchitectureMultitenancy()` after authentication or routing. +Use `autoUseMiddleware` when header or host resolution is enough and you do not depend on route values. For claim- or route-based resolution, disable it and place `app.UseCleanArchitectureMultitenancy()` after authentication or routing. If you want tenant resolution exceptions to flow through your global exception handler, place it after `app.UseExceptionHandler(...)`. ### 2) Add the middleware (manual)