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