1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-25 12:45:18 +01:00

[AC-1344] Provider users unable to bulk restore vault items for client organizations (#2871)

* [AC-1344] Added method PutRestoreManyAdmin to CiphersController and refactored PutRestoreMany

* [AC-1344] Fixed unit test

* [AC-1344] Removed comment

* [AC-1344] Fixed sql.csproj

* [AC-1344] Added check for empty or null array; added more unit tests
This commit is contained in:
Rui Tomé 2023-08-02 16:22:37 +01:00 committed by GitHub
parent 4a110ad135
commit d94a54516e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 221 additions and 16 deletions

View File

@ -451,7 +451,7 @@ public class CiphersController : Controller
} }
[HttpPut("restore")] [HttpPut("restore")]
public async Task<ListResponseModel<CipherResponseModel>> PutRestoreMany([FromBody] CipherBulkRestoreRequestModel model) public async Task<ListResponseModel<CipherMiniResponseModel>> PutRestoreMany([FromBody] CipherBulkRestoreRequestModel model)
{ {
if (!_globalSettings.SelfHosted && model.Ids.Count() > 500) if (!_globalSettings.SelfHosted && model.Ids.Count() > 500)
{ {
@ -461,12 +461,30 @@ public class CiphersController : Controller
var userId = _userService.GetProperUserId(User).Value; var userId = _userService.GetProperUserId(User).Value;
var cipherIdsToRestore = new HashSet<Guid>(model.Ids.Select(i => new Guid(i))); var cipherIdsToRestore = new HashSet<Guid>(model.Ids.Select(i => new Guid(i)));
var ciphers = await _cipherRepository.GetManyByUserIdAsync(userId); var restoredCiphers = await _cipherService.RestoreManyAsync(cipherIdsToRestore, userId);
var restoringCiphers = ciphers.Where(c => cipherIdsToRestore.Contains(c.Id) && c.Edit); var responses = restoredCiphers.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp));
return new ListResponseModel<CipherMiniResponseModel>(responses);
}
await _cipherService.RestoreManyAsync(restoringCiphers, userId); [HttpPut("restore-admin")]
var responses = restoringCiphers.Select(c => new CipherResponseModel(c, _globalSettings)); public async Task<ListResponseModel<CipherMiniResponseModel>> PutRestoreManyAdmin([FromBody] CipherBulkRestoreRequestModel model)
return new ListResponseModel<CipherResponseModel>(responses); {
if (!_globalSettings.SelfHosted && model.Ids.Count() > 500)
{
throw new BadRequestException("You can only restore up to 500 items at a time.");
}
if (model == null || model.OrganizationId == default || !await _currentContext.EditAnyCollection(model.OrganizationId))
{
throw new NotFoundException();
}
var userId = _userService.GetProperUserId(User).Value;
var cipherIdsToRestore = new HashSet<Guid>(model.Ids.Select(i => new Guid(i)));
var restoredCiphers = await _cipherService.RestoreManyAsync(cipherIdsToRestore, userId, model.OrganizationId, true);
var responses = restoredCiphers.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp));
return new ListResponseModel<CipherMiniResponseModel>(responses);
} }
[HttpPut("move")] [HttpPut("move")]

View File

@ -291,6 +291,7 @@ public class CipherBulkRestoreRequestModel
{ {
[Required] [Required]
public IEnumerable<string> Ids { get; set; } public IEnumerable<string> Ids { get; set; }
public Guid OrganizationId { get; set; }
} }
public class CipherBulkMoveRequestModel public class CipherBulkMoveRequestModel

View File

@ -36,5 +36,6 @@ public interface ICipherRepository : IRepository<Cipher, Guid>
Task SoftDeleteAsync(IEnumerable<Guid> ids, Guid userId); Task SoftDeleteAsync(IEnumerable<Guid> ids, Guid userId);
Task SoftDeleteByIdsOrganizationIdAsync(IEnumerable<Guid> ids, Guid organizationId); Task SoftDeleteByIdsOrganizationIdAsync(IEnumerable<Guid> ids, Guid organizationId);
Task<DateTime> RestoreAsync(IEnumerable<Guid> ids, Guid userId); Task<DateTime> RestoreAsync(IEnumerable<Guid> ids, Guid userId);
Task<DateTime> RestoreByIdsOrganizationIdAsync(IEnumerable<Guid> ids, Guid organizationId);
Task DeleteDeletedAsync(DateTime deletedDateBefore); Task DeleteDeletedAsync(DateTime deletedDateBefore);
} }

View File

