diff --git a/bitwarden_license/src/Commercial.Core/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/Services/ProviderService.cs index e50388f401..cf3b68efa5 100644 --- a/bitwarden_license/src/Commercial.Core/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/Services/ProviderService.cs @@ -419,7 +419,7 @@ public class ProviderService : IProviderService AccessAll = true, Type = OrganizationUserType.Owner, Permissions = null, - Collections = Array.Empty(), + Collections = Array.Empty(), }, null ) diff --git a/bitwarden_license/src/Scim/Users/PostUserCommand.cs b/bitwarden_license/src/Scim/Users/PostUserCommand.cs index 27b028cb86..3526cf5ab1 100644 --- a/bitwarden_license/src/Scim/Users/PostUserCommand.cs +++ b/bitwarden_license/src/Scim/Users/PostUserCommand.cs @@ -80,7 +80,7 @@ public class PostUserCommand : IPostUserCommand } var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, EventSystemUser.SCIM, email, - OrganizationUserType.User, false, externalId, new List()); + OrganizationUserType.User, false, externalId, new List(), new List()); var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id); return orgUser; diff --git a/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs b/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs index 43c195a9f4..304a290709 100644 --- a/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs +++ b/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs @@ -34,13 +34,15 @@ public class PostUserCommandTests .Returns(organizationUsers); sutProvider.GetDependency() - .InviteUserAsync(organizationId, EventSystemUser.SCIM, scimUserRequestModel.PrimaryEmail.ToLowerInvariant(), OrganizationUserType.User, false, externalId, Arg.Any>()) + .InviteUserAsync(organizationId, EventSystemUser.SCIM, scimUserRequestModel.PrimaryEmail.ToLowerInvariant(), + OrganizationUserType.User, false, externalId, Arg.Any>(), + Arg.Any>()) .Returns(newUser); var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel); await sutProvider.GetDependency().Received(1).InviteUserAsync(organizationId, EventSystemUser.SCIM, scimUserRequestModel.PrimaryEmail.ToLowerInvariant(), - OrganizationUserType.User, false, scimUserRequestModel.ExternalId, Arg.Any>()); + OrganizationUserType.User, false, scimUserRequestModel.ExternalId, Arg.Any>(), Arg.Any>()); await sutProvider.GetDependency().Received(1).GetDetailsByIdAsync(newUser.Id); } diff --git a/src/Api/Controllers/CollectionsController.cs b/src/Api/Controllers/CollectionsController.cs index 14d4e95b27..3697c7f4b6 100644 --- a/src/Api/Controllers/CollectionsController.cs +++ b/src/Api/Controllers/CollectionsController.cs @@ -3,6 +3,7 @@ using Bit.Api.Models.Response; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; +using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; @@ -16,17 +17,20 @@ public class CollectionsController : Controller { private readonly ICollectionRepository _collectionRepository; private readonly ICollectionService _collectionService; + private readonly IDeleteCollectionCommand _deleteCollectionCommand; private readonly IUserService _userService; private readonly ICurrentContext _currentContext; public CollectionsController( ICollectionRepository collectionRepository, ICollectionService collectionService, + IDeleteCollectionCommand deleteCollectionCommand, IUserService userService, ICurrentContext currentContext) { _collectionRepository = collectionRepository; _collectionService = collectionService; + _deleteCollectionCommand = deleteCollectionCommand; _userService = userService; _currentContext = currentContext; } @@ -44,7 +48,7 @@ public class CollectionsController : Controller } [HttpGet("{id}/details")] - public async Task GetDetails(Guid orgId, Guid id) + public async Task GetDetails(Guid orgId, Guid id) { if (!await ViewAtLeastOneCollectionAsync(orgId) && !await _currentContext.ManageUsers(orgId)) { @@ -53,25 +57,58 @@ public class CollectionsController : Controller if (await _currentContext.ViewAllCollections(orgId)) { - var collectionDetails = await _collectionRepository.GetByIdWithGroupsAsync(id); - if (collectionDetails?.Item1 == null || collectionDetails.Item1.OrganizationId != orgId) + (var collection, var access) = await _collectionRepository.GetByIdWithAccessAsync(id); + if (collection == null || collection.OrganizationId != orgId) { throw new NotFoundException(); } - return new CollectionGroupDetailsResponseModel(collectionDetails.Item1, collectionDetails.Item2); + return new CollectionAccessDetailsResponseModel(collection, access.Groups, access.Users); } else { - var collectionDetails = await _collectionRepository.GetByIdWithGroupsAsync(id, + (var collection, var access) = await _collectionRepository.GetByIdWithAccessAsync(id, _currentContext.UserId.Value); - if (collectionDetails?.Item1 == null || collectionDetails.Item1.OrganizationId != orgId) + if (collection == null || collection.OrganizationId != orgId) { throw new NotFoundException(); } - return new CollectionGroupDetailsResponseModel(collectionDetails.Item1, collectionDetails.Item2); + return new CollectionAccessDetailsResponseModel(collection, access.Groups, access.Users); } } + [HttpGet("details")] + public async Task> GetManyWithDetails(Guid orgId) + { + if (!await ViewAtLeastOneCollectionAsync(orgId) && !await _currentContext.ManageUsers(orgId)) + { + throw new NotFoundException(); + } + + // We always need to know which collections the current user is assigned to + var assignedOrgCollections = await _collectionRepository.GetManyByUserIdWithAccessAsync(_currentContext.UserId.Value, orgId); + + if (await _currentContext.ViewAllCollections(orgId)) + { + // The user can view all collections, but they may not always be assigned to all of them + var allOrgCollections = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(orgId); + + return new ListResponseModel(allOrgCollections.Select(c => + new CollectionAccessDetailsResponseModel(c.Item1, c.Item2.Groups, c.Item2.Users) + { + // Manually determine which collections they're assigned to + Assigned = assignedOrgCollections.Any(ac => ac.Item1.Id == c.Item1.Id) + }) + ); + } + + return new ListResponseModel(assignedOrgCollections.Select(c => + new CollectionAccessDetailsResponseModel(c.Item1, c.Item2.Groups, c.Item2.Users) + { + Assigned = true // Mapping from assignedOrgCollections implies they're all assigned + }) + ); + } + [HttpGet("")] public async Task> Get(Guid orgId) { @@ -110,11 +147,13 @@ public class CollectionsController : Controller throw new NotFoundException(); } + var groups = model.Groups?.Select(g => g.ToSelectionReadOnly()); + var users = model.Users?.Select(g => g.ToSelectionReadOnly()); + var assignUserToCollection = !(await _currentContext.EditAnyCollection(orgId)) && await _currentContext.EditAssignedCollections(orgId); - await _collectionService.SaveAsync(collection, model.Groups?.Select(g => g.ToSelectionReadOnly()), - assignUserToCollection ? _currentContext.UserId : null); + await _collectionService.SaveAsync(collection, groups, users, assignUserToCollection ? _currentContext.UserId : null); return new CollectionResponseModel(collection); } @@ -128,8 +167,9 @@ public class CollectionsController : Controller } var collection = await GetCollectionAsync(id, orgId); - await _collectionService.SaveAsync(model.ToCollection(collection), - model.Groups?.Select(g => g.ToSelectionReadOnly())); + var groups = model.Groups?.Select(g => g.ToSelectionReadOnly()); + var users = model.Users?.Select(g => g.ToSelectionReadOnly()); + await _collectionService.SaveAsync(model.ToCollection(collection), groups, users); return new CollectionResponseModel(collection); } @@ -155,7 +195,29 @@ public class CollectionsController : Controller } var collection = await GetCollectionAsync(id, orgId); - await _collectionService.DeleteAsync(collection); + await _deleteCollectionCommand.DeleteAsync(collection); + } + + [HttpDelete("")] + [HttpPost("delete")] + public async Task DeleteMany([FromBody] CollectionBulkDeleteRequestModel model) + { + var orgId = new Guid(model.OrganizationId); + var collectionIds = model.Ids.Select(i => new Guid(i)); + if (!await _currentContext.DeleteAssignedCollections(orgId)) + { + throw new NotFoundException(); + } + + var userCollections = await _collectionRepository.GetManyByUserIdAsync(_currentContext.UserId.Value); + var filteredCollections = userCollections.Where(c => collectionIds.Contains(c.Id) && c.OrganizationId == orgId); + + if (!filteredCollections.Any()) + { + throw new BadRequestException("No collections found."); + } + + await _deleteCollectionCommand.DeleteManyAsync(filteredCollections); } [HttpDelete("{id}/user/{orgUserId}")] diff --git a/src/Api/Controllers/GroupsController.cs b/src/Api/Controllers/GroupsController.cs index 5aae7ad04f..0220cfd5e2 100644 --- a/src/Api/Controllers/GroupsController.cs +++ b/src/Api/Controllers/GroupsController.cs @@ -16,6 +16,7 @@ public class GroupsController : Controller { private readonly IGroupRepository _groupRepository; private readonly IGroupService _groupService; + private readonly IDeleteGroupCommand _deleteGroupCommand; private readonly IOrganizationRepository _organizationRepository; private readonly ICurrentContext _currentContext; private readonly ICreateGroupCommand _createGroupCommand; @@ -27,7 +28,8 @@ public class GroupsController : Controller IOrganizationRepository organizationRepository, ICurrentContext currentContext, ICreateGroupCommand createGroupCommand, - IUpdateGroupCommand updateGroupCommand) + IUpdateGroupCommand updateGroupCommand, + IDeleteGroupCommand deleteGroupCommand) { _groupRepository = groupRepository; _groupService = groupService; @@ -35,6 +37,7 @@ public class GroupsController : Controller _currentContext = currentContext; _createGroupCommand = createGroupCommand; _updateGroupCommand = updateGroupCommand; + _deleteGroupCommand = deleteGroupCommand; } [HttpGet("{id}")] @@ -62,7 +65,7 @@ public class GroupsController : Controller } [HttpGet("")] - public async Task> Get(string orgId) + public async Task> Get(string orgId) { var orgIdGuid = new Guid(orgId); var canAccess = await _currentContext.ManageGroups(orgIdGuid) || @@ -75,9 +78,9 @@ public class GroupsController : Controller throw new NotFoundException(); } - var groups = await _groupRepository.GetManyByOrganizationIdAsync(orgIdGuid); - var responses = groups.Select(g => new GroupResponseModel(g)); - return new ListResponseModel(responses); + var groups = await _groupRepository.GetManyWithCollectionsByOrganizationIdAsync(orgIdGuid); + var responses = groups.Select(g => new GroupDetailsResponseModel(g.Item1, g.Item2)); + return new ListResponseModel(responses); } [HttpGet("{id}/users")] @@ -105,7 +108,7 @@ public class GroupsController : Controller var organization = await _organizationRepository.GetByIdAsync(orgIdGuid); var group = model.ToGroup(orgIdGuid); - await _createGroupCommand.CreateGroupAsync(group, organization, model.Collections?.Select(c => c.ToSelectionReadOnly())); + await _createGroupCommand.CreateGroupAsync(group, organization, model.Collections?.Select(c => c.ToSelectionReadOnly()), model.Users); return new GroupResponseModel(group); } @@ -123,7 +126,7 @@ public class GroupsController : Controller var orgIdGuid = new Guid(orgId); var organization = await _organizationRepository.GetByIdAsync(orgIdGuid); - await _updateGroupCommand.UpdateGroupAsync(model.ToGroup(group), organization, model.Collections?.Select(c => c.ToSelectionReadOnly())); + await _updateGroupCommand.UpdateGroupAsync(model.ToGroup(group), organization, model.Collections?.Select(c => c.ToSelectionReadOnly()), model.Users); return new GroupResponseModel(group); } @@ -148,7 +151,24 @@ public class GroupsController : Controller throw new NotFoundException(); } - await _groupService.DeleteAsync(group); + await _deleteGroupCommand.DeleteAsync(group); + } + + [HttpDelete("")] + [HttpPost("delete")] + public async Task BulkDelete([FromBody] GroupBulkRequestModel model) + { + var groups = await _groupRepository.GetManyByManyIds(model.Ids); + + foreach (var group in groups) + { + if (!await _currentContext.ManageGroups(group.OrganizationId)) + { + throw new NotFoundException(); + } + } + + await _deleteGroupCommand.DeleteManyAsync(groups); } [HttpDelete("{id}/user/{orgUserId}")] diff --git a/src/Api/Controllers/OrganizationUsersController.cs b/src/Api/Controllers/OrganizationUsersController.cs index 64340e3ede..e320aca768 100644 --- a/src/Api/Controllers/OrganizationUsersController.cs +++ b/src/Api/Controllers/OrganizationUsersController.cs @@ -49,7 +49,7 @@ public class OrganizationUsersController : Controller } [HttpGet("{id}")] - public async Task Get(string orgId, string id) + public async Task Get(string id, bool includeGroups = false) { var organizationUser = await _organizationUserRepository.GetByIdWithCollectionsAsync(new Guid(id)); if (organizationUser == null || !await _currentContext.ManageUsers(organizationUser.Item1.OrganizationId)) @@ -57,11 +57,18 @@ public class OrganizationUsersController : Controller throw new NotFoundException(); } - return new OrganizationUserDetailsResponseModel(organizationUser.Item1, organizationUser.Item2); + var response = new OrganizationUserDetailsResponseModel(organizationUser.Item1, organizationUser.Item2); + + if (includeGroups) + { + response.Groups = await _groupRepository.GetManyIdsByUserIdAsync(organizationUser.Item1.Id); + } + + return response; } [HttpGet("")] - public async Task> Get(string orgId) + public async Task> Get(string orgId, bool includeGroups = false, bool includeCollections = false) { var orgGuidId = new Guid(orgId); if (!await _currentContext.ViewAllCollections(orgGuidId) && @@ -72,7 +79,7 @@ public class OrganizationUsersController : Controller throw new NotFoundException(); } - var organizationUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(orgGuidId); + var organizationUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(orgGuidId, includeGroups, includeCollections); var responseTasks = organizationUsers.Select(async o => new OrganizationUserUserDetailsResponseModel(o, await _userService.TwoFactorIsEnabledAsync(o))); var responses = await Task.WhenAll(responseTasks); @@ -262,7 +269,7 @@ public class OrganizationUsersController : Controller var userId = _userService.GetProperUserId(User); await _organizationService.SaveUserAsync(model.ToOrganizationUser(organizationUser), userId.Value, - model.Collections?.Select(c => c.ToSelectionReadOnly())); + model.Collections?.Select(c => c.ToSelectionReadOnly()), model.Groups); } [HttpPut("{id}/groups")] diff --git a/src/Api/Models/Public/Request/AssociationWithPermissionsRequestModel.cs b/src/Api/Models/Public/Request/AssociationWithPermissionsRequestModel.cs index b93b16e599..f518eb9e53 100644 --- a/src/Api/Models/Public/Request/AssociationWithPermissionsRequestModel.cs +++ b/src/Api/Models/Public/Request/AssociationWithPermissionsRequestModel.cs @@ -4,9 +4,9 @@ namespace Bit.Api.Models.Public.Request; public class AssociationWithPermissionsRequestModel : AssociationWithPermissionsBaseModel { - public SelectionReadOnly ToSelectionReadOnly() + public CollectionAccessSelection ToSelectionReadOnly() { - return new SelectionReadOnly + return new CollectionAccessSelection { Id = Id.Value, ReadOnly = ReadOnly.Value diff --git a/src/Api/Models/Public/Request/MemberUpdateRequestModel.cs b/src/Api/Models/Public/Request/MemberUpdateRequestModel.cs index 6b5881186c..6ec89e0456 100644 --- a/src/Api/Models/Public/Request/MemberUpdateRequestModel.cs +++ b/src/Api/Models/Public/Request/MemberUpdateRequestModel.cs @@ -9,6 +9,11 @@ public class MemberUpdateRequestModel : MemberBaseModel /// public IEnumerable Collections { get; set; } + /// + /// Ids of the associated groups that this member will belong to + /// + public IEnumerable Groups { get; set; } + public virtual OrganizationUser ToOrganizationUser(OrganizationUser existingUser) { existingUser.Type = Type.Value; diff --git a/src/Api/Models/Public/Response/AssociationWithPermissionsResponseModel.cs b/src/Api/Models/Public/Response/AssociationWithPermissionsResponseModel.cs index 04863d9b46..608a668cfd 100644 --- a/src/Api/Models/Public/Response/AssociationWithPermissionsResponseModel.cs +++ b/src/Api/Models/Public/Response/AssociationWithPermissionsResponseModel.cs @@ -4,7 +4,7 @@ namespace Bit.Api.Models.Public.Response; public class AssociationWithPermissionsResponseModel : AssociationWithPermissionsBaseModel { - public AssociationWithPermissionsResponseModel(SelectionReadOnly selection) + public AssociationWithPermissionsResponseModel(CollectionAccessSelection selection) { if (selection == null) { diff --git a/src/Api/Models/Public/Response/CollectionResponseModel.cs b/src/Api/Models/Public/Response/CollectionResponseModel.cs index 93e484801d..91b1b7dbfc 100644 --- a/src/Api/Models/Public/Response/CollectionResponseModel.cs +++ b/src/Api/Models/Public/Response/CollectionResponseModel.cs @@ -9,7 +9,7 @@ namespace Bit.Api.Models.Public.Response; /// public class CollectionResponseModel : CollectionBaseModel, IResponseModel { - public CollectionResponseModel(Collection collection, IEnumerable groups) + public CollectionResponseModel(Collection collection, IEnumerable groups) { if (collection == null) { diff --git a/src/Api/Models/Public/Response/GroupResponseModel.cs b/src/Api/Models/Public/Response/GroupResponseModel.cs index c2e8df4bee..13b4993395 100644 --- a/src/Api/Models/Public/Response/GroupResponseModel.cs +++ b/src/Api/Models/Public/Response/GroupResponseModel.cs @@ -9,7 +9,7 @@ namespace Bit.Api.Models.Public.Response; /// public class GroupResponseModel : GroupBaseModel, IResponseModel { - public GroupResponseModel(Group group, IEnumerable collections) + public GroupResponseModel(Group group, IEnumerable collections) { if (group == null) { diff --git a/src/Api/Models/Public/Response/MemberResponseModel.cs b/src/Api/Models/Public/Response/MemberResponseModel.cs index ccb8a8c953..b37605323b 100644 --- a/src/Api/Models/Public/Response/MemberResponseModel.cs +++ b/src/Api/Models/Public/Response/MemberResponseModel.cs @@ -11,7 +11,7 @@ namespace Bit.Api.Models.Public.Response; /// public class MemberResponseModel : MemberBaseModel, IResponseModel { - public MemberResponseModel(OrganizationUser user, IEnumerable collections) + public MemberResponseModel(OrganizationUser user, IEnumerable collections) : base(user) { if (user == null) @@ -27,7 +27,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel } public MemberResponseModel(OrganizationUserUserDetails user, bool twoFactorEnabled, - IEnumerable collections) + IEnumerable collections) : base(user) { if (user == null) diff --git a/src/Api/Models/Request/CollectionRequestModel.cs b/src/Api/Models/Request/CollectionRequestModel.cs index fb0be314d3..94b4801f09 100644 --- a/src/Api/Models/Request/CollectionRequestModel.cs +++ b/src/Api/Models/Request/CollectionRequestModel.cs @@ -13,6 +13,7 @@ public class CollectionRequestModel [StringLength(300)] public string ExternalId { get; set; } public IEnumerable Groups { get; set; } + public IEnumerable Users { get; set; } public Collection ToCollection(Guid orgId) { @@ -29,3 +30,10 @@ public class CollectionRequestModel return existingCollection; } } + +public class CollectionBulkDeleteRequestModel +{ + [Required] + public IEnumerable Ids { get; set; } + public string OrganizationId { get; set; } +} diff --git a/src/Api/Models/Request/GroupRequestModel.cs b/src/Api/Models/Request/GroupRequestModel.cs index 71e76590be..6fcb34825a 100644 --- a/src/Api/Models/Request/GroupRequestModel.cs +++ b/src/Api/Models/Request/GroupRequestModel.cs @@ -13,6 +13,7 @@ public class GroupRequestModel [StringLength(300)] public string ExternalId { get; set; } public IEnumerable Collections { get; set; } + public IEnumerable Users { get; set; } public Group ToGroup(Guid orgId) { @@ -30,3 +31,9 @@ public class GroupRequestModel return existingGroup; } } + +public class GroupBulkRequestModel +{ + [Required] + public IEnumerable Ids { get; set; } +} diff --git a/src/Api/Models/Request/Organizations/OrganizationUserRequestModels.cs b/src/Api/Models/Request/Organizations/OrganizationUserRequestModels.cs index 4d6fcfedba..a09012d792 100644 --- a/src/Api/Models/Request/Organizations/OrganizationUserRequestModels.cs +++ b/src/Api/Models/Request/Organizations/OrganizationUserRequestModels.cs @@ -19,6 +19,7 @@ public class OrganizationUserInviteRequestModel public bool AccessAll { get; set; } public Permissions Permissions { get; set; } public IEnumerable Collections { get; set; } + public IEnumerable Groups { get; set; } public OrganizationUserInviteData ToData() { @@ -28,6 +29,7 @@ public class OrganizationUserInviteRequestModel Type = Type, AccessAll = AccessAll, Collections = Collections?.Select(c => c.ToSelectionReadOnly()), + Groups = Groups, Permissions = Permissions, }; } @@ -73,6 +75,7 @@ public class OrganizationUserUpdateRequestModel public bool AccessAll { get; set; } public Permissions Permissions { get; set; } public IEnumerable Collections { get; set; } + public IEnumerable Groups { get; set; } public OrganizationUser ToOrganizationUser(OrganizationUser existingUser) { diff --git a/src/Api/Models/Request/SelectionReadOnlyRequestModel.cs b/src/Api/Models/Request/SelectionReadOnlyRequestModel.cs index 5b82dc3e33..c239ed33e0 100644 --- a/src/Api/Models/Request/SelectionReadOnlyRequestModel.cs +++ b/src/Api/Models/Request/SelectionReadOnlyRequestModel.cs @@ -10,9 +10,9 @@ public class SelectionReadOnlyRequestModel public bool ReadOnly { get; set; } public bool HidePasswords { get; set; } - public SelectionReadOnly ToSelectionReadOnly() + public CollectionAccessSelection ToSelectionReadOnly() { - return new SelectionReadOnly + return new CollectionAccessSelection { Id = new Guid(Id), ReadOnly = ReadOnly, diff --git a/src/Api/Models/Response/CollectionResponseModel.cs b/src/Api/Models/Response/CollectionResponseModel.cs index aa56402c0a..7f278bc3c9 100644 --- a/src/Api/Models/Response/CollectionResponseModel.cs +++ b/src/Api/Models/Response/CollectionResponseModel.cs @@ -39,13 +39,20 @@ public class CollectionDetailsResponseModel : CollectionResponseModel public bool HidePasswords { get; set; } } -public class CollectionGroupDetailsResponseModel : CollectionResponseModel +public class CollectionAccessDetailsResponseModel : CollectionResponseModel { - public CollectionGroupDetailsResponseModel(Collection collection, IEnumerable groups) - : base(collection, "collectionGroupDetails") + public CollectionAccessDetailsResponseModel(Collection collection, IEnumerable groups, IEnumerable users) + : base(collection, "collectionAccessDetails") { Groups = groups.Select(g => new SelectionReadOnlyResponseModel(g)); + Users = users.Select(g => new SelectionReadOnlyResponseModel(g)); } public IEnumerable Groups { get; set; } + public IEnumerable Users { get; set; } + + /// + /// True if the acting user is explicitly assigned to the collection + /// + public bool Assigned { get; set; } } diff --git a/src/Api/Models/Response/GroupResponseModel.cs b/src/Api/Models/Response/GroupResponseModel.cs index 4b6496a40c..887f9760b0 100644 --- a/src/Api/Models/Response/GroupResponseModel.cs +++ b/src/Api/Models/Response/GroupResponseModel.cs @@ -30,7 +30,7 @@ public class GroupResponseModel : ResponseModel public class GroupDetailsResponseModel : GroupResponseModel { - public GroupDetailsResponseModel(Group group, IEnumerable collections) + public GroupDetailsResponseModel(Group group, IEnumerable collections) : base(group, "groupDetails") { Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c)); diff --git a/src/Api/Models/Response/Organizations/OrganizationUserResponseModel.cs b/src/Api/Models/Response/Organizations/OrganizationUserResponseModel.cs index 619769b066..92a4e7f4d3 100644 --- a/src/Api/Models/Response/Organizations/OrganizationUserResponseModel.cs +++ b/src/Api/Models/Response/Organizations/OrganizationUserResponseModel.cs @@ -1,4 +1,5 @@ -using Bit.Core.Entities; +using System.Text.Json.Serialization; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Api; using Bit.Core.Models.Data; @@ -57,13 +58,16 @@ public class OrganizationUserResponseModel : ResponseModel public class OrganizationUserDetailsResponseModel : OrganizationUserResponseModel { public OrganizationUserDetailsResponseModel(OrganizationUser organizationUser, - IEnumerable collections) + IEnumerable collections) : base(organizationUser, "organizationUserDetails") { Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c)); } public IEnumerable Collections { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IEnumerable Groups { get; set; } } public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel @@ -81,6 +85,8 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse Email = organizationUser.Email; TwoFactorEnabled = twoFactorEnabled; SsoBound = !string.IsNullOrWhiteSpace(organizationUser.SsoExternalId); + Collections = organizationUser.Collections.Select(c => new SelectionReadOnlyResponseModel(c)); + Groups = organizationUser.Groups; // Prevent reset password when using key connector. ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector; } @@ -89,6 +95,8 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse public string Email { get; set; } public bool TwoFactorEnabled { get; set; } public bool SsoBound { get; set; } + public IEnumerable Collections { get; set; } + public IEnumerable Groups { get; set; } } public class OrganizationUserResetPasswordDetailsResponseModel : ResponseModel diff --git a/src/Api/Models/Response/SelectionReadOnlyResponseModel.cs b/src/Api/Models/Response/SelectionReadOnlyResponseModel.cs index 0d4cc637d1..a23d0d7c4b 100644 --- a/src/Api/Models/Response/SelectionReadOnlyResponseModel.cs +++ b/src/Api/Models/Response/SelectionReadOnlyResponseModel.cs @@ -4,7 +4,7 @@ namespace Bit.Api.Models.Response; public class SelectionReadOnlyResponseModel { - public SelectionReadOnlyResponseModel(SelectionReadOnly selection) + public SelectionReadOnlyResponseModel(CollectionAccessSelection selection) { if (selection == null) { diff --git a/src/Api/Public/Controllers/CollectionsController.cs b/src/Api/Public/Controllers/CollectionsController.cs index ae56d6824a..f2a745862c 100644 --- a/src/Api/Public/Controllers/CollectionsController.cs +++ b/src/Api/Public/Controllers/CollectionsController.cs @@ -40,13 +40,12 @@ public class CollectionsController : Controller [ProducesResponseType((int)HttpStatusCode.NotFound)] public async Task Get(Guid id) { - var collectionWithGroups = await _collectionRepository.GetByIdWithGroupsAsync(id); - var collection = collectionWithGroups?.Item1; + (var collection, var access) = await _collectionRepository.GetByIdWithAccessAsync(id); if (collection == null || collection.OrganizationId != _currentContext.OrganizationId) { return new NotFoundResult(); } - var response = new CollectionResponseModel(collection, collectionWithGroups.Item2); + var response = new CollectionResponseModel(collection, access.Groups); return new JsonResult(response); } diff --git a/src/Api/Public/Controllers/GroupsController.cs b/src/Api/Public/Controllers/GroupsController.cs index 17e9a96dde..7080405451 100644 --- a/src/Api/Public/Controllers/GroupsController.cs +++ b/src/Api/Public/Controllers/GroupsController.cs @@ -83,15 +83,14 @@ public class GroupsController : Controller /// /// /// Returns a list of your organization's groups. - /// Group objects listed in this call do not include information about their associated collections. + /// Group objects listed in this call include information about their associated collections. /// [HttpGet] [ProducesResponseType(typeof(ListResponseModel), (int)HttpStatusCode.OK)] public async Task List() { - var groups = await _groupRepository.GetManyByOrganizationIdAsync(_currentContext.OrganizationId.Value); - // TODO: Get all CollectionGroup associations for the organization and marry them up here for the response. - var groupResponses = groups.Select(g => new GroupResponseModel(g, null)); + var groups = await _groupRepository.GetManyWithCollectionsByOrganizationIdAsync(_currentContext.OrganizationId.Value); + var groupResponses = groups.Select(g => new GroupResponseModel(g.Item1, g.Item2)); var response = new ListResponseModel(groupResponses); return new JsonResult(response); } diff --git a/src/Api/Public/Controllers/MembersController.cs b/src/Api/Public/Controllers/MembersController.cs index 5ea079ee39..9d7ca86d3b 100644 --- a/src/Api/Public/Controllers/MembersController.cs +++ b/src/Api/Public/Controllers/MembersController.cs @@ -122,7 +122,7 @@ public class MembersController : Controller Collections = associations }; var user = await _organizationService.InviteUserAsync(_currentContext.OrganizationId.Value, null, - model.Email, model.Type.Value, model.AccessAll.Value, model.ExternalId, associations); + model.Email, model.Type.Value, model.AccessAll.Value, model.ExternalId, associations, model.Groups); var response = new MemberResponseModel(user, associations); return new JsonResult(response); } @@ -149,7 +149,7 @@ public class MembersController : Controller } var updatedUser = model.ToOrganizationUser(existingUser); var associations = model.Collections?.Select(c => c.ToSelectionReadOnly()); - await _organizationService.SaveUserAsync(updatedUser, null, associations); + await _organizationService.SaveUserAsync(updatedUser, null, associations, model.Groups); MemberResponseModel response = null; if (existingUser.UserId.HasValue) { diff --git a/src/Core/Models/Business/OrganizationUserInvite.cs b/src/Core/Models/Business/OrganizationUserInvite.cs index 4fa61d55c0..78edfb267e 100644 --- a/src/Core/Models/Business/OrganizationUserInvite.cs +++ b/src/Core/Models/Business/OrganizationUserInvite.cs @@ -9,7 +9,8 @@ public class OrganizationUserInvite public Enums.OrganizationUserType? Type { get; set; } public bool AccessAll { get; set; } public Permissions Permissions { get; set; } - public IEnumerable Collections { get; set; } + public IEnumerable Collections { get; set; } + public IEnumerable Groups { get; set; } public OrganizationUserInvite() { } @@ -19,6 +20,7 @@ public class OrganizationUserInvite Type = requestModel.Type; AccessAll = requestModel.AccessAll; Collections = requestModel.Collections; + Groups = requestModel.Groups; Permissions = requestModel.Permissions; } } diff --git a/src/Core/Models/Data/CollectionAccessDetails.cs b/src/Core/Models/Data/CollectionAccessDetails.cs new file mode 100644 index 0000000000..447d55460c --- /dev/null +++ b/src/Core/Models/Data/CollectionAccessDetails.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.Models.Data; + +public class CollectionAccessDetails +{ + public IEnumerable Groups { get; set; } + public IEnumerable Users { get; set; } +} + diff --git a/src/Core/Models/Data/SelectionReadOnly.cs b/src/Core/Models/Data/CollectionAccessSelection.cs similarity index 80% rename from src/Core/Models/Data/SelectionReadOnly.cs rename to src/Core/Models/Data/CollectionAccessSelection.cs index 426abb57f7..9d9aeef298 100644 --- a/src/Core/Models/Data/SelectionReadOnly.cs +++ b/src/Core/Models/Data/CollectionAccessSelection.cs @@ -1,6 +1,6 @@ namespace Bit.Core.Models.Data; -public class SelectionReadOnly +public class CollectionAccessSelection { public Guid Id { get; set; } public bool ReadOnly { get; set; } diff --git a/src/Core/Models/Data/Organizations/OrganizationUsers/OrganizationUserInviteData.cs b/src/Core/Models/Data/Organizations/OrganizationUsers/OrganizationUserInviteData.cs index ff360c10f1..887b64e963 100644 --- a/src/Core/Models/Data/Organizations/OrganizationUsers/OrganizationUserInviteData.cs +++ b/src/Core/Models/Data/Organizations/OrganizationUsers/OrganizationUserInviteData.cs @@ -7,6 +7,7 @@ public class OrganizationUserInviteData public IEnumerable Emails { get; set; } public OrganizationUserType? Type { get; set; } public bool AccessAll { get; set; } - public IEnumerable Collections { get; set; } + public IEnumerable Collections { get; set; } + public IEnumerable Groups { get; set; } public Permissions Permissions { get; set; } } diff --git a/src/Core/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs b/src/Core/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs index ce9cf64a5c..b155118161 100644 --- a/src/Core/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs +++ b/src/Core/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs @@ -23,6 +23,9 @@ public class OrganizationUserUserDetails : IExternal, ITwoFactorProvidersUser public string ResetPasswordKey { get; set; } public bool UsesKeyConnector { get; set; } + public ICollection Groups { get; set; } = new List(); + public ICollection Collections { get; set; } = new List(); + public Dictionary GetTwoFactorProviders() { if (string.IsNullOrWhiteSpace(TwoFactorProviders)) diff --git a/src/Core/OrganizationFeatures/Groups/CreateGroupCommand.cs b/src/Core/OrganizationFeatures/Groups/CreateGroupCommand.cs index 37a862ced8..d8e23d1176 100644 --- a/src/Core/OrganizationFeatures/Groups/CreateGroupCommand.cs +++ b/src/Core/OrganizationFeatures/Groups/CreateGroupCommand.cs @@ -13,35 +13,52 @@ public class CreateGroupCommand : ICreateGroupCommand { private readonly IEventService _eventService; private readonly IGroupRepository _groupRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IReferenceEventService _referenceEventService; public CreateGroupCommand( IEventService eventService, IGroupRepository groupRepository, + IOrganizationUserRepository organizationUserRepository, IReferenceEventService referenceEventService) { _eventService = eventService; _groupRepository = groupRepository; + _organizationUserRepository = organizationUserRepository; _referenceEventService = referenceEventService; } public async Task CreateGroupAsync(Group group, Organization organization, - IEnumerable collections = null) + IEnumerable collections = null, + IEnumerable users = null) { Validate(organization); await GroupRepositoryCreateGroupAsync(group, organization, collections); + + if (users != null) + { + await GroupRepositoryUpdateUsersAsync(group, users); + } + await _eventService.LogGroupEventAsync(group, Enums.EventType.Group_Created); } public async Task CreateGroupAsync(Group group, Organization organization, EventSystemUser systemUser, - IEnumerable collections = null) + IEnumerable collections = null, + IEnumerable users = null) { Validate(organization); await GroupRepositoryCreateGroupAsync(group, organization, collections); + + if (users != null) + { + await GroupRepositoryUpdateUsersAsync(group, users, systemUser); + } + await _eventService.LogGroupEventAsync(group, Enums.EventType.Group_Created, systemUser); } - private async Task GroupRepositoryCreateGroupAsync(Group group, Organization organization, IEnumerable collections = null) + private async Task GroupRepositoryCreateGroupAsync(Group group, Organization organization, IEnumerable collections = null) { group.CreationDate = group.RevisionDate = DateTime.UtcNow; @@ -57,6 +74,28 @@ public class CreateGroupCommand : ICreateGroupCommand await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.GroupCreated, organization)); } + private async Task GroupRepositoryUpdateUsersAsync(Group group, IEnumerable userIds, + EventSystemUser? systemUser = null) + { + var usersToAddToGroup = userIds as Guid[] ?? userIds.ToArray(); + + await _groupRepository.UpdateUsersAsync(group.Id, usersToAddToGroup); + + var users = await _organizationUserRepository.GetManyAsync(usersToAddToGroup); + var eventDate = DateTime.UtcNow; + + if (systemUser.HasValue) + { + await _eventService.LogOrganizationUserEventsAsync(users.Select(u => + (u, EventType.OrganizationUser_UpdatedGroups, systemUser.Value, (DateTime?)eventDate))); + } + else + { + await _eventService.LogOrganizationUserEventsAsync(users.Select(u => + (u, EventType.OrganizationUser_UpdatedGroups, (DateTime?)eventDate))); + } + } + private static void Validate(Organization organization) { if (organization == null) diff --git a/src/Core/OrganizationFeatures/Groups/DeleteGroupCommand.cs b/src/Core/OrganizationFeatures/Groups/DeleteGroupCommand.cs index f805791204..86fb615f82 100644 --- a/src/Core/OrganizationFeatures/Groups/DeleteGroupCommand.cs +++ b/src/Core/OrganizationFeatures/Groups/DeleteGroupCommand.cs @@ -9,25 +9,43 @@ namespace Bit.Core.OrganizationFeatures.Groups; public class DeleteGroupCommand : IDeleteGroupCommand { - private readonly IEventService _eventService; private readonly IGroupRepository _groupRepository; + private readonly IEventService _eventService; - public DeleteGroupCommand(IEventService eventService, IGroupRepository groupRepository) + public DeleteGroupCommand(IGroupRepository groupRepository, IEventService eventService) { - _eventService = eventService; _groupRepository = groupRepository; + _eventService = eventService; } public async Task DeleteGroupAsync(Guid organizationId, Guid id) { var group = await GroupRepositoryDeleteGroupAsync(organizationId, id); - await _eventService.LogGroupEventAsync(group, Core.Enums.EventType.Group_Deleted); + await _eventService.LogGroupEventAsync(group, EventType.Group_Deleted); } public async Task DeleteGroupAsync(Guid organizationId, Guid id, EventSystemUser eventSystemUser) { var group = await GroupRepositoryDeleteGroupAsync(organizationId, id); - await _eventService.LogGroupEventAsync(group, Core.Enums.EventType.Group_Deleted, eventSystemUser); + await _eventService.LogGroupEventAsync(group, EventType.Group_Deleted, eventSystemUser); + } + + public async Task DeleteAsync(Group group) + { + await _groupRepository.DeleteAsync(group); + await _eventService.LogGroupEventAsync(group, EventType.Group_Deleted); + } + + public async Task DeleteManyAsync(ICollection groups) + { + await _eventService.LogGroupEventsAsync( + groups.Select(g => + (g, EventType.Group_Deleted, (EventSystemUser?)null, (DateTime?)DateTime.UtcNow) + )); + + await _groupRepository.DeleteManyAsync( + groups.Select(g => g.Id) + ); } private async Task GroupRepositoryDeleteGroupAsync(Guid organizationId, Guid id) diff --git a/src/Core/OrganizationFeatures/Groups/Interfaces/ICreateGroupCommand.cs b/src/Core/OrganizationFeatures/Groups/Interfaces/ICreateGroupCommand.cs index bdd1d1d793..d8da08e8f2 100644 --- a/src/Core/OrganizationFeatures/Groups/Interfaces/ICreateGroupCommand.cs +++ b/src/Core/OrganizationFeatures/Groups/Interfaces/ICreateGroupCommand.cs @@ -7,8 +7,10 @@ namespace Bit.Core.OrganizationFeatures.Groups.Interfaces; public interface ICreateGroupCommand { Task CreateGroupAsync(Group group, Organization organization, - IEnumerable collections = null); + IEnumerable collections = null, + IEnumerable users = null); Task CreateGroupAsync(Group group, Organization organization, EventSystemUser systemUser, - IEnumerable collections = null); + IEnumerable collections = null, + IEnumerable users = null); } diff --git a/src/Core/OrganizationFeatures/Groups/Interfaces/IDeleteGroupCommand.cs b/src/Core/OrganizationFeatures/Groups/Interfaces/IDeleteGroupCommand.cs index 267e4fdfae..6dc9d01f99 100644 --- a/src/Core/OrganizationFeatures/Groups/Interfaces/IDeleteGroupCommand.cs +++ b/src/Core/OrganizationFeatures/Groups/Interfaces/IDeleteGroupCommand.cs @@ -1,4 +1,5 @@ -using Bit.Core.Enums; +using Bit.Core.Entities; +using Bit.Core.Enums; namespace Bit.Core.OrganizationFeatures.Groups.Interfaces; @@ -6,4 +7,6 @@ public interface IDeleteGroupCommand { Task DeleteGroupAsync(Guid organizationId, Guid id); Task DeleteGroupAsync(Guid organizationId, Guid id, EventSystemUser eventSystemUser); + Task DeleteAsync(Group group); + Task DeleteManyAsync(ICollection groups); } diff --git a/src/Core/OrganizationFeatures/Groups/Interfaces/IUpdateGroupCommand.cs b/src/Core/OrganizationFeatures/Groups/Interfaces/IUpdateGroupCommand.cs index d0b2134753..7abb012fad 100644 --- a/src/Core/OrganizationFeatures/Groups/Interfaces/IUpdateGroupCommand.cs +++ b/src/Core/OrganizationFeatures/Groups/Interfaces/IUpdateGroupCommand.cs @@ -7,8 +7,10 @@ namespace Bit.Core.OrganizationFeatures.Groups.Interfaces; public interface IUpdateGroupCommand { Task UpdateGroupAsync(Group group, Organization organization, - IEnumerable collections = null); + IEnumerable collections = null, + IEnumerable users = null); Task UpdateGroupAsync(Group group, Organization organization, EventSystemUser systemUser, - IEnumerable collections = null); + IEnumerable collections = null, + IEnumerable users = null); } diff --git a/src/Core/OrganizationFeatures/Groups/UpdateGroupCommand.cs b/src/Core/OrganizationFeatures/Groups/UpdateGroupCommand.cs index 50422f04e6..857adcc8c0 100644 --- a/src/Core/OrganizationFeatures/Groups/UpdateGroupCommand.cs +++ b/src/Core/OrganizationFeatures/Groups/UpdateGroupCommand.cs @@ -12,32 +12,49 @@ public class UpdateGroupCommand : IUpdateGroupCommand { private readonly IEventService _eventService; private readonly IGroupRepository _groupRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; public UpdateGroupCommand( IEventService eventService, - IGroupRepository groupRepository) + IGroupRepository groupRepository, + IOrganizationUserRepository organizationUserRepository) { _eventService = eventService; _groupRepository = groupRepository; + _organizationUserRepository = organizationUserRepository; } public async Task UpdateGroupAsync(Group group, Organization organization, - IEnumerable collections = null) + IEnumerable collections = null, + IEnumerable userIds = null) { Validate(organization); await GroupRepositoryUpdateGroupAsync(group, collections); + + if (userIds != null) + { + await GroupRepositoryUpdateUsersAsync(group, userIds); + } + await _eventService.LogGroupEventAsync(group, Enums.EventType.Group_Updated); } public async Task UpdateGroupAsync(Group group, Organization organization, EventSystemUser systemUser, - IEnumerable collections = null) + IEnumerable collections = null, + IEnumerable userIds = null) { Validate(organization); await GroupRepositoryUpdateGroupAsync(group, collections); + + if (userIds != null) + { + await GroupRepositoryUpdateUsersAsync(group, userIds, systemUser); + } + await _eventService.LogGroupEventAsync(group, Enums.EventType.Group_Updated, systemUser); } - private async Task GroupRepositoryUpdateGroupAsync(Group group, IEnumerable collections = null) + private async Task GroupRepositoryUpdateGroupAsync(Group group, IEnumerable collections = null) { group.RevisionDate = DateTime.UtcNow; @@ -51,6 +68,34 @@ public class UpdateGroupCommand : IUpdateGroupCommand } } + private async Task GroupRepositoryUpdateUsersAsync(Group group, IEnumerable userIds, EventSystemUser? systemUser = null) + { + var newUserIds = userIds as Guid[] ?? userIds.ToArray(); + var originalUserIds = await _groupRepository.GetManyUserIdsByIdAsync(group.Id); + + await _groupRepository.UpdateUsersAsync(group.Id, newUserIds); + + // We only want to create events OrganizationUserEvents for those that were actually modified. + // HashSet.SymmetricExceptWith is a convenient method of finding the difference between lists + var changedUserIds = new HashSet(originalUserIds); + changedUserIds.SymmetricExceptWith(newUserIds); + + // Fetch all changed users for logging the event + var users = await _organizationUserRepository.GetManyAsync(changedUserIds); + var eventDate = DateTime.UtcNow; + + if (systemUser.HasValue) + { + await _eventService.LogOrganizationUserEventsAsync(users.Select(u => + (u, EventType.OrganizationUser_UpdatedGroups, systemUser.Value, (DateTime?)eventDate))); + } + else + { + await _eventService.LogOrganizationUserEventsAsync(users.Select(u => + (u, EventType.OrganizationUser_UpdatedGroups, (DateTime?)eventDate))); + } + } + private static void Validate(Organization organization) { if (organization == null) diff --git a/src/Core/OrganizationFeatures/OrganizationCollections/DeleteCollectionCommand.cs b/src/Core/OrganizationFeatures/OrganizationCollections/DeleteCollectionCommand.cs new file mode 100644 index 0000000000..11f29f228f --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationCollections/DeleteCollectionCommand.cs @@ -0,0 +1,39 @@ +using Bit.Core.Entities; +using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.OrganizationFeatures.OrganizationCollections; + +public class DeleteCollectionCommand : IDeleteCollectionCommand +{ + private readonly ICollectionRepository _collectionRepository; + private readonly IEventService _eventService; + + public DeleteCollectionCommand( + ICollectionRepository collectionRepository, + IEventService eventService) + { + _collectionRepository = collectionRepository; + _eventService = eventService; + } + + public async Task DeleteAsync(Collection collection) + { + await _collectionRepository.DeleteAsync(collection); + await _eventService.LogCollectionEventAsync(collection, Enums.EventType.Collection_Deleted, DateTime.UtcNow); + } + + public async Task DeleteManyAsync(IEnumerable collectionIds) + { + var ids = collectionIds as Guid[] ?? collectionIds.ToArray(); + var collectionsToDelete = await _collectionRepository.GetManyByManyIdsAsync(ids); + await this.DeleteManyAsync(collectionsToDelete); + } + + public async Task DeleteManyAsync(IEnumerable collections) + { + await _collectionRepository.DeleteManyAsync(collections.Select(c => c.Id)); + await _eventService.LogCollectionEventsAsync(collections.Select(c => (c, Enums.EventType.Collection_Deleted, (DateTime?)DateTime.UtcNow))); + } +} diff --git a/src/Core/OrganizationFeatures/OrganizationCollections/Interfaces/IDeleteCollectionCommand.cs b/src/Core/OrganizationFeatures/OrganizationCollections/Interfaces/IDeleteCollectionCommand.cs new file mode 100644 index 0000000000..adbfe2f676 --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationCollections/Interfaces/IDeleteCollectionCommand.cs @@ -0,0 +1,10 @@ +using Bit.Core.Entities; + +namespace Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; + +public interface IDeleteCollectionCommand +{ + Task DeleteAsync(Collection collection); + Task DeleteManyAsync(IEnumerable collectionIds); + Task DeleteManyAsync(IEnumerable collections); +} diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 07e39a964e..18c2f44dc9 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -3,6 +3,8 @@ using Bit.Core.OrganizationFeatures.Groups; using Bit.Core.OrganizationFeatures.Groups.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationApiKeys; using Bit.Core.OrganizationFeatures.OrganizationApiKeys.Interfaces; +using Bit.Core.OrganizationFeatures.OrganizationCollections; +using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationConnections; using Bit.Core.OrganizationFeatures.OrganizationConnections.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise; @@ -28,13 +30,8 @@ public static class OrganizationServiceCollectionExtensions services.AddOrganizationConnectionCommands(); services.AddOrganizationSponsorshipCommands(globalSettings); services.AddOrganizationApiKeyCommandsQueries(); - } - - private static void AddOrganizationGroupCommands(this IServiceCollection services) - { - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddOrganizationCollectionCommands(); + services.AddOrganizationGroupCommands(); } private static void AddOrganizationConnectionCommands(this IServiceCollection services) @@ -76,6 +73,18 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); } + public static void AddOrganizationCollectionCommands(this IServiceCollection services) + { + services.AddScoped(); + } + + private static void AddOrganizationGroupCommands(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } + private static void AddTokenizers(this IServiceCollection services) { services.AddSingleton>(serviceProvider => diff --git a/src/Core/Repositories/ICollectionRepository.cs b/src/Core/Repositories/ICollectionRepository.cs index dda042aa89..2114a60a89 100644 --- a/src/Core/Repositories/ICollectionRepository.cs +++ b/src/Core/Repositories/ICollectionRepository.cs @@ -6,14 +6,18 @@ namespace Bit.Core.Repositories; public interface ICollectionRepository : IRepository { Task GetCountByOrganizationIdAsync(Guid organizationId); - Task>> GetByIdWithGroupsAsync(Guid id); - Task>> GetByIdWithGroupsAsync(Guid id, Guid userId); + Task> GetByIdWithAccessAsync(Guid id); + Task> GetByIdWithAccessAsync(Guid id, Guid userId); Task> GetManyByOrganizationIdAsync(Guid organizationId); + Task>> GetManyByOrganizationIdWithAccessAsync(Guid organizationId); + Task>> GetManyByUserIdWithAccessAsync(Guid userId, Guid organizationId); Task GetByIdAsync(Guid id, Guid userId); + Task> GetManyByManyIdsAsync(IEnumerable collectionIds); Task> GetManyByUserIdAsync(Guid userId); - Task CreateAsync(Collection obj, IEnumerable groups); - Task ReplaceAsync(Collection obj, IEnumerable groups); + Task CreateAsync(Collection obj, IEnumerable groups, IEnumerable users); + Task ReplaceAsync(Collection obj, IEnumerable groups, IEnumerable users); Task DeleteUserAsync(Guid collectionId, Guid organizationUserId); - Task UpdateUsersAsync(Guid id, IEnumerable users); - Task> GetManyUsersByIdAsync(Guid id); + Task UpdateUsersAsync(Guid id, IEnumerable users); + Task> GetManyUsersByIdAsync(Guid id); + Task DeleteManyAsync(IEnumerable collectionIds); } diff --git a/src/Core/Repositories/IGroupRepository.cs b/src/Core/Repositories/IGroupRepository.cs index d7b9b664d7..f1b506de07 100644 --- a/src/Core/Repositories/IGroupRepository.cs +++ b/src/Core/Repositories/IGroupRepository.cs @@ -5,13 +5,17 @@ namespace Bit.Core.Repositories; public interface IGroupRepository : IRepository { - Task>> GetByIdWithCollectionsAsync(Guid id); + Task>> GetByIdWithCollectionsAsync(Guid id); Task> GetManyByOrganizationIdAsync(Guid organizationId); + Task>>> GetManyWithCollectionsByOrganizationIdAsync( + Guid organizationId); + Task> GetManyByManyIds(IEnumerable groupIds); Task> GetManyIdsByUserIdAsync(Guid organizationUserId); Task> GetManyUserIdsByIdAsync(Guid id); Task> GetManyGroupUsersByOrganizationIdAsync(Guid organizationId); - Task CreateAsync(Group obj, IEnumerable collections); - Task ReplaceAsync(Group obj, IEnumerable collections); + Task CreateAsync(Group obj, IEnumerable collections); + Task ReplaceAsync(Group obj, IEnumerable collections); Task DeleteUserAsync(Guid groupId, Guid organizationUserId); Task UpdateUsersAsync(Guid groupId, IEnumerable organizationUserIds); + Task DeleteManyAsync(IEnumerable groupIds); } diff --git a/src/Core/Repositories/IOrganizationUserRepository.cs b/src/Core/Repositories/IOrganizationUserRepository.cs index f8909f7844..3844b1a87d 100644 --- a/src/Core/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/Repositories/IOrganizationUserRepository.cs @@ -15,20 +15,20 @@ public interface IOrganizationUserRepository : IRepository GetCountByOrganizationAsync(Guid organizationId, string email, bool onlyRegisteredUsers); Task> SelectKnownEmailsAsync(Guid organizationId, IEnumerable emails, bool onlyRegisteredUsers); Task GetByOrganizationAsync(Guid organizationId, Guid userId); - Task>> GetByIdWithCollectionsAsync(Guid id); + Task>> GetByIdWithCollectionsAsync(Guid id); Task GetDetailsByIdAsync(Guid id); - Task>> + Task>> GetDetailsByIdWithCollectionsAsync(Guid id); - Task> GetManyDetailsByOrganizationAsync(Guid organizationId); + Task> GetManyDetailsByOrganizationAsync(Guid organizationId, bool includeGroups = false, bool includeCollections = false); Task> GetManyDetailsByUserAsync(Guid userId, OrganizationUserStatusType? status = null); Task GetDetailsByUserAsync(Guid userId, Guid organizationId, OrganizationUserStatusType? status = null); Task UpdateGroupsAsync(Guid orgUserId, IEnumerable groupIds); Task UpsertManyAsync(IEnumerable organizationUsers); - Task CreateAsync(OrganizationUser obj, IEnumerable collections); + Task CreateAsync(OrganizationUser obj, IEnumerable collections); Task> CreateManyAsync(IEnumerable organizationIdUsers); - Task ReplaceAsync(OrganizationUser obj, IEnumerable collections); + Task ReplaceAsync(OrganizationUser obj, IEnumerable collections); Task ReplaceManyAsync(IEnumerable organizationUsers); Task> GetManyByManyUsersAsync(IEnumerable userIds); Task> GetManyAsync(IEnumerable Ids); diff --git a/src/Core/Services/ICollectionService.cs b/src/Core/Services/ICollectionService.cs index 7ae3562ea0..5da8d639e5 100644 --- a/src/Core/Services/ICollectionService.cs +++ b/src/Core/Services/ICollectionService.cs @@ -5,8 +5,7 @@ namespace Bit.Core.Services; public interface ICollectionService { - Task SaveAsync(Collection collection, IEnumerable groups = null, Guid? assignUserId = null); - Task DeleteAsync(Collection collection); + Task SaveAsync(Collection collection, IEnumerable groups = null, IEnumerable users = null, Guid? assignUserId = null); Task DeleteUserAsync(Collection collection, Guid organizationUserId); Task> GetOrganizationCollections(Guid organizationId); } diff --git a/src/Core/Services/IEventService.cs b/src/Core/Services/IEventService.cs index 72b738a4c8..68c0acfc6c 100644 --- a/src/Core/Services/IEventService.cs +++ b/src/Core/Services/IEventService.cs @@ -10,8 +10,10 @@ public interface IEventService Task LogCipherEventAsync(Cipher cipher, EventType type, DateTime? date = null); Task LogCipherEventsAsync(IEnumerable> events); Task LogCollectionEventAsync(Collection collection, EventType type, DateTime? date = null); + Task LogCollectionEventsAsync(IEnumerable<(Collection collection, EventType type, DateTime? date)> events); Task LogGroupEventAsync(Group group, EventType type, DateTime? date = null); Task LogGroupEventAsync(Group group, EventType type, EventSystemUser systemUser, DateTime? date = null); + Task LogGroupEventsAsync(IEnumerable<(Group group, EventType type, EventSystemUser? systemUser, DateTime? date)> events); Task LogPolicyEventAsync(Policy policy, EventType type, DateTime? date = null); Task LogOrganizationUserEventAsync(OrganizationUser organizationUser, EventType type, DateTime? date = null); Task LogOrganizationUserEventAsync(OrganizationUser organizationUser, EventType type, EventSystemUser systemUser, DateTime? date = null); diff --git a/src/Core/Services/IOrganizationService.cs b/src/Core/Services/IOrganizationService.cs index fbdec32599..f3f3f16139 100644 --- a/src/Core/Services/IOrganizationService.cs +++ b/src/Core/Services/IOrganizationService.cs @@ -34,9 +34,9 @@ public interface IOrganizationService Task> InviteUsersAsync(Guid organizationId, EventSystemUser systemUser, IEnumerable<(OrganizationUserInvite invite, string externalId)> invites); Task InviteUserAsync(Guid organizationId, Guid? invitingUserId, string email, - OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections); + OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections, IEnumerable groups); Task InviteUserAsync(Guid organizationId, EventSystemUser systemUser, string email, - OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections); + OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections, IEnumerable groups); Task>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable organizationUsersId); Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId); Task AcceptUserAsync(Guid organizationUserId, User user, string token, @@ -46,7 +46,7 @@ public interface IOrganizationService Guid confirmingUserId, IUserService userService); Task>> ConfirmUsersAsync(Guid organizationId, Dictionary keys, Guid confirmingUserId, IUserService userService); - Task SaveUserAsync(OrganizationUser user, Guid? savingUserId, IEnumerable collections); + Task SaveUserAsync(OrganizationUser user, Guid? savingUserId, IEnumerable collections, IEnumerable groups); [Obsolete("IDeleteOrganizationUserCommand should be used instead. To be removed by EC-607.")] Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId); [Obsolete("IDeleteOrganizationUserCommand should be used instead. To be removed by EC-607.")] diff --git a/src/Core/Services/Implementations/CollectionService.cs b/src/Core/Services/Implementations/CollectionService.cs index 699f38925d..607cb9eb35 100644 --- a/src/Core/Services/Implementations/CollectionService.cs +++ b/src/Core/Services/Implementations/CollectionService.cs @@ -39,8 +39,8 @@ public class CollectionService : ICollectionService _currentContext = currentContext; } - public async Task SaveAsync(Collection collection, IEnumerable groups = null, - Guid? assignUserId = null) + public async Task SaveAsync(Collection collection, IEnumerable groups = null, + IEnumerable users = null, Guid? assignUserId = null) { var org = await _organizationRepository.GetByIdAsync(collection.OrganizationId); if (org == null) @@ -60,14 +60,7 @@ public class CollectionService : ICollectionService } } - if (groups == null || !org.UseGroups) - { - await _collectionRepository.CreateAsync(collection); - } - else - { - await _collectionRepository.CreateAsync(collection, groups); - } + await _collectionRepository.CreateAsync(collection, org.UseGroups ? groups : null, users); // Assign a user to the newly created collection. if (assignUserId.HasValue) @@ -76,8 +69,8 @@ public class CollectionService : ICollectionService if (orgUser != null && orgUser.Status == Enums.OrganizationUserStatusType.Confirmed) { await _collectionRepository.UpdateUsersAsync(collection.Id, - new List { - new SelectionReadOnly { Id = orgUser.Id, ReadOnly = false } }); + new List { + new CollectionAccessSelection { Id = orgUser.Id, ReadOnly = false } }); } } @@ -86,25 +79,11 @@ public class CollectionService : ICollectionService } else { - if (!org.UseGroups) - { - await _collectionRepository.ReplaceAsync(collection); - } - else - { - await _collectionRepository.ReplaceAsync(collection, groups ?? new List()); - } - + await _collectionRepository.ReplaceAsync(collection, org.UseGroups ? groups : null, users); await _eventService.LogCollectionEventAsync(collection, Enums.EventType.Collection_Updated); } } - public async Task DeleteAsync(Collection collection) - { - await _collectionRepository.DeleteAsync(collection); - await _eventService.LogCollectionEventAsync(collection, Enums.EventType.Collection_Deleted); - } - public async Task DeleteUserAsync(Collection collection, Guid organizationUserId) { var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); diff --git a/src/Core/Services/Implementations/EventService.cs b/src/Core/Services/Implementations/EventService.cs index 24223d4e5f..82b6658e0b 100644 --- a/src/Core/Services/Implementations/EventService.cs +++ b/src/Core/Services/Implementations/EventService.cs @@ -135,59 +135,67 @@ public class EventService : IEventService }; } - public async Task LogCollectionEventAsync(Collection collection, EventType type, DateTime? date = null) + public async Task LogCollectionEventAsync(Collection collection, EventType type, DateTime? date = null) => + await LogCollectionEventsAsync(new[] { (collection, type, date) }); + + + public async Task LogCollectionEventsAsync(IEnumerable<(Collection collection, EventType type, DateTime? date)> events) { var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); - if (!CanUseEvents(orgAbilities, collection.OrganizationId)) + var eventMessages = new List(); + foreach (var (collection, type, date) in events) { - return; + if (!CanUseEvents(orgAbilities, collection.OrganizationId)) + { + continue; + } + + eventMessages.Add(new EventMessage(_currentContext) + { + OrganizationId = collection.OrganizationId, + CollectionId = collection.Id, + Type = type, + ActingUserId = _currentContext?.UserId, + ProviderId = await GetProviderIdAsync(collection.OrganizationId), + Date = date.GetValueOrDefault(DateTime.UtcNow) + }); } - var e = new EventMessage(_currentContext) - { - OrganizationId = collection.OrganizationId, - CollectionId = collection.Id, - Type = type, - ActingUserId = _currentContext?.UserId, - ProviderId = await GetProviderIdAsync(collection.OrganizationId), - Date = date.GetValueOrDefault(DateTime.UtcNow) - }; - await _eventWriteService.CreateAsync(e); + await _eventWriteService.CreateManyAsync(eventMessages); } - public async Task LogGroupEventAsync(Group group, EventType type, - DateTime? date = null) - { - await CreateLogGroupEventAsync(group, type, systemUser: null, date); - } + public async Task LogGroupEventAsync(Group group, EventType type, DateTime? date = null) => + await LogGroupEventsAsync(new[] { (group, type, (EventSystemUser?)null, date) }); - public async Task LogGroupEventAsync(Group group, EventType type, EventSystemUser systemUser, - DateTime? date = null) - { - await CreateLogGroupEventAsync(group, type, systemUser, date); - } + public async Task LogGroupEventAsync(Group group, EventType type, EventSystemUser systemUser, DateTime? date = null) => + await LogGroupEventsAsync(new[] { (group, type, (EventSystemUser?)systemUser, date) }); - private async Task CreateLogGroupEventAsync(Group group, EventType type, EventSystemUser? systemUser, DateTime? date = null) + public async Task LogGroupEventsAsync(IEnumerable<(Group group, EventType type, EventSystemUser? systemUser, DateTime? date)> events) { var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); - if (!CanUseEvents(orgAbilities, group.OrganizationId)) + var eventMessages = new List(); + foreach (var (group, type, systemUser, date) in events) { - return; - } + if (!CanUseEvents(orgAbilities, group.OrganizationId)) + { + continue; + } - var e = new EventMessage(_currentContext) - { - OrganizationId = group.OrganizationId, - GroupId = group.Id, - Type = type, - ActingUserId = _currentContext?.UserId, - ProviderId = await GetProviderIdAsync(@group.OrganizationId), - Date = date.GetValueOrDefault(DateTime.UtcNow), - SystemUser = systemUser - }; - await _eventWriteService.CreateAsync(e); + eventMessages.Add(new EventMessage(_currentContext) + { + OrganizationId = group.OrganizationId, + GroupId = group.Id, + Type = type, + ActingUserId = _currentContext?.UserId, + ProviderId = await GetProviderIdAsync(group.OrganizationId), + SystemUser = systemUser, + Date = date.GetValueOrDefault(DateTime.UtcNow) + }); + } + await _eventWriteService.CreateManyAsync(eventMessages); } + public async Task LogPolicyEventAsync(Policy policy, EventType type, DateTime? date = null) { var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index ac5f3656bc..88feb8c800 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -1207,7 +1207,8 @@ public class OrganizationService : IOrganizationService } var orgUsers = new List(); - var limitedCollectionOrgUsers = new List<(OrganizationUser, IEnumerable)>(); + var limitedCollectionOrgUsers = new List<(OrganizationUser, IEnumerable)>(); + var orgUserGroups = new List<(OrganizationUser, IEnumerable)>(); var orgUserInvitedCount = 0; var exceptions = new List(); var events = new List<(OrganizationUser, EventType, DateTime?)>(); @@ -1252,6 +1253,11 @@ public class OrganizationService : IOrganizationService orgUsers.Add(orgUser); } + if (invite.Groups != null && invite.Groups.Any()) + { + orgUserGroups.Add((orgUser, invite.Groups)); + } + events.Add((orgUser, EventType.OrganizationUser_Invited, DateTime.UtcNow)); orgUserInvitedCount++; } @@ -1276,6 +1282,11 @@ public class OrganizationService : IOrganizationService await _organizationUserRepository.CreateAsync(orgUser, collections); } + foreach (var (orgUser, groups) in orgUserGroups) + { + await _organizationUserRepository.UpdateGroupsAsync(orgUser.Id, groups); + } + if (!await _currentContext.ManageUsers(organization.Id)) { throw new BadRequestException("Cannot add seats. Cannot manage organization users."); @@ -1659,7 +1670,8 @@ public class OrganizationService : IOrganizationService } public async Task SaveUserAsync(OrganizationUser user, Guid? savingUserId, - IEnumerable collections) + IEnumerable collections, + IEnumerable groups) { if (user.Id.Equals(default(Guid))) { @@ -1688,9 +1700,15 @@ public class OrganizationService : IOrganizationService if (user.AccessAll) { // We don't need any collections if we're flagged to have all access. - collections = new List(); + collections = new List(); } await _organizationUserRepository.ReplaceAsync(user, collections); + + if (groups != null) + { + await _organizationUserRepository.UpdateGroupsAsync(user.Id, groups); + } + await _eventService.LogOrganizationUserEventAsync(user, EventType.OrganizationUser_Updated); } @@ -1915,19 +1933,21 @@ public class OrganizationService : IOrganizationService } public async Task InviteUserAsync(Guid organizationId, Guid? invitingUserId, string email, - OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections) + OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections, + IEnumerable groups) { - return await SaveUserSendInviteAsync(organizationId, invitingUserId, systemUser: null, email, type, accessAll, externalId, collections); + return await SaveUserSendInviteAsync(organizationId, invitingUserId, systemUser: null, email, type, accessAll, externalId, collections, groups); } public async Task InviteUserAsync(Guid organizationId, EventSystemUser systemUser, string email, - OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections) + OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections, + IEnumerable groups) { - return await SaveUserSendInviteAsync(organizationId, invitingUserId: null, systemUser, email, type, accessAll, externalId, collections); + return await SaveUserSendInviteAsync(organizationId, invitingUserId: null, systemUser, email, type, accessAll, externalId, collections, groups); } private async Task SaveUserSendInviteAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, string email, - OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections) + OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections, IEnumerable groups) { var invite = new OrganizationUserInvite() { @@ -1935,6 +1955,7 @@ public class OrganizationService : IOrganizationService Type = type, AccessAll = accessAll, Collections = collections, + Groups = groups }; var results = systemUser.HasValue ? await InviteUsersAsync(organizationId, systemUser.Value, new (OrganizationUserInvite, string)[] { (invite, externalId) }) : await InviteUsersAsync(organizationId, invitingUserId, @@ -2048,7 +2069,7 @@ public class OrganizationService : IOrganizationService Emails = new List { user.Email }, Type = OrganizationUserType.User, AccessAll = false, - Collections = new List(), + Collections = new List(), }; userInvites.Add((invite, user.ExternalId)); } diff --git a/src/Core/Services/NoopImplementations/NoopEventService.cs b/src/Core/Services/NoopImplementations/NoopEventService.cs index ff19415252..7f9d02b949 100644 --- a/src/Core/Services/NoopImplementations/NoopEventService.cs +++ b/src/Core/Services/NoopImplementations/NoopEventService.cs @@ -21,6 +21,17 @@ public class NoopEventService : IEventService return Task.FromResult(0); } + Task IEventService.LogCollectionEventsAsync(IEnumerable<(Collection collection, EventType type, DateTime? date)> events) + { + return Task.FromResult(0); + } + + public Task LogGroupEventsAsync( + IEnumerable<(Group group, EventType type, EventSystemUser? systemUser, DateTime? date)> events) + { + return Task.FromResult(0); + } + public Task LogPolicyEventAsync(Policy policy, EventType type, DateTime? date = null) { return Task.FromResult(0); @@ -82,4 +93,5 @@ public class NoopEventService : IEventService { return Task.FromResult(0); } + } diff --git a/src/Infrastructure.Dapper/DapperHelpers.cs b/src/Infrastructure.Dapper/DapperHelpers.cs index 48949df671..bef1f39741 100644 --- a/src/Infrastructure.Dapper/DapperHelpers.cs +++ b/src/Infrastructure.Dapper/DapperHelpers.cs @@ -29,7 +29,7 @@ public static class DapperHelpers return table; } - public static DataTable ToArrayTVP(this IEnumerable values) + public static DataTable ToArrayTVP(this IEnumerable values) { var table = new DataTable(); table.SetTypeName("[dbo].[SelectionReadOnlyArray]"); diff --git a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs index b6021fe47d..22c4bebdc7 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs @@ -32,36 +32,53 @@ public class CollectionRepository : Repository, ICollectionRep } } - public async Task>> GetByIdWithGroupsAsync(Guid id) + public async Task> GetByIdWithAccessAsync(Guid id) { using (var connection = new SqlConnection(ConnectionString)) { var results = await connection.QueryMultipleAsync( - $"[{Schema}].[Collection_ReadWithGroupsById]", + $"[{Schema}].[Collection_ReadWithGroupsAndUsersById]", new { Id = id }, commandType: CommandType.StoredProcedure); var collection = await results.ReadFirstOrDefaultAsync(); - var groups = (await results.ReadAsync()).ToList(); + var groups = (await results.ReadAsync()).ToList(); + var users = (await results.ReadAsync()).ToList(); + var access = new CollectionAccessDetails { Groups = groups, Users = users }; - return new Tuple>(collection, groups); + return new Tuple(collection, access); } } - public async Task>> GetByIdWithGroupsAsync( + public async Task> GetByIdWithAccessAsync( Guid id, Guid userId) { using (var connection = new SqlConnection(ConnectionString)) { var results = await connection.QueryMultipleAsync( - $"[{Schema}].[Collection_ReadWithGroupsByIdUserId]", + $"[{Schema}].[Collection_ReadWithGroupsAndUsersByIdUserId]", new { Id = id, UserId = userId }, commandType: CommandType.StoredProcedure); var collection = await results.ReadFirstOrDefaultAsync(); - var groups = (await results.ReadAsync()).ToList(); + var groups = (await results.ReadAsync()).ToList(); + var users = (await results.ReadAsync()).ToList(); + var access = new CollectionAccessDetails { Groups = groups, Users = users }; - return new Tuple>(collection, groups); + return new Tuple(collection, access); + } + } + + public async Task> GetManyByManyIdsAsync(IEnumerable collectionIds) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[Collection_ReadByIds]", + new { Ids = collectionIds.ToGuidIdArrayTVP() }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); } } @@ -78,6 +95,91 @@ public class CollectionRepository : Repository, ICollectionRep } } + public async Task>> GetManyByOrganizationIdWithAccessAsync(Guid organizationId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryMultipleAsync( + $"[{Schema}].[Collection_ReadWithGroupsAndUsersByOrganizationId]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + var collections = (await results.ReadAsync()); + var groups = (await results.ReadAsync()) + .GroupBy(g => g.CollectionId); + var users = (await results.ReadAsync()) + .GroupBy(u => u.CollectionId); + + return collections.Select(collection => + new Tuple( + collection, + new CollectionAccessDetails + { + Groups = groups + .FirstOrDefault(g => g.Key == collection.Id)? + .Select(g => new CollectionAccessSelection + { + Id = g.GroupId, + HidePasswords = g.HidePasswords, + ReadOnly = g.ReadOnly + }).ToList() ?? new List(), + Users = users + .FirstOrDefault(u => u.Key == collection.Id)? + .Select(c => new CollectionAccessSelection + { + Id = c.OrganizationUserId, + HidePasswords = c.HidePasswords, + ReadOnly = c.ReadOnly + }).ToList() ?? new List() + } + ) + ).ToList(); + } + } + + public async Task>> GetManyByUserIdWithAccessAsync(Guid userId, Guid organizationId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryMultipleAsync( + $"[{Schema}].[Collection_ReadWithGroupsAndUsersByUserId]", + new { UserId = userId }, + commandType: CommandType.StoredProcedure); + + var collections = (await results.ReadAsync()).Where(c => c.OrganizationId == organizationId); + var groups = (await results.ReadAsync()) + .GroupBy(g => g.CollectionId); + var users = (await results.ReadAsync()) + .GroupBy(u => u.CollectionId); + + return collections.Select(collection => + new Tuple( + collection, + new CollectionAccessDetails + { + Groups = groups + .FirstOrDefault(g => g.Key == collection.Id)? + .Select(g => new CollectionAccessSelection + { + Id = g.GroupId, + HidePasswords = g.HidePasswords, + ReadOnly = g.ReadOnly + }).ToList() ?? new List(), + Users = users + .FirstOrDefault(u => u.Key == collection.Id)? + .Select(c => new CollectionAccessSelection + { + Id = c.OrganizationUserId, + HidePasswords = c.HidePasswords, + ReadOnly = c.ReadOnly + }).ToList() ?? new List() + } + ) + ).ToList(); + } + + } + public async Task GetByIdAsync(Guid id, Guid userId) { using (var connection = new SqlConnection(ConnectionString)) @@ -104,35 +206,48 @@ public class CollectionRepository : Repository, ICollectionRep } } - public async Task CreateAsync(Collection obj, IEnumerable groups) + public async Task CreateAsync(Collection obj, IEnumerable groups, IEnumerable users) { obj.SetNewId(); - var objWithGroups = JsonSerializer.Deserialize(JsonSerializer.Serialize(obj)); - objWithGroups.Groups = groups.ToArrayTVP(); + var objWithGroupsAndUsers = JsonSerializer.Deserialize(JsonSerializer.Serialize(obj)); + + objWithGroupsAndUsers.Groups = groups != null ? groups.ToArrayTVP() : Enumerable.Empty().ToArrayTVP(); + objWithGroupsAndUsers.Users = users != null ? users.ToArrayTVP() : Enumerable.Empty().ToArrayTVP(); using (var connection = new SqlConnection(ConnectionString)) { var results = await connection.ExecuteAsync( - $"[{Schema}].[Collection_CreateWithGroups]", - objWithGroups, + $"[{Schema}].[Collection_CreateWithGroupsAndUsers]", + objWithGroupsAndUsers, commandType: CommandType.StoredProcedure); } } - public async Task ReplaceAsync(Collection obj, IEnumerable groups) + public async Task ReplaceAsync(Collection obj, IEnumerable groups, IEnumerable users) { - var objWithGroups = JsonSerializer.Deserialize(JsonSerializer.Serialize(obj)); - objWithGroups.Groups = groups.ToArrayTVP(); + var objWithGroupsAndUsers = JsonSerializer.Deserialize(JsonSerializer.Serialize(obj)); + + objWithGroupsAndUsers.Groups = groups != null ? groups.ToArrayTVP() : Enumerable.Empty().ToArrayTVP(); + objWithGroupsAndUsers.Users = users != null ? users.ToArrayTVP() : Enumerable.Empty().ToArrayTVP(); using (var connection = new SqlConnection(ConnectionString)) { var results = await connection.ExecuteAsync( - $"[{Schema}].[Collection_UpdateWithGroups]", - objWithGroups, + $"[{Schema}].[Collection_UpdateWithGroupsAndUsers]", + objWithGroupsAndUsers, commandType: CommandType.StoredProcedure); } } + public async Task DeleteManyAsync(IEnumerable collectionIds) + { + using (var connection = new SqlConnection(ConnectionString)) + { + await connection.ExecuteAsync("[dbo].[Collection_DeleteByIds]", + new { Ids = collectionIds.ToGuidIdArrayTVP() }, commandType: CommandType.StoredProcedure); + } + } + public async Task CreateUserAsync(Guid collectionId, Guid organizationUserId) { using (var connection = new SqlConnection(ConnectionString)) @@ -155,7 +270,7 @@ public class CollectionRepository : Repository, ICollectionRep } } - public async Task UpdateUsersAsync(Guid id, IEnumerable users) + public async Task UpdateUsersAsync(Guid id, IEnumerable users) { using (var connection = new SqlConnection(ConnectionString)) { @@ -166,11 +281,11 @@ public class CollectionRepository : Repository, ICollectionRep } } - public async Task> GetManyUsersByIdAsync(Guid id) + public async Task> GetManyUsersByIdAsync(Guid id) { using (var connection = new SqlConnection(ConnectionString)) { - var results = await connection.QueryAsync( + var results = await connection.QueryAsync( $"[{Schema}].[CollectionUser_ReadByCollectionId]", new { CollectionId = id }, commandType: CommandType.StoredProcedure); @@ -179,8 +294,9 @@ public class CollectionRepository : Repository, ICollectionRep } } - public class CollectionWithGroups : Collection + public class CollectionWithGroupsAndUsers : Collection { public DataTable Groups { get; set; } + public DataTable Users { get; set; } } } diff --git a/src/Infrastructure.Dapper/Repositories/GroupRepository.cs b/src/Infrastructure.Dapper/Repositories/GroupRepository.cs index 0df20320c6..c5ef42c6b7 100644 --- a/src/Infrastructure.Dapper/Repositories/GroupRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/GroupRepository.cs @@ -19,7 +19,7 @@ public class GroupRepository : Repository, IGroupRepository : base(connectionString, readOnlyConnectionString) { } - public async Task>> GetByIdWithCollectionsAsync(Guid id) + public async Task>> GetByIdWithCollectionsAsync(Guid id) { using (var connection = new SqlConnection(ConnectionString)) { @@ -29,9 +29,9 @@ public class GroupRepository : Repository, IGroupRepository commandType: CommandType.StoredProcedure); var group = await results.ReadFirstOrDefaultAsync(); - var colletions = (await results.ReadAsync()).ToList(); + var colletions = (await results.ReadAsync()).ToList(); - return new Tuple>(group, colletions); + return new Tuple>(group, colletions); } } @@ -48,6 +48,48 @@ public class GroupRepository : Repository, IGroupRepository } } + public async Task>>> GetManyWithCollectionsByOrganizationIdAsync(Guid organizationId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryMultipleAsync( + $"[{Schema}].[Group_ReadWithCollectionsByOrganizationId]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + var groups = (await results.ReadAsync()).ToList(); + var collections = (await results.ReadAsync()) + .GroupBy(c => c.GroupId) + .ToList(); + + return groups.Select(group => + new Tuple>( + group, + collections.FirstOrDefault(c => c.Key == group.Id)? + .Select(c => new CollectionAccessSelection + { + Id = c.CollectionId, + HidePasswords = c.HidePasswords, + ReadOnly = c.ReadOnly + } + ).ToList() ?? new List()) + ).ToList(); + } + } + + public async Task> GetManyByManyIds(IEnumerable groupIds) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[Group_ReadByIds]", + new { Ids = groupIds.ToGuidIdArrayTVP() }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + public async Task> GetManyIdsByUserIdAsync(Guid organizationUserId) { using (var connection = new SqlConnection(ConnectionString)) @@ -87,7 +129,7 @@ public class GroupRepository : Repository, IGroupRepository } } - public async Task CreateAsync(Group obj, IEnumerable collections) + public async Task CreateAsync(Group obj, IEnumerable collections) { obj.SetNewId(); var objWithCollections = JsonSerializer.Deserialize(JsonSerializer.Serialize(obj)); @@ -102,7 +144,7 @@ public class GroupRepository : Repository, IGroupRepository } } - public async Task ReplaceAsync(Group obj, IEnumerable collections) + public async Task ReplaceAsync(Group obj, IEnumerable collections) { var objWithCollections = JsonSerializer.Deserialize(JsonSerializer.Serialize(obj)); objWithCollections.Collections = collections.ToArrayTVP(); @@ -137,4 +179,13 @@ public class GroupRepository : Repository, IGroupRepository commandType: CommandType.StoredProcedure); } } + + public async Task DeleteManyAsync(IEnumerable groupIds) + { + using (var connection = new SqlConnection(ConnectionString)) + { + await connection.ExecuteAsync("[dbo].[Group_DeleteByIds]", + new { Ids = groupIds.ToGuidIdArrayTVP() }, commandType: CommandType.StoredProcedure); + } + } } diff --git a/src/Infrastructure.Dapper/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/Repositories/OrganizationUserRepository.cs index 48a90bdca8..60c6c204c7 100644 --- a/src/Infrastructure.Dapper/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/OrganizationUserRepository.cs @@ -142,7 +142,7 @@ public class OrganizationUserRepository : Repository, IO } } - public async Task>> GetByIdWithCollectionsAsync(Guid id) + public async Task>> GetByIdWithCollectionsAsync(Guid id) { using (var connection = new SqlConnection(ConnectionString)) { @@ -152,8 +152,8 @@ public class OrganizationUserRepository : Repository, IO commandType: CommandType.StoredProcedure); var user = (await results.ReadAsync()).SingleOrDefault(); - var collections = (await results.ReadAsync()).ToList(); - return new Tuple>(user, collections); + var collections = (await results.ReadAsync()).ToList(); + return new Tuple>(user, collections); } } @@ -169,7 +169,7 @@ public class OrganizationUserRepository : Repository, IO return results.SingleOrDefault(); } } - public async Task>> + public async Task>> GetDetailsByIdWithCollectionsAsync(Guid id) { using (var connection = new SqlConnection(ConnectionString)) @@ -180,12 +180,12 @@ public class OrganizationUserRepository : Repository, IO commandType: CommandType.StoredProcedure); var user = (await results.ReadAsync()).SingleOrDefault(); - var collections = (await results.ReadAsync()).ToList(); - return new Tuple>(user, collections); + var collections = (await results.ReadAsync()).ToList(); + return new Tuple>(user, collections); } } - public async Task> GetManyDetailsByOrganizationAsync(Guid organizationId) + public async Task> GetManyDetailsByOrganizationAsync(Guid organizationId, bool includeGroups, bool includeCollections) { using (var connection = new SqlConnection(ConnectionString)) { @@ -194,7 +194,58 @@ public class OrganizationUserRepository : Repository, IO new { OrganizationId = organizationId }, commandType: CommandType.StoredProcedure); - return results.ToList(); + List> userGroups = null; + List> userCollections = null; + + var users = results.ToList(); + + if (!includeCollections && !includeGroups) + { + return users; + } + + var orgUserIds = users.Select(u => u.Id).ToGuidIdArrayTVP(); + + if (includeGroups) + { + userGroups = (await connection.QueryAsync( + "[dbo].[GroupUser_ReadByOrganizationUserIds]", + new { OrganizationUserIds = orgUserIds }, + commandType: CommandType.StoredProcedure)).GroupBy(u => u.OrganizationUserId).ToList(); + } + + if (includeCollections) + { + userCollections = (await connection.QueryAsync( + "[dbo].[CollectionUser_ReadByOrganizationUserIds]", + new { OrganizationUserIds = orgUserIds }, + commandType: CommandType.StoredProcedure)).GroupBy(u => u.OrganizationUserId).ToList(); + } + + // Map any queried collections and groups to their respective users + foreach (var user in users) + { + if (userGroups != null) + { + user.Groups = userGroups + .FirstOrDefault(u => u.Key == user.Id)? + .Select(ug => ug.GroupId).ToList() ?? new List(); + } + + if (userCollections != null) + { + user.Collections = userCollections + .FirstOrDefault(u => u.Key == user.Id)? + .Select(uc => new CollectionAccessSelection + { + Id = uc.CollectionId, + ReadOnly = uc.ReadOnly, + HidePasswords = uc.HidePasswords + }).ToList() ?? new List(); + } + } + + return users; } } @@ -237,7 +288,7 @@ public class OrganizationUserRepository : Repository, IO } } - public async Task CreateAsync(OrganizationUser obj, IEnumerable collections) + public async Task CreateAsync(OrganizationUser obj, IEnumerable collections) { obj.SetNewId(); var objWithCollections = JsonSerializer.Deserialize( @@ -255,7 +306,7 @@ public class OrganizationUserRepository : Repository, IO return obj.Id; } - public async Task ReplaceAsync(OrganizationUser obj, IEnumerable collections) + public async Task ReplaceAsync(OrganizationUser obj, IEnumerable collections) { var objWithCollections = JsonSerializer.Deserialize( JsonSerializer.Serialize(obj)); diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs index f85517a7ba..755e69264d 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs @@ -48,25 +48,46 @@ public class CollectionRepository : Repository groups) + public async Task CreateAsync(Core.Entities.Collection obj, IEnumerable groups, IEnumerable users) { await CreateAsync(obj); using (var scope = ServiceScopeFactory.CreateScope()) { var dbContext = GetDatabaseContext(scope); - var availibleGroups = await (from g in dbContext.Groups - where g.OrganizationId == obj.OrganizationId - select g.Id).ToListAsync(); - var collectionGroups = groups - .Where(g => availibleGroups.Contains(g.Id)) - .Select(g => new CollectionGroup - { - CollectionId = obj.Id, - GroupId = g.Id, - ReadOnly = g.ReadOnly, - HidePasswords = g.HidePasswords, - }); - await dbContext.AddRangeAsync(collectionGroups); + + if (groups != null) + { + var availableGroups = await (from g in dbContext.Groups + where g.OrganizationId == obj.OrganizationId + select g.Id).ToListAsync(); + var collectionGroups = groups + .Where(g => availableGroups.Contains(g.Id)) + .Select(g => new CollectionGroup + { + CollectionId = obj.Id, + GroupId = g.Id, + ReadOnly = g.ReadOnly, + HidePasswords = g.HidePasswords, + }); + await dbContext.AddRangeAsync(collectionGroups); + } + + if (users != null) + { + var availableUsers = await (from g in dbContext.OrganizationUsers + where g.OrganizationId == obj.OrganizationId + select g.Id).ToListAsync(); + var collectionUsers = users + .Where(u => availableUsers.Contains(u.Id)) + .Select(u => new CollectionUser + { + CollectionId = obj.Id, + OrganizationUserId = u.Id, + ReadOnly = u.ReadOnly, + HidePasswords = u.HidePasswords, + }); + await dbContext.AddRangeAsync(collectionUsers); + } await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(obj.OrganizationId); await dbContext.SaveChangesAsync(); } @@ -96,41 +117,169 @@ public class CollectionRepository : Repository>> GetByIdWithGroupsAsync(Guid id) + public async Task> GetByIdWithAccessAsync(Guid id) { var collection = await base.GetByIdAsync(id); using (var scope = ServiceScopeFactory.CreateScope()) { var dbContext = GetDatabaseContext(scope); - var collectionGroups = await (from cg in dbContext.CollectionGroups - where cg.CollectionId == id - select cg).ToListAsync(); - var selectionReadOnlys = collectionGroups.Select(cg => new SelectionReadOnly - { - Id = cg.GroupId, - ReadOnly = cg.ReadOnly, - HidePasswords = cg.HidePasswords, - }).ToList(); - return new Tuple>(collection, selectionReadOnlys); + var groupQuery = from cg in dbContext.CollectionGroups + where cg.CollectionId.Equals(id) + select new CollectionAccessSelection + { + Id = cg.GroupId, + ReadOnly = cg.ReadOnly, + HidePasswords = cg.HidePasswords, + }; + var groups = await groupQuery.ToArrayAsync(); + + var userQuery = from cg in dbContext.CollectionUsers + where cg.CollectionId.Equals(id) + select new CollectionAccessSelection + { + Id = cg.OrganizationUserId, + ReadOnly = cg.ReadOnly, + HidePasswords = cg.HidePasswords, + }; + var users = await userQuery.ToArrayAsync(); + var access = new CollectionAccessDetails { Users = users, Groups = groups }; + + return new Tuple(collection, access); } } - public async Task>> GetByIdWithGroupsAsync(Guid id, Guid userId) + public async Task> GetByIdWithAccessAsync(Guid id, Guid userId) { var collection = await GetByIdAsync(id, userId); using (var scope = ServiceScopeFactory.CreateScope()) { var dbContext = GetDatabaseContext(scope); - var query = from cg in dbContext.CollectionGroups - where cg.CollectionId.Equals(id) - select new SelectionReadOnly - { - Id = cg.GroupId, - ReadOnly = cg.ReadOnly, - HidePasswords = cg.HidePasswords, - }; - var configurations = await query.ToArrayAsync(); - return new Tuple>(collection, configurations); + var groupQuery = from cg in dbContext.CollectionGroups + where cg.CollectionId.Equals(id) + select new CollectionAccessSelection + { + Id = cg.GroupId, + ReadOnly = cg.ReadOnly, + HidePasswords = cg.HidePasswords, + }; + var groups = await groupQuery.ToArrayAsync(); + + var userQuery = from cg in dbContext.CollectionUsers + where cg.CollectionId.Equals(id) + select new CollectionAccessSelection + { + Id = cg.OrganizationUserId, + ReadOnly = cg.ReadOnly, + HidePasswords = cg.HidePasswords, + }; + var users = await userQuery.ToArrayAsync(); + var access = new CollectionAccessDetails { Users = users, Groups = groups }; + + return new Tuple(collection, access); + } + } + + public async Task>> GetManyByOrganizationIdWithAccessAsync(Guid organizationId) + { + var collections = await GetManyByOrganizationIdAsync(organizationId); + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var groups = + from cg in dbContext.CollectionGroups + where cg.Collection.OrganizationId == organizationId + group cg by cg.CollectionId into g + select g; + var users = + from cu in dbContext.CollectionUsers + where cu.Collection.OrganizationId == organizationId + group cu by cu.CollectionId into u + select u; + + return collections.Select(collection => + new Tuple( + collection, + new CollectionAccessDetails + { + Groups = groups + .FirstOrDefault(g => g.Key == collection.Id)? + .Select(g => new CollectionAccessSelection + { + Id = g.GroupId, + HidePasswords = g.HidePasswords, + ReadOnly = g.ReadOnly + }).ToList() ?? new List(), + Users = users + .FirstOrDefault(u => u.Key == collection.Id)? + .Select(c => new CollectionAccessSelection + { + Id = c.OrganizationUserId, + HidePasswords = c.HidePasswords, + ReadOnly = c.ReadOnly + }).ToList() ?? new List() + } + ) + ).ToList(); + } + } + + public async Task>> GetManyByUserIdWithAccessAsync(Guid userId, Guid organizationId) + { + var collections = (await GetManyByUserIdAsync(userId)).Where(c => c.OrganizationId == organizationId).ToList(); + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var groups = + from cg in dbContext.CollectionGroups + where cg.Collection.OrganizationId == organizationId + && collections.Select(c => c.Id).Contains(cg.Collection.Id) + group cg by cg.CollectionId into g + select g; + var users = + from cu in dbContext.CollectionUsers + where cu.Collection.OrganizationId == organizationId + && collections.Select(c => c.Id).Contains(cu.Collection.Id) + group cu by cu.CollectionId into u + select u; + + + return collections.Select(collection => + new Tuple( + collection, + new CollectionAccessDetails + { + Groups = groups + .FirstOrDefault(g => g.Key == collection.Id)? + .Select(g => new CollectionAccessSelection + { + Id = g.GroupId, + HidePasswords = g.HidePasswords, + ReadOnly = g.ReadOnly + }).ToList() ?? new List(), + Users = users + .FirstOrDefault(u => u.Key == collection.Id)? + .Select(c => new CollectionAccessSelection + { + Id = c.OrganizationUserId, + HidePasswords = c.HidePasswords, + ReadOnly = c.ReadOnly + }).ToList() ?? new List() + } + ) + ).ToList(); + } + } + + public async Task> GetManyByManyIdsAsync(IEnumerable collectionIds) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var query = from c in dbContext.Collections + where collectionIds.Contains(c.Id) + select c; + var data = await query.ToArrayAsync(); + return data; } } @@ -174,7 +323,7 @@ public class CollectionRepository : Repository> GetManyUsersByIdAsync(Guid id) + public async Task> GetManyUsersByIdAsync(Guid id) { using (var scope = ServiceScopeFactory.CreateScope()) { @@ -183,7 +332,7 @@ public class CollectionRepository : Repository new SelectionReadOnly + return collectionUsers.Select(cu => new CollectionAccessSelection { Id = cu.OrganizationUserId, ReadOnly = cu.ReadOnly, @@ -192,75 +341,20 @@ public class CollectionRepository : Repository groups) + public async Task ReplaceAsync(Core.Entities.Collection collection, IEnumerable groups, + IEnumerable users) { await UpsertAsync(collection); using (var scope = ServiceScopeFactory.CreateScope()) { var dbContext = GetDatabaseContext(scope); - var groupsInOrg = dbContext.Groups.Where(g => g.OrganizationId == collection.OrganizationId); - var modifiedGroupEntities = dbContext.Groups.Where(x => groups.Select(x => x.Id).Contains(x.Id)); - var target = (from cg in dbContext.CollectionGroups - join g in modifiedGroupEntities - on cg.CollectionId equals collection.Id into s_g - from g in s_g.DefaultIfEmpty() - where g == null || cg.GroupId == g.Id - select new { cg, g }).AsNoTracking(); - var source = (from g in modifiedGroupEntities - from cg in dbContext.CollectionGroups - .Where(cg => cg.CollectionId == collection.Id && cg.GroupId == g.Id).DefaultIfEmpty() - select new { cg, g }).AsNoTracking(); - var union = await target - .Union(source) - .Where(x => - x.cg == null || - ((x.g == null || x.g.Id == x.cg.GroupId) && - (x.cg.CollectionId == collection.Id))) - .AsNoTracking() - .ToListAsync(); - var insert = union.Where(x => x.cg == null && groupsInOrg.Any(c => x.g.Id == c.Id)) - .Select(x => new CollectionGroup - { - CollectionId = collection.Id, - GroupId = x.g.Id, - ReadOnly = groups.FirstOrDefault(g => g.Id == x.g.Id).ReadOnly, - HidePasswords = groups.FirstOrDefault(g => g.Id == x.g.Id).HidePasswords, - }).ToList(); - var update = union - .Where( - x => x.g != null && - x.cg != null && - (x.cg.ReadOnly != groups.FirstOrDefault(g => g.Id == x.g.Id).ReadOnly || - x.cg.HidePasswords != groups.FirstOrDefault(g => g.Id == x.g.Id).HidePasswords) - ) - .Select(x => new CollectionGroup - { - CollectionId = collection.Id, - GroupId = x.g.Id, - ReadOnly = groups.FirstOrDefault(g => g.Id == x.g.Id).ReadOnly, - HidePasswords = groups.FirstOrDefault(g => g.Id == x.g.Id).HidePasswords, - }); - var delete = union - .Where( - x => x.g == null && - x.cg.CollectionId == collection.Id - ) - .Select(x => new CollectionGroup - { - CollectionId = collection.Id, - GroupId = x.cg.GroupId, - }) - .ToList(); - - await dbContext.AddRangeAsync(insert); - dbContext.UpdateRange(update); - dbContext.RemoveRange(delete); + await ReplaceCollectionGroupsAsync(dbContext, collection, groups); + await ReplaceCollectionUsersAsync(dbContext, collection, users); await dbContext.UserBumpAccountRevisionDateByCollectionIdAsync(collection.Id, collection.OrganizationId); - await dbContext.SaveChangesAsync(); } } - public async Task UpdateUsersAsync(Guid id, IEnumerable requestedUsers) + public async Task UpdateUsersAsync(Guid id, IEnumerable requestedUsers) { using (var scope = ServiceScopeFactory.CreateScope()) { @@ -304,4 +398,151 @@ public class CollectionRepository : Repository collectionIds) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var collectionGroupEntities = await dbContext.CollectionGroups + .Where(cg => collectionIds.Contains(cg.CollectionId)) + .ToListAsync(); + var collectionEntities = await dbContext.Collections + .Where(c => collectionIds.Contains(c.Id)) + .ToListAsync(); + + dbContext.CollectionGroups.RemoveRange(collectionGroupEntities); + dbContext.Collections.RemoveRange(collectionEntities); + await dbContext.SaveChangesAsync(); + + foreach (var collection in collectionEntities.GroupBy(g => g.Organization.Id)) + { + await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(collection.Key); + } + } + } + + private async Task ReplaceCollectionGroupsAsync(DatabaseContext dbContext, Core.Entities.Collection collection, IEnumerable groups) + { + var groupsInOrg = dbContext.Groups.Where(g => g.OrganizationId == collection.OrganizationId); + var modifiedGroupEntities = dbContext.Groups.Where(x => groups.Select(x => x.Id).Contains(x.Id)); + var target = (from cg in dbContext.CollectionGroups + join g in modifiedGroupEntities + on cg.CollectionId equals collection.Id into s_g + from g in s_g.DefaultIfEmpty() + where g == null || cg.GroupId == g.Id + select new { cg, g }).AsNoTracking(); + var source = (from g in modifiedGroupEntities + from cg in dbContext.CollectionGroups + .Where(cg => cg.CollectionId == collection.Id && cg.GroupId == g.Id).DefaultIfEmpty() + select new { cg, g }).AsNoTracking(); + var union = await target + .Union(source) + .Where(x => + x.cg == null || + ((x.g == null || x.g.Id == x.cg.GroupId) && + (x.cg.CollectionId == collection.Id))) + .AsNoTracking() + .ToListAsync(); + var insert = union.Where(x => x.cg == null && groupsInOrg.Any(c => x.g.Id == c.Id)) + .Select(x => new CollectionGroup + { + CollectionId = collection.Id, + GroupId = x.g.Id, + ReadOnly = groups.FirstOrDefault(g => g.Id == x.g.Id).ReadOnly, + HidePasswords = groups.FirstOrDefault(g => g.Id == x.g.Id).HidePasswords, + }).ToList(); + var update = union + .Where( + x => x.g != null && + x.cg != null && + (x.cg.ReadOnly != groups.FirstOrDefault(g => g.Id == x.g.Id).ReadOnly || + x.cg.HidePasswords != groups.FirstOrDefault(g => g.Id == x.g.Id).HidePasswords) + ) + .Select(x => new CollectionGroup + { + CollectionId = collection.Id, + GroupId = x.g.Id, + ReadOnly = groups.FirstOrDefault(g => g.Id == x.g.Id).ReadOnly, + HidePasswords = groups.FirstOrDefault(g => g.Id == x.g.Id).HidePasswords, + }); + var delete = union + .Where( + x => x.g == null && + x.cg.CollectionId == collection.Id + ) + .Select(x => new CollectionGroup + { + CollectionId = collection.Id, + GroupId = x.cg.GroupId, + }) + .ToList(); + + await dbContext.AddRangeAsync(insert); + dbContext.UpdateRange(update); + dbContext.RemoveRange(delete); + await dbContext.SaveChangesAsync(); + } + + private async Task ReplaceCollectionUsersAsync(DatabaseContext dbContext, Core.Entities.Collection collection, IEnumerable users) + { + var usersInOrg = dbContext.OrganizationUsers.Where(u => u.OrganizationId == collection.OrganizationId); + var modifiedUserEntities = dbContext.OrganizationUsers.Where(x => users.Select(x => x.Id).Contains(x.Id)); + var target = (from cu in dbContext.CollectionUsers + join u in modifiedUserEntities + on cu.CollectionId equals collection.Id into s_g + from u in s_g.DefaultIfEmpty() + where u == null || cu.OrganizationUserId == u.Id + select new { cu, u }).AsNoTracking(); + var source = (from u in modifiedUserEntities + from cu in dbContext.CollectionUsers + .Where(cu => cu.CollectionId == collection.Id && cu.OrganizationUserId == u.Id).DefaultIfEmpty() + select new { cu, u }).AsNoTracking(); + var union = await target + .Union(source) + .Where(x => + x.cu == null || + ((x.u == null || x.u.Id == x.cu.OrganizationUserId) && + (x.cu.CollectionId == collection.Id))) + .AsNoTracking() + .ToListAsync(); + var insert = union.Where(x => x.u == null && usersInOrg.Any(c => x.u.Id == c.Id)) + .Select(x => new CollectionUser + { + CollectionId = collection.Id, + OrganizationUserId = x.u.Id, + ReadOnly = users.FirstOrDefault(u => u.Id == x.u.Id).ReadOnly, + HidePasswords = users.FirstOrDefault(u => u.Id == x.u.Id).HidePasswords, + }).ToList(); + var update = union + .Where( + x => x.u != null && + x.cu != null && + (x.cu.ReadOnly != users.FirstOrDefault(u => u.Id == x.u.Id).ReadOnly || + x.cu.HidePasswords != users.FirstOrDefault(u => u.Id == x.u.Id).HidePasswords) + ) + .Select(x => new CollectionUser + { + CollectionId = collection.Id, + OrganizationUserId = x.u.Id, + ReadOnly = users.FirstOrDefault(u => u.Id == x.u.Id).ReadOnly, + HidePasswords = users.FirstOrDefault(u => u.Id == x.u.Id).HidePasswords, + }); + var delete = union + .Where( + x => x.u == null && + x.cu.CollectionId == collection.Id + ) + .Select(x => new CollectionUser + { + CollectionId = collection.Id, + OrganizationUserId = x.cu.OrganizationUserId, + }) + .ToList(); + + await dbContext.AddRangeAsync(insert); + dbContext.UpdateRange(update); + dbContext.RemoveRange(delete); + await dbContext.SaveChangesAsync(); + } } diff --git a/src/Infrastructure.EntityFramework/Repositories/GroupRepository.cs b/src/Infrastructure.EntityFramework/Repositories/GroupRepository.cs index 8f3d6ba4ff..0209dbf2d9 100644 --- a/src/Infrastructure.EntityFramework/Repositories/GroupRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/GroupRepository.cs @@ -13,7 +13,7 @@ public class GroupRepository : Repository, IGr : base(serviceScopeFactory, mapper, (DatabaseContext context) => context.Groups) { } - public async Task CreateAsync(Core.Entities.Group obj, IEnumerable collections) + public async Task CreateAsync(Core.Entities.Group obj, IEnumerable collections) { var grp = await base.CreateAsync(obj); using (var scope = ServiceScopeFactory.CreateScope()) @@ -51,7 +51,7 @@ public class GroupRepository : Repository, IGr } } - public async Task>> GetByIdWithCollectionsAsync(Guid id) + public async Task>> GetByIdWithCollectionsAsync(Guid id) { var grp = await base.GetByIdAsync(id); using (var scope = ServiceScopeFactory.CreateScope()) @@ -61,13 +61,13 @@ public class GroupRepository : Repository, IGr from cg in dbContext.CollectionGroups where cg.GroupId == id select cg).ToListAsync(); - var collections = query.Select(c => new SelectionReadOnly + var collections = query.Select(c => new CollectionAccessSelection { Id = c.CollectionId, ReadOnly = c.ReadOnly, HidePasswords = c.HidePasswords, }).ToList(); - return new Tuple>( + return new Tuple>( grp, collections); } } @@ -85,6 +85,49 @@ public class GroupRepository : Repository, IGr } } + public async Task>>> + GetManyWithCollectionsByOrganizationIdAsync(Guid organizationId) + { + var groups = await GetManyByOrganizationIdAsync(organizationId); + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var query = await ( + from cg in dbContext.CollectionGroups + where cg.Group.OrganizationId == organizationId + select cg).ToListAsync(); + + var collections = query.GroupBy(c => c.GroupId).ToList(); + + return groups.Select(group => + new Tuple>( + group, + collections + .FirstOrDefault(c => c.Key == group.Id)? + .Select(c => new CollectionAccessSelection + { + Id = c.CollectionId, + HidePasswords = c.HidePasswords, + ReadOnly = c.ReadOnly + } + ).ToList() ?? new List()) + ).ToList(); + } + } + + public async Task> GetManyByManyIds(IEnumerable groupIds) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var query = from g in dbContext.Groups + where groupIds.Contains(g.Id) + select g; + var groups = await query.ToListAsync(); + return Mapper.Map>(groups); + } + } + public async Task> GetManyGroupUsersByOrganizationIdAsync(Guid organizationId) { using (var scope = ServiceScopeFactory.CreateScope()) @@ -129,7 +172,7 @@ public class GroupRepository : Repository, IGr } } - public async Task ReplaceAsync(Core.Entities.Group group, IEnumerable requestedCollections) + public async Task ReplaceAsync(Core.Entities.Group group, IEnumerable requestedCollections) { await base.ReplaceAsync(group); using (var scope = ServiceScopeFactory.CreateScope()) @@ -204,4 +247,23 @@ public class GroupRepository : Repository, IGr await dbContext.SaveChangesAsync(); } } + + public async Task DeleteManyAsync(IEnumerable groupIds) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var entities = await dbContext.Groups + .Where(g => groupIds.Contains(g.Id)) + .ToListAsync(); + + dbContext.Groups.RemoveRange(entities); + await dbContext.SaveChangesAsync(); + + foreach (var group in entities.GroupBy(g => g.Organization.Id)) + { + await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(group.Key); + } + } + } } diff --git a/src/Infrastructure.EntityFramework/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/OrganizationUserRepository.cs index 2423c5f67d..6fe13f245d 100644 --- a/src/Infrastructure.EntityFramework/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/OrganizationUserRepository.cs @@ -16,7 +16,7 @@ public class OrganizationUserRepository : Repository context.OrganizationUsers) { } - public async Task CreateAsync(Core.Entities.OrganizationUser obj, IEnumerable collections) + public async Task CreateAsync(Core.Entities.OrganizationUser obj, IEnumerable collections) { var organizationUser = await base.CreateAsync(obj); using (var scope = ServiceScopeFactory.CreateScope()) @@ -123,7 +123,7 @@ public class OrganizationUserRepository : Repository>> GetByIdWithCollectionsAsync(Guid id) + public async Task>> GetByIdWithCollectionsAsync(Guid id) { var organizationUser = await base.GetByIdAsync(id); using (var scope = ServiceScopeFactory.CreateScope()) @@ -136,13 +136,13 @@ public class OrganizationUserRepository : Repository new SelectionReadOnly + var collections = query.Select(cu => new CollectionAccessSelection { Id = cu.CollectionId, ReadOnly = cu.ReadOnly, HidePasswords = cu.HidePasswords, }); - return new Tuple>( + return new Tuple>( organizationUser, collections.ToList()); } } @@ -214,7 +214,7 @@ public class OrganizationUserRepository : Repository>> GetDetailsByIdWithCollectionsAsync(Guid id) + public async Task>> GetDetailsByIdWithCollectionsAsync(Guid id) { var organizationUserUserDetails = await GetDetailsByIdAsync(id); using (var scope = ServiceScopeFactory.CreateScope()) @@ -224,13 +224,13 @@ public class OrganizationUserRepository : Repository new SelectionReadOnly + var collections = await query.Select(cu => new CollectionAccessSelection { Id = cu.CollectionId, ReadOnly = cu.ReadOnly, HidePasswords = cu.HidePasswords, }).ToListAsync(); - return new Tuple>(organizationUserUserDetails, collections); + return new Tuple>(organizationUserUserDetails, collections); } } @@ -299,16 +299,67 @@ public class OrganizationUserRepository : Repository> GetManyDetailsByOrganizationAsync(Guid organizationId) + public async Task> GetManyDetailsByOrganizationAsync(Guid organizationId, bool includeGroups, bool includeCollections) { using (var scope = ServiceScopeFactory.CreateScope()) { var dbContext = GetDatabaseContext(scope); var view = new OrganizationUserUserDetailsViewQuery(); - var query = from ou in view.Run(dbContext) - where ou.OrganizationId == organizationId - select ou; - return await query.ToListAsync(); + var users = await (from ou in view.Run(dbContext) + where ou.OrganizationId == organizationId + select ou).ToListAsync(); + + if (!includeCollections && !includeGroups) + { + return users; + } + + List> groups = null; + List> collections = null; + var userIds = users.Select(u => u.Id); + var userIdEntities = dbContext.OrganizationUsers.Where(x => userIds.Contains(x.Id)); + + // Query groups/collections separately to avoid cartesian explosion + if (includeGroups) + { + groups = (await (from gu in dbContext.GroupUsers + join ou in userIdEntities on gu.OrganizationUserId equals ou.Id + select gu).ToListAsync()) + .GroupBy(g => g.OrganizationUserId).ToList(); + } + + if (includeCollections) + { + collections = (await (from cu in dbContext.CollectionUsers + join ou in userIdEntities on cu.OrganizationUserId equals ou.Id + select cu).ToListAsync()) + .GroupBy(c => c.OrganizationUserId).ToList(); + } + + // Map any queried collections and groups to their respective users + foreach (var user in users) + { + if (groups != null) + { + user.Groups = groups + .FirstOrDefault(g => g.Key == user.Id)? + .Select(g => g.GroupId).ToList() ?? new List(); + } + + if (collections != null) + { + user.Collections = collections + .FirstOrDefault(c => c.Key == user.Id)? + .Select(cu => new CollectionAccessSelection + { + Id = cu.CollectionId, + ReadOnly = cu.ReadOnly, + HidePasswords = cu.HidePasswords + }).ToList() ?? new List(); + } + } + + return users; } } @@ -360,7 +411,7 @@ public class OrganizationUserRepository : Repository requestedCollections) + public async Task ReplaceAsync(Core.Entities.OrganizationUser obj, IEnumerable requestedCollections) { await ReplaceAsync(obj); using (var scope = ServiceScopeFactory.CreateScope()) diff --git a/src/Infrastructure.EntityFramework/Repositories/Queries/CollectionUserUpdateUsersQuery.cs b/src/Infrastructure.EntityFramework/Repositories/Queries/CollectionUserUpdateUsersQuery.cs new file mode 100644 index 0000000000..5f282702bb --- /dev/null +++ b/src/Infrastructure.EntityFramework/Repositories/Queries/CollectionUserUpdateUsersQuery.cs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/Infrastructure.EntityFramework/Repositories/Queries/OrganizationUserUpdateWithCollectionsQuery.cs b/src/Infrastructure.EntityFramework/Repositories/Queries/OrganizationUserUpdateWithCollectionsQuery.cs new file mode 100644 index 0000000000..5f282702bb --- /dev/null +++ b/src/Infrastructure.EntityFramework/Repositories/Queries/OrganizationUserUpdateWithCollectionsQuery.cs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/Sql/Sql.sqlproj b/src/Sql/Sql.sqlproj index 36bbbaeb64..9495c56a9f 100644 --- a/src/Sql/Sql.sqlproj +++ b/src/Sql/Sql.sqlproj @@ -120,8 +120,11 @@ + + + @@ -131,6 +134,8 @@ + + @@ -174,6 +179,7 @@ + @@ -181,9 +187,12 @@ + + + @@ -330,6 +339,7 @@ + diff --git a/src/Sql/dbo/Stored Procedures/CollectionGroup_ReadByCollectionId.sql b/src/Sql/dbo/Stored Procedures/CollectionGroup_ReadByCollectionId.sql new file mode 100644 index 0000000000..412a2d9db4 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/CollectionGroup_ReadByCollectionId.sql @@ -0,0 +1,15 @@ +CREATE PROCEDURE [dbo].[CollectionGroup_ReadByCollectionId] + @CollectionId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + [GroupId] [Id], + [ReadOnly], + [HidePasswords] + FROM + [dbo].[CollectionGroup] + WHERE + [CollectionId] = @CollectionId +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/CollectionGroup_ReadByOrganizationId.sql b/src/Sql/dbo/Stored Procedures/CollectionGroup_ReadByOrganizationId.sql new file mode 100644 index 0000000000..d29d3f3fb6 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/CollectionGroup_ReadByOrganizationId.sql @@ -0,0 +1,15 @@ +CREATE PROCEDURE [dbo].[CollectionGroup_ReadByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + CG.* + FROM + [dbo].[CollectionGroup] CG + INNER JOIN + [dbo].[Group] G ON G.[Id] = CG.[GroupId] + WHERE + G.[OrganizationId] = @OrganizationId +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/CollectionUser_ReadByOrganizationId.sql b/src/Sql/dbo/Stored Procedures/CollectionUser_ReadByOrganizationId.sql new file mode 100644 index 0000000000..9eb5d16c0a --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/CollectionUser_ReadByOrganizationId.sql @@ -0,0 +1,16 @@ +CREATE PROCEDURE [dbo].[CollectionUser_ReadByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + CU.* + FROM + [dbo].[CollectionUser] CU + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = CU.[OrganizationUserId] + WHERE + OU.[OrganizationId] = @OrganizationId + +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/CollectionUser_ReadByOrganizationUserIds.sql b/src/Sql/dbo/Stored Procedures/CollectionUser_ReadByOrganizationUserIds.sql new file mode 100644 index 0000000000..6982371ee4 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/CollectionUser_ReadByOrganizationUserIds.sql @@ -0,0 +1,15 @@ +CREATE PROCEDURE [dbo].[CollectionUser_ReadByOrganizationUserIds] + @OrganizationUserIds [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT + CU.* + FROM + [dbo].[OrganizationUser] OU + INNER JOIN + [dbo].[CollectionUser] CU ON OU.[AccessAll] = 0 AND CU.[OrganizationUserId] = OU.[Id] + INNER JOIN + @OrganizationUserIds OUI ON OUI.[Id] = OU.[Id] +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Collection_CreateWithGroupsAndUsers.sql b/src/Sql/dbo/Stored Procedures/Collection_CreateWithGroupsAndUsers.sql new file mode 100644 index 0000000000..120a5e83dd --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Collection_CreateWithGroupsAndUsers.sql @@ -0,0 +1,69 @@ +CREATE PROCEDURE [dbo].[Collection_CreateWithGroupsAndUsers] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name VARCHAR(MAX), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Groups AS [dbo].[SelectionReadOnlyArray] READONLY, + @Users AS [dbo].[SelectionReadOnlyArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_Create] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate + + -- Groups + ;WITH [AvailableGroupsCTE] AS( + SELECT + [Id] + FROM + [dbo].[Group] + WHERE + [OrganizationId] = @OrganizationId + ) + INSERT INTO [dbo].[CollectionGroup] + ( + [CollectionId], + [GroupId], + [ReadOnly], + [HidePasswords] + ) + SELECT + @Id, + [Id], + [ReadOnly], + [HidePasswords] + FROM + @Groups + WHERE + [Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) + + -- Users + ;WITH [AvailableUsersCTE] AS( + SELECT + [Id] + FROM + [dbo].[OrganizationUser] + WHERE + [OrganizationId] = @OrganizationId + ) + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords] + ) + SELECT + @Id, + [Id], + [ReadOnly], + [HidePasswords] + FROM + @Users + WHERE + [Id] IN (SELECT [Id] FROM [AvailableUsersCTE]) + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Collection_DeleteByIds.sql b/src/Sql/dbo/Stored Procedures/Collection_DeleteByIds.sql new file mode 100644 index 0000000000..ddb465e796 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Collection_DeleteByIds.sql @@ -0,0 +1,54 @@ +CREATE PROCEDURE [dbo].[Collection_DeleteByIds] + @Ids AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + DECLARE @OrgIds AS [dbo].[GuidIdArray] + + INSERT INTO @OrgIds (Id) + SELECT + [OrganizationId] + FROM + [dbo].[Collection] + WHERE + [Id] in (SELECT [Id] FROM @Ids) + GROUP BY + [OrganizationId] + + DECLARE @BatchSize INT = 100 + + -- Delete Collection Groups + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION CollectionGroup_DeleteMany + DELETE TOP(@BatchSize) + FROM + [dbo].[CollectionGroup] + WHERE + [CollectionId] IN (SELECT [Id] FROM @Ids) + + + SET @BatchSize = @@ROWCOUNT + COMMIT TRANSACTION CollectionGroup_DeleteMany + END + + -- Reset batch size + SET @BatchSize = 100 + + -- Delete Collections + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION Collection_DeleteMany + DELETE TOP(@BatchSize) + FROM + [dbo].[Collection] + WHERE + [Id] IN (SELECT [Id] FROM @Ids) + + SET @BatchSize = @@ROWCOUNT + COMMIT TRANSACTION CollectionGroup_DeleteMany + END + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationIds] @OrgIds +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Collection_ReadByIds.sql b/src/Sql/dbo/Stored Procedures/Collection_ReadByIds.sql new file mode 100644 index 0000000000..a60ff45d99 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Collection_ReadByIds.sql @@ -0,0 +1,18 @@ +CREATE PROCEDURE [dbo].[Collection_ReadByIds] + @Ids AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + IF (SELECT COUNT(1) FROM @Ids) < 1 + BEGIN + RETURN(-1) + END + + SELECT + * + FROM + [dbo].[Collection] + WHERE + [Id] IN (SELECT [Id] FROM @Ids) +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Collection_ReadWithGroupsAndUsersById.sql b/src/Sql/dbo/Stored Procedures/Collection_ReadWithGroupsAndUsersById.sql new file mode 100644 index 0000000000..f9985ff633 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Collection_ReadWithGroupsAndUsersById.sql @@ -0,0 +1,12 @@ +CREATE PROCEDURE [dbo].[Collection_ReadWithGroupsAndUsersById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_ReadById] @Id + + EXEC [dbo].[CollectionGroup_ReadByCollectionId] @Id + + EXEC [dbo].[CollectionUser_ReadByCollectionId] @Id +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Collection_ReadWithGroupsAndUsersByIdUserId.sql b/src/Sql/dbo/Stored Procedures/Collection_ReadWithGroupsAndUsersByIdUserId.sql new file mode 100644 index 0000000000..a29077e41a --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Collection_ReadWithGroupsAndUsersByIdUserId.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[Collection_ReadWithGroupsAndUsersByIdUserId] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_ReadByIdUserId] @Id, @UserId + + EXEC [dbo].[CollectionGroup_ReadByCollectionId] @Id + + EXEC [dbo].[CollectionUser_ReadByCollectionId] @Id +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Collection_ReadWithGroupsAndUsersByOrganizationId.sql b/src/Sql/dbo/Stored Procedures/Collection_ReadWithGroupsAndUsersByOrganizationId.sql new file mode 100644 index 0000000000..24a75ef147 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Collection_ReadWithGroupsAndUsersByOrganizationId.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[Collection_ReadWithGroupsAndUsersByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_ReadByOrganizationId] @OrganizationId + + EXEC [dbo].[CollectionGroup_ReadByOrganizationId] @OrganizationId + + EXEC [dbo].[CollectionUser_ReadByOrganizationId] @OrganizationId + +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Collection_ReadWithGroupsAndUsersByUserId.sql b/src/Sql/dbo/Stored Procedures/Collection_ReadWithGroupsAndUsersByUserId.sql new file mode 100644 index 0000000000..7e2b01f8b4 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Collection_ReadWithGroupsAndUsersByUserId.sql @@ -0,0 +1,38 @@ +CREATE PROCEDURE [dbo].[Collection_ReadWithGroupsAndUsersByUserId] + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + DECLARE @TempUserCollections TABLE( + Id UNIQUEIDENTIFIER, + OrganizationId UNIQUEIDENTIFIER, + Name VARCHAR(MAX), + CreationDate DATETIME2(7), + RevisionDate DATETIME2(7), + ExternalId NVARCHAR(300), + ReadOnly BIT, + HidePasswords BIT) + + INSERT INTO @TempUserCollections EXEC [dbo].[Collection_ReadByUserId] @UserId + + SELECT + * + FROM + @TempUserCollections C + + SELECT + CG.* + FROM + [dbo].[CollectionGroup] CG + INNER JOIN + @TempUserCollections C ON C.[Id] = CG.[CollectionId] + + SELECT + CU.* + FROM + [dbo].[CollectionUser] CU + INNER JOIN + @TempUserCollections C ON C.[Id] = CU.[CollectionId] + +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql b/src/Sql/dbo/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql new file mode 100644 index 0000000000..dd70b5b35e --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql @@ -0,0 +1,89 @@ +CREATE PROCEDURE [dbo].[Collection_UpdateWithGroupsAndUsers] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name VARCHAR(MAX), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Groups AS [dbo].[SelectionReadOnlyArray] READONLY, + @Users AS [dbo].[SelectionReadOnlyArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate + + -- Groups + ;WITH [AvailableGroupsCTE] AS( + SELECT + Id + FROM + [dbo].[Group] + WHERE + OrganizationId = @OrganizationId + ) + MERGE + [dbo].[CollectionGroup] AS [Target] + USING + @Groups AS [Source] + ON + [Target].[CollectionId] = @Id + AND [Target].[GroupId] = [Source].[Id] + WHEN NOT MATCHED BY TARGET + AND [Source].[Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) THEN + INSERT VALUES + ( + @Id, + [Source].[Id], + [Source].[ReadOnly], + [Source].[HidePasswords] + ) + WHEN MATCHED AND ( + [Target].[ReadOnly] != [Source].[ReadOnly] + OR [Target].[HidePasswords] != [Source].[HidePasswords] + ) THEN + UPDATE SET [Target].[ReadOnly] = [Source].[ReadOnly], + [Target].[HidePasswords] = [Source].[HidePasswords] + WHEN NOT MATCHED BY SOURCE + AND [Target].[CollectionId] = @Id THEN + DELETE + ; + + -- Users + ;WITH [AvailableGroupsCTE] AS( + SELECT + Id + FROM + [dbo].[OrganizationUser] + WHERE + OrganizationId = @OrganizationId + ) + MERGE + [dbo].[CollectionUser] AS [Target] + USING + @Users AS [Source] + ON + [Target].[CollectionId] = @Id + AND [Target].[OrganizationUserId] = [Source].[Id] + WHEN NOT MATCHED BY TARGET + AND [Source].[Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) THEN + INSERT VALUES + ( + @Id, + [Source].[Id], + [Source].[ReadOnly], + [Source].[HidePasswords] + ) + WHEN MATCHED AND ( + [Target].[ReadOnly] != [Source].[ReadOnly] + OR [Target].[HidePasswords] != [Source].[HidePasswords] + ) THEN + UPDATE SET [Target].[ReadOnly] = [Source].[ReadOnly], + [Target].[HidePasswords] = [Source].[HidePasswords] + WHEN NOT MATCHED BY SOURCE + AND [Target].[CollectionId] = @Id THEN + DELETE + ; + + EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/GroupUser_ReadByOrganizationUserIds.sql b/src/Sql/dbo/Stored Procedures/GroupUser_ReadByOrganizationUserIds.sql new file mode 100644 index 0000000000..7e6cea7458 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/GroupUser_ReadByOrganizationUserIds.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[GroupUser_ReadByOrganizationUserIds] + @OrganizationUserIds [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT + GU.* + FROM + [dbo].[GroupUser] GU + INNER JOIN + @OrganizationUserIds OUI ON OUI.[Id] = GU.[OrganizationUserId] +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Group_DeleteByIds.sql b/src/Sql/dbo/Stored Procedures/Group_DeleteByIds.sql new file mode 100644 index 0000000000..d5d1636670 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Group_DeleteByIds.sql @@ -0,0 +1,35 @@ +CREATE PROCEDURE [dbo].[Group_DeleteByIds] + @Ids AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + DECLARE @OrgIds AS [dbo].[GuidIdArray] + + INSERT INTO @OrgIds (Id) + SELECT + [OrganizationId] + FROM + [dbo].[Group] + WHERE + [Id] in (SELECT [Id] FROM @Ids) + GROUP BY + [OrganizationId] + + DECLARE @BatchSize INT = 100 + + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION Group_DeleteMany_Groups + DELETE TOP(@BatchSize) + FROM + [dbo].[Group] + WHERE + [Id] IN (SELECT [Id] FROM @Ids) + + SET @BatchSize = @@ROWCOUNT + COMMIT TRANSACTION Group_DeleteMany_Groups + END + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationIds] @OrgIds +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Group_ReadByIds.sql b/src/Sql/dbo/Stored Procedures/Group_ReadByIds.sql new file mode 100644 index 0000000000..26ea12cec7 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Group_ReadByIds.sql @@ -0,0 +1,18 @@ +CREATE PROCEDURE [dbo].[Group_ReadByIds] + @Ids AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + IF (SELECT COUNT(1) FROM @Ids) < 1 + BEGIN + RETURN(-1) + END + + SELECT + * + FROM + [dbo].[Group] + WHERE + [Id] IN (SELECT [Id] FROM @Ids) +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Group_ReadWithCollectionsByOrganizationId.sql b/src/Sql/dbo/Stored Procedures/Group_ReadWithCollectionsByOrganizationId.sql new file mode 100644 index 0000000000..ea1e45f2f1 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Group_ReadWithCollectionsByOrganizationId.sql @@ -0,0 +1,10 @@ +CREATE PROCEDURE [dbo].[Group_ReadWithCollectionsByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Group_ReadByOrganizationId] @OrganizationId + + EXEC [dbo].[CollectionGroup_ReadByOrganizationId] @OrganizationId +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/User_BumpAccountRevisionDateByOrganizationIds.sql b/src/Sql/dbo/Stored Procedures/User_BumpAccountRevisionDateByOrganizationIds.sql new file mode 100644 index 0000000000..913252107d --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/User_BumpAccountRevisionDateByOrganizationIds.sql @@ -0,0 +1,18 @@ +CREATE PROCEDURE [dbo].[User_BumpAccountRevisionDateByOrganizationIds] + @OrganizationIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + UPDATE + U + SET + U.[AccountRevisionDate] = GETUTCDATE() + FROM + [dbo].[User] U + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[UserId] = U.[Id] + WHERE + OU.[OrganizationId] IN (SELECT [Id] FROM @OrganizationIds) + AND OU.[Status] = 2 -- Confirmed +END \ No newline at end of file diff --git a/src/Sql/dbo_future/Stored Procedures/Collection_CreateWithGroups.sql b/src/Sql/dbo_future/Stored Procedures/Collection_CreateWithGroups.sql new file mode 100644 index 0000000000..0c55ab99b7 --- /dev/null +++ b/src/Sql/dbo_future/Stored Procedures/Collection_CreateWithGroups.sql @@ -0,0 +1,2 @@ +-- Created 2022-11 +-- DELETE FILE \ No newline at end of file diff --git a/src/Sql/dbo_future/Stored Procedures/Collection_ReadWithGroupsById.sql b/src/Sql/dbo_future/Stored Procedures/Collection_ReadWithGroupsById.sql new file mode 100644 index 0000000000..0c55ab99b7 --- /dev/null +++ b/src/Sql/dbo_future/Stored Procedures/Collection_ReadWithGroupsById.sql @@ -0,0 +1,2 @@ +-- Created 2022-11 +-- DELETE FILE \ No newline at end of file diff --git a/src/Sql/dbo_future/Stored Procedures/Collection_ReadWithGroupsByIdUserId.sql b/src/Sql/dbo_future/Stored Procedures/Collection_ReadWithGroupsByIdUserId.sql new file mode 100644 index 0000000000..0c55ab99b7 --- /dev/null +++ b/src/Sql/dbo_future/Stored Procedures/Collection_ReadWithGroupsByIdUserId.sql @@ -0,0 +1,2 @@ +-- Created 2022-11 +-- DELETE FILE \ No newline at end of file diff --git a/src/Sql/dbo_future/Stored Procedures/Collection_UpdateWithGroups.sql b/src/Sql/dbo_future/Stored Procedures/Collection_UpdateWithGroups.sql new file mode 100644 index 0000000000..0c55ab99b7 --- /dev/null +++ b/src/Sql/dbo_future/Stored Procedures/Collection_UpdateWithGroups.sql @@ -0,0 +1,2 @@ +-- Created 2022-11 +-- DELETE FILE \ No newline at end of file diff --git a/test/Api.Test/Controllers/CollectionsControllerTests.cs b/test/Api.Test/Controllers/CollectionsControllerTests.cs index b5d304f7a0..d056f4240c 100644 --- a/test/Api.Test/Controllers/CollectionsControllerTests.cs +++ b/test/Api.Test/Controllers/CollectionsControllerTests.cs @@ -4,6 +4,7 @@ using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Models.Data; +using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Test.Common.AutoFixture; @@ -38,7 +39,7 @@ public class CollectionsControllerTests await sutProvider.GetDependency() .Received(1) - .SaveAsync(Arg.Any(), Arg.Any>(), null); + .SaveAsync(Arg.Any(), Arg.Any>(), null); } [Theory, BitAutoData] @@ -85,4 +86,168 @@ public class CollectionsControllerTests _ = await Assert.ThrowsAsync(async () => await sutProvider.Sut.Put(orgId, collectionId, collectionRequest)); } + + [Theory, BitAutoData] + public async Task GetOrganizationCollectionsWithGroups_NoManagerPermissions_ThrowsNotFound(Organization organization, SutProvider sutProvider) + { + sutProvider.GetDependency().ViewAssignedCollections(organization.Id).Returns(false); + + await Assert.ThrowsAsync(() => sutProvider.Sut.GetManyWithDetails(organization.Id)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetManyByOrganizationIdWithAccessAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetManyByUserIdWithAccessAsync(default, default); + } + + [Theory, BitAutoData] + public async Task GetOrganizationCollectionsWithGroups_AdminPermissions_GetsAllCollections(Organization organization, User user, SutProvider sutProvider) + { + sutProvider.GetDependency().UserId.Returns(user.Id); + sutProvider.GetDependency().ViewAllCollections(organization.Id).Returns(true); + sutProvider.GetDependency().OrganizationAdmin(organization.Id).Returns(true); + + await sutProvider.Sut.GetManyWithDetails(organization.Id); + + await sutProvider.GetDependency().Received().GetManyByOrganizationIdWithAccessAsync(organization.Id); + await sutProvider.GetDependency().Received().GetManyByUserIdWithAccessAsync(user.Id, organization.Id); + } + + [Theory, BitAutoData] + public async Task GetOrganizationCollectionsWithGroups_MissingViewAllPermissions_GetsAssignedCollections(Organization organization, User user, SutProvider sutProvider) + { + sutProvider.GetDependency().UserId.Returns(user.Id); + sutProvider.GetDependency().ViewAssignedCollections(organization.Id).Returns(true); + sutProvider.GetDependency().OrganizationManager(organization.Id).Returns(true); + + await sutProvider.Sut.GetManyWithDetails(organization.Id); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetManyByOrganizationIdWithAccessAsync(default); + await sutProvider.GetDependency().Received().GetManyByUserIdWithAccessAsync(user.Id, organization.Id); + } + + [Theory, BitAutoData] + public async Task GetOrganizationCollectionsWithGroups_CustomUserWithManagerPermissions_GetsAssignedCollections(Organization organization, User user, SutProvider sutProvider) + { + sutProvider.GetDependency().UserId.Returns(user.Id); + sutProvider.GetDependency().ViewAssignedCollections(organization.Id).Returns(true); + sutProvider.GetDependency().EditAssignedCollections(organization.Id).Returns(true); + + + await sutProvider.Sut.GetManyWithDetails(organization.Id); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetManyByOrganizationIdWithAccessAsync(default); + await sutProvider.GetDependency().Received().GetManyByUserIdWithAccessAsync(user.Id, organization.Id); + } + + + [Theory, BitAutoData] + public async Task DeleteMany_Success(Guid orgId, User user, Collection collection1, Collection collection2, SutProvider sutProvider) + { + // Arrange + var model = new CollectionBulkDeleteRequestModel + { + Ids = new[] { collection1.Id.ToString(), collection2.Id.ToString() }, + OrganizationId = orgId.ToString() + }; + + var collections = new List + { + new CollectionDetails + { + Id = collection1.Id, + OrganizationId = orgId, + }, + new CollectionDetails + { + Id = collection2.Id, + OrganizationId = orgId, + }, + }; + + sutProvider.GetDependency() + .DeleteAssignedCollections(orgId) + .Returns(true); + + sutProvider.GetDependency() + .UserId + .Returns(user.Id); + + sutProvider.GetDependency() + .GetManyByUserIdAsync(user.Id) + .Returns(collections); + + // Act + await sutProvider.Sut.DeleteMany(model); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .DeleteManyAsync(Arg.Is>(coll => coll.Select(c => c.Id).SequenceEqual(collections.Select(c => c.Id)))); + + } + + [Theory, BitAutoData] + public async Task DeleteMany_CanNotDeleteAssignedCollection_ThrowsNotFound(Guid orgId, Collection collection1, Collection collection2, SutProvider sutProvider) + { + // Arrange + var model = new CollectionBulkDeleteRequestModel + { + Ids = new[] { collection1.Id.ToString(), collection2.Id.ToString() }, + OrganizationId = orgId.ToString() + }; + + sutProvider.GetDependency() + .DeleteAssignedCollections(orgId) + .Returns(false); + + // Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.DeleteMany(model)); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteManyAsync((IEnumerable)default); + + } + + + [Theory, BitAutoData] + public async Task DeleteMany_UserCanNotAccessCollections_FiltersOutInvalid(Guid orgId, User user, Collection collection1, Collection collection2, SutProvider sutProvider) + { + // Arrange + var model = new CollectionBulkDeleteRequestModel + { + Ids = new[] { collection1.Id.ToString(), collection2.Id.ToString() }, + OrganizationId = orgId.ToString() + }; + + var collections = new List + { + new CollectionDetails + { + Id = collection2.Id, + OrganizationId = orgId, + }, + }; + + sutProvider.GetDependency() + .DeleteAssignedCollections(orgId) + .Returns(true); + + sutProvider.GetDependency() + .UserId + .Returns(user.Id); + + sutProvider.GetDependency() + .GetManyByUserIdAsync(user.Id) + .Returns(collections); + + // Act + await sutProvider.Sut.DeleteMany(model); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .DeleteManyAsync(Arg.Is>(coll => coll.Select(c => c.Id).SequenceEqual(collections.Select(c => c.Id)))); + } + + } diff --git a/test/Api.Test/Controllers/GroupsControllerTests.cs b/test/Api.Test/Controllers/GroupsControllerTests.cs index 8554a9ca91..5d700b4e36 100644 --- a/test/Api.Test/Controllers/GroupsControllerTests.cs +++ b/test/Api.Test/Controllers/GroupsControllerTests.cs @@ -31,7 +31,8 @@ public class GroupsControllerTests g.OrganizationId == organization.Id && g.Name == groupRequestModel.Name && g.AccessAll == groupRequestModel.AccessAll && g.ExternalId == groupRequestModel.ExternalId), organization, - Arg.Any>()); + Arg.Any>(), + Arg.Any>()); Assert.NotNull(response.Id); Assert.Equal(groupRequestModel.Name, response.Name); @@ -58,7 +59,8 @@ public class GroupsControllerTests g.OrganizationId == organization.Id && g.Name == groupRequestModel.Name && g.AccessAll == groupRequestModel.AccessAll && g.ExternalId == groupRequestModel.ExternalId), Arg.Is(o => o.Id == organization.Id), - Arg.Any>()); + Arg.Any>(), + Arg.Any>()); Assert.NotNull(response.Id); Assert.Equal(groupRequestModel.Name, response.Name); diff --git a/test/Api.Test/Public/Controllers/GroupsControllerTests.cs b/test/Api.Test/Public/Controllers/GroupsControllerTests.cs index fd6a609330..fe268ff1e0 100644 --- a/test/Api.Test/Public/Controllers/GroupsControllerTests.cs +++ b/test/Api.Test/Public/Controllers/GroupsControllerTests.cs @@ -33,7 +33,7 @@ public class GroupsControllerTests g.OrganizationId == organization.Id && g.Name == groupRequestModel.Name && g.AccessAll == groupRequestModel.AccessAll && g.ExternalId == groupRequestModel.ExternalId), organization, - Arg.Any>()); + Arg.Any>()); Assert.Equal(groupRequestModel.Name, responseValue.Name); Assert.Equal(groupRequestModel.AccessAll, responseValue.AccessAll); @@ -58,7 +58,7 @@ public class GroupsControllerTests g.OrganizationId == organization.Id && g.Name == groupRequestModel.Name && g.AccessAll == groupRequestModel.AccessAll && g.ExternalId == groupRequestModel.ExternalId), Arg.Is(o => o.Id == organization.Id), - Arg.Any>()); + Arg.Any>()); Assert.Equal(groupRequestModel.Name, responseValue.Name); Assert.Equal(groupRequestModel.AccessAll, responseValue.AccessAll); diff --git a/test/Core.Test/OrganizationFeatures/Groups/CreateGroupCommandTests.cs b/test/Core.Test/OrganizationFeatures/Groups/CreateGroupCommandTests.cs index 9d18da466f..6aefb31a11 100644 --- a/test/Core.Test/OrganizationFeatures/Groups/CreateGroupCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/Groups/CreateGroupCommandTests.cs @@ -31,7 +31,7 @@ public class CreateGroupCommandTests } [Theory, OrganizationCustomize(UseGroups = true), BitAutoData] - public async Task CreateGroup_WithCollections_Success(SutProvider sutProvider, Organization organization, Group group, List collections) + public async Task CreateGroup_WithCollections_Success(SutProvider sutProvider, Organization organization, Group group, List collections) { await sutProvider.Sut.CreateGroupAsync(group, organization, collections); diff --git a/test/Core.Test/OrganizationFeatures/Groups/DeleteGroupCommandTests.cs b/test/Core.Test/OrganizationFeatures/Groups/DeleteGroupCommandTests.cs index 7d396cd7e4..27b070d213 100644 --- a/test/Core.Test/OrganizationFeatures/Groups/DeleteGroupCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/Groups/DeleteGroupCommandTests.cs @@ -4,6 +4,7 @@ using Bit.Core.Exceptions; using Bit.Core.OrganizationFeatures.Groups; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Test.AutoFixture.OrganizationFixtures; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -52,7 +53,8 @@ public class DeleteGroupCommandTests [Theory] [BitAutoData] - public async Task DeleteGroup_WithEventSystemUser_Success(SutProvider sutProvider, Group group, EventSystemUser eventSystemUser) + public async Task DeleteGroup_WithEventSystemUser_Success(SutProvider sutProvider, Group group, + EventSystemUser eventSystemUser) { sutProvider.GetDependency() .GetByIdAsync(group.Id) @@ -61,6 +63,38 @@ public class DeleteGroupCommandTests await sutProvider.Sut.DeleteGroupAsync(group.OrganizationId, group.Id, eventSystemUser); await sutProvider.GetDependency().Received(1).DeleteAsync(group); - await sutProvider.GetDependency().Received(1).LogGroupEventAsync(group, Core.Enums.EventType.Group_Deleted, eventSystemUser); + await sutProvider.GetDependency().Received(1) + .LogGroupEventAsync(group, Core.Enums.EventType.Group_Deleted, eventSystemUser); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_DeletesGroup(Group group, SutProvider sutProvider) + { + // Act + await sutProvider.Sut.DeleteAsync(group); + + // Assert + await sutProvider.GetDependency().Received().DeleteAsync(group); + await sutProvider.GetDependency().Received().LogGroupEventAsync(group, EventType.Group_Deleted); + } + + [Theory, BitAutoData] + [OrganizationCustomize] + public async Task DeleteManyAsync_DeletesManyGroup(Group group, Group group2, SutProvider sutProvider) + { + // Arrange + var groups = new[] { group, group2 }; + + // Act + await sutProvider.Sut.DeleteManyAsync(groups); + + // Assert + await sutProvider.GetDependency().Received() + .DeleteManyAsync(Arg.Is>(ids => ids.SequenceEqual(groups.Select(g => g.Id)))); + + await sutProvider.GetDependency().Received().LogGroupEventsAsync( + Arg.Is>(a => + a.All(g => groups.Contains(g.Item1) && g.Item2 == EventType.Group_Deleted)) + ); } } diff --git a/test/Core.Test/OrganizationFeatures/Groups/UpdateGroupCommandTests.cs b/test/Core.Test/OrganizationFeatures/Groups/UpdateGroupCommandTests.cs index 6f765736c5..dde281b785 100644 --- a/test/Core.Test/OrganizationFeatures/Groups/UpdateGroupCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/Groups/UpdateGroupCommandTests.cs @@ -28,7 +28,7 @@ public class UpdateGroupCommandTests } [Theory, OrganizationCustomize(UseGroups = true), BitAutoData] - public async Task UpdateGroup_WithCollections_Success(SutProvider sutProvider, Group group, Organization organization, List collections) + public async Task UpdateGroup_WithCollections_Success(SutProvider sutProvider, Group group, Organization organization, List collections) { await sutProvider.Sut.UpdateGroupAsync(group, organization, collections); diff --git a/test/Core.Test/OrganizationFeatures/OrganizationCollections/DeleteCollectionCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationCollections/DeleteCollectionCommandTests.cs new file mode 100644 index 0000000000..99eca20a09 --- /dev/null +++ b/test/Core.Test/OrganizationFeatures/OrganizationCollections/DeleteCollectionCommandTests.cs @@ -0,0 +1,55 @@ + +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.OrganizationFeatures.OrganizationCollections; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.AutoFixture.OrganizationFixtures; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.OrganizationFeatures.OrganizationConnections; + +[SutProviderCustomize] +public class DeleteCollectionCommandTests +{ + + [Theory, BitAutoData] + [OrganizationCustomize] + public async Task DeleteAsync_DeletesCollection(Collection collection, SutProvider sutProvider) + { + // Act + await sutProvider.Sut.DeleteAsync(collection); + + // Assert + await sutProvider.GetDependency().Received().DeleteAsync(collection); + await sutProvider.GetDependency().Received().LogCollectionEventAsync(collection, EventType.Collection_Deleted, Arg.Any()); + } + + [Theory, BitAutoData] + [OrganizationCustomize] + public async Task DeleteManyAsync_DeletesManyCollections(Collection collection, Collection collection2, SutProvider sutProvider) + { + // Arrange + var collectionIds = new[] { collection.Id, collection2.Id }; + + sutProvider.GetDependency() + .GetManyByManyIdsAsync(collectionIds) + .Returns(new List { collection, collection2 }); + + // Act + await sutProvider.Sut.DeleteManyAsync(collectionIds); + + // Assert + await sutProvider.GetDependency().Received() + .DeleteManyAsync(Arg.Is>(ids => ids.SequenceEqual(collectionIds))); + + await sutProvider.GetDependency().Received().LogCollectionEventsAsync( + Arg.Is>(a => + a.All(c => collectionIds.Contains(c.Item1.Id) && c.Item2 == EventType.Collection_Deleted))); + } + + +} diff --git a/test/Core.Test/OrganizationFeatures/OrganizationCollections/CreateOrganizationConnectionCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationConnections/CreateOrganizationConnectionCommandTests.cs similarity index 100% rename from test/Core.Test/OrganizationFeatures/OrganizationCollections/CreateOrganizationConnectionCommandTests.cs rename to test/Core.Test/OrganizationFeatures/OrganizationConnections/CreateOrganizationConnectionCommandTests.cs diff --git a/test/Core.Test/OrganizationFeatures/OrganizationCollections/DeleteOrganizationConnectionCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationConnections/DeleteOrganizationConnectionCommandTests.cs similarity index 100% rename from test/Core.Test/OrganizationFeatures/OrganizationCollections/DeleteOrganizationConnectionCommandTests.cs rename to test/Core.Test/OrganizationFeatures/OrganizationConnections/DeleteOrganizationConnectionCommandTests.cs diff --git a/test/Core.Test/OrganizationFeatures/OrganizationCollections/UpdateOrganizationConnectionCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationConnections/UpdateOrganizationConnectionCommandTests.cs similarity index 100% rename from test/Core.Test/OrganizationFeatures/OrganizationCollections/UpdateOrganizationConnectionCommandTests.cs rename to test/Core.Test/OrganizationFeatures/OrganizationConnections/UpdateOrganizationConnectionCommandTests.cs diff --git a/test/Core.Test/Services/CollectionServiceTests.cs b/test/Core.Test/Services/CollectionServiceTests.cs index 1478d2014b..4577e591db 100644 --- a/test/Core.Test/Services/CollectionServiceTests.cs +++ b/test/Core.Test/Services/CollectionServiceTests.cs @@ -25,7 +25,7 @@ public class CollectionServiceTest await sutProvider.Sut.SaveAsync(collection); - await sutProvider.GetDependency().Received().CreateAsync(collection); + await sutProvider.GetDependency().Received().CreateAsync(collection, null, null); await sutProvider.GetDependency().Received() .LogCollectionEventAsync(collection, EventType.Collection_Created); Assert.True(collection.CreationDate - utcNow < TimeSpan.FromSeconds(1)); @@ -33,17 +33,33 @@ public class CollectionServiceTest } [Theory, BitAutoData] - public async Task SaveAsync_DefaultIdWithGroups_CreateCollectionWithGroupsInRepository(Collection collection, - IEnumerable groups, Organization organization, SutProvider sutProvider) + public async Task SaveAsync_DefaultIdWithUsers_CreatesCollectionInTheRepository(Collection collection, Organization organization, IEnumerable users, SutProvider sutProvider) + { + collection.Id = default; + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + var utcNow = DateTime.UtcNow; + + await sutProvider.Sut.SaveAsync(collection, null, users); + + await sutProvider.GetDependency().Received().CreateAsync(collection, null, users); + await sutProvider.GetDependency().Received() + .LogCollectionEventAsync(collection, EventType.Collection_Created); + Assert.True(collection.CreationDate - utcNow < TimeSpan.FromSeconds(1)); + Assert.True(collection.RevisionDate - utcNow < TimeSpan.FromSeconds(1)); + } + + [Theory, BitAutoData] + public async Task SaveAsync_DefaultIdWithGroupsAndUsers_CreateCollectionWithGroupsAndUsersInRepository(Collection collection, + IEnumerable groups, IEnumerable users, Organization organization, SutProvider sutProvider) { collection.Id = default; organization.UseGroups = true; sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); var utcNow = DateTime.UtcNow; - await sutProvider.Sut.SaveAsync(collection, groups); + await sutProvider.Sut.SaveAsync(collection, groups, users); - await sutProvider.GetDependency().Received().CreateAsync(collection, groups); + await sutProvider.GetDependency().Received().CreateAsync(collection, groups, users); await sutProvider.GetDependency().Received() .LogCollectionEventAsync(collection, EventType.Collection_Created); Assert.True(collection.CreationDate - utcNow < TimeSpan.FromSeconds(1)); @@ -59,7 +75,7 @@ public class CollectionServiceTest await sutProvider.Sut.SaveAsync(collection); - await sutProvider.GetDependency().Received().ReplaceAsync(collection); + await sutProvider.GetDependency().Received().ReplaceAsync(collection, null, null); await sutProvider.GetDependency().Received() .LogCollectionEventAsync(collection, EventType.Collection_Updated); Assert.Equal(collection.CreationDate, creationDate); @@ -67,7 +83,7 @@ public class CollectionServiceTest } [Theory, BitAutoData] - public async Task SaveAsync_OrganizationNotUseGroup_CreateCollectionWithoutGroupsInRepository(Collection collection, IEnumerable groups, + public async Task SaveAsync_OrganizationNotUseGroup_CreateCollectionWithoutGroupsInRepository(Collection collection, IEnumerable groups, Organization organization, SutProvider sutProvider) { collection.Id = default; @@ -76,7 +92,7 @@ public class CollectionServiceTest await sutProvider.Sut.SaveAsync(collection, groups); - await sutProvider.GetDependency().Received().CreateAsync(collection); + await sutProvider.GetDependency().Received().CreateAsync(collection, null, null); await sutProvider.GetDependency().Received() .LogCollectionEventAsync(collection, EventType.Collection_Created); Assert.True(collection.CreationDate - utcNow < TimeSpan.FromSeconds(1)); @@ -94,12 +110,12 @@ public class CollectionServiceTest .Returns(organizationUser); var utcNow = DateTime.UtcNow; - await sutProvider.Sut.SaveAsync(collection, null, organizationUser.Id); + await sutProvider.Sut.SaveAsync(collection, null, null, organizationUser.Id); - await sutProvider.GetDependency().Received().CreateAsync(collection); + await sutProvider.GetDependency().Received().CreateAsync(collection, null, null); await sutProvider.GetDependency().Received() .GetByOrganizationAsync(organization.Id, organizationUser.Id); - await sutProvider.GetDependency().Received().UpdateUsersAsync(collection.Id, Arg.Any>()); + await sutProvider.GetDependency().Received().UpdateUsersAsync(collection.Id, Arg.Any>()); await sutProvider.GetDependency().Received() .LogCollectionEventAsync(collection, EventType.Collection_Created); Assert.True(collection.CreationDate - utcNow < TimeSpan.FromSeconds(1)); @@ -112,7 +128,7 @@ public class CollectionServiceTest var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.SaveAsync(collection)); Assert.Contains("Organization not found", ex.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default, default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().LogCollectionEventAsync(default, default); } @@ -128,7 +144,7 @@ public class CollectionServiceTest var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.SaveAsync(collection)); Assert.Equal($@"You have reached the maximum number of collections ({organization.MaxCollections.Value}) for this organization.", ex.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default, default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().LogCollectionEventAsync(default, default); } @@ -168,4 +184,5 @@ public class CollectionServiceTest await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .LogOrganizationUserEventAsync(default, default); } + } diff --git a/test/Core.Test/Services/EventServiceTests.cs b/test/Core.Test/Services/EventServiceTests.cs index 010f2f9a19..cd3498f306 100644 --- a/test/Core.Test/Services/EventServiceTests.cs +++ b/test/Core.Test/Services/EventServiceTests.cs @@ -23,7 +23,7 @@ public class EventServiceTests [Theory, BitAutoData] public async Task LogGroupEvent_LogsRequiredInfo(Group group, EventType eventType, DateTime date, - Guid actingUserId, Guid providerId, DeviceType deviceType, SutProvider sutProvider) + Guid actingUserId, Guid providerId, string ipAddress, DeviceType deviceType, SutProvider sutProvider) { var orgAbilities = new Dictionary() { @@ -31,24 +31,33 @@ public class EventServiceTests }; sutProvider.GetDependency().GetOrganizationAbilitiesAsync().Returns(orgAbilities); sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().IpAddress.Returns(ipAddress); sutProvider.GetDependency().DeviceType.Returns(deviceType); sutProvider.GetDependency().ProviderIdForOrg(Arg.Any()).Returns(providerId); await sutProvider.Sut.LogGroupEventAsync(group, eventType, date); - await sutProvider.GetDependency().Received(1).CreateAsync(Arg.Is(e => - e.OrganizationId == group.OrganizationId && - e.GroupId == group.Id && - e.Type == eventType && - e.ActingUserId == actingUserId && - e.ProviderId == providerId && - e.Date == date && - e.SystemUser == null)); + var expected = new List() { + new EventMessage() + { + IpAddress = ipAddress, + DeviceType = deviceType, + OrganizationId = group.OrganizationId, + GroupId = group.Id, + Type = eventType, + ActingUserId = actingUserId, + ProviderId = providerId, + Date = date, + SystemUser = null + } + }; + + await sutProvider.GetDependency().Received(1).CreateManyAsync(Arg.Is(AssertHelper.AssertPropertyEqual(expected, new[] { "IdempotencyId" }))); } [Theory, BitAutoData] public async Task LogGroupEvent_WithEventSystemUser_LogsRequiredInfo(Group group, EventType eventType, EventSystemUser eventSystemUser, DateTime date, - Guid actingUserId, Guid providerId, DeviceType deviceType, SutProvider sutProvider) + Guid actingUserId, Guid providerId, string ipAddress, DeviceType deviceType, SutProvider sutProvider) { var orgAbilities = new Dictionary() { @@ -56,19 +65,28 @@ public class EventServiceTests }; sutProvider.GetDependency().GetOrganizationAbilitiesAsync().Returns(orgAbilities); sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().IpAddress.Returns(ipAddress); sutProvider.GetDependency().DeviceType.Returns(deviceType); sutProvider.GetDependency().ProviderIdForOrg(Arg.Any()).Returns(providerId); await sutProvider.Sut.LogGroupEventAsync(group, eventType, eventSystemUser, date); - await sutProvider.GetDependency().Received(1).CreateAsync(Arg.Is(e => - e.OrganizationId == group.OrganizationId && - e.GroupId == group.Id && - e.Type == eventType && - e.ActingUserId == actingUserId && - e.ProviderId == providerId && - e.Date == date && - e.SystemUser == eventSystemUser)); + var expected = new List() { + new EventMessage() + { + IpAddress = ipAddress, + DeviceType = deviceType, + OrganizationId = group.OrganizationId, + GroupId = group.Id, + Type = eventType, + ActingUserId = actingUserId, + ProviderId = providerId, + Date = date, + SystemUser = eventSystemUser + } + }; + + await sutProvider.GetDependency().Received(1).CreateManyAsync(Arg.Is(AssertHelper.AssertPropertyEqual(expected, new[] { "IdempotencyId" }))); } [Theory] diff --git a/test/Core.Test/Services/OrganizationServiceTests.cs b/test/Core.Test/Services/OrganizationServiceTests.cs index 960a38f35d..d112cdf9de 100644 --- a/test/Core.Test/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/Services/OrganizationServiceTests.cs @@ -499,22 +499,22 @@ public class OrganizationServiceTests [Theory, BitAutoData] public async Task SaveUser_NoUserId_Throws(OrganizationUser user, Guid? savingUserId, - IEnumerable collections, SutProvider sutProvider) + IEnumerable collections, IEnumerable groups, SutProvider sutProvider) { user.Id = default(Guid); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveUserAsync(user, savingUserId, collections)); + () => sutProvider.Sut.SaveUserAsync(user, savingUserId, collections, groups)); Assert.Contains("invite the user first", exception.Message.ToLowerInvariant()); } [Theory, BitAutoData] public async Task SaveUser_NoChangeToData_Throws(OrganizationUser user, Guid? savingUserId, - IEnumerable collections, SutProvider sutProvider) + IEnumerable collections, IEnumerable groups, SutProvider sutProvider) { var organizationUserRepository = sutProvider.GetDependency(); organizationUserRepository.GetByIdAsync(user.Id).Returns(user); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveUserAsync(user, savingUserId, collections)); + () => sutProvider.Sut.SaveUserAsync(user, savingUserId, collections, groups)); Assert.Contains("make changes before saving", exception.Message.ToLowerInvariant()); } @@ -523,7 +523,8 @@ public class OrganizationServiceTests Organization organization, OrganizationUser oldUserData, OrganizationUser newUserData, - IEnumerable collections, + IEnumerable collections, + IEnumerable groups, [OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser savingUser, SutProvider sutProvider) { @@ -541,7 +542,7 @@ public class OrganizationServiceTests .Returns(new List { savingUser }); currentContext.OrganizationOwner(savingUser.OrganizationId).Returns(true); - await sutProvider.Sut.SaveUserAsync(newUserData, savingUser.UserId, collections); + await sutProvider.Sut.SaveUserAsync(newUserData, savingUser.UserId, collections, groups); } [Theory, BitAutoData] @@ -549,7 +550,8 @@ public class OrganizationServiceTests Organization organization, OrganizationUser oldUserData, [OrganizationUser(type: OrganizationUserType.Custom)] OrganizationUser newUserData, - IEnumerable collections, + IEnumerable collections, + IEnumerable groups, [OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser savingUser, SutProvider sutProvider) { @@ -570,7 +572,7 @@ public class OrganizationServiceTests currentContext.OrganizationOwner(savingUser.OrganizationId).Returns(true); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveUserAsync(newUserData, savingUser.UserId, collections)); + () => sutProvider.Sut.SaveUserAsync(newUserData, savingUser.UserId, collections, groups)); Assert.Contains("to enable custom permissions", exception.Message.ToLowerInvariant()); } @@ -584,7 +586,8 @@ public class OrganizationServiceTests Organization organization, OrganizationUser oldUserData, OrganizationUser newUserData, - IEnumerable collections, + IEnumerable collections, + IEnumerable groups, [OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser savingUser, SutProvider sutProvider) { @@ -605,7 +608,7 @@ public class OrganizationServiceTests .Returns(new List { savingUser }); currentContext.OrganizationOwner(savingUser.OrganizationId).Returns(true); - await sutProvider.Sut.SaveUserAsync(newUserData, savingUser.UserId, collections); + await sutProvider.Sut.SaveUserAsync(newUserData, savingUser.UserId, collections, groups); } [Theory, BitAutoData] @@ -613,7 +616,8 @@ public class OrganizationServiceTests Organization organization, OrganizationUser oldUserData, [OrganizationUser(type: OrganizationUserType.Custom)] OrganizationUser newUserData, - IEnumerable collections, + IEnumerable collections, + IEnumerable groups, [OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser savingUser, SutProvider sutProvider) { @@ -633,7 +637,7 @@ public class OrganizationServiceTests .Returns(new List { savingUser }); currentContext.OrganizationOwner(savingUser.OrganizationId).Returns(true); - await sutProvider.Sut.SaveUserAsync(newUserData, savingUser.UserId, collections); + await sutProvider.Sut.SaveUserAsync(newUserData, savingUser.UserId, collections, groups); } [Theory, BitAutoData] diff --git a/test/Infrastructure.EFIntegration.Test/Repositories/CipherRepositoryTests.cs b/test/Infrastructure.EFIntegration.Test/Repositories/CipherRepositoryTests.cs index 91832c313f..7868c7c034 100644 --- a/test/Infrastructure.EFIntegration.Test/Repositories/CipherRepositoryTests.cs +++ b/test/Infrastructure.EFIntegration.Test/Repositories/CipherRepositoryTests.cs @@ -140,8 +140,8 @@ public class CipherRepositoryTests orgUsers = await efOrgUserRepos[i].CreateMany(orgUsers); - var selectionReadOnlyList = new List(); - orgUsers.ForEach(ou => selectionReadOnlyList.Add(new SelectionReadOnly() { Id = ou.Id })); + var selectionReadOnlyList = new List(); + orgUsers.ForEach(ou => selectionReadOnlyList.Add(new CollectionAccessSelection() { Id = ou.Id })); await efCollectionRepos[i].UpdateUsersAsync(efCollection.Id, selectionReadOnlyList); efCollectionRepos[i].ClearChangeTracking(); diff --git a/util/Migrator/DbScripts/2022-10-24_00_CollectionManagement.sql b/util/Migrator/DbScripts/2022-10-24_00_CollectionManagement.sql new file mode 100644 index 0000000000..83a90bb873 --- /dev/null +++ b/util/Migrator/DbScripts/2022-10-24_00_CollectionManagement.sql @@ -0,0 +1,181 @@ +-- Collection_ReadWithGroupsAndUsersByOrganizationId +IF OBJECT_ID('[dbo].[Collection_ReadWithGroupsAndUsersByOrganizationId]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Collection_ReadWithGroupsAndUsersByOrganizationId] +END +GO + +CREATE PROCEDURE [dbo].[Collection_ReadWithGroupsAndUsersByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_ReadByOrganizationId] @OrganizationId + + EXEC [dbo].[CollectionGroup_ReadByOrganizationId] @OrganizationId + + EXEC [dbo].[CollectionUser_ReadByOrganizationId] @OrganizationId + +END +GO + + +-- Collection_ReadWithGroupsAndUsersByUserId +IF OBJECT_ID('[dbo].[Collection_ReadWithGroupsAndUsersByUserId]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Collection_ReadWithGroupsAndUsersByUserId] +END +GO + +CREATE PROCEDURE [dbo].[Collection_ReadWithGroupsAndUsersByUserId] + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + DECLARE @TempUserCollections TABLE( + Id UNIQUEIDENTIFIER, + OrganizationId UNIQUEIDENTIFIER, + Name VARCHAR(MAX), + CreationDate DATETIME2(7), + RevisionDate DATETIME2(7), + ExternalId NVARCHAR(300), + ReadOnly BIT, + HidePasswords BIT) + + INSERT INTO @TempUserCollections EXEC [dbo].[Collection_ReadByUserId] @UserId + + SELECT + * + FROM + @TempUserCollections C + + SELECT + CG.* + FROM + [dbo].[CollectionGroup] CG + INNER JOIN + @TempUserCollections C ON C.[Id] = CG.[CollectionId] + + SELECT + CU.* + FROM + [dbo].[CollectionUser] CU + INNER JOIN + @TempUserCollections C ON C.[Id] = CU.[CollectionId] + +END +GO + + +-- CollectionUser_ReadByOrganizationId +CREATE PROCEDURE [dbo].[CollectionUser_ReadByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + CU.* + FROM + [dbo].[CollectionUser] CU + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = CU.[OrganizationUserId] + WHERE + OU.[OrganizationId] = @OrganizationId + +END +GO + + +-- Collection_ReadByIds +IF OBJECT_ID('[dbo].[Collection_ReadByIds]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Collection_ReadByIds] +END +GO + +CREATE PROCEDURE [dbo].[Collection_ReadByIds] + @Ids AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + IF (SELECT COUNT(1) FROM @Ids) < 1 + BEGIN + RETURN(-1) + END + + SELECT + * + FROM + [dbo].[Collection] + WHERE + [Id] IN (SELECT [Id] FROM @Ids) +END +GO + + +-- Collection_DeleteByIds +IF OBJECT_ID('[dbo].[Collection_DeleteByIds]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Collection_DeleteByIds] +END +GO + +CREATE PROCEDURE [dbo].[Collection_DeleteByIds] + @Ids AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + DECLARE @OrgIds AS [dbo].[GuidIdArray] + + INSERT INTO @OrgIds (Id) + SELECT + [OrganizationId] + FROM + [dbo].[Collection] + WHERE + [Id] in (SELECT [Id] FROM @Ids) + GROUP BY + [OrganizationId] + + DECLARE @BatchSize INT = 100 + + -- Delete Collection Groups + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION CollectionGroup_DeleteMany + DELETE TOP(@BatchSize) + FROM + [dbo].[CollectionGroup] + WHERE + [CollectionId] IN (SELECT [Id] FROM @Ids) + + + SET @BatchSize = @@ROWCOUNT + COMMIT TRANSACTION CollectionGroup_DeleteMany + END + + -- Reset batch size + SET @BatchSize = 100 + + -- Delete Collections + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION Collection_DeleteMany + DELETE TOP(@BatchSize) + FROM + [dbo].[Collection] + WHERE + [Id] IN (SELECT [Id] FROM @Ids) + + SET @BatchSize = @@ROWCOUNT + COMMIT TRANSACTION CollectionGroup_DeleteMany + END + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationIds] @OrgIds +END +GO \ No newline at end of file diff --git a/util/Migrator/DbScripts/2022-10-24_01_ReadGroupsWithCollectionsByOrgId.sql b/util/Migrator/DbScripts/2022-10-24_01_ReadGroupsWithCollectionsByOrgId.sql new file mode 100644 index 0000000000..72ca67014f --- /dev/null +++ b/util/Migrator/DbScripts/2022-10-24_01_ReadGroupsWithCollectionsByOrgId.sql @@ -0,0 +1,134 @@ +IF OBJECT_ID('[dbo].[CollectionGroup_ReadByOrganizationId]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[CollectionGroup_ReadByOrganizationId]; +END +GO + +CREATE PROCEDURE [dbo].[CollectionGroup_ReadByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + +SELECT + CG.* +FROM + [dbo].[CollectionGroup] CG + INNER JOIN + [dbo].[Group] G ON G.[Id] = CG.[GroupId] +WHERE + G.[OrganizationId] = @OrganizationId +END +GO + +IF OBJECT_ID('[dbo].[Group_ReadWithCollectionsByOrganizationId]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Group_ReadWithCollectionsByOrganizationId]; +END +GO + +CREATE PROCEDURE [dbo].[Group_ReadWithCollectionsByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Group_ReadByOrganizationId] @OrganizationId + + EXEC [dbo].[CollectionGroup_ReadByOrganizationId] @OrganizationId +END +GO + +IF OBJECT_ID('[dbo].[Group_ReadByIds]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Group_ReadByIds]; +END +GO + +CREATE PROCEDURE [dbo].[Group_ReadByIds] + @Ids AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + IF (SELECT COUNT(1) FROM @Ids) < 1 + BEGIN + RETURN(-1) + END + + SELECT + * + FROM + [dbo].[Group] + WHERE + [Id] IN (SELECT [Id] FROM @Ids) +END +GO + +IF OBJECT_ID('[dbo].[User_BumpAccountRevisionDateByOrganizationIds]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[User_BumpAccountRevisionDateByOrganizationIds]; +END +GO + +CREATE PROCEDURE [dbo].[User_BumpAccountRevisionDateByOrganizationIds] + @OrganizationIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + +UPDATE + U +SET + U.[AccountRevisionDate] = GETUTCDATE() + FROM + [dbo].[User] U + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[UserId] = U.[Id] +WHERE + OU.[OrganizationId] IN (SELECT [Id] FROM @OrganizationIds) + AND OU.[Status] = 2 -- Confirmed +END +GO + +IF OBJECT_ID('[dbo].[Group_DeleteByIds]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Group_DeleteByIds]; +END +GO + +CREATE PROCEDURE [dbo].[Group_DeleteByIds] + @Ids AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + DECLARE @OrgIds AS [dbo].[GuidIdArray] + + INSERT INTO @OrgIds (Id) + SELECT + [OrganizationId] + FROM + [dbo].[Group] + WHERE + [Id] in (SELECT [Id] FROM @Ids) + GROUP BY + [OrganizationId] + + DECLARE @BatchSize INT = 100 + + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION Group_DeleteMany_Groups + DELETE TOP(@BatchSize) + FROM + [dbo].[Group] + WHERE + [Id] IN (SELECT [Id] FROM @Ids) + + SET @BatchSize = @@ROWCOUNT + COMMIT TRANSACTION Group_DeleteMany_Groups + END + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationIds] @OrgIds +END \ No newline at end of file diff --git a/util/Migrator/DbScripts/2022-10-25_00_CollectionsWithGroupsAndUsers.sql b/util/Migrator/DbScripts/2022-10-25_00_CollectionsWithGroupsAndUsers.sql new file mode 100644 index 0000000000..06f19fb112 --- /dev/null +++ b/util/Migrator/DbScripts/2022-10-25_00_CollectionsWithGroupsAndUsers.sql @@ -0,0 +1,211 @@ +-- Stored Procedure: CollectionGroup_ReadByCollectionId +CREATE OR ALTER PROCEDURE [dbo].[CollectionGroup_ReadByCollectionId] + @CollectionId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + [GroupId] [Id], + [ReadOnly], + [HidePasswords] + FROM + [dbo].[CollectionGroup] + WHERE + [CollectionId] = @CollectionId +END +GO + +-- Stored Procedure: Collection_ReadWithGroupsAndUsersById +CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadWithGroupsAndUsersById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_ReadById] @Id + + EXEC [dbo].[CollectionGroup_ReadByCollectionId] @Id + + EXEC [dbo].[CollectionUser_ReadByCollectionId] @Id +END +GO + +-- Stored Procedure: Collection_ReadWithGroupsAndUsersByIdUserId +CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadWithGroupsAndUsersByIdUserId] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_ReadByIdUserId] @Id, @UserId + + EXEC [dbo].[CollectionGroup_ReadByCollectionId] @Id + + EXEC [dbo].[CollectionUser_ReadByCollectionId] @Id +END +GO + +-- Stored Procedure: Collection_CreateWithGroupsAndUsers +CREATE OR ALTER PROCEDURE [dbo].[Collection_CreateWithGroupsAndUsers] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name VARCHAR(MAX), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Groups AS [dbo].[SelectionReadOnlyArray] READONLY, + @Users AS [dbo].[SelectionReadOnlyArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_Create] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate + + -- Groups + ;WITH [AvailableGroupsCTE] AS( + SELECT + [Id] + FROM + [dbo].[Group] + WHERE + [OrganizationId] = @OrganizationId + ) + INSERT INTO [dbo].[CollectionGroup] + ( + [CollectionId], + [GroupId], + [ReadOnly], + [HidePasswords] + ) + SELECT + @Id, + [Id], + [ReadOnly], + [HidePasswords] + FROM + @Groups + WHERE + [Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) + + -- Users + ;WITH [AvailableUsersCTE] AS( + SELECT + [Id] + FROM + [dbo].[OrganizationUser] + WHERE + [OrganizationId] = @OrganizationId + ) + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords] + ) + SELECT + @Id, + [Id], + [ReadOnly], + [HidePasswords] + FROM + @Users + WHERE + [Id] IN (SELECT [Id] FROM [AvailableUsersCTE]) + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId +END +GO + +-- Stored Procedure: Collection_UpdateWithGroupsAndUsers +CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithGroupsAndUsers] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name VARCHAR(MAX), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Groups AS [dbo].[SelectionReadOnlyArray] READONLY, + @Users AS [dbo].[SelectionReadOnlyArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate + + -- Groups + ;WITH [AvailableGroupsCTE] AS( + SELECT + Id + FROM + [dbo].[Group] + WHERE + OrganizationId = @OrganizationId + ) + MERGE + [dbo].[CollectionGroup] AS [Target] + USING + @Groups AS [Source] + ON + [Target].[CollectionId] = @Id + AND [Target].[GroupId] = [Source].[Id] + WHEN NOT MATCHED BY TARGET + AND [Source].[Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) THEN + INSERT VALUES + ( + @Id, + [Source].[Id], + [Source].[ReadOnly], + [Source].[HidePasswords] + ) + WHEN MATCHED AND ( + [Target].[ReadOnly] != [Source].[ReadOnly] + OR [Target].[HidePasswords] != [Source].[HidePasswords] + ) THEN + UPDATE SET [Target].[ReadOnly] = [Source].[ReadOnly], + [Target].[HidePasswords] = [Source].[HidePasswords] + WHEN NOT MATCHED BY SOURCE + AND [Target].[CollectionId] = @Id THEN + DELETE + ; + + -- Users + ;WITH [AvailableGroupsCTE] AS( + SELECT + Id + FROM + [dbo].[OrganizationUser] + WHERE + OrganizationId = @OrganizationId + ) + MERGE + [dbo].[CollectionUser] AS [Target] + USING + @Users AS [Source] + ON + [Target].[CollectionId] = @Id + AND [Target].[OrganizationUserId] = [Source].[Id] + WHEN NOT MATCHED BY TARGET + AND [Source].[Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) THEN + INSERT VALUES + ( + @Id, + [Source].[Id], + [Source].[ReadOnly], + [Source].[HidePasswords] + ) + WHEN MATCHED AND ( + [Target].[ReadOnly] != [Source].[ReadOnly] + OR [Target].[HidePasswords] != [Source].[HidePasswords] + ) THEN + UPDATE SET [Target].[ReadOnly] = [Source].[ReadOnly], + [Target].[HidePasswords] = [Source].[HidePasswords] + WHEN NOT MATCHED BY SOURCE + AND [Target].[CollectionId] = @Id THEN + DELETE + ; + + EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId +END \ No newline at end of file diff --git a/util/Migrator/DbScripts/2022-12-08_00_OrgUserGroupsAndCollections.sql b/util/Migrator/DbScripts/2022-12-08_00_OrgUserGroupsAndCollections.sql new file mode 100644 index 0000000000..96b889bebf --- /dev/null +++ b/util/Migrator/DbScripts/2022-12-08_00_OrgUserGroupsAndCollections.sql @@ -0,0 +1,42 @@ +IF OBJECT_ID('[dbo].[CollectionUser_ReadByOrganizationUserIds]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[CollectionUser_ReadByOrganizationUserIds]; +END +GO + +CREATE PROCEDURE [dbo].[CollectionUser_ReadByOrganizationUserIds] + @OrganizationUserIds [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + +SELECT + CU.* +FROM + [dbo].[OrganizationUser] OU + INNER JOIN + [dbo].[CollectionUser] CU ON OU.[AccessAll] = 0 AND CU.[OrganizationUserId] = OU.[Id] + INNER JOIN + @OrganizationUserIds OUI ON OUI.[Id] = OU.[Id] +END +GO + +IF OBJECT_ID('[dbo].[GroupUser_ReadByOrganizationUserIds]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[GroupUser_ReadByOrganizationUserIds]; +END +GO + +CREATE PROCEDURE [dbo].[GroupUser_ReadByOrganizationUserIds] + @OrganizationUserIds [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + +SELECT + GU.* +FROM + [dbo].[GroupUser] GU + INNER JOIN + @OrganizationUserIds OUI ON OUI.[Id] = GU.[OrganizationUserId] +END \ No newline at end of file diff --git a/util/Migrator/DbScripts_future/2022-11-FutureMigration.sql b/util/Migrator/DbScripts_future/2022-11-FutureMigration.sql new file mode 100644 index 0000000000..ef3fa0557c --- /dev/null +++ b/util/Migrator/DbScripts_future/2022-11-FutureMigration.sql @@ -0,0 +1,14 @@ +-- Stored Procedure: Collection_ReadWithGroupsById +DROP PROCEDURE [dbo].[Collection_ReadWithGroupsById]; +GO + +-- Stored Procedure: Collection_ReadWithGroupsByIdUserId +DROP PROCEDURE [dbo].[Collection_ReadWithGroupsByIdUserId]; +GO + +-- Stored Procedure: Collection_CreateWithGroups +DROP PROCEDURE [dbo].[Collection_CreateWithGroups]; +GO + +-- Stored Procedure: Collection_UpdateWithGroups +DROP PROCEDURE [dbo].[Collection_UpdateWithGroups]; \ No newline at end of file