mirror of
https://github.com/bitwarden/server.git
synced 2024-11-21 12:05:42 +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:
parent
4a110ad135
commit
d94a54516e
@ -451,7 +451,7 @@ public class CiphersController : Controller
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
@ -461,12 +461,30 @@ public class CiphersController : Controller
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var cipherIdsToRestore = new HashSet<Guid>(model.Ids.Select(i => new Guid(i)));
|
||||
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(userId);
|
||||
var restoringCiphers = ciphers.Where(c => cipherIdsToRestore.Contains(c.Id) && c.Edit);
|
||||
var restoredCiphers = await _cipherService.RestoreManyAsync(cipherIdsToRestore, userId);
|
||||
var responses = restoredCiphers.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp));
|
||||
return new ListResponseModel<CipherMiniResponseModel>(responses);
|
||||
}
|
||||
|
||||
await _cipherService.RestoreManyAsync(restoringCiphers, userId);
|
||||
var responses = restoringCiphers.Select(c => new CipherResponseModel(c, _globalSettings));
|
||||
return new ListResponseModel<CipherResponseModel>(responses);
|
||||
[HttpPut("restore-admin")]
|
||||
public async Task<ListResponseModel<CipherMiniResponseModel>> PutRestoreManyAdmin([FromBody] CipherBulkRestoreRequestModel model)
|
||||
{
|
||||
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")]
|
||||
|
@ -291,6 +291,7 @@ public class CipherBulkRestoreRequestModel
|
||||
{
|
||||
[Required]
|
||||
public IEnumerable<string> Ids { get; set; }
|
||||
public Guid OrganizationId { get; set; }
|
||||
}
|
||||
|
||||
public class CipherBulkMoveRequestModel
|
||||
|
@ -36,5 +36,6 @@ public interface ICipherRepository : IRepository<Cipher, Guid>
|
||||
Task SoftDeleteAsync(IEnumerable<Guid> ids, Guid userId);
|
||||
Task SoftDeleteByIdsOrganizationIdAsync(IEnumerable<Guid> ids, Guid organizationId);
|
||||
Task<DateTime> RestoreAsync(IEnumerable<Guid> ids, Guid userId);
|
||||
Task<DateTime> RestoreByIdsOrganizationIdAsync(IEnumerable<Guid> ids, Guid organizationId);
|
||||
Task DeleteDeletedAsync(DateTime deletedDateBefore);
|
||||
}
|
||||
|
@ -35,7 +35,7 @@ public interface ICipherService
|
||||
Task SoftDeleteAsync(Cipher cipher, Guid deletingUserId, 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 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<AttachmentResponseData> GetAttachmentDownloadDataAsync(Cipher cipher, string attachmentId);
|
||||
Task<bool> ValidateCipherAttachmentFile(Cipher cipher, CipherAttachment.MetaData attachmentData);
|
||||
|
@ -898,13 +898,33 @@ public class CipherService : ICipherService
|
||||
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);
|
||||
|
||||
var events = ciphers.Select(c =>
|
||||
if (cipherIds == null || !cipherIds.Any())
|
||||
{
|
||||
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;
|
||||
return new Tuple<Cipher, EventType, DateTime?>(c, EventType.Cipher_Restored, null);
|
||||
});
|
||||
@ -915,6 +935,8 @@ public class CipherService : ICipherService
|
||||
|
||||
// push
|
||||
await _pushService.PushSyncCiphersAsync(restoringUserId);
|
||||
|
||||
return restoringCiphers;
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<CipherOrganizationDetails>, Dictionary<Guid, IGrouping<Guid, CollectionCipher>>)> GetOrganizationCiphers(Guid userId, Guid organizationId)
|
||||
|
@ -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)
|
||||
{
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
|
@ -625,6 +625,31 @@ public class CipherRepository : Repository<Core.Vault.Entities.Cipher, Cipher, G
|
||||
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)
|
||||
{
|
||||
await ToggleCipherStates(ids, userId, CipherStateAction.SoftDelete);
|
||||
|
@ -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
|
@ -1,4 +1,5 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@ -601,21 +602,23 @@ public class CipherServiceTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RestoreManyAsync_UpdatesCiphers(IEnumerable<CipherDetails> ciphers,
|
||||
public async Task RestoreManyAsync_UpdatesCiphers(ICollection<CipherDetails> 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.Edit = true;
|
||||
cipher.RevisionDate = previousRevisionDate;
|
||||
}
|
||||
|
||||
sutProvider.GetDependency<ICipherRepository>().GetManyByUserIdAsync(restoringUserId).Returns(ciphers);
|
||||
var revisionDate = previousRevisionDate + TimeSpan.FromMinutes(1);
|
||||
sutProvider.GetDependency<ICipherRepository>().RestoreAsync(Arg.Any<IEnumerable<Guid>>(), restoringUserId)
|
||||
.Returns(revisionDate);
|
||||
sutProvider.GetDependency<ICipherRepository>().RestoreAsync(Arg.Any<IEnumerable<Guid>>(), restoringUserId).Returns(revisionDate);
|
||||
|
||||
await sutProvider.Sut.RestoreManyAsync(ciphers, restoringUserId);
|
||||
await sutProvider.Sut.RestoreManyAsync(cipherIds, restoringUserId);
|
||||
|
||||
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]
|
||||
public async Task ShareManyAsync_FreeOrgWithAttachment_Throws(SutProvider<CipherService> sutProvider,
|
||||
IEnumerable<Cipher> ciphers, Guid organizationId, List<Guid> collectionIds)
|
||||
@ -667,4 +722,15 @@ public class CipherServiceTests
|
||||
await sutProvider.GetDependency<ICipherRepository>().Received(1).UpdateCiphersAsync(sharingUserId,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -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
|
Loading…
Reference in New Issue
Block a user