Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ public static class DependencyInjectionExtensions
/// <param name="services">Service collection.</param>
/// <param name="configureCore">Optional callback to configure <see cref="MultitenancyOptions"/>.</param>
/// <param name="configureAspNetCore">Optional callback to configure <see cref="AspNetCoreMultitenancyOptions"/>.</param>
/// <param name="autoUseMiddleware">Whether to add the multitenancy middleware automatically.</param>
/// <param name="autoUseMiddleware">
/// Whether to add the multitenancy middleware automatically. Use only for host/header resolution.
/// Route/claim resolution requires manual ordering after routing/authentication.
/// </param>
public static IServiceCollection AddCleanArchitectureMultitenancyAspNetCore(
this IServiceCollection services,
Action<MultitenancyOptions>? configureCore = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,27 @@ public static TenantRequirementMode Resolve(IEnumerable<object>? metadata, Multi
{
ArgumentNullException.ThrowIfNull(options);

TenantRequirementMode? requirement = null;

if (metadata is not null)
{
var requirements = metadata.OfType<ITenantRequirement>().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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,6 +22,7 @@ public sealed class TenantResolutionMiddleware
private readonly AspNetCoreMultitenancyOptions _aspNetCoreOptions;
private readonly MultitenancyOptions _options;
private readonly ILogger<TenantResolutionMiddleware> _logger;
private readonly ITenantCorrelationScopeAccessor _scopeAccessor;

/// <summary>
/// Initializes a new instance of the <see cref="TenantResolutionMiddleware"/> class.
Expand All @@ -32,14 +34,16 @@ public sealed class TenantResolutionMiddleware
/// <param name="aspNetCoreOptions">ASP.NET Core multitenancy options.</param>
/// <param name="options">Core multitenancy options.</param>
/// <param name="logger">Logger.</param>
/// <param name="scopeAccessor">Correlation scope accessor.</param>
public TenantResolutionMiddleware(
RequestDelegate next,
ITenantResolver resolver,
ITenantAccessor accessor,
ITenantResolutionContextFactory contextFactory,
IOptions<AspNetCoreMultitenancyOptions> aspNetCoreOptions,
IOptions<MultitenancyOptions> options,
ILogger<TenantResolutionMiddleware> logger)
ILogger<TenantResolutionMiddleware> logger,
ITenantCorrelationScopeAccessor scopeAccessor)
{
_next = next ?? throw new ArgumentNullException(nameof(next));
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
Expand All @@ -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));
}

/// <summary>
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ public void Customize(ModelBuilder modelBuilder, ITenantDbContext context, EfCor
builder.Property<string>(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;
Expand Down Expand Up @@ -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];
Expand Down
5 changes: 5 additions & 0 deletions src/CleanArchitecture.Extensions.Multitenancy/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -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")]
Original file line number Diff line number Diff line change
Expand Up @@ -16,36 +16,32 @@ public sealed class TenantCorrelationBehavior<TRequest, TResponse> : IPipelineBe
private readonly ICurrentTenant _currentTenant;
private readonly MultitenancyOptions _options;
private readonly ILogger<TenantCorrelationBehavior<TRequest, TResponse>> _logger;
private readonly ITenantCorrelationScopeAccessor _scopeAccessor;

/// <summary>
/// Initializes a new instance of the <see cref="TenantCorrelationBehavior{TRequest, TResponse}"/> class.
/// </summary>
public TenantCorrelationBehavior(
ICurrentTenant currentTenant,
IOptions<MultitenancyOptions> options,
ILogger<TenantCorrelationBehavior<TRequest, TResponse>> logger)
ILogger<TenantCorrelationBehavior<TRequest, TResponse>> 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));
}

/// <inheritdoc />
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();

var existingScope = TenantCorrelationScope.Current;
var existingScope = _scopeAccessor.CurrentScope;
if (existingScope is not null)
{
try
{
return await next().ConfigureAwait(false);
}
finally
{
TenantCorrelationScope.Clear()?.Dispose();
}
return await next().ConfigureAwait(false);
}

var tenantId = _currentTenant.TenantId;
Expand All @@ -64,15 +60,15 @@ public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TRe
}

var scope = _logger.BeginScope(new Dictionary<string, object?> { [scopeKey] = tenantId });
TenantCorrelationScope.Set(scope);
_scopeAccessor.SetScope(scope, owned: true);

try
{
return await next().ConfigureAwait(false);
}
finally
{
TenantCorrelationScope.Clear()?.Dispose();
_scopeAccessor.ClearScope(onlyIfOwned: true)?.Dispose();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,21 @@ namespace CleanArchitecture.Extensions.Multitenancy.Behaviors;
public sealed class TenantCorrelationPostProcessor<TRequest, TResponse> : IRequestPostProcessor<TRequest, TResponse>
where TRequest : notnull
{
private readonly ITenantCorrelationScopeAccessor _scopeAccessor;

/// <summary>
/// Initializes a new instance of the <see cref="TenantCorrelationPostProcessor{TRequest, TResponse}"/> class.
/// </summary>
/// <param name="scopeAccessor">Correlation scope accessor.</param>
public TenantCorrelationPostProcessor(ITenantCorrelationScopeAccessor scopeAccessor)
{
_scopeAccessor = scopeAccessor ?? throw new ArgumentNullException(nameof(scopeAccessor));
}

/// <inheritdoc />
public Task Process(TRequest request, TResponse response, CancellationToken cancellationToken)
{
TenantCorrelationScope.Clear()?.Dispose();
_scopeAccessor.ClearScope(onlyIfOwned: true)?.Dispose();
cancellationToken.ThrowIfCancellationRequested();
return Task.CompletedTask;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,29 @@ public sealed class TenantCorrelationPreProcessor<TRequest> : IRequestPreProcess
private readonly ICurrentTenant _currentTenant;
private readonly MultitenancyOptions _options;
private readonly ILogger<TenantCorrelationPreProcessor<TRequest>> _logger;
private readonly ITenantCorrelationScopeAccessor _scopeAccessor;

/// <summary>
/// Initializes a new instance of the <see cref="TenantCorrelationPreProcessor{TRequest}"/> class.
/// </summary>
public TenantCorrelationPreProcessor(
ICurrentTenant currentTenant,
IOptions<MultitenancyOptions> options,
ILogger<TenantCorrelationPreProcessor<TRequest>> logger)
ILogger<TenantCorrelationPreProcessor<TRequest>> 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));
}

/// <inheritdoc />
public Task Process(TRequest request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();

if (TenantCorrelationScope.Current is not null)
if (_scopeAccessor.CurrentScope is not null)
{
return Task.CompletedTask;
}
Expand All @@ -56,7 +59,7 @@ public Task Process(TRequest request, CancellationToken cancellationToken)
}

var scope = _logger.BeginScope(new Dictionary<string, object?> { [scopeKey] = tenantId });
TenantCorrelationScope.Set(scope);
_scopeAccessor.SetScope(scope, owned: true);

return Task.CompletedTask;
}
Expand Down

This file was deleted.

Loading
Loading