@ -35,7 +35,7 @@ public interface ICipherService
Task SoftDeleteAsync(Cipher cipher, Guid deletingUserId, bool orgAdmin = false); Task SoftDeleteAsync(Cipher cipher, Guid deletingUserId, bool orgAdmin = false);
Task SoftDeleteManyAsync(IEnumerable<Guid> cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false); Task SoftDeleteManyAsync(IEnumerable<Guid> cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false);
Task RestoreAsync(Cipher cipher, Guid restoringUserId, bool orgAdmin = false); Task RestoreAsync(Cipher cipher, Guid restoringUserId, bool orgAdmin = false);
Task RestoreManyAsync(IEnumerable<CipherDetails> ciphers, Guid restoringUserId); Task<ICollection<CipherOrganizationDetails>> RestoreManyAsync(IEnumerable<Guid> cipherIds, Guid restoringUserId, Guid? organizationId = null, bool orgAdmin = false);
Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachmentId); Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachmentId);
Task<AttachmentResponseData> GetAttachmentDownloadDataAsync(Cipher cipher, string attachmentId); Task<AttachmentResponseData> GetAttachmentDownloadDataAsync(Cipher cipher, string attachmentId);
Task<bool> ValidateCipherAttachmentFile(Cipher cipher, CipherAttachment.MetaData attachmentData); Task<bool> ValidateCipherAttachmentFile(Cipher cipher, CipherAttachment.MetaData attachmentData);

View File

@ -898,13 +898,33 @@ public class CipherService : ICipherService
await _pushService.PushSyncCipherUpdateAsync(cipher, null); await _pushService.PushSyncCipherUpdateAsync(cipher, null);
} }
public async Task RestoreManyAsync(IEnumerable<CipherDetails> ciphers, Guid restoringUserId) public async Task<ICollection<CipherOrganizationDetails>> RestoreManyAsync(IEnumerable<Guid> cipherIds, Guid restoringUserId, Guid? organizationId = null, bool orgAdmin = false)
{ {
var revisionDate = await _cipherRepository.RestoreAsync(ciphers.Select(c => c.Id), restoringUserId); if (cipherIds == null || !cipherIds.Any())
var events = ciphers.Select(c =>
{ {
c.RevisionDate = revisionDate; return new List<CipherOrganizationDetails>();
}
var cipherIdsSet = new HashSet<Guid>(cipherIds);
var restoringCiphers = new List<CipherOrganizationDetails>();
DateTime? revisionDate;
if (orgAdmin && organizationId.HasValue)
{
var ciphers = await _cipherRepository.GetManyOrganizationDetailsByOrganizationIdAsync(organizationId.Value);
restoringCiphers = ciphers.Where(c => cipherIdsSet.Contains(c.Id)).ToList();
revisionDate = await _cipherRepository.RestoreByIdsOrganizationIdAsync(restoringCiphers.Select(c => c.Id), organizationId.Value);
}
else
{
var ciphers = await _cipherRepository.GetManyByUserIdAsync(restoringUserId);
restoringCiphers = ciphers.Where(c => cipherIdsSet.Contains(c.Id) && c.Edit).Select(c => (CipherOrganizationDetails)c).ToList();
revisionDate = await _cipherRepository.RestoreAsync(restoringCiphers.Select(c => c.Id), restoringUserId);
}
var events = restoringCiphers.Select(c =>
{
c.RevisionDate = revisionDate.Value;
c.DeletedDate = null; c.DeletedDate = null;
return new Tuple<Cipher, EventType, DateTime?>(c, EventType.Cipher_Restored, null); return new Tuple<Cipher, EventType, DateTime?>(c, EventType.Cipher_Restored, null);
}); });
@ -915,6 +935,8 @@ public class CipherService : ICipherService
// push // push
await _pushService.PushSyncCiphersAsync(restoringUserId); await _pushService.PushSyncCiphersAsync(restoringUserId);
return restoringCiphers;
} }
public async Task<(IEnumerable<CipherOrganizationDetails>, Dictionary<Guid, IGrouping<Guid, CollectionCipher>>)> GetOrganizationCiphers(Guid userId, Guid organizationId) public async Task<(IEnumerable<CipherOrganizationDetails>, Dictionary<Guid, IGrouping<Guid, CollectionCipher>>)> GetOrganizationCiphers(Guid userId, Guid organizationId)

View File

