diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 8c5df96262..6a506cc01f 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -760,7 +760,7 @@ public class CiphersController : Controller ValidateClientVersionForFido2CredentialSupport(cipher); var original = cipher.Clone(); - await _cipherService.ShareAsync(original, model.Cipher.ToCipher(cipher), new Guid(model.Cipher.OrganizationId), + await _cipherService.ShareAsync(original, model.Cipher.ToCipher(cipher, user.Id), new Guid(model.Cipher.OrganizationId), model.CollectionIds.Select(c => new Guid(c)), user.Id, model.Cipher.LastKnownRevisionDate); var sharedCipher = await GetByIdAsync(id, user.Id); diff --git a/src/Api/Vault/Models/Request/CipherRequestModel.cs b/src/Api/Vault/Models/Request/CipherRequestModel.cs index b0589a62f9..18a1aec559 100644 --- a/src/Api/Vault/Models/Request/CipherRequestModel.cs +++ b/src/Api/Vault/Models/Request/CipherRequestModel.cs @@ -84,7 +84,7 @@ public class CipherRequestModel return existingCipher; } - public Cipher ToCipher(Cipher existingCipher) + public Cipher ToCipher(Cipher existingCipher, Guid? userId = null) { // If Data field is provided, use it directly if (!string.IsNullOrWhiteSpace(Data)) @@ -124,9 +124,12 @@ public class CipherRequestModel } } + var userIdKey = userId.HasValue ? userId.ToString().ToUpperInvariant() : null; existingCipher.Reprompt = Reprompt; existingCipher.Key = Key; existingCipher.ArchivedDate = ArchivedDate; + existingCipher.Folders = UpdateUserSpecificJsonField(existingCipher.Folders, userIdKey, FolderId); + existingCipher.Favorites = UpdateUserSpecificJsonField(existingCipher.Favorites, userIdKey, Favorite); var hasAttachments2 = (Attachments2?.Count ?? 0) > 0; var hasAttachments = (Attachments?.Count ?? 0) > 0; @@ -291,6 +294,37 @@ public class CipherRequestModel KeyFingerprint = SSHKey.KeyFingerprint, }; } + + /// + /// Updates a JSON string representing a dictionary by adding, updating, or removing a key-value pair + /// based on the provided userIdKey and newValue. + /// + private static string UpdateUserSpecificJsonField(string existingJson, string userIdKey, object newValue) + { + if (userIdKey == null) + { + return existingJson; + } + + var jsonDict = string.IsNullOrWhiteSpace(existingJson) + ? new Dictionary() + : JsonSerializer.Deserialize>(existingJson) ?? new Dictionary(); + + var shouldRemove = newValue == null || + (newValue is string strValue && string.IsNullOrWhiteSpace(strValue)) || + (newValue is bool boolValue && !boolValue); + + if (shouldRemove) + { + jsonDict.Remove(userIdKey); + } + else + { + jsonDict[userIdKey] = newValue is string str ? str.ToUpperInvariant() : newValue; + } + + return jsonDict.Count == 0 ? null : JsonSerializer.Serialize(jsonDict); + } } public class CipherWithIdRequestModel : CipherRequestModel diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs index 3c45afe530..ebe39852f4 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs @@ -704,6 +704,9 @@ public class CipherRepository : Repository(() => sutProvider.Sut.PostPurge(model, organizationId)); } + + [Theory, BitAutoData] + public async Task PutShare_WithNullFolderAndFalseFavorite_UpdatesFieldsCorrectly( + Guid cipherId, + Guid userId, + Guid organizationId, + Guid folderId, + SutProvider sutProvider) + { + var user = new User { Id = userId }; + var userIdKey = userId.ToString().ToUpperInvariant(); + + var existingCipher = new Cipher + { + Id = cipherId, + UserId = userId, + Type = CipherType.Login, + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + Folders = JsonSerializer.Serialize(new Dictionary { { userIdKey, folderId.ToString().ToUpperInvariant() } }), + Favorites = JsonSerializer.Serialize(new Dictionary { { userIdKey, true } }) + }; + + // Clears folder and favorite when sharing + var model = new CipherShareRequestModel + { + Cipher = new CipherRequestModel + { + Type = CipherType.Login, + OrganizationId = organizationId.ToString(), + Name = "SharedCipher", + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + FolderId = null, + Favorite = false, + EncryptedFor = userId + }, + CollectionIds = [Guid.NewGuid().ToString()] + }; + + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + sutProvider.GetDependency() + .GetByIdAsync(cipherId) + .Returns(existingCipher); + + sutProvider.GetDependency() + .OrganizationUser(organizationId) + .Returns(true); + + var sharedCipher = new CipherDetails + { + Id = cipherId, + OrganizationId = organizationId, + Type = CipherType.Login, + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + FolderId = null, + Favorite = false + }; + + sutProvider.GetDependency() + .GetByIdAsync(cipherId, userId) + .Returns(sharedCipher); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(new Dictionary + { + { organizationId, new OrganizationAbility { Id = organizationId } } + }); + + var result = await sutProvider.Sut.PutShare(cipherId, model); + + Assert.Null(result.FolderId); + Assert.False(result.Favorite); + } + + [Theory, BitAutoData] + public async Task PutShare_WithFolderAndFavoriteSet_AddsUserSpecificFields( + Guid cipherId, + Guid userId, + Guid organizationId, + Guid folderId, + SutProvider sutProvider) + { + var user = new User { Id = userId }; + var userIdKey = userId.ToString().ToUpperInvariant(); + + var existingCipher = new Cipher + { + Id = cipherId, + UserId = userId, + Type = CipherType.Login, + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + Folders = null, + Favorites = null + }; + + // Sets folder and favorite when sharing + var model = new CipherShareRequestModel + { + Cipher = new CipherRequestModel + { + Type = CipherType.Login, + OrganizationId = organizationId.ToString(), + Name = "SharedCipher", + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + FolderId = folderId.ToString(), + Favorite = true, + EncryptedFor = userId + }, + CollectionIds = [Guid.NewGuid().ToString()] + }; + + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + sutProvider.GetDependency() + .GetByIdAsync(cipherId) + .Returns(existingCipher); + + sutProvider.GetDependency() + .OrganizationUser(organizationId) + .Returns(true); + + var sharedCipher = new CipherDetails + { + Id = cipherId, + OrganizationId = organizationId, + Type = CipherType.Login, + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + Folders = JsonSerializer.Serialize(new Dictionary { { userIdKey, folderId.ToString().ToUpperInvariant() } }), + Favorites = JsonSerializer.Serialize(new Dictionary { { userIdKey, true } }), + FolderId = folderId, + Favorite = true + }; + + sutProvider.GetDependency() + .GetByIdAsync(cipherId, userId) + .Returns(sharedCipher); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(new Dictionary + { + { organizationId, new OrganizationAbility { Id = organizationId } } + }); + + var result = await sutProvider.Sut.PutShare(cipherId, model); + + Assert.Equal(folderId, result.FolderId); + Assert.True(result.Favorite); + } + + [Theory, BitAutoData] + public async Task PutShare_UpdateExistingFolderAndFavorite_UpdatesUserSpecificFields( + Guid cipherId, + Guid userId, + Guid organizationId, + Guid oldFolderId, + Guid newFolderId, + SutProvider sutProvider) + { + var user = new User { Id = userId }; + var userIdKey = userId.ToString().ToUpperInvariant(); + + // Existing cipher with old folder and not favorited + var existingCipher = new Cipher + { + Id = cipherId, + UserId = userId, + Type = CipherType.Login, + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + Folders = JsonSerializer.Serialize(new Dictionary { { userIdKey, oldFolderId.ToString().ToUpperInvariant() } }), + Favorites = null + }; + + var model = new CipherShareRequestModel + { + Cipher = new CipherRequestModel + { + Type = CipherType.Login, + OrganizationId = organizationId.ToString(), + Name = "SharedCipher", + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + FolderId = newFolderId.ToString(), // Update to new folder + Favorite = true, // Add favorite + EncryptedFor = userId + }, + CollectionIds = [Guid.NewGuid().ToString()] + }; + + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + sutProvider.GetDependency() + .GetByIdAsync(cipherId) + .Returns(existingCipher); + + sutProvider.GetDependency() + .OrganizationUser(organizationId) + .Returns(true); + + var sharedCipher = new CipherDetails + { + Id = cipherId, + OrganizationId = organizationId, + Type = CipherType.Login, + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + Folders = JsonSerializer.Serialize(new Dictionary { { userIdKey, newFolderId.ToString().ToUpperInvariant() } }), + Favorites = JsonSerializer.Serialize(new Dictionary { { userIdKey, true } }), + FolderId = newFolderId, + Favorite = true + }; + + sutProvider.GetDependency() + .GetByIdAsync(cipherId, userId) + .Returns(sharedCipher); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(new Dictionary + { + { organizationId, new OrganizationAbility { Id = organizationId } } + }); + + var result = await sutProvider.Sut.PutShare(cipherId, model); + + Assert.Equal(newFolderId, result.FolderId); + Assert.True(result.Favorite); + } } diff --git a/test/Infrastructure.EFIntegration.Test/Vault/Repositories/CipherRepositoryTests.cs b/test/Infrastructure.EFIntegration.Test/Vault/Repositories/CipherRepositoryTests.cs index 689bd5e243..5aceb15124 100644 --- a/test/Infrastructure.EFIntegration.Test/Vault/Repositories/CipherRepositoryTests.cs +++ b/test/Infrastructure.EFIntegration.Test/Vault/Repositories/CipherRepositoryTests.cs @@ -225,4 +225,58 @@ public class CipherRepositoryTests Assert.True(savedCipher == null); } } + + [CiSkippedTheory, EfOrganizationCipherCustomize, BitAutoData] + public async Task ReplaceAsync_WithCollections_UpdatesFoldersFavoritesRepromptAndArchivedDateAsync( + Cipher cipher, + User user, + Organization org, + Collection collection, + List suts, + List efUserRepos, + List efOrgRepos, + List efCollectionRepos) + { + foreach (var sut in suts) + { + var i = suts.IndexOf(sut); + + var postEfOrg = await efOrgRepos[i].CreateAsync(org); + efOrgRepos[i].ClearChangeTracking(); + var postEfUser = await efUserRepos[i].CreateAsync(user); + efUserRepos[i].ClearChangeTracking(); + + collection.OrganizationId = postEfOrg.Id; + var postEfCollection = await efCollectionRepos[i].CreateAsync(collection); + efCollectionRepos[i].ClearChangeTracking(); + + cipher.UserId = postEfUser.Id; + cipher.OrganizationId = null; + cipher.Folders = $"{{\"{postEfUser.Id}\":\"some-folder-id\"}}"; + cipher.Favorites = $"{{\"{postEfUser.Id}\":true}}"; + cipher.Reprompt = Core.Vault.Enums.CipherRepromptType.Password; + + var createdCipher = await sut.CreateAsync(cipher); + sut.ClearChangeTracking(); + + var updatedCipher = await sut.GetByIdAsync(createdCipher.Id); + updatedCipher.UserId = postEfUser.Id; + updatedCipher.OrganizationId = postEfOrg.Id; + updatedCipher.Folders = $"{{\"{postEfUser.Id}\":\"new-folder-id\"}}"; + updatedCipher.Favorites = $"{{\"{postEfUser.Id}\":true}}"; + updatedCipher.Reprompt = Core.Vault.Enums.CipherRepromptType.Password; + + await sut.ReplaceAsync(updatedCipher, new List { postEfCollection.Id }); + sut.ClearChangeTracking(); + + + var savedCipher = await sut.GetByIdAsync(createdCipher.Id); + Assert.NotNull(savedCipher); + Assert.Null(savedCipher.UserId); + Assert.Equal(postEfOrg.Id, savedCipher.OrganizationId); + Assert.Equal($"{{\"{postEfUser.Id}\":\"new-folder-id\"}}", savedCipher.Folders); + Assert.Equal($"{{\"{postEfUser.Id}\":true}}", savedCipher.Favorites); + Assert.Equal(Core.Vault.Enums.CipherRepromptType.Password, savedCipher.Reprompt); + } + } } diff --git a/util/Migrator/DbScripts/2025-12-09_00_ShareFavoriteFolderReprompt.sql b/util/Migrator/DbScripts/2025-12-09_00_ShareFavoriteFolderReprompt.sql new file mode 100644 index 0000000000..6d4ea668a3 --- /dev/null +++ b/util/Migrator/DbScripts/2025-12-09_00_ShareFavoriteFolderReprompt.sql @@ -0,0 +1,62 @@ +CREATE OR ALTER PROCEDURE [dbo].[Cipher_UpdateWithCollections] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Data NVARCHAR(MAX), + @Favorites NVARCHAR(MAX), + @Folders NVARCHAR(MAX), + @Attachments NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @DeletedDate DATETIME2(7), + @Reprompt TINYINT, + @Key VARCHAR(MAX) = NULL, + @CollectionIds AS [dbo].[GuidIdArray] READONLY, + @ArchivedDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + BEGIN TRANSACTION Cipher_UpdateWithCollections + + DECLARE @UpdateCollectionsSuccess INT + EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds + + IF @UpdateCollectionsSuccess < 0 + BEGIN + COMMIT TRANSACTION Cipher_UpdateWithCollections + SELECT -1 -- -1 = Failure + RETURN + END + + UPDATE + [dbo].[Cipher] + SET + [UserId] = NULL, + [OrganizationId] = @OrganizationId, + [Data] = @Data, + [Attachments] = @Attachments, + [RevisionDate] = @RevisionDate, + [DeletedDate] = @DeletedDate, + [Key] = @Key, + [ArchivedDate] = @ArchivedDate, + [Folders] = @Folders, + [Favorites] = @Favorites, + [Reprompt] = @Reprompt + -- No need to update CreationDate or Type since that data will not change + WHERE + [Id] = @Id + + COMMIT TRANSACTION Cipher_UpdateWithCollections + + IF @Attachments IS NOT NULL + BEGIN + EXEC [dbo].[Organization_UpdateStorage] @OrganizationId + EXEC [dbo].[User_UpdateStorage] @UserId + END + + EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId + + SELECT 0 -- 0 = Success +END