diff --git a/framework/src/Volo.Abp.Gdpr.Abstractions/Volo.Abp.Gdpr.Abstractions.csproj b/framework/src/Volo.Abp.Gdpr.Abstractions/Volo.Abp.Gdpr.Abstractions.csproj index e918718925..1dce223a34 100644 --- a/framework/src/Volo.Abp.Gdpr.Abstractions/Volo.Abp.Gdpr.Abstractions.csproj +++ b/framework/src/Volo.Abp.Gdpr.Abstractions/Volo.Abp.Gdpr.Abstractions.csproj @@ -12,6 +12,7 @@ + diff --git a/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/AbpGdprAbstractionsModule.cs b/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/AbpGdprAbstractionsModule.cs index 3cb53c8be2..ed8f682641 100644 --- a/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/AbpGdprAbstractionsModule.cs +++ b/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/AbpGdprAbstractionsModule.cs @@ -1,7 +1,11 @@ using Volo.Abp.Modularity; +using Volo.Abp.EventBus.Abstractions; namespace Volo.Abp.Gdpr; +[DependsOn( + typeof(AbpEventBusAbstractionsModule) +)] public class AbpGdprAbstractionsModule : AbpModule { } \ No newline at end of file diff --git a/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataDeletionRequestedEto.cs b/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataDeletionRequestedEto.cs index aa7b721117..e9520d9580 100644 --- a/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataDeletionRequestedEto.cs +++ b/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataDeletionRequestedEto.cs @@ -1,9 +1,18 @@ using System; +using Volo.Abp.EventBus; namespace Volo.Abp.Gdpr; [Serializable] -public class GdprUserDataDeletionRequestedEto +public class GdprUserDataDeletionRequestedEto : IEventDataMayHaveTenantId { + public Guid? TenantId { get; set; } + public Guid UserId { get; set; } -} \ No newline at end of file + + public bool IsMultiTenant(out Guid? tenantId) + { + tenantId = TenantId; + return TenantId.HasValue; + } +} diff --git a/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataPreparedEto.cs b/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataPreparedEto.cs index d52e12783c..26f075d894 100644 --- a/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataPreparedEto.cs +++ b/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataPreparedEto.cs @@ -1,13 +1,22 @@ using System; +using Volo.Abp.EventBus; namespace Volo.Abp.Gdpr; [Serializable] -public class GdprUserDataPreparedEto +public class GdprUserDataPreparedEto : IEventDataMayHaveTenantId { + public Guid? TenantId { get; set; } + public Guid RequestId { get; set; } public string Provider { get; set; } = default!; public GdprDataInfo Data { get; set; } = default!; -} \ No newline at end of file + + public bool IsMultiTenant(out Guid? tenantId) + { + tenantId = TenantId; + return TenantId.HasValue; + } +} diff --git a/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataRequestedEto.cs b/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataRequestedEto.cs index b341ab2659..3403b0d514 100644 --- a/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataRequestedEto.cs +++ b/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataRequestedEto.cs @@ -1,11 +1,20 @@ using System; +using Volo.Abp.EventBus; namespace Volo.Abp.Gdpr; [Serializable] -public class GdprUserDataRequestedEto +public class GdprUserDataRequestedEto : IEventDataMayHaveTenantId { + public Guid? TenantId { get; set; } + public Guid UserId { get; set; } public Guid RequestId { get; set; } -} \ No newline at end of file + + public bool IsMultiTenant(out Guid? tenantId) + { + tenantId = TenantId; + return TenantId.HasValue; + } +} diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs index f1906087b7..8e327439af 100644 --- a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs +++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs @@ -115,15 +115,16 @@ public class IdentityUserManager : UserManager, IDomainService user.PasswordHistories.Clear(); user.Passkeys.Clear(); + //Soft deleting reloads the original values, the store saves the changes without validating the user. + //Nothing else is deleted before this succeeds, it is where the user is checked for concurrency. + (await Store.UpdateAsync(user, CancellationToken)).CheckErrors(); + //They are in the host database and deleting them here covers the current unit of work. using (CurrentTenant.Change(null)) { await IdentityLinkUserRepository.DeleteAsync(new IdentityLinkUserInfo(user.Id, user.TenantId), CancellationToken); } - //Soft deleting reloads the original values, the store saves the changes without validating the user. - (await Store.UpdateAsync(user, CancellationToken)).CheckErrors(); - return await base.DeleteAsync(user); } diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/OrganizationUnitManager.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/OrganizationUnitManager.cs index efc78184a9..f6272f6878 100644 --- a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/OrganizationUnitManager.cs +++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/OrganizationUnitManager.cs @@ -50,38 +50,47 @@ public class OrganizationUnitManager : DomainService /// /// Creates the given organization units by querying the siblings of a parent once instead of once - /// per organization unit. The parents must already exist. Custom validation should be added by - /// overriding . + /// per organization unit. They all have to belong to the current tenant and their parents must + /// already exist. is used for the first organization unit of a + /// parent, the codes of the rest follow it. Custom validation should be added by overriding + /// . /// [UnitOfWork] public virtual async Task CreateManyAsync(List organizationUnits) { Check.NotNull(organizationUnits, nameof(organizationUnits)); - foreach (var group in organizationUnits.GroupBy(x => new { x.TenantId, x.ParentId })) + if (organizationUnits.Any(x => x.TenantId != CurrentTenant.Id)) { - //Siblings, codes and the database of a group belong to its own tenant. - using (CurrentTenant.Change(group.Key.TenantId)) - { - await ValidateParentTenantAsync(group.Key.ParentId, group.Key.TenantId); + throw new AbpException("Organization units of another tenant can not be created, change the current tenant instead!"); + } - var siblings = await FindChildrenAsync(group.Key.ParentId); - var lastCode = siblings.OrderBy(x => x.Code).LastOrDefault()?.Code; + var groups = organizationUnits.GroupBy(x => x.ParentId).ToList(); + + foreach (var group in groups) + { + await ValidateParentTenantAsync(group.Key, CurrentTenant.Id); - foreach (var organizationUnit in group) - { - await ValidateOrganizationUnitAsync(organizationUnit, siblings); + var siblings = await FindChildrenAsync(group.Key); + string lastCode = null; - organizationUnit.Code = lastCode = lastCode == null - ? await GetNextChildCodeAsync(group.Key.ParentId) - : OrganizationUnit.CalculateNextCode(lastCode); + foreach (var organizationUnit in group) + { + organizationUnit.Code = lastCode = lastCode == null + ? await GetNextChildCodeAsync(group.Key) + : OrganizationUnit.CalculateNextCode(lastCode); - siblings.Add(organizationUnit); - } + await ValidateOrganizationUnitAsync(organizationUnit, siblings); - await OrganizationUnitRepository.InsertManyAsync(group.ToList()); + siblings.Add(organizationUnit); } } + + //Nothing is inserted before every group is validated. + foreach (var group in groups) + { + await OrganizationUnitRepository.InsertManyAsync(group.ToList()); + } } public virtual async Task UpdateAsync(OrganizationUnit organizationUnit) diff --git a/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/OrganizationUnitManager_CreateMany_Tests.cs b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/OrganizationUnitManager_CreateMany_Tests.cs new file mode 100644 index 0000000000..94f6dce0eb --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/OrganizationUnitManager_CreateMany_Tests.cs @@ -0,0 +1,5 @@ +namespace Volo.Abp.Identity; + +public class OrganizationUnitManager_CreateMany_Tests : OrganizationUnitManager_CreateMany_Tests +{ +} diff --git a/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/OrganizationUnitManager_Tests.cs b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/OrganizationUnitManager_Tests.cs index 0c0083c88d..a9c51678c2 100644 --- a/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/OrganizationUnitManager_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/OrganizationUnitManager_Tests.cs @@ -5,6 +5,12 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Threading; +using Volo.Abp.Security.Claims; +using Volo.Abp.Identity.Localization; +using Volo.Abp.Caching; +using Microsoft.Extensions.Localization; using Volo.Abp.Guids; using Volo.Abp.MultiTenancy; using Volo.Abp.Uow; @@ -232,4 +238,84 @@ public class OrganizationUnitManager_Tests : AbpIdentityDomainTestBase await uow.CompleteAsync(); } } + + [Fact] + public async Task CreateManyAsync_Should_Not_Create_Organization_Units_Of_Another_Tenant() + { + using (var uow = _unitOfWorkManager.Begin()) + { + await Should.ThrowAsync(async () => + await _organizationUnitManager.CreateManyAsync([ + new OrganizationUnit(_guidGenerator.Create(), "another-tenant", null, Guid.NewGuid()) + ])); + + await uow.CompleteAsync(); + } + } + + [Fact] + public async Task CreateManyAsync_Should_Use_The_Overridden_Extension_Points() + { + var manager = new TestOrganizationUnitManager( + _organizationUnitRepository, + GetRequiredService>(), + _identityRoleRepository, + GetRequiredService>(), + GetRequiredService()) + { + LazyServiceProvider = GetRequiredService() + }; + + using (var uow = _unitOfWorkManager.Begin()) + { + await Should.ThrowAsync(async () => + await manager.CreateManyAsync([new OrganizationUnit(_guidGenerator.Create(), "rejected-by-the-override")])); + + await manager.CreateManyAsync([ + new OrganizationUnit(_guidGenerator.Create(), $"extension-point-1-{Guid.NewGuid():N}"), + new OrganizationUnit(_guidGenerator.Create(), $"extension-point-2-{Guid.NewGuid():N}") + ]); + + await uow.CompleteAsync(); + } + + //Every organization unit is validated, the code generator is only used for the first one of a parent. + //Both calls above created a root organization unit, so the code generator was used twice. + manager.ValidateCallCount.ShouldBe(3); + manager.GetNextChildCodeCallCount.ShouldBe(2); + } + + public class TestOrganizationUnitManager : OrganizationUnitManager + { + public int ValidateCallCount { get; private set; } + public int GetNextChildCodeCallCount { get; private set; } + + public TestOrganizationUnitManager( + IOrganizationUnitRepository organizationUnitRepository, + IStringLocalizer localizer, + IIdentityRoleRepository identityRoleRepository, + IDistributedCache dynamicClaimCache, + ICancellationTokenProvider cancellationTokenProvider) + : base(organizationUnitRepository, localizer, identityRoleRepository, dynamicClaimCache, cancellationTokenProvider) + { + } + + public override async Task GetNextChildCodeAsync(Guid? parentId) + { + GetNextChildCodeCallCount++; + return await base.GetNextChildCodeAsync(parentId); + } + + protected override async Task ValidateOrganizationUnitAsync(OrganizationUnit organizationUnit, List siblings) + { + ValidateCallCount++; + + if (organizationUnit.DisplayName == "rejected-by-the-override") + { + throw new BusinessException(IdentityErrorCodes.DuplicateOrganizationUnitDisplayName); + } + + await base.ValidateOrganizationUnitAsync(organizationUnit, siblings); + } + } } diff --git a/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo/Abp/Identity/MongoDB/OrganizationUnitManager_CreateMany_Tests.cs b/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo/Abp/Identity/MongoDB/OrganizationUnitManager_CreateMany_Tests.cs new file mode 100644 index 0000000000..35f8004c5b --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo/Abp/Identity/MongoDB/OrganizationUnitManager_CreateMany_Tests.cs @@ -0,0 +1,8 @@ +using Xunit; + +namespace Volo.Abp.Identity.MongoDB; + +[Collection(MongoTestCollection.Name)] +public class OrganizationUnitManager_CreateMany_Tests : OrganizationUnitManager_CreateMany_Tests +{ +} diff --git a/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserManager_Delete_Tests.cs b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserManager_Delete_Tests.cs index 0f874c836a..c6e6b179c6 100644 --- a/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserManager_Delete_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserManager_Delete_Tests.cs @@ -148,7 +148,18 @@ public abstract class IdentityUserManager_Delete_Tests : AbpIden using (var uow = UnitOfWorkManager.Begin()) { var user = await IdentityUserManager.GetByIdAsync(userId); - await IdentityUserManager.AddToRoleAsync(user, "moderator"); + + (await IdentityUserManager.AddClaimAsync(user, new Claim("test", "test"))).CheckErrors(); + (await IdentityUserManager.AddLoginAsync(user, new UserLoginInfo("test", "test", "test"))).CheckErrors(); + (await IdentityUserManager.AddToRoleAsync(user, "moderator")).CheckErrors(); + user.SetToken("test", "test", "test"); + user.AddPasswordHistory("test"); + user.AddPasskey([1, 2, 3], new IdentityPasskeyData()); + await IdentityUserManager.AddToOrganizationUnitAsync( + user, + await OrganizationUnitRepository.GetAsync(LookupNormalizer.NormalizeName("OU11"))); + await IdentityUserRepository.UpdateAsync(user); + await uow.CompleteAsync(); } @@ -163,12 +174,20 @@ public abstract class IdentityUserManager_Delete_Tests : AbpIden await uow.CompleteAsync(); } + //Every collection of the user has to be loaded before it is cleared. using (var uow = UnitOfWorkManager.Begin()) using (DataFilter.Disable()) { var deletedUser = await IdentityUserRepository.FindAsync(userId); deletedUser.ShouldNotBeNull(); + + deletedUser.Claims.Count.ShouldBe(0); + deletedUser.Logins.Count.ShouldBe(0); deletedUser.Roles.Count.ShouldBe(0); + deletedUser.Tokens.Count.ShouldBe(0); + deletedUser.OrganizationUnits.Count.ShouldBe(0); + deletedUser.PasswordHistories.Count.ShouldBe(0); + deletedUser.Passkeys.Count.ShouldBe(0); await uow.CompleteAsync(); } @@ -358,4 +377,101 @@ public abstract class IdentityUserManager_Delete_Tests : AbpIden async () => await IdentityUserManager.DeleteAsync(staleUser)); } } + + [Fact] + public virtual async Task Deleting_A_User_Through_The_Repository_Should_Not_Clear_Its_Own_Collections() + { + var userId = Guid.NewGuid(); + + using (var uow = UnitOfWorkManager.Begin()) + { + (await IdentityUserManager.CreateAsync( + new IdentityUser(userId, $"repo-collections-{userId:N}", $"repo-collections-{userId:N}@abp.io"))).CheckErrors(); + + await uow.CompleteAsync(); + } + + using (var uow = UnitOfWorkManager.Begin()) + { + var user = await IdentityUserManager.GetByIdAsync(userId); + (await IdentityUserManager.AddToRoleAsync(user, "moderator")).CheckErrors(); + user.AddPasswordHistory("test"); + await IdentityUserRepository.UpdateAsync(user); + + await uow.CompleteAsync(); + } + + using (var uow = UnitOfWorkManager.Begin()) + { + await IdentityUserRepository.DeleteAsync(await IdentityUserRepository.GetAsync(userId)); + + await uow.CompleteAsync(); + } + + //UserDeletedEventHandler only covers the aggregates that have no navigation from the user, + //the collections of the user itself are cleared by IdentityUserManager.DeleteAsync. + using (var uow = UnitOfWorkManager.Begin()) + using (DataFilter.Disable()) + { + var deletedUser = await IdentityUserRepository.FindAsync(userId); + deletedUser.Roles.Count.ShouldBe(1); + deletedUser.PasswordHistories.Count.ShouldBe(1); + + await uow.CompleteAsync(); + } + } + + [Fact] + public virtual async Task Deleting_A_Stale_User_Should_Not_Delete_Its_Link_Users() + { + var userId = Guid.NewGuid(); + var linkedUserId = Guid.NewGuid(); + IdentityUser staleUser; + + using (var uow = UnitOfWorkManager.Begin()) + { + (await IdentityUserManager.CreateAsync( + new IdentityUser(userId, $"stale-link-{userId:N}", $"stale-link-{userId:N}@abp.io"))).CheckErrors(); + (await IdentityUserManager.CreateAsync( + new IdentityUser(linkedUserId, $"stale-linked-{linkedUserId:N}", $"stale-linked-{linkedUserId:N}@abp.io"))).CheckErrors(); + + await uow.CompleteAsync(); + } + + using (var uow = UnitOfWorkManager.Begin()) + { + await IdentityLinkUserManager.LinkAsync( + new IdentityLinkUserInfo(userId), + new IdentityLinkUserInfo(linkedUserId)); + + staleUser = await IdentityUserRepository.GetAsync(userId); + + await uow.CompleteAsync(); + } + + using (var uow = UnitOfWorkManager.Begin()) + { + var user = await IdentityUserRepository.GetAsync(userId); + user.Name = "Changed"; + await IdentityUserRepository.UpdateAsync(user); + + await uow.CompleteAsync(); + } + + using (var uow = UnitOfWorkManager.Begin()) + { + await Should.ThrowAsync( + async () => await IdentityUserManager.DeleteAsync(staleUser)); + } + + //The user is still there, so its link users must be there as well. + using (var uow = UnitOfWorkManager.Begin()) + { + (await IdentityLinkUserManager.IsLinkedAsync( + new IdentityLinkUserInfo(userId), + new IdentityLinkUserInfo(linkedUserId))).ShouldBeTrue(); + + await uow.CompleteAsync(); + } + } } diff --git a/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/OrganizationUnitManager_CreateMany_Tests.cs b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/OrganizationUnitManager_CreateMany_Tests.cs new file mode 100644 index 0000000000..347d91b9c9 --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/OrganizationUnitManager_CreateMany_Tests.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Shouldly; +using Volo.Abp.Guids; +using Volo.Abp.Modularity; +using Volo.Abp.Uow; +using Xunit; + +namespace Volo.Abp.Identity; + +public abstract class OrganizationUnitManager_CreateMany_Tests : AbpIdentityTestBase + where TStartupModule : IAbpModule +{ + protected OrganizationUnitManager OrganizationUnitManager { get; } + protected IOrganizationUnitRepository OrganizationUnitRepository { get; } + protected ILookupNormalizer LookupNormalizer { get; } + protected IUnitOfWorkManager UnitOfWorkManager { get; } + protected IGuidGenerator GuidGenerator { get; } + + protected OrganizationUnitManager_CreateMany_Tests() + { + OrganizationUnitManager = GetRequiredService(); + OrganizationUnitRepository = GetRequiredService(); + LookupNormalizer = GetRequiredService(); + UnitOfWorkManager = GetRequiredService(); + GuidGenerator = GetRequiredService(); + } + + [Fact] + public virtual async Task Should_Not_Insert_Anything_When_A_Group_Is_Not_Valid() + { + var validDisplayName = $"valid-{Guid.NewGuid():N}"; + + using (var uow = UnitOfWorkManager.Begin()) + { + var parent = await OrganizationUnitRepository.GetAsync("OU1"); + + //The second group is not valid, OU11 is already a child of OU1. + await Should.ThrowAsync(async () => + await OrganizationUnitManager.CreateManyAsync([ + new OrganizationUnit(GuidGenerator.Create(), validDisplayName), + new OrganizationUnit(GuidGenerator.Create(), "OU11", parent.Id) + ])); + } + + using (var uow = UnitOfWorkManager.Begin()) + { + (await OrganizationUnitRepository.GetAsync(validDisplayName)).ShouldBeNull(); + + await uow.CompleteAsync(); + } + } +}