@ -669,6 +669,19 @@ public class CipherRepository : Repository<Cipher, Guid>, ICipherRepository
} }
} }
public async Task<DateTime> RestoreByIdsOrganizationIdAsync(IEnumerable<Guid> ids, Guid organizationId)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.ExecuteScalarAsync<DateTime>(
$"[{Schema}].[Cipher_RestoreByIdsOrganizationId]",
new { Ids = ids.ToGuidIdArrayTVP(), OrganizationId = organizationId },
commandType: CommandType.StoredProcedure);
return results;
}
}
public async Task DeleteDeletedAsync(DateTime deletedDateBefore) public async Task DeleteDeletedAsync(DateTime deletedDateBefore)
{ {
using (var connection = new SqlConnection(ConnectionString)) using (var connection = new SqlConnection(ConnectionString))

View File

@ -625,6 +625,31 @@ public class CipherRepository : Repository<Core.Vault.Entities.Cipher, Cipher, G
return await ToggleCipherStates(ids, userId, CipherStateAction.Restore); return await ToggleCipherStates(ids, userId, CipherStateAction.Restore);
} }
public async Task<DateTime> RestoreByIdsOrganizationIdAsync(IEnumerable<Guid> ids, Guid organizationId)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var utcNow = DateTime.UtcNow;
var ciphers = from c in dbContext.Ciphers
where c.OrganizationId == organizationId &&
ids.Contains(c.Id)
select c;
await ciphers.ForEachAsync(cipher =>
{
dbContext.Attach(cipher);
cipher.DeletedDate = null;
cipher.RevisionDate = utcNow;
});
await OrganizationUpdateStorage(organizationId);
await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(organizationId);
await dbContext.SaveChangesAsync();
return utcNow;
}
}
public async Task SoftDeleteAsync(IEnumerable<Guid> ids, Guid userId) public async Task SoftDeleteAsync(IEnumerable<Guid> ids, Guid userId)
{ {
await ToggleCipherStates(ids, userId, CipherStateAction.SoftDelete); await ToggleCipherStates(ids, userId, CipherStateAction.SoftDelete);

View File

@ -0,0 +1,29 @@
CREATE PROCEDURE [dbo].[Cipher_RestoreByIdsOrganizationId]
@Ids AS [dbo].[GuidIdArray] READONLY,
@OrganizationId AS UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
IF (SELECT COUNT(1) FROM @Ids) < 1
BEGIN
RETURN(-1)
END
-- Delete ciphers
DECLARE @UtcNow DATETIME2(7) = GETUTCDATE();
UPDATE
[dbo].[Cipher]
SET
[DeletedDate] = NULL,
[RevisionDate] = @UtcNow
WHERE
[Id] IN (SELECT * FROM @Ids)
AND OrganizationId = @OrganizationId
-- Cleanup organization
EXEC [dbo].[Organization_UpdateStorage] @OrganizationId
EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId
SELECT @UtcNow
END

View File

@ -1,4 +1,5 @@
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -601,21 +602,23 @@ public class CipherServiceTests
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task RestoreManyAsync_UpdatesCiphers(IEnumerable<CipherDetails> ciphers, public async Task RestoreManyAsync_UpdatesCiphers(ICollection<CipherDetails> ciphers,
SutProvider<CipherService> sutProvider) SutProvider<CipherService> sutProvider)
{ {
var cipherIds = ciphers.Select(c => c.Id).ToArray();
var restoringUserId = ciphers.First().UserId.Value; var restoringUserId = ciphers.First().UserId.Value;
var previousRevisionDate = DateTime.UtcNow; var previousRevisionDate = DateTime.UtcNow;
foreach (var cipher in ciphers) foreach (var cipher in ciphers)
{ {
cipher.Edit = true;
cipher.RevisionDate = previousRevisionDate; cipher.RevisionDate = previousRevisionDate;
} }
sutProvider.GetDependency<ICipherRepository>().GetManyByUserIdAsync(restoringUserId).Returns(ciphers);
var revisionDate = previousRevisionDate + TimeSpan.FromMinutes(1); var revisionDate = previousRevisionDate + TimeSpan.FromMinutes(1);
sutProvider.GetDependency<ICipherRepository>().RestoreAsync(Arg.Any<IEnumerable<Guid>>(), restoringUserId) sutProvider.GetDependency<ICipherRepository>().RestoreAsync(Arg.Any<IEnumerable<Guid>>(), restoringUserId).Returns(revisionDate);
.Returns(revisionDate);
await sutProvider.Sut.RestoreManyAsync(ciphers, restoringUserId); await sutProvider.Sut.RestoreManyAsync(cipherIds, restoringUserId);
foreach (var cipher in ciphers) foreach (var cipher in ciphers)
{ {
@ -624,6 +627,58 @@ public class CipherServiceTests
} }
} }
[Theory]
[BitAutoData]
public async Task RestoreManyAsync_WithOrgAdmin_UpdatesCiphers(Guid organizationId, ICollection<CipherOrganizationDetails> ciphers,
SutProvider<CipherService> sutProvider)
{
var cipherIds = ciphers.Select(c => c.Id).ToArray();
var restoringUserId = ciphers.First().UserId.Value;
var previousRevisionDate = DateTime.UtcNow;
foreach (var cipher in ciphers)
{
cipher.RevisionDate = previousRevisionDate;
cipher.OrganizationId = organizationId;
}
sutProvider.GetDependency<ICipherRepository>().GetManyOrganizationDetailsByOrganizationIdAsync(organizationId).Returns(ciphers);
var revisionDate = previousRevisionDate + TimeSpan.FromMinutes(1);
sutProvider.GetDependency<ICipherRepository>().RestoreByIdsOrganizationIdAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.All(i => cipherIds.Contains(i))), organizationId).Returns(revisionDate);
await sutProvider.Sut.RestoreManyAsync(cipherIds, restoringUserId, organizationId, true);
foreach (var cipher in ciphers)
{
Assert.Null(cipher.DeletedDate);
Assert.Equal(revisionDate, cipher.RevisionDate);
}
await sutProvider.GetDependency<IEventService>().Received(1).LogCipherEventsAsync(Arg.Is<IEnumerable<Tuple<Cipher, EventType, DateTime?>>>(events => events.All(e => cipherIds.Contains(e.Item1.Id))));
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncCiphersAsync(restoringUserId);
}
[Theory]
[BitAutoData]
public async Task RestoreManyAsync_WithEmptyCipherIdsArray_DoesNothing(Guid restoringUserId,
SutProvider<CipherService> sutProvider)
{
var cipherIds = Array.Empty<Guid>();
await sutProvider.Sut.RestoreManyAsync(cipherIds, restoringUserId);
await AssertNoActionsAsync(sutProvider);
}
[Theory]
[BitAutoData]
public async Task RestoreManyAsync_WithNullCipherIdsArray_DoesNothing(Guid restoringUserId,
SutProvider<CipherService> sutProvider)
{
await sutProvider.Sut.RestoreManyAsync(null, restoringUserId);
await AssertNoActionsAsync(sutProvider);
}
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task ShareManyAsync_FreeOrgWithAttachment_Throws(SutProvider<CipherService> sutProvider, public async Task ShareManyAsync_FreeOrgWithAttachment_Throws(SutProvider<CipherService> sutProvider,
IEnumerable<Cipher> ciphers, Guid organizationId, List<Guid> collectionIds) IEnumerable<Cipher> ciphers, Guid organizationId, List<Guid> collectionIds)
@ -667,4 +722,15 @@ public class CipherServiceTests
await sutProvider.GetDependency<ICipherRepository>().Received(1).UpdateCiphersAsync(sharingUserId, await sutProvider.GetDependency<ICipherRepository>().Received(1).UpdateCiphersAsync(sharingUserId,
Arg.Is<IEnumerable<Cipher>>(arg => arg.Except(ciphers).IsNullOrEmpty())); Arg.Is<IEnumerable<Cipher>>(arg => arg.Except(ciphers).IsNullOrEmpty()));
} }
private async Task AssertNoActionsAsync(SutProvider<CipherService> sutProvider)
{
await sutProvider.GetDependency<ICipherRepository>().DidNotReceiveWithAnyArgs().GetManyOrganizationDetailsByOrganizationIdAsync(default);
await sutProvider.GetDependency<ICipherRepository>().DidNotReceiveWithAnyArgs().RestoreByIdsOrganizationIdAsync(default, default);
await sutProvider.GetDependency<ICipherRepository>().DidNotReceiveWithAnyArgs().RestoreByIdsOrganizationIdAsync(default, default);
await sutProvider.GetDependency<ICipherRepository>().DidNotReceiveWithAnyArgs().GetManyByUserIdAsync(default);
await sutProvider.GetDependency<ICipherRepository>().DidNotReceiveWithAnyArgs().RestoreAsync(default, default);
await sutProvider.GetDependency<IEventService>().DidNotReceiveWithAnyArgs().LogCipherEventsAsync(default);
await sutProvider.GetDependency<IPushNotificationService>().DidNotReceiveWithAnyArgs().PushSyncCiphersAsync(default);
}
} }

View File

@ -0,0 +1,30 @@
CREATE OR ALTER PROCEDURE [dbo].[Cipher_RestoreByIdsOrganizationId]
@Ids AS [dbo].[GuidIdArray] READONLY,
@OrganizationId AS UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
IF (SELECT COUNT(1) FROM @Ids) < 1
BEGIN
RETURN(-1)
END
-- Delete ciphers
DECLARE @UtcNow DATETIME2(7) = GETUTCDATE();
UPDATE
[dbo].[Cipher]
SET
[DeletedDate] = NULL,
[RevisionDate] = @UtcNow
WHERE
[Id] IN (SELECT * FROM @Ids)
AND OrganizationId = @OrganizationId
-- Cleanup organization
EXEC [dbo].[Organization_UpdateStorage] @OrganizationId
EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId
SELECT @UtcNow
END
GO