1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-21 12:05:42 +01:00

[PM-2383] Bulk collection assignment (#3919)

* [PM-2383] Add bulk add/remove collection cipher repository methods

* [PM-2383] Add additional authorization helpers for CiphersControlle

* [PM-2383] Add /bulk-collections endpoint to CiphersController.cs

* [PM-2383] Add EF implementation for new CollectionCipherRepository methods

* [PM-2383] Ensure V1 logic only applies when the flag is enabled for new bulk functionality
This commit is contained in:
Shane Melton 2024-03-22 13:16:34 -07:00 committed by GitHub
parent 5dd1a9410a
commit 6a0f6e1dac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 448 additions and 1 deletions

View File

@ -45,6 +45,7 @@ public class CiphersController : Controller
private readonly IFeatureService _featureService;
private readonly IOrganizationCiphersQuery _organizationCiphersQuery;
private readonly IApplicationCacheService _applicationCacheService;
private readonly ICollectionRepository _collectionRepository;
private bool UseFlexibleCollections =>
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections);
@ -61,7 +62,8 @@ public class CiphersController : Controller
GlobalSettings globalSettings,
IFeatureService featureService,
IOrganizationCiphersQuery organizationCiphersQuery,
IApplicationCacheService applicationCacheService)
IApplicationCacheService applicationCacheService,
ICollectionRepository collectionRepository)
{
_cipherRepository = cipherRepository;
_collectionCipherRepository = collectionCipherRepository;
@ -75,6 +77,7 @@ public class CiphersController : Controller
_featureService = featureService;
_organizationCiphersQuery = organizationCiphersQuery;
_applicationCacheService = applicationCacheService;
_collectionRepository = collectionRepository;
}
[HttpGet("{id}")]
@ -342,6 +345,45 @@ public class CiphersController : Controller
return false;
}
/// <summary>
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062
/// </summary>
private async Task<bool> CanEditAllCiphersAsync(Guid organizationId)
{
var org = _currentContext.GetOrganization(organizationId);
// If not using V1, owners, admins, and users with EditAnyCollection permissions, and providers can always edit all ciphers
if (!await UseFlexibleCollectionsV1Async(organizationId))
{
return org is { Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
{ Permissions.EditAnyCollection: true } ||
await _currentContext.ProviderUserForOrgAsync(organizationId);
}
// Custom users with EditAnyCollection permissions can always edit all ciphers
if (org is { Type: OrganizationUserType.Custom, Permissions.EditAnyCollection: true })
{
return true;
}
var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId);
// Owners/Admins can only edit all ciphers if the organization has the setting enabled
if (orgAbility is { AllowAdminAccessToAllCollectionItems: true } && org is
{ Type: OrganizationUserType.Admin or OrganizationUserType.Owner })
{
return true;
}
// Provider users can edit all ciphers in V1 (to change later)
if (await _currentContext.ProviderUserForOrgAsync(organizationId))
{
return true;
}
return false;
}
/// <summary>
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062
/// </summary>
@ -388,6 +430,97 @@ public class CiphersController : Controller
return false;
}
/// <summary>
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062
/// </summary>
private async Task<bool> CanEditCiphersAsync(Guid organizationId, IEnumerable<Guid> cipherIds)
{
// If the user can edit all ciphers for the organization, just check they all belong to the org
if (await CanEditAllCiphersAsync(organizationId))
{
// TODO: This can likely be optimized to only query the requested ciphers and then checking they belong to the org
var orgCiphers = (await _cipherRepository.GetManyByOrganizationIdAsync(organizationId)).ToDictionary(c => c.Id);
// Ensure all requested ciphers are in orgCiphers
if (cipherIds.Any(c => !orgCiphers.ContainsKey(c)))
{
return false;
}
return true;
}
// The user cannot access any ciphers for the organization, we're done
if (!await CanAccessOrganizationCiphersAsync(organizationId))
{
return false;
}
var userId = _userService.GetProperUserId(User).Value;
// Select all editable ciphers for this user belonging to the organization
var editableOrgCipherList = (await _cipherRepository.GetManyByUserIdAsync(userId, true))
.Where(c => c.OrganizationId == organizationId && c.UserId == null && c.Edit).ToList();
// Special case for unassigned ciphers
if (await CanAccessUnassignedCiphersAsync(organizationId))
{
var unassignedCiphers =
(await _cipherRepository.GetManyUnassignedOrganizationDetailsByOrganizationIdAsync(
organizationId));
// Users that can access unassigned ciphers can also edit them
editableOrgCipherList.AddRange(unassignedCiphers.Select(c => new CipherDetails(c) { Edit = true }));
}
var editableOrgCiphers = editableOrgCipherList
.ToDictionary(c => c.Id);
if (cipherIds.Any(c => !editableOrgCiphers.ContainsKey(c)))
{
return false;
}
return true;
}
/// <summary>
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062
/// This likely belongs to the BulkCollectionAuthorizationHandler
/// </summary>
private async Task<bool> CanEditItemsInCollections(Guid organizationId, IEnumerable<Guid> collectionIds)
{
if (await CanEditAllCiphersAsync(organizationId))
{
// TODO: This can likely be optimized to only query the requested ciphers and then checking they belong to the org
var orgCollections = (await _collectionRepository.GetManyByOrganizationIdAsync(organizationId)).ToDictionary(c => c.Id);
// Ensure all requested collections are in orgCollections
if (collectionIds.Any(c => !orgCollections.ContainsKey(c)))
{
return false;
}
return true;
}
if (!await CanAccessOrganizationCiphersAsync(organizationId))
{
return false;
}
var userId = _userService.GetProperUserId(User).Value;
var editableCollections = (await _collectionRepository.GetManyByUserIdAsync(userId, true))
.Where(c => c.OrganizationId == organizationId && !c.ReadOnly)
.ToDictionary(c => c.Id);
if (collectionIds.Any(c => !editableCollections.ContainsKey(c)))
{
return false;
}
return true;
}
[HttpPut("{id}/partial")]
[HttpPost("{id}/partial")]
public async Task<CipherResponseModel> PutPartial(Guid id, [FromBody] CipherPartialRequestModel model)
@ -457,6 +590,33 @@ public class CiphersController : Controller
model.CollectionIds.Select(c => new Guid(c)), userId, true);
}
[HttpPost("bulk-collections")]
public async Task PostBulkCollections([FromBody] CipherBulkUpdateCollectionsRequestModel model)
{
var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(model.OrganizationId);
// Only available for organizations with flexible collections
if (orgAbility is null or { FlexibleCollections: false })
{
throw new NotFoundException();
}
if (!await CanEditCiphersAsync(model.OrganizationId, model.CipherIds) ||
!await CanEditItemsInCollections(model.OrganizationId, model.CollectionIds))
{
throw new NotFoundException();
}
if (model.RemoveCollections)
{
await _collectionCipherRepository.RemoveCollectionsForManyCiphersAsync(model.OrganizationId, model.CipherIds, model.CollectionIds);
}
else
{
await _collectionCipherRepository.AddCollectionsForManyCiphersAsync(model.OrganizationId, model.CipherIds, model.CollectionIds);
}
}
[HttpDelete("{id}")]
[HttpPost("{id}/delete")]
public async Task Delete(Guid id)

