diff --git a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs index 37a830c92e..b17de3c51d 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs @@ -87,4 +87,13 @@ public interface IOrganizationUserRepository : IRepository> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role); Task CreateManyAsync(IEnumerable organizationUserCollection); + + /// + /// It will only confirm if the user is in the `Accepted` state. + /// + /// This is an idempotent operation. + /// + /// Accepted OrganizationUser to confirm + /// True, if the user was updated. False, if not performed. + Task ConfirmOrganizationUserAsync(OrganizationUser organizationUser); } diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs index 5f389ae56d..dc4fc74ff8 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -15,8 +15,6 @@ using Dapper; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; -#nullable enable - namespace Bit.Infrastructure.Dapper.Repositories; public class OrganizationUserRepository : Repository, IOrganizationUserRepository @@ -672,4 +670,20 @@ public class OrganizationUserRepository : Repository, IO }, commandType: CommandType.StoredProcedure); } + + public async Task ConfirmOrganizationUserAsync(OrganizationUser organizationUser) + { + await using var connection = new SqlConnection(_marsConnectionString); + + var rowCount = await connection.ExecuteScalarAsync( + $"[{Schema}].[OrganizationUser_ConfirmById]", + new + { + organizationUser.Id, + organizationUser.UserId, + RevisionDate = DateTime.UtcNow.Date + }); + + return rowCount > 0; + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs index fae0598c1c..b871ec44bf 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -942,4 +942,24 @@ public class OrganizationUserRepository : Repository ConfirmOrganizationUserAsync(Core.Entities.OrganizationUser organizationUser) + { + using var scope = ServiceScopeFactory.CreateScope(); + await using var dbContext = GetDatabaseContext(scope); + + var result = await dbContext.OrganizationUsers + .Where(ou => ou.Id == organizationUser.Id && ou.Status == OrganizationUserStatusType.Accepted) + .ExecuteUpdateAsync(x => + x.SetProperty(y => y.Status, OrganizationUserStatusType.Confirmed)); + + if (result <= 0) + { + return false; + } + + await dbContext.UserBumpAccountRevisionDateByOrganizationUserIdAsync(organizationUser.Id); + return true; + + } } diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_ConfirmById.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_ConfirmById.sql new file mode 100644 index 0000000000..004f1c93eb --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_ConfirmById.sql @@ -0,0 +1,28 @@ +CREATE PROCEDURE [dbo].[OrganizationUser_ConfirmById] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + DECLARE @RowCount INT; + + UPDATE + [dbo].[OrganizationUser] + SET + [Status] = 2, -- Set to Confirmed + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id + AND [Status] = 1 -- Only update if status is Accepted + + SET @RowCount = @@ROWCOUNT; + + IF @RowCount > 0 + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + END + + SELECT @RowCount; +END diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/OrganizationTestHelpers.cs b/test/Infrastructure.IntegrationTest/AdminConsole/OrganizationTestHelpers.cs index 2aee528260..4d5f99f846 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/OrganizationTestHelpers.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/OrganizationTestHelpers.cs @@ -69,6 +69,42 @@ public static class OrganizationTestHelpers Type = OrganizationUserType.Owner }); + public static Task CreateAcceptedTestOrganizationUserAsync( + this IOrganizationUserRepository organizationUserRepository, + Organization organization, + User user) + => organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Accepted, + Type = OrganizationUserType.Owner + }); + + public static Task CreateRevokedTestOrganizationUserAsync( + this IOrganizationUserRepository organizationUserRepository, + Organization organization, + User user) + => organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Revoked, + Type = OrganizationUserType.Owner + }); + + public static Task CreateConfirmedTestOrganizationUserAsync( + this IOrganizationUserRepository organizationUserRepository, + Organization organization, + User user) + => organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Owner + }); + public static Task CreateTestGroupAsync( this IGroupRepository groupRepository, Organization organization, @@ -81,9 +117,9 @@ public static class OrganizationTestHelpers this ICollectionRepository collectionRepository, Organization organization, string identifier = "test") - => collectionRepository.CreateAsync(new Collection - { - OrganizationId = organization.Id, - Name = $"{identifier} {Guid.NewGuid()}" - }); + => collectionRepository.CreateAsync(new Collection + { + OrganizationId = organization.Id, + Name = $"{identifier} {Guid.NewGuid()}" + }); } diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs index 042d354a87..a60a8e046c 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs @@ -1417,4 +1417,146 @@ public class OrganizationUserRepositoryTests // Regular collection should be removed Assert.DoesNotContain(actualCollections, c => c.Id == regularCollection.Id); } + + [Theory, DatabaseData] + public async Task ConfirmOrganizationUserAsync_WhenUserIsAccepted_ReturnsTrue(IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IUserRepository userRepository) + { + // Arrange + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var user = await userRepository.CreateTestUserAsync(); + var orgUser = await organizationUserRepository.CreateAcceptedTestOrganizationUserAsync(organization, user); + + // Act + var result = await organizationUserRepository.ConfirmOrganizationUserAsync(orgUser); + + // Assert + Assert.True(result); + var updatedUser = await organizationUserRepository.GetByIdAsync(orgUser.Id); + Assert.NotNull(updatedUser); + Assert.Equal(OrganizationUserStatusType.Confirmed, updatedUser.Status); + + // Annul + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteAsync(user); + } + + [Theory, DatabaseData] + public async Task ConfirmOrganizationUserAsync_WhenUserIsInvited_ReturnsFalse(IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository) + { + // Arrange + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var orgUser = await organizationUserRepository.CreateTestOrganizationUserInviteAsync(organization); + + // Act + var result = await organizationUserRepository.ConfirmOrganizationUserAsync(orgUser); + + // Assert + Assert.False(result); + var unchangedUser = await organizationUserRepository.GetByIdAsync(orgUser.Id); + Assert.NotNull(unchangedUser); + Assert.Equal(OrganizationUserStatusType.Invited, unchangedUser.Status); + + // Annul + await organizationRepository.DeleteAsync(organization); + } + + [Theory, DatabaseData] + public async Task ConfirmOrganizationUserAsync_WhenUserIsAlreadyConfirmed_ReturnsFalse(IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IUserRepository userRepository) + { + // Arrange + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var user = await userRepository.CreateTestUserAsync(); + var orgUser = await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user); + + // Act + var result = await organizationUserRepository.ConfirmOrganizationUserAsync(orgUser); + + // Assert + Assert.False(result); + var unchangedUser = await organizationUserRepository.GetByIdAsync(orgUser.Id); + Assert.NotNull(unchangedUser); + Assert.Equal(OrganizationUserStatusType.Confirmed, unchangedUser.Status); + + // Annul + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteAsync(user); + } + + [Theory, DatabaseData] + public async Task ConfirmOrganizationUserAsync_WhenUserIsRevoked_ReturnsFalse(IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IUserRepository userRepository) + { + // Arrange + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var user = await userRepository.CreateTestUserAsync(); + var orgUser = await organizationUserRepository.CreateRevokedTestOrganizationUserAsync(organization, user); + + // Act + var result = await organizationUserRepository.ConfirmOrganizationUserAsync(orgUser); + + // Assert + Assert.False(result); + var unchangedUser = await organizationUserRepository.GetByIdAsync(orgUser.Id); + Assert.NotNull(unchangedUser); + Assert.Equal(OrganizationUserStatusType.Revoked, unchangedUser.Status); + + // Annul + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteAsync(user); + } + + [Theory, DatabaseData] + public async Task ConfirmOrganizationUserAsync_IsIdempotent_WhenCalledMultipleTimes( + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IUserRepository userRepository) + { + // Arrange + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var user = await userRepository.CreateTestUserAsync(); + var orgUser = await organizationUserRepository.CreateAcceptedTestOrganizationUserAsync(organization, user); + + // Act - First call should confirm + var firstResult = await organizationUserRepository.ConfirmOrganizationUserAsync(orgUser); + var secondResult = await organizationUserRepository.ConfirmOrganizationUserAsync(orgUser); + + // Assert + Assert.True(firstResult); + Assert.False(secondResult); + var finalUser = await organizationUserRepository.GetByIdAsync(orgUser.Id); + Assert.NotNull(finalUser); + Assert.Equal(OrganizationUserStatusType.Confirmed, finalUser.Status); + + // Annul + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteAsync(user); + } + + [Theory, DatabaseData] + public async Task ConfirmOrganizationUserAsync_WhenUserDoesNotExist_ReturnsFalse( + IOrganizationUserRepository organizationUserRepository) + { + // Arrange + var nonExistentUser = new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + UserId = Guid.NewGuid(), + Email = "nonexistent@bitwarden.com", + Status = OrganizationUserStatusType.Accepted, + Type = OrganizationUserType.Owner + }; + + // Act + var result = await organizationUserRepository.ConfirmOrganizationUserAsync(nonExistentUser); + + // Assert + Assert.False(result); + } } diff --git a/util/Migrator/DbScripts/2025-10-15_00_OrgUserConfirmById.sql b/util/Migrator/DbScripts/2025-10-15_00_OrgUserConfirmById.sql new file mode 100644 index 0000000000..a64cd1401b --- /dev/null +++ b/util/Migrator/DbScripts/2025-10-15_00_OrgUserConfirmById.sql @@ -0,0 +1,28 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_ConfirmById] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + DECLARE @RowCount INT; + + UPDATE + [dbo].[OrganizationUser] + SET + [Status] = 2, -- Set to Confirmed + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id + AND [Status] = 1 -- Only update if status is Accepted + + SET @RowCount = @@ROWCOUNT; + + IF @RowCount > 0 + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + END + + SELECT @RowCount; +END