Skip to content

Commit 02c03f4

Browse files
[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
1 parent afd47ad commit 02c03f4

File tree

38 files changed

+11354
-79
lines changed

38 files changed

+11354
-79
lines changed

src/Api/Vault/Controllers/CiphersController.cs

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -903,7 +903,7 @@ public async Task PostBulkCollections([FromBody] CipherBulkUpdateCollectionsRequ
903903

904904
[HttpPut("{id}/archive")]
905905
[RequireFeature(FeatureFlagKeys.ArchiveVaultItems)]
906-
public async Task<CipherMiniResponseModel> PutArchive(Guid id)
906+
public async Task<CipherResponseModel> PutArchive(Guid id)
907907
{
908908
var userId = _userService.GetProperUserId(User).Value;
909909

@@ -914,19 +914,24 @@ public async Task<CipherMiniResponseModel> PutArchive(Guid id)
914914
throw new BadRequestException("Cipher was not archived. Ensure the provided ID is correct and you have permission to archive it.");
915915
}
916916

917-
return new CipherMiniResponseModel(archivedCipherOrganizationDetails.First(), _globalSettings, archivedCipherOrganizationDetails.First().OrganizationUseTotp);
917+
return new CipherResponseModel(archivedCipherOrganizationDetails.First(),
918+
await _userService.GetUserByPrincipalAsync(User),
919+
await _applicationCacheService.GetOrganizationAbilitiesAsync(),
920+
_globalSettings
921+
);
918922
}
919923

920924
[HttpPut("archive")]
921925
[RequireFeature(FeatureFlagKeys.ArchiveVaultItems)]
922-
public async Task<ListResponseModel<CipherMiniResponseModel>> PutArchiveMany([FromBody] CipherBulkArchiveRequestModel model)
926+
public async Task<ListResponseModel<CipherResponseModel>> PutArchiveMany([FromBody] CipherBulkArchiveRequestModel model)
923927
{
924928
if (!_globalSettings.SelfHosted && model.Ids.Count() > 500)
925929
{
926930
throw new BadRequestException("You can only archive up to 500 items at a time.");
927931
}
928932

929933
var userId = _userService.GetProperUserId(User).Value;
934+
var user = await _userService.GetUserByPrincipalAsync(User);
930935

931936
var cipherIdsToArchive = new HashSet<Guid>(model.Ids);
932937

@@ -937,9 +942,14 @@ public async Task<ListResponseModel<CipherMiniResponseModel>> PutArchiveMany([Fr
937942
throw new BadRequestException("No ciphers were archived. Ensure the provided IDs are correct and you have permission to archive them.");
938943
}
939944

940-
var responses = archivedCiphers.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp));
945+
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
946+
var responses = archivedCiphers.Select(c => new CipherResponseModel(c,
947+
user,
948+
organizationAbilities,
949+
_globalSettings
950+
));
941951

942-
return new ListResponseModel<CipherMiniResponseModel>(responses);
952+
return new ListResponseModel<CipherResponseModel>(responses);
943953
}
944954

945955
[HttpDelete("{id}")]
@@ -1101,7 +1111,7 @@ public async Task PutDeleteManyAdmin([FromBody] CipherBulkDeleteRequestModel mod
11011111

11021112
[HttpPut("{id}/unarchive")]
11031113
[RequireFeature(FeatureFlagKeys.ArchiveVaultItems)]
1104-
public async Task<CipherMiniResponseModel> PutUnarchive(Guid id)
1114+
public async Task<CipherResponseModel> PutUnarchive(Guid id)
11051115
{
11061116
var userId = _userService.GetProperUserId(User).Value;
11071117

@@ -1112,19 +1122,25 @@ public async Task<CipherMiniResponseModel> PutUnarchive(Guid id)
11121122
throw new BadRequestException("Cipher was not unarchived. Ensure the provided ID is correct and you have permission to archive it.");
11131123
}
11141124

1115-
return new CipherMiniResponseModel(unarchivedCipherDetails.First(), _globalSettings, unarchivedCipherDetails.First().OrganizationUseTotp);
1125+
return new CipherResponseModel(unarchivedCipherDetails.First(),
1126+
await _userService.GetUserByPrincipalAsync(User),
1127+
await _applicationCacheService.GetOrganizationAbilitiesAsync(),
1128+
_globalSettings
1129+
);
11161130
}
11171131

