diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs index 9c5f2a39cd..33ffc453d5 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -516,9 +516,11 @@ public class OrganizationUserRepository : Repository cu.OrganizationUserId == obj.Id) - .ToListAsync(); + // Retrieve all collection assignments, excluding DefaultUserCollection + var existingCollectionUsers = await (from cu in dbContext.CollectionUsers + join c in dbContext.Collections on cu.CollectionId equals c.Id + where cu.OrganizationUserId == obj.Id && c.Type != CollectionType.DefaultUserCollection + select cu).ToListAsync(); foreach (var requestedCollection in requestedCollections) { diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql index 6486e002c3..e030958c3e 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql @@ -72,8 +72,11 @@ BEGIN CU FROM [dbo].[CollectionUser] CU + INNER JOIN + [dbo].[Collection] C ON C.[Id] = CU.[CollectionId] WHERE CU.[OrganizationUserId] = @Id + AND C.[Type] != 1 -- Don't delete default collections AND NOT EXISTS ( SELECT 1 diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs index febfca89e4..130add6332 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs @@ -1166,4 +1166,67 @@ public class OrganizationUserRepositoryTests Assert.NotNull(responseModel); Assert.Empty(responseModel); } + + [DatabaseTheory, DatabaseData] + public async Task ReplaceAsync_PreservesDefaultCollections_WhenUpdatingCollectionAccess( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository) + { + // Arrange + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var user = await userRepository.CreateTestUserAsync(); + var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user); + + // Create a regular collection and a default collection + var regularCollection = await collectionRepository.CreateTestCollectionAsync(organization); + + // Manually create default collection since CreateTestCollectionAsync doesn't support type parameter + var defaultCollection = new Collection + { + OrganizationId = organization.Id, + Name = $"Default Collection {Guid.NewGuid()}", + Type = CollectionType.DefaultUserCollection + }; + await collectionRepository.CreateAsync(defaultCollection); + + var newCollection = await collectionRepository.CreateTestCollectionAsync(organization); + + // Set up initial collection access: user has access to both regular and default collections + await organizationUserRepository.ReplaceAsync(orgUser, [ + new CollectionAccessSelection { Id = regularCollection.Id, ReadOnly = false, HidePasswords = false, Manage = false }, + new CollectionAccessSelection { Id = defaultCollection.Id, ReadOnly = false, HidePasswords = false, Manage = true } + ]); + + // Verify initial state + var (_, initialCollections) = await organizationUserRepository.GetByIdWithCollectionsAsync(orgUser.Id); + Assert.Equal(2, initialCollections.Count); + Assert.Contains(initialCollections, c => c.Id == regularCollection.Id); + Assert.Contains(initialCollections, c => c.Id == defaultCollection.Id); + + // Act: Update collection access with only the new collection + // This should preserve the default collection but remove the regular collection + await organizationUserRepository.ReplaceAsync(orgUser, [ + new CollectionAccessSelection { Id = newCollection.Id, ReadOnly = false, HidePasswords = false, Manage = true } + ]); + + // Assert + var (actualOrgUser, actualCollections) = await organizationUserRepository.GetByIdWithCollectionsAsync(orgUser.Id); + Assert.NotNull(actualOrgUser); + Assert.Equal(2, actualCollections.Count); // Should have default collection + new collection + + // Default collection should be preserved + var preservedDefaultCollection = actualCollections.FirstOrDefault(c => c.Id == defaultCollection.Id); + Assert.NotNull(preservedDefaultCollection); + Assert.True(preservedDefaultCollection.Manage); // Original permissions preserved + + // New collection should be added + var addedNewCollection = actualCollections.FirstOrDefault(c => c.Id == newCollection.Id); + Assert.NotNull(addedNewCollection); + Assert.True(addedNewCollection.Manage); + + // Regular collection should be removed + Assert.DoesNotContain(actualCollections, c => c.Id == regularCollection.Id); + } } diff --git a/util/Migrator/DbScripts/2025-07-04_00_PreserveDefaultCollectionsAccessOnUpdate.sql b/util/Migrator/DbScripts/2025-07-04_00_PreserveDefaultCollectionsAccessOnUpdate.sql new file mode 100644 index 0000000000..952a96d402 --- /dev/null +++ b/util/Migrator/DbScripts/2025-07-04_00_PreserveDefaultCollectionsAccessOnUpdate.sql @@ -0,0 +1,89 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_UpdateWithCollections] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @Email NVARCHAR(256), + @Key VARCHAR(MAX), + @Status SMALLINT, + @Type TINYINT, + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Permissions NVARCHAR(MAX), + @ResetPasswordKey VARCHAR(MAX), + @Collections AS [dbo].[CollectionAccessSelectionType] READONLY, + @AccessSecretsManager BIT = 0 +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[OrganizationUser_Update] @Id, @OrganizationId, @UserId, @Email, @Key, @Status, @Type, @ExternalId, @CreationDate, @RevisionDate, @Permissions, @ResetPasswordKey, @AccessSecretsManager + -- Update + UPDATE + [Target] + SET + [Target].[ReadOnly] = [Source].[ReadOnly], + [Target].[HidePasswords] = [Source].[HidePasswords], + [Target].[Manage] = [Source].[Manage] + FROM + [dbo].[CollectionUser] AS [Target] + INNER JOIN + @Collections AS [Source] ON [Source].[Id] = [Target].[CollectionId] + WHERE + [Target].[OrganizationUserId] = @Id + AND ( + [Target].[ReadOnly] != [Source].[ReadOnly] + OR [Target].[HidePasswords] != [Source].[HidePasswords] + OR [Target].[Manage] != [Source].[Manage] + ) + + -- Insert + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + [Source].[Id], + @Id, + [Source].[ReadOnly], + [Source].[HidePasswords], + [Source].[Manage] + FROM + @Collections AS [Source] + INNER JOIN + [dbo].[Collection] C ON C.[Id] = [Source].[Id] AND C.[OrganizationId] = @OrganizationId + WHERE + NOT EXISTS ( + SELECT + 1 + FROM + [dbo].[CollectionUser] + WHERE + [CollectionId] = [Source].[Id] + AND [OrganizationUserId] = @Id + ) + + -- Delete + DELETE + CU + FROM + [dbo].[CollectionUser] CU + INNER JOIN + [dbo].[Collection] C ON C.[Id] = CU.[CollectionId] + WHERE + CU.[OrganizationUserId] = @Id + AND C.[Type] != 1 -- Don't delete default collections + AND NOT EXISTS ( + SELECT + 1 + FROM + @Collections + WHERE + [Id] = CU.[CollectionId] + ) +END +GO