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/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/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.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.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) 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/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/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/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..e96dfb1 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy/Providers/CompositeTenantResolutionStrategy.cs +++ b/src/CleanArchitecture.Extensions.Multitenancy/Providers/CompositeTenantResolutionStrategy.cs @@ -68,30 +68,40 @@ 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; + var resolvedFromFallback = candidates.Count == 0 && fallbackCandidates.Count > 0; + + if (resolvedCandidates.Count == 0) { return TenantResolutionResult.NotFound(TenantResolutionSource.Composite); } - if (candidates.Count == 1) + var source = resolvedFromFallback ? TenantResolutionSource.Default : TenantResolutionSource.Composite; + + if (resolvedCandidates.Count == 1) { - var tenantId = candidates.First(); - return TenantResolutionResult.Resolved(tenantId, TenantResolutionSource.Composite, TenantResolutionConfidence.Medium); + var tenantId = resolvedCandidates.First(); + var confidence = resolvedFromFallback ? TenantResolutionConfidence.Low : TenantResolutionConfidence.Medium; + return TenantResolutionResult.Resolved(tenantId, source, confidence); } - return TenantResolutionResult.FromCandidates(candidates, TenantResolutionSource.Composite, TenantResolutionConfidence.Low); + return TenantResolutionResult.FromCandidates(resolvedCandidates, source, TenantResolutionConfidence.Low); } private IReadOnlyList OrderProviders() 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"]); + } } 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.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] diff --git a/tests/CleanArchitecture.Extensions.Multitenancy.Tests/CompositeTenantResolutionStrategyTests.cs b/tests/CleanArchitecture.Extensions.Multitenancy.Tests/CompositeTenantResolutionStrategyTests.cs index 8db58d7..1ac5464 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.Default, 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..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); @@ -208,10 +223,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 +252,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();