11181132
[HttpPut("unarchive")]
11191133
[RequireFeature(FeatureFlagKeys.ArchiveVaultItems)]
1120-
public async Task<ListResponseModel<CipherMiniResponseModel>> PutUnarchiveMany([FromBody] CipherBulkUnarchiveRequestModel model)
1134+
public async Task<ListResponseModel<CipherResponseModel>> PutUnarchiveMany([FromBody] CipherBulkUnarchiveRequestModel model)
11211135
{
11221136
if (!_globalSettings.SelfHosted && model.Ids.Count() > 500)
11231137
{
11241138
throw new BadRequestException("You can only unarchive up to 500 items at a time.");
11251139
}
11261140

11271141
var userId = _userService.GetProperUserId(User).Value;
1142+
var user = await _userService.GetUserByPrincipalAsync(User);
1143+
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
11281144

11291145
var cipherIdsToUnarchive = new HashSet<Guid>(model.Ids);
11301146

@@ -1135,9 +1151,9 @@ public async Task<ListResponseModel<CipherMiniResponseModel>> PutUnarchiveMany([
11351151
throw new BadRequestException("Ciphers were not unarchived. Ensure the provided ID is correct and you have permission to archive it.");
11361152
}
11371153

1138-
var responses = unarchivedCipherOrganizationDetails.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp));
1154+
var responses = unarchivedCipherOrganizationDetails.Select(c => new CipherResponseModel(c, user, organizationAbilities, _globalSettings));
11391155

1140-
return new ListResponseModel<CipherMiniResponseModel>(responses);
1156+
return new ListResponseModel<CipherResponseModel>(responses);
11411157
}
11421158

11431159
[HttpPut("{id}/restore")]

src/Api/Vault/Models/Request/CipherRequestModel.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ public CipherDetails ToCipherDetails(CipherDetails existingCipher)
8080
{
8181
existingCipher.FolderId = string.IsNullOrWhiteSpace(FolderId) ? null : (Guid?)new Guid(FolderId);
8282
existingCipher.Favorite = Favorite;
83+
existingCipher.ArchivedDate = ArchivedDate;
8384
ToCipher(existingCipher);
8485
return existingCipher;
8586
}
@@ -127,9 +128,9 @@ public Cipher ToCipher(Cipher existingCipher, Guid? userId = null)
127128
var userIdKey = userId.HasValue ? userId.ToString().ToUpperInvariant() : null;
128129
existingCipher.Reprompt = Reprompt;
129130
existingCipher.Key = Key;
130-
existingCipher.ArchivedDate = ArchivedDate;
131131
existingCipher.Folders = UpdateUserSpecificJsonField(existingCipher.Folders, userIdKey, FolderId);
132132
existingCipher.Favorites = UpdateUserSpecificJsonField(existingCipher.Favorites, userIdKey, Favorite);
133+
existingCipher.Archives = UpdateUserSpecificJsonField(existingCipher.Archives, userIdKey, ArchivedDate);
133134

134135
var hasAttachments2 = (Attachments2?.Count ?? 0) > 0;
135136
var hasAttachments = (Attachments?.Count ?? 0) > 0;

src/Api/Vault/Models/Response/CipherResponseModel.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,6 @@ public CipherMiniResponseModel(Cipher cipher, IGlobalSettings globalSettings, bo
7070
DeletedDate = cipher.DeletedDate;
7171
Reprompt = cipher.Reprompt.GetValueOrDefault(CipherRepromptType.None);
7272
Key = cipher.Key;
73-
ArchivedDate = cipher.ArchivedDate;
7473
}
7574

7675
public Guid Id { get; set; }
@@ -111,7 +110,6 @@ public CipherMiniResponseModel(Cipher cipher, IGlobalSettings globalSettings, bo
111110
public DateTime? DeletedDate { get; set; }
112111
public CipherRepromptType Reprompt { get; set; }
113112
public string Key { get; set; }
114-
public DateTime? ArchivedDate { get; set; }
115113
}
116114

