mirror of
https://github.com/bitwarden/server.git
synced 2025-07-01 16:12: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:
@ -20,23 +20,26 @@ public class CollectionsController : Controller
|
|||||||
private readonly ICollectionService _collectionService;
|
private readonly ICollectionService _collectionService;
|
||||||
private readonly IDeleteCollectionCommand _deleteCollectionCommand;
|
private readonly IDeleteCollectionCommand _deleteCollectionCommand;
|
||||||
private readonly IUserService _userService;
|
private readonly IUserService _userService;
|
||||||
private readonly ICurrentContext _currentContext;
|
|
||||||
private readonly IAuthorizationService _authorizationService;
|
private readonly IAuthorizationService _authorizationService;
|
||||||
|
private readonly ICurrentContext _currentContext;
|
||||||
|
private readonly IBulkAddCollectionAccessCommand _bulkAddCollectionAccessCommand;
|
||||||
|
|
||||||
public CollectionsController(
|
public CollectionsController(
|
||||||
ICollectionRepository collectionRepository,
|
ICollectionRepository collectionRepository,
|
||||||
ICollectionService collectionService,
|
ICollectionService collectionService,
|
||||||
IDeleteCollectionCommand deleteCollectionCommand,
|
IDeleteCollectionCommand deleteCollectionCommand,
|
||||||
IUserService userService,
|
IUserService userService,
|
||||||
|
IAuthorizationService authorizationService,
|
||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
IAuthorizationService authorizationService)
|
IBulkAddCollectionAccessCommand bulkAddCollectionAccessCommand)
|
||||||
{
|
{
|
||||||
_collectionRepository = collectionRepository;
|
_collectionRepository = collectionRepository;
|
||||||
_collectionService = collectionService;
|
_collectionService = collectionService;
|
||||||
_deleteCollectionCommand = deleteCollectionCommand;
|
_deleteCollectionCommand = deleteCollectionCommand;
|
||||||
_userService = userService;
|
_userService = userService;
|
||||||
_currentContext = currentContext;
|
|
||||||
_authorizationService = authorizationService;
|
_authorizationService = authorizationService;
|
||||||
|
_currentContext = currentContext;
|
||||||
|
_bulkAddCollectionAccessCommand = bulkAddCollectionAccessCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
@ -190,6 +193,29 @@ public class CollectionsController : Controller
|
|||||||
await _collectionRepository.UpdateUsersAsync(collection.Id, model?.Select(g => g.ToSelectionReadOnly()));
|
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}")]
|
[HttpDelete("{id}")]
|
||||||
[HttpPost("{id}/delete")]
|
[HttpPost("{id}/delete")]
|
||||||
public async Task Delete(Guid orgId, Guid id)
|
public async Task Delete(Guid orgId, Guid id)
|
||||||
|
@ -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; }
|
||||||
|
}
|
@ -6,7 +6,7 @@ namespace Bit.Api.Models.Request;
|
|||||||
public class SelectionReadOnlyRequestModel
|
public class SelectionReadOnlyRequestModel
|
||||||
{
|
{
|
||||||
[Required]
|
[Required]
|
||||||
public string Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public bool ReadOnly { get; set; }
|
public bool ReadOnly { get; set; }
|
||||||
public bool HidePasswords { get; set; }
|
public bool HidePasswords { get; set; }
|
||||||
public bool Manage { get; set; }
|
public bool Manage { get; set; }
|
||||||
@ -15,7 +15,7 @@ public class SelectionReadOnlyRequestModel
|
|||||||
{
|
{
|
||||||
return new CollectionAccessSelection
|
return new CollectionAccessSelection
|
||||||
{
|
{
|
||||||
Id = new Guid(Id),
|
Id = Id,
|
||||||
ReadOnly = ReadOnly,
|
ReadOnly = ReadOnly,
|
||||||
HidePasswords = HidePasswords,
|
HidePasswords = HidePasswords,
|
||||||
Manage = Manage,
|
Manage = Manage,
|
||||||
|
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
@ -98,6 +98,7 @@ public static class OrganizationServiceCollectionExtensions
|
|||||||
public static void AddOrganizationCollectionCommands(this IServiceCollection services)
|
public static void AddOrganizationCollectionCommands(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddScoped<IDeleteCollectionCommand, DeleteCollectionCommand>();
|
services.AddScoped<IDeleteCollectionCommand, DeleteCollectionCommand>();
|
||||||
|
services.AddScoped<IBulkAddCollectionAccessCommand, BulkAddCollectionAccessCommand>();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddOrganizationGroupCommands(this IServiceCollection services)
|
private static void AddOrganizationGroupCommands(this IServiceCollection services)
|
||||||
|
@ -20,4 +20,6 @@ public interface ICollectionRepository : IRepository<Collection, Guid>
|
|||||||
Task UpdateUsersAsync(Guid id, IEnumerable<CollectionAccessSelection> users);
|
Task UpdateUsersAsync(Guid id, IEnumerable<CollectionAccessSelection> users);
|
||||||
Task<ICollection<CollectionAccessSelection>> GetManyUsersByIdAsync(Guid id);
|
Task<ICollection<CollectionAccessSelection>> GetManyUsersByIdAsync(Guid id);
|
||||||
Task DeleteManyAsync(IEnumerable<Guid> collectionIds);
|
Task DeleteManyAsync(IEnumerable<Guid> collectionIds);
|
||||||
|
Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumerable<Guid> collectionIds,
|
||||||
|
IEnumerable<CollectionAccessSelection> users, IEnumerable<CollectionAccessSelection> groups);
|
||||||
}
|
}
|
||||||
|
@ -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)
|
public async Task CreateUserAsync(Guid collectionId, Guid organizationUserId)
|
||||||
{
|
{
|
||||||
using (var connection = new SqlConnection(ConnectionString))
|
using (var connection = new SqlConnection(ConnectionString))
|
||||||
|
@ -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)
|
private async Task ReplaceCollectionGroupsAsync(DatabaseContext dbContext, Core.Entities.Collection collection, IEnumerable<CollectionAccessSelection> groups)
|
||||||
{
|
{
|
||||||
var groupsInOrg = dbContext.Groups.Where(g => g.OrganizationId == collection.OrganizationId);
|
var groupsInOrg = dbContext.Groups.Where(g => g.OrganizationId == collection.OrganizationId);
|
||||||
|
@ -74,6 +74,39 @@ public static class DatabaseContextExtensions
|
|||||||
UpdateUserRevisionDate(users);
|
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)
|
public static async Task UserBumpAccountRevisionDateByOrganizationUserIdAsync(this DatabaseContext context, Guid organizationUserId)
|
||||||
{
|
{
|
||||||
var query = from u in context.Users
|
var query = from u in context.Users
|
||||||
|
@ -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
|
@ -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
|
@ -221,4 +221,120 @@ public class CollectionsControllerTests
|
|||||||
.DidNotReceiveWithAnyArgs()
|
.DidNotReceiveWithAnyArgs()
|
||||||
.DeleteManyAsync((IEnumerable<Collection>)default);
|
.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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -17,6 +17,9 @@ public class CollectionCustomization : ICustomization
|
|||||||
{
|
{
|
||||||
var orgId = Guid.NewGuid();
|
var orgId = Guid.NewGuid();
|
||||||
|
|
||||||
|
fixture.Customize<Organization>(composer => composer
|
||||||
|
.With(o => o.Id, orgId));
|
||||||
|
|
||||||
fixture.Customize<CurrentContextOrganization>(composer => composer
|
fixture.Customize<CurrentContextOrganization>(composer => composer
|
||||||
.With(o => o.Id, orgId));
|
.With(o => o.Id, orgId));
|
||||||
|
|
||||||
|
@ -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
|
Reference in New Issue
Block a user