[PM-25947] Add folders and favorites when sharing a cipher (#6402)

* add folders and favorites when sharing a cipher

* refactor folders and favorites assignment to consider existing folders/favorite assignments on a cipher

* remove unneeded string manipulation

* remove comment

* add unit test for folder/favorite sharing

* add migration for sharing a cipher to org and collect reprompt, favorite and folders

* update date timestamp of migration
This commit is contained in:
Nick Krantz
2025-12-11 12:31:12 -06:00
committed by GitHub
parent e3d54060fe
commit 20755f6c2f
7 changed files with 395 additions and 4 deletions

View File

@@ -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);

View File

@@ -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,
};
}
/// <summary>
/// Updates a JSON string representing a dictionary by adding, updating, or removing a key-value pair
/// based on the provided userIdKey and newValue.
/// </summary>
private static string UpdateUserSpecificJsonField(string existingJson, string userIdKey, object newValue)
{
if (userIdKey == null)
{
return existingJson;
}
var jsonDict = string.IsNullOrWhiteSpace(existingJson)
? new Dictionary<string, object>()
: JsonSerializer.Deserialize<Dictionary<string, object>>(existingJson) ?? new Dictionary<string, object>();
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

View File

@@ -704,6 +704,9 @@ public class CipherRepository : Repository<Core.Vault.Entities.Cipher, Cipher, G
trackedCipher.RevisionDate = cipher.RevisionDate;
trackedCipher.DeletedDate = cipher.DeletedDate;
trackedCipher.Key = cipher.Key;
trackedCipher.Folders = cipher.Folders;
trackedCipher.Favorites = cipher.Favorites;
trackedCipher.Reprompt = cipher.Reprompt;
await transaction.CommitAsync();

View File

@@ -38,8 +38,13 @@ BEGIN
[Data] = @Data,
[Attachments] = @Attachments,
[RevisionDate] = @RevisionDate,
[DeletedDate] = @DeletedDate, [Key] = @Key, [ArchivedDate] = @ArchivedDate
-- No need to update CreationDate, Favorites, Folders, or Type since that data will not change
[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

View File

@@ -1909,4 +1909,237 @@ public class CiphersControllerTests
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.PostPurge(model, organizationId));
}
[Theory, BitAutoData]
public async Task PutShare_WithNullFolderAndFalseFavorite_UpdatesFieldsCorrectly(
Guid cipherId,
Guid userId,
Guid organizationId,
Guid folderId,
SutProvider<CiphersController> 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<string, object> { { userIdKey, folderId.ToString().ToUpperInvariant() } }),
Favorites = JsonSerializer.Serialize(new Dictionary<string, object> { { 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<IUserService>()
.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
.Returns(user);
sutProvider.GetDependency<ICipherRepository>()
.GetByIdAsync(cipherId)
.Returns(existingCipher);
sutProvider.GetDependency<ICurrentContext>()
.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<ICipherRepository>()
.GetByIdAsync(cipherId, userId)
.Returns(sharedCipher);
sutProvider.GetDependency<IApplicationCacheService>()
.GetOrganizationAbilitiesAsync()
.Returns(new Dictionary<Guid, OrganizationAbility>
{
{ 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<CiphersController> 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<IUserService>()
.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
.Returns(user);
sutProvider.GetDependency<ICipherRepository>()
.GetByIdAsync(cipherId)
.Returns(existingCipher);
sutProvider.GetDependency<ICurrentContext>()
.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<string, object> { { userIdKey, folderId.ToString().ToUpperInvariant() } }),
Favorites = JsonSerializer.Serialize(new Dictionary<string, object> { { userIdKey, true } }),
FolderId = folderId,
Favorite = true
};
sutProvider.GetDependency<ICipherRepository>()
.GetByIdAsync(cipherId, userId)
.Returns(sharedCipher);
sutProvider.GetDependency<IApplicationCacheService>()
.GetOrganizationAbilitiesAsync()
.Returns(new Dictionary<Guid, OrganizationAbility>
{
{ 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<CiphersController> 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<string, object> { { 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<IUserService>()
.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
.Returns(user);
sutProvider.GetDependency<ICipherRepository>()
.GetByIdAsync(cipherId)
.Returns(existingCipher);
sutProvider.GetDependency<ICurrentContext>()
.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<string, object> { { userIdKey, newFolderId.ToString().ToUpperInvariant() } }),
Favorites = JsonSerializer.Serialize(new Dictionary<string, object> { { userIdKey, true } }),
FolderId = newFolderId,
Favorite = true
};
sutProvider.GetDependency<ICipherRepository>()
.GetByIdAsync(cipherId, userId)
.Returns(sharedCipher);
sutProvider.GetDependency<IApplicationCacheService>()
.GetOrganizationAbilitiesAsync()
.Returns(new Dictionary<Guid, OrganizationAbility>
{
{ organizationId, new OrganizationAbility { Id = organizationId } }
});
var result = await sutProvider.Sut.PutShare(cipherId, model);
Assert.Equal(newFolderId, result.FolderId);
Assert.True(result.Favorite);
}
}

View File

@@ -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<EfVaultRepo.CipherRepository> suts,
List<EfRepo.UserRepository> efUserRepos,
List<EfRepo.OrganizationRepository> efOrgRepos,
List<EfRepo.CollectionRepository> 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<Guid> { 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);
}
}
}

View File

@@ -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