1
0
mirror of https://github.com/bitwarden/server synced 2026-01-16 15:33:19 +00:00

[PM-27884][PM-27886][PM-27885] - Add Cipher Archives (#6578)

* add Archives column to ciphers table

* add archives column

* update cipher archive/unarchive and cipher deatils query

* add migrations

* add missing migrations

* fixes

* update tests. cleanup

* syntax fix

* fix sql syntax

* fix sql

* fix CreateWithCollections

* fix sql

* fix migration file

* fix migration

* add go

* add missing go

* fix migrations

* add missing proc

* fix migrations

* implement claude suggestions

* fix test

* update cipher service and tests

* updates to soft delete

* update UserCipherDetailsQuery and migration

* update migration

* update archive ciphers command to allow org ciphers to be archived

* updates to archivedDate

* revert change to UserCipherDetails

* updates to migration and procs

* remove archivedDate from Cipher_CreateWithCollections

* remove trailing comma

* fix syntax errors

* fix migration

* add double quotes around datetime

* fix syntax error

* remove archivedDate from cipher entity

* re-add ArchivedDate into cipher

* fix migration

* do not set Cipher.ArchivedDate in CipherRepository

* re-add ArchivedDate until removed from the db

* set defaults

* change to CREATE OR ALTER

* fix migration

* fix migration file

* quote datetime

* fix existing archiveAsync test. add additional test

* quote datetime

* update migration

* do not wrap datetime in quotes

* do not wrap datetime in quotes

* fix migration

* clean up archives and archivedDate from procs

* fix UserCipherDetailsQuery

* fix setting date in JSON_MODIFY

* prefer cast over convert

* fix cipher response model

* re-add ArchivedDate

* add new keyword

* remove ArchivedDate from entity

* use custom parameters for CipherDetails_CreateWithCollections

* remove reference to archivedDate

* add missing param

* add missing param

* fix params

* fix cipher repository

* fix migration file

* update request/response models

* update migration

* remove Archives from Cipher_CreateWithCollections

* revert last change

* clean up

* remove comment

* remove column in migration

* change language in drop

* wrap in brackets

* put drop column in separate migration

* remove archivedDate column

* re-add archivedDate

* add refresh module

* bump migration name

* fix proc and migration

* do not require edit permission for archiving ciphers

* do not require edit permission for unarchiving ciphers
This commit is contained in:
Jordan Aasen
2026-01-07 09:29:10 -08:00
committed by GitHub
parent afd47ad085
commit 02c03f4493
38 changed files with 11354 additions and 79 deletions

View File

@@ -903,7 +903,7 @@ public class CiphersController : Controller
[HttpPut("{id}/archive")]
[RequireFeature(FeatureFlagKeys.ArchiveVaultItems)]
public async Task<CipherMiniResponseModel> PutArchive(Guid id)
public async Task<CipherResponseModel> PutArchive(Guid id)
{
var userId = _userService.GetProperUserId(User).Value;
@@ -914,12 +914,16 @@ public class CiphersController : Controller
throw new BadRequestException("Cipher was not archived. Ensure the provided ID is correct and you have permission to archive it.");
}
return new CipherMiniResponseModel(archivedCipherOrganizationDetails.First(), _globalSettings, archivedCipherOrganizationDetails.First().OrganizationUseTotp);
return new CipherResponseModel(archivedCipherOrganizationDetails.First(),
await _userService.GetUserByPrincipalAsync(User),
await _applicationCacheService.GetOrganizationAbilitiesAsync(),
_globalSettings
);
}
[HttpPut("archive")]
[RequireFeature(FeatureFlagKeys.ArchiveVaultItems)]
public async Task<ListResponseModel<CipherMiniResponseModel>> PutArchiveMany([FromBody] CipherBulkArchiveRequestModel model)
public async Task<ListResponseModel<CipherResponseModel>> PutArchiveMany([FromBody] CipherBulkArchiveRequestModel model)
{
if (!_globalSettings.SelfHosted && model.Ids.Count() > 500)
{
@@ -927,6 +931,7 @@ public class CiphersController : Controller
}
var userId = _userService.GetProperUserId(User).Value;
var user = await _userService.GetUserByPrincipalAsync(User);
var cipherIdsToArchive = new HashSet<Guid>(model.Ids);
@@ -937,9 +942,14 @@ public class CiphersController : Controller
throw new BadRequestException("No ciphers were archived. Ensure the provided IDs are correct and you have permission to archive them.");
}
var responses = archivedCiphers.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp));
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
var responses = archivedCiphers.Select(c => new CipherResponseModel(c,
user,
organizationAbilities,
_globalSettings
));
return new ListResponseModel<CipherMiniResponseModel>(responses);
return new ListResponseModel<CipherResponseModel>(responses);
}
[HttpDelete("{id}")]
@@ -1101,7 +1111,7 @@ public class CiphersController : Controller
[HttpPut("{id}/unarchive")]
[RequireFeature(FeatureFlagKeys.ArchiveVaultItems)]
public async Task<CipherMiniResponseModel> PutUnarchive(Guid id)
public async Task<CipherResponseModel> PutUnarchive(Guid id)
{
var userId = _userService.GetProperUserId(User).Value;
@@ -1112,12 +1122,16 @@ public class CiphersController : Controller
throw new BadRequestException("Cipher was not unarchived. Ensure the provided ID is correct and you have permission to archive it.");
}
return new CipherMiniResponseModel(unarchivedCipherDetails.First(), _globalSettings, unarchivedCipherDetails.First().OrganizationUseTotp);
return new CipherResponseModel(unarchivedCipherDetails.First(),
await _userService.GetUserByPrincipalAsync(User),
await _applicationCacheService.GetOrganizationAbilitiesAsync(),
_globalSettings
);
}
[HttpPut("unarchive")]
[RequireFeature(FeatureFlagKeys.ArchiveVaultItems)]
public async Task<ListResponseModel<CipherMiniResponseModel>> PutUnarchiveMany([FromBody] CipherBulkUnarchiveRequestModel model)
public async Task<ListResponseModel<CipherResponseModel>> PutUnarchiveMany([FromBody] CipherBulkUnarchiveRequestModel model)
{
if (!_globalSettings.SelfHosted && model.Ids.Count() > 500)
{
@@ -1125,6 +1139,8 @@ public class CiphersController : Controller
}
var userId = _userService.GetProperUserId(User).Value;
var user = await _userService.GetUserByPrincipalAsync(User);
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
var cipherIdsToUnarchive = new HashSet<Guid>(model.Ids);
@@ -1135,9 +1151,9 @@ public class CiphersController : Controller
throw new BadRequestException("Ciphers were not unarchived. Ensure the provided ID is correct and you have permission to archive it.");
}
var responses = unarchivedCipherOrganizationDetails.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp));
var responses = unarchivedCipherOrganizationDetails.Select(c => new CipherResponseModel(c, user, organizationAbilities, _globalSettings));
return new ListResponseModel<CipherMiniResponseModel>(responses);
return new ListResponseModel<CipherResponseModel>(responses);
}
[HttpPut("{id}/restore")]

