diff --git a/BotSharp.sln b/BotSharp.sln index 5079435f3..b87060e96 100644 --- a/BotSharp.sln +++ b/BotSharp.sln @@ -153,6 +153,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotSharp.Plugin.MMPEmbeddin EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotSharp.Plugin.Membase", "src\Plugins\BotSharp.Plugin.Membase\BotSharp.Plugin.Membase.csproj", "{13223C71-9EAC-9835-28ED-5A4833E6F915}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MultiTenancy", "MultiTenancy", "{7C64208C-8D11-4E17-A3E9-14D7910763EB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotSharp.Plugin.MultiTenancy", "src\Plugins\BotSharp.Plugin.MultiTenancy\BotSharp.Plugin.MultiTenancy.csproj", "{9BC8DF43-88D1-4C57-A678-AC0153BDF4EB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -649,6 +653,14 @@ Global {13223C71-9EAC-9835-28ED-5A4833E6F915}.Release|Any CPU.Build.0 = Release|Any CPU {13223C71-9EAC-9835-28ED-5A4833E6F915}.Release|x64.ActiveCfg = Release|Any CPU {13223C71-9EAC-9835-28ED-5A4833E6F915}.Release|x64.Build.0 = Release|Any CPU + {9BC8DF43-88D1-4C57-A678-AC0153BDF4EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9BC8DF43-88D1-4C57-A678-AC0153BDF4EB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9BC8DF43-88D1-4C57-A678-AC0153BDF4EB}.Debug|x64.ActiveCfg = Debug|Any CPU + {9BC8DF43-88D1-4C57-A678-AC0153BDF4EB}.Debug|x64.Build.0 = Debug|Any CPU + {9BC8DF43-88D1-4C57-A678-AC0153BDF4EB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9BC8DF43-88D1-4C57-A678-AC0153BDF4EB}.Release|Any CPU.Build.0 = Release|Any CPU + {9BC8DF43-88D1-4C57-A678-AC0153BDF4EB}.Release|x64.ActiveCfg = Release|Any CPU + {9BC8DF43-88D1-4C57-A678-AC0153BDF4EB}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -723,6 +735,8 @@ Global {E7C243B9-E751-B3B4-8F16-95C76CA90D31} = {51AFE054-AE99-497D-A593-69BAEFB5106F} {394B858B-9C26-B977-A2DA-8CC7BE5914CB} = {4F346DCE-087F-4368-AF88-EE9C720D0E69} {13223C71-9EAC-9835-28ED-5A4833E6F915} = {53E7CD86-0D19-40D9-A0FA-AB4613837E89} + {7C64208C-8D11-4E17-A3E9-14D7910763EB} = {2635EC9B-2E5F-4313-AC21-0B847F31F36C} + {9BC8DF43-88D1-4C57-A678-AC0153BDF4EB} = {7C64208C-8D11-4E17-A3E9-14D7910763EB} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A9969D89-C98B-40A5-A12B-FC87E55B3A19} diff --git a/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ConnectionStringNameAttribute.cs b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ConnectionStringNameAttribute.cs new file mode 100644 index 000000000..74722c06b --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ConnectionStringNameAttribute.cs @@ -0,0 +1,16 @@ +namespace BotSharp.Abstraction.MultiTenancy; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public class ConnectionStringNameAttribute : Attribute +{ + public string Name { get; } + + public ConnectionStringNameAttribute(string name) + { + Name = name; + } + + public static string GetConnStringName(Type type) => type.FullName ?? string.Empty; + + public static string GetConnStringName() => GetConnStringName(typeof(T)); +} \ No newline at end of file diff --git a/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ConnectionStrings.cs b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ConnectionStrings.cs new file mode 100644 index 000000000..31733afb3 --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ConnectionStrings.cs @@ -0,0 +1,13 @@ +namespace BotSharp.Abstraction.MultiTenancy; + +[Serializable] +public class ConnectionStrings : Dictionary +{ + public const string DefaultConnectionStringName = "Default"; + + public string? Default + { + get => this[DefaultConnectionStringName]; + set => this[DefaultConnectionStringName] = value; + } +} \ No newline at end of file diff --git a/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/IConnectionStringResolver.cs b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/IConnectionStringResolver.cs new file mode 100644 index 000000000..99b716a9a --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/IConnectionStringResolver.cs @@ -0,0 +1,8 @@ +namespace BotSharp.Abstraction.MultiTenancy; + +public interface IConnectionStringResolver +{ + string? GetConnectionString(string connectionStringName); + + string? GetConnectionString(); +} diff --git a/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ICurrentTenant.cs b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ICurrentTenant.cs new file mode 100644 index 000000000..815b801ad --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ICurrentTenant.cs @@ -0,0 +1,12 @@ +namespace BotSharp.Abstraction.MultiTenancy; + +public interface ICurrentTenant +{ + Guid? Id { get; } + + string? Name { get; } + + string? TenantId => Id?.ToString(); + + IDisposable Change(Guid? id, string? name = null); +} \ No newline at end of file diff --git a/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ITenantConnectionProvider.cs b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ITenantConnectionProvider.cs new file mode 100644 index 000000000..934611983 --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ITenantConnectionProvider.cs @@ -0,0 +1,7 @@ +namespace BotSharp.Abstraction.MultiTenancy; + +public interface ITenantConnectionProvider +{ + string GetConnectionString(string name); + string GetDefaultConnectionString(); +} \ No newline at end of file diff --git a/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ITenantFeature.cs b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ITenantFeature.cs new file mode 100644 index 000000000..95c8c223e --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ITenantFeature.cs @@ -0,0 +1,6 @@ +namespace BotSharp.Abstraction.MultiTenancy; + +public interface ITenantFeature +{ + bool Enabled { get; } +} \ No newline at end of file diff --git a/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ITenantOptionProvider.cs b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ITenantOptionProvider.cs new file mode 100644 index 000000000..658f1498e --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ITenantOptionProvider.cs @@ -0,0 +1,8 @@ +using BotSharp.Abstraction.MultiTenancy.Models; + +namespace BotSharp.Abstraction.MultiTenancy; + +public interface ITenantOptionProvider +{ + Task> GetOptionsAsync(); +} \ No newline at end of file diff --git a/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ITenantResolveContributor.cs b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ITenantResolveContributor.cs new file mode 100644 index 000000000..97928389c --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ITenantResolveContributor.cs @@ -0,0 +1,10 @@ +using BotSharp.Abstraction.MultiTenancy.Models; + +namespace BotSharp.Abstraction.MultiTenancy; + +public interface ITenantResolveContributor +{ + string Name { get; } + + Task ResolveAsync(TenantResolveContext context); +} \ No newline at end of file diff --git a/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ITenantResolver.cs b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ITenantResolver.cs new file mode 100644 index 000000000..510dd6c11 --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/ITenantResolver.cs @@ -0,0 +1,8 @@ +using BotSharp.Abstraction.MultiTenancy.Models; + +namespace BotSharp.Abstraction.MultiTenancy; + +public interface ITenantResolver +{ + Task ResolveAsync(TenantResolveContext context); +} diff --git a/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/Models/TenantOption.cs b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/Models/TenantOption.cs new file mode 100644 index 000000000..8ef18fbfb --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/Models/TenantOption.cs @@ -0,0 +1,3 @@ +namespace BotSharp.Abstraction.MultiTenancy.Models; + +public sealed record TenantOption(Guid TenantId, string Name); \ No newline at end of file diff --git a/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/Models/TenantResolveContext.cs b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/Models/TenantResolveContext.cs new file mode 100644 index 000000000..5b25b7aae --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/Models/TenantResolveContext.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Http; + +namespace BotSharp.Abstraction.MultiTenancy.Models; + +public class TenantResolveContext +{ + public HttpContext HttpContext { get; set; } = default!; +} \ No newline at end of file diff --git a/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/Models/TenantResolveResult.cs b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/Models/TenantResolveResult.cs new file mode 100644 index 000000000..d504ae2d5 --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/Models/TenantResolveResult.cs @@ -0,0 +1,8 @@ +namespace BotSharp.Abstraction.MultiTenancy.Models; + +public class TenantResolveResult +{ + public Guid? TenantId { get; set; } + public string? Name { get; set; } + public bool Succeeded => TenantId.HasValue; +} \ No newline at end of file diff --git a/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/Options/TenantConfiguration.cs b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/Options/TenantConfiguration.cs new file mode 100644 index 000000000..59cf4b14e --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/Options/TenantConfiguration.cs @@ -0,0 +1,38 @@ +using System.Diagnostics.CodeAnalysis; + +namespace BotSharp.Abstraction.MultiTenancy.Options; + +public class TenantConfiguration +{ + public Guid Id { get; set; } + + public string Name { get; set; } = default!; + + public string NormalizedName { get; set; } = default!; + + public ConnectionStrings? ConnectionStrings { get; set; } + + public bool IsActive { get; set; } + + public TenantConfiguration() + { + IsActive = true; + } + + public TenantConfiguration(Guid id, [NotNull] string name) + : this() + { + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Name cannot be null or whitespace."); + Id = id; + Name = name; + + ConnectionStrings = new ConnectionStrings(); + } + + public TenantConfiguration(Guid id, [NotNull] string name, [NotNull] string normalizedName) + : this(id, name) + { + if (string.IsNullOrWhiteSpace(normalizedName)) throw new ArgumentException("NormalizedName cannot be null or whitespace."); + NormalizedName = normalizedName; + } +} \ No newline at end of file diff --git a/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/Options/TenantStoreOptions.cs b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/Options/TenantStoreOptions.cs new file mode 100644 index 000000000..24c35e0d4 --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/MultiTenancy/Options/TenantStoreOptions.cs @@ -0,0 +1,8 @@ +namespace BotSharp.Abstraction.MultiTenancy.Options; + +public class TenantStoreOptions +{ + public bool Enabled { get; set; } = false; + + public TenantConfiguration[] Tenants { get; set; } = []; +} \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.MongoStorage/MongoStoragePlugin.cs b/src/Plugins/BotSharp.Plugin.MongoStorage/MongoStoragePlugin.cs index 3ac76339e..a260af7b4 100644 --- a/src/Plugins/BotSharp.Plugin.MongoStorage/MongoStoragePlugin.cs +++ b/src/Plugins/BotSharp.Plugin.MongoStorage/MongoStoragePlugin.cs @@ -1,3 +1,4 @@ +using BotSharp.Abstraction.MultiTenancy; using BotSharp.Abstraction.Repositories.Enums; using BotSharp.Abstraction.Repositories.Settings; using BotSharp.Plugin.MongoStorage.Repository; @@ -25,10 +26,32 @@ public void RegisterDI(IServiceCollection services, IConfiguration config) var conventionPack = new ConventionPack { new IgnoreExtraElementsConvention(true) }; ConventionRegistry.Register("IgnoreExtraElements", conventionPack, type => true); - services.AddSingleton((IServiceProvider x) => + var tenantEnabled = config.GetValue("TenantStore:Enabled", false); + if (tenantEnabled) { - return new MongoDbContext(dbSettings); - }); + services.AddScoped((IServiceProvider x) => + { + var tenantEnabled = x.GetService()?.Enabled ?? false; + if (tenantEnabled) + { + var provider = x.GetService(); + if (provider != null) + { + var cs = provider.GetConnectionString("BotSharpMongoDb"); + if (!string.IsNullOrWhiteSpace(cs)) dbSettings.BotSharpMongoDb = cs; + } + } + + return new MongoDbContext(dbSettings); + }); + } + else + { + services.AddSingleton((IServiceProvider x) => + { + return new MongoDbContext(dbSettings); + }); + } services.AddScoped(); } diff --git a/src/Plugins/BotSharp.Plugin.MultiTenancy/BotSharp.Plugin.MultiTenancy.csproj b/src/Plugins/BotSharp.Plugin.MultiTenancy/BotSharp.Plugin.MultiTenancy.csproj new file mode 100644 index 000000000..d961f97d1 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.MultiTenancy/BotSharp.Plugin.MultiTenancy.csproj @@ -0,0 +1,17 @@ + + + + $(TargetFramework) + $(LangVersion) + enable + $(BotSharpVersion) + $(GeneratePackageOnBuild) + $(GenerateDocumentationFile) + $(SolutionDir)packages + + + + + + + \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.MultiTenancy/Controllers/TenantsController.cs b/src/Plugins/BotSharp.Plugin.MultiTenancy/Controllers/TenantsController.cs new file mode 100644 index 000000000..317058ffc --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.MultiTenancy/Controllers/TenantsController.cs @@ -0,0 +1,28 @@ +using BotSharp.Abstraction.MultiTenancy; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Linq; +using System.Threading.Tasks; + +namespace BotSharp.Plugin.MultiTenancy.Controllers; + +[ApiController] +public class TenantsController : ControllerBase +{ + private readonly ITenantOptionProvider _tenantOptionProvider; + + public TenantsController(ITenantOptionProvider tenantOptionProvider) + { + _tenantOptionProvider = tenantOptionProvider; + } + + [AllowAnonymous] + [HttpGet] + [Route("/tenants/options")] + public async Task Options() + { + var tenants = await _tenantOptionProvider.GetOptionsAsync(); + var payload = tenants.Select(t => new { tenantId = t.TenantId, name = t.Name }).ToArray(); + return Ok(payload); + } +} \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.MultiTenancy/Enums/TenantConsts.cs b/src/Plugins/BotSharp.Plugin.MultiTenancy/Enums/TenantConsts.cs new file mode 100644 index 000000000..22ba4d969 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.MultiTenancy/Enums/TenantConsts.cs @@ -0,0 +1,8 @@ +namespace BotSharp.Plugin.MultiTenancy.Enums; + +public static class TenantConsts +{ + public const string DefaultTenantKey = "__tenant"; + + public const string TenantId = "tenantid"; +} \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.MultiTenancy/Extensions/ApplicationBuilderExtensions.cs b/src/Plugins/BotSharp.Plugin.MultiTenancy/Extensions/ApplicationBuilderExtensions.cs new file mode 100644 index 000000000..0bd16e451 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.MultiTenancy/Extensions/ApplicationBuilderExtensions.cs @@ -0,0 +1,12 @@ +using BotSharp.Plugin.MultiTenancy.MultiTenancy; +using Microsoft.AspNetCore.Builder; + +namespace BotSharp.Plugin.MultiTenancy.Extensions; + +public static class ApplicationBuilderExtensions +{ + public static IApplicationBuilder UseMultiTenancy(this IApplicationBuilder app) + { + return app.UseMiddleware(); + } +} \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.MultiTenancy/Extensions/MultiTenancyServiceCollectionExtensions.cs b/src/Plugins/BotSharp.Plugin.MultiTenancy/Extensions/MultiTenancyServiceCollectionExtensions.cs new file mode 100644 index 000000000..a9e7766df --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.MultiTenancy/Extensions/MultiTenancyServiceCollectionExtensions.cs @@ -0,0 +1,36 @@ +using BotSharp.Abstraction.MultiTenancy; +using BotSharp.Abstraction.MultiTenancy.Options; +using BotSharp.Plugin.MultiTenancy.Models; +using BotSharp.Plugin.MultiTenancy.MultiTenancy; +using BotSharp.Plugin.MultiTenancy.MultiTenancy.Resolvers; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace BotSharp.Plugin.MultiTenancy.Extensions; + +public static class MultiTenancyServiceCollectionExtensions +{ + public static IServiceCollection AddMultiTenancy(this IServiceCollection services, IConfiguration configuration, string sectionName = "TenantStore") + { + services.Configure(options => + { + options.TenantResolvers.Add(new ClaimsTenantResolveContributor()); + options.TenantResolvers.Add(new HeaderTenantResolveContributor()); + options.TenantResolvers.Add(new QueryStringTenantResolveContributor()); + }); + + services.Configure(configuration.GetSection(sectionName)); + services.AddScoped(); + services.AddSingleton(AsyncLocalCurrentTenantAccessor.Instance); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); + + services.TryAddScoped(); + + return services; + } +} \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.MultiTenancy/Models/TenantInfoBasic.cs b/src/Plugins/BotSharp.Plugin.MultiTenancy/Models/TenantInfoBasic.cs new file mode 100644 index 000000000..4d1a85e03 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.MultiTenancy/Models/TenantInfoBasic.cs @@ -0,0 +1,5 @@ +using System; + +namespace BotSharp.Plugin.MultiTenancy.Models; + +public sealed record TenantInfoBasic(Guid? TenantId, string? Name); \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.MultiTenancy/Models/TenantResolveOptions.cs b/src/Plugins/BotSharp.Plugin.MultiTenancy/Models/TenantResolveOptions.cs new file mode 100644 index 000000000..835a04433 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.MultiTenancy/Models/TenantResolveOptions.cs @@ -0,0 +1,9 @@ +using BotSharp.Abstraction.MultiTenancy; +using System.Collections.Generic; + +namespace BotSharp.Plugin.MultiTenancy.Models; + +public class TenantResolveOptions +{ + public List TenantResolvers { get; set; } = new(); +} \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/AsyncLocalCurrentTenantAccessor.cs b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/AsyncLocalCurrentTenantAccessor.cs new file mode 100644 index 000000000..be8fcc7e5 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/AsyncLocalCurrentTenantAccessor.cs @@ -0,0 +1,21 @@ +using BotSharp.Plugin.MultiTenancy.Models; +using System.Threading; + +namespace BotSharp.Plugin.MultiTenancy.MultiTenancy; + +public class AsyncLocalCurrentTenantAccessor : ICurrentTenantAccessor +{ + private readonly AsyncLocal _currentScope; + private AsyncLocalCurrentTenantAccessor() + { + _currentScope = new AsyncLocal(); + } + + public static AsyncLocalCurrentTenantAccessor Instance { get; } = new(); + + public TenantInfoBasic? Current + { + get => _currentScope.Value; + set => _currentScope.Value = value; + } +} diff --git a/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/ConfigTenantOptionProvider.cs b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/ConfigTenantOptionProvider.cs new file mode 100644 index 000000000..8449e8cf2 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/ConfigTenantOptionProvider.cs @@ -0,0 +1,37 @@ +using BotSharp.Abstraction.MultiTenancy; +using BotSharp.Abstraction.MultiTenancy.Models; +using BotSharp.Abstraction.MultiTenancy.Options; +using Microsoft.Extensions.Options; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace BotSharp.Plugin.MultiTenancy.MultiTenancy; + +public class ConfigTenantOptionProvider : ITenantOptionProvider +{ + private readonly IOptionsMonitor _tenantStoreOptions; + private readonly ITenantFeature _feature; + + public ConfigTenantOptionProvider(IOptionsMonitor tenantStoreOptions, ITenantFeature feature) + { + _tenantStoreOptions = tenantStoreOptions; + _feature = feature; + } + + public Task> GetOptionsAsync() + { + if (!_feature.Enabled) + { + return Task.FromResult>(Array.Empty()); + } + + var tenants = _tenantStoreOptions.CurrentValue.Tenants + .Select(t => new TenantOption(t.Id, t.Name)) + .Distinct() + .ToArray(); + + return Task.FromResult>(tenants); + } +} \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/CurrentTenant.cs b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/CurrentTenant.cs new file mode 100644 index 000000000..4c97cb23d --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/CurrentTenant.cs @@ -0,0 +1,36 @@ +using BotSharp.Abstraction.MultiTenancy; +using BotSharp.Plugin.MultiTenancy.Models; +using System; + +namespace BotSharp.Plugin.MultiTenancy.MultiTenancy; + +public class CurrentTenant : ICurrentTenant +{ + private readonly ICurrentTenantAccessor _currentTenantAccessor; + + public CurrentTenant(ICurrentTenantAccessor currentTenantAccessor) + { + _currentTenantAccessor = currentTenantAccessor; + } + + public virtual Guid? Id => _currentTenantAccessor.Current?.TenantId; + + public string? Name => _currentTenantAccessor.Current?.Name; + + public IDisposable Change(Guid? id, string? name = null) + { + return SetCurrent(id, name); + } + + private IDisposable SetCurrent(Guid? tenantId, string? name = null) + { + var parentScope = _currentTenantAccessor.Current; + _currentTenantAccessor.Current = new TenantInfoBasic(tenantId, name); + + return new DisposeAction>(static (state) => + { + var (currentTenantAccessor, parentScope) = state; + currentTenantAccessor.Current = parentScope; + }, (_currentTenantAccessor, parentScope)); + } +} \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/DefaultConnectionStringResolver.cs b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/DefaultConnectionStringResolver.cs new file mode 100644 index 000000000..deb47317c --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/DefaultConnectionStringResolver.cs @@ -0,0 +1,39 @@ +using BotSharp.Abstraction.MultiTenancy; +using BotSharp.Abstraction.MultiTenancy.Options; +using Microsoft.Extensions.Options; +using System.Collections.Generic; +using System.Linq; + +namespace BotSharp.Plugin.MultiTenancy.MultiTenancy +{ + public class DefaultConnectionStringResolver : IConnectionStringResolver + { + private readonly TenantStoreOptions _tenantStoreOptions; + private readonly ICurrentTenant _currentTenant; + + public DefaultConnectionStringResolver(IOptionsMonitor tenantStoreOptions, ICurrentTenant currentTenant) + { + _tenantStoreOptions = tenantStoreOptions.CurrentValue; + _currentTenant = currentTenant; + } + + public string? GetConnectionString(string connectionStringName) + { + if (!_tenantStoreOptions.Enabled || !_tenantStoreOptions.Tenants.Any()) return null; + if (_currentTenant.Id.HasValue) + { + var tenant = _tenantStoreOptions.Tenants.FirstOrDefault(t => t.Id == _currentTenant.Id.Value); + return tenant?.ConnectionStrings?.GetValueOrDefault(connectionStringName); + } + + return null; + } + + public string? GetConnectionString() + { + var contextType = typeof(TContext); + var connStringName = ConnectionStringNameAttribute.GetConnStringName(contextType); + return GetConnectionString(connStringName); + } + } +} \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/DisposeAction.cs b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/DisposeAction.cs new file mode 100644 index 000000000..697b23560 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/DisposeAction.cs @@ -0,0 +1,20 @@ +using System; + +namespace BotSharp.Plugin.MultiTenancy.MultiTenancy; + +public sealed class DisposeAction : IDisposable +{ + private readonly Action _action; + private readonly T _state; + + public DisposeAction(Action action, T state) + { + _action = action; + _state = state; + } + + public void Dispose() + { + _action?.Invoke(_state); + } +} \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/ICurrentTenantAccessor.cs b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/ICurrentTenantAccessor.cs new file mode 100644 index 000000000..50245b5b7 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/ICurrentTenantAccessor.cs @@ -0,0 +1,8 @@ +using BotSharp.Plugin.MultiTenancy.Models; + +namespace BotSharp.Plugin.MultiTenancy.MultiTenancy; + +public interface ICurrentTenantAccessor +{ + TenantInfoBasic? Current { get; set; } +} \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/MultiTenancyMiddleware.cs b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/MultiTenancyMiddleware.cs new file mode 100644 index 000000000..3b6a737a5 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/MultiTenancyMiddleware.cs @@ -0,0 +1,43 @@ +using BotSharp.Abstraction.MultiTenancy; +using BotSharp.Abstraction.MultiTenancy.Models; +using Microsoft.AspNetCore.Http; +using System.Threading.Tasks; + +namespace BotSharp.Plugin.MultiTenancy.MultiTenancy; + +public class MultiTenancyMiddleware : IMiddleware +{ + private readonly ITenantResolver _resolver; + private readonly ICurrentTenant _currentTenant; + private readonly ITenantFeature _feature; + + public MultiTenancyMiddleware(ITenantResolver resolver, ICurrentTenant currentTenant, ITenantFeature feature) + { + _resolver = resolver; + _currentTenant = currentTenant; + _feature = feature; + } + + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + if (!_feature.Enabled) + { + await next(context); + return; + } + + var resolveContext = new TenantResolveContext { HttpContext = context }; + var resolveResult = await _resolver.ResolveAsync(resolveContext); + if (resolveResult.TenantId.HasValue) + { + using (_currentTenant.Change(resolveResult.TenantId, resolveResult.Name)) + { + await next(context); + } + } + else + { + await next(context); + } + } +} \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/Resolvers/ClaimsTenantResolverContributor.cs b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/Resolvers/ClaimsTenantResolverContributor.cs new file mode 100644 index 000000000..7427bc08d --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/Resolvers/ClaimsTenantResolverContributor.cs @@ -0,0 +1,23 @@ +using BotSharp.Abstraction.MultiTenancy; +using BotSharp.Abstraction.MultiTenancy.Models; +using BotSharp.Plugin.MultiTenancy.Enums; +using System.Threading.Tasks; + +namespace BotSharp.Plugin.MultiTenancy.MultiTenancy.Resolvers; + +public class ClaimsTenantResolveContributor : ITenantResolveContributor +{ + public string Name => "Claims"; + + public Task ResolveAsync(TenantResolveContext context) + { + var user = context.HttpContext.User; + var claim = user.FindFirst(TenantConsts.TenantId); + if (claim != null && System.Guid.TryParse(claim.Value, out var id)) + { + return Task.FromResult(new TenantResolveResult { TenantId = id }); + } + + return Task.FromResult(new TenantResolveResult()); + } +} \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/Resolvers/HeaderTenantResolveContributor.cs b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/Resolvers/HeaderTenantResolveContributor.cs new file mode 100644 index 000000000..6fada9fd9 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/Resolvers/HeaderTenantResolveContributor.cs @@ -0,0 +1,27 @@ +using BotSharp.Abstraction.MultiTenancy; +using BotSharp.Abstraction.MultiTenancy.Models; +using BotSharp.Plugin.MultiTenancy.Enums; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace BotSharp.Plugin.MultiTenancy.MultiTenancy.Resolvers; + +public class HeaderTenantResolveContributor : ITenantResolveContributor +{ + public string Name => "Header"; + + public Task ResolveAsync(TenantResolveContext context) + { + var request = context.HttpContext.Request; + if (request.Headers.TryGetValue(TenantConsts.DefaultTenantKey, out var values)) + { + if (Guid.TryParse(values.FirstOrDefault(), out var id)) + { + return Task.FromResult(new TenantResolveResult { TenantId = id }); + } + } + + return Task.FromResult(new TenantResolveResult()); + } +} \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/Resolvers/QueryStringTenantResolveContributor.cs b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/Resolvers/QueryStringTenantResolveContributor.cs new file mode 100644 index 000000000..14243488e --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/Resolvers/QueryStringTenantResolveContributor.cs @@ -0,0 +1,26 @@ +using BotSharp.Abstraction.MultiTenancy; +using BotSharp.Abstraction.MultiTenancy.Models; +using BotSharp.Plugin.MultiTenancy.Enums; +using System.Linq; +using System.Threading.Tasks; + +namespace BotSharp.Plugin.MultiTenancy.MultiTenancy.Resolvers; + +public class QueryStringTenantResolveContributor : ITenantResolveContributor +{ + public string Name => "QueryString"; + + public Task ResolveAsync(TenantResolveContext context) + { + var request = context.HttpContext.Request; + if (request.Query.TryGetValue(TenantConsts.TenantId, out var values)) + { + if (System.Guid.TryParse(values.FirstOrDefault(), out var id)) + { + return Task.FromResult(new TenantResolveResult { TenantId = id }); + } + } + + return Task.FromResult(new TenantResolveResult()); + } +} \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/TenantConnectionProvider.cs b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/TenantConnectionProvider.cs new file mode 100644 index 000000000..9dc89a21a --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/TenantConnectionProvider.cs @@ -0,0 +1,28 @@ +using BotSharp.Abstraction.MultiTenancy; +using Microsoft.Extensions.Configuration; + +namespace BotSharp.Plugin.MultiTenancy.MultiTenancy; + +public class TenantConnectionProvider : ITenantConnectionProvider +{ + private readonly IConnectionStringResolver _resolver; + private readonly IConfiguration _configuration; + + public TenantConnectionProvider(IConnectionStringResolver resolver, IConfiguration configuration) + { + _resolver = resolver; + _configuration = configuration; + } + + public string GetConnectionString(string name) + { + var cs = _resolver.GetConnectionString(name); + if (!string.IsNullOrWhiteSpace(cs)) return cs; + return _configuration.GetConnectionString(name) ?? string.Empty; + } + + public string GetDefaultConnectionString() + { + return GetConnectionString("Default"); + } +} \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/TenantFeature.cs b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/TenantFeature.cs new file mode 100644 index 000000000..714c34b04 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/TenantFeature.cs @@ -0,0 +1,17 @@ +using BotSharp.Abstraction.MultiTenancy; +using BotSharp.Abstraction.MultiTenancy.Options; +using Microsoft.Extensions.Options; + +namespace BotSharp.Plugin.MultiTenancy.MultiTenancy; + +public class TenantFeature : ITenantFeature +{ + private readonly TenantStoreOptions _tenantStoreOptions; + + public TenantFeature(IOptions tenantStoreOptions) + { + _tenantStoreOptions = tenantStoreOptions.Value; + } + + public bool Enabled => _tenantStoreOptions.Enabled; +} \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/TenantResolver.cs b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/TenantResolver.cs new file mode 100644 index 000000000..b754ea2e6 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancy/TenantResolver.cs @@ -0,0 +1,27 @@ +using BotSharp.Abstraction.MultiTenancy; +using BotSharp.Abstraction.MultiTenancy.Models; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace BotSharp.Plugin.MultiTenancy.MultiTenancy; + +public class TenantResolver : ITenantResolver +{ + private readonly IEnumerable _contributors; + + public TenantResolver(IEnumerable contributors) + { + _contributors = contributors; + } + + public async Task ResolveAsync(TenantResolveContext context) + { + foreach (var c in _contributors) + { + var result = await c.ResolveAsync(context); + if (result.Succeeded) return result; + } + + return new TenantResolveResult(); + } +} \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancyPlugin.cs b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancyPlugin.cs new file mode 100644 index 000000000..1ad293a78 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.MultiTenancy/MultiTenancyPlugin.cs @@ -0,0 +1,19 @@ +using BotSharp.Abstraction.Plugins; +using BotSharp.Plugin.MultiTenancy.Extensions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace BotSharp.Plugin.MultiTenancy; + +public class MultiTenancyPlugin: IBotSharpPlugin +{ + public string Id => "55adcb55-3d05-400e-92f2-65cdeefba360"; + public string Name => "MultiTenancy"; + public string Description => "Multi-tenancy support plugin"; + public string IconUrl => null; + + public void RegisterDI(IServiceCollection services, IConfiguration config) + { + services.AddMultiTenancy(config); + } +} \ No newline at end of file diff --git a/src/WebStarter/Program.cs b/src/WebStarter/Program.cs index 2c9c073c2..49aa72317 100644 --- a/src/WebStarter/Program.cs +++ b/src/WebStarter/Program.cs @@ -6,6 +6,7 @@ using Serilog; using BotSharp.Abstraction.Messaging.JsonConverters; using StackExchange.Redis; +using BotSharp.Plugin.MultiTenancy.Extensions; var builder = WebApplication.CreateBuilder(args); @@ -42,7 +43,6 @@ { o.Configuration.ChannelPrefix = RedisChannel.Literal("botsharp"); })*/; - var app = builder.Build(); app.UseWebSockets(); @@ -52,6 +52,9 @@ app.UseMiddleware(); app.UseMiddleware(); +// Enable Multi-Tenancy +app.UseMultiTenancy(); + // Use BotSharp app.UseBotSharp() .UseBotSharpOpenAPI(app.Environment) diff --git a/src/WebStarter/WebStarter.csproj b/src/WebStarter/WebStarter.csproj index c49e28cfc..07dba6efd 100644 --- a/src/WebStarter/WebStarter.csproj +++ b/src/WebStarter/WebStarter.csproj @@ -39,6 +39,7 @@ + diff --git a/src/WebStarter/appsettings.json b/src/WebStarter/appsettings.json index 24aeb5e2e..e0aca50d8 100644 --- a/src/WebStarter/appsettings.json +++ b/src/WebStarter/appsettings.json @@ -1047,7 +1047,32 @@ "BotSharp.Plugin.SqlDriver", "BotSharp.Plugin.TencentCos", "BotSharp.Plugin.PythonInterpreter", - "BotSharp.Plugin.FuzzySharp" + "BotSharp.Plugin.FuzzySharp", + "BotSharp.Plugin.MultiTenancy" + ] + }, + + "TenantStore": { + "Enabled": false, + "Tenants": [ + { + "Id": "12c0e161-c7cb-41b4-a9fa-fb31de7f6818", + "Name": "Demo1", + "NormalizedName": "DEMO1", + "ConnectionStrings": { + "Default": "", + "BotSharpMongoDb": "mongodb://localhost:27017/Demo1" + } + }, + { + "Id": "d56a4871-2c2e-467d-ac73-a547db29e425", + "Name": "Demo2", + "NormalizedName": "DEMO2", + "ConnectionStrings": { + "Default": "", + "BotSharpMongoDb": "mongodb://localhost:27017/Demo2" + } + } ] } }