1
0
mirror of https://github.com/bitwarden/server synced 2026-01-02 00:23:40 +00:00

[AC-1174] Bulk Collection Management (#3229)

* [AC-1174] Update SelectionReadOnlyRequestModel to use Guid for Id property

* [AC-1174] Introduce initial bulk-access collection endpoint

* [AC-1174] Introduce BulkAddCollectionAccessCommand and validation logic/tests

* [AC-1174] Add CreateOrUpdateAccessMany method to CollectionRepository

* [AC-1174] Add event logs for bulk add collection access command

* [AC-1174] Add User_BumpAccountRevisionDateByCollectionIds and database migration script

* [AC-1174] Implement EF repository method

* [AC-1174] Improve null checks

* [AC-1174] Remove unnecessary BulkCollectionAccessRequestModel helpers

* [AC-1174] Add unit tests for new controller endpoint

* [AC-1174] Fix formatting

* [AC-1174] Remove comment

* [AC-1174] Remove redundant organizationId parameter

* [AC-1174] Ensure user and group Ids are distinct

* [AC-1174] Cleanup tests based on PR feedback

* [AC-1174] Formatting

* [AC-1174] Update CollectionGroup alias in the sproc

* [AC-1174] Add some additional comments to SQL sproc

* [AC-1174] Add comment explaining additional SaveChangesAsync call

---------

Co-authored-by: Thomas Rittson <trittson@bitwarden.com>
This commit is contained in:
Shane Melton
2023-09-26 09:30:07 -07:00
committed by GitHub
parent 2c7d02dcbb
commit 5d431adbd4
16 changed files with 943 additions and 5 deletions

View File

@@ -20,23 +20,26 @@ public class CollectionsController : Controller
private readonly ICollectionService _collectionService;
private readonly IDeleteCollectionCommand _deleteCollectionCommand;
private readonly IUserService _userService;
private readonly ICurrentContext _currentContext;
private readonly IAuthorizationService _authorizationService;
private readonly ICurrentContext _currentContext;
private readonly IBulkAddCollectionAccessCommand _bulkAddCollectionAccessCommand;
public CollectionsController(
ICollectionRepository collectionRepository,
ICollectionService collectionService,
IDeleteCollectionCommand deleteCollectionCommand,
IUserService userService,
IAuthorizationService authorizationService,
ICurrentContext currentContext,
IAuthorizationService authorizationService)
IBulkAddCollectionAccessCommand bulkAddCollectionAccessCommand)
{
_collectionRepository = collectionRepository;
_collectionService = collectionService;
_deleteCollectionCommand = deleteCollectionCommand;
_userService = userService;
_currentContext = currentContext;
_authorizationService = authorizationService;
_currentContext = currentContext;
_bulkAddCollectionAccessCommand = bulkAddCollectionAccessCommand;
}
[HttpGet("{id}")]
@@ -190,6 +193,29 @@ public class CollectionsController : Controller
await _collectionRepository.UpdateUsersAsync(collection.Id, model?.Select(g => g.ToSelectionReadOnly()));
}
[HttpPost("bulk-access")]
public async Task PostBulkCollectionAccess([FromBody] BulkCollectionAccessRequestModel model)
{
var collections = await _collectionRepository.GetManyByManyIdsAsync(model.CollectionIds);
if (collections.Count != model.CollectionIds.Count())
{
throw new NotFoundException("One or more collections not found.");
}
var result = await _authorizationService.AuthorizeAsync(User, collections, CollectionOperations.ModifyAccess);
if (!result.Succeeded)
{
throw new NotFoundException();
}
await _bulkAddCollectionAccessCommand.AddAccessAsync(
collections,
model.Users?.Select(u => u.ToSelectionReadOnly()).ToList(),
model.Groups?.Select(g => g.ToSelectionReadOnly()).ToList());
}
[HttpDelete("{id}")]
[HttpPost("{id}/delete")]
public async Task Delete(Guid orgId, Guid id)

View File

@@ -0,0 +1,8 @@
namespace Bit.Api.Models.Request;
public class BulkCollectionAccessRequestModel
{
public IEnumerable<Guid> CollectionIds { get; set; }
public IEnumerable<SelectionReadOnlyRequestModel> Groups { get; set; }
public IEnumerable<SelectionReadOnlyRequestModel> Users { get; set; }
}

View File

@@ -6,7 +6,7 @@ namespace Bit.Api.Models.Request;
public class SelectionReadOnlyRequestModel
{
[Required]
public string Id { get; set; }
public Guid Id { get; set; }
public bool ReadOnly { get; set; }
public bool HidePasswords { get; set; }
public bool Manage { get; set; }
@@ -15,7 +15,7 @@ public class SelectionReadOnlyRequestModel
{
return new CollectionAccessSelection
{
Id = new Guid(Id),
Id = Id,
ReadOnly = ReadOnly,
HidePasswords = HidePasswords,
Manage = Manage,

View File

@@ -0,0 +1,95 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
namespace Bit.Core.OrganizationFeatures.OrganizationCollections;
public class BulkAddCollectionAccessCommand : IBulkAddCollectionAccessCommand
{
private readonly ICollectionRepository _collectionRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IGroupRepository _groupRepository;
private readonly IEventService _eventService;
public BulkAddCollectionAccessCommand(
ICollectionRepository collectionRepository,
IOrganizationUserRepository organizationUserRepository,
IGroupRepository groupRepository,
IEventService eventService)
{
_collectionRepository = collectionRepository;
_organizationUserRepository = organizationUserRepository;
_groupRepository = groupRepository;
_eventService = eventService;
}
public async Task AddAccessAsync(ICollection<Collection> collections,
ICollection<CollectionAccessSelection> users,
ICollection<CollectionAccessSelection> groups)
{
await ValidateRequestAsync(collections, users, groups);
await _collectionRepository.CreateOrUpdateAccessForManyAsync(
collections.First().OrganizationId,
collections.Select(c => c.Id),
users,
groups
);
await _eventService.LogCollectionEventsAsync(collections.Select(c =>
(c, EventType.Collection_Updated, (DateTime?)DateTime.UtcNow)));
}
private async Task ValidateRequestAsync(ICollection<Collection> collections, ICollection<CollectionAccessSelection> usersAccess, ICollection<CollectionAccessSelection> groupsAccess)
{
if (collections == null || collections.Count == 0)
{
throw new BadRequestException("No collections were provided.");
}
var orgId = collections.First().OrganizationId;
if (collections.Any(c => c.OrganizationId != orgId))
{
throw new BadRequestException("All collections must belong to the same organization.");
}
var collectionUserIds = usersAccess?.Select(u => u.Id).Distinct().ToList();
if (collectionUserIds is { Count: > 0 })
{
var users = await _organizationUserRepository.GetManyAsync(collectionUserIds);
if (users.Count != collectionUserIds.Count)
{
throw new BadRequestException("One or more users do not exist.");
}
if (users.Any(u => u.OrganizationId != orgId))
{
throw new BadRequestException("One or more users do not belong to the same organization as the collection being assigned.");
}
}
var collectionGroupIds = groupsAccess?.Select(g => g.Id).Distinct().ToList();
if (collectionGroupIds is { Count: > 0 })
{
var groups = await _groupRepository.GetManyByManyIds(collectionGroupIds);
if (groups.Count != collectionGroupIds.Count)
{
throw new BadRequestException("One or more groups do not exist.");
}
if (groups.Any(g => g.OrganizationId != orgId))
{
throw new BadRequestException("One or more groups do not belong to the same organization as the collection being assigned.");
}
}
}
}

View File

@@ -0,0 +1,10 @@
using Bit.Core.Entities;
using Bit.Core.Models.Data;
namespace Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces;
public interface IBulkAddCollectionAccessCommand
{
Task AddAccessAsync(ICollection<Collection> collections,
ICollection<CollectionAccessSelection> users, ICollection<CollectionAccessSelection> groups);
}

View File

@@ -98,6 +98,7 @@ public static class OrganizationServiceCollectionExtensions
public static void AddOrganizationCollectionCommands(this IServiceCollection services)
{
services.AddScoped<IDeleteCollectionCommand, DeleteCollectionCommand>();
services.AddScoped<IBulkAddCollectionAccessCommand, BulkAddCollectionAccessCommand>();
}
private static void AddOrganizationGroupCommands(this IServiceCollection services)

View File

@@ -20,4 +20,6 @@ public interface ICollectionRepository : IRepository<Collection, Guid>
Task UpdateUsersAsync(Guid id, IEnumerable<CollectionAccessSelection> users);
Task<ICollection<CollectionAccessSelection>> GetManyUsersByIdAsync(Guid id);
Task DeleteManyAsync(IEnumerable<Guid> collectionIds);
Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumerable<Guid> collectionIds,
IEnumerable<CollectionAccessSelection> users, IEnumerable<CollectionAccessSelection> groups);
}

View File

@@ -252,6 +252,21 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
}
}
public async Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumerable<Guid> collectionIds,
IEnumerable<CollectionAccessSelection> users, IEnumerable<CollectionAccessSelection> groups)
{
using (var connection = new SqlConnection(ConnectionString))
{
var usersArray = users != null ? users.ToArrayTVP() : Enumerable.Empty<CollectionAccessSelection>().ToArrayTVP();
var groupsArray = groups != null ? groups.ToArrayTVP() : Enumerable.Empty<CollectionAccessSelection>().ToArrayTVP();
var results = await connection.ExecuteAsync(
$"[{Schema}].[Collection_CreateOrUpdateAccessForMany]",
new { OrganizationId = organizationId, CollectionIds = collectionIds.ToGuidIdArrayTVP(), Users = usersArray, Groups = groupsArray },
commandType: CommandType.StoredProcedure);
}
}
public async Task CreateUserAsync(Guid collectionId, Guid organizationUserId)
{
using (var connection = new SqlConnection(ConnectionString))

View File

@@ -473,6 +473,97 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
}
}
public async Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumerable<Guid> collectionIds,
IEnumerable<CollectionAccessSelection> users, IEnumerable<CollectionAccessSelection> groups)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var collectionIdsList = collectionIds.ToList();
if (users != null)
{
var existingCollectionUsers = await dbContext.CollectionUsers
.Where(cu => collectionIdsList.Contains(cu.CollectionId))
.ToDictionaryAsync(x => (x.CollectionId, x.OrganizationUserId));
var requestedUsers = users.ToList();
foreach (var collectionId in collectionIdsList)
{
foreach (var requestedUser in requestedUsers)
{
if (!existingCollectionUsers.TryGetValue(
(collectionId, requestedUser.Id),
out var existingCollectionUser)
)
{
// This is a brand new entry
dbContext.CollectionUsers.Add(new CollectionUser
{
CollectionId = collectionId,
OrganizationUserId = requestedUser.Id,
HidePasswords = requestedUser.HidePasswords,
ReadOnly = requestedUser.ReadOnly,
Manage = requestedUser.Manage
});
continue;
}
// It already exists, update it
existingCollectionUser.HidePasswords = requestedUser.HidePasswords;
existingCollectionUser.ReadOnly = requestedUser.ReadOnly;
existingCollectionUser.Manage = requestedUser.Manage;
dbContext.CollectionUsers.Update(existingCollectionUser);
}
}
}
if (groups != null)
{
var existingCollectionGroups = await dbContext.CollectionGroups
.Where(cu => collectionIdsList.Contains(cu.CollectionId))
.ToDictionaryAsync(x => (x.CollectionId, x.GroupId));
var requestedGroups = groups.ToList();
foreach (var collectionId in collectionIdsList)
{
foreach (var requestedGroup in requestedGroups)
{
if (!existingCollectionGroups.TryGetValue(
(collectionId, requestedGroup.Id),
out var existingCollectionGroup)
)
{
// This is a brand new entry
dbContext.CollectionGroups.Add(new CollectionGroup()
{
CollectionId = collectionId,
GroupId = requestedGroup.Id,
HidePasswords = requestedGroup.HidePasswords,
ReadOnly = requestedGroup.ReadOnly,
Manage = requestedGroup.Manage
});
continue;
}
// It already exists, update it
existingCollectionGroup.HidePasswords = requestedGroup.HidePasswords;
existingCollectionGroup.ReadOnly = requestedGroup.ReadOnly;
existingCollectionGroup.Manage = requestedGroup.Manage;
dbContext.CollectionGroups.Update(existingCollectionGroup);
}
}
}
// Need to save the new collection users/groups before running the bump revision code
await dbContext.SaveChangesAsync();
await dbContext.UserBumpAccountRevisionDateByCollectionIdsAsync(collectionIdsList, organizationId);
await dbContext.SaveChangesAsync();
}
}
private async Task ReplaceCollectionGroupsAsync(DatabaseContext dbContext, Core.Entities.Collection collection, IEnumerable<CollectionAccessSelection> groups)
{
var groupsInOrg = dbContext.Groups.Where(g => g.OrganizationId == collection.OrganizationId);

View File

@@ -74,6 +74,39 @@ public static class DatabaseContextExtensions
UpdateUserRevisionDate(users);
}
public static async Task UserBumpAccountRevisionDateByCollectionIdsAsync(this DatabaseContext context, IEnumerable<Guid> collectionIds, Guid organizationId)
{
var query = from u in context.Users
from c in context.Collections
join ou in context.OrganizationUsers
on u.Id equals ou.UserId
join cu in context.CollectionUsers
on new { ou.AccessAll, OrganizationUserId = ou.Id, CollectionId = c.Id } equals
new { AccessAll = false, cu.OrganizationUserId, cu.CollectionId } into cu_g
from cu in cu_g.DefaultIfEmpty()
join gu in context.GroupUsers
on new { CollectionId = (Guid?)cu.CollectionId, ou.AccessAll, OrganizationUserId = ou.Id } equals
new { CollectionId = (Guid?)null, AccessAll = false, gu.OrganizationUserId } into gu_g
from gu in gu_g.DefaultIfEmpty()
join g in context.Groups
on gu.GroupId equals g.Id into g_g
from g in g_g.DefaultIfEmpty()
join cg in context.CollectionGroups
on new { g.AccessAll, gu.GroupId, CollectionId = c.Id } equals
new { AccessAll = false, cg.GroupId, cg.CollectionId } into cg_g
from cg in cg_g.DefaultIfEmpty()
where ou.OrganizationId == organizationId && collectionIds.Contains(c.Id) &&
ou.Status == OrganizationUserStatusType.Confirmed &&
(cu.CollectionId != null ||
cg.CollectionId != null ||
ou.AccessAll == true ||
g.AccessAll == true)
select u;
var users = await query.ToListAsync();
UpdateUserRevisionDate(users);
}
public static async Task UserBumpAccountRevisionDateByOrganizationUserIdAsync(this DatabaseContext context, Guid organizationUserId)
{
var query = from u in context.Users

View File

@@ -0,0 +1,97 @@
CREATE PROCEDURE [dbo].[Collection_CreateOrUpdateAccessForMany]
@OrganizationId UNIQUEIDENTIFIER,
@CollectionIds AS [dbo].[GuidIdArray] READONLY,
@Groups AS [dbo].[SelectionReadOnlyArray] READONLY,
@Users AS [dbo].[SelectionReadOnlyArray] READONLY
AS
BEGIN
SET NOCOUNT ON
-- Groups
;WITH [NewCollectionGroups] AS (
SELECT
cId.[Id] AS [CollectionId],
cg.[Id] AS [GroupId],
cg.[ReadOnly],
cg.[HidePasswords],
cg.[Manage]
FROM
@Groups AS cg
CROSS JOIN -- Create a CollectionGroup record for every CollectionId
@CollectionIds cId
INNER JOIN
[dbo].[Group] g ON cg.[Id] = g.[Id]
WHERE
g.[OrganizationId] = @OrganizationId
)
MERGE
[dbo].[CollectionGroup] as [Target]
USING
[NewCollectionGroups] AS [Source]
ON
[Target].[CollectionId] = [Source].[CollectionId]
AND [Target].[GroupId] = [Source].[GroupId]
-- Update the target if any values are different from the source
WHEN MATCHED AND EXISTS(
SELECT [Source].[ReadOnly], [Source].[HidePasswords], [Source].[Manage]
EXCEPT
SELECT [Target].[ReadOnly], [Target].[HidePasswords], [Target].[Manage]
) THEN UPDATE SET
[Target].[ReadOnly] = [Source].[ReadOnly],
[Target].[HidePasswords] = [Source].[HidePasswords],
[Target].[Manage] = [Source].[Manage]
WHEN NOT MATCHED BY TARGET
THEN INSERT VALUES
(
[Source].[CollectionId],
[Source].[GroupId],
[Source].[ReadOnly],
[Source].[HidePasswords],
[Source].[Manage]
);
-- Users
;WITH [NewCollectionUsers] AS (
SELECT
cId.[Id] AS [CollectionId],
cu.[Id] AS [OrganizationUserId],
cu.[ReadOnly],
cu.[HidePasswords],
cu.[Manage]
FROM
@Users AS cu
CROSS JOIN -- Create a CollectionUser record for every CollectionId
@CollectionIds cId
INNER JOIN
[dbo].[OrganizationUser] u ON cu.[Id] = u.[Id]
WHERE
u.[OrganizationId] = @OrganizationId
)
MERGE
[dbo].[CollectionUser] as [Target]
USING
[NewCollectionUsers] AS [Source]
ON
[Target].[CollectionId] = [Source].[CollectionId]
AND [Target].[OrganizationUserId] = [Source].[OrganizationUserId]
-- Update the target if any values are different from the source
WHEN MATCHED AND EXISTS(
SELECT [Source].[ReadOnly], [Source].[HidePasswords], [Source].[Manage]
EXCEPT
SELECT [Target].[ReadOnly], [Target].[HidePasswords], [Target].[Manage]
) THEN UPDATE SET
[Target].[ReadOnly] = [Source].[ReadOnly],
[Target].[HidePasswords] = [Source].[HidePasswords],
[Target].[Manage] = [Source].[Manage]
WHEN NOT MATCHED BY TARGET
THEN INSERT VALUES
(
[Source].[CollectionId],
[Source].[OrganizationUserId],
[Source].[ReadOnly],
[Source].[HidePasswords],
[Source].[Manage]
);
EXEC [dbo].[User_BumpAccountRevisionDateByCollectionIds] @CollectionIds, @OrganizationId
END

View File

@@ -0,0 +1,35 @@
CREATE PROCEDURE [dbo].[User_BumpAccountRevisionDateByCollectionIds]
@CollectionIds AS [dbo].[GuidIdArray] READONLY,
@OrganizationId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
UPDATE
U
SET
U.[AccountRevisionDate] = GETUTCDATE()
FROM
[dbo].[User] U
INNER JOIN
[dbo].[Collection] C ON C.[Id] IN (SELECT [Id] FROM @CollectionIds)
INNER JOIN
[dbo].[OrganizationUser] OU ON OU.[UserId] = U.[Id]
LEFT JOIN
[dbo].[CollectionUser] CU ON OU.[AccessAll] = 0 AND CU.[OrganizationUserId] = OU.[Id] AND CU.[CollectionId] = C.[Id]
LEFT JOIN
[dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND OU.[AccessAll] = 0 AND GU.[OrganizationUserId] = OU.[Id]
LEFT JOIN
[dbo].[Group] G ON G.[Id] = GU.[GroupId]
LEFT JOIN
[dbo].[CollectionGroup] CG ON G.[AccessAll] = 0 AND CG.[GroupId] = GU.[GroupId] AND CG.[CollectionId] = C.[Id]
WHERE
OU.[OrganizationId] = @OrganizationId
AND OU.[Status] = 2 -- 2 = Confirmed
AND (
CU.[CollectionId] IS NOT NULL
OR CG.[CollectionId] IS NOT NULL
OR OU.[AccessAll] = 1
OR G.[AccessAll] = 1
)
END