View File

@@ -80,6 +80,7 @@ public class CipherRequestModel
{
existingCipher.FolderId = string.IsNullOrWhiteSpace(FolderId) ? null : (Guid?)new Guid(FolderId);
existingCipher.Favorite = Favorite;
existingCipher.ArchivedDate = ArchivedDate;
ToCipher(existingCipher);
return existingCipher;
}
@@ -127,9 +128,9 @@ 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);
existingCipher.Archives = UpdateUserSpecificJsonField(existingCipher.Archives, userIdKey, ArchivedDate);
var hasAttachments2 = (Attachments2?.Count ?? 0) > 0;
var hasAttachments = (Attachments?.Count ?? 0) > 0;

View File

@@ -70,7 +70,6 @@ public class CipherMiniResponseModel : ResponseModel
DeletedDate = cipher.DeletedDate;
Reprompt = cipher.Reprompt.GetValueOrDefault(CipherRepromptType.None);
Key = cipher.Key;
ArchivedDate = cipher.ArchivedDate;
}
public Guid Id { get; set; }
@@ -111,7 +110,6 @@ public class CipherMiniResponseModel : ResponseModel
public DateTime? DeletedDate { get; set; }
public CipherRepromptType Reprompt { get; set; }
public string Key { get; set; }
public DateTime? ArchivedDate { get; set; }
}
public class CipherResponseModel : CipherMiniResponseModel
@@ -127,6 +125,7 @@ public class CipherResponseModel : CipherMiniResponseModel
FolderId = cipher.FolderId;
Favorite = cipher.Favorite;
Edit = cipher.Edit;
ArchivedDate = cipher.ArchivedDate;
ViewPassword = cipher.ViewPassword;
Permissions = new CipherPermissionsResponseModel(user, cipher, organizationAbilities);
}
@@ -135,6 +134,7 @@ public class CipherResponseModel : CipherMiniResponseModel
public bool Favorite { get; set; }
public bool Edit { get; set; }
public bool ViewPassword { get; set; }
public DateTime? ArchivedDate { get; set; }
public CipherPermissionsResponseModel Permissions { get; set; }
}

View File

@@ -37,7 +37,7 @@ public class ArchiveCiphersCommand : IArchiveCiphersCommand
}
var archivingCiphers = ciphers
.Where(c => cipherIdsSet.Contains(c.Id) && c is { Edit: true, OrganizationId: null, ArchivedDate: null })
.Where(c => cipherIdsSet.Contains(c.Id) && c is { ArchivedDate: null })
.ToList();
var revisionDate = await _cipherRepository.ArchiveAsync(archivingCiphers.Select(c => c.Id), archivingUserId);

View File

@@ -37,7 +37,7 @@ public class UnarchiveCiphersCommand : IUnarchiveCiphersCommand
}
var unarchivingCiphers = ciphers
.Where(c => cipherIdsSet.Contains(c.Id) && c is { Edit: true, ArchivedDate: not null })
.Where(c => cipherIdsSet.Contains(c.Id) && c is { ArchivedDate: not null })
.ToList();
var revisionDate =

View File

