diff --git a/AuthPermissions.AspNetCore.sln b/AuthPermissions.AspNetCore.sln index 429cce58..99e7d3ac 100644 --- a/AuthPermissions.AspNetCore.sln +++ b/AuthPermissions.AspNetCore.sln @@ -34,6 +34,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Example3.InvoiceCode", "Exa EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Example5.MvcWebApp.AzureAdB2C", "Example5.MvcWebApp.AzureAdB2C\Example5.MvcWebApp.AzureAdB2C.csproj", "{8019DAB0-F134-4980-8B1F-0190DF5A41BD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example6.SingleLevelSharding", "Example6.SingleLevelSharding\Example6.SingleLevelSharding.csproj", "{9D89C5A6-3692-40A8-A83E-722D99C3004B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -176,6 +178,18 @@ Global {8019DAB0-F134-4980-8B1F-0190DF5A41BD}.Release|x64.Build.0 = Release|Any CPU {8019DAB0-F134-4980-8B1F-0190DF5A41BD}.Release|x86.ActiveCfg = Release|Any CPU {8019DAB0-F134-4980-8B1F-0190DF5A41BD}.Release|x86.Build.0 = Release|Any CPU + {9D89C5A6-3692-40A8-A83E-722D99C3004B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9D89C5A6-3692-40A8-A83E-722D99C3004B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9D89C5A6-3692-40A8-A83E-722D99C3004B}.Debug|x64.ActiveCfg = Debug|Any CPU + {9D89C5A6-3692-40A8-A83E-722D99C3004B}.Debug|x64.Build.0 = Debug|Any CPU + {9D89C5A6-3692-40A8-A83E-722D99C3004B}.Debug|x86.ActiveCfg = Debug|Any CPU + {9D89C5A6-3692-40A8-A83E-722D99C3004B}.Debug|x86.Build.0 = Debug|Any CPU + {9D89C5A6-3692-40A8-A83E-722D99C3004B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9D89C5A6-3692-40A8-A83E-722D99C3004B}.Release|Any CPU.Build.0 = Release|Any CPU + {9D89C5A6-3692-40A8-A83E-722D99C3004B}.Release|x64.ActiveCfg = Release|Any CPU + {9D89C5A6-3692-40A8-A83E-722D99C3004B}.Release|x64.Build.0 = Release|Any CPU + {9D89C5A6-3692-40A8-A83E-722D99C3004B}.Release|x86.ActiveCfg = Release|Any CPU + {9D89C5A6-3692-40A8-A83E-722D99C3004B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/AuthPermissions.AspNetCore/AccessTenantData/ILinkToTenantDataService.cs b/AuthPermissions.AspNetCore/AccessTenantData/ILinkToTenantDataService.cs index 1aed6080..879b6634 100644 --- a/AuthPermissions.AspNetCore/AccessTenantData/ILinkToTenantDataService.cs +++ b/AuthPermissions.AspNetCore/AccessTenantData/ILinkToTenantDataService.cs @@ -36,6 +36,14 @@ public interface ILinkToTenantDataService /// string GetDataKeyOfLinkedTenant(); + /// + /// This gets the DataKey and ConnectionName from the + /// If there no cookie it returns null for both properties + /// + /// + /// + (string dataKey, string connectionName) GetShardingDataOfLinkedTenant(); + /// /// This gets the TenantFullName of the tenant that the contains /// If there no cookie it returns null diff --git a/AuthPermissions.AspNetCore/AccessTenantData/Services/LinkToTenantDataService.cs b/AuthPermissions.AspNetCore/AccessTenantData/Services/LinkToTenantDataService.cs index c29ede9f..895be72f 100644 --- a/AuthPermissions.AspNetCore/AccessTenantData/Services/LinkToTenantDataService.cs +++ b/AuthPermissions.AspNetCore/AccessTenantData/Services/LinkToTenantDataService.cs @@ -2,6 +2,7 @@ // Licensed under MIT license. See License.txt in the project root for license information. using System.Threading.Tasks; +using AuthPermissions.AdminCode; using AuthPermissions.CommonCode; using AuthPermissions.DataLayer.Classes; using AuthPermissions.DataLayer.EfCode; @@ -94,13 +95,37 @@ public void StopLinkingToTenant() /// If there no cookie it returns null /// /// + /// public string GetDataKeyOfLinkedTenant() { + if (_options.TenantType.IsSharding()) + throw new AuthPermissionsException("You shouldn't be using this method if sharding is turn on"); + var cookieValue = _cookieAccessor.GetValue(); return cookieValue == null ? null : DecodeCookieContent(cookieValue).dataKey; } + /// + /// This gets the DataKey and ConnectionName from the + /// If there no cookie it returns null for both properties + /// + /// + /// + public (string dataKey, string connectionName) GetShardingDataOfLinkedTenant() + { + if (!_options.TenantType.IsSharding()) + throw new AuthPermissionsException("You shouldn't be using this method if sharding is turned off"); + + var cookieValue = _cookieAccessor.GetValue(); + if (cookieValue == null) + return (null, null); + + var content = DecodeCookieContent(cookieValue); + + return (content.dataKey, content.connectionName); + } + /// /// This gets the TenantFullName of the tenant that the contains /// If there no cookie it returns null @@ -119,29 +144,37 @@ public string GetNameOfLinkedTenant() private string EncodeCookieContent(Tenant tenantToLinkToTenant) { - //thanks to https://stackoverflow.com/questions/13254211/how-to-convert-string-to-datetime-as-utc-as-simple-as-that - //var threeValues = $"{tenantToLinkToTenant.GetTenantDataKey()},{DateTime.UtcNow.ToShortTimeString()},{tenantToLinkToTenant.TenantFullName}"; - var twoValues = $"{tenantToLinkToTenant.GetTenantDataKey()},{tenantToLinkToTenant.TenantFullName}"; + var values = _options.TenantType.IsSharding() + ? $"{tenantToLinkToTenant.GetTenantDataKey()},{tenantToLinkToTenant.ConnectionName},{tenantToLinkToTenant.TenantFullName}" + : $"{tenantToLinkToTenant.GetTenantDataKey()},{tenantToLinkToTenant.TenantFullName}"; - return _encryptorService.Encrypt(twoValues); + return _encryptorService.Encrypt(values); } - private (string dataKey, string tenantName) DecodeCookieContent(string cookieValue) + private (string dataKey, string tenantName, string connectionName) DecodeCookieContent(string cookieValue) { - string twoValues; + string values; try { - twoValues = _encryptorService.Decrypt(cookieValue); + values = _encryptorService.Decrypt(cookieValue); } catch { throw new AuthPermissionsException("The content of the Access Tenant Data cookie was bad."); } - var firstComma = twoValues.IndexOf(','); + var firstComma = values.IndexOf(','); if (firstComma == -1) throw new AuthPermissionsException("Could not find the user you were looking for."); - return (twoValues.Substring(0, firstComma), twoValues.Substring(firstComma + 1)); + //without sharding + if (!_options.TenantType.HasFlag(TenantTypes.AddSharding)) + return (values.Substring(0, firstComma), values.Substring(firstComma + 1), null); + + //with sharding (order is DataKey, ConnectionName, Tenant name - this overcomes the problem of commas in the tenant name + var secondComma = values.Substring(firstComma + 1).IndexOf(',')+ firstComma + 1; + return (values.Substring(0, firstComma), + values.Substring(secondComma + 1), + values.Substring(firstComma + 1, secondComma - firstComma - 1 )); } } \ No newline at end of file diff --git a/AuthPermissions.AspNetCore/GetDataKeyCode/GetDataKeyFromUserNormal.cs b/AuthPermissions.AspNetCore/GetDataKeyCode/GetDataKeyFromUserNormal.cs index 51eed493..a7bb2181 100644 --- a/AuthPermissions.AspNetCore/GetDataKeyCode/GetDataKeyFromUserNormal.cs +++ b/AuthPermissions.AspNetCore/GetDataKeyCode/GetDataKeyFromUserNormal.cs @@ -8,8 +8,8 @@ namespace AuthPermissions.AspNetCore.GetDataKeyCode { /// - /// This service is registered if a multi-tenant setup is defined - /// NOTE: There is a version if the "Access the data of other tenant" is turned on + /// This service is registered if a multi-tenant setup without sharding + /// NOTE: There are other version if the "Access the data of other tenant" is turned on /// public class GetDataKeyFromUserNormal : IGetDataKeyFromUser { diff --git a/AuthPermissions.AspNetCore/GetDataKeyCode/GetShardingDataAppAndHierarchicalUsersAccessTenantData.cs b/AuthPermissions.AspNetCore/GetDataKeyCode/GetShardingDataAppAndHierarchicalUsersAccessTenantData.cs new file mode 100644 index 00000000..6c22bd91 --- /dev/null +++ b/AuthPermissions.AspNetCore/GetDataKeyCode/GetShardingDataAppAndHierarchicalUsersAccessTenantData.cs @@ -0,0 +1,52 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using AuthPermissions.AspNetCore.AccessTenantData; +using AuthPermissions.AspNetCore.Services; +using AuthPermissions.CommonCode; +using Microsoft.AspNetCore.Http; + +namespace AuthPermissions.AspNetCore.GetDataKeyCode +{ + + /// + /// This service is registered if a multi-tenant setup with sharding on + /// NOTE: There are other versions if the "Access the data of other tenant" is turned on + /// + public class GetShardingDataAppAndHierarchicalUsersAccessTenantData : IGetShardingDataFromUser + { + /// + /// This will return the AuthP's DataKey and the connection string via the ConnectionName claim. + /// This version works with tenant users, but is little bit slower than the version that only works with app users + /// If no user, or no claim then both parameters will be null + /// + /// + /// Service to get the current connection string for the + /// + public GetShardingDataAppAndHierarchicalUsersAccessTenantData(IHttpContextAccessor accessor, + IShardingConnections connectionService, + ILinkToTenantDataService linkService) + { + var overrideLink = linkService.GetShardingDataOfLinkedTenant(); + + DataKey = overrideLink.dataKey ?? accessor.HttpContext?.User.GetAuthDataKeyFromUser(); + + var connectionStringName = overrideLink.connectionName + ?? accessor.HttpContext?.User.GetConnectionNameFromUser(); + + if (connectionStringName != null) + ConnectionString = connectionService.GetNamedConnectionString(connectionStringName); + } + + /// + /// The AuthP' DataKey, can be null. + /// + public string DataKey { get; } + + /// + /// This contains the connection string to the database to use + /// If null, then use the default connection string as defined at the time when your application's DbContext was registered + /// + public string ConnectionString { get; } + } +} \ No newline at end of file diff --git a/AuthPermissions.AspNetCore/GetDataKeyCode/GetShardingDataUserAccessTenantData.cs b/AuthPermissions.AspNetCore/GetDataKeyCode/GetShardingDataUserAccessTenantData.cs new file mode 100644 index 00000000..66de0c52 --- /dev/null +++ b/AuthPermissions.AspNetCore/GetDataKeyCode/GetShardingDataUserAccessTenantData.cs @@ -0,0 +1,52 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using AuthPermissions.AspNetCore.AccessTenantData; +using AuthPermissions.AspNetCore.Services; +using AuthPermissions.CommonCode; +using Microsoft.AspNetCore.Http; + +namespace AuthPermissions.AspNetCore.GetDataKeyCode +{ + + /// + /// This service is registered if a multi-tenant setup with sharding on + /// NOTE: There are other versions if the "Access the data of other tenant" is turned on + /// + public class GetShardingDataUserAccessTenantData : IGetShardingDataFromUser + { + /// + /// This will return the AuthP's DataKey and the connection string via the ConnectionName claim, + /// but only if the user doesn't have a tenant, i.e. an app admin user + /// If no user, or no claim then both parameters will be null + /// + /// + /// Service to get the current connection string for the + /// + public GetShardingDataUserAccessTenantData(IHttpContextAccessor accessor, + IShardingConnections connectionService, + ILinkToTenantDataService linkService) + { + var overrideLink = linkService.GetShardingDataOfLinkedTenant(); + + DataKey = accessor.HttpContext?.User.GetAuthDataKeyFromUser() + ?? overrideLink.dataKey; + var connectionStringName = accessor.HttpContext?.User.GetConnectionNameFromUser() + ?? overrideLink.connectionName; + + if (connectionStringName != null) + ConnectionString = connectionService.GetNamedConnectionString(connectionStringName); + } + + /// + /// The AuthP' DataKey, can be null. + /// + public string DataKey { get; } + + /// + /// This contains the connection string to the database to use + /// If null, then use the default connection string as defined at the time when your application's DbContext was registered + /// + public string ConnectionString { get; } + } +} \ No newline at end of file diff --git a/AuthPermissions.AspNetCore/GetDataKeyCode/GetShardingDataUserNormal.cs b/AuthPermissions.AspNetCore/GetDataKeyCode/GetShardingDataUserNormal.cs new file mode 100644 index 00000000..933a8a41 --- /dev/null +++ b/AuthPermissions.AspNetCore/GetDataKeyCode/GetShardingDataUserNormal.cs @@ -0,0 +1,42 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using AuthPermissions.AspNetCore.Services; +using AuthPermissions.CommonCode; +using Microsoft.AspNetCore.Http; + +namespace AuthPermissions.AspNetCore.GetDataKeyCode +{ + + /// + /// This service is registered if a multi-tenant setup with sharding on + /// NOTE: There are other versions if the "Access the data of other tenant" is turned on + /// + public class GetShardingDataUserNormal : IGetShardingDataFromUser + { + /// + /// This will return the AuthP's DataKey and the connection string via the ConnectionName claim. + /// If no user, or no claim then both parameters will be null + /// + /// + /// Service to get the current connection string for the + public GetShardingDataUserNormal(IHttpContextAccessor accessor, IShardingConnections connectionService) + { + DataKey = accessor.HttpContext?.User.GetAuthDataKeyFromUser(); + var connectionStringName = accessor.HttpContext?.User.GetConnectionNameFromUser(); + if (connectionStringName != null) + ConnectionString = connectionService.GetNamedConnectionString(connectionStringName); + } + + /// + /// The AuthP' DataKey, can be null. + /// + public string DataKey { get; } + + /// + /// This contains the connection string to the database to use + /// If null, then use the default connection string as defined at the time when your application's DbContext was registered + /// + public string ConnectionString { get; } + } +} \ No newline at end of file diff --git a/AuthPermissions.AspNetCore/GetDataKeyCode/IGetShardingDataFromUser.cs b/AuthPermissions.AspNetCore/GetDataKeyCode/IGetShardingDataFromUser.cs new file mode 100644 index 00000000..d3d362b8 --- /dev/null +++ b/AuthPermissions.AspNetCore/GetDataKeyCode/IGetShardingDataFromUser.cs @@ -0,0 +1,22 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +namespace AuthPermissions.AspNetCore.GetDataKeyCode; + +/// +/// This is the interface provides both the DataKey and the connection string +/// +public interface IGetShardingDataFromUser +{ + /// + /// The DataKey to be used for multi-tenant applications + /// + string DataKey { get; } + + /// + /// This contains the connection string to the database to use + /// If null, then use the default connection string as defined at the time when your application's DbContext was registered + /// + string ConnectionString { get; } + +} \ No newline at end of file diff --git a/AuthPermissions.AspNetCore/Services/IShardingConnections.cs b/AuthPermissions.AspNetCore/Services/IShardingConnections.cs new file mode 100644 index 00000000..08be2281 --- /dev/null +++ b/AuthPermissions.AspNetCore/Services/IShardingConnections.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace AuthPermissions.AspNetCore.Services; + +/// +/// The interface for the service to manage the connection strings in the appsetting file. +/// +public interface IShardingConnections +{ + /// + /// This returns all the connection strings name in the application's appsettings + /// + /// The name of each connection string + IEnumerable GetAllConnectionStringNames(); + + /// + /// This will provide the connection string for the entry with the given connection string name + /// + /// The name of the connection string you want to access + /// The connection string, or null if not found + string GetNamedConnectionString(string connectionName); +} \ No newline at end of file diff --git a/AuthPermissions.AspNetCore/Services/ShardingConnectionStringsJson.cs b/AuthPermissions.AspNetCore/Services/ShardingConnectionStringsJson.cs new file mode 100644 index 00000000..9938e16c --- /dev/null +++ b/AuthPermissions.AspNetCore/Services/ShardingConnectionStringsJson.cs @@ -0,0 +1,60 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using AuthPermissions.CommonCode; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; + +namespace AuthPermissions.AspNetCore.Services; + +/// +/// This service reads in connection strings +/// +public class ShardingConnectionStringsJson +{ + private readonly IWebHostEnvironment _env; + + /// + /// ctor + /// + /// + public ShardingConnectionStringsJson(IWebHostEnvironment env) + { + _env = env; + } + + public IEnumerable<(string name, string connectionString)> GetAllConnectionStrings() + { + var fileDirectory = _env.ContentRootPath; + var appsettingsName = _env.IsDevelopment() + ? "appsettings.Development.json" + : "appsettings.Production.json"; + var filepath = Path.Combine(fileDirectory, appsettingsName); + + if (!File.Exists(filepath)) + throw new AuthPermissionsException( + $"When using sharding you must have a {appsettingsName} file to contain the connection strings."); + + //thanks to https://kevsoft.net/2021/12/19/traversing-json-with-jsondocument.html + using var jsonDocument = JsonDocument.Parse(File.ReadAllText(filepath)); + JsonElement connectionStringsElement; + try + { + connectionStringsElement = jsonDocument.RootElement.GetProperty("ConnectionStrings"); + } + catch (Exception ex) + { + throw new AuthPermissionsException( + $"Could not find a ConnectionStrings section in the {appsettingsName} file."); + } + + foreach (var jsonProperty in connectionStringsElement.EnumerateObject()) + { + yield return (jsonProperty.Name, jsonProperty.Value.ToString()); + } + } +} \ No newline at end of file diff --git a/AuthPermissions.AspNetCore/Services/ShardingConnections.cs b/AuthPermissions.AspNetCore/Services/ShardingConnections.cs new file mode 100644 index 00000000..1036ff16 --- /dev/null +++ b/AuthPermissions.AspNetCore/Services/ShardingConnections.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; +using AuthPermissions.CommonCode; +using Microsoft.Extensions.Options; + +namespace AuthPermissions.AspNetCore.Services; + +/// +/// This is used to get all the connection strings +/// +public class ConnectionStringsOption : Dictionary { } + +/// +/// This service reads in connection strings +/// +public class ShardingConnections : IShardingConnections +{ + private readonly ConnectionStringsOption _connectionDict; + + /// + /// ctor + /// + /// + public ShardingConnections(IOptionsSnapshot optionsAccessor) + { + //thanks to https://stackoverflow.com/questions/37287427/get-multiple-connection-strings-in-appsettings-json-without-ef + _connectionDict = optionsAccessor.Value; + } + + /// + /// This returns all the connection string names currently in the application's appsettings + /// + /// The name of each connection string + public IEnumerable GetAllConnectionStringNames() + { + return _connectionDict.Keys; + } + + /// + /// This will provide the connection string for the entry with the given connection string name + /// + /// The name of the connection string you want to access + /// The connection string, or null if not found + public string GetNamedConnectionString(string connectionName) + { + return _connectionDict.ContainsKey(connectionName) + ? _connectionDict[connectionName] + : null; + } +} \ No newline at end of file diff --git a/AuthPermissions.AspNetCore/SetupExtensions.cs b/AuthPermissions.AspNetCore/SetupExtensions.cs index 03832a4c..c1a3142f 100644 --- a/AuthPermissions.AspNetCore/SetupExtensions.cs +++ b/AuthPermissions.AspNetCore/SetupExtensions.cs @@ -248,19 +248,32 @@ private static void SetupMultiTenantServices(AuthSetupData setupData) //And register the service that manages the cookie and the service to start/stop linking setupData.Services.AddScoped(); setupData.Services.AddScoped(); - switch (setupData.Options.LinkToTenantType) - { - - case LinkToTenantTypes.OnlyAppUsers: - setupData.Services.AddScoped(); - break; - case LinkToTenantTypes.AppAndHierarchicalUsers: - setupData.Services.AddScoped(); - break; - default: - throw new ArgumentOutOfRangeException(); - } - + if (setupData.Options.TenantType.IsSharding()) + switch (setupData.Options.LinkToTenantType) + { + case LinkToTenantTypes.OnlyAppUsers: + setupData.Services.AddScoped(); + break; + case LinkToTenantTypes.AppAndHierarchicalUsers: + setupData.Services + .AddScoped(); + break; + default: + throw new ArgumentOutOfRangeException(); + } + else + switch (setupData.Options.LinkToTenantType) + { + case LinkToTenantTypes.OnlyAppUsers: + setupData.Services.AddScoped(); + break; + case LinkToTenantTypes.AppAndHierarchicalUsers: + setupData.Services + .AddScoped(); + break; + default: + throw new ArgumentOutOfRangeException(); + } } } } diff --git a/AuthPermissions/AdminCode/IAuthTenantAdminService.cs b/AuthPermissions/AdminCode/IAuthTenantAdminService.cs index 88a4cb30..9926a7fb 100644 --- a/AuthPermissions/AdminCode/IAuthTenantAdminService.cs +++ b/AuthPermissions/AdminCode/IAuthTenantAdminService.cs @@ -51,8 +51,11 @@ public interface IAuthTenantAdminService /// /// Name of the new single-level tenant (must be unique) /// Optional: List of tenant role names + /// Needed if sharding: Is true if this tenant has its own database, else false + /// /// A status with any errors found - Task AddSingleTenantAsync(string tenantName, List tenantRoleNames = null); + Task AddSingleTenantAsync(string tenantName, List tenantRoleNames = null, + bool? hasOwnDb = false, string connectionName = null); /// /// This adds a new Hierarchical Tenant, liking it into the parent (which can be null) @@ -60,8 +63,12 @@ public interface IAuthTenantAdminService /// Name of the new tenant. This will be prefixed with the parent's tenant name to make it unique /// The primary key of the parent. If 0 then the new tenant is at the top level /// Optional: List of tenant role names + /// Needed if sharding: Is true if this tenant has its own database, else false + /// /// A status with any errors found - Task AddHierarchicalTenantAsync(string tenantName, int parentTenantId, List tenantRoleNames = null); + Task AddHierarchicalTenantAsync(string tenantName, int parentTenantId, + List tenantRoleNames = null, + bool? hasOwnDb = null, string connectionName = null); /// /// This replaces the in the tenant with primary key @@ -98,8 +105,23 @@ public interface IAuthTenantAdminService /// but only if no AuthP user are linked to this tenant (it will return errors listing all the AuthP user that are linked to this tenant /// This method uses the you provided via the /// to delete the application's tenant data. + /// NOTE: If the tenant is hierarchical, then it will delete the tenant and all of its child tenants /// + /// The primary key of the AuthP tenant to be deleted /// Status returning the service, in case you want copy the delete data instead of deleting Task> DeleteTenantAsync(int tenantId); + + /// + /// This is used when sharding is enabled. It updates the tenant's and + /// and calls the + /// which moves the tenant data to another database and then deletes the the original tenant data. + /// + /// The primary key of the AuthP tenant to be moved. + /// NOTE: If its a hierarchical tenant, then the tenant will be the highest parent. + /// Says whether the new database will only hold this tenant + /// + /// status + Task MoveToDifferentDatabaseAsync(int tenantToMoveId, + bool hasOwnDb, string connectionName); } } \ No newline at end of file diff --git a/AuthPermissions/AdminCode/ITenantChangeService.cs b/AuthPermissions/AdminCode/ITenantChangeService.cs index 6101071d..fe146af2 100644 --- a/AuthPermissions/AdminCode/ITenantChangeService.cs +++ b/AuthPermissions/AdminCode/ITenantChangeService.cs @@ -22,22 +22,18 @@ public interface ITenantChangeService /// You should apply multiple changes within a transaction so that if any fails then any previous changes will be rolled back. /// NOTE: With hierarchical tenants you cannot be sure that the tenant has, or will have, children /// - /// The DataKey of the tenant being deleted - /// The TenantId of the tenant being deleted - /// The full name of the tenant being deleted + /// /// Returns null if all OK, otherwise the create is rolled back and the return string is shown to the user - Task CreateNewTenantAsync(string dataKey, int tenantId, string fullTenantName); + Task CreateNewTenantAsync(Tenant tenant); /// - /// This is called when the name of your Tenants is changed. This is useful if you use the tenant name in your multi-tenant data. - /// NOTE: The created application's DbContext won't have a DataKey, so you will need to use IgnoreQueryFilters on any EF Core read. + /// This is called when the name of your single-level tenant is changed. This is useful if you use the tenant name in your multi-tenant data. + /// NOTE: The application's DbContext won't have a DataKey, so you will need to use IgnoreQueryFilters on any EF Core read. /// You should apply multiple changes within a transaction so that if any fails then any previous changes will be rolled back. /// - /// The DataKey of the tenant - /// The TenantId of the tenant - /// The full name of the tenant + /// /// Returns null if all OK, otherwise the name change is rolled back and the return string is shown to the user - Task HandleUpdateNameAsync(string dataKey, int tenantId, string fullTenantName); + Task SingleTenantUpdateNameAsync(Tenant tenant); /// /// This is used with single-level tenant to either @@ -49,11 +45,21 @@ public interface ITenantChangeService /// - You can provide information of what you have done by adding public parameters to this class. /// The TenantAdmin method returns your class on a successful Delete /// - /// The DataKey of the tenant being deleted - /// The TenantId of the tenant being deleted - /// The full name of the tenant being deleted + /// /// Returns null if all OK, otherwise the AuthP part of the delete is rolled back and the return string is shown to the user - Task SingleTenantDeleteAsync(string dataKey, int tenantId, string fullTenantName); + Task SingleTenantDeleteAsync(Tenant tenant); + + //------------------------------------------------ + // Hierarchical change methods + + /// + /// This is called when the name of your Hierarchical tenants is changed. This is useful if you use the tenant name in your multi-tenant data. + /// NOTE: The application's DbContext won't have a DataKey, so you will need to use IgnoreQueryFilters on any EF Core read. + /// You should apply multiple changes within a transaction so that if any fails then any previous changes will be rolled back. + /// + /// This contains the tenants to update. + /// Returns null if all OK, otherwise the name change is rolled back and the return string is shown to the user + Task HierarchicalTenantUpdateNameAsync(List tenantsToUpdate); /// /// This is used with hierarchical tenants to either @@ -79,8 +85,23 @@ public interface ITenantChangeService /// /// The data to update each tenant. This starts at the parent and then recursively works down the children /// Returns null if all OK, otherwise AuthP part of the move is rolled back and the return string is shown to the user - Task MoveHierarchicalTenantDataAsync( - List<(string oldDataKey, string newDataKey, int tenantId, string newFullTenantName)> tenantToUpdate); + Task MoveHierarchicalTenantDataAsync(List<(string oldDataKey, Tenant tenantToMove)> tenantToUpdate); + + /// + /// This is called when a tenant is moved to a new database setting. + /// Its job is to move all the application's data to a new database (which isn't an easy thing to do!) + /// and then delete the old data + /// This method is called for both a single-level or hierarchical tenant, but the code for each is quite different. + /// NOTE: If its a hierarchical tenant, then the tenant will be the highest parent. + /// NOTE: If the tenant's is true, then its worth you checking the database + /// doesn't have any application's data in the new database. This is especially important for a single-level tenant + /// because the query filter will be turned off and any other data would be returned. + /// + /// The connection string to the old database + /// + /// This tenant has had its sharding information updated + /// + Task MoveToDifferentDatabaseAsync(string oldConnectionName, string oldDataKey, Tenant updatedTenant); } } \ No newline at end of file diff --git a/AuthPermissions/AdminCode/MultiTenantExtensions.cs b/AuthPermissions/AdminCode/MultiTenantExtensions.cs new file mode 100644 index 00000000..c256674f --- /dev/null +++ b/AuthPermissions/AdminCode/MultiTenantExtensions.cs @@ -0,0 +1,83 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using AuthPermissions.CommonCode; +using AuthPermissions.DataLayer.Classes; + +namespace AuthPermissions.AdminCode; + +/// +/// Extension methods to +/// +public static class MultiTenantExtensions +{ + /// + /// If the DataKey contains this string, then the single-level query filter should be set to true + /// + public const string DataKeyNoQueryFilter = "NoQueryFilter"; + + /// + /// This calculates the data key from the tenantId and the parentDataKey. + /// If it is a single layer multi-tenant it will by the TenantId as a string + /// - If the tenant is in its own database, then it will send back the constant + /// If it is a hierarchical multi-tenant it will contains a concatenation of the tenantsId in the parents as well + /// + /// + /// The parentDataKey is needed if hierarchical + /// + /// + public static string GetTenantDataKey(this int tenantId, string parentDataKey, bool isHierarchical, bool hasItsOwnDb) + { + if (tenantId == default) + throw new AuthPermissionsException( + "The Tenant DataKey is only correct if the tenant primary key is set"); + + + return isHierarchical || !hasItsOwnDb + ? parentDataKey + $"{tenantId}." //This works for single-level because the parentDataKey is null in that case + : DataKeyNoQueryFilter; + } + + /// + /// This calculates the data key for given tenant. + /// If it is a single layer multi-tenant it will by the TenantId as a string + /// If it is a hierarchical multi-tenant it will contains a concatenation of the tenantsId in the parents as well + /// + /// + /// + public static string GetTenantDataKey(this Tenant tenant) + { + return tenant.TenantId.GetTenantDataKey(tenant.ParentDataKey, tenant.IsHierarchical, tenant.HasOwnDb); + } + + /// + /// This returns true if the Tenant is using sharding + /// + /// + /// + public static bool IsSharding(this Tenant tenant) + { + return tenant.ConnectionName != null; + } + + /// + /// This returns the highest TenantId for a tenant + /// This is used if a tenant is moved to another database, as we must move all the hierarchical data + /// - For single-level multi-tenant, this will be the TenantId + /// - for hierarchical multi-tenant, this will be the first TenantId in the ParentDataKey, + /// or this TenantId if the ParentDataKey is null + /// + /// The highest TenantId of a tenant + /// + public static int GetHighestTenantId(this int tenantId, string parentDataKey = null) + { + if (tenantId == default) + throw new AuthPermissionsException( + "The Tenant DataKey is only correct if the tenant primary key is set"); + + //NOTE: If single-level, then ParentDataKey will be null + return parentDataKey == null + ? tenantId + : int.Parse(parentDataKey.Substring(0, parentDataKey.IndexOf('.'))); + } +} \ No newline at end of file diff --git a/AuthPermissions/AdminCode/Services/AuthTenantAdminService.cs b/AuthPermissions/AdminCode/Services/AuthTenantAdminService.cs index d66b67c5..0d70550c 100644 --- a/AuthPermissions/AdminCode/Services/AuthTenantAdminService.cs +++ b/AuthPermissions/AdminCode/Services/AuthTenantAdminService.cs @@ -6,16 +6,13 @@ using System.Data; using System.Linq; using System.Threading.Tasks; -using AuthPermissions.AdminCode.Services.Internal; using AuthPermissions.CommonCode; -using AuthPermissions.DataLayer; using AuthPermissions.DataLayer.Classes; using AuthPermissions.DataLayer.Classes.SupportTypes; using AuthPermissions.DataLayer.EfCode; using AuthPermissions.SetupCode; using AuthPermissions.SetupCode.Factories; using Microsoft.EntityFrameworkCore; - using Microsoft.Extensions.Logging; using StatusGeneric; @@ -30,7 +27,6 @@ public class AuthTenantAdminService : IAuthTenantAdminService private readonly AuthPermissionsOptions _options; private readonly IAuthPServiceFactory _tenantChangeServiceFactory; private readonly ILogger _logger; - private readonly IRegisterStateChangeEvent _eventSetup; private readonly TenantTypes _tenantType; @@ -41,18 +37,15 @@ public class AuthTenantAdminService : IAuthTenantAdminService /// /// /// - /// Optional: public AuthTenantAdminService(AuthPermissionsDbContext context, AuthPermissionsOptions options, IAuthPServiceFactory tenantChangeServiceFactory, - ILogger logger, - IRegisterStateChangeEvent eventSetup = null) + ILogger logger) { _context = context; _options = options; _tenantChangeServiceFactory = tenantChangeServiceFactory; _logger = logger; - _eventSetup = eventSetup; _tenantType = options.TenantType; } @@ -134,8 +127,11 @@ public async Task> GetHierarchicalTenantChildrenViaIdAsync(int tena /// /// Name of the new single-level tenant (must be unique) /// Optional: List of tenant role names + /// Needed if sharding: Is true if this tenant has its own database, else false + /// /// A status with any errors found - public async Task AddSingleTenantAsync(string tenantName, List tenantRoleNames = null) + public async Task AddSingleTenantAsync(string tenantName, List tenantRoleNames = null, + bool? hasOwnDb = null, string connectionName = null) { var status = new StatusGenericHandler { Message = $"Successfully added the new tenant {tenantName}." }; @@ -145,7 +141,7 @@ public async Task AddSingleTenantAsync(string tenantName, List AddSingleTenantAsync(string tenantName, List AddSingleTenantAsync(string tenantName, List AddSingleTenantAsync(string tenantName, ListName of the new tenant. This will be prefixed with the parent's tenant name to make it unique /// The primary key of the parent. If 0 then the new tenant is at the top level /// Optional: List of tenant role names + /// Needed if sharding: Is true if this tenant has its own database, else false + /// /// A status with any errors found public async Task AddHierarchicalTenantAsync(string tenantName, int parentTenantId, - List tenantRoleNames = null) + List tenantRoleNames = null, bool? hasOwnDb = false, string connectionName = null) { - var status = new StatusGenericHandler(); + var status = new StatusGenericHandler { Message = $"Successfully added the new tenant {tenantName}." }; if (!_tenantType.IsHierarchical()) throw new AuthPermissionsException( @@ -213,6 +226,10 @@ public async Task AddHierarchicalTenantAsync(string tenantName, parentTenant = await _context.Tenants.SingleOrDefaultAsync(x => x.TenantId == parentTenantId); if (parentTenant == null) return status.AddError("Could not find the parent tenant you asked for."); + + if (!parentTenant.IsHierarchical) + throw new AuthPermissionsException( + "attempted to add a Hierarchical tenant to a single-level tenant, which isn't allowed"); } var fullTenantName = Tenant.CombineParentNameWithTenantName(tenantName, parentTenant?.TenantFullName); @@ -225,14 +242,60 @@ public async Task AddHierarchicalTenantAsync(string tenantName, if (status.CombineStatuses(newTenantStatus).HasErrors) return status; + if (_tenantType.IsSharding()) + { + if (parentTenant != null) + { + //If there is a parent we use its sharding settings + //But to make sure the user thinks their values are used we send back errors if they are different + + if (hasOwnDb != null && parentTenant.HasOwnDb != hasOwnDb) + status.AddError( + $"The {nameof(hasOwnDb)} parameter doesn't match the parent's " + + $"{nameof(Tenant.HasOwnDb)}. Set the {nameof(hasOwnDb)} " + + $"parameter to null to use the parent's {nameof(Tenant.HasOwnDb)} value.", + nameof(hasOwnDb).CamelToPascal()); + + if (connectionName != null && + parentTenant.ConnectionName != connectionName) + status.AddError( + $"The {nameof(connectionName)} parameter doesn't match the parent's " + + $"{nameof(Tenant.ConnectionName)}. Set the {nameof(connectionName)} " + + $"parameter to null to use the parent's {nameof(Tenant.ConnectionName)} value.", + nameof(connectionName).CamelToPascal()); + + + hasOwnDb = parentTenant.HasOwnDb; + connectionName = parentTenant.ConnectionName; + + status.CombineStatuses(await CheckHasOwnDbIsValidAsync((bool)hasOwnDb, connectionName)); + } + else + { + + if (hasOwnDb == null) + return status.AddError( + $"The {nameof(hasOwnDb)} parameter must be set to true or false if there is no parent and sharding is turned on.", + nameof(hasOwnDb).CamelToPascal()); + + status.CombineStatuses(await CheckHasOwnDbIsValidAsync((bool)hasOwnDb, connectionName)); + } + + if (status.HasErrors) + return status; + + newTenantStatus.Result.UpdateShardingState( + connectionName ?? _options.ShardingDefaultConnectionName, + (bool)hasOwnDb); + } + _context.Add(newTenantStatus.Result); status.CombineStatuses(await _context.SaveChangesWithChecksAsync()); if (status.HasErrors) return status; - var errorString = await tenantChangeService.CreateNewTenantAsync(newTenantStatus.Result.GetTenantDataKey(), - newTenantStatus.Result.TenantId, newTenantStatus.Result.TenantFullName); + var errorString = await tenantChangeService.CreateNewTenantAsync(newTenantStatus.Result); if (errorString != null) return status.AddError(errorString); @@ -243,7 +306,7 @@ public async Task AddHierarchicalTenantAsync(string tenantName, if (_logger == null) throw; - _logger.LogError(e, $"Failed to {status.Message}"); + _logger.LogError(e, $"Failed to {e.Message}"); return status.AddError( "The attempt to delete a tenant failed with a system error. Please contact the admin team."); } @@ -263,7 +326,7 @@ public async Task UpdateTenantRolesAsync(int tenantId, List x.TenantRoles) .SingleOrDefaultAsync(x => x.TenantId == tenantId); @@ -293,7 +356,8 @@ public async Task UpdateTenantRolesAsync(int tenantId, List public async Task UpdateTenantNameAsync(int tenantId, string newTenantName) { - var status = new StatusGenericHandler(); + var status = new StatusGenericHandler + { Message = $"Successfully updated the tenant's name to {newTenantName}." }; if (string.IsNullOrEmpty(newTenantName)) return status.AddError("The new name was empty", nameof(newTenantName).CamelToPascal()); @@ -307,7 +371,6 @@ public async Task UpdateTenantNameAsync(int tenantId, string new using var transaction = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable); try { - var tenant = await _context.Tenants .SingleOrDefaultAsync(x => x.TenantId == tenantId); @@ -327,22 +390,13 @@ public async Task UpdateTenantNameAsync(int tenantId, string new .Single(x => x.TenantId == tenantId); existingTenantWithChildren.UpdateTenantName(newTenantName); - - foreach (var tenantToUpdate in tenantsWithChildren) - { - var errorString = await tenantChangeService.HandleUpdateNameAsync(tenantToUpdate.GetTenantDataKey(), - tenantToUpdate.TenantId, tenantToUpdate.TenantFullName); - if (errorString != null) - return status.AddError(errorString); - } + await tenantChangeService.HierarchicalTenantUpdateNameAsync(tenantsWithChildren); } else { tenant.UpdateTenantName(newTenantName); - var errorString = await tenantChangeService.HandleUpdateNameAsync(tenant.GetTenantDataKey(), - tenant.TenantId, - tenant.TenantFullName); + var errorString = await tenantChangeService.SingleTenantUpdateNameAsync(tenant); if (errorString != null) return status.AddError(errorString); } @@ -357,7 +411,7 @@ public async Task UpdateTenantNameAsync(int tenantId, string new if (_logger == null) throw; - _logger.LogError(e, $"Failed to {status.Message}"); + _logger.LogError(e, $"Failed to {e.Message}"); return status.AddError( "The attempt to delete a tenant failed with a system error. Please contact the admin team."); } @@ -376,7 +430,7 @@ public async Task UpdateTenantNameAsync(int tenantId, string new /// status public async Task MoveHierarchicalTenantToAnotherParentAsync(int tenantToMoveId, int newParentTenantId) { - var status = new StatusGenericHandler { }; + var status = new StatusGenericHandler { Message = "Successfully moved the hierarchical tenant to a new parent." }; if (!_tenantType.IsHierarchical()) throw new AuthPermissionsException( @@ -387,10 +441,9 @@ public async Task MoveHierarchicalTenantToAnotherParentAsync(int var tenantChangeService = _tenantChangeServiceFactory.GetService(); - using var transaction = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable); + await using var transaction = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable); try { - var tenantToMove = await _context.Tenants .SingleOrDefaultAsync(x => x.TenantId == tenantToMoveId); var originalName = tenantToMove.TenantFullName; @@ -418,8 +471,7 @@ public async Task MoveHierarchicalTenantToAnotherParentAsync(int } //Now we ask the Tenant entity to do the move on the AuthP's Tenants, and capture each change - var listOfChanges = - new List<(string oldDataKey, string newDataKey, int tenantId, string newFullTenantName)>(); + var listOfChanges = new List<(string oldDataKey, Tenant)>(); existingTenantWithChildren.MoveTenantToNewParent(parentTenant, tuple => listOfChanges.Add(tuple)); var errorString = await tenantChangeService.MoveHierarchicalTenantDataAsync(listOfChanges); if (errorString != null) @@ -437,7 +489,7 @@ public async Task MoveHierarchicalTenantToAnotherParentAsync(int if (_logger == null) throw; - _logger.LogError(e, $"Failed to {status.Message}"); + _logger.LogError(e, $"Failed to {e.Message}"); return status.AddError( "The attempt to delete a tenant failed with a system error. Please contact the admin team."); } @@ -463,7 +515,6 @@ public async Task> DeleteTenantAsync(int te using var transaction = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable); try { - var tenantToDelete = await _context.Tenants .SingleOrDefaultAsync(x => x.TenantId == tenantId); @@ -520,9 +571,7 @@ public async Task> DeleteTenantAsync(int te else { //delete the tenant that the user defines - var mainError = await tenantChangeService.SingleTenantDeleteAsync(tenantToDelete.GetTenantDataKey(), - tenantToDelete.TenantId, - tenantToDelete.TenantFullName); + var mainError = await tenantChangeService.SingleTenantDeleteAsync(tenantToDelete); if (mainError != null) return status.AddError(mainError); _context.Remove(tenantToDelete); @@ -538,7 +587,7 @@ public async Task> DeleteTenantAsync(int te if (_logger == null) throw; - _logger.LogError(e, $"Failed to {status.Message}"); + _logger.LogError(e, $"Failed to {e.Message}"); return status.AddError( "The attempt to delete a tenant failed with a system error. Please contact the admin team."); } @@ -547,9 +596,96 @@ public async Task> DeleteTenantAsync(int te return status; } + /// + /// This is used when sharding is enabled. It updates the tenant's and + /// and calls the + /// which moves the tenant data to another database and then deletes the the original tenant data. + /// + /// The primary key of the AuthP tenant to be moved. + /// NOTE: If its a hierarchical tenant, then the tenant will be the highest parent. + /// Says whether the new database will only hold this tenant + /// + /// status + public async Task MoveToDifferentDatabaseAsync(int tenantToMoveId, bool hasOwnDb, + string connectionName) + { + var status = new StatusGenericHandler { Message = "Successfully moved the tenant to a different database." }; + + if (!_tenantType.IsSharding()) + throw new AuthPermissionsException( + "This method can only be called when sharding is turned on."); + + var tenantChangeService = _tenantChangeServiceFactory.GetService(); + + await using var transaction = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable); + try + { + var tenant = await _context.Tenants + .SingleOrDefaultAsync(x => x.TenantId == tenantToMoveId); + + if (tenant == null) + return status.AddError("Could not find the tenant you were looking for."); + + if (tenant.ConnectionName == connectionName) + { + status.Message = $"The database connection string is already set to {connectionName}."; + return status; + } + + if (status.CombineStatuses(await CheckHasOwnDbIsValidAsync(hasOwnDb, connectionName)).HasErrors) + return status; + + var previousConnectionName = tenant.ConnectionName; + var previousDataKey = tenant.GetTenantDataKey(); + tenant.UpdateShardingState(connectionName, hasOwnDb); + + if (status.CombineStatuses(await _context.SaveChangesWithChecksAsync()).HasErrors) + return status; + + var mainError = await tenantChangeService + .MoveToDifferentDatabaseAsync(previousConnectionName, previousDataKey, tenant); + if (mainError != null) + return status.AddError(mainError); + + if (status.IsValid) + await transaction.CommitAsync(); + } + catch (Exception e) + { + if (_logger == null) + throw; + + _logger.LogError(e, $"Failed to {e.Message}"); + return status.AddError( + "The attempt to move the tenant to another database failed. Please contact the admin team."); + } + + return status; + } + //---------------------------------------------------------- // private methods + /// + /// If the hasOwnDb is true, it returns an error if any tenants have the same + /// + /// + /// + /// status + private async Task CheckHasOwnDbIsValidAsync(bool hasOwnDb, string connectionName) + { + var status = new StatusGenericHandler(); + if (!hasOwnDb) + return status; + + if (await _context.Tenants.AnyAsync(x => x.ConnectionName == connectionName)) + status.AddError( + $"The {nameof(hasOwnDb)} parameter is true, but there is already a tenant with the " + + $"same connection name '{connectionName}', so {nameof(hasOwnDb)} should be false."); + + return status; + } + /// /// This finds the roles with the given names from the AuthP database. Returns errors if not found /// NOTE: The Tenant checks that the role's are valid for a tenant diff --git a/AuthPermissions/AdminCode/TenantTypeExtensions.cs b/AuthPermissions/AdminCode/TenantTypeExtensions.cs index 6cc9e4c7..d884d835 100644 --- a/AuthPermissions/AdminCode/TenantTypeExtensions.cs +++ b/AuthPermissions/AdminCode/TenantTypeExtensions.cs @@ -6,8 +6,16 @@ namespace AuthPermissions.AdminCode; +/// +/// Methods to decode the property +/// public static class TenantTypeExtensions { + /// + /// This checks that the property contains a valid state + /// + /// + /// public static void ThrowExceptionIfTenantTypeIsWrong(this TenantTypes tenantType) { if (tenantType.HasFlag(TenantTypes.SingleLevel) && tenantType.HasFlag(TenantTypes.HierarchicalTenant)) @@ -19,25 +27,40 @@ public static void ThrowExceptionIfTenantTypeIsWrong(this TenantTypes tenantType throw new AuthPermissionsException( $"You need to set the {nameof(AuthPermissionsOptions.TenantType)} option to either {nameof(TenantTypes.SingleLevel)} or " + $"{nameof(TenantTypes.HierarchicalTenant)} when setting the {nameof(TenantTypes.AddSharding)} flag."); - } + /// + /// Returns true if the property is set to use AuthP's multi-tenant feature + /// + /// + /// public static bool IsMultiTenant(this TenantTypes tenantType) { return tenantType.HasFlag(TenantTypes.SingleLevel) || tenantType.HasFlag(TenantTypes.HierarchicalTenant); } - + /// + /// Returns true if the property is set to + /// + /// public static bool IsSingleLevel(this TenantTypes tenantType) { return tenantType.HasFlag(TenantTypes.SingleLevel); } + /// + /// Returns true if the property is set to + /// + /// public static bool IsHierarchical(this TenantTypes tenantType) { return tenantType.HasFlag(TenantTypes.HierarchicalTenant); } - public static bool UsingSharding(this TenantTypes tenantType) + /// + /// Returns true if the property has the flag set + /// + /// + public static bool IsSharding(this TenantTypes tenantType) { return tenantType.HasFlag(TenantTypes.AddSharding); } diff --git a/AuthPermissions/AuthPermissionsOptions.cs b/AuthPermissions/AuthPermissionsOptions.cs index 74311649..f5567c8e 100644 --- a/AuthPermissions/AuthPermissionsOptions.cs +++ b/AuthPermissions/AuthPermissionsOptions.cs @@ -20,6 +20,12 @@ public class AuthPermissionsOptions /// public TenantTypes TenantType { get; set; } + /// + /// If sharding is turned on, then you can set the default connection string name in the appsettings file. + /// This defaults to "DefaultConnection" + /// + public string ShardingDefaultConnectionName { get; set; } = "DefaultConnection"; + /// /// This turns on the LinkToTenantData feature, e.g. an admin person can access the data in a specific tenant /// diff --git a/AuthPermissions/ClaimsCalculator.cs b/AuthPermissions/ClaimsCalculator.cs index 219f02e9..20dd5db1 100644 --- a/AuthPermissions/ClaimsCalculator.cs +++ b/AuthPermissions/ClaimsCalculator.cs @@ -6,11 +6,10 @@ using System.Security.Claims; using System.Threading.Tasks; using AuthPermissions.AdminCode; -using AuthPermissions.AdminCode.Services.Internal; +using AuthPermissions.DataLayer.Classes; using AuthPermissions.DataLayer.Classes.SupportTypes; using AuthPermissions.DataLayer.EfCode; using AuthPermissions.PermissionsCode; -using AuthPermissions.SetupCode; using Microsoft.EntityFrameworkCore; namespace AuthPermissions @@ -49,13 +48,19 @@ public async Task> GetClaimsForAuthUserAsync(string userId) { var result = new List(); + var userWithTenant = await _context.AuthUsers.Where(x => x.UserId == userId) + .Include(x => x.UserTenant) + .SingleOrDefaultAsync(); + + if (userWithTenant == null || userWithTenant.IsDisabled) + return result; + var permissions = await CalcPermissionsForUserAsync(userId); - if (permissions != null) + if (permissions != null) result.Add(new Claim(PermissionConstants.PackedPermissionClaimType, permissions)); - var dataKey = await GetDataKeyAsync(userId); - if (dataKey != null) - result.Add(new Claim(PermissionConstants.DataKeyClaimType, dataKey)); + if (_options.TenantType.IsMultiTenant()) + result.AddRange(GetMultiTenantClaims(userWithTenant.UserTenant)); foreach (var claimsAdder in _claimsAdders) { @@ -109,19 +114,27 @@ private async Task CalcPermissionsForUserAsync(string userId) } /// - /// This return the multi-tenant data key if one is found + /// This adds the correct claims for a multi-tenant application /// - /// - /// Returns the dataKey, or null if a) tenant isn't turned on, or b) the user doesn't have a tenant - private async Task GetDataKeyAsync(string userid) + /// + /// + private List GetMultiTenantClaims(Tenant tenant) { - if (!_options.TenantType.IsMultiTenant()) - return null; + var result = new List(); - var userWithTenant = await _context.AuthUsers.Include(x => x.UserTenant) - .SingleOrDefaultAsync(x => x.UserId == userid); + if (tenant == null) + return result; - return userWithTenant?.UserTenant?.GetTenantDataKey(); + var dataKey = tenant.GetTenantDataKey(); + + result.Add(new Claim(PermissionConstants.DataKeyClaimType, dataKey)); + + if (_options.TenantType.IsSharding()) + { + result.Add(new Claim(PermissionConstants.ConnectionNameType, tenant.ConnectionName)); + } + + return result; } } } \ No newline at end of file diff --git a/AuthPermissions/CommonCode/ClaimsExtensions.cs b/AuthPermissions/CommonCode/ClaimsExtensions.cs index 233abb17..78da5cc3 100644 --- a/AuthPermissions/CommonCode/ClaimsExtensions.cs +++ b/AuthPermissions/CommonCode/ClaimsExtensions.cs @@ -45,7 +45,7 @@ public static string GetPackedPermissionsFromUser(this ClaimsPrincipal user) } /// - /// This returns the AuthP DataKey. Can be null if AuthP user has no tenant, or tenants are not configured + /// This returns the AuthP DataKey. Can be null if AuthP user has no user, user not a tenants, or tenants are not configured /// /// The current ClaimsPrincipal user /// The AuthP DataKey from the claim, or null if no DataKey claim @@ -53,5 +53,15 @@ public static string GetAuthDataKeyFromUser(this ClaimsPrincipal user) { return user?.Claims.SingleOrDefault(x => x.Type == PermissionConstants.DataKeyClaimType)?.Value; } + + /// + /// Returns the ConnectionName claim. Can be null if no user, user not a tenants or sharding isn't configured + /// + /// + /// + public static string GetConnectionNameFromUser(this ClaimsPrincipal user) + { + return user?.Claims.SingleOrDefault(x => x.Type == PermissionConstants.ConnectionNameType)?.Value; + } } } \ No newline at end of file diff --git a/AuthPermissions/DataLayer/Classes/AuthUser.cs b/AuthPermissions/DataLayer/Classes/AuthUser.cs index d43a3938..ea25e1dc 100644 --- a/AuthPermissions/DataLayer/Classes/AuthUser.cs +++ b/AuthPermissions/DataLayer/Classes/AuthUser.cs @@ -74,6 +74,12 @@ public static IStatusGeneric CreateAuthUser(string userId, string emai [MaxLength(AuthDbConstants.UserNameSize)] public string UserName { get; private set; } + /// + /// If true the user is disabled, which means no AuthP claims will be added to its claims + /// NOTE: By default this does not stop this user from logging in + /// + public bool IsDisabled { get; private set; } + //------------------------------------------------- //relationships @@ -207,6 +213,15 @@ public void ChangeUserNameAndEmailWithChecks(string email, string userName) $"The {nameof(Email)} and {nameof(UserName)} can't both be null."); } + /// + /// This allows you to change the user's setting + /// + /// If true, then no AuthP claims are adding the the user's claims + public void SetIsDisabled(bool isDisabled) + { + IsDisabled = isDisabled; + } + //--------------------------------------------------------- // private methods diff --git a/AuthPermissions/DataLayer/Classes/Tenant.cs b/AuthPermissions/DataLayer/Classes/Tenant.cs index 5897ac46..689a1e79 100644 --- a/AuthPermissions/DataLayer/Classes/Tenant.cs +++ b/AuthPermissions/DataLayer/Classes/Tenant.cs @@ -6,6 +6,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Linq; +using AuthPermissions.AdminCode; using AuthPermissions.CommonCode; using AuthPermissions.DataLayer.Classes.SupportTypes; using StatusGeneric; @@ -92,10 +93,23 @@ public static IStatusGeneric CreateHierarchicalTenant(string fullTenantN public string TenantFullName { get; private set; } /// - /// This is true if the tenant is an hierarchical + /// This is true if the tenant is hierarchical /// public bool IsHierarchical { get; private set; } + /// + /// This is true if the tenant has its own database. + /// This is used by single-level tenants to return true for the query filter + /// Also provides a quick way to find out what databases are used and how many tenants are in each database + /// + public bool HasOwnDb { get; private set; } + + /// + /// If sharding is turned on then this will contain the name of the connection string + /// in the appsettings.json "ConnectionStrings" section. This must not be null + /// + public string ConnectionName { get; private set; } + //--------------------------------------------------------- //relationships - only used for hierarchical multi-tenant system @@ -126,7 +140,7 @@ public static IStatusGeneric CreateHierarchicalTenant(string fullTenantN /// public override string ToString() { - return $"{TenantFullName}: Key = {GetTenantDataKey()}"; + return $"{TenantFullName}: Key = {this.GetTenantDataKey()}"; } //-------------------------------------------------- @@ -141,17 +155,14 @@ public override string ToString() //access methods /// - /// This calculates the data key for this tenant. - /// If it is a single layer multi-tenant it will by the TenantId as a string - /// If it is a hierarchical multi-tenant it will contains a concatenation of the tenantsId in the parents as well + /// This allows you to change the sharding information for this tenant /// - public string GetTenantDataKey() + /// + /// true if it is the only tenant in its database + public void UpdateShardingState(string newConnectionName, bool hasOwnDb) { - if (TenantId == default) - throw new AuthPermissionsException( - "The Tenant DataKey is only correct if the tenant primary key is set"); - - return ParentDataKey + $"{TenantId}."; + ConnectionName = newConnectionName ?? throw new ArgumentNullException(nameof(newConnectionName)); + HasOwnDb = hasOwnDb; } /// @@ -226,18 +237,18 @@ public IStatusGeneric UpdateTenantRoles(List tenantRoles) /// These starts at the parent and then recursively works down the children. /// This allows you to obtains the previous DataKey, the new DataKey and the fullname of every tenant that was moved public void MoveTenantToNewParent(Tenant newParentTenant, - Action<(string oldDataKey, string newDataKey, int tenantId, string newFullTenantName)> getChangeData) + Action<(string oldDataKey, Tenant tenant)> getChangeData) { if (!IsHierarchical) throw new AuthPermissionsException("You can only move a hierarchical tenant to a new parent"); if (Children == null) throw new AuthPermissionsException("The children must be loaded to move a hierarchical tenant"); - var oldDataKey = GetTenantDataKey(); + var oldDataKey = this.GetTenantDataKey(); TenantFullName = CombineParentNameWithTenantName(ExtractEndLeftTenantName(this.TenantFullName), newParentTenant?.TenantFullName); Parent = newParentTenant; ParentDataKey = newParentTenant?.GetTenantDataKey(); - getChangeData((oldDataKey, GetTenantDataKey(), TenantId, TenantFullName)); + getChangeData((oldDataKey, this)); RecursivelyChangeChildNames(this, Children, (parent, child) => { @@ -245,8 +256,7 @@ public void MoveTenantToNewParent(Tenant newParentTenant, child.TenantFullName = CombineParentNameWithTenantName(thisLevelTenantName, parent.TenantFullName); var previousDataKey = child.GetTenantDataKey(); child.ParentDataKey = parent?.GetTenantDataKey(); - var newDataKey = child.GetTenantDataKey(); - getChangeData?.Invoke((previousDataKey, newDataKey, child.TenantId, child.TenantFullName)); + getChangeData?.Invoke((previousDataKey, child)); }); } diff --git a/AuthPermissions/DataLayer/EfCode/DataKeyQueryExtension.cs b/AuthPermissions/DataLayer/EfCode/DataKeyQueryExtension.cs index ea2104eb..08fde84a 100644 --- a/AuthPermissions/DataLayer/EfCode/DataKeyQueryExtension.cs +++ b/AuthPermissions/DataLayer/EfCode/DataKeyQueryExtension.cs @@ -4,9 +4,9 @@ using System; using System.Linq.Expressions; using System.Reflection; +using AuthPermissions.AdminCode; using AuthPermissions.CommonCode; using AuthPermissions.DataLayer.Classes.SupportTypes; -using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; namespace AuthPermissions.DataLayer.EfCode @@ -71,5 +71,44 @@ private static LambdaExpression SetupMultiTenantQueryFilter(IDataKeyFil Expression> filter = x => x.DataKey.StartsWith(dataKey.DataKey); return filter; } + + /// + /// This method will set up a single-level tenant query filter when using sharding + /// The difference from the non-sharding version is if the tenant is in a database all on its own, + /// then it sets a true, which will remove the effect of query filter to improve performance + /// See the Example6 for an example of using this + /// + /// + /// + public static void AddSingleTenantShardingQueryFilter(this IMutableEntityType entityData, + IDataKeyFilterReadOnly dataKey) + { + var methodToCall = typeof(DataKeyQueryExtension) + .GetMethod(nameof(SetupSingleTenantShardingQueryFilter), + BindingFlags.NonPublic | BindingFlags.Static) + .MakeGenericMethod(entityData.ClrType); + var filter = methodToCall.Invoke(null, new object[] { dataKey }); + entityData.SetQueryFilter((LambdaExpression)filter); + entityData.GetProperty(nameof(IDataKeyFilterReadWrite.DataKey)).SetIsUnicode(false); //Make unicode + var dataKeySize = Math.Max(12, MultiTenantExtensions.DataKeyNoQueryFilter.Length); + entityData.GetProperty(nameof(IDataKeyFilterReadWrite.DataKey)).SetMaxLength(dataKeySize); //size must contain "no query" string + entityData.AddIndex(entityData.FindProperty(nameof(IDataKeyFilterReadWrite.DataKey))); + } + + /// + /// This version will set a true if the DataKey == + /// This removes the effect of the query filter in single-level tenant using sharding and is the only tenant in a database + /// + /// + /// + /// + private static LambdaExpression SetupSingleTenantShardingQueryFilter(IDataKeyFilterReadOnly dataKey) + where TEntity : class, IDataKeyFilterReadWrite + { + Expression> filter = x => + dataKey.DataKey == MultiTenantExtensions.DataKeyNoQueryFilter || + x.DataKey == dataKey.DataKey; + return filter; + } } } \ No newline at end of file diff --git a/AuthPermissions/PermissionsCode/PermissionConstants.cs b/AuthPermissions/PermissionsCode/PermissionConstants.cs index d1090c76..dacc23f6 100644 --- a/AuthPermissions/PermissionsCode/PermissionConstants.cs +++ b/AuthPermissions/PermissionsCode/PermissionConstants.cs @@ -16,6 +16,10 @@ public static class PermissionConstants /// The claim name holding the optional DataKey /// public const string DataKeyClaimType = "DataKey"; + /// + /// The claim name holding the name of the connection string in the appsettings + /// + public const string ConnectionNameType = "ConnectionName"; /// /// This is the char for the AccessAll permission diff --git a/Example3.InvoiceCode/AppStart/SeedInvoiceDbContext.cs b/Example3.InvoiceCode/AppStart/SeedInvoiceDbContext.cs index b8244d43..488e807b 100644 --- a/Example3.InvoiceCode/AppStart/SeedInvoiceDbContext.cs +++ b/Example3.InvoiceCode/AppStart/SeedInvoiceDbContext.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Threading.Tasks; +using AuthPermissions.AdminCode; using AuthPermissions.DataLayer.Classes; using Example3.InvoiceCode.EfCoreClasses; using Example3.InvoiceCode.EfCoreCode; diff --git a/Example3.InvoiceCode/EfCoreCode/InvoiceTenantChangeService.cs b/Example3.InvoiceCode/EfCoreCode/InvoiceTenantChangeService.cs index 4976985b..add5dc4c 100644 --- a/Example3.InvoiceCode/EfCoreCode/InvoiceTenantChangeService.cs +++ b/Example3.InvoiceCode/EfCoreCode/InvoiceTenantChangeService.cs @@ -19,6 +19,12 @@ public class InvoiceTenantChangeService : ITenantChangeService private readonly InvoicesDbContext _context; private readonly ILogger _logger; + /// + /// This allows the tenantId of the deleted tenant to be returned. + /// This is useful if you want to soft delete the data + /// + public int DeletedTenantId { get; private set; } + public InvoiceTenantChangeService(InvoicesDbContext context, ILogger logger) { _context = context; @@ -31,17 +37,15 @@ public InvoiceTenantChangeService(InvoicesDbContext context, ILogger - /// The DataKey of the tenant being deleted - /// The TenantId of the tenant being deleted - /// The full name of the tenant being deleted + /// /// Returns null if all OK, otherwise the create is rolled back and the return string is shown to the user - public async Task CreateNewTenantAsync(string dataKey, int tenantId, string fullTenantName) + public async Task CreateNewTenantAsync(Tenant tenant) { var newCompanyTenant = new CompanyTenant { - DataKey = dataKey, - AuthPTenantId = tenantId, - CompanyName = fullTenantName + DataKey = tenant.GetTenantDataKey(), + AuthPTenantId = tenant.TenantId, + CompanyName = tenant.TenantFullName }; _context.Add(newCompanyTenant); await _context.SaveChangesAsync(); @@ -59,16 +63,14 @@ public async Task CreateNewTenantAsync(string dataKey, int tenantId, str /// - You can provide information of what you have done by adding public parameters to this class. /// The TenantAdmin method returns your class on a successful Delete /// - /// The DataKey of the tenant being deleted - /// The TenantId of the tenant being deleted - /// The full name of the tenant being deleted + /// /// Returns null if all OK, otherwise the AuthP part of the delete is rolled back and the return string is shown to the user - - public async Task SingleTenantDeleteAsync(string dataKey, int tenantId, string fullTenantName) + public async Task SingleTenantDeleteAsync(Tenant tenant) { await using var transaction = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable); try { + var dataKey = tenant.GetTenantDataKey(); var deleteSalesSql = $"DELETE FROM invoice.{nameof(InvoicesDbContext.LineItems)} WHERE DataKey = '{dataKey}'"; await _context.Database.ExecuteSqlRawAsync(deleteSalesSql); var deleteStockSql = $"DELETE FROM invoice.{nameof(InvoicesDbContext.Invoices)} WHERE DataKey = '{dataKey}'"; @@ -76,18 +78,19 @@ public async Task SingleTenantDeleteAsync(string dataKey, int tenantId, var companyTenant = await _context.Set() .IgnoreQueryFilters() - .SingleOrDefaultAsync(x => x.AuthPTenantId == tenantId); + .SingleOrDefaultAsync(x => x.AuthPTenantId == tenant.TenantId); if (companyTenant != null) { _context.Remove(companyTenant); await _context.SaveChangesAsync(); + DeletedTenantId = tenant.TenantId; } await transaction.CommitAsync(); } catch (Exception e) { - _logger.LogError(e, $"Failure when trying to delete the '{fullTenantName}' tenant."); + _logger.LogError(e, $"Failure when trying to delete the '{tenant.TenantFullName}' tenant."); return "There was a system-level problem - see logs for more detail"; } @@ -98,24 +101,28 @@ public async Task SingleTenantDeleteAsync(string dataKey, int tenantId, /// This is called when the name of your Tenants is changed. This is useful if you use the tenant name in your multi-tenant data. /// NOTE: The created application's DbContext won't have a DataKey, so you will need to use IgnoreQueryFilters on any EF Core read /// - /// The DataKey of the tenant - /// The TenantId of the tenant - /// The full name of the tenant + /// /// Returns null if all OK, otherwise the tenant name is rolled back and the return string is shown to the user - public async Task HandleUpdateNameAsync(string dataKey, int tenantId, string fullTenantName) + public async Task SingleTenantUpdateNameAsync(Tenant tenant) { var companyTenant = await _context.Companies .IgnoreQueryFilters() - .SingleOrDefaultAsync(x => x.AuthPTenantId == tenantId); + .SingleOrDefaultAsync(x => x.AuthPTenantId == tenant.TenantId); if (companyTenant != null) { - companyTenant.CompanyName = fullTenantName; + companyTenant.CompanyName = tenant.TenantFullName; await _context.SaveChangesAsync(); } return null; } + + public Task HierarchicalTenantUpdateNameAsync(List tenantsToUpdate) + { + throw new NotImplementedException(); + } + //Not used public Task HierarchicalTenantDeleteAsync(List tenantsInOrder) { @@ -125,11 +132,15 @@ public Task HierarchicalTenantDeleteAsync(List tenantsInOrder) } //Not used - public Task MoveHierarchicalTenantDataAsync(List<(string oldDataKey, string newDataKey, int tenantId, string newFullTenantName)> tenantToUpdate) + public Task MoveHierarchicalTenantDataAsync(List<(string oldDataKey, Tenant tenantToMove)> tenantToUpdate) { - //This example is using single level multi-tenant, so this will never be called. + throw new NotImplementedException(); + } - throw new System.NotImplementedException(); + public Task MoveToDifferentDatabaseAsync(string oldConnectionName, string oldDataKey, + Tenant updatedTenant) + { + throw new NotImplementedException(); } } } \ No newline at end of file diff --git a/Example3.InvoiceCode/Example3.InvoiceCode.csproj b/Example3.InvoiceCode/Example3.InvoiceCode.csproj index 327ec0d9..d59e143c 100644 --- a/Example3.InvoiceCode/Example3.InvoiceCode.csproj +++ b/Example3.InvoiceCode/Example3.InvoiceCode.csproj @@ -17,11 +17,7 @@ - - - - - + diff --git a/Example4.ShopCode/AppStart/SeedShopsOnStartup.cs b/Example4.ShopCode/AppStart/SeedShopsOnStartup.cs index bc141d35..cc0e41f1 100644 --- a/Example4.ShopCode/AppStart/SeedShopsOnStartup.cs +++ b/Example4.ShopCode/AppStart/SeedShopsOnStartup.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Runtime.InteropServices.ComTypes; using System.Threading.Tasks; using AuthPermissions.AdminCode; using AuthPermissions.CommonCode; diff --git a/Example4.ShopCode/EfCoreCode/Migrations/TenantChangeExtensions.cs b/Example4.ShopCode/EfCoreCode/Migrations/TenantChangeExtensions.cs new file mode 100644 index 00000000..96c39281 --- /dev/null +++ b/Example4.ShopCode/EfCoreCode/Migrations/TenantChangeExtensions.cs @@ -0,0 +1,55 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace Example4.ShopCode.EfCoreCode.Migrations; +/// +/// These methods come from the library https://github.com/JonPSmith/EfCore.SchemaCompare +/// +public static class TenantChangeExtensions +{ + /// + /// This returns a string in the format "table" or "{schema}.{table}" that this entity is mapped to + /// This also handles "ToView" entities, in which case it will map the + /// It it isn't mapped to a table it returns null + /// + /// + /// + public static string FormSchemaTableFromModel(this IEntityType entityType) + { + var tableOrViewName = !string.IsNullOrEmpty((string?)entityType.GetAnnotation(RelationalAnnotationNames.TableName).Value) + ? RelationalAnnotationNames.TableName + : RelationalAnnotationNames.ViewName; + + var tableOrViewSchema = !string.IsNullOrEmpty((string?)entityType.GetAnnotation(RelationalAnnotationNames.TableName).Value) + ? RelationalAnnotationNames.Schema + : RelationalAnnotationNames.ViewSchema; + + var viewAnnotations = entityType.GetAnnotations() + .Where(a => a.Name == tableOrViewName || + a.Name == tableOrViewSchema) + .ToArray(); + + return viewAnnotations.Any() + ? FormSchemaTable((string)viewAnnotations.First(a => a.Name == tableOrViewSchema).Value, (string)viewAnnotations.First(a => a.Name == tableOrViewName).Value) + : entityType.GetTableName() == null + ? null + : FormSchemaTable(entityType.GetSchema(), entityType.GetTableName()); + } + + /// + /// Use this on Model side, where the schema is null for the default schema + /// + /// + /// + /// + public static string FormSchemaTable(this string schema, string table) + { + return string.IsNullOrEmpty(schema) + ? table + : $"{schema}.{table}"; + } +} \ No newline at end of file diff --git a/Example4.ShopCode/EfCoreCode/RetailTenantChangeService.cs b/Example4.ShopCode/EfCoreCode/RetailTenantChangeService.cs index c97b419b..86a168f7 100644 --- a/Example4.ShopCode/EfCoreCode/RetailTenantChangeService.cs +++ b/Example4.ShopCode/EfCoreCode/RetailTenantChangeService.cs @@ -4,12 +4,16 @@ using System; using System.Collections.Generic; using System.Data; +using System.Linq; using System.Threading.Tasks; using AuthPermissions.AdminCode; using AuthPermissions.AdminCode.Services; +using AuthPermissions.CommonCode; using AuthPermissions.DataLayer.Classes; using Example4.ShopCode.EfCoreClasses; +using Example4.ShopCode.EfCoreCode.Migrations; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Logging; namespace Example4.ShopCode.EfCoreCode @@ -19,6 +23,8 @@ public class RetailTenantChangeService : ITenantChangeService private readonly RetailDbContext _context; private readonly ILogger _logger; + public IReadOnlyList DeletedTenantIds { get; private set; } + public RetailTenantChangeService(RetailDbContext context, ILogger logger) { _context = context; @@ -29,48 +35,65 @@ public RetailTenantChangeService(RetailDbContext context, ILogger - /// The DataKey of the tenant being deleted - /// The TenantId of the tenant being deleted - /// The full name of the tenant being deleted + /// /// Returns null if all OK, otherwise the create is rolled back and the return string is shown to the user - public async Task CreateNewTenantAsync(string dataKey, int tenantId, string fullTenantName) + public async Task CreateNewTenantAsync(Tenant tenant) { - _context.Add(new RetailOutlet(tenantId, fullTenantName, dataKey)); + _context.Add(new RetailOutlet(tenant.TenantId, tenant.TenantFullName, tenant.GetTenantDataKey())); await _context.SaveChangesAsync(); return null; } + //not used + public Task SingleTenantUpdateNameAsync(Tenant tenant) + { + throw new NotImplementedException(); + } + /// /// This is called when the name of your Tenants is changed. This is useful if you use the tenant name in your multi-tenant data. /// NOTE: The created application's DbContext won't have a DataKey, so you will need to use IgnoreQueryFilters on any EF Core read. /// You should apply multiple changes within a transaction so that if any fails then any previous changes will be rolled back. /// - /// The DataKey of the tenant - /// The TenantId of the tenant - /// The full name of the tenant + /// This contains the tenants to update. /// Returns null if all OK, otherwise the name change is rolled back and the return string is shown to the user - public async Task HandleUpdateNameAsync(string dataKey, int tenantId, - string fullTenantName) + public async Task HierarchicalTenantUpdateNameAsync(List tenantsToUpdate) { - //Higher hierarchical levels don't have data in this example - var retailOutletToUpdate = - await _context.RetailOutlets - .IgnoreQueryFilters().SingleOrDefaultAsync(x => x.AuthPTenantId == tenantId); + await using var transaction = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable); + + try + { + foreach (var tenant in tenantsToUpdate) + { + //Higher hierarchical levels don't have data in this example + var retailOutletToUpdate = + await _context.RetailOutlets + .IgnoreQueryFilters().SingleOrDefaultAsync(x => x.AuthPTenantId == tenant.TenantId); - if (retailOutletToUpdate != null) + if (retailOutletToUpdate != null) + { + retailOutletToUpdate.UpdateNames(tenant.TenantFullName); + await _context.SaveChangesAsync(); + } + } + + await transaction.CommitAsync(); + } + catch (Exception e) { - retailOutletToUpdate.UpdateNames(fullTenantName); - await _context.SaveChangesAsync(); + _logger.LogError(e, "Failure when trying to update a hierarchical tenant."); + return "There was a system-level problem - see logs for more detail"; } + return null; } //Not used - public Task SingleTenantDeleteAsync(string dataKey, int tenantId, string fullTenantName) + public Task SingleTenantDeleteAsync(Tenant tenant) { throw new System.NotImplementedException(); } @@ -91,9 +114,11 @@ public Task SingleTenantDeleteAsync(string dataKey, int tenantId, string public async Task HierarchicalTenantDeleteAsync(List tenantsInOrder) { await using var transaction = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable); - foreach (var tenant in tenantsInOrder) + + try { - try + var deletedTenantIds = new List(); + foreach (var tenant in tenantsInOrder) { //Higher hierarchical levels don't have data in this example, so it only tries to delete data if there is a RetailOutlet var retailOutletToDelete = @@ -102,29 +127,34 @@ await _context.RetailOutlets if (retailOutletToDelete != null) { //yes, its a shop so delete all the stock / sales - var deleteSalesSql = $"DELETE FROM retail.{nameof(RetailDbContext.ShopSales)} WHERE DataKey = '{tenant.GetTenantDataKey()}'"; + var deleteSalesSql = + $"DELETE FROM retail.{nameof(RetailDbContext.ShopSales)} WHERE DataKey = '{tenant.GetTenantDataKey()}'"; await _context.Database.ExecuteSqlRawAsync(deleteSalesSql); - var deleteStockSql = $"DELETE FROM retail.{nameof(RetailDbContext.ShopStocks)} WHERE DataKey = '{tenant.GetTenantDataKey()}'"; + var deleteStockSql = + $"DELETE FROM retail.{nameof(RetailDbContext.ShopStocks)} WHERE DataKey = '{tenant.GetTenantDataKey()}'"; await _context.Database.ExecuteSqlRawAsync(deleteStockSql); _context.Remove(retailOutletToDelete); //finally delete the RetailOutlet await _context.SaveChangesAsync(); + deletedTenantIds.Add(tenant.TenantId); } - - await transaction.CommitAsync(); - } - catch (Exception e) - { - _logger.LogError(e, $"Failure when trying to delete the '{tenant.TenantFullName}' tenant."); - return "There was a system-level problem - see logs for more detail"; } + + await transaction.CommitAsync(); + DeletedTenantIds = deletedTenantIds.AsReadOnly(); } + catch (Exception e) + { + _logger.LogError(e, "Failure when trying to delete a hierarchical tenant."); + return "There was a system-level problem - see logs for more detail"; + } + + return null; //null means OK, otherwise the delete is rolled back and the return string is shown to the user } - /// /// This is used with hierarchical tenants, where you move one tenant (and its children) to another tenant /// This requires you to change the DataKeys of each application's tenant data, so they link to the new tenant. @@ -135,8 +165,7 @@ await _context.RetailOutlets /// /// The data to update each tenant. This starts at the parent and then recursively works down the children /// Returns null if all OK, otherwise AuthP part of the move is rolled back and the return string is shown to the user - public async Task MoveHierarchicalTenantDataAsync( - List<(string oldDataKey, string newDataKey, int tenantId, string newFullTenantName)> tenantToUpdate) + public async Task MoveHierarchicalTenantDataAsync(List<(string oldDataKey, Tenant tenantToMove)> tenantToUpdate) { await using var transaction = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable); @@ -144,31 +173,26 @@ public async Task MoveHierarchicalTenantDataAsync( { foreach (var tuple in tenantToUpdate) { - try + //Higher hierarchical levels don't have data in this example, so it only tries to move data if there is a RetailOutlet + var retailOutletMove = + await _context.RetailOutlets + .IgnoreQueryFilters() + .SingleOrDefaultAsync(x => x.AuthPTenantId == tuple.tenantToMove.TenantId); + if (retailOutletMove != null) { - //Higher hierarchical levels don't have data in this example, so it only tries to move data if there is a RetailOutlet - var retailOutletMove = - await _context.RetailOutlets - .IgnoreQueryFilters().SingleOrDefaultAsync(x => x.AuthPTenantId == tuple.tenantId); - if (retailOutletMove != null) + //yes, its a shop so move all the stock / sales + + //This code will update the DataKey of every entity that has the IDataKeyFilterReadOnly interface + foreach (var entityType in _context.Model.GetEntityTypes() + .Where(x => typeof(IDataKeyFilterReadOnly).IsAssignableFrom(x.ClrType))) { - //yes, its a shop so move all the stock / sales - var moveSalesSql = $"UPDATE retail.{nameof(RetailDbContext.ShopSales)} " + - $"SET DataKey = '{tuple.newDataKey}' WHERE DataKey = '{tuple.oldDataKey}'"; - await _context.Database.ExecuteSqlRawAsync(moveSalesSql); - var moveStockSql = $"UPDATE retail.{nameof(RetailDbContext.ShopStocks)} " + - $"SET DataKey = '{tuple.newDataKey}' WHERE DataKey = '{tuple.oldDataKey}'"; - await _context.Database.ExecuteSqlRawAsync(moveStockSql); - - retailOutletMove.UpdateNames(tuple.newFullTenantName); - retailOutletMove.UpdateDataKey(tuple.newDataKey); - await _context.SaveChangesAsync(); + var updateDataKey = $"UPDATE {entityType.FormSchemaTableFromModel()} " + + $"SET DataKey = '{tuple.tenantToMove.GetTenantDataKey()}' WHERE DataKey = '{tuple.oldDataKey}'"; + await _context.Database.ExecuteSqlRawAsync(updateDataKey); } - } - catch (Exception e) - { - _logger.LogError(e, $"Failure when trying to move old Datakey {tuple.oldDataKey} to {tuple.newDataKey}."); - return "There was a system-level problem - see logs for more detail"; + + retailOutletMove.UpdateNames(tuple.tenantToMove.TenantFullName); + await _context.SaveChangesAsync(); } } @@ -176,11 +200,17 @@ await _context.RetailOutlets } catch (Exception e) { - _logger.LogError(e, $"Failure when calling transaction.CommitAsync in a hierarchical Move."); + _logger.LogError(e, "Failure when trying to Move a hierarchical tenant."); return "There was a system-level problem - see logs for more detail"; } return null; } + + public Task MoveToDifferentDatabaseAsync(string oldConnectionName, string oldDataKey, + Tenant updatedTenant) + { + throw new NotImplementedException(); + } } } \ No newline at end of file diff --git a/Example6.SingleLevelSharding/AppStart/ExampleInvoiceBuilder.cs b/Example6.SingleLevelSharding/AppStart/ExampleInvoiceBuilder.cs new file mode 100644 index 00000000..38a6ef60 --- /dev/null +++ b/Example6.SingleLevelSharding/AppStart/ExampleInvoiceBuilder.cs @@ -0,0 +1,61 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using Example6.SingleLevelSharding.EfCoreClasses; + +namespace Example6.SingleLevelSharding.AppStart +{ + public enum ExampleInvoiceTypes { Computer = 0, Office = 1, Travel = 2 } + + public class ExampleInvoiceBuilder + { + private readonly string _dataKey; + + private readonly Random _random = new Random(); + + private readonly Dictionary LineItemsDict = new Dictionary() + { + { ExampleInvoiceTypes.Computer, new [] {"Windows PC", "Keyboard", "BIG Screen" } }, + { ExampleInvoiceTypes.Office, new [] {"Desk", "Chair", "Filing cabinet", "Waste bin" } }, + { ExampleInvoiceTypes.Travel, new [] { "Taxi", "Flight", "Hotel", "Taxi", "Lunch", "Flight", "Taxi" } }, + }; + + public ExampleInvoiceBuilder(string dataKey) + { + _dataKey = dataKey; + } + + public Invoice CreateRandomInvoice(string companyName, string invoiceName = null) + { + //thanks to https://stackoverflow.com/questions/29482/how-can-i-cast-int-to-enum + var invoiceType = (ExampleInvoiceTypes)Enum.ToObject(typeof(ExampleInvoiceTypes), + _random.Next(0, ((int)ExampleInvoiceTypes.Travel)+1)); + + return CreateExampleInvoice(invoiceType, invoiceName ?? invoiceType.ToString(), companyName); + } + + public Invoice CreateExampleInvoice(ExampleInvoiceTypes invoiceType, string invoiceName, string companyName) + { + var invoice = new Invoice + { + InvoiceName = invoiceName + $" - ({companyName})", + DataKey = _dataKey, + DateCreated = DateTime.UtcNow, + LineItems = new List() + }; + + foreach (var name in LineItemsDict[invoiceType]) + { + invoice.LineItems.Add(new LineItem + { + ItemName = name, + NumberItems = 1, + TotalPrice = _random.Next(10, 1000), + DataKey = _dataKey + }); + } + + return invoice; + } + } +} \ No newline at end of file diff --git a/Example6.SingleLevelSharding/AppStart/SeedShardingDbContext.cs b/Example6.SingleLevelSharding/AppStart/SeedShardingDbContext.cs new file mode 100644 index 00000000..306ebd23 --- /dev/null +++ b/Example6.SingleLevelSharding/AppStart/SeedShardingDbContext.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using AuthPermissions.AdminCode; +using AuthPermissions.DataLayer.Classes; +using Example6.SingleLevelSharding.EfCoreClasses; +using Example6.SingleLevelSharding.EfCoreCode; + +namespace Example6.SingleLevelSharding.AppStart +{ + public class SeedShardingDbContext + { + private readonly ShardingSingleDbContext _context; + + public SeedShardingDbContext(ShardingSingleDbContext context) + { + _context = context; + } + + public async Task SeedInvoicesForAllTenantsAsync(IEnumerable authTenants) + { + foreach (var authTenant in authTenants) + { + + var company = new CompanyTenant + { + AuthPTenantId = authTenant.TenantId, + CompanyName = authTenant.TenantFullName, + DataKey = authTenant.GetTenantDataKey(), + }; + _context.Add(company); + var invoiceBuilder = new ExampleInvoiceBuilder(authTenant.GetTenantDataKey()); + + for (int i = 0; i < 5; i++) + { + var invoice = invoiceBuilder.CreateRandomInvoice(authTenant.TenantFullName); + _context.Add(invoice); + } + } + + await _context.SaveChangesAsync(); + } + } +} \ No newline at end of file diff --git a/Example6.SingleLevelSharding/AppStart/StartupExtensions.cs b/Example6.SingleLevelSharding/AppStart/StartupExtensions.cs new file mode 100644 index 00000000..1a44851b --- /dev/null +++ b/Example6.SingleLevelSharding/AppStart/StartupExtensions.cs @@ -0,0 +1,30 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using Example6.SingleLevelSharding.EfCoreCode; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NetCore.AutoRegisterDi; + +namespace Example6.SingleLevelSharding.AppStart +{ + public static class StartupExtensions + { + public const string ShardingSingleDbContextHistoryName = "Example6-ShardingSingleDbContext"; + + public static void RegisterExample3Invoices(this IServiceCollection services, IConfiguration configuration) + { + //Register any services in this project + services.RegisterAssemblyPublicNonGenericClasses() + .Where(c => c.Name.EndsWith("Service")) //optional + .AsPublicImplementedInterfaces(); + + //Register the retail database to the same database used for individual accounts and AuthP database + services.AddDbContext(options => + options.UseSqlServer( + configuration.GetConnectionString("DefaultConnection"), dbOptions => + dbOptions.MigrationsHistoryTable(ShardingSingleDbContextHistoryName))); + } + } +} \ No newline at end of file diff --git a/Example6.SingleLevelSharding/AppStart/StartupServiceSeedShardingDbContext.cs b/Example6.SingleLevelSharding/AppStart/StartupServiceSeedShardingDbContext.cs new file mode 100644 index 00000000..134c7333 --- /dev/null +++ b/Example6.SingleLevelSharding/AppStart/StartupServiceSeedShardingDbContext.cs @@ -0,0 +1,33 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using AuthPermissions.AdminCode; +using Example6.SingleLevelSharding.EfCoreCode; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using RunMethodsSequentially; + +namespace Example6.SingleLevelSharding.AppStart +{ + /// + /// If there are no RetailOutlets in the RetailDbContext it seeds the RetailDbContext with RetailOutlets and gives each of them some stock + /// + + public class StartupServiceSeedShardingDbContext : IStartupServiceToRunSequentially + { + public int OrderNum { get; } //runs after migration of the InvoicesDbContext + + public async ValueTask ApplyYourChangeAsync(IServiceProvider scopedServices) + { + var context = scopedServices.GetRequiredService(); + var numInvoices = await context.Invoices.IgnoreQueryFilters().CountAsync(); + if (numInvoices == 0) + { + var authTenantAdmin = scopedServices.GetRequiredService(); + + var seeder = new SeedShardingDbContext(context); + await seeder.SeedInvoicesForAllTenantsAsync(authTenantAdmin.QueryTenants().ToArray()); + } + } + } +} \ No newline at end of file diff --git a/Example6.SingleLevelSharding/Dtos/CreateTenantDto.cs b/Example6.SingleLevelSharding/Dtos/CreateTenantDto.cs new file mode 100644 index 00000000..37cafa88 --- /dev/null +++ b/Example6.SingleLevelSharding/Dtos/CreateTenantDto.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +namespace Example6.SingleLevelSharding.Dtos; + +public class CreateTenantDto +{ + public string TenantName { get; set; } + public string Email { get; set; } + public string Password { get; set; } + public string Version { get; set; } + + public TenantVersionTypes GetTenantVersionType() + { + return string.IsNullOrWhiteSpace(Version) + ? TenantVersionTypes.NotSet + : Enum.Parse(Version); + } + + + public override string ToString() + { + return $"{nameof(TenantName)}: {TenantName}, {nameof(Email)}: {Email}, {nameof(Password)}: {Password}, {nameof(Version)}: {Version}"; + } +} \ No newline at end of file diff --git a/Example6.SingleLevelSharding/Dtos/InvoiceSummaryDto.cs b/Example6.SingleLevelSharding/Dtos/InvoiceSummaryDto.cs new file mode 100644 index 00000000..e409e892 --- /dev/null +++ b/Example6.SingleLevelSharding/Dtos/InvoiceSummaryDto.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using Example6.SingleLevelSharding.EfCoreClasses; + +namespace Example6.SingleLevelSharding.Dtos +{ + public class InvoiceSummaryDto + { + public int InvoiceId { get; set; } + + public string InvoiceName { get; set; } + + public DateTime DateCreated { get; set; } + + public int NumItems { get; set; } + + public double? TotalCost { get; set; } + + public static IQueryable SelectInvoices(IQueryable invoices) + { + return invoices.Select(x => new InvoiceSummaryDto + { + InvoiceId = x.InvoiceId, + InvoiceName = x.InvoiceName, + DateCreated = x.DateCreated, + NumItems = x.LineItems.Count, + TotalCost = x.LineItems.Select(y => (double?)y.TotalPrice).Sum() + }); + } + } +} \ No newline at end of file diff --git a/Example6.SingleLevelSharding/Dtos/TenantVersionTypes.cs b/Example6.SingleLevelSharding/Dtos/TenantVersionTypes.cs new file mode 100644 index 00000000..5df4ead2 --- /dev/null +++ b/Example6.SingleLevelSharding/Dtos/TenantVersionTypes.cs @@ -0,0 +1,16 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +namespace Example6.SingleLevelSharding.Dtos; + +public enum TenantVersionTypes +{ + //Error + NotSet, + //Only allows one user per tenant + Free, + //Allows many users in one tenant + Pro, + //Have your own admin user + Enterprise +} \ No newline at end of file diff --git a/Example6.SingleLevelSharding/EfCoreClasses/CompanyTenant.cs b/Example6.SingleLevelSharding/EfCoreClasses/CompanyTenant.cs new file mode 100644 index 00000000..1ad41666 --- /dev/null +++ b/Example6.SingleLevelSharding/EfCoreClasses/CompanyTenant.cs @@ -0,0 +1,27 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using AuthPermissions.CommonCode; + +namespace Example6.SingleLevelSharding.EfCoreClasses +{ + public class CompanyTenant : IDataKeyFilterReadWrite + { + public int CompanyTenantId { get; set; } + + /// + /// This contains the fullname of the AuthP Tenant + /// + public string CompanyName { get; set; } + + /// + /// This contains the Primary key from the AuthP's Tenant + /// + public int AuthPTenantId { get; set; } + + /// + /// This contains the datakey from the AuthP's Tenant + /// + public string DataKey { get; set; } + } +} \ No newline at end of file diff --git a/Example6.SingleLevelSharding/EfCoreClasses/Invoice.cs b/Example6.SingleLevelSharding/EfCoreClasses/Invoice.cs new file mode 100644 index 00000000..08741214 --- /dev/null +++ b/Example6.SingleLevelSharding/EfCoreClasses/Invoice.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using System.ComponentModel.DataAnnotations; +using AuthPermissions.CommonCode; + +namespace Example6.SingleLevelSharding.EfCoreClasses +{ + public class Invoice : IDataKeyFilterReadWrite + { + public int InvoiceId { get; set; } + + [Required(AllowEmptyStrings = false)] + public string InvoiceName { get; set; } + + public DateTime DateCreated { get; set; } + + public string DataKey { get; set; } + + //---------------------------------------- + // relationships + + public List LineItems { get; set; } + } +} \ No newline at end of file diff --git a/Example6.SingleLevelSharding/EfCoreClasses/LineItem.cs b/Example6.SingleLevelSharding/EfCoreClasses/LineItem.cs new file mode 100644 index 00000000..cd52b965 --- /dev/null +++ b/Example6.SingleLevelSharding/EfCoreClasses/LineItem.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using AuthPermissions.CommonCode; + +namespace Example6.SingleLevelSharding.EfCoreClasses +{ + public class LineItem : IDataKeyFilterReadWrite + { + public int LineItemId { get; set; } + + public string ItemName { get; set; } + + public int NumberItems { get; set; } + + public decimal TotalPrice { get; set; } + + public string DataKey { get; set; } + + //---------------------------------------------- + // relationships + + public int InvoiceId { get; set; } + } +} \ No newline at end of file diff --git a/Example6.SingleLevelSharding/EfCoreCode/MarkDataKeyExtension.cs b/Example6.SingleLevelSharding/EfCoreCode/MarkDataKeyExtension.cs new file mode 100644 index 00000000..8843e79f --- /dev/null +++ b/Example6.SingleLevelSharding/EfCoreCode/MarkDataKeyExtension.cs @@ -0,0 +1,24 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using AuthPermissions.CommonCode; +using Microsoft.EntityFrameworkCore; + +namespace Example6.SingleLevelSharding.EfCoreCode +{ + public static class MarkDataKeyExtension + { + public static void MarkWithDataKeyIfNeeded(this DbContext context, string accessKey) + { + foreach (var entityEntry in context.ChangeTracker.Entries() + .Where(e => e.State == EntityState.Added)) + { + var hasDataKey = entityEntry.Entity as IDataKeyFilterReadWrite; + if (hasDataKey != null && hasDataKey.DataKey == null) + // If the entity has a DataKey it will only update it if its null + // This allow for the code to define the DataKey on creation + hasDataKey.DataKey = accessKey; + } + } + } +} \ No newline at end of file diff --git a/Example6.SingleLevelSharding/EfCoreCode/ShardingSingleDbContext.cs b/Example6.SingleLevelSharding/EfCoreCode/ShardingSingleDbContext.cs new file mode 100644 index 00000000..6b2fe080 --- /dev/null +++ b/Example6.SingleLevelSharding/EfCoreCode/ShardingSingleDbContext.cs @@ -0,0 +1,86 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using AuthPermissions.AspNetCore.GetDataKeyCode; +using AuthPermissions.CommonCode; +using AuthPermissions.DataLayer.EfCode; +using Example6.SingleLevelSharding.EfCoreClasses; +using Microsoft.EntityFrameworkCore; + +namespace Example6.SingleLevelSharding.EfCoreCode; + +/// +/// This is a DBContext that supports sharding +/// +public class ShardingSingleDbContext : DbContext, IDataKeyFilterReadOnly +{ + /// + /// ctor + /// + /// + /// This uses a service that obtains the DataKey and database connection string + /// via the claims in the logged in users. + public ShardingSingleDbContext(DbContextOptions options, + IGetShardingDataFromUser shardingDataKeyAndConnect) + : base(options) + { + // The DataKey is null when: no one is logged in, its a background service, or user hasn't got an assigned tenant + // In these cases its best to set the data key that doesn't match any possible DataKey + DataKey = shardingDataKeyAndConnect?.DataKey ?? "stop any user without a DataKey to access the data"; + + if (shardingDataKeyAndConnect?.ConnectionString != null) + //NOTE: If no connection string is provided the DbContext will use the connection it was provided when it was registered + //If you don't want that to happen, then remove the if above and the connection will be set to null (and fail) + Database.SetConnectionString(shardingDataKeyAndConnect.ConnectionString); + } + + public DbSet Companies { get; set; } + public DbSet Invoices { get; set; } + public DbSet LineItems { get; set; } + public string DataKey { get; } + + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + this.MarkWithDataKeyIfNeeded(DataKey); + return base.SaveChanges(acceptAllChangesOnSuccess); + } + + public override async Task SaveChangesAsync(bool acceptAllChangesOnSuccess, + CancellationToken cancellationToken = default(CancellationToken)) + { + this.MarkWithDataKeyIfNeeded(DataKey); + return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("invoice"); + + // You could manually set up the Query Filter, but there is a easier approach + //modelBuilder.Entity().HasQueryFilter(x => x.DataKey == DataKey); + //modelBuilder.Entity().HasQueryFilter(x => x.DataKey == DataKey); + //modelBuilder.Entity().HasQueryFilter(x => x.DataKey == DataKey); + + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + if (typeof(IDataKeyFilterReadWrite).IsAssignableFrom(entityType.ClrType)) + { + entityType.AddSingleTenantShardingQueryFilter(this); + } + else + { + throw new Exception( + $"You haven't added the {nameof(IDataKeyFilterReadWrite)} to the entity {entityType.ClrType.Name}"); + } + + foreach (var mutableProperty in entityType.GetProperties()) + { + if (mutableProperty.ClrType == typeof(decimal)) + { + mutableProperty.SetPrecision(9); + mutableProperty.SetScale(2); + } + } + } + } +} \ No newline at end of file diff --git a/Example6.SingleLevelSharding/EfCoreCode/ShardingSingleDesignTimeContextFactory.cs b/Example6.SingleLevelSharding/EfCoreCode/ShardingSingleDesignTimeContextFactory.cs new file mode 100644 index 00000000..52d34ba2 --- /dev/null +++ b/Example6.SingleLevelSharding/EfCoreCode/ShardingSingleDesignTimeContextFactory.cs @@ -0,0 +1,51 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using Example6.SingleLevelSharding.AppStart; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Example6.SingleLevelSharding.EfCoreCode +{ + public class ShardingSingleDesignTimeContextFactory : IDesignTimeDbContextFactory + { + // This connection links to an invalidate database, but that's OK as I only used the Add-Migration command + private const string connectionString = + "Server=(localdb)\\mssqllocaldb;Database=AuthPermissions;Trusted_Connection=True;MultipleActiveResultSets=true"; + + public ShardingSingleDbContext CreateDbContext(string[] args) + { + var optionsBuilder = + new DbContextOptionsBuilder(); + optionsBuilder.UseSqlServer(connectionString, dbOptions => + dbOptions.MigrationsHistoryTable(StartupExtensions.ShardingSingleDbContextHistoryName)); + + return new ShardingSingleDbContext(optionsBuilder.Options, null); + } + } + /****************************************************************************** + * NOTES ON MIGRATION: + * + * The AuthPermissionsDbContext is stored in the AuthPermissions project + * + * see https://docs.microsoft.com/en-us/aspnet/core/data/ef-rp/migrations?tabs=visual-studio + * + * Add the following NuGet libraries to this project + * 1. "Microsoft.EntityFrameworkCore.Tools" + * 2. "Microsoft.EntityFrameworkCore.SqlServer" (or another database provider) + * + * 2. Using Package Manager Console commands + * The steps are: + * a) Make sure the default project is Example3.InvoiceCode + * b) Set the Example3 project as the startup project + * b) Use the PMC command + * Add-Migration Initial -Context InvoicesDbContext -OutputDir EfCoreCode/Migrations + * c) Don't migrate the database using the Update-database, but use the AddDatabaseOnStartup extension + * method when registering the AuthPermissions in ASP.NET Core. + * + * If you want to start afresh then: + * a) Delete the current database + * b) Delete all the class in the Migration directory + * c) follow the steps to add a migration + ******************************************************************************/ +} \ No newline at end of file diff --git a/Example6.SingleLevelSharding/EfCoreCode/ShardingTenantChangeService.cs b/Example6.SingleLevelSharding/EfCoreCode/ShardingTenantChangeService.cs new file mode 100644 index 00000000..761cad62 --- /dev/null +++ b/Example6.SingleLevelSharding/EfCoreCode/ShardingTenantChangeService.cs @@ -0,0 +1,234 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using System.Data; +using AuthPermissions.AdminCode; +using AuthPermissions.AspNetCore.GetDataKeyCode; +using AuthPermissions.AspNetCore.Services; +using AuthPermissions.DataLayer.Classes; +using Example6.SingleLevelSharding.EfCoreClasses; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using TestSupport.SeedDatabase; + +namespace Example6.SingleLevelSharding.EfCoreCode; + +/// +/// This is the service for a single-level tenant with Sharding turned on. +/// This is different to the non-sharding versions, as we have to create the the instance of the application's +/// DbContext because the connection string relies on the in the tenant - +/// see at the end of this class. This also allows the DataKey to be added +/// which removes the need for using the IgnoreQueryFilters method on any queries +/// +public class ShardingTenantChangeService : ITenantChangeService +{ + private readonly DbContextOptions _options; + private readonly IShardingConnections _connections; + private readonly ILogger _logger; + + /// + /// This allows the tenantId of the deleted tenant to be returned. + /// This is useful if you want to soft delete the data + /// + public int DeletedTenantId { get; private set; } + + public ShardingTenantChangeService(DbContextOptions options, + IShardingConnections connections, ILogger logger) + { + _options = options; + _connections = connections; + _logger = logger; + } + + /// + /// This creates a in the given database + /// + /// + /// + public async Task CreateNewTenantAsync(Tenant tenant) + { + var context = GetShardingSingleDbContext(tenant.ConnectionName, tenant.GetTenantDataKey()); + if (context == null) + return $"There is no connection string with the name {tenant.ConnectionName}."; + + var newCompanyTenant = new CompanyTenant + { + DataKey = tenant.GetTenantDataKey(), + AuthPTenantId = tenant.TenantId, + CompanyName = tenant.TenantFullName + }; + context.Add(newCompanyTenant); + await context.SaveChangesAsync(); + + return null; + } + + public async Task SingleTenantUpdateNameAsync(Tenant tenant) + { + var context = GetShardingSingleDbContext(tenant.ConnectionName, tenant.GetTenantDataKey()); + if (context == null) + return $"There is no connection string with the name {tenant.ConnectionName}."; + + var companyTenant = await context.Companies + .SingleOrDefaultAsync(x => x.AuthPTenantId == tenant.TenantId); + if (companyTenant != null) + { + companyTenant.CompanyName = tenant.TenantFullName; + await context.SaveChangesAsync(); + } + + return null; + } + + public async Task SingleTenantDeleteAsync(Tenant tenant) + { + var context = GetShardingSingleDbContext(tenant.ConnectionName, tenant.GetTenantDataKey()); + if (context == null) + return $"There is no connection string with the name {tenant.ConnectionName}."; + + await using var transaction = await context.Database.BeginTransactionAsync(IsolationLevel.Serializable); + try + { + await DeleteTenantData(tenant.GetTenantDataKey(), context); + DeletedTenantId = tenant.TenantId; + + await transaction.CommitAsync(); + } + catch (Exception e) + { + _logger.LogError(e, $"Failure when trying to delete the '{tenant.TenantFullName}' tenant."); + return "There was a system-level problem - see logs for more detail"; + } + + return null; + } + + public Task HierarchicalTenantUpdateNameAsync(List tenantsToUpdate) + { + throw new NotImplementedException(); + } + + public Task HierarchicalTenantDeleteAsync(List tenantsInOrder) + { + throw new NotImplementedException(); + } + + public Task MoveHierarchicalTenantDataAsync(List<(string oldDataKey, Tenant tenantToMove)> tenantToUpdate) + { + throw new NotImplementedException(); + } + + /// + /// This method can be quite complicated. It has to + /// 1. Copy the data from the previous database into the new database + /// 2. Delete the old data + /// These two steps have to be done within a transaction, so that a failure to delete the old data will roll back the copy. + /// + /// + /// + /// + /// + public async Task MoveToDifferentDatabaseAsync(string oldConnectionName, string oldDataKey, Tenant updatedTenant) + { + var oldContext = GetShardingSingleDbContext(oldConnectionName, oldDataKey); + if (oldContext == null) + return $"There is no connection string with the name {oldConnectionName}."; + + var newContext = GetShardingSingleDbContext(updatedTenant.ConnectionName, updatedTenant.GetTenantDataKey()); + if (newContext == null) + return $"There is no connection string with the name {updatedTenant.ConnectionName}."; + + await using var transactionNew = await newContext.Database.BeginTransactionAsync(IsolationLevel.Serializable); + try + { + var invoicesWithLineItems = await oldContext.Invoices.AsNoTracking().Include(x => x.LineItems) + .ToListAsync(); + + //This looks through the entities and resets the primary key to their default value + var resetter = new DataResetter(newContext); + resetter.ResetKeysEntityAndRelationships(invoicesWithLineItems); + + newContext.AddRange(invoicesWithLineItems); + + var companyTenant = await oldContext.Companies.AsNoTracking().SingleOrDefaultAsync(); + if (companyTenant != null) + { + companyTenant.CompanyTenantId = default; + newContext.Add(companyTenant); + } + + await newContext.SaveChangesAsync(); + + //Now we try to delete the old data + await using var transactionOld = await oldContext.Database.BeginTransactionAsync(IsolationLevel.Serializable); + try + { + await DeleteTenantData(oldDataKey, oldContext); + + await transactionOld.CommitAsync(); + } + catch (Exception e) + { + _logger.LogError(e, "Failure when trying to delete the original tenant data after the copy over."); + return "There was a system-level problem - see logs for more detail"; + } + + await transactionNew.CommitAsync(); + } + catch (Exception e) + { + _logger.LogError(e, "Failure when trying to copy the tenant data to the new database."); + return "There was a system-level problem - see logs for more detail"; + } + + return null; + + + return null; + } + + //-------------------------------------------------- + //private methods / classes + + private async Task DeleteTenantData(string dataKey, ShardingSingleDbContext context) + { + var deleteSalesSql = $"DELETE FROM invoice.{nameof(ShardingSingleDbContext.LineItems)} WHERE DataKey = '{dataKey}'"; + await context.Database.ExecuteSqlRawAsync(deleteSalesSql); + var deleteStockSql = $"DELETE FROM invoice.{nameof(ShardingSingleDbContext.Invoices)} WHERE DataKey = '{dataKey}'"; + await context.Database.ExecuteSqlRawAsync(deleteStockSql); + + var companyTenant = await context.Companies.SingleOrDefaultAsync(); + if (companyTenant != null) + { + context.Remove(companyTenant); + await context.SaveChangesAsync(); + } + } + + /// + /// This create a with the correct connection string and DataKey + /// + /// + /// + /// or null if connectionName wasn't found in the appsetting file + private ShardingSingleDbContext? GetShardingSingleDbContext(string connectionName, string dataKey) + { + var connectionString = _connections.GetNamedConnectionString(connectionName); + if (connectionString == null) + return null; + + return new ShardingSingleDbContext(_options, new StubGetShardingDataFromUser(connectionString, dataKey)); + } + + private class StubGetShardingDataFromUser : IGetShardingDataFromUser + { + public StubGetShardingDataFromUser(string connectionString, string dataKey) + { + ConnectionString = connectionString; + DataKey = dataKey; + } + + public string DataKey { get; } + public string ConnectionString { get; } + } +} \ No newline at end of file diff --git a/Example6.SingleLevelSharding/Example6.SingleLevelSharding.csproj b/Example6.SingleLevelSharding/Example6.SingleLevelSharding.csproj new file mode 100644 index 00000000..024c30bf --- /dev/null +++ b/Example6.SingleLevelSharding/Example6.SingleLevelSharding.csproj @@ -0,0 +1,33 @@ + + + + net6.0 + enable + enable + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + diff --git a/Test/Test.csproj b/Test/Test.csproj index a71c9dc0..7f6521c9 100644 --- a/Test/Test.csproj +++ b/Test/Test.csproj @@ -25,7 +25,7 @@ - + @@ -53,6 +53,7 @@ + diff --git a/Test/TestData/Test.txt b/Test/TestData/Test.txt index 15e68c26..85b90cfe 100644 --- a/Test/TestData/Test.txt +++ b/Test/TestData/Test.txt @@ -1 +1 @@ -8db6e054-0ea3-42f9-a143-4cba433737f7 \ No newline at end of file +b63369fc-ce06-412d-8278-dd1402c6cd58 \ No newline at end of file diff --git a/Test/TestData/appsettings.json b/Test/TestData/appsettings.json new file mode 100644 index 00000000..be46bba9 --- /dev/null +++ b/Test/TestData/appsettings.json @@ -0,0 +1,9 @@ +{ + "ConnectionStrings": { + "UnitTestConnection": "Server=(localdb)\\mssqllocaldb;Database=AuthPermissions-Test;Trusted_Connection=True;MultipleActiveResultSets=true", + "Version1Example4": "Server=(localdb)\\mssqllocaldb;Database=AuthPermissions-Version1-Example4-HierarchicalMultiTenant;Trusted_Connection=True;MultipleActiveResultSets=true", + "AnotherConnectionString": "Server=MyServer;Database=DummyDatabase;" + }, + "This": 1, + "That": [1,2,3] +} diff --git a/Test/TestHelpers/AuthPSetupHelpers.cs b/Test/TestHelpers/AuthPSetupHelpers.cs index 2e3ea8bc..0adec86c 100644 --- a/Test/TestHelpers/AuthPSetupHelpers.cs +++ b/Test/TestHelpers/AuthPSetupHelpers.cs @@ -6,10 +6,17 @@ using System.Linq; using System.Threading.Tasks; using AuthPermissions; +using AuthPermissions.AdminCode; using AuthPermissions.BulkLoadServices.Concrete; using AuthPermissions.DataLayer.Classes; using AuthPermissions.DataLayer.EfCode; using AuthPermissions.SetupCode; +using Example3.InvoiceCode.EfCoreClasses; +using Example3.InvoiceCode.EfCoreCode; +using Example4.ShopCode.EfCoreClasses; +using Example4.ShopCode.EfCoreCode; +using Example6.SingleLevelSharding.AppStart; +using Example6.SingleLevelSharding.EfCoreCode; using Xunit.Extensions.AssertExtensions; namespace Test.TestHelpers @@ -66,15 +73,59 @@ public static void AddMultipleUsersWithRolesInDb(this AuthPermissionsDbContext c context.SaveChanges(); } - public static List SetupSingleTenantsInDb(this AuthPermissionsDbContext context) + public static List SetupSingleTenantsInDb(this AuthPermissionsDbContext context, InvoicesDbContext invoiceContext = null) { - var s1 = Tenant.CreateSingleTenant("Tenant1"); - var s2 = Tenant.CreateSingleTenant("Tenant2"); - var s3 = Tenant.CreateSingleTenant("Tenant3"); - context.AddRange(s1.Result, s2.Result, s3.Result); + var tenants = new [] + { + Tenant.CreateSingleTenant("Tenant1").Result, + Tenant.CreateSingleTenant("Tenant2").Result, + Tenant.CreateSingleTenant("Tenant3").Result, + }; + + context.AddRange(tenants); context.SaveChanges(); - return new List { s1.Result.TenantId, s2.Result.TenantId, s3.Result.TenantId }; + if (invoiceContext != null) + { + foreach (var tenant in tenants) + { + var company = new CompanyTenant + { + AuthPTenantId = tenant.TenantId, + CompanyName = tenant.TenantFullName, + DataKey = tenant.GetTenantDataKey(), + }; + invoiceContext.Add(company); + } + + invoiceContext.SaveChanges(); + } + + return tenants.Select(x => x.TenantId).ToList(); + } + + public async static Task> SetupSingleShardingTenantsInDb(this AuthPermissionsDbContext context, + ShardingSingleDbContext appContext = null) + { + var tenants = new List + { + Tenant.CreateSingleTenant("Tenant1").Result, + Tenant.CreateSingleTenant("Tenant2").Result, + Tenant.CreateSingleTenant("Tenant3").Result, + }; + + tenants.ForEach(x => x.UpdateShardingState("DefaultConnection", false)); + + context.AddRange(tenants); + context.SaveChanges(); + + if (appContext != null) + { + var seeder = new SeedShardingDbContext(appContext); + await seeder.SeedInvoicesForAllTenantsAsync(tenants); + } + + return tenants.Select(x => x.TenantId).ToList(); } public static List GetSingleTenant123() @@ -113,14 +164,38 @@ public static List GetHierarchicalDefinitionCompany() }; } - public static async Task> SetupHierarchicalTenantInDbAsync(this AuthPermissionsDbContext context) + public static async Task> BulkLoadHierarchicalTenantInDbAsync(this AuthPermissionsDbContext context, + RetailDbContext retailContext = null) { var service = new BulkLoadTenantsService(context); var authOptions = new AuthPermissionsOptions {TenantType = TenantTypes.HierarchicalTenant}; (await service.AddTenantsToDatabaseAsync(GetHierarchicalDefinitionCompany(), authOptions)).IsValid.ShouldBeTrue(); + if (retailContext != null) + { + //We add + foreach (var tenant in context.Tenants) + { + retailContext.Add(new RetailOutlet(tenant.TenantId, tenant.TenantFullName, + tenant.GetTenantDataKey())); + } + + retailContext.SaveChanges(); + } + + return context.Tenants.Select(x => x.TenantId).OrderBy(x => x).ToList(); + } + + public static async Task> BulkLoadHierarchicalTenantShardingAsync(this AuthPermissionsDbContext context) + { + var service = new BulkLoadTenantsService(context); + var authOptions = new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant }; + + (await service.AddTenantsToDatabaseAsync(GetHierarchicalDefinitionCompany(), authOptions)).IsValid.ShouldBeTrue(); + context.Tenants.ToList().ForEach(x => x.UpdateShardingState("DefaultConnection", false)); + await context.SaveChangesAsync(); - return context.Tenants.Select(x => x.TenantId).ToList(); + return context.Tenants.Select(x => x.TenantId).OrderBy(x => x).ToList(); } public static List TestUserDefineWithUserId(string user2Roles = "Role1,Role2") diff --git a/Test/TestHelpers/TenantChangeSqlServerSetup.cs b/Test/TestHelpers/HierarchicalTenantChangeSqlServerSetup.cs similarity index 73% rename from Test/TestHelpers/TenantChangeSqlServerSetup.cs rename to Test/TestHelpers/HierarchicalTenantChangeSqlServerSetup.cs index 1bd06769..3f48ff01 100644 --- a/Test/TestHelpers/TenantChangeSqlServerSetup.cs +++ b/Test/TestHelpers/HierarchicalTenantChangeSqlServerSetup.cs @@ -12,33 +12,28 @@ namespace Test.TestHelpers { - public class TenantChangeSqlServerSetup : IDisposable + public class HierarchicalTenantChangeSqlServerSetup : IDisposable { - public string ConnectionString { get; } public AuthPermissionsDbContext AuthPContext { get; } public RetailDbContext RetailDbContext { get; } - public TenantChangeSqlServerSetup(object caller) + public HierarchicalTenantChangeSqlServerSetup(object caller) { - ConnectionString = caller.GetUniqueDatabaseConnectionString(); - var authOptions = new DbContextOptionsBuilder() - .UseSqlServer(ConnectionString, dbOptions => + .UseSqlServer(caller.GetUniqueDatabaseConnectionString("authp"), dbOptions => dbOptions.MigrationsHistoryTable(AuthDbConstants.MigrationsHistoryTableName)); EntityFramework.Exceptions.SqlServer.ExceptionProcessorExtensions.UseExceptionProcessor(authOptions); AuthPContext = new AuthPermissionsDbContext(authOptions.Options); var retailOptions = new DbContextOptionsBuilder() - .UseSqlServer(ConnectionString, dbOptions => + .UseSqlServer(caller.GetUniqueDatabaseConnectionString("retail"), dbOptions => dbOptions.MigrationsHistoryTable(StartupExtensions.RetailDbContextHistoryName)).Options; RetailDbContext = new RetailDbContext(retailOptions, null); - AuthPContext.Database.EnsureClean(false); - - AuthPContext.Database.Migrate(); - RetailDbContext.Database.Migrate(); + AuthPContext.Database.EnsureClean(); + RetailDbContext.Database.EnsureClean(); } public void Dispose() diff --git a/Test/TestHelpers/ShardingSingleLevelTenantChangeSqlServerSetup.cs b/Test/TestHelpers/ShardingSingleLevelTenantChangeSqlServerSetup.cs new file mode 100644 index 00000000..d5bd68c5 --- /dev/null +++ b/Test/TestHelpers/ShardingSingleLevelTenantChangeSqlServerSetup.cs @@ -0,0 +1,67 @@ +// Copyright (c) 2021 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using System; +using AuthPermissions.AdminCode; +using AuthPermissions.AspNetCore.GetDataKeyCode; +using AuthPermissions.DataLayer.Classes.SupportTypes; +using AuthPermissions.DataLayer.EfCode; +using Example6.SingleLevelSharding.AppStart; +using Example6.SingleLevelSharding.EfCoreCode; +using Microsoft.EntityFrameworkCore; +using TestSupport.EfHelpers; +using TestSupport.Helpers; + +namespace Test.TestHelpers; + +public class ShardingSingleLevelTenantChangeSqlServerSetup : IDisposable +{ + public AuthPermissionsDbContext AuthPContext { get; } + + public ShardingSingleDbContext MainContext { get; } + public ShardingSingleDbContext OtherContext { get; } + + public ShardingSingleLevelTenantChangeSqlServerSetup(object caller) + { + var authOptions = new DbContextOptionsBuilder() + .UseSqlServer(caller.GetUniqueDatabaseConnectionString("authp"), dbOptions => + dbOptions.MigrationsHistoryTable(AuthDbConstants.MigrationsHistoryTableName)); + EntityFramework.Exceptions.SqlServer.ExceptionProcessorExtensions.UseExceptionProcessor(authOptions); + AuthPContext = new AuthPermissionsDbContext(authOptions.Options); + + var shardingOptions = new DbContextOptionsBuilder() + .UseSqlServer("bad connection string", dbOptions => + dbOptions.MigrationsHistoryTable(StartupExtensions.ShardingSingleDbContextHistoryName)).Options; + MainContext = new ShardingSingleDbContext(shardingOptions, new StubGetShardingData("DefaultConnection", caller)); + OtherContext = new ShardingSingleDbContext(shardingOptions, new StubGetShardingData("OtherConnection", caller)); + + AuthPContext.Database.EnsureClean(); + MainContext.Database.EnsureClean(); + OtherContext.Database.EnsureClean(); + } + + private class StubGetShardingData : IGetShardingDataFromUser + { + public StubGetShardingData(string connectionName, object caller) + { + ConnectionString = new StubConnectionsService(caller).GetNamedConnectionString(connectionName) + ?? throw new NotImplementedException("Don't know that connection name"); + DataKey = connectionName == "OtherConnection" + ? MultiTenantExtensions.DataKeyNoQueryFilter + : ".1"; + } + + public string DataKey { get; } + public string ConnectionString { get; } + } + + + + + public void Dispose() + { + AuthPContext?.Dispose(); + MainContext?.Dispose(); + OtherContext?.Dispose(); + } +} \ No newline at end of file diff --git a/Test/TestHelpers/SingleLevelTenantChangeSqlServerSetup.cs b/Test/TestHelpers/SingleLevelTenantChangeSqlServerSetup.cs new file mode 100644 index 00000000..9489ebe6 --- /dev/null +++ b/Test/TestHelpers/SingleLevelTenantChangeSqlServerSetup.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2021 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using System; +using AuthPermissions.DataLayer.Classes.SupportTypes; +using AuthPermissions.DataLayer.EfCode; +using Example3.InvoiceCode.AppStart; +using Example3.InvoiceCode.EfCoreCode; +using Microsoft.EntityFrameworkCore; +using TestSupport.EfHelpers; +using TestSupport.Helpers; + +namespace Test.TestHelpers +{ + public class SingleLevelTenantChangeSqlServerSetup : IDisposable + { + public AuthPermissionsDbContext AuthPContext { get; } + + public InvoicesDbContext InvoiceDbContext { get; } + + + public SingleLevelTenantChangeSqlServerSetup(object caller) + { + var authOptions = new DbContextOptionsBuilder() + .UseSqlServer(caller.GetUniqueDatabaseConnectionString("authp"), dbOptions => + dbOptions.MigrationsHistoryTable(AuthDbConstants.MigrationsHistoryTableName)); + EntityFramework.Exceptions.SqlServer.ExceptionProcessorExtensions.UseExceptionProcessor(authOptions); + AuthPContext = new AuthPermissionsDbContext(authOptions.Options); + + var retailOptions = new DbContextOptionsBuilder() + .UseSqlServer(caller.GetUniqueDatabaseConnectionString("invoice"), dbOptions => + dbOptions.MigrationsHistoryTable(StartupExtensions.InvoicesDbContextHistoryName)).Options; + InvoiceDbContext = new InvoicesDbContext(retailOptions, null); + + AuthPContext.Database.EnsureClean(); + InvoiceDbContext.Database.EnsureClean(); + } + + public void Dispose() + { + AuthPContext?.Dispose(); + InvoiceDbContext?.Dispose(); + } + } +} \ No newline at end of file diff --git a/Test/TestHelpers/StubChangeChangeServiceFactory.cs b/Test/TestHelpers/StubChangeChangeServiceFactory.cs new file mode 100644 index 00000000..ea7a9b6d --- /dev/null +++ b/Test/TestHelpers/StubChangeChangeServiceFactory.cs @@ -0,0 +1,39 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using System.Collections.Generic; +using AuthPermissions.AdminCode; +using AuthPermissions.AspNetCore.Services; +using AuthPermissions.SetupCode.Factories; +using Example3.InvoiceCode.EfCoreCode; +using Example6.SingleLevelSharding.EfCoreCode; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using TestSupport.EfHelpers; +using TestSupport.Helpers; + +namespace Test.TestHelpers; + +public class StubChangeChangeServiceFactory : IAuthPServiceFactory +{ + private readonly ShardingSingleDbContext _context; + private readonly object _caller; + + public StubChangeChangeServiceFactory(ShardingSingleDbContext context, object caller) + { + _context = context; + _caller = caller; + } + + public List Logs { get; set; } = new List(); + + public ITenantChangeService GetService(bool throwExceptionIfNull = true, string callingMethod = "") + { + var builder = new DbContextOptionsBuilder(); + builder.UseSqlServer(null); + var logger = new LoggerFactory( + new[] { new MyLoggerProviderActionOut(log => Logs.Add(log)) }) + .CreateLogger(); + return new ShardingTenantChangeService(builder.Options, new StubConnectionsService(_caller), logger); + } +} \ No newline at end of file diff --git a/Test/TestHelpers/StubConnectionsService.cs b/Test/TestHelpers/StubConnectionsService.cs new file mode 100644 index 00000000..c0ce7b1a --- /dev/null +++ b/Test/TestHelpers/StubConnectionsService.cs @@ -0,0 +1,34 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using System.Collections.Generic; +using AuthPermissions.AspNetCore.Services; +using TestSupport.Helpers; + +namespace Test.TestHelpers; + +public class StubConnectionsService : IShardingConnections +{ + private readonly object _caller; + + public StubConnectionsService(object caller) + { + _caller = caller; + } + + + public IEnumerable GetAllConnectionStringNames() + { + return new[] { "DefaultConnection", "OtherConnection" }; + } + + public string GetNamedConnectionString(string connectionName) + { + return connectionName switch + { + "DefaultConnection" => _caller.GetUniqueDatabaseConnectionString("main"), + "OtherConnection" => _caller.GetUniqueDatabaseConnectionString("other"), + _ => null + }; + } +} \ No newline at end of file diff --git a/Test/TestHelpers/StubITenantChangeService.cs b/Test/TestHelpers/StubITenantChangeService.cs index d9a0dd13..99b2fdfa 100644 --- a/Test/TestHelpers/StubITenantChangeService.cs +++ b/Test/TestHelpers/StubITenantChangeService.cs @@ -7,36 +7,36 @@ using AuthPermissions.AdminCode; using AuthPermissions.DataLayer.Classes; using AuthPermissions.SetupCode.Factories; -using Microsoft.EntityFrameworkCore; namespace Test.TestHelpers { public class StubITenantChangeServiceFactory : IAuthPServiceFactory { - private readonly DbContext _appContext; private readonly string _errorMessage; public string NewTenantName { get; set; } - public List<(string oldDataKey, string newDataKey, int tenantId, string newFullTenantName)> MoveReturnedTuples = - new List<(string oldDataKey, string newDataKey, int tenantId, string newFullTenantName)>(); + public List<(string oldDataKey, string newDataKey, int tenantId, string newFullTenantName)> MoveReturnedTuples = new (); - - public StubITenantChangeServiceFactory(DbContext appContext, string errorMessage = null) + public StubITenantChangeServiceFactory(string errorMessage = null) { - _appContext = appContext; _errorMessage = errorMessage; } + + public ITenantChangeService GetService(bool throwExceptionIfNull = true) + { + return new StubITenantChangeService(this, _errorMessage); + } + public class StubITenantChangeService : ITenantChangeService { private readonly StubITenantChangeServiceFactory _factory; private readonly string _errorMessage; - public List<(string dataKey, string fullTenantName)> DeleteReturnedTuples { get; } = - new List<(string fullTenantName, string dataKey)>(); + public List<(string dataKey, string fullTenantName)> DeleteReturnedTuples { get; } = new (); public StubITenantChangeService(StubITenantChangeServiceFactory factory, string errorMessage) { @@ -44,13 +44,18 @@ public StubITenantChangeService(StubITenantChangeServiceFactory factory, string _errorMessage = errorMessage; } - public Task CreateNewTenantAsync(string dataKey, int tenantId, string fullTenantName) + public Task CreateNewTenantAsync(Tenant tenant) { - _factory.NewTenantName = fullTenantName; + _factory.NewTenantName = tenant.TenantFullName; return Task.FromResult(_errorMessage); } + public Task SingleTenantUpdateNameAsync(Tenant tenant) + { + return Task.FromResult(_errorMessage); + } + public Task HandleTenantDeleteAsync(string dataKey, int tenantId, string fullTenantName) { @@ -59,15 +64,15 @@ public Task HandleTenantDeleteAsync(string dataKey, int tenantId, return Task.FromResult(_errorMessage); } - public Task HandleUpdateNameAsync(string dataKey, int tenantId, string fullTenantName) + public Task SingleTenantDeleteAsync(Tenant tenant) { + DeleteReturnedTuples.Add((tenant.GetTenantDataKey(), tenant.TenantFullName)); + return Task.FromResult(_errorMessage); } - public Task SingleTenantDeleteAsync(string dataKey, int tenantId, string fullTenantName) + public Task HierarchicalTenantUpdateNameAsync(List tenantsToUpdate) { - DeleteReturnedTuples.Add((dataKey, fullTenantName)); - return Task.FromResult(_errorMessage); } @@ -78,12 +83,20 @@ public Task HierarchicalTenantDeleteAsync(List tenantsInOrder) return Task.FromResult(_errorMessage); } - public Task MoveHierarchicalTenantDataAsync(List<(string oldDataKey, string newDataKey, int tenantId, string newFullTenantName)> tenantToUpdate) + public Task MoveHierarchicalTenantDataAsync(List<(string oldDataKey, Tenant tenantToMove)> tenantToUpdate) { - _factory.MoveReturnedTuples = tenantToUpdate; + _factory.MoveReturnedTuples = tenantToUpdate.Select(x => + (x.oldDataKey, x.tenantToMove.GetTenantDataKey(), x.tenantToMove.TenantId, x.tenantToMove.TenantFullName) + ).ToList(); return Task.FromResult(_errorMessage); } + public Task MoveToDifferentDatabaseAsync(string oldConnectionName, + string oldDataKey, + Tenant updatedTenant) + { + return Task.FromResult(_errorMessage); + } } public ITenantChangeService GetService(bool throwExceptionIfNull = true, string callingMethod = "") diff --git a/Test/TestHelpers/StubInvoiceChangeServiceFactory.cs b/Test/TestHelpers/StubInvoiceChangeServiceFactory.cs new file mode 100644 index 00000000..e2a26f68 --- /dev/null +++ b/Test/TestHelpers/StubInvoiceChangeServiceFactory.cs @@ -0,0 +1,31 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using System.Collections.Generic; +using AuthPermissions.AdminCode; +using AuthPermissions.SetupCode.Factories; +using Example3.InvoiceCode.EfCoreCode; +using Microsoft.Extensions.Logging; +using TestSupport.EfHelpers; + +namespace Test.TestHelpers; + +public class StubInvoiceChangeServiceFactory : IAuthPServiceFactory +{ + private readonly InvoicesDbContext _context; + + public StubInvoiceChangeServiceFactory(InvoicesDbContext context) + { + _context = context; + } + + public List Logs { get; set; } = new List(); + + public ITenantChangeService GetService(bool throwExceptionIfNull = true, string callingMethod = "") + { + var logger = new LoggerFactory( + new[] { new MyLoggerProviderActionOut(log => Logs.Add(log)) }) + .CreateLogger(); + return new InvoiceTenantChangeService(_context, logger); + } +} \ No newline at end of file diff --git a/Test/TestHelpers/StubRetailChangeServiceFactory.cs b/Test/TestHelpers/StubRetailChangeServiceFactory.cs new file mode 100644 index 00000000..48799a39 --- /dev/null +++ b/Test/TestHelpers/StubRetailChangeServiceFactory.cs @@ -0,0 +1,31 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using System.Collections.Generic; +using AuthPermissions.AdminCode; +using AuthPermissions.SetupCode.Factories; +using Example4.ShopCode.EfCoreCode; +using Microsoft.Extensions.Logging; +using TestSupport.EfHelpers; + +namespace Test.TestHelpers; + +public class StubRetailChangeServiceFactory : IAuthPServiceFactory +{ + private readonly RetailDbContext _context; + + public StubRetailChangeServiceFactory(RetailDbContext context) + { + _context = context; + } + + public List Logs { get; set; } = new List(); + + public ITenantChangeService GetService(bool throwExceptionIfNull = true, string callingMethod = "") + { + var logger = new LoggerFactory( + new[] { new MyLoggerProviderActionOut(log => Logs.Add(log)) }) + .CreateLogger(); + return new RetailTenantChangeService(_context, logger); + } +} \ No newline at end of file diff --git a/Test/UnitTests/TestAuthPermissions/TestClaimsCalculatorNoTenant.cs b/Test/UnitTests/TestAuthPermissions/TestClaimsCalculatorNoTenant.cs index 849dae90..6cbe6f43 100644 --- a/Test/UnitTests/TestAuthPermissions/TestClaimsCalculatorNoTenant.cs +++ b/Test/UnitTests/TestAuthPermissions/TestClaimsCalculatorNoTenant.cs @@ -120,5 +120,32 @@ public async Task TestCalcAllowedPermissionsNoUser() claims.Count.ShouldEqual(0); } + [Fact] + public async Task TestUserIsDisabled() + { + //SETUP + var options = SqliteInMemory.CreateOptions(); + using var context = new AuthPermissionsDbContext(options); + context.Database.EnsureCreated(); + + var rolePer1 = new RoleToPermissions("Role1", null, $"{(char)1}{(char)3}"); + var rolePer2 = new RoleToPermissions("Role2", null, $"{(char)2}{(char)3}"); + context.AddRange(rolePer1, rolePer2); + var user = AuthUser.CreateAuthUser("User1", "User1@g.com", null, new List() { rolePer1 }).Result; + user.SetIsDisabled(true); + context.Add(user); + context.SaveChanges(); + + context.ChangeTracker.Clear(); + + var service = new ClaimsCalculator(context, new AuthPermissionsOptions { TenantType = TenantTypes.NotUsingTenants }, new List()); + + //ATTEMPT + var claims = await service.GetClaimsForAuthUserAsync("User1"); + + //VERIFY + claims.Count.ShouldEqual(0); + } + } } \ No newline at end of file diff --git a/Test/UnitTests/TestAuthPermissions/TestClaimsCalculatorWithTenant.cs b/Test/UnitTests/TestAuthPermissions/TestClaimsCalculatorWithTenant.cs index e5a306aa..0b0d9f8a 100644 --- a/Test/UnitTests/TestAuthPermissions/TestClaimsCalculatorWithTenant.cs +++ b/Test/UnitTests/TestAuthPermissions/TestClaimsCalculatorWithTenant.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using AuthPermissions; +using AuthPermissions.AdminCode; using AuthPermissions.CommonCode; using AuthPermissions.DataLayer.Classes; using AuthPermissions.DataLayer.Classes.SupportTypes; @@ -138,5 +139,102 @@ public async Task TestCalcDataKeySimple() claims.Last().Value.ShouldEqual(tenant.GetTenantDataKey()); } + [Fact] + public async Task TestCalcDataKeySharding() + { + //SETUP + var options = SqliteInMemory.CreateOptions(); + using var context = new AuthPermissionsDbContext(options); + context.Database.EnsureCreated(); + + var tenant = Tenant.CreateSingleTenant("Tenant1").Result + ?? throw new AuthPermissionsException("CreateSingleTenant had errors."); + tenant.UpdateShardingState("MyConnectionName", false); + var role = new RoleToPermissions("Role1", null, $"{((char)1)}"); + var user = AuthUser.CreateAuthUser("User1", "User1@g.com", null, new List() { role }, tenant).Result; + + context.AddRange(tenant, role, user); + context.SaveChanges(); + + context.ChangeTracker.Clear(); + + var service = new ClaimsCalculator(context, + new AuthPermissionsOptions { TenantType = TenantTypes.SingleLevel | TenantTypes.AddSharding }, + new List()); + + //ATTEMPT + var claims = await service.GetClaimsForAuthUserAsync("User1"); + + //VERIFY + claims.Count.ShouldEqual(3); + claims[0].Type.ShouldEqual(PermissionConstants.PackedPermissionClaimType); + claims[1].Type.ShouldEqual(PermissionConstants.DataKeyClaimType); + claims[1].Value.ShouldEqual(tenant.GetTenantDataKey()); + claims[2].Type.ShouldEqual(PermissionConstants.ConnectionNameType); + claims[2].Value.ShouldEqual(tenant.ConnectionName); + } + + [Fact] + public async Task TestCalcDataKeyShardingNoQueryFilter() + { + //SETUP + var options = SqliteInMemory.CreateOptions(); + using var context = new AuthPermissionsDbContext(options); + context.Database.EnsureCreated(); + + var tenant = Tenant.CreateSingleTenant("Tenant1").Result + ?? throw new AuthPermissionsException("CreateSingleTenant had errors."); + tenant.UpdateShardingState("MyConnectionName", true); + var role = new RoleToPermissions("Role1", null, $"{((char)1)}"); + var user = AuthUser.CreateAuthUser("User1", "User1@g.com", null, new List() { role }, tenant).Result; + + context.AddRange(tenant, role, user); + context.SaveChanges(); + + context.ChangeTracker.Clear(); + + var service = new ClaimsCalculator(context, + new AuthPermissionsOptions { TenantType = TenantTypes.SingleLevel | TenantTypes.AddSharding }, + new List()); + + //ATTEMPT + var claims = await service.GetClaimsForAuthUserAsync("User1"); + + //VERIFY + claims.Count.ShouldEqual(3); + claims[0].Type.ShouldEqual(PermissionConstants.PackedPermissionClaimType); + claims[1].Type.ShouldEqual(PermissionConstants.DataKeyClaimType); + claims[1].Value.ShouldEqual(MultiTenantExtensions.DataKeyNoQueryFilter); + claims[2].Type.ShouldEqual(PermissionConstants.ConnectionNameType); + claims[2].Value.ShouldEqual(tenant.ConnectionName); + } + + [Fact] + public async Task TestUserIsDisabled() + { + //SETUP + var options = SqliteInMemory.CreateOptions(); + using var context = new AuthPermissionsDbContext(options); + context.Database.EnsureCreated(); + + var tenant = Tenant.CreateSingleTenant("Tenant1").Result + ?? throw new AuthPermissionsException("CreateSingleTenant had errors."); + var role = new RoleToPermissions("Role1", null, $"{((char)1)}"); + var user = AuthUser.CreateAuthUser("User1", "User1@g.com", null, new List() { role }, tenant).Result; + user.SetIsDisabled(true); + + context.AddRange(tenant, role, user); + context.SaveChanges(); + + context.ChangeTracker.Clear(); + + var service = new ClaimsCalculator(context, new AuthPermissionsOptions { TenantType = TenantTypes.SingleLevel }, new List()); + + //ATTEMPT + var claims = await service.GetClaimsForAuthUserAsync("User1"); + + //VERIFY + claims.Count.ShouldEqual(0); + } } } \ No newline at end of file diff --git a/Test/UnitTests/TestAuthPermissions/TestLinkToTenantDataService.cs b/Test/UnitTests/TestAuthPermissions/TestLinkToTenantDataService.cs index 7a154cd6..2cb0ffa6 100644 --- a/Test/UnitTests/TestAuthPermissions/TestLinkToTenantDataService.cs +++ b/Test/UnitTests/TestAuthPermissions/TestLinkToTenantDataService.cs @@ -1,18 +1,15 @@ // Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ // Licensed under MIT license. See License.txt in the project root for license information. -using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using AuthPermissions; -using AuthPermissions.AspNetCore.AccessTenantData; using AuthPermissions.AspNetCore.AccessTenantData.Services; using AuthPermissions.CommonCode; using AuthPermissions.DataLayer.Classes; using AuthPermissions.DataLayer.EfCode; using AuthPermissions.SetupCode; -using Microsoft.Extensions.Options; using Test.TestHelpers; using TestSupport.EfHelpers; using Xunit; @@ -56,15 +53,50 @@ public async Task TestStartLinkingToTenantDataAsyncAppUserSingleTenant() var service = new LinkToTenantDataService(context, authOptions, cookieStub, encyptor); //ATTEMPT - await service.StartLinkingToTenantDataAsync(authUser.UserId, tenantIds[1]); + var status = await service.StartLinkingToTenantDataAsync(authUser.UserId, tenantIds[1]); //VERIFY + status.IsValid.ShouldBeTrue(status.GetAllErrors()); _output.WriteLine($"encrypted string = {cookieStub.CookieValue}"); service.GetDataKeyOfLinkedTenant().ShouldEqual("2."); service.GetNameOfLinkedTenant().ShouldEqual("Tenant2"); } + [Fact] + public async Task TestStartLinkingToTenantDataAsyncAppUserSharding() + { + //SETUP + var options = SqliteInMemory.CreateOptions(); + using var context = new AuthPermissionsDbContext(options); + context.Database.EnsureCreated(); + + var tenant = Tenant.CreateSingleTenant("Tenant1").Result; + tenant.UpdateShardingState("MyConnectionName", true); + var authUser = AuthUser.CreateAuthUser("user1", "user1@g.com", null, new List()).Result; + context.AddRange(authUser, tenant); + context.SaveChanges(); + context.ChangeTracker.Clear(); + + var authOptions = new AuthPermissionsOptions + { + TenantType = TenantTypes.SingleLevel | TenantTypes.AddSharding, + LinkToTenantType = LinkToTenantTypes.OnlyAppUsers, + EncryptionKey = "asfafffggdgerxbd" + }; + var cookieStub = new StubIAccessTenantDataCookie(); + var encyptor = new EncryptDecryptService(authOptions); + var service = new LinkToTenantDataService(context, authOptions, cookieStub, encyptor); + + //ATTEMPT + var status = await service.StartLinkingToTenantDataAsync(authUser.UserId, tenant.TenantId); + + //VERIFY + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + _output.WriteLine($"encrypted string = {cookieStub.CookieValue}"); + service.GetShardingDataOfLinkedTenant().ShouldEqual(("NoQueryFilter", "MyConnectionName")); + service.GetNameOfLinkedTenant().ShouldEqual("Tenant1"); + } [Theory] [InlineData(LinkToTenantTypes.NotTurnedOn, true)] @@ -181,5 +213,5 @@ public void TestGetDataKeyOfLinkedTenantNoCookie() service.GetDataKeyOfLinkedTenant().ShouldBeNull(); service.GetNameOfLinkedTenant().ShouldBeNull(); } - + } \ No newline at end of file diff --git a/Test/UnitTests/TestAuthPermissions/TestSetupPartsSetupTenantService_TenantRoles.cs b/Test/UnitTests/TestAuthPermissions/TestSetupPartsSetupTenantService_TenantRoles.cs index c39080b0..3b42c774 100644 --- a/Test/UnitTests/TestAuthPermissions/TestSetupPartsSetupTenantService_TenantRoles.cs +++ b/Test/UnitTests/TestAuthPermissions/TestSetupPartsSetupTenantService_TenantRoles.cs @@ -11,7 +11,6 @@ using AuthPermissions.DataLayer.EfCode; using AuthPermissions.SetupCode; using Microsoft.EntityFrameworkCore; -using Test.TestHelpers; using TestSupport.EfHelpers; using Xunit; using Xunit.Abstractions; diff --git a/Test/UnitTests/TestAuthPermissionsAdmin/TestTenantAdminServicesHierarchical.cs b/Test/UnitTests/TestAuthPermissionsAdmin/TestTenantAdminServicesHierarchical.cs index 86495262..c936cfa2 100644 --- a/Test/UnitTests/TestAuthPermissionsAdmin/TestTenantAdminServicesHierarchical.cs +++ b/Test/UnitTests/TestAuthPermissionsAdmin/TestTenantAdminServicesHierarchical.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using AuthPermissions; +using AuthPermissions.AdminCode; using AuthPermissions.AdminCode.Services; using AuthPermissions.DataLayer.Classes; using AuthPermissions.DataLayer.Classes.SupportTypes; @@ -38,7 +39,7 @@ public async Task TestSetupHierarchicalTenantInDbAsync() context.Database.EnsureCreated(); //ATTEMPT - await context.SetupHierarchicalTenantInDbAsync(); + await context.BulkLoadHierarchicalTenantInDbAsync(); //VERIFY context.ChangeTracker.Clear(); @@ -57,7 +58,7 @@ public async Task TestQueryEndLeafTenantsHierarchical() using var context = new AuthPermissionsDbContext(options); context.Database.EnsureCreated(); - await context.SetupHierarchicalTenantInDbAsync(); + await context.BulkLoadHierarchicalTenantInDbAsync(); context.ChangeTracker.Clear(); var service = new AuthTenantAdminService(context, new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant }, null, null); @@ -79,7 +80,7 @@ public async Task TestGetHierarchicalTenantChildrenViaIdAsync() using var context = new AuthPermissionsDbContext(options); context.Database.EnsureCreated(); - var tenantIds = await context.SetupHierarchicalTenantInDbAsync(); + var tenantIds = await context.BulkLoadHierarchicalTenantInDbAsync(); context.ChangeTracker.Clear(); var service = new AuthTenantAdminService(context, new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant }, null, null); @@ -108,10 +109,10 @@ public async Task TestAddHierarchicalTenantAsyncOk() appOptions.TurnOffDispose(); var retailContext = new RetailDbContext(appOptions, null); - var tenantIds = await context.SetupHierarchicalTenantInDbAsync(); + var tenantIds = await context.BulkLoadHierarchicalTenantInDbAsync(); context.ChangeTracker.Clear(); - var subTenantChangeService = new StubITenantChangeServiceFactory(retailContext); + var subTenantChangeService = new StubITenantChangeServiceFactory(); var service = new AuthTenantAdminService(context, new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant }, subTenantChangeService, null); @@ -153,10 +154,10 @@ public async Task TestAddHierarchicalTenantAsyncWithRolesOk() context.AddRange(role1, role2); context.SaveChanges(); - var tenantIds = await context.SetupHierarchicalTenantInDbAsync(); + var tenantIds = await context.BulkLoadHierarchicalTenantInDbAsync(); context.ChangeTracker.Clear(); - var subTenantChangeService = new StubITenantChangeServiceFactory(retailContext); + var subTenantChangeService = new StubITenantChangeServiceFactory(); var service = new AuthTenantAdminService(context, new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant }, subTenantChangeService, null); @@ -196,10 +197,10 @@ public async Task TestAddHierarchicalTenantAsyncWithRolesBad(string roleName, st context.Add(new RoleToPermissions("NormalRole", null, $"{(char)1}{(char)3}")); context.SaveChanges(); - var tenantIds = await context.SetupHierarchicalTenantInDbAsync(); + var tenantIds = await context.BulkLoadHierarchicalTenantInDbAsync(); context.ChangeTracker.Clear(); - var subTenantChangeService = new StubITenantChangeServiceFactory(retailContext); + var subTenantChangeService = new StubITenantChangeServiceFactory(); var service = new AuthTenantAdminService(context, new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant }, subTenantChangeService, null); @@ -229,10 +230,10 @@ public async Task TestAddHierarchicalTenantAsyncTopLevelOk() appOptions.TurnOffDispose(); var retailContext = new RetailDbContext(appOptions, null); - var tenantIds = await context.SetupHierarchicalTenantInDbAsync(); + var tenantIds = await context.BulkLoadHierarchicalTenantInDbAsync(); context.ChangeTracker.Clear(); - var subTenantChangeService = new StubITenantChangeServiceFactory(retailContext); + var subTenantChangeService = new StubITenantChangeServiceFactory(); var service = new AuthTenantAdminService(context, new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant }, subTenantChangeService, null); @@ -258,11 +259,11 @@ public async Task TestAddHierarchicalTenantAsyncTopLevelOk() public async Task TestAddHierarchicalTenantAsyncDuplicate() { //SETUP - using var contexts = new TenantChangeSqlServerSetup(this); - var tenantIds = await contexts.AuthPContext.SetupHierarchicalTenantInDbAsync(); + using var contexts = new HierarchicalTenantChangeSqlServerSetup(this); + var tenantIds = await contexts.AuthPContext.BulkLoadHierarchicalTenantInDbAsync(); contexts.RetailDbContext.SetupHierarchicalRetailAndStock(contexts.AuthPContext); - var subTenantChangeService = new StubITenantChangeServiceFactory(contexts.RetailDbContext); + var subTenantChangeService = new StubITenantChangeServiceFactory(); var service = new AuthTenantAdminService(contexts.AuthPContext, new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant, @@ -291,10 +292,10 @@ public async Task TestUpdateHierarchicalTenantAsyncOk() appOptions.TurnOffDispose(); var retailContext = new RetailDbContext(appOptions, null); - var tenantIds = await context.SetupHierarchicalTenantInDbAsync(); + var tenantIds = await context.BulkLoadHierarchicalTenantInDbAsync(); context.ChangeTracker.Clear(); - var subTenantChangeService = new StubITenantChangeServiceFactory(retailContext); + var subTenantChangeService = new StubITenantChangeServiceFactory(); var service = new AuthTenantAdminService(context, new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant }, subTenantChangeService, null); @@ -317,36 +318,6 @@ public async Task TestUpdateHierarchicalTenantAsyncOk() } } - [Fact] - public async Task TestUpdateHierarchicalTenantSqlServerOk() - { - //SETUP - using var contexts = new TenantChangeSqlServerSetup(this); - var tenantIds = await contexts.AuthPContext.SetupHierarchicalTenantInDbAsync(); - contexts.RetailDbContext.SetupHierarchicalRetailAndStock(contexts.AuthPContext); - contexts.AuthPContext.ChangeTracker.Clear(); - - var service = new AuthTenantAdminService(contexts.AuthPContext, new AuthPermissionsOptions - { - TenantType = TenantTypes.HierarchicalTenant - }, new StubRetailTenantChangeServiceFactory(contexts.RetailDbContext), null); - - //ATTEMPT - var status = await service.UpdateTenantNameAsync(tenantIds[1], "West Area"); - - //VERIFY - status.IsValid.ShouldBeTrue(status.GetAllErrors()); - contexts.AuthPContext.ChangeTracker.Clear(); - var tenants = contexts.AuthPContext.Tenants.ToList(); - foreach (var tenant in tenants.OrderBy(x => x.GetTenantDataKey())) - { - _output.WriteLine(tenant.ToString()); - } - tenants.Count(x => x.TenantFullName.StartsWith("Company | West Area")).ShouldEqual(4); - contexts.RetailDbContext.RetailOutlets.IgnoreQueryFilters() - .Count(x => x.FullName.StartsWith("Company | West Area")).ShouldEqual(2); - } - [Fact] public async Task TestMoveHierarchicalTenantToAnotherParentAsyncBaseOk() { @@ -362,10 +333,10 @@ public async Task TestMoveHierarchicalTenantToAnotherParentAsyncBaseOk() appOptions.TurnOffDispose(); var retailContext = new RetailDbContext(appOptions, null); - await context.SetupHierarchicalTenantInDbAsync(); + await context.BulkLoadHierarchicalTenantInDbAsync(); context.ChangeTracker.Clear(); - var subTenantChangeService = new StubITenantChangeServiceFactory(retailContext); + var subTenantChangeService = new StubITenantChangeServiceFactory(); var service = new AuthTenantAdminService(context, new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant }, subTenantChangeService, null); @@ -409,10 +380,10 @@ public async Task TestMoveHierarchicalTenantToAnotherParentAsyncOk() appOptions.TurnOffDispose(); var retailContext = new RetailDbContext(appOptions, null); - await context.SetupHierarchicalTenantInDbAsync(); + await context.BulkLoadHierarchicalTenantInDbAsync(); context.ChangeTracker.Clear(); - var subTenantChangeService = new StubITenantChangeServiceFactory(retailContext); + var subTenantChangeService = new StubITenantChangeServiceFactory(); var service = new AuthTenantAdminService(context, new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant }, subTenantChangeService, null); @@ -444,48 +415,6 @@ public async Task TestMoveHierarchicalTenantToAnotherParentAsyncOk() } } - [Fact] - public async Task TestMoveHierarchicalTenantToAnotherParentAsyncSqlServerOk() - { - //SETUP - using var contexts = new TenantChangeSqlServerSetup(this); - await contexts.AuthPContext.SetupHierarchicalTenantInDbAsync(); - contexts.RetailDbContext.SetupHierarchicalRetailAndStock(contexts.AuthPContext); - var preStocks = contexts.RetailDbContext.ShopStocks.IgnoreQueryFilters() - .Include(x => x.Shop).ToList(); - foreach (var tenant in preStocks.OrderBy(x => x.DataKey)) - { - _output.WriteLine($"{tenant.Shop.ShortName}: DataKey = {tenant.DataKey}"); - } - contexts.AuthPContext.ChangeTracker.Clear(); - - var service = new AuthTenantAdminService(contexts.AuthPContext, new AuthPermissionsOptions - { - TenantType = TenantTypes.HierarchicalTenant - }, new StubRetailTenantChangeServiceFactory(contexts.RetailDbContext), null); - - //ATTEMPT - var status = await service.MoveHierarchicalTenantToAnotherParentAsync(2, 3); - - //VERIFY - status.IsValid.ShouldBeTrue(status.GetAllErrors()); - contexts.AuthPContext.ChangeTracker.Clear(); - contexts.RetailDbContext.ChangeTracker.Clear(); - - var authTenants = contexts.AuthPContext.Tenants.ToList(); - foreach (var tenant in authTenants.OrderBy(x => x.GetTenantDataKey())) - { - _output.WriteLine(tenant.ToString()); - } - var shopStocks = contexts.RetailDbContext.ShopStocks.IgnoreQueryFilters() - .Include(x => x.Shop).ToList(); - foreach (var tenant in shopStocks.OrderBy(x => x.DataKey)) - { - _output.WriteLine($"{tenant.Shop.ShortName}: DataKey = {tenant.DataKey}"); - } - authTenants.Count(x => x.TenantFullName.StartsWith("Company | East Coast | West Coast")).ShouldEqual(4); - } - [Fact] public async Task TestMoveHierarchicalTenantToAnotherParentAsyncMoveToTop() { @@ -501,10 +430,10 @@ public async Task TestMoveHierarchicalTenantToAnotherParentAsyncMoveToTop() appOptions.TurnOffDispose(); var retailContext = new RetailDbContext(appOptions, null); - await context.SetupHierarchicalTenantInDbAsync(); + await context.BulkLoadHierarchicalTenantInDbAsync(); context.ChangeTracker.Clear(); - var subTenantChangeService = new StubITenantChangeServiceFactory(retailContext); + var subTenantChangeService = new StubITenantChangeServiceFactory(); var service = new AuthTenantAdminService(context, new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant }, subTenantChangeService, null); @@ -555,10 +484,10 @@ public async Task TestMoveHierarchicalTenantToAnotherParentAsyncMoveToChild(int appOptions.TurnOffDispose(); var retailContext = new RetailDbContext(appOptions, null); - await context.SetupHierarchicalTenantInDbAsync(); + await context.BulkLoadHierarchicalTenantInDbAsync(); context.ChangeTracker.Clear(); - var subTenantChangeService = new StubITenantChangeServiceFactory(retailContext); + var subTenantChangeService = new StubITenantChangeServiceFactory(); var service = new AuthTenantAdminService(context, new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant }, subTenantChangeService, null); @@ -587,10 +516,10 @@ public async Task TestMoveHierarchicalTenantToAnotherParentAsyncMoveToSelf() appOptions.TurnOffDispose(); var retailContext = new RetailDbContext(appOptions, null); - await context.SetupHierarchicalTenantInDbAsync(); + await context.BulkLoadHierarchicalTenantInDbAsync(); context.ChangeTracker.Clear(); - var subTenantChangeService = new StubITenantChangeServiceFactory(retailContext); + var subTenantChangeService = new StubITenantChangeServiceFactory(); var service = new AuthTenantAdminService(context, new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant }, subTenantChangeService, null); @@ -619,11 +548,11 @@ public async Task TestDeleteTenantAsyncBaseOk() appOptions.TurnOffDispose(); var retailContext = new RetailDbContext(appOptions, null); - numTenants = (await context.SetupHierarchicalTenantInDbAsync()).Count; + numTenants = (await context.BulkLoadHierarchicalTenantInDbAsync()).Count; context.ChangeTracker.Clear(); var service = new AuthTenantAdminService(context, new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant }, - new StubITenantChangeServiceFactory(retailContext), null); + new StubITenantChangeServiceFactory(), null); //ATTEMPT var status = await service.DeleteTenantAsync(6); @@ -664,11 +593,11 @@ public async Task TestDeleteTenantAsyncAnotherParentOk() appOptions.TurnOffDispose(); var retailContext = new RetailDbContext(appOptions, null); - var tenantIds = await context.SetupHierarchicalTenantInDbAsync(); + var tenantIds = await context.BulkLoadHierarchicalTenantInDbAsync(); context.ChangeTracker.Clear(); var service = new AuthTenantAdminService(context, new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant}, - new StubITenantChangeServiceFactory(retailContext), null); + new StubITenantChangeServiceFactory(), null); options.StopNextDispose(); //ATTEMPT @@ -711,14 +640,14 @@ public async Task TestDeleteTenantAsyncUserOnActualTenantBad() appOptions.TurnOffDispose(); var retailContext = new RetailDbContext(appOptions, null); - await context.SetupHierarchicalTenantInDbAsync(); + await context.BulkLoadHierarchicalTenantInDbAsync(); var tenantToDelete = context.Find(7); context.Add(AuthUser.CreateAuthUser("123", "me@gmail.com", "Mr Me", new List(), tenantToDelete).Result); context.SaveChanges(); context.ChangeTracker.Clear(); var service = new AuthTenantAdminService(context, new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant }, - new StubITenantChangeServiceFactory(retailContext), null); + new StubITenantChangeServiceFactory(), null); options.StopNextDispose(); //ATTEMPT @@ -743,14 +672,14 @@ public async Task TestDeleteTenantAsyncUserOnChildTenant() appOptions.TurnOffDispose(); var retailContext = new RetailDbContext(appOptions, null); - await context.SetupHierarchicalTenantInDbAsync(); + await context.BulkLoadHierarchicalTenantInDbAsync(); var childTenant = context.Find(7); context.Add(AuthUser.CreateAuthUser("123", "me@gmail.com", "Mr Me", new List(), childTenant).Result); context.SaveChanges(); context.ChangeTracker.Clear(); var service = new AuthTenantAdminService(context, new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant }, - new StubITenantChangeServiceFactory(retailContext), null); + new StubITenantChangeServiceFactory(), null); //ATTEMPT var status = await service.DeleteTenantAsync(2); diff --git a/Test/UnitTests/TestAuthPermissionsAdmin/TestTenantAdminServicesSharding.cs b/Test/UnitTests/TestAuthPermissionsAdmin/TestTenantAdminServicesSharding.cs new file mode 100644 index 00000000..f8d40559 --- /dev/null +++ b/Test/UnitTests/TestAuthPermissionsAdmin/TestTenantAdminServicesSharding.cs @@ -0,0 +1,250 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using System.Linq; +using System.Threading.Tasks; +using AuthPermissions; +using AuthPermissions.AdminCode.Services; +using AuthPermissions.DataLayer.EfCode; +using AuthPermissions.SetupCode; +using Test.TestHelpers; +using TestSupport.EfHelpers; +using Xunit; +using Xunit.Extensions.AssertExtensions; + +namespace Test.UnitTests.TestAuthPermissionsAdmin; + +public class TestTenantAdminServicesSharding +{ + + [Fact] + public async Task TestSetupSingleShardingTenantsInDbOk() + { + //SETUP + var options = SqliteInMemory.CreateOptions(); + using var context = new AuthPermissionsDbContext(options); + context.Database.EnsureCreated(); + + context.ChangeTracker.Clear(); + + //ATTEMPT + await context.SetupSingleShardingTenantsInDb(); + + //VERIFY + context.ChangeTracker.Clear(); + var tenants = context.Tenants.ToList(); + tenants.Select(x => x.TenantFullName).ToArray().ShouldEqual(new[] { "Tenant1", "Tenant2", "Tenant3" }); + tenants.All(x => !x.HasOwnDb).ShouldBeTrue(); + tenants.All(x => x.ConnectionName == "DefaultConnection").ShouldBeTrue(); + } + + [Fact] + public async Task TestAddSingleTenantAsyncOk() + { + //SETUP + var options = SqliteInMemory.CreateOptions(); + using var context = new AuthPermissionsDbContext(options); + context.Database.EnsureCreated(); + + context.SetupSingleShardingTenantsInDb(); + context.ChangeTracker.Clear(); + + var tenantChange = new StubITenantChangeServiceFactory(); + var service = new AuthTenantAdminService(context, + new AuthPermissionsOptions { TenantType = TenantTypes.SingleLevel | TenantTypes.AddSharding }, + tenantChange, null); + + //ATTEMPT + var status = await service.AddSingleTenantAsync("Tenant4", null, true,"MyConnectionName"); + + //VERIFY + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + tenantChange.NewTenantName.ShouldEqual("Tenant4"); + context.ChangeTracker.Clear(); + var tenants = context.Tenants.ToList(); + tenants.Count.ShouldEqual(4); + tenants.Last().ConnectionName.ShouldEqual("MyConnectionName"); + tenants.Last().HasOwnDb.ShouldEqual(true); + } + + [Fact] + public async Task TestAddSingleTenantAsyncBadSettings() + { + //SETUP + var options = SqliteInMemory.CreateOptions(); + using var context = new AuthPermissionsDbContext(options); + context.Database.EnsureCreated(); + + await context.SetupSingleShardingTenantsInDb(); + context.ChangeTracker.Clear(); + + var tenantChange = new StubITenantChangeServiceFactory(); + var service = new AuthTenantAdminService(context, + new AuthPermissionsOptions { TenantType = TenantTypes.SingleLevel | TenantTypes.AddSharding }, + tenantChange, null); + + //ATTEMPT + var status = await service.AddSingleTenantAsync("Tenant4"); + + //VERIFY + status.IsValid.ShouldBeFalse(); + status.GetAllErrors().ShouldEqual("The hasOwnDb parameter must be set to true or false when sharding is turned on."); + } + + [Fact] + public async Task TestAddSingleTenantAsyncHasOwnDbCheck() + { + //SETUP + var options = SqliteInMemory.CreateOptions(); + using var context = new AuthPermissionsDbContext(options); + context.Database.EnsureCreated(); + + await context.SetupSingleShardingTenantsInDb(); + context.ChangeTracker.Clear(); + + var tenantChange = new StubITenantChangeServiceFactory(); + var service = new AuthTenantAdminService(context, + new AuthPermissionsOptions { TenantType = TenantTypes.SingleLevel | TenantTypes.AddSharding }, + tenantChange, null); + + //ATTEMPT + var status = await service.AddSingleTenantAsync("Tenant4", null, true, "DefaultConnection"); + + //VERIFY + status.IsValid.ShouldBeFalse(); + status.GetAllErrors().ShouldEqual( + "The hasOwnDb parameter is true, but there is already a tenant with the same connection name 'DefaultConnection', so hasOwnDb should be false."); + } + + [Fact] + public async Task TestBulkLoadHierarchicalTenantShardingAsync() + { + //SETUP + var options = SqliteInMemory.CreateOptions(); + using var context = new AuthPermissionsDbContext(options); + context.Database.EnsureCreated(); + + context.ChangeTracker.Clear(); + + //ATTEMPT + await context.BulkLoadHierarchicalTenantShardingAsync(); + + //VERIFY + context.ChangeTracker.Clear(); + var tenants = context.Tenants.ToList(); + tenants.Count.ShouldEqual(9); + tenants.All(x => !x.HasOwnDb).ShouldBeTrue(); + tenants.All(x => x.ConnectionName == "DefaultConnection").ShouldBeTrue(); + } + + [Fact] + public async Task TestAddHierarchicalTenantAsyncToExistingParentNoShardingParamsOk() + { + //SETUP + var options = SqliteInMemory.CreateOptions(); + using var context = new AuthPermissionsDbContext(options); + context.Database.EnsureCreated(); + + var tenantIds = await context.BulkLoadHierarchicalTenantShardingAsync(); + context.ChangeTracker.Clear(); + + var tenantChange = new StubITenantChangeServiceFactory(); + var service = new AuthTenantAdminService(context, + new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant | TenantTypes.AddSharding }, + tenantChange, null); + + //ATTEMPT + var status = await service.AddHierarchicalTenantAsync("New Child", tenantIds[1]); + + //VERIFY + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + tenantChange.NewTenantName.ShouldEqual("Company | West Coast | New Child"); + context.ChangeTracker.Clear(); + var tenants = context.Tenants.ToList(); + tenants.Count.ShouldEqual(10); + tenants.All(x => !x.HasOwnDb).ShouldBeTrue(); + tenants.All(x => x.ConnectionName == "DefaultConnection").ShouldBeTrue(); + } + + [Fact] + public async Task TestAddHierarchicalTenantAsyncNoParentGoodParamsOk() + { + //SETUP + var options = SqliteInMemory.CreateOptions(); + using var context = new AuthPermissionsDbContext(options); + context.Database.EnsureCreated(); + + var tenantIds = await context.BulkLoadHierarchicalTenantShardingAsync(); + context.ChangeTracker.Clear(); + + var tenantChange = new StubITenantChangeServiceFactory(); + var service = new AuthTenantAdminService(context, + new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant | TenantTypes.AddSharding }, + tenantChange, null); + + //ATTEMPT + var status = await service.AddHierarchicalTenantAsync("New Company", 0, null, + true, "DiffConnectionName"); + + //VERIFY + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + tenantChange.NewTenantName.ShouldEqual("New Company"); + context.ChangeTracker.Clear(); + var tenants = context.Tenants.ToList(); + tenants.Count.ShouldEqual(10); + tenants.Last().HasOwnDb.ShouldBeTrue(); + } + + [Fact] + public async Task TestAddHierarchicalTenantAsyncToExistingParentBadParams() + { + //SETUP + var options = SqliteInMemory.CreateOptions(); + using var context = new AuthPermissionsDbContext(options); + context.Database.EnsureCreated(); + + var tenantIds = await context.BulkLoadHierarchicalTenantShardingAsync(); + context.ChangeTracker.Clear(); + + var tenantChange = new StubITenantChangeServiceFactory(); + var service = new AuthTenantAdminService(context, + new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant | TenantTypes.AddSharding }, + tenantChange, null); + + //ATTEMPT + var status = await service.AddHierarchicalTenantAsync("New Child", tenantIds[1], null, + true, "DiffConnectionName"); + + //VERIFY + status.IsValid.ShouldBeFalse(); + status.Errors.Count.ShouldEqual(2); + status.Errors[0].ToString().ShouldEqual("The hasOwnDb parameter doesn't match the parent's HasOwnDb. Set the hasOwnDb parameter to null to use the parent's HasOwnDb value."); + status.Errors[1].ToString().ShouldEqual("The connectionName parameter doesn't match the parent's ConnectionName. Set the connectionName parameter to null to use the parent's ConnectionName value."); + } + + [Fact] + public async Task TestAddHierarchicalTenantAsyncNoParentHasOwnDbCheck() + { + //SETUP + var options = SqliteInMemory.CreateOptions(); + using var context = new AuthPermissionsDbContext(options); + context.Database.EnsureCreated(); + + var tenantIds = await context.BulkLoadHierarchicalTenantShardingAsync(); + context.ChangeTracker.Clear(); + + var tenantChange = new StubITenantChangeServiceFactory(); + var service = new AuthTenantAdminService(context, + new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant | TenantTypes.AddSharding }, + tenantChange, null); + + //ATTEMPT + var status = await service.AddHierarchicalTenantAsync("New Company", 0, null, + true, "DefaultConnection"); + + //VERIFY + status.IsValid.ShouldBeFalse(); + status.GetAllErrors().ShouldEqual( + "The hasOwnDb parameter is true, but there is already a tenant with the same connection name 'DefaultConnection', so hasOwnDb should be false."); + } +} \ No newline at end of file diff --git a/Test/UnitTests/TestAuthPermissionsAdmin/TestTenantAdminServicesSingle.cs b/Test/UnitTests/TestAuthPermissionsAdmin/TestTenantAdminServicesSingle.cs index 5bc7e7a0..2fdbef31 100644 --- a/Test/UnitTests/TestAuthPermissionsAdmin/TestTenantAdminServicesSingle.cs +++ b/Test/UnitTests/TestAuthPermissionsAdmin/TestTenantAdminServicesSingle.cs @@ -11,8 +11,6 @@ using AuthPermissions.DataLayer.Classes.SupportTypes; using AuthPermissions.DataLayer.EfCode; using AuthPermissions.SetupCode; -using Example3.InvoiceCode.EfCoreCode; -using Example4.ShopCode.EfCoreCode; using Microsoft.EntityFrameworkCore; using Test.TestHelpers; using TestSupport.EfHelpers; @@ -73,7 +71,6 @@ public void TestQueryEndLeafTenantsSingle() tenants.Select(x => x.TenantFullName).ShouldEqual(new[] { "Tenant1", "Tenant2", "Tenant3" }); } - [Fact] public async Task TestAddSingleTenantAsyncOk() { @@ -84,15 +81,10 @@ public async Task TestAddSingleTenantAsyncOk() { context.Database.EnsureCreated(); - var appOptions = SqliteInMemory.CreateOptions(builder => - builder.UseSqlite(context.Database.GetDbConnection())); - appOptions.TurnOffDispose(); - var retailContext = new InvoicesDbContext(appOptions, null); - context.SetupSingleTenantsInDb(); context.ChangeTracker.Clear(); - var tenantChange = new StubITenantChangeServiceFactory(retailContext); + var tenantChange = new StubITenantChangeServiceFactory(); var service = new AuthTenantAdminService(context, new AuthPermissionsOptions { TenantType = TenantTypes.SingleLevel }, tenantChange, null); @@ -139,7 +131,6 @@ public async Task TestGetRoleNamesForTenantsAsyncOk() } } - [Fact] public async Task TestAddSingleTenantAsyncWithRolesOk() { @@ -150,11 +141,6 @@ public async Task TestAddSingleTenantAsyncWithRolesOk() { context.Database.EnsureCreated(); - var appOptions = SqliteInMemory.CreateOptions(builder => - builder.UseSqlite(context.Database.GetDbConnection())); - appOptions.TurnOffDispose(); - var retailContext = new RetailDbContext(appOptions, null); - var role1 = new RoleToPermissions("TenantRole1", null, $"{(char)1}{(char)3}", RoleTypes.TenantAutoAdd); var role2 = new RoleToPermissions("TenantRole2", null, $"{(char)2}{(char)3}", RoleTypes.TenantAdminAdd); context.AddRange(role1, role2); @@ -162,7 +148,7 @@ public async Task TestAddSingleTenantAsyncWithRolesOk() var tenantIds = context.SetupSingleTenantsInDb(); context.ChangeTracker.Clear(); - var tenantChange = new StubITenantChangeServiceFactory(retailContext); + var tenantChange = new StubITenantChangeServiceFactory(); var service = new AuthTenantAdminService(context, new AuthPermissionsOptions { TenantType = TenantTypes.SingleLevel }, tenantChange, null); @@ -193,17 +179,12 @@ public async Task TestAddSingleTenantAsyncWithRolesBad(string roleName, string e { context.Database.EnsureCreated(); - var appOptions = SqliteInMemory.CreateOptions(builder => - builder.UseSqlite(context.Database.GetDbConnection())); - appOptions.TurnOffDispose(); - var retailContext = new RetailDbContext(appOptions, null); - context.Add(new RoleToPermissions("NormalRole", null, $"{(char)1}{(char)3}")); context.SaveChanges(); var tenantIds = context.SetupSingleTenantsInDb(); context.ChangeTracker.Clear(); - var tenantChange = new StubITenantChangeServiceFactory(retailContext); + var tenantChange = new StubITenantChangeServiceFactory(); var service = new AuthTenantAdminService(context, new AuthPermissionsOptions { TenantType = TenantTypes.SingleLevel }, tenantChange, null); @@ -251,7 +232,7 @@ public async Task TestUpdateTenantRolesAsync() public async Task TestAddSingleTenantAsyncDuplicate() { //SETUP - using var contexts = new TenantChangeSqlServerSetup(this); + using var contexts = new HierarchicalTenantChangeSqlServerSetup(this); var tenantIds = contexts.AuthPContext.SetupSingleTenantsInDb(); contexts.RetailDbContext.SetupSingleRetailAndStock(); contexts.AuthPContext.ChangeTracker.Clear(); @@ -279,16 +260,11 @@ public async Task TestUpdateNameSingleTenantAsyncOk() { context.Database.EnsureCreated(); - var appOptions = SqliteInMemory.CreateOptions(builder => - builder.UseSqlite(context.Database.GetDbConnection())); - appOptions.TurnOffDispose(); - var retailContext = new RetailDbContext(appOptions, null); - var tenantIds = context.SetupSingleTenantsInDb(); context.ChangeTracker.Clear(); var service = new AuthTenantAdminService(context, new AuthPermissionsOptions { TenantType = TenantTypes.SingleLevel }, - new StubITenantChangeServiceFactory(retailContext), null); + new StubITenantChangeServiceFactory(), null); //ATTEMPT var status = await service.UpdateTenantNameAsync(tenantIds[1], "New Tenant"); @@ -305,32 +281,7 @@ public async Task TestUpdateNameSingleTenantAsyncOk() } [Fact] - public async Task TestUpdateSingleTenantAsyncSqlServerOk() - { - //SETUP - using var contexts = new TenantChangeSqlServerSetup(this); - var tenantIds = contexts.AuthPContext.SetupSingleTenantsInDb(); - contexts.RetailDbContext.SetupSingleRetailAndStock(); - contexts.AuthPContext.ChangeTracker.Clear(); - - var service = new AuthTenantAdminService(contexts.AuthPContext, new AuthPermissionsOptions - { - TenantType = TenantTypes.SingleLevel - }, new StubRetailTenantChangeServiceFactory(contexts.RetailDbContext), null); - - //ATTEMPT - var status = await service.UpdateTenantNameAsync(tenantIds[1], "New Tenant"); - - //VERIFY - status.IsValid.ShouldBeTrue(status.GetAllErrors()); - var tenants = contexts.AuthPContext.Tenants.ToList(); - tenants.Select(x => x.TenantFullName).ShouldEqual(new[] { "Tenant1", "New Tenant", "Tenant3" }); - contexts.RetailDbContext.RetailOutlets.IgnoreQueryFilters().Select(x => x.FullName) - .ToArray().ShouldEqual(new[] { "Tenant1", "New Tenant", "Tenant3" }); - } - - [Fact] - public async Task TestDeleteSingleTenantAsyncSqliteOk() + public async Task TestDeleteSingleTenantAsync() { //SETUP var options = SqliteInMemory.CreateOptions(); @@ -339,16 +290,11 @@ public async Task TestDeleteSingleTenantAsyncSqliteOk() { context.Database.EnsureCreated(); - var appOptions = SqliteInMemory.CreateOptions(builder => - builder.UseSqlite(context.Database.GetDbConnection())); - appOptions.TurnOffDispose(); - var retailContext = new RetailDbContext(appOptions, null); - var tenantIds = context.SetupSingleTenantsInDb(); context.ChangeTracker.Clear(); var service = new AuthTenantAdminService(context, new AuthPermissionsOptions{TenantType = TenantTypes.SingleLevel}, - new StubITenantChangeServiceFactory(retailContext), null); + new StubITenantChangeServiceFactory(), null); //ATTEMPT var status = await service.DeleteTenantAsync(tenantIds[1]); @@ -368,30 +314,6 @@ public async Task TestDeleteSingleTenantAsyncSqliteOk() } } - [Fact] - public async Task TestDeleteSingleTenantAsyncCheckReturn() - { - //SETUP - using var contexts = new TenantChangeSqlServerSetup(this); - var tenantIds = contexts.AuthPContext.SetupSingleTenantsInDb(); - contexts.RetailDbContext.SetupSingleRetailAndStock(); - contexts.AuthPContext.ChangeTracker.Clear(); - - var tenantChange = new StubITenantChangeServiceFactory(contexts.RetailDbContext); - var service = new AuthTenantAdminService(contexts.AuthPContext, new AuthPermissionsOptions - { - TenantType = TenantTypes.SingleLevel - }, tenantChange, null); - - //ATTEMPT - var status = await service.DeleteTenantAsync(tenantIds[1]); - - //VERIFY - status.IsValid.ShouldBeTrue(status.GetAllErrors()); - var deleteLogs = ((StubITenantChangeServiceFactory.StubITenantChangeService)status.Result).DeleteReturnedTuples; - deleteLogs.Single().dataKey.ShouldEqual($"{tenantIds[1]}."); - } - [Fact] public async Task TestDeleteSingleTenantAsyncBadBecauseUserLinkedToIt() { @@ -400,11 +322,6 @@ public async Task TestDeleteSingleTenantAsyncBadBecauseUserLinkedToIt() using var context = new AuthPermissionsDbContext(options); context.Database.EnsureCreated(); - var appOptions = SqliteInMemory.CreateOptions(builder => - builder.UseSqlite(context.Database.GetDbConnection())); - appOptions.TurnOffDispose(); - var retailContext = new RetailDbContext(appOptions, null); - var tenantIds = context.SetupSingleTenantsInDb(); var tenant = context.Find(tenantIds[1]); context.Add(AuthUser.CreateAuthUser("123", "me@gmail.com", "Mr Me", new List(), tenant).Result); @@ -412,7 +329,7 @@ public async Task TestDeleteSingleTenantAsyncBadBecauseUserLinkedToIt() context.ChangeTracker.Clear(); var service = new AuthTenantAdminService(context, new AuthPermissionsOptions { TenantType = TenantTypes.SingleLevel }, - new StubITenantChangeServiceFactory(retailContext), null); + new StubITenantChangeServiceFactory(), null); //ATTEMPT var status = await service.DeleteTenantAsync(tenant.TenantId); @@ -430,16 +347,11 @@ public async Task TestDeleteSingleTenantAsyncErrorFromTenantChangeService() using var context = new AuthPermissionsDbContext(options); context.Database.EnsureCreated(); - var appOptions = SqliteInMemory.CreateOptions(builder => - builder.UseSqlite(context.Database.GetDbConnection())); - appOptions.TurnOffDispose(); - var retailContext = new RetailDbContext(appOptions, null); - var tenantIds = context.SetupSingleTenantsInDb(); context.ChangeTracker.Clear(); var service = new AuthTenantAdminService(context, new AuthPermissionsOptions { TenantType = TenantTypes.SingleLevel }, - new StubITenantChangeServiceFactory(retailContext, "error from TenantChangeService"), null); + new StubITenantChangeServiceFactory("error from TenantChangeService"), null); //ATTEMPT var status = await service.DeleteTenantAsync(tenantIds[1]); diff --git a/Test/UnitTests/TestAuthPermissionsAdmin/TestTenantChangeServiceHierarchical.cs b/Test/UnitTests/TestAuthPermissionsAdmin/TestTenantChangeServiceHierarchical.cs new file mode 100644 index 00000000..0063ee2e --- /dev/null +++ b/Test/UnitTests/TestAuthPermissionsAdmin/TestTenantChangeServiceHierarchical.cs @@ -0,0 +1,266 @@ +// Copyright (c) 2021 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AuthPermissions; +using AuthPermissions.AdminCode.Services; +using AuthPermissions.SetupCode; +using Example4.ShopCode.EfCoreCode; +using Microsoft.EntityFrameworkCore; +using Test.TestHelpers; +using Xunit; +using Xunit.Abstractions; +using Xunit.Extensions.AssertExtensions; + +namespace Test.UnitTests.TestAuthPermissionsAdmin +{ + public class TestTenantChangeServiceHierarchical + { + private readonly ITestOutputHelper _output; + + public TestTenantChangeServiceHierarchical(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public async Task TestSetupHierarchicalTenantInDbAsync() + { + //SETUP + using var contexts = new HierarchicalTenantChangeSqlServerSetup(this); + + //ATTEMPT + await contexts.AuthPContext.BulkLoadHierarchicalTenantInDbAsync(contexts.RetailDbContext); + + //VERIFY + contexts.AuthPContext.ChangeTracker.Clear(); + var tenants = contexts.AuthPContext.Tenants.OrderBy(x => x.TenantId).ToList(); + foreach (var tenant in tenants) + { + _output.WriteLine(tenant.ToString()); + } + tenants.Select(x => x.TenantFullName).ToArray().ShouldEqual(new [] + { + "Company", + "Company | West Coast", + "Company | East Coast", + "Company | West Coast | SanFran", + "Company | East Coast | New York", + "Company | West Coast | SanFran | Shop1", + "Company | West Coast | SanFran | Shop2", + "Company | East Coast | New York | Shop3", + "Company | East Coast | New York | Shop4" + }); + } + + + [Fact] + public async Task TestAddHierarchicalTenantAsyncOk() + { + //SETUP + using var contexts = new HierarchicalTenantChangeSqlServerSetup(this); + var tenantIds = await contexts.AuthPContext.BulkLoadHierarchicalTenantInDbAsync(contexts.RetailDbContext); + contexts.AuthPContext.ChangeTracker.Clear(); + + var changeServiceFactory = new StubRetailChangeServiceFactory(contexts.RetailDbContext); + var service = new AuthTenantAdminService(contexts.AuthPContext, + new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant }, + changeServiceFactory, null); + + //ATTEMPT + var status = await service.AddHierarchicalTenantAsync("LA", tenantIds[1]); + + //VERIFY + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + contexts.RetailDbContext.ChangeTracker.Clear(); + var retails = contexts.RetailDbContext.RetailOutlets.IgnoreQueryFilters().ToList(); + retails.Count.ShouldEqual(10); + + var newTenant = retails.SingleOrDefault(x => x.FullName == "Company | West Coast | LA"); + newTenant.ShouldNotBeNull(); + newTenant.DataKey.ShouldEqual("1.2.10."); + } + + [Fact] + public async Task TestUpdateHierarchicalTenantAsyncOk() + { + //SETUP + using var contexts = new HierarchicalTenantChangeSqlServerSetup(this); + var tenantIds = await contexts.AuthPContext.BulkLoadHierarchicalTenantInDbAsync(contexts.RetailDbContext); + contexts.AuthPContext.ChangeTracker.Clear(); + + var changeServiceFactory = new StubRetailChangeServiceFactory(contexts.RetailDbContext); + var service = new AuthTenantAdminService(contexts.AuthPContext, + new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant }, + changeServiceFactory, null); + + //ATTEMPT + var status = await service.UpdateTenantNameAsync(tenantIds[1], "West Area"); + + //VERIFY + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + contexts.RetailDbContext.ChangeTracker.Clear(); + var retails = contexts.RetailDbContext.RetailOutlets.IgnoreQueryFilters().ToList(); + retails.Count(x => x.FullName.StartsWith("Company | West Area")).ShouldEqual(4); + } + + [Fact] + public async Task TestMoveHierarchicalTenantToAnotherParentAsyncBaseOk() + { + //SETUP + using var contexts = new HierarchicalTenantChangeSqlServerSetup(this); + var tenantIds = await contexts.AuthPContext.BulkLoadHierarchicalTenantInDbAsync(contexts.RetailDbContext); + contexts.AuthPContext.ChangeTracker.Clear(); + + var changeServiceFactory = new StubRetailChangeServiceFactory(contexts.RetailDbContext); + var service = new AuthTenantAdminService(contexts.AuthPContext, + new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant }, + changeServiceFactory, null); + + //ATTEMPT + var status = await service.MoveHierarchicalTenantToAnotherParentAsync(6, 5); + + //VERIFY + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + contexts.RetailDbContext.ChangeTracker.Clear(); + var retails = contexts.RetailDbContext.RetailOutlets.IgnoreQueryFilters().ToList(); + foreach (var tenant in retails.OrderBy(x => x.DataKey)) + { + _output.WriteLine(tenant.FullName); + } + + retails.Count(x => x.FullName.StartsWith("Company | East Coast | New York | Shop1")) + .ShouldEqual(1); + } + + [Fact] + public async Task TestMoveHierarchicalTenantToAnotherParentAsyncOk() + { + //SETUP + using var contexts = new HierarchicalTenantChangeSqlServerSetup(this); + var tenantIds = await contexts.AuthPContext.BulkLoadHierarchicalTenantInDbAsync(contexts.RetailDbContext); + contexts.AuthPContext.ChangeTracker.Clear(); + + var changeServiceFactory = new StubRetailChangeServiceFactory(contexts.RetailDbContext); + var service = new AuthTenantAdminService(contexts.AuthPContext, + new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant }, + changeServiceFactory, null); + + //ATTEMPT + var status = await service.MoveHierarchicalTenantToAnotherParentAsync(2, 3); + + //VERIFY + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + status.Message.ShouldEqual( + "Successfully moved the tenant originally named 'Company | West Coast' to the new named 'Company | East Coast | West Coast'."); + + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + contexts.RetailDbContext.ChangeTracker.Clear(); + var retails = contexts.RetailDbContext.RetailOutlets.IgnoreQueryFilters().ToList(); + foreach (var tenant in retails.OrderBy(x => x.DataKey)) + { + _output.WriteLine(tenant.FullName); + } + + retails.Count(x => x.FullName.StartsWith("Company | East Coast | West Coast")).ShouldEqual(4); + } + + [Fact] + public async Task TestMoveHierarchicalTenantToAnotherParentAsyncMoveToTop() + { + //SETUP + using var contexts = new HierarchicalTenantChangeSqlServerSetup(this); + var tenantIds = await contexts.AuthPContext.BulkLoadHierarchicalTenantInDbAsync(contexts.RetailDbContext); + contexts.AuthPContext.ChangeTracker.Clear(); + + var changeServiceFactory = new StubRetailChangeServiceFactory(contexts.RetailDbContext); + var service = new AuthTenantAdminService(contexts.AuthPContext, + new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant }, + changeServiceFactory, null); + + //ATTEMPT + var status = await service.MoveHierarchicalTenantToAnotherParentAsync(3, 0); + + //VERIFY + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + status.Message.ShouldEqual( + "Successfully moved the tenant originally named 'Company | East Coast' to top level."); + + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + contexts.RetailDbContext.ChangeTracker.Clear(); + var retails = contexts.RetailDbContext.RetailOutlets.IgnoreQueryFilters().ToList(); + foreach (var tenant in retails.OrderBy(x => x.DataKey)) + { + _output.WriteLine(tenant.FullName); + } + + retails.Count(x => x.FullName.StartsWith("East Coast")).ShouldEqual(4); + } + + [Fact] + public async Task TestDeleteTenantAsyncBaseOk() + { + //SETUP + using var contexts = new HierarchicalTenantChangeSqlServerSetup(this); + var tenantIds = await contexts.AuthPContext.BulkLoadHierarchicalTenantInDbAsync(contexts.RetailDbContext); + contexts.AuthPContext.ChangeTracker.Clear(); + + var changeServiceFactory = new StubRetailChangeServiceFactory(contexts.RetailDbContext); + var service = new AuthTenantAdminService(contexts.AuthPContext, + new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant }, + changeServiceFactory, null); + + //ATTEMPT + var status = await service.DeleteTenantAsync(6); + + //VERIFY + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + + contexts.RetailDbContext.ChangeTracker.Clear(); + var retails = contexts.RetailDbContext.RetailOutlets.IgnoreQueryFilters().ToList(); + foreach (var tenant in retails.OrderBy(x => x.DataKey)) + { + _output.WriteLine(tenant.FullName); + } + + retails.SingleOrDefault(x => x.FullName == "Company | West Coast | SanFran | Shop1").ShouldBeNull(); + retails.Count.ShouldEqual(tenantIds.Count - 1); + } + + [Fact] + public async Task TestDeleteTenantAsyncAnotherParentOk() + { + //SETUP + using var contexts = new HierarchicalTenantChangeSqlServerSetup(this); + var tenantIds = await contexts.AuthPContext.BulkLoadHierarchicalTenantInDbAsync(contexts.RetailDbContext); + contexts.AuthPContext.ChangeTracker.Clear(); + + var changeServiceFactory = new StubRetailChangeServiceFactory(contexts.RetailDbContext); + var service = new AuthTenantAdminService(contexts.AuthPContext, + new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant }, + changeServiceFactory, null); + + //ATTEMPT + var status = await service.DeleteTenantAsync(2); + + //VERIFY + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + contexts.RetailDbContext.ChangeTracker.Clear(); + var retails = contexts.RetailDbContext.RetailOutlets.IgnoreQueryFilters().ToList(); + foreach (var tenant in retails.OrderBy(x => x.DataKey)) + { + _output.WriteLine(tenant.FullName); + } + + retails.SingleOrDefault(x => x.FullName == "Company | West Coast").ShouldBeNull(); + retails.Count.ShouldEqual(5); + + var deletedIds = ((RetailTenantChangeService)status.Result).DeletedTenantIds; + deletedIds.ShouldEqual(new List{ 6, 7, 4, 2 }); + } + } +} \ No newline at end of file diff --git a/Test/UnitTests/TestAuthPermissionsAdmin/TestTenantChangeServiceShardingSingle.cs b/Test/UnitTests/TestAuthPermissionsAdmin/TestTenantChangeServiceShardingSingle.cs new file mode 100644 index 00000000..6d5ee2d0 --- /dev/null +++ b/Test/UnitTests/TestAuthPermissionsAdmin/TestTenantChangeServiceShardingSingle.cs @@ -0,0 +1,175 @@ +// Copyright (c) 2021 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using System.Linq; +using System.Threading.Tasks; +using AuthPermissions; +using AuthPermissions.AdminCode; +using AuthPermissions.AdminCode.Services; +using AuthPermissions.SetupCode; +using Example6.SingleLevelSharding.EfCoreCode; +using Microsoft.EntityFrameworkCore; +using Test.TestHelpers; +using Xunit; +using Xunit.Abstractions; +using Xunit.Extensions.AssertExtensions; + +namespace Test.UnitTests.TestAuthPermissionsAdmin +{ + public class TestTenantChangeServiceShardingSingle + { + private readonly ITestOutputHelper _output; + + public TestTenantChangeServiceShardingSingle(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public async Task TestAddSingleTenantAsyncToMainDatabaseOk() + { + //SETUP + using var contexts = new ShardingSingleLevelTenantChangeSqlServerSetup(this); + await contexts.AuthPContext.SetupSingleShardingTenantsInDb(contexts.MainContext); + contexts.AuthPContext.ChangeTracker.Clear(); + + var changeServiceFactory = new StubChangeChangeServiceFactory(contexts.MainContext, this); + var service = new AuthTenantAdminService(contexts.AuthPContext, + new AuthPermissionsOptions { TenantType = TenantTypes.SingleLevel | TenantTypes.AddSharding }, + changeServiceFactory, null); + + //ATTEMPT + var status = await service.AddSingleTenantAsync("Tenant4", null, false); + + //VERIFY + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + contexts.MainContext.ChangeTracker.Clear(); + var companies = contexts.MainContext.Companies.IgnoreQueryFilters().ToList(); + companies.Count.ShouldEqual(4); + companies.Last().CompanyName.ShouldEqual("Tenant4"); + } + + [Fact] + public async Task TestAddSingleTenantAsyncToOtherDatabaseHasOwnDbOk() + { + //SETUP + using var contexts = new ShardingSingleLevelTenantChangeSqlServerSetup(this); + await contexts.AuthPContext.SetupSingleShardingTenantsInDb(contexts.MainContext); + contexts.AuthPContext.ChangeTracker.Clear(); + + var changeServiceFactory = new StubChangeChangeServiceFactory(contexts.MainContext, this); + var service = new AuthTenantAdminService(contexts.AuthPContext, + new AuthPermissionsOptions { TenantType = TenantTypes.SingleLevel | TenantTypes.AddSharding }, + changeServiceFactory, null); + + //ATTEMPT + var status = await service.AddSingleTenantAsync("Tenant4", null, true, "OtherConnection"); + + //VERIFY + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + contexts.MainContext.ChangeTracker.Clear(); + var mainCompanies = contexts.MainContext.Companies.IgnoreQueryFilters().ToList(); + mainCompanies.Count.ShouldEqual(3); + contexts.OtherContext.DataKey.ShouldEqual(MultiTenantExtensions.DataKeyNoQueryFilter); + var otherCompanies = contexts.OtherContext.Companies.ToList(); + otherCompanies.Single().CompanyName.ShouldEqual("Tenant4"); + } + + [Fact] + public async Task TestUpdateNameSingleTenantAsyncOk() + { + //SETUP + using var contexts = new ShardingSingleLevelTenantChangeSqlServerSetup(this); + var tenantIds = await contexts.AuthPContext.SetupSingleShardingTenantsInDb(contexts.MainContext); + contexts.AuthPContext.ChangeTracker.Clear(); + + var changeServiceFactory = new StubChangeChangeServiceFactory(contexts.MainContext, this); + var service = new AuthTenantAdminService(contexts.AuthPContext, + new AuthPermissionsOptions { TenantType = TenantTypes.SingleLevel | TenantTypes.AddSharding }, + changeServiceFactory, null); + + //ATTEMPT + var status = await service.UpdateTenantNameAsync(tenantIds[1], "New Tenant"); + + //VERIFY + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + contexts.MainContext.ChangeTracker.Clear(); + var companies = contexts.MainContext.Companies.IgnoreQueryFilters().ToList(); + companies.Select(x => x.CompanyName).ShouldEqual(new[] { "Tenant1", "New Tenant", "Tenant3" }); + } + + [Fact] + public async Task TestDeleteSingleTenantAsync() + { + //SETUP + using var contexts = new ShardingSingleLevelTenantChangeSqlServerSetup(this); + var tenantIds = await contexts.AuthPContext.SetupSingleShardingTenantsInDb(contexts.MainContext); + contexts.AuthPContext.ChangeTracker.Clear(); + + var changeServiceFactory = new StubChangeChangeServiceFactory(contexts.MainContext, this); + var service = new AuthTenantAdminService(contexts.AuthPContext, + new AuthPermissionsOptions { TenantType = TenantTypes.SingleLevel | TenantTypes.AddSharding }, + changeServiceFactory, null); + + //ATTEMPT + var status = await service.DeleteTenantAsync(tenantIds[1]); + + //VERIFY + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + var companies = contexts.MainContext.Companies.IgnoreQueryFilters().ToList(); + companies.Select(x => x.CompanyName).ShouldEqual(new[] { "Tenant1", "Tenant3" }); + } + + [Fact] + public async Task TestDeleteSingleTenantAsyncCheckReturn() + { + //SETUP + using var contexts = new ShardingSingleLevelTenantChangeSqlServerSetup(this); + var tenantIds = await contexts.AuthPContext.SetupSingleShardingTenantsInDb(contexts.MainContext); + contexts.AuthPContext.ChangeTracker.Clear(); + + var changeServiceFactory = new StubChangeChangeServiceFactory(contexts.MainContext, this); + var service = new AuthTenantAdminService(contexts.AuthPContext, + new AuthPermissionsOptions { TenantType = TenantTypes.SingleLevel | TenantTypes.AddSharding }, + changeServiceFactory, null); + + //ATTEMPT + var status = await service.DeleteTenantAsync(tenantIds[1]); + + //VERIFY + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + var deletedId = ((ShardingTenantChangeService)status.Result).DeletedTenantId; + deletedId.ShouldEqual(tenantIds[1]); + } + + [Fact] + public async Task TestMoveToDifferentDatabaseAsync() + { + //SETUP + using var contexts = new ShardingSingleLevelTenantChangeSqlServerSetup(this); + var tenantIds = await contexts.AuthPContext.SetupSingleShardingTenantsInDb(contexts.MainContext); + contexts.AuthPContext.ChangeTracker.Clear(); + + var changeServiceFactory = new StubChangeChangeServiceFactory(contexts.MainContext, this); + var service = new AuthTenantAdminService(contexts.AuthPContext, + new AuthPermissionsOptions { TenantType = TenantTypes.SingleLevel | TenantTypes.AddSharding }, + changeServiceFactory, null); + + //ATTEMPT + var status = await service.MoveToDifferentDatabaseAsync(tenantIds[1], true, "OtherConnection"); + + //VERIFY + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + contexts.MainContext.ChangeTracker.Clear(); + var mainCompanies = contexts.MainContext.Companies.IgnoreQueryFilters().ToList(); + mainCompanies.Count.ShouldEqual(2); + contexts.OtherContext.DataKey.ShouldEqual(MultiTenantExtensions.DataKeyNoQueryFilter); + var query = contexts.OtherContext.Companies; + _output.WriteLine(query.ToQueryString()); + var otherCompanies = query.ToList(); + otherCompanies.Single().CompanyName.ShouldEqual("Tenant2"); + } + + } +} \ No newline at end of file diff --git a/Test/UnitTests/TestAuthPermissionsAdmin/TestTenantChangeServiceSingle.cs b/Test/UnitTests/TestAuthPermissionsAdmin/TestTenantChangeServiceSingle.cs new file mode 100644 index 00000000..322d67e9 --- /dev/null +++ b/Test/UnitTests/TestAuthPermissionsAdmin/TestTenantChangeServiceSingle.cs @@ -0,0 +1,119 @@ +// Copyright (c) 2021 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using System.Linq; +using System.Threading.Tasks; +using AuthPermissions; +using AuthPermissions.AdminCode.Services; +using AuthPermissions.SetupCode; +using Example3.InvoiceCode.EfCoreCode; +using Microsoft.EntityFrameworkCore; +using Test.TestHelpers; +using Xunit; +using Xunit.Abstractions; +using Xunit.Extensions.AssertExtensions; + +namespace Test.UnitTests.TestAuthPermissionsAdmin +{ + public class TestTenantChangeServiceSingle + { + private readonly ITestOutputHelper _output; + + public TestTenantChangeServiceSingle(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public async Task TestAddSingleTenantAsyncOk() + { + //SETUP + using var contexts = new SingleLevelTenantChangeSqlServerSetup(this); + contexts.AuthPContext.SetupSingleTenantsInDb(contexts.InvoiceDbContext); + contexts.AuthPContext.ChangeTracker.Clear(); + + var changeServiceFactory = new StubInvoiceChangeServiceFactory(contexts.InvoiceDbContext); + var service = new AuthTenantAdminService(contexts.AuthPContext, + new AuthPermissionsOptions { TenantType = TenantTypes.SingleLevel }, + changeServiceFactory, null); + + //ATTEMPT + var status = await service.AddSingleTenantAsync("Tenant4"); + + //VERIFY + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + contexts.InvoiceDbContext.ChangeTracker.Clear(); + var companies = contexts.InvoiceDbContext.Companies.IgnoreQueryFilters().ToList(); + companies.Count.ShouldEqual(4); + companies.Last().CompanyName.ShouldEqual("Tenant4"); + } + + [Fact] + public async Task TestUpdateNameSingleTenantAsyncOk() + { + //SETUP + using var contexts = new SingleLevelTenantChangeSqlServerSetup(this); + var tenantIds = contexts.AuthPContext.SetupSingleTenantsInDb(contexts.InvoiceDbContext); + contexts.AuthPContext.ChangeTracker.Clear(); + + var changeServiceFactory = new StubInvoiceChangeServiceFactory(contexts.InvoiceDbContext); + var service = new AuthTenantAdminService(contexts.AuthPContext, + new AuthPermissionsOptions { TenantType = TenantTypes.SingleLevel }, + changeServiceFactory, null); + + //ATTEMPT + var status = await service.UpdateTenantNameAsync(tenantIds[1], "New Tenant"); + + //VERIFY + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + contexts.InvoiceDbContext.ChangeTracker.Clear(); + var companies = contexts.InvoiceDbContext.Companies.IgnoreQueryFilters().ToList(); + companies.Select(x => x.CompanyName).ShouldEqual(new[] { "Tenant1", "New Tenant", "Tenant3" }); + } + + [Fact] + public async Task TestDeleteSingleTenantAsync() + { + //SETUP + using var contexts = new SingleLevelTenantChangeSqlServerSetup(this); + var tenantIds = contexts.AuthPContext.SetupSingleTenantsInDb(contexts.InvoiceDbContext); + contexts.AuthPContext.ChangeTracker.Clear(); + + var changeServiceFactory = new StubInvoiceChangeServiceFactory(contexts.InvoiceDbContext); + var service = new AuthTenantAdminService(contexts.AuthPContext, + new AuthPermissionsOptions { TenantType = TenantTypes.SingleLevel }, + changeServiceFactory, null); + + //ATTEMPT + var status = await service.DeleteTenantAsync(tenantIds[1]); + + //VERIFY + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + var companies = contexts.InvoiceDbContext.Companies.IgnoreQueryFilters().ToList(); + companies.Select(x => x.CompanyName).ShouldEqual(new[] { "Tenant1", "Tenant3" }); + } + + [Fact] + public async Task TestDeleteSingleTenantAsyncCheckReturn() + { + //SETUP + using var contexts = new SingleLevelTenantChangeSqlServerSetup(this); + var tenantIds = contexts.AuthPContext.SetupSingleTenantsInDb(contexts.InvoiceDbContext); + contexts.AuthPContext.ChangeTracker.Clear(); + + var changeServiceFactory = new StubInvoiceChangeServiceFactory(contexts.InvoiceDbContext); + var service = new AuthTenantAdminService(contexts.AuthPContext, + new AuthPermissionsOptions { TenantType = TenantTypes.SingleLevel }, + changeServiceFactory, null); + + //ATTEMPT + var status = await service.DeleteTenantAsync(tenantIds[1]); + + //VERIFY + status.IsValid.ShouldBeTrue(status.GetAllErrors()); + var deletedId = ((InvoiceTenantChangeService)status.Result).DeletedTenantId; + deletedId.ShouldEqual(tenantIds[1]); + } + + } +} \ No newline at end of file diff --git a/Test/UnitTests/TestAuthPermissionsAspNetCore/TestShardingConnectionString.cs b/Test/UnitTests/TestAuthPermissionsAspNetCore/TestShardingConnectionString.cs new file mode 100644 index 00000000..6d4b08d7 --- /dev/null +++ b/Test/UnitTests/TestAuthPermissionsAspNetCore/TestShardingConnectionString.cs @@ -0,0 +1,67 @@ +// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/ +// Licensed under MIT license. See License.txt in the project root for license information. + +using System.Linq; +using AuthPermissions.AspNetCore.Services; +using Microsoft.Extensions.DependencyInjection; +using TestSupport.Helpers; +using Xunit; +using Xunit.Abstractions; +using Xunit.Extensions.AssertExtensions; + +namespace Test.UnitTests.TestAuthPermissionsAspNetCore; + +public class TestShardingConnectionString +{ + private readonly ITestOutputHelper _output; + + public TestShardingConnectionString(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void TestGetAllConnectionStrings() + { + //SETUP + var config = AppSettings.GetConfiguration("..\\Test\\TestData"); + var services = new ServiceCollection(); + services.Configure(config.GetSection("ConnectionStrings")); + services.AddTransient(); + var serviceProvider = services.BuildServiceProvider(); + + var service = serviceProvider.GetRequiredService(); + + //ATTEMPT + var connectionNames = service.GetAllConnectionStringNames().ToArray(); + + //VERIFY + foreach (var name in connectionNames) + { + _output.WriteLine(name); + } + connectionNames.Length.ShouldEqual(3); + connectionNames[0].ShouldEqual("AnotherConnectionString"); + connectionNames[1].ShouldEqual("UnitTestConnection"); + connectionNames[2].ShouldEqual("Version1Example4"); + } + + [Fact] + public void TestGetNamedConnectionString() + { + //SETUP + var config = AppSettings.GetConfiguration("..\\Test\\TestData"); + var services = new ServiceCollection(); + services.Configure(config.GetSection("ConnectionStrings")); + services.AddTransient(); + var serviceProvider = services.BuildServiceProvider(); + + var service = serviceProvider.GetRequiredService(); + + //ATTEMPT + var connectionString = service.GetNamedConnectionString("AnotherConnectionString"); + + //VERIFY + connectionString.ShouldEqual("Server=MyServer;Database=DummyDatabase;"); + } +} \ No newline at end of file diff --git a/Test/UnitTests/TestAzureAd/TestSyncAzureAdUsers.cs b/Test/UnitTests/TestAzureAd/TestSyncAzureAdUsers.cs index a1daf680..94a0987e 100644 --- a/Test/UnitTests/TestAzureAd/TestSyncAzureAdUsers.cs +++ b/Test/UnitTests/TestAzureAd/TestSyncAzureAdUsers.cs @@ -8,7 +8,6 @@ using Microsoft.Extensions.DependencyInjection; using TestSupport.Attributes; using TestSupport.Helpers; -using Xunit; using Xunit.Abstractions; using Xunit.Extensions.AssertExtensions; @@ -30,6 +29,7 @@ public TestSyncAzureAdUsers(ITestOutputHelper output) [RunnableInDebugOnly] public async Task TestSyncAzureAdUsersService() { + //SETUP var config = AppSettings.GetConfiguration(); var services = new ServiceCollection(); diff --git a/Test/UnitTests/TestExamples/TestExample3RegisterTenantDataKeyChangeService.cs b/Test/UnitTests/TestExamples/TestExample3RegisterTenantDataKeyChangeService.cs index bf815d3e..09b44edd 100644 --- a/Test/UnitTests/TestExamples/TestExample3RegisterTenantDataKeyChangeService.cs +++ b/Test/UnitTests/TestExamples/TestExample3RegisterTenantDataKeyChangeService.cs @@ -46,7 +46,7 @@ public async Task TestDetectTenantDataKeyChangeService_DataKeyChanges() var context = new AuthPermissionsDbContext(options, service); context.Database.EnsureCreated(); - await context.SetupHierarchicalTenantInDbAsync(); + await context.BulkLoadHierarchicalTenantInDbAsync(); globalAccessor.NumTimesCalled.ShouldEqual(0); //ATTEMPT diff --git a/Test/UnitTests/TestExamples/TestExample4SeedShopOnStartup.cs b/Test/UnitTests/TestExamples/TestExample4SeedShopOnStartup.cs index b307245b..32d97603 100644 --- a/Test/UnitTests/TestExamples/TestExample4SeedShopOnStartup.cs +++ b/Test/UnitTests/TestExamples/TestExample4SeedShopOnStartup.cs @@ -33,7 +33,7 @@ public async Task TestCreateShopsAndSeedStockAsyncSimpleStock() var authPOptions = SqliteInMemory.CreateOptions(); using var authPContext = new AuthPermissionsDbContext(authPOptions); authPContext.Database.EnsureCreated(); - await authPContext.SetupHierarchicalTenantInDbAsync(); + await authPContext.BulkLoadHierarchicalTenantInDbAsync(); var tenantService = new AuthTenantAdminService(authPContext, new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant}, null, null); var rOptions = SqliteInMemory.CreateOptions(); @@ -59,7 +59,7 @@ public async Task TestCreateShopsAndSeedStockAsyncAllStock() var authPOptions = SqliteInMemory.CreateOptions(); using var authPContext = new AuthPermissionsDbContext(authPOptions); authPContext.Database.EnsureCreated(); - await authPContext.SetupHierarchicalTenantInDbAsync(); + await authPContext.BulkLoadHierarchicalTenantInDbAsync(); var tenantService = new AuthTenantAdminService(authPContext, new AuthPermissionsOptions { TenantType = TenantTypes.HierarchicalTenant}, null, null); var rOptions = SqliteInMemory.CreateOptions(); diff --git a/Test/UnitTests/TestExamples/UnitTestExample.cs b/Test/UnitTests/TestExamples/UnitTestExample.cs index 83108200..0c5f150d 100644 --- a/Test/UnitTests/TestExamples/UnitTestExample.cs +++ b/Test/UnitTests/TestExamples/UnitTestExample.cs @@ -11,7 +11,6 @@ using AuthPermissions.DataLayer.EfCode; using AuthPermissions.SetupCode; using Example4.MvcWebApp.IndividualAccounts.PermissionsCode; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Test.TestHelpers; using Xunit; diff --git a/Test/UnitTests/TestIssues/TestIssue0013.cs b/Test/UnitTests/TestIssues/TestIssue0013.cs index e3b7fd86..5888845a 100644 --- a/Test/UnitTests/TestIssues/TestIssue0013.cs +++ b/Test/UnitTests/TestIssues/TestIssue0013.cs @@ -5,15 +5,12 @@ using System.Collections; using System.Collections.Generic; using System.Linq; -using System.Runtime.InteropServices.ComTypes; using System.Threading.Tasks; using AuthPermissions; using AuthPermissions.AdminCode.Services; using AuthPermissions.DataLayer.Classes; using AuthPermissions.DataLayer.Classes.SupportTypes; using AuthPermissions.DataLayer.EfCode; -using EntityFramework.Exceptions.SqlServer; -using Microsoft.EntityFrameworkCore; using Test.TestHelpers; using TestSupport.EfHelpers; using Xunit;