diff --git a/src/Core/Repositories/EntityFramework/ProviderUserRepository.cs b/src/Core/Repositories/EntityFramework/ProviderUserRepository.cs index 841c3f0f8..6ca4f83a9 100644 --- a/src/Core/Repositories/EntityFramework/ProviderUserRepository.cs +++ b/src/Core/Repositories/EntityFramework/ProviderUserRepository.cs @@ -155,5 +155,11 @@ namespace Bit.Core.Repositories.EntityFramework return organizationUsers; } } + + public async Task GetCountByOnlyOwnerAsync(Guid userId) + { + var query = new ProviderUserReadCountByOnlyOwnerQuery(userId); + return await GetCountFromQuery(query); + } } } diff --git a/src/Core/Repositories/EntityFramework/Queries/OrganizationUserReadCountByOnlyOwnerQuery.cs b/src/Core/Repositories/EntityFramework/Queries/OrganizationUserReadCountByOnlyOwnerQuery.cs index ed36db2c7..23be161fd 100644 --- a/src/Core/Repositories/EntityFramework/Queries/OrganizationUserReadCountByOnlyOwnerQuery.cs +++ b/src/Core/Repositories/EntityFramework/Queries/OrganizationUserReadCountByOnlyOwnerQuery.cs @@ -19,11 +19,12 @@ namespace Bit.Core.Repositories.EntityFramework.Queries { var owners = from ou in dbContext.OrganizationUsers where ou.Type == OrganizationUserType.Owner && - ou.Status == OrganizationUserStatusType.Confirmed + ou.Status == OrganizationUserStatusType.Confirmed group ou by ou.OrganizationId into g select new { - OrgUser = g.Select(x => new {x.UserId, x.Id}).FirstOrDefault(), ConfirmedOwnerCount = g.Count() + OrgUser = g.Select(x => new {x.UserId, x.Id}).FirstOrDefault(), + ConfirmedOwnerCount = g.Count(), }; var query = from owner in owners diff --git a/src/Core/Repositories/EntityFramework/Queries/ProviderUserReadCountByOnlyOwnerQuery.cs b/src/Core/Repositories/EntityFramework/Queries/ProviderUserReadCountByOnlyOwnerQuery.cs new file mode 100644 index 000000000..86e5296c3 --- /dev/null +++ b/src/Core/Repositories/EntityFramework/Queries/ProviderUserReadCountByOnlyOwnerQuery.cs @@ -0,0 +1,39 @@ +using System.Linq; +using System; +using Bit.Core.Enums.Provider; +using Bit.Core.Models.EntityFramework.Provider; + +namespace Bit.Core.Repositories.EntityFramework.Queries +{ + public class ProviderUserReadCountByOnlyOwnerQuery : IQuery + { + private readonly Guid _userId; + + public ProviderUserReadCountByOnlyOwnerQuery(Guid userId) + { + _userId = userId; + } + + public IQueryable Run(DatabaseContext dbContext) + { + var owners = from pu in dbContext.ProviderUsers + where pu.Type == ProviderUserType.ProviderAdmin && + pu.Status == ProviderUserStatusType.Confirmed + group pu by pu.ProviderId into g + select new + { + ProviderUser = g.Select(x => new {x.UserId, x.Id}).FirstOrDefault(), + ConfirmedOwnerCount = g.Count(), + }; + + var query = from owner in owners + join pu in dbContext.ProviderUsers + on owner.ProviderUser.Id equals pu.Id + where owner.ProviderUser.UserId == _userId && + owner.ConfirmedOwnerCount == 1 + select pu; + + return query; + } + } +} diff --git a/src/Core/Repositories/IProviderUserRepository.cs b/src/Core/Repositories/IProviderUserRepository.cs index 388b1e3da..754c2433e 100644 --- a/src/Core/Repositories/IProviderUserRepository.cs +++ b/src/Core/Repositories/IProviderUserRepository.cs @@ -20,5 +20,6 @@ namespace Bit.Core.Repositories Task> GetManyOrganizationDetailsByUserAsync(Guid userId, ProviderUserStatusType? status = null); Task DeleteManyAsync(IEnumerable userIds); Task> GetManyPublicKeysByProviderUserAsync(Guid providerId, IEnumerable Ids); + Task GetCountByOnlyOwnerAsync(Guid userId); } } diff --git a/src/Core/Repositories/SqlServer/ProviderUserRepository.cs b/src/Core/Repositories/SqlServer/ProviderUserRepository.cs index 3f62a74ca..9c7da61ad 100644 --- a/src/Core/Repositories/SqlServer/ProviderUserRepository.cs +++ b/src/Core/Repositories/SqlServer/ProviderUserRepository.cs @@ -151,5 +151,18 @@ namespace Bit.Core.Repositories.SqlServer return results.ToList(); } } + + public async Task GetCountByOnlyOwnerAsync(Guid userId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.ExecuteScalarAsync( + "[dbo].[ProviderUser_ReadCountByOnlyOwner]", + new { UserId = userId }, + commandType: CommandType.StoredProcedure); + + return results; + } + } } } diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 224496a50..d034f75ad 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -52,7 +52,7 @@ namespace Bit.Core.Services private readonly ICurrentContext _currentContext; private readonly GlobalSettings _globalSettings; private readonly IOrganizationService _organizationService; - private readonly ISendRepository _sendRepository; + private readonly IProviderUserRepository _providerUserRepository; public UserService( IUserRepository userRepository, @@ -81,7 +81,7 @@ namespace Bit.Core.Services ICurrentContext currentContext, GlobalSettings globalSettings, IOrganizationService organizationService, - ISendRepository sendRepository) + IProviderUserRepository providerUserRepository) : base( store, optionsAccessor, @@ -115,7 +115,7 @@ namespace Bit.Core.Services _currentContext = currentContext; _globalSettings = globalSettings; _organizationService = organizationService; - _sendRepository = sendRepository; + _providerUserRepository = providerUserRepository; } public Guid? GetProperUserId(ClaimsPrincipal principal) @@ -216,11 +216,20 @@ namespace Bit.Core.Services { return IdentityResult.Failed(new IdentityError { - Description = "You must leave or delete any organizations that you are the only owner of first." + Description = "Cannot delete this user because it is the sole owner of at least one organization. Please delete these organizations or upgrade another user.", }); } } + var onlyOwnerProviderCount = await _providerUserRepository.GetCountByOnlyOwnerAsync(user.Id); + if (onlyOwnerProviderCount > 0) + { + return IdentityResult.Failed(new IdentityError + { + Description = "Cannot delete this user because it is the sole owner of at least one provider. Please delete these providers or upgrade another user.", + }); + } + if (!string.IsNullOrWhiteSpace(user.GatewaySubscriptionId)) { try diff --git a/src/Sql/Sql.sqlproj b/src/Sql/Sql.sqlproj index 6aa9b6896..774f1f7e5 100644 --- a/src/Sql/Sql.sqlproj +++ b/src/Sql/Sql.sqlproj @@ -74,6 +74,7 @@ + diff --git a/src/Sql/dbo/Stored Procedures/ProviderUser_ReadCountByOnlyOwner.sql b/src/Sql/dbo/Stored Procedures/ProviderUser_ReadCountByOnlyOwner.sql new file mode 100644 index 000000000..09a8cac40 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/ProviderUser_ReadCountByOnlyOwner.sql @@ -0,0 +1,25 @@ +CREATE PROCEDURE [dbo].[ProviderUser_ReadCountByOnlyOwner] + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + ;WITH [OwnerCountCTE] AS + ( + SELECT + PU.[UserId], + COUNT(1) OVER (PARTITION BY PU.[ProviderId]) [ConfirmedOwnerCount] + FROM + [dbo].[ProviderUser] PU + WHERE + PU.[Type] = 0 -- 0 = ProviderAdmin + AND PU.[Status] = 2 -- 2 = Confirmed + ) + SELECT + COUNT(1) + FROM + [OwnerCountCTE] OC + WHERE + OC.[UserId] = @UserId + AND OC.[ConfirmedOwnerCount] = 1 +END diff --git a/src/Sql/dbo/Stored Procedures/User_DeleteById.sql b/src/Sql/dbo/Stored Procedures/User_DeleteById.sql index a39c5e9d2..a3002995f 100644 --- a/src/Sql/dbo/Stored Procedures/User_DeleteById.sql +++ b/src/Sql/dbo/Stored Procedures/User_DeleteById.sql @@ -65,6 +65,13 @@ BEGIN WHERE [UserId] = @Id + -- Delete provider users + DELETE + FROM + [dbo].[ProviderUser] + WHERE + [UserId] = @Id + -- Delete U2F logins DELETE FROM diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index 13fd5e6d8..0f375ec78 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -45,7 +45,7 @@ namespace Bit.Core.Test.Services private readonly CurrentContext _currentContext; private readonly GlobalSettings _globalSettings; private readonly IOrganizationService _organizationService; - private readonly ISendRepository _sendRepository; + private readonly IProviderUserRepository _providerUserRepository; public UserServiceTests() { @@ -75,7 +75,7 @@ namespace Bit.Core.Test.Services _currentContext = new CurrentContext(null); _globalSettings = new GlobalSettings(); _organizationService = Substitute.For(); - _sendRepository = Substitute.For(); + _providerUserRepository = Substitute.For(); _sut = new UserService( _userRepository, @@ -104,7 +104,7 @@ namespace Bit.Core.Test.Services _currentContext, _globalSettings, _organizationService, - _sendRepository + _providerUserRepository ); } diff --git a/util/Migrator/DbScripts/2021-09-10_00_DeleteProviderUser.sql b/util/Migrator/DbScripts/2021-09-10_00_DeleteProviderUser.sql new file mode 100644 index 000000000..70fddbe9c --- /dev/null +++ b/util/Migrator/DbScripts/2021-09-10_00_DeleteProviderUser.sql @@ -0,0 +1,153 @@ +IF OBJECT_ID('[dbo].[ProviderUser_ReadCountByOnlyOwner]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[ProviderUser_ReadCountByOnlyOwner] +END +GO + +CREATE PROCEDURE [dbo].[ProviderUser_ReadCountByOnlyOwner] +@UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + ;WITH [OwnerCountCTE] AS + ( + SELECT + PU.[UserId], + COUNT(1) OVER (PARTITION BY PU.[ProviderId]) [ConfirmedOwnerCount] + FROM + [dbo].[ProviderUser] PU + WHERE + PU.[Type] = 0 -- 0 = ProviderAdmin + AND PU.[Status] = 2 -- 2 = Confirmed + ) + SELECT + COUNT(1) + FROM + [OwnerCountCTE] OC + WHERE + OC.[UserId] = @UserId + AND OC.[ConfirmedOwnerCount] = 1 +END +GO + +IF OBJECT_ID('[dbo].[User_DeleteById]') IS NOT NULL + BEGIN + DROP PROCEDURE [dbo].[User_DeleteById] + END +GO + +CREATE PROCEDURE [dbo].[User_DeleteById] + @Id UNIQUEIDENTIFIER +WITH RECOMPILE +AS +BEGIN + SET NOCOUNT ON + DECLARE @BatchSize INT = 100 + + -- Delete ciphers + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION User_DeleteById_Ciphers + + DELETE TOP(@BatchSize) + FROM + [dbo].[Cipher] + WHERE + [UserId] = @Id + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION User_DeleteById_Ciphers + END + + BEGIN TRANSACTION User_DeleteById + + -- Delete folders + DELETE + FROM + [dbo].[Folder] + WHERE + [UserId] = @Id + + -- Delete devices + DELETE + FROM + [dbo].[Device] + WHERE + [UserId] = @Id + + -- Delete collection users + DELETE + CU + FROM + [dbo].[CollectionUser] CU + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = CU.[OrganizationUserId] + WHERE + OU.[UserId] = @Id + + -- Delete group users + DELETE + GU + FROM + [dbo].[GroupUser] GU + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = GU.[OrganizationUserId] + WHERE + OU.[UserId] = @Id + + -- Delete organization users + DELETE + FROM + [dbo].[OrganizationUser] + WHERE + [UserId] = @Id + + -- Delete provider users + DELETE + FROM + [dbo].[ProviderUser] + WHERE + [UserId] = @Id + + -- Delete U2F logins + DELETE + FROM + [dbo].[U2f] + WHERE + [UserId] = @Id + + -- Delete SSO Users + DELETE + FROM + [dbo].[SsoUser] + WHERE + [UserId] = @Id + + -- Delete Emergency Accesses + DELETE + FROM + [dbo].[EmergencyAccess] + WHERE + [GrantorId] = @Id + OR + [GranteeId] = @Id + + -- Delete Sends + DELETE + FROM + [dbo].[Send] + WHERE + [UserId] = @Id + + -- Finally, delete the user + DELETE + FROM + [dbo].[User] + WHERE + [Id] = @Id + + COMMIT TRANSACTION User_DeleteById +END +go