@@ -25,7 +25,7 @@ public class Cipher : ITableObject<Guid>, ICloneable
public DateTime? DeletedDate { get; set; }
public Enums.CipherRepromptType? Reprompt { get; set; }
public string Key { get; set; }
public DateTime? ArchivedDate { get; set; }
public string Archives { get; set; }
public void SetNewId()
{

View File

@@ -9,7 +9,8 @@ public class CipherDetails : CipherOrganizationDetails
public bool Edit { get; set; }
public bool ViewPassword { get; set; }
public bool Manage { get; set; }
// Per-user archived date from Archives JSON.
public DateTime? ArchivedDate { get; set; }
public CipherDetails() { }
public CipherDetails(CipherOrganizationDetails cipher)
@@ -51,6 +52,7 @@ public class CipherDetailsWithCollections : CipherDetails
Reprompt = cipher.Reprompt;
Key = cipher.Key;
FolderId = cipher.FolderId;
ArchivedDate = cipher.ArchivedDate;
Favorite = cipher.Favorite;
Edit = cipher.Edit;
ViewPassword = cipher.ViewPassword;

View File

@@ -218,8 +218,8 @@ public static class BulkResourceCreationService
ciphersTable.Columns.Add(revisionDateColumn);
var deletedDateColumn = new DataColumn(nameof(c.DeletedDate), typeof(DateTime));
ciphersTable.Columns.Add(deletedDateColumn);
var archivedDateColumn = new DataColumn(nameof(c.ArchivedDate), typeof(DateTime));
ciphersTable.Columns.Add(archivedDateColumn);
var archivesColumn = new DataColumn(nameof(c.Archives), typeof(string));
ciphersTable.Columns.Add(archivesColumn);
var repromptColumn = new DataColumn(nameof(c.Reprompt), typeof(short));
ciphersTable.Columns.Add(repromptColumn);
var keyColummn = new DataColumn(nameof(c.Key), typeof(string));
@@ -249,7 +249,7 @@ public static class BulkResourceCreationService
row[creationDateColumn] = cipher.CreationDate;
row[revisionDateColumn] = cipher.RevisionDate;
row[deletedDateColumn] = cipher.DeletedDate.HasValue ? (object)cipher.DeletedDate : DBNull.Value;
row[archivedDateColumn] = cipher.ArchivedDate.HasValue ? cipher.ArchivedDate : DBNull.Value;
row[archivesColumn] = cipher.Archives;
row[repromptColumn] = cipher.Reprompt.HasValue ? cipher.Reprompt.Value : DBNull.Value;
row[keyColummn] = cipher.Key;

View File

@@ -5,7 +5,6 @@ using System.Text.Json;
using Bit.Core.Enums;
using Bit.Core.Vault.Models.Data;
using Bit.Infrastructure.EntityFramework.Vault.Models;
namespace Bit.Infrastructure.EntityFramework.Repositories.Queries;
public class UserCipherDetailsQuery : IQuery<CipherDetails>
@@ -72,7 +71,7 @@ public class UserCipherDetailsQuery : IQuery<CipherDetails>
OrganizationUseTotp = o.UseTotp,
c.Reprompt,
c.Key,
c.ArchivedDate
c.Archives
};
var query2 = from c in dbContext.Ciphers
@@ -96,7 +95,7 @@ public class UserCipherDetailsQuery : IQuery<CipherDetails>
OrganizationUseTotp = false,
c.Reprompt,
c.Key,
c.ArchivedDate
c.Archives
};
var union = query.Union(query2).Select(c => new CipherDetails
@@ -118,11 +117,32 @@ public class UserCipherDetailsQuery : IQuery<CipherDetails>
Manage = c.Manage,
OrganizationUseTotp = c.OrganizationUseTotp,
Key = c.Key,
ArchivedDate = c.ArchivedDate
ArchivedDate = GetArchivedDate(_userId, new Cipher { Id = c.Id, Archives = c.Archives })
});
return union;
}
private static DateTime? GetArchivedDate(Guid? userId, Cipher cipher)
{
try
{
if (userId.HasValue && !string.IsNullOrWhiteSpace(cipher.Archives))
{
var archives = JsonSerializer.Deserialize<Dictionary<Guid, DateTime>>(cipher.Archives);
if (archives.TryGetValue(userId.Value, out var archivedDate))
{
return archivedDate;
}
}
return null;
}
catch
{
return null;
}
}
private static Guid? GetFolderId(Guid? userId, Cipher cipher)
{
try

View File

@@ -811,7 +811,29 @@ public class CipherRepository : Repository<Core.Vault.Entities.Cipher, Cipher, G
await cipherEntitiesToModify.ForEachAsync(cipher =>
{
dbContext.Attach(cipher);
cipher.ArchivedDate = action == CipherStateAction.Unarchive ? null : utcNow;
// Build or load the per-user archives map
var archives = string.IsNullOrWhiteSpace(cipher.Archives)
? new Dictionary<Guid, DateTime>()
: CoreHelpers.LoadClassFromJsonData<Dictionary<Guid, DateTime>>(cipher.Archives)
?? new Dictionary<Guid, DateTime>();
if (action == CipherStateAction.Unarchive)
{
// Remove this user's archive record
archives.Remove(userId);
}
else if (action == CipherStateAction.Archive)
{
// Set this user's archive date
archives[userId] = utcNow;
}
// Persist the updated JSON or clear it if empty
cipher.Archives = archives.Count == 0
? null
: CoreHelpers.ClassToJsonData(archives);
cipher.RevisionDate = utcNow;
});

View File

@@ -34,7 +34,9 @@ public class CipherDetailsQuery : IQuery<CipherDetails>
FolderId = (_ignoreFolders || !_userId.HasValue || c.Folders == null || !c.Folders.ToLowerInvariant().Contains(_userId.Value.ToString())) ?
null :
CoreHelpers.LoadClassFromJsonData<Dictionary<Guid, Guid>>(c.Folders)[_userId.Value],
ArchivedDate = c.ArchivedDate,
ArchivedDate = !_userId.HasValue || c.Archives == null || !c.Archives.ToLowerInvariant().Contains(_userId.Value.ToString()) ?
null :
CoreHelpers.LoadClassFromJsonData<Dictionary<Guid, DateTime?>>(c.Archives)[_userId.Value],
};
return query;
}

View File

@@ -28,6 +28,12 @@ SELECT
C.[DeletedDate],
C.[Reprompt],
C.[Key],
C.[ArchivedDate]
CASE
WHEN
@UserId IS NULL
OR C.[Archives] IS NULL
THEN NULL
ELSE TRY_CONVERT(DATETIME2(7), JSON_VALUE(C.[Archives], CONCAT('$."', @UserId, '"')))
END [ArchivedDate]
FROM
[dbo].[Cipher] C
[dbo].[Cipher] C;

View File

@@ -19,9 +19,9 @@ SELECT
ELSE 0
END [Edit],
CASE
WHEN COALESCE(CU.[HidePasswords], CG.[HidePasswords], 0) = 0
THEN 1
ELSE 0
WHEN COALESCE(CU.[HidePasswords], CG.[HidePasswords], 0) = 0
THEN 1
ELSE 0
END [ViewPassword],
CASE
WHEN COALESCE(CU.[Manage], CG.[Manage], 0) = 1
@@ -64,4 +64,4 @@ SELECT
FROM
[dbo].[CipherDetails](@UserId)
WHERE
[UserId] = @UserId
[UserId] = @UserId;

View File

@@ -18,7 +18,8 @@
@DeletedDate DATETIME2(7),
@Reprompt TINYINT,
@Key VARCHAR(MAX) = NULL,
@ArchivedDate DATETIME2(7) = NULL
@ArchivedDate DATETIME2(7) = NULL,
@Archives NVARCHAR(MAX) = NULL -- not used
AS
BEGIN
SET NOCOUNT ON
@@ -40,7 +41,7 @@ BEGIN
[DeletedDate],
[Reprompt],
[Key],
[ArchivedDate]
[Archives]
)
VALUES
(
@@ -56,7 +57,7 @@ BEGIN
@DeletedDate,
@Reprompt,
@Key,
@ArchivedDate
CASE WHEN @ArchivedDate IS NOT NULL THEN CONCAT('{', @UserIdKey, ':"', CONVERT(NVARCHAR(30), @ArchivedDate, 127), '"}') ELSE NULL END
)
IF @OrganizationId IS NOT NULL

View File

@@ -18,8 +18,9 @@
@DeletedDate DATETIME2(7),
@Reprompt TINYINT,
@Key VARCHAR(MAX) = NULL,
@CollectionIds AS [dbo].[GuidIdArray] READONLY,
@ArchivedDate DATETIME2(7) = NULL
@ArchivedDate DATETIME2(7) = NULL,
@Archives NVARCHAR(MAX) = NULL, -- not used
@CollectionIds AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON

View File

@@ -18,7 +18,8 @@
@DeletedDate DATETIME2(2),
@Reprompt TINYINT,
@Key VARCHAR(MAX) = NULL,
@ArchivedDate DATETIME2(7) = NULL
@ArchivedDate DATETIME2(7) = NULL,
@Archives NVARCHAR(MAX) = NULL -- not used
AS
BEGIN
SET NOCOUNT ON
@@ -51,13 +52,21 @@ BEGIN
ELSE
JSON_MODIFY([Favorites], @UserIdPath, NULL)
END,
[Archives] =
CASE
WHEN @ArchivedDate IS NOT NULL AND [Archives] IS NULL THEN
CONCAT('{', @UserIdKey, ':"', CONVERT(NVARCHAR(30), @ArchivedDate, 127), '"}')
WHEN @ArchivedDate IS NOT NULL THEN
JSON_MODIFY([Archives], @UserIdPath, CONVERT(NVARCHAR(30), @ArchivedDate, 127))
ELSE
JSON_MODIFY([Archives], @UserIdPath, NULL)
END,
[Attachments] = @Attachments,
[Reprompt] = @Reprompt,
[CreationDate] = @CreationDate,
[RevisionDate] = @RevisionDate,
[DeletedDate] = @DeletedDate,
[Key] = @Key,
[ArchivedDate] = @ArchivedDate
[Key] = @Key
WHERE
[Id] = @Id

View File

@@ -26,7 +26,11 @@ BEGIN
UPDATE
[dbo].[Cipher]
SET
[ArchivedDate] = @UtcNow,
[Archives] = JSON_MODIFY(
COALESCE([Archives], N'{}'),
CONCAT('$."', @UserId, '"'),
CONVERT(NVARCHAR(30), @UtcNow, 127)
),
[RevisionDate] = @UtcNow
WHERE
[Id] IN (SELECT [Id] FROM #Temp)

View File

@@ -12,7 +12,7 @@
@DeletedDate DATETIME2(7),
@Reprompt TINYINT,
@Key VARCHAR(MAX) = NULL,
@ArchivedDate DATETIME2(7) = NULL
@Archives NVARCHAR(MAX) = NULL
AS
BEGIN
SET NOCOUNT ON
@@ -31,7 +31,7 @@ BEGIN
[DeletedDate],
[Reprompt],
[Key],
[ArchivedDate]
[Archives]
)
VALUES
(
@@ -47,7 +47,7 @@ BEGIN
@DeletedDate,
@Reprompt,
@Key,
@ArchivedDate
@Archives
)
IF @OrganizationId IS NOT NULL

View File

@@ -12,14 +12,14 @@
@DeletedDate DATETIME2(7),
@Reprompt TINYINT,
@Key VARCHAR(MAX) = NULL,
@CollectionIds AS [dbo].[GuidIdArray] READONLY,
@ArchivedDate DATETIME2(7) = NULL
@Archives NVARCHAR(MAX) = NULL,
@CollectionIds AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON
EXEC [dbo].[Cipher_Create] @Id, @UserId, @OrganizationId, @Type, @Data, @Favorites, @Folders,
@Attachments, @CreationDate, @RevisionDate, @DeletedDate, @Reprompt, @Key, @ArchivedDate
@Attachments, @CreationDate, @RevisionDate, @DeletedDate, @Reprompt, @Key, @Archives
DECLARE @UpdateCollectionsSuccess INT
EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds

View File

@@ -6,7 +6,7 @@ BEGIN
SET NOCOUNT ON
CREATE TABLE #Temp
(
(
[Id] UNIQUEIDENTIFIER NOT NULL,
[UserId] UNIQUEIDENTIFIER NULL,
[OrganizationId] UNIQUEIDENTIFIER NULL

View File

@@ -26,7 +26,11 @@ BEGIN
UPDATE
[dbo].[Cipher]
SET
[ArchivedDate] = NULL,
[Archives] = JSON_MODIFY(
COALESCE([Archives], N'{}'),
CONCAT('$."', @UserId, '"'),
NULL
),
[RevisionDate] = @UtcNow
WHERE
[Id] IN (SELECT [Id] FROM #Temp)

View File

@@ -12,7 +12,7 @@
@DeletedDate DATETIME2(7),
@Reprompt TINYINT,
@Key VARCHAR(MAX) = NULL,
@ArchivedDate DATETIME2(7) = NULL
@Archives NVARCHAR(MAX) = NULL
AS
BEGIN
SET NOCOUNT ON
@@ -32,7 +32,7 @@ BEGIN
[DeletedDate] = @DeletedDate,
[Reprompt] = @Reprompt,
[Key] = @Key,
[ArchivedDate] = @ArchivedDate
[Archives] = @Archives
WHERE
[Id] = @Id

View File

@@ -12,8 +12,8 @@
@DeletedDate DATETIME2(7),
@Reprompt TINYINT,
@Key VARCHAR(MAX) = NULL,
@CollectionIds AS [dbo].[GuidIdArray] READONLY,
@ArchivedDate DATETIME2(7) = NULL
@Archives NVARCHAR(MAX) = NULL,
@CollectionIds AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON
@@ -40,10 +40,10 @@ BEGIN
[RevisionDate] = @RevisionDate,
[DeletedDate] = @DeletedDate,
[Key] = @Key,
[ArchivedDate] = @ArchivedDate,
[Folders] = @Folders,
[Favorites] = @Favorites,
[Reprompt] = @Reprompt
[Reprompt] = @Reprompt,
[Archives] = @Archives
-- No need to update CreationDate or Type since that data will not change
WHERE
[Id] = @Id

View File

@@ -14,6 +14,7 @@ CREATE TABLE [dbo].[Cipher] (
[Reprompt] TINYINT NULL,
[Key] VARCHAR(MAX) NULL,
[ArchivedDate] DATETIME2 (7) NULL,
[Archives] NVARCHAR(MAX) NULL,
CONSTRAINT [PK_Cipher] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_Cipher_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]),
CONSTRAINT [FK_Cipher_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id])

View File

@@ -12,7 +12,6 @@ internal class OrganizationCipher : ICustomization
{
fixture.Customize<Cipher>(composer => composer
.With(c => c.OrganizationId, OrganizationId ?? Guid.NewGuid())
.Without(c => c.ArchivedDate)
.Without(c => c.UserId));
fixture.Customize<CipherDetails>(composer => composer
.With(c => c.OrganizationId, Guid.NewGuid())
@@ -28,7 +27,6 @@ internal class UserCipher : ICustomization
{
fixture.Customize<Cipher>(composer => composer
.With(c => c.UserId, UserId ?? Guid.NewGuid())
.Without(c => c.ArchivedDate)
.Without(c => c.OrganizationId));
fixture.Customize<CipherDetails>(composer => composer
.With(c => c.UserId, Guid.NewGuid())

View File

@@ -16,16 +16,15 @@ namespace Bit.Core.Test.Vault.Commands;
public class ArchiveCiphersCommandTest
{
[Theory]
[BitAutoData(true, false, 1, 1, 1)]
[BitAutoData(false, false, 1, 0, 1)]
[BitAutoData(false, true, 1, 0, 1)]
[BitAutoData(true, true, 1, 0, 1)]
public async Task ArchiveAsync_Works(
bool isEditable, bool hasOrganizationId,
[BitAutoData(true, 1, 1, 1)]
[BitAutoData(false, 1, 0, 1)]
[BitAutoData(false, 1, 0, 1)]
[BitAutoData(true, 1, 0, 1)]
public async Task ArchiveManyAsync_Works(
bool hasOrganizationId,
int cipherRepoCalls, int resultCountFromQuery, int pushNotificationsCalls,
SutProvider<ArchiveCiphersCommand> sutProvider, CipherDetails cipher, User user)
{
cipher.Edit = isEditable;
cipher.OrganizationId = hasOrganizationId ? Guid.NewGuid() : null;
var cipherList = new List<CipherDetails> { cipher };
@@ -46,4 +45,33 @@ public class ArchiveCiphersCommandTest
await sutProvider.GetDependency<IPushNotificationService>().Received(pushNotificationsCalls)
.PushSyncCiphersAsync(user.Id);
}
[Theory]
[BitAutoData]
public async Task ArchiveManyAsync_SetsArchivedDateOnReturnedCiphers(
SutProvider<ArchiveCiphersCommand> sutProvider,
CipherDetails cipher,
User user)
{
// Allow organization cipher to be archived in this test
cipher.OrganizationId = Guid.Parse("3f2504e0-4f89-11d3-9a0c-0305e82c3301");
sutProvider.GetDependency<ICipherRepository>()
.GetManyByUserIdAsync(user.Id)
.Returns(new List<CipherDetails> { cipher });
var repoRevisionDate = DateTime.UtcNow;
sutProvider.GetDependency<ICipherRepository>()
.ArchiveAsync(Arg.Any<IEnumerable<Guid>>(), user.Id)
.Returns(repoRevisionDate);
// Act
var result = await sutProvider.Sut.ArchiveManyAsync(new[] { cipher.Id }, user.Id);
// Assert
var archivedCipher = Assert.Single(result);
Assert.Equal(repoRevisionDate, archivedCipher.RevisionDate);
Assert.Equal(repoRevisionDate, archivedCipher.ArchivedDate);
}
}

View File

@@ -16,16 +16,15 @@ namespace Bit.Core.Test.Vault.Commands;
public class UnarchiveCiphersCommandTest
{
[Theory]
[BitAutoData(true, false, 1, 1, 1)]
[BitAutoData(false, false, 1, 0, 1)]
[BitAutoData(false, true, 1, 0, 1)]
[BitAutoData(true, true, 1, 1, 1)]
[BitAutoData(true, 1, 1, 1)]
[BitAutoData(false, 1, 0, 1)]
[BitAutoData(false, 1, 0, 1)]
[BitAutoData(true, 1, 1, 1)]
public async Task UnarchiveAsync_Works(
bool isEditable, bool hasOrganizationId,
bool hasOrganizationId,
int cipherRepoCalls, int resultCountFromQuery, int pushNotificationsCalls,
SutProvider<UnarchiveCiphersCommand> sutProvider, CipherDetails cipher, User user)
{
cipher.Edit = isEditable;
cipher.OrganizationId = hasOrganizationId ? Guid.NewGuid() : null;
var cipherList = new List<CipherDetails> { cipher };
@@ -46,4 +45,33 @@ public class UnarchiveCiphersCommandTest
await sutProvider.GetDependency<IPushNotificationService>().Received(pushNotificationsCalls)
.PushSyncCiphersAsync(user.Id);
}
[Theory]
[BitAutoData]
public async Task UnarchiveAsync_ClearsArchivedDateOnReturnedCiphers(
SutProvider<UnarchiveCiphersCommand> sutProvider,
CipherDetails cipher,
User user)
{
cipher.OrganizationId = null;
cipher.ArchivedDate = DateTime.UtcNow;
sutProvider.GetDependency<ICipherRepository>()
.GetManyByUserIdAsync(user.Id)
.Returns(new List<CipherDetails> { cipher });
var repoRevisionDate = DateTime.UtcNow.AddMinutes(1);
sutProvider.GetDependency<ICipherRepository>()
.UnarchiveAsync(Arg.Any<IEnumerable<Guid>>(), user.Id)
.Returns(repoRevisionDate);
// Act
var result = await sutProvider.Sut.UnarchiveManyAsync(new[] { cipher.Id }, user.Id);
// Assert
var unarchivedCipher = Assert.Single(result);
Assert.Equal(repoRevisionDate, unarchivedCipher.RevisionDate);
Assert.Null(unarchivedCipher.ArchivedDate);
}
}

View File

@@ -1207,10 +1207,110 @@ public class CipherRepositoryTests
// Act
await sutRepository.ArchiveAsync(new List<Guid> { cipher.Id }, user.Id);
// Assert
var archivedCipher = await sutRepository.GetByIdAsync(cipher.Id, user.Id);
Assert.NotNull(archivedCipher);
Assert.NotNull(archivedCipher.ArchivedDate);
// Assert per-user view should show an archive date
var archivedCipherForUser = await sutRepository.GetByIdAsync(cipher.Id, user.Id);
Assert.NotNull(archivedCipherForUser);
Assert.NotNull(archivedCipherForUser.ArchivedDate);
}
[DatabaseTheory, DatabaseData]
public async Task ArchiveAsync_IsPerUserForSharedCipher(
ICipherRepository cipherRepository,
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
ICollectionRepository collectionRepository,
ICollectionCipherRepository collectionCipherRepository)
{
// Arrange: two users in the same org, both with access to the same cipher
var user1 = await userRepository.CreateAsync(new User
{
Name = "Test User 1",
Email = $"test+{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
var user2 = await userRepository.CreateAsync(new User
{
Name = "Test User 2",
Email = $"test+{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
var org = await organizationRepository.CreateAsync(new Organization
{
Name = "Test Organization",
BillingEmail = user1.Email,
Plan = "Test",
});
var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser
{
UserId = user1.Id,
OrganizationId = org.Id,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.Owner,
});
var orgUser2 = await organizationUserRepository.CreateAsync(new OrganizationUser
{
UserId = user2.Id,
OrganizationId = org.Id,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.User,
});
var sharedCollection = await collectionRepository.CreateAsync(new Collection
{
Name = "Shared Collection",
OrganizationId = org.Id,
});
var cipher = await cipherRepository.CreateAsync(new Cipher
{
Type = CipherType.Login,
OrganizationId = org.Id,
Data = "",
});
await collectionCipherRepository.UpdateCollectionsForAdminAsync(
cipher.Id,
org.Id,
new List<Guid> { sharedCollection.Id });
// Give both org users access to the shared collection
await collectionRepository.UpdateUsersAsync(sharedCollection.Id, new List<CollectionAccessSelection>
{
new()
{
Id = orgUser1.Id,
HidePasswords = false,
ReadOnly = false,
Manage = true,
},
new()
{
Id = orgUser2.Id,
HidePasswords = false,
ReadOnly = false,
Manage = true,
},
});
// Act: user1 archives the shared cipher
await cipherRepository.ArchiveAsync(new List<Guid> { cipher.Id }, user1.Id);
// Assert: user1 sees it as archived
var cipherForUser1 = await cipherRepository.GetByIdAsync(cipher.Id, user1.Id);
Assert.NotNull(cipherForUser1);
Assert.NotNull(cipherForUser1.ArchivedDate);
// Assert: user2 still sees it as *not* archived
var cipherForUser2 = await cipherRepository.GetByIdAsync(cipher.Id, user2.Id);
Assert.NotNull(cipherForUser2);
Assert.Null(cipherForUser2.ArchivedDate);
}
[DatabaseTheory, DatabaseData]

View File

@@ -0,0 +1,618 @@
-- Add new JSON column for Archives (similar to Favorites/Folders pattern)
IF NOT EXISTS (
SELECT 1
FROM sys.columns
WHERE object_id = OBJECT_ID(N'[dbo].[Cipher]')
AND name = 'Archives'
)
BEGIN
ALTER TABLE [dbo].[Cipher]
ADD [Archives] NVARCHAR(MAX) NULL;
END;
GO
-- Update CipherDetails function to use JSON column approac
CREATE OR ALTER FUNCTION [dbo].[CipherDetails](@UserId UNIQUEIDENTIFIER)
RETURNS TABLE
AS RETURN
SELECT
C.[Id],
C.[UserId],
C.[OrganizationId],
C.[Type],
C.[Data],
C.[Attachments],
C.[CreationDate],
C.[RevisionDate],
CASE
WHEN
@UserId IS NULL
OR C.[Favorites] IS NULL
OR JSON_VALUE(C.[Favorites], CONCAT('$."', @UserId, '"')) IS NULL
THEN 0
ELSE 1
END [Favorite],
CASE
WHEN
@UserId IS NULL
OR C.[Folders] IS NULL
THEN NULL
ELSE TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(C.[Folders], CONCAT('$."', @UserId, '"')))
END [FolderId],
C.[DeletedDate],
C.[Reprompt],
C.[Key],
CASE
WHEN
@UserId IS NULL
OR C.[Archives] IS NULL
THEN NULL
ELSE TRY_CONVERT(DATETIME2(7), JSON_VALUE(C.[Archives], CONCAT('$."', @UserId, '"')))
END [ArchivedDate]
FROM
[dbo].[Cipher] C;
GO
CREATE OR ALTER PROCEDURE [dbo].[Cipher_Archive]
@Ids AS [dbo].[GuidIdArray] READONLY,
@UserId AS UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
CREATE TABLE #Temp
(
[Id] UNIQUEIDENTIFIER NOT NULL,
[UserId] UNIQUEIDENTIFIER NULL
)
INSERT INTO #Temp
SELECT
[Id],
[UserId]
FROM
[dbo].[UserCipherDetails](@UserId)
WHERE
[Edit] = 1
AND [ArchivedDate] IS NULL
AND [Id] IN (SELECT * FROM @Ids)
DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME();
UPDATE
[dbo].[Cipher]
SET
[Archives] = JSON_MODIFY(
COALESCE([Archives], N'{}'),
CONCAT('$."', @UserId, '"'),
CONVERT(NVARCHAR(30), @UtcNow, 127)
),
[RevisionDate] = @UtcNow
WHERE
[Id] IN (SELECT [Id] FROM #Temp)
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
DROP TABLE #Temp
SELECT @UtcNow
END
GO
CREATE OR ALTER PROCEDURE [dbo].[Cipher_Unarchive]
@Ids AS [dbo].[GuidIdArray] READONLY,
@UserId AS UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
CREATE TABLE #Temp
(
[Id] UNIQUEIDENTIFIER NOT NULL,
[UserId] UNIQUEIDENTIFIER NULL
)
INSERT INTO #Temp
SELECT
[Id],
[UserId]
FROM
[dbo].[UserCipherDetails](@UserId)
WHERE
[Edit] = 1
AND [ArchivedDate] IS NOT NULL
AND [Id] IN (SELECT * FROM @Ids)
DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME();
UPDATE
[dbo].[Cipher]
SET
[Archives] = JSON_MODIFY(
COALESCE([Archives], N'{}'),
CONCAT('$."', @UserId, '"'),
NULL
),
[RevisionDate] = @UtcNow
WHERE
[Id] IN (SELECT [Id] FROM #Temp)
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
DROP TABLE #Temp
SELECT @UtcNow
END
GO
CREATE OR ALTER PROCEDURE [dbo].[Cipher_Create]
@Id UNIQUEIDENTIFIER OUTPUT,
@UserId UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Type TINYINT,
@Data NVARCHAR(MAX),
@Favorites NVARCHAR(MAX),
@Folders NVARCHAR(MAX),
@Attachments NVARCHAR(MAX), -- not used
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@DeletedDate DATETIME2(7),
@Reprompt TINYINT,
@Key VARCHAR(MAX) = NULL,
@Archives NVARCHAR(MAX) = NULL
AS
BEGIN
SET NOCOUNT ON
INSERT INTO [dbo].[Cipher]
(
[Id],
[UserId],
[OrganizationId],
[Type],
[Data],
[Favorites],
[Folders],
[CreationDate],
[RevisionDate],
[DeletedDate],
[Reprompt],
[Key],
[Archives]
)
VALUES
(
@Id,
CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END,
@OrganizationId,
@Type,
@Data,
@Favorites,
@Folders,
@CreationDate,
@RevisionDate,
@DeletedDate,
@Reprompt,
@Key,
@Archives
)
IF @OrganizationId IS NOT NULL
BEGIN
EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId
END
ELSE IF @UserId IS NOT NULL
BEGIN
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
END
END
GO
CREATE OR ALTER PROCEDURE [dbo].[Cipher_Update]
@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,
@Archives NVARCHAR(MAX) = NULL
AS
BEGIN
SET NOCOUNT ON
UPDATE
[dbo].[Cipher]
SET
[UserId] = CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END,
[OrganizationId] = @OrganizationId,
[Type] = @Type,
[Data] = @Data,
[Favorites] = @Favorites,
[Folders] = @Folders,
[Attachments] = @Attachments,
[CreationDate] = @CreationDate,
[RevisionDate] = @RevisionDate,
[DeletedDate] = @DeletedDate,
[Reprompt] = @Reprompt,
[Key] = @Key,
[Archives] = @Archives
WHERE
[Id] = @Id
IF @OrganizationId IS NOT NULL
BEGIN
EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId
END
ELSE IF @UserId IS NOT NULL
BEGIN
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
END
END
GO
CREATE OR ALTER PROCEDURE [dbo].[Cipher_CreateWithCollections]
@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,
@Archives NVARCHAR(MAX) = NULL,
@CollectionIds AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON
EXEC [dbo].[Cipher_Create] @Id, @UserId, @OrganizationId, @Type, @Data, @Favorites, @Folders,
@Attachments, @CreationDate, @RevisionDate, @DeletedDate, @Reprompt, @Key, @Archives
DECLARE @UpdateCollectionsSuccess INT
EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds
-- Bump the account revision date AFTER collections are assigned.
IF @UpdateCollectionsSuccess = 0
BEGIN
EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId
END
END
GO
CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_Create]
@Id UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Type TINYINT,
@Data NVARCHAR(MAX),
@Favorites NVARCHAR(MAX), -- not used
@Folders NVARCHAR(MAX), -- not used
@Attachments NVARCHAR(MAX), -- not used
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@FolderId UNIQUEIDENTIFIER,
@Favorite BIT,
@Edit BIT, -- not used
@ViewPassword BIT, -- not used
@Manage BIT, -- not used
@OrganizationUseTotp BIT, -- not used
@DeletedDate DATETIME2(7),
@Reprompt TINYINT,
@Key VARCHAR(MAX) = NULL,
@ArchivedDate DATETIME2(7) = NULL,
@Archives NVARCHAR(MAX) = NULL -- not used
AS
BEGIN
SET NOCOUNT ON
DECLARE @UserIdKey VARCHAR(50) = CONCAT('"', @UserId, '"')
DECLARE @UserIdPath VARCHAR(50) = CONCAT('$.', @UserIdKey)
INSERT INTO [dbo].[Cipher]
(
[Id],
[UserId],
[OrganizationId],
[Type],
[Data],
[Favorites],
[Folders],
[CreationDate],
[RevisionDate],
[DeletedDate],
[Reprompt],
[Key],
[Archives]
)
VALUES
(
@Id,
CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END,
@OrganizationId,
@Type,
@Data,
CASE WHEN @Favorite = 1 THEN CONCAT('{', @UserIdKey, ':true}') ELSE NULL END,
CASE WHEN @FolderId IS NOT NULL THEN CONCAT('{', @UserIdKey, ':"', @FolderId, '"', '}') ELSE NULL END,
@CreationDate,
@RevisionDate,
@DeletedDate,
@Reprompt,
@Key,
CASE WHEN @ArchivedDate IS NOT NULL THEN CONCAT('{', @UserIdKey, ':"', CONVERT(NVARCHAR(30), @ArchivedDate, 127), '"}') ELSE NULL END
)
IF @OrganizationId IS NOT NULL
BEGIN
EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId
END
ELSE IF @UserId IS NOT NULL
BEGIN
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
END
END
GO
CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_CreateWithCollections]
@Id UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Type TINYINT,
@Data NVARCHAR(MAX),
@Favorites NVARCHAR(MAX), -- not used
@Folders NVARCHAR(MAX), -- not used
@Attachments NVARCHAR(MAX), -- not used
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@FolderId UNIQUEIDENTIFIER,
@Favorite BIT,
@Edit BIT, -- not used
@ViewPassword BIT, -- not used
@Manage BIT, -- not used
@OrganizationUseTotp BIT, -- not used
@DeletedDate DATETIME2(7),
@Reprompt TINYINT,
@Key VARCHAR(MAX) = NULL,
@ArchivedDate DATETIME2(7) = NULL,
@Archives NVARCHAR(MAX) = NULL, -- not used
@CollectionIds AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON
EXEC [dbo].[CipherDetails_Create] @Id, @UserId, @OrganizationId, @Type, @Data, @Favorites, @Folders,
@Attachments, @CreationDate, @RevisionDate, @FolderId, @Favorite, @Edit, @ViewPassword, @Manage,
@OrganizationUseTotp, @DeletedDate, @Reprompt, @Key, @ArchivedDate
DECLARE @UpdateCollectionsSuccess INT
EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds
-- Bump the account revision date AFTER collections are assigned.
IF @UpdateCollectionsSuccess = 0
BEGIN
EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId
END
END
GO
CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_Update]
@Id UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Type TINYINT,
@Data NVARCHAR(MAX),
@Favorites NVARCHAR(MAX), -- not used
@Folders NVARCHAR(MAX), -- not used
@Attachments NVARCHAR(MAX),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@FolderId UNIQUEIDENTIFIER,
@Favorite BIT,
@Edit BIT, -- not used
@ViewPassword BIT, -- not used
@Manage BIT, -- not used
@OrganizationUseTotp BIT, -- not used
@DeletedDate DATETIME2(2),
@Reprompt TINYINT,
@Key VARCHAR(MAX) = NULL,
@ArchivedDate DATETIME2(7) = NULL,
@Archives NVARCHAR(MAX) = NULL -- not used
AS
BEGIN
SET NOCOUNT ON
DECLARE @UserIdKey VARCHAR(50) = CONCAT('"', @UserId, '"')
DECLARE @UserIdPath VARCHAR(50) = CONCAT('$.', @UserIdKey)
UPDATE
[dbo].[Cipher]
SET
[UserId] = CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END,
[OrganizationId] = @OrganizationId,
[Type] = @Type,
[Data] = @Data,
[Folders] =
CASE
WHEN @FolderId IS NOT NULL AND [Folders] IS NULL THEN
CONCAT('{', @UserIdKey, ':"', @FolderId, '"', '}')
WHEN @FolderId IS NOT NULL THEN
JSON_MODIFY([Folders], @UserIdPath, CAST(@FolderId AS VARCHAR(50)))
ELSE
JSON_MODIFY([Folders], @UserIdPath, NULL)
END,
[Favorites] =
CASE
WHEN @Favorite = 1 AND [Favorites] IS NULL THEN
CONCAT('{', @UserIdKey, ':true}')
WHEN @Favorite = 1 THEN
JSON_MODIFY([Favorites], @UserIdPath, CAST(1 AS BIT))
ELSE
JSON_MODIFY([Favorites], @UserIdPath, NULL)
END,
[Archives] =
CASE
WHEN @ArchivedDate IS NOT NULL AND [Archives] IS NULL THEN
CONCAT('{', @UserIdKey, ':"', CONVERT(NVARCHAR(30), @ArchivedDate, 127), '"}')
WHEN @ArchivedDate IS NOT NULL THEN
JSON_MODIFY([Archives], @UserIdPath, CONVERT(NVARCHAR(30), @ArchivedDate, 127))
ELSE
JSON_MODIFY([Archives], @UserIdPath, NULL)
END,
[Attachments] = @Attachments,
[Reprompt] = @Reprompt,
[CreationDate] = @CreationDate,
[RevisionDate] = @RevisionDate,
[DeletedDate] = @DeletedDate,
[Key] = @Key
WHERE
[Id] = @Id
IF @OrganizationId IS NOT NULL
BEGIN
EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId
END
ELSE IF @UserId IS NOT NULL
BEGIN
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
END
END
GO
CREATE OR ALTER PROCEDURE [dbo].[Cipher_SoftDelete]
@Ids AS [dbo].[GuidIdArray] READONLY,
@UserId AS UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
CREATE TABLE #Temp
(
[Id] UNIQUEIDENTIFIER NOT NULL,
[UserId] UNIQUEIDENTIFIER NULL,
[OrganizationId] UNIQUEIDENTIFIER NULL
)
INSERT INTO #Temp
SELECT
[Id],
[UserId],
[OrganizationId]
FROM
[dbo].[UserCipherDetails](@UserId)
WHERE
[Edit] = 1
AND [DeletedDate] IS NULL
AND [Id] IN (SELECT * FROM @Ids)
-- Delete ciphers
DECLARE @UtcNow DATETIME2(7) = GETUTCDATE();
UPDATE
[dbo].[Cipher]
SET
[DeletedDate] = @UtcNow,
[RevisionDate] = @UtcNow
WHERE
[Id] IN (SELECT [Id] FROM #Temp)
-- Cleanup orgs
DECLARE @OrgId UNIQUEIDENTIFIER
DECLARE [OrgCursor] CURSOR FORWARD_ONLY FOR
SELECT
[OrganizationId]
FROM
#Temp
WHERE
[OrganizationId] IS NOT NULL
GROUP BY
[OrganizationId]
OPEN [OrgCursor]
FETCH NEXT FROM [OrgCursor] INTO @OrgId
WHILE @@FETCH_STATUS = 0 BEGIN
EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrgId
FETCH NEXT FROM [OrgCursor] INTO @OrgId
END
CLOSE [OrgCursor]
DEALLOCATE [OrgCursor]
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
DROP TABLE #Temp
END
GO
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,
@Archives NVARCHAR(MAX) = NULL,
@CollectionIds AS [dbo].[GuidIdArray] READONLY
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,
[Folders] = @Folders,
[Favorites] = @Favorites,
[Reprompt] = @Reprompt,
[Archives] = @Archives
-- 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
GO
EXECUTE sp_refreshview N'[dbo].[CipherView]'
GO

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.MySqlMigrations.Migrations;
/// <inheritdoc />
public partial class AddCipherArchives : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Archives",
table: "Cipher",
type: "longtext",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Archives",
table: "Cipher");
}
}

View File

@@ -2317,8 +2317,8 @@ namespace Bit.MySqlMigrations.Migrations
b.Property<Guid>("Id")
.HasColumnType("char(36)");
b.Property<DateTime?>("ArchivedDate")
.HasColumnType("datetime(6)");
b.Property<string>("Archives")
.HasColumnType("longtext");
b.Property<string>("Attachments")
.HasColumnType("longtext");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.PostgresMigrations.Migrations;
/// <inheritdoc />
public partial class AddCipherArchives : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Archives",
table: "Cipher",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Archives",
table: "Cipher");
}
}

View File

@@ -2323,8 +2323,8 @@ namespace Bit.PostgresMigrations.Migrations
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTime?>("ArchivedDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Archives")
.HasColumnType("text");
b.Property<string>("Attachments")
.HasColumnType("text");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.SqliteMigrations.Migrations;
/// <inheritdoc />
public partial class AddCipherArchives : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Archives",
table: "Cipher",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Archives",
table: "Cipher");
}
}

View File

@@ -2306,7 +2306,7 @@ namespace Bit.SqliteMigrations.Migrations
b.Property<Guid>("Id")
.HasColumnType("TEXT");
b.Property<DateTime?>("ArchivedDate")
b.Property<string>("Archives")
.HasColumnType("TEXT");
b.Property<string>("Attachments")