View File

@ -0,0 +1,15 @@
namespace Bit.Api.Vault.Models.Request;
public class CipherBulkUpdateCollectionsRequestModel
{
public Guid OrganizationId { get; set; }
public IEnumerable<Guid> CipherIds { get; set; }
public IEnumerable<Guid> CollectionIds { get; set; }
/// <summary>
/// If true, the collections will be removed from the ciphers. Otherwise, they will be added.
/// </summary>
public bool RemoveCollections { get; set; }
}

View File

@ -11,4 +11,22 @@ public interface ICollectionCipherRepository
Task UpdateCollectionsForAdminAsync(Guid cipherId, Guid organizationId, IEnumerable<Guid> collectionIds);
Task UpdateCollectionsForCiphersAsync(IEnumerable<Guid> cipherIds, Guid userId, Guid organizationId,
IEnumerable<Guid> collectionIds, bool useFlexibleCollections);
/// <summary>
/// Add the specified collections to the specified ciphers. If a cipher already belongs to a requested collection,
/// no action is taken.
/// </summary>
/// <remarks>
/// This method does not perform any authorization checks.
/// </remarks>
Task AddCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable<Guid> cipherIds, IEnumerable<Guid> collectionIds);
/// <summary>
/// Remove the specified collections from the specified ciphers. If a cipher does not belong to a requested collection,
/// no action is taken.
/// </summary>
/// <remarks>
/// This method does not perform any authorization checks.
/// </remarks>
Task RemoveCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable<Guid> cipherIds, IEnumerable<Guid> collectionIds);
}

