1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-01 08:02:49 -05: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

View File

@ -221,4 +221,120 @@ public class CollectionsControllerTests
.DidNotReceiveWithAnyArgs()
.DeleteManyAsync((IEnumerable<Collection>)default);
}
[Theory, BitAutoData]
public async Task PostBulkCollectionAccess_Success(User actingUser, ICollection<Collection> collections, SutProvider<CollectionsController> sutProvider)
{
// Arrange
var userId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var model = new BulkCollectionAccessRequestModel
{
CollectionIds = collections.Select(c => c.Id),
Users = new[] { new SelectionReadOnlyRequestModel { Id = userId, Manage = true } },
Groups = new[] { new SelectionReadOnlyRequestModel { Id = groupId, ReadOnly = true } },
};
sutProvider.GetDependency<ICollectionRepository>()
.GetManyByManyIdsAsync(model.CollectionIds)
.Returns(collections);
sutProvider.GetDependency<ICurrentContext>()
.UserId.Returns(actingUser.Id);
sutProvider.GetDependency<IAuthorizationService>().AuthorizeAsync(
Arg.Any<ClaimsPrincipal>(), ExpectedCollectionAccess(),
Arg.Is<IEnumerable<IAuthorizationRequirement>>(
r => r.Contains(CollectionOperations.ModifyAccess)
))
.Returns(AuthorizationResult.Success());
IEnumerable<Collection> ExpectedCollectionAccess() => Arg.Is<IEnumerable<Collection>>(cols => cols.SequenceEqual(collections));
// Act
await sutProvider.Sut.PostBulkCollectionAccess(model);
// Assert
await sutProvider.GetDependency<IAuthorizationService>().Received().AuthorizeAsync(
Arg.Any<ClaimsPrincipal>(),
ExpectedCollectionAccess(),
Arg.Is<IEnumerable<IAuthorizationRequirement>>(
r => r.Contains(CollectionOperations.ModifyAccess))
);
await sutProvider.GetDependency<IBulkAddCollectionAccessCommand>().Received()
.AddAccessAsync(
Arg.Is<ICollection<Collection>>(g => g.SequenceEqual(collections)),
Arg.Is<ICollection<CollectionAccessSelection>>(u => u.All(c => c.Id == userId && c.Manage)),
Arg.Is<ICollection<CollectionAccessSelection>>(g => g.All(c => c.Id == groupId && c.ReadOnly)));
}
[Theory, BitAutoData]
public async Task PostBulkCollectionAccess_CollectionsNotFound_Throws(User actingUser, ICollection<Collection> collections, SutProvider<CollectionsController> sutProvider)
{
var userId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var model = new BulkCollectionAccessRequestModel
{
CollectionIds = collections.Select(c => c.Id),
Users = new[] { new SelectionReadOnlyRequestModel { Id = userId, Manage = true } },
Groups = new[] { new SelectionReadOnlyRequestModel { Id = groupId, ReadOnly = true } },
};
sutProvider.GetDependency<ICurrentContext>()
.UserId.Returns(actingUser.Id);
sutProvider.GetDependency<ICollectionRepository>()
.GetManyByManyIdsAsync(model.CollectionIds)
.Returns(collections.Skip(1).ToList());
var exception = await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.PostBulkCollectionAccess(model));
Assert.Equal("One or more collections not found.", exception.Message);
await sutProvider.GetDependency<IAuthorizationService>().DidNotReceiveWithAnyArgs().AuthorizeAsync(
Arg.Any<ClaimsPrincipal>(),
Arg.Any<IEnumerable<Collection>>(),
Arg.Any<IEnumerable<IAuthorizationRequirement>>()
);
await sutProvider.GetDependency<IBulkAddCollectionAccessCommand>().DidNotReceiveWithAnyArgs()
.AddAccessAsync(default, default, default);
}
[Theory, BitAutoData]
public async Task PostBulkCollectionAccess_AccessDenied_Throws(User actingUser, ICollection<Collection> collections, SutProvider<CollectionsController> sutProvider)
{
var userId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var model = new BulkCollectionAccessRequestModel
{
CollectionIds = collections.Select(c => c.Id),
Users = new[] { new SelectionReadOnlyRequestModel { Id = userId, Manage = true } },
Groups = new[] { new SelectionReadOnlyRequestModel { Id = groupId, ReadOnly = true } },
};
sutProvider.GetDependency<ICurrentContext>()
.UserId.Returns(actingUser.Id);
sutProvider.GetDependency<ICollectionRepository>()
.GetManyByManyIdsAsync(model.CollectionIds)
.Returns(collections);
sutProvider.GetDependency<IAuthorizationService>().AuthorizeAsync(
Arg.Any<ClaimsPrincipal>(), ExpectedCollectionAccess(),
Arg.Is<IEnumerable<IAuthorizationRequirement>>(
r => r.Contains(CollectionOperations.ModifyAccess)
))
.Returns(AuthorizationResult.Failed());
IEnumerable<Collection> ExpectedCollectionAccess() => Arg.Is<IEnumerable<Collection>>(cols => cols.SequenceEqual(collections));
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.PostBulkCollectionAccess(model));
await sutProvider.GetDependency<IAuthorizationService>().Received().AuthorizeAsync(
Arg.Any<ClaimsPrincipal>(),
ExpectedCollectionAccess(),
Arg.Is<IEnumerable<IAuthorizationRequirement>>(
r => r.Contains(CollectionOperations.ModifyAccess))
);
await sutProvider.GetDependency<IBulkAddCollectionAccessCommand>().DidNotReceiveWithAnyArgs()
.AddAccessAsync(default, default, default);
}
}

View File

@ -0,0 +1,271 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.OrganizationFeatures.OrganizationCollections;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Test.Vault.AutoFixture;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.OrganizationFeatures.OrganizationCollections;
[SutProviderCustomize]
public class BulkAddCollectionAccessCommandTests
{
[Theory, BitAutoData, CollectionCustomization]
public async Task AddAccessAsync_Success(SutProvider<BulkAddCollectionAccessCommand> sutProvider,
Organization org,
ICollection<Collection> collections,
ICollection<OrganizationUser> organizationUsers,
ICollection<Group> groups,
IEnumerable<CollectionUser> collectionUsers,
IEnumerable<CollectionGroup> collectionGroups)
{
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyAsync(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionUsers.Select(u => u.OrganizationUserId)))
)
.Returns(organizationUsers);
sutProvider.GetDependency<IGroupRepository>()
.GetManyByManyIds(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionGroups.Select(u => u.GroupId)))
)
.Returns(groups);
var userAccessSelections = ToAccessSelection(collectionUsers);
var groupAccessSelections = ToAccessSelection(collectionGroups);
await sutProvider.Sut.AddAccessAsync(collections,
userAccessSelections,
groupAccessSelections
);
await sutProvider.GetDependency<IOrganizationUserRepository>().Received().GetManyAsync(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(userAccessSelections.Select(u => u.Id)))
);
await sutProvider.GetDependency<IGroupRepository>().Received().GetManyByManyIds(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(groupAccessSelections.Select(g => g.Id)))
);
await sutProvider.GetDependency<ICollectionRepository>().Received().CreateOrUpdateAccessForManyAsync(
org.Id,
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collections.Select(c => c.Id))),
userAccessSelections,
groupAccessSelections);
await sutProvider.GetDependency<IEventService>().Received().LogCollectionEventsAsync(
Arg.Is<IEnumerable<(Collection, EventType, DateTime?)>>(
events => events.All(e =>
collections.Contains(e.Item1) &&
e.Item2 == EventType.Collection_Updated &&
e.Item3.HasValue
)
)
);
}
[Theory, BitAutoData, CollectionCustomization]
public async Task ValidateRequestAsync_NoCollectionsProvided_Failure(SutProvider<BulkAddCollectionAccessCommand> sutProvider)
{
var exception =
await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.AddAccessAsync(null, null, null));
Assert.Contains("No collections were provided.", exception.Message);
await sutProvider.GetDependency<ICollectionRepository>().DidNotReceiveWithAnyArgs().GetManyByManyIdsAsync(default);
await sutProvider.GetDependency<IOrganizationUserRepository>().DidNotReceiveWithAnyArgs().GetManyAsync(default);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyByManyIds(default);
}
[Theory, BitAutoData, CollectionCustomization]
public async Task ValidateRequestAsync_NoCollection_Failure(SutProvider<BulkAddCollectionAccessCommand> sutProvider,
IEnumerable<CollectionUser> collectionUsers,
IEnumerable<CollectionGroup> collectionGroups)
{
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.AddAccessAsync(Enumerable.Empty<Collection>().ToList(),
ToAccessSelection(collectionUsers),
ToAccessSelection(collectionGroups)
));
Assert.Contains("No collections were provided.", exception.Message);
await sutProvider.GetDependency<IOrganizationUserRepository>().DidNotReceiveWithAnyArgs().GetManyAsync(default);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyByManyIds(default);
}
[Theory, BitAutoData, CollectionCustomization]
public async Task ValidateRequestAsync_DifferentOrgs_Failure(SutProvider<BulkAddCollectionAccessCommand> sutProvider,
ICollection<Collection> collections,
IEnumerable<CollectionUser> collectionUsers,
IEnumerable<CollectionGroup> collectionGroups)
{
collections.First().OrganizationId = Guid.NewGuid();
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.AddAccessAsync(collections,
ToAccessSelection(collectionUsers),
ToAccessSelection(collectionGroups)
));
Assert.Contains("All collections must belong to the same organization.", exception.Message);
await sutProvider.GetDependency<IOrganizationUserRepository>().DidNotReceiveWithAnyArgs().GetManyAsync(default);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyByManyIds(default);
}
[Theory, BitAutoData, CollectionCustomization]
public async Task ValidateRequestAsync_MissingUser_Failure(SutProvider<BulkAddCollectionAccessCommand> sutProvider,
IList<Collection> collections,
IList<OrganizationUser> organizationUsers,
IEnumerable<CollectionUser> collectionUsers,
IEnumerable<CollectionGroup> collectionGroups)
{
organizationUsers.RemoveAt(0);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyAsync(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionUsers.Select(u => u.OrganizationUserId)))
)
.Returns(organizationUsers);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.AddAccessAsync(collections,
ToAccessSelection(collectionUsers),
ToAccessSelection(collectionGroups)
));
Assert.Contains("One or more users do not exist.", exception.Message);
await sutProvider.GetDependency<IOrganizationUserRepository>().Received().GetManyAsync(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionUsers.Select(u => u.OrganizationUserId)))
);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyByManyIds(default);
}
[Theory, BitAutoData, CollectionCustomization]
public async Task ValidateRequestAsync_UserWrongOrg_Failure(SutProvider<BulkAddCollectionAccessCommand> sutProvider,
IList<Collection> collections,
IList<OrganizationUser> organizationUsers,
IEnumerable<CollectionUser> collectionUsers,
IEnumerable<CollectionGroup> collectionGroups)
{
organizationUsers.First().OrganizationId = Guid.NewGuid();
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyAsync(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionUsers.Select(u => u.OrganizationUserId)))
)
.Returns(organizationUsers);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.AddAccessAsync(collections,
ToAccessSelection(collectionUsers),
ToAccessSelection(collectionGroups)
));
Assert.Contains("One or more users do not belong to the same organization as the collection being assigned.", exception.Message);
await sutProvider.GetDependency<IOrganizationUserRepository>().Received().GetManyAsync(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionUsers.Select(u => u.OrganizationUserId)))
);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyByManyIds(default);
}
[Theory, BitAutoData, CollectionCustomization]
public async Task ValidateRequestAsync_MissingGroup_Failure(SutProvider<BulkAddCollectionAccessCommand> sutProvider,
IList<Collection> collections,
IList<OrganizationUser> organizationUsers,
IList<Group> groups,
IEnumerable<CollectionUser> collectionUsers,
IEnumerable<CollectionGroup> collectionGroups)
{
groups.RemoveAt(0);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyAsync(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionUsers.Select(u => u.OrganizationUserId)))
)
.Returns(organizationUsers);
sutProvider.GetDependency<IGroupRepository>()
.GetManyByManyIds(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionGroups.Select(u => u.GroupId)))
)
.Returns(groups);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.AddAccessAsync(collections,
ToAccessSelection(collectionUsers),
ToAccessSelection(collectionGroups)
));
Assert.Contains("One or more groups do not exist.", exception.Message);
await sutProvider.GetDependency<IOrganizationUserRepository>().Received().GetManyAsync(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionUsers.Select(u => u.OrganizationUserId)))
);
await sutProvider.GetDependency<IGroupRepository>().Received().GetManyByManyIds(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionGroups.Select(u => u.GroupId)))
);
}
[Theory, BitAutoData, CollectionCustomization]
public async Task ValidateRequestAsync_GroupWrongOrg_Failure(SutProvider<BulkAddCollectionAccessCommand> sutProvider,
IList<Collection> collections,
IList<OrganizationUser> organizationUsers,
IList<Group> groups,
IEnumerable<CollectionUser> collectionUsers,
IEnumerable<CollectionGroup> collectionGroups)
{
groups.First().OrganizationId = Guid.NewGuid();
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyAsync(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionUsers.Select(u => u.OrganizationUserId)))
)
.Returns(organizationUsers);
sutProvider.GetDependency<IGroupRepository>()
.GetManyByManyIds(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionGroups.Select(u => u.GroupId)))
)
.Returns(groups);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.AddAccessAsync(collections,
ToAccessSelection(collectionUsers),
ToAccessSelection(collectionGroups)
));
Assert.Contains("One or more groups do not belong to the same organization as the collection being assigned.", exception.Message);
await sutProvider.GetDependency<IOrganizationUserRepository>().Received().GetManyAsync(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionUsers.Select(u => u.OrganizationUserId)))
);
await sutProvider.GetDependency<IGroupRepository>().Received().GetManyByManyIds(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionGroups.Select(u => u.GroupId)))
);
}
private static ICollection<CollectionAccessSelection> ToAccessSelection(IEnumerable<CollectionUser> collectionUsers)
{
return collectionUsers.Select(cu => new CollectionAccessSelection
{
Id = cu.OrganizationUserId,
Manage = cu.Manage,
HidePasswords = cu.HidePasswords,
ReadOnly = cu.ReadOnly
}).ToList();
}
private static ICollection<CollectionAccessSelection> ToAccessSelection(IEnumerable<CollectionGroup> collectionGroups)
{
return collectionGroups.Select(cg => new CollectionAccessSelection
{
Id = cg.GroupId,
Manage = cg.Manage,
HidePasswords = cg.HidePasswords,
ReadOnly = cg.ReadOnly
}).ToList();
}
}

View File

@ -17,6 +17,9 @@ public class CollectionCustomization : ICustomization
{
var orgId = Guid.NewGuid();
fixture.Customize<Organization>(composer => composer
.With(o => o.Id, orgId));
fixture.Customize<CurrentContextOrganization>(composer => composer
.With(o => o.Id, orgId));

View File

@ -0,0 +1,135 @@
CREATE OR ALTER 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
GO
CREATE OR ALTER 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
GO