117115
public class CipherResponseModel : CipherMiniResponseModel
@@ -127,6 +125,7 @@ public CipherResponseModel(
127125
FolderId = cipher.FolderId;
128126
Favorite = cipher.Favorite;
129127
Edit = cipher.Edit;
128+
ArchivedDate = cipher.ArchivedDate;
130129
ViewPassword = cipher.ViewPassword;
131130
Permissions = new CipherPermissionsResponseModel(user, cipher, organizationAbilities);
132131
}
@@ -135,6 +134,7 @@ public CipherResponseModel(
135134
public bool Favorite { get; set; }
136135
public bool Edit { get; set; }
137136
public bool ViewPassword { get; set; }
137+
public DateTime? ArchivedDate { get; set; }
138138
public CipherPermissionsResponseModel Permissions { get; set; }
139139
}
140140

src/Core/Vault/Commands/ArchiveCiphersCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public async Task<ICollection<CipherDetails>> ArchiveManyAsync(IEnumerable<Guid>
3737
}
3838

3939
var archivingCiphers = ciphers
40-
.Where(c => cipherIdsSet.Contains(c.Id) && c is { Edit: true, OrganizationId: null, ArchivedDate: null })
40+
.Where(c => cipherIdsSet.Contains(c.Id) && c is { ArchivedDate: null })
4141
.ToList();
4242

4343
var revisionDate = await _cipherRepository.ArchiveAsync(archivingCiphers.Select(c => c.Id), archivingUserId);

src/Core/Vault/Commands/UnarchiveCiphersCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public async Task<ICollection<CipherDetails>> UnarchiveManyAsync(IEnumerable<Gui
3737
}
3838

3939
var unarchivingCiphers = ciphers
40-
.Where(c => cipherIdsSet.Contains(c.Id) && c is { Edit: true, ArchivedDate: not null })
40+
.Where(c => cipherIdsSet.Contains(c.Id) && c is { ArchivedDate: not null })
4141
.ToList();
4242

4343
var revisionDate =

src/Core/Vault/Entities/Cipher.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public class Cipher : ITableObject<Guid>, ICloneable
2525
public DateTime? DeletedDate { get; set; }
2626
public Enums.CipherRepromptType? Reprompt { get; set; }
2727
public string Key { get; set; }
28-
public DateTime? ArchivedDate { get; set; }
28+
public string Archives { get; set; }
2929