View File

@ -8,6 +8,26 @@ public class CipherDetails : CipherOrganizationDetails
public bool Favorite { get; set; }
public bool Edit { get; set; }
public bool ViewPassword { get; set; }
public CipherDetails() { }
public CipherDetails(CipherOrganizationDetails cipher)
{
Id = cipher.Id;
UserId = cipher.UserId;
OrganizationId = cipher.OrganizationId;
Type = cipher.Type;
Data = cipher.Data;
Favorites = cipher.Favorites;
Folders = cipher.Folders;
Attachments = cipher.Attachments;
CreationDate = cipher.CreationDate;
RevisionDate = cipher.RevisionDate;
DeletedDate = cipher.DeletedDate;
Reprompt = cipher.Reprompt;
Key = cipher.Key;
OrganizationUseTotp = cipher.OrganizationUseTotp;
}
}
public class CipherDetailsWithCollections : CipherDetails

View File

@ -111,4 +111,28 @@ public class CollectionCipherRepository : BaseRepository, ICollectionCipherRepos
commandType: CommandType.StoredProcedure);
}
}
public async Task AddCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable<Guid> cipherIds,
IEnumerable<Guid> collectionIds)
{
using (var connection = new SqlConnection(ConnectionString))
{
await connection.ExecuteAsync(
"[dbo].[CollectionCipher_AddCollectionsForManyCiphers]",
new { CipherIds = cipherIds.ToGuidIdArrayTVP(), OrganizationId = organizationId, CollectionIds = collectionIds.ToGuidIdArrayTVP() },
commandType: CommandType.StoredProcedure);
}
}
public async Task RemoveCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable<Guid> cipherIds,
IEnumerable<Guid> collectionIds)
{
using (var connection = new SqlConnection(ConnectionString))
{
await connection.ExecuteAsync(
"[dbo].[CollectionCipher_RemoveCollectionsForManyCiphers]",
new { CipherIds = cipherIds.ToGuidIdArrayTVP(), OrganizationId = organizationId, CollectionIds = collectionIds.ToGuidIdArrayTVP() },
commandType: CommandType.StoredProcedure);
}
}
}

View File

@ -248,4 +248,47 @@ public class CollectionCipherRepository : BaseEntityFrameworkRepository, ICollec
await dbContext.SaveChangesAsync();
}
}
public async Task AddCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable<Guid> cipherIds,
IEnumerable<Guid> collectionIds)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var availableCollections = await (from c in dbContext.Collections
join o in dbContext.Organizations on c.OrganizationId equals o.Id
where o.Id == organizationId && o.Enabled
select c).ToListAsync();
var currentCollectionCiphers = await (from cc in dbContext.CollectionCiphers
where cipherIds.Contains(cc.CipherId)
select cc).ToListAsync();
var insertData = from collectionId in collectionIds
from cipherId in cipherIds
where
availableCollections.Select(c => c.Id).Contains(collectionId) &&
!currentCollectionCiphers.Any(cc => cc.CipherId == cipherId && cc.CollectionId == collectionId)
select new Models.CollectionCipher { CollectionId = collectionId, CipherId = cipherId, };
await dbContext.AddRangeAsync(insertData);
await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(organizationId);
await dbContext.SaveChangesAsync();
}
}
public async Task RemoveCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable<Guid> cipherIds,
IEnumerable<Guid> collectionIds)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var currentCollectionCiphersToBeRemoved = await (from cc in dbContext.CollectionCiphers
where cipherIds.Contains(cc.CipherId) && collectionIds.Contains(cc.CollectionId)
select cc).ToListAsync();
dbContext.RemoveRange(currentCollectionCiphersToBeRemoved);
await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(organizationId);
await dbContext.SaveChangesAsync();
}
}
}

View File

@ -0,0 +1,56 @@
CREATE PROCEDURE [dbo].[CollectionCipher_AddCollectionsForManyCiphers]
@CipherIds AS [dbo].[GuidIdArray] READONLY,
@OrganizationId UNIQUEIDENTIFIER,
@CollectionIds AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON
CREATE TABLE #AvailableCollections (
[Id] UNIQUEIDENTIFIER
)
INSERT INTO #AvailableCollections
SELECT
C.[Id]
FROM
[dbo].[Collection] C
INNER JOIN
[dbo].[Organization] O ON O.[Id] = C.[OrganizationId]
WHERE
O.[Id] = @OrganizationId AND O.[Enabled] = 1
IF (SELECT COUNT(1) FROM #AvailableCollections) < 1
BEGIN
-- No collections available
RETURN
END
;WITH [SourceCollectionCipherCTE] AS(
SELECT
[Collection].[Id] AS [CollectionId],
[Cipher].[Id] AS [CipherId]
FROM
@CollectionIds AS [Collection]
CROSS JOIN
@CipherIds AS [Cipher]
WHERE
[Collection].[Id] IN (SELECT [Id] FROM #AvailableCollections)
)
MERGE
[CollectionCipher] AS [Target]
USING
[SourceCollectionCipherCTE] AS [Source]
ON
[Target].[CollectionId] = [Source].[CollectionId]
AND [Target].[CipherId] = [Source].[CipherId]
WHEN NOT MATCHED BY TARGET THEN
INSERT VALUES
(
[Source].[CollectionId],
[Source].[CipherId]
)
;
EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId
END

View File

@ -0,0 +1,26 @@
CREATE PROCEDURE [dbo].[CollectionCipher_RemoveCollectionsForManyCiphers]
@CipherIds AS [dbo].[GuidIdArray] READONLY,
@OrganizationId UNIQUEIDENTIFIER,
@CollectionIds AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON
DECLARE @BatchSize INT = 100
WHILE @BatchSize > 0
BEGIN
BEGIN TRANSACTION CollectionCipher_DeleteMany
DELETE TOP(@BatchSize)
FROM
[dbo].[CollectionCipher]
WHERE
[CipherId] IN (SELECT [Id] FROM @CipherIds) AND
[CollectionId] IN (SELECT [Id] FROM @CollectionIds)
SET @BatchSize = @@ROWCOUNT
COMMIT TRANSACTION CollectionCipher_DeleteMany
END
EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId
END

View File

@ -0,0 +1,85 @@
CREATE OR ALTER PROCEDURE [dbo].[CollectionCipher_AddCollectionsForManyCiphers]
@CipherIds AS [dbo].[GuidIdArray] READONLY,
@OrganizationId UNIQUEIDENTIFIER,
@CollectionIds AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON
CREATE TABLE #AvailableCollections (
[Id] UNIQUEIDENTIFIER
)
INSERT INTO #AvailableCollections
SELECT
C.[Id]
FROM
[dbo].[Collection] C
INNER JOIN
[dbo].[Organization] O ON O.[Id] = C.[OrganizationId]
WHERE
O.[Id] = @OrganizationId AND O.[Enabled] = 1
IF (SELECT COUNT(1) FROM #AvailableCollections) < 1
BEGIN
-- No collections available
RETURN
END
;WITH [SourceCollectionCipherCTE] AS(
SELECT
[Collection].[Id] AS [CollectionId],
[Cipher].[Id] AS [CipherId]
FROM
@CollectionIds AS [Collection]
CROSS JOIN
@CipherIds AS [Cipher]
WHERE
[Collection].[Id] IN (SELECT [Id] FROM #AvailableCollections)
)
MERGE
[CollectionCipher] AS [Target]
USING
[SourceCollectionCipherCTE] AS [Source]
ON
[Target].[CollectionId] = [Source].[CollectionId]
AND [Target].[CipherId] = [Source].[CipherId]
WHEN NOT MATCHED BY TARGET THEN
INSERT VALUES
(
[Source].[CollectionId],
[Source].[CipherId]
)
;
EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId
END
GO
CREATE OR ALTER PROCEDURE [dbo].[CollectionCipher_RemoveCollectionsForManyCiphers]
@CipherIds AS [dbo].[GuidIdArray] READONLY,
@OrganizationId UNIQUEIDENTIFIER,
@CollectionIds AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON
DECLARE @BatchSize INT = 100
WHILE @BatchSize > 0
BEGIN
BEGIN TRANSACTION CollectionCipher_DeleteMany
DELETE TOP(@BatchSize)
FROM
[dbo].[CollectionCipher]
WHERE
[CipherId] IN (SELECT [Id] FROM @CipherIds) AND
[CollectionId] IN (SELECT [Id] FROM @CollectionIds)
SET @BatchSize = @@ROWCOUNT
COMMIT TRANSACTION CollectionCipher_DeleteMany
END
EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId
END
GO