3030
public void SetNewId()
3131
{

src/Core/Vault/Models/Data/CipherDetails.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ public class CipherDetails : CipherOrganizationDetails
99
public bool Edit { get; set; }
1010
public bool ViewPassword { get; set; }
1111
public bool Manage { get; set; }
12-
12+
// Per-user archived date from Archives JSON.
13+
public DateTime? ArchivedDate { get; set; }
1314
public CipherDetails() { }
1415

1516
public CipherDetails(CipherOrganizationDetails cipher)
@@ -51,6 +52,7 @@ public CipherDetailsWithCollections(
5152
Reprompt = cipher.Reprompt;
5253
Key = cipher.Key;
5354
FolderId = cipher.FolderId;
55+
ArchivedDate = cipher.ArchivedDate;
5456
Favorite = cipher.Favorite;
5557
Edit = cipher.Edit;
5658
ViewPassword = cipher.ViewPassword;

src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,8 @@ private static DataTable BuildCiphersTable(SqlBulkCopy bulkCopy, IEnumerable<Cip
218218
ciphersTable.Columns.Add(revisionDateColumn);
219219
var deletedDateColumn = new DataColumn(nameof(c.DeletedDate), typeof(DateTime));
220220
ciphersTable.Columns.Add(deletedDateColumn);
221-
var archivedDateColumn = new DataColumn(nameof(c.ArchivedDate), typeof(DateTime));
222-
ciphersTable.Columns.Add(archivedDateColumn);
221+
var archivesColumn = new DataColumn(nameof(c.Archives), typeof(string));
222+
ciphersTable.Columns.Add(archivesColumn);
223223
var repromptColumn = new DataColumn(nameof(c.Reprompt), typeof(short));
224224
ciphersTable.Columns.Add(repromptColumn);
225225
var keyColummn = new DataColumn(nameof(c.Key), typeof(string));
@@ -249,7 +249,7 @@ private static DataTable BuildCiphersTable(SqlBulkCopy bulkCopy, IEnumerable<Cip
249249
row[creationDateColumn] = cipher.CreationDate;
250250
row[revisionDateColumn] = cipher.RevisionDate;
251251
row[deletedDateColumn] = cipher.DeletedDate.HasValue ? (object)cipher.DeletedDate : DBNull.Value;
252-
row[archivedDateColumn] = cipher.ArchivedDate.HasValue ? cipher.ArchivedDate : DBNull.Value;
252+
row[archivesColumn] = cipher.Archives;
253253
row[repromptColumn] = cipher.Reprompt.HasValue ? cipher.Reprompt.Value : DBNull.Value;
254254
row[keyColummn] = cipher.Key;
255255

src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
using Bit.Core.Enums;
66
using Bit.Core.Vault.Models.Data;
77
using Bit.Infrastructure.EntityFramework.Vault.Models;
8-
98
namespace Bit.Infrastructure.EntityFramework.Repositories.Queries;
109

1110
public class UserCipherDetailsQuery : IQuery<CipherDetails>
@@ -72,7 +71,7 @@ from cg in cg_g.DefaultIfEmpty()
7271
OrganizationUseTotp = o.UseTotp,
7372
c.Reprompt,
7473
c.Key,
75-
c.ArchivedDate
74+
c.Archives
7675
};
7776

7877
var query2 = from c in dbContext.Ciphers
@@ -96,7 +95,7 @@ from cg in cg_g.DefaultIfEmpty()
9695
OrganizationUseTotp = false,
9796
c.Reprompt,
9897
c.Key,
99-
c.ArchivedDate
98+
c.Archives
10099
};
101100

102101
var union = query.Union(query2).Select(c => new CipherDetails
@@ -118,11 +117,32 @@ from cg in cg_g.DefaultIfEmpty()
118117
Manage = c.Manage,
119118
OrganizationUseTotp = c.OrganizationUseTotp,
120119
Key = c.Key,
121-
ArchivedDate = c.ArchivedDate
120+
ArchivedDate = GetArchivedDate(_userId, new Cipher { Id = c.Id, Archives = c.Archives })
122121
});
123122
return union;
124123
}
125124

125+
private static DateTime? GetArchivedDate(Guid? userId, Cipher cipher)
126+
{
127+
try
128+
{
129+
if (userId.HasValue && !string.IsNullOrWhiteSpace(cipher.Archives))
130+
{
131+
var archives = JsonSerializer.Deserialize<Dictionary<Guid, DateTime>>(cipher.Archives);
132+
if (archives.TryGetValue(userId.Value, out var archivedDate))
133+
{
134+
return archivedDate;
135+
}
136+
}
137+
138+
return null;
139+
}
140+
catch
141+
{
142+
return null;
143+
}
144+
}
145+
126146
private static Guid? GetFolderId(Guid? userId, Cipher cipher)
127147
{
128148
try

src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -811,7 +811,29 @@ on ucd.Id equals c.Id
811811
await cipherEntitiesToModify.ForEachAsync(cipher =>
812812
{
813813
dbContext.Attach(cipher);
814-
cipher.ArchivedDate = action == CipherStateAction.Unarchive ? null : utcNow;
814+
815+
// Build or load the per-user archives map
816+
var archives = string.IsNullOrWhiteSpace(cipher.Archives)
817+
? new Dictionary<Guid, DateTime>()
818+
: CoreHelpers.LoadClassFromJsonData<Dictionary<Guid, DateTime>>(cipher.Archives)
819+
?? new Dictionary<Guid, DateTime>();
820+
821+
if (action == CipherStateAction.Unarchive)
822+
{
823+
// Remove this user's archive record
824+
archives.Remove(userId);
825+
}
826+
else if (action == CipherStateAction.Archive)
827+
{
828+
// Set this user's archive date
829+
archives[userId] = utcNow;
830+
}
831+
832+
// Persist the updated JSON or clear it if empty
833+
cipher.Archives = archives.Count == 0
834+
? null
835+
: CoreHelpers.ClassToJsonData(archives);
836+
815837
cipher.RevisionDate = utcNow;
816838
});
817839

0 commit comments

Comments
 (0)