diff --git a/src/Api/AdminConsole/Controllers/GroupsController.cs b/src/Api/AdminConsole/Controllers/GroupsController.cs index 9749691583c6..7eb203b12e67 100644 --- a/src/Api/AdminConsole/Controllers/GroupsController.cs +++ b/src/Api/AdminConsole/Controllers/GroupsController.cs @@ -1,17 +1,7 @@ -using Bit.Api.AdminConsole.Models.Request; +using Api.AdminConsole.Services; +using Bit.Api.AdminConsole.Models.Request; using Bit.Api.AdminConsole.Models.Response; using Bit.Api.Models.Response; -using Bit.Api.Utilities; -using Bit.Api.Vault.AuthorizationHandlers.Collections; -using Bit.Api.Vault.AuthorizationHandlers.Groups; -using Bit.Core; -using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; -using Bit.Core.AdminConsole.Repositories; -using Bit.Core.AdminConsole.Services; -using Bit.Core.Context; -using Bit.Core.Exceptions; -using Bit.Core.Repositories; -using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -21,255 +11,75 @@ namespace Bit.Api.AdminConsole.Controllers; [Authorize("Application")] 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; - private readonly IUpdateGroupCommand _updateGroupCommand; - private readonly IAuthorizationService _authorizationService; - private readonly IApplicationCacheService _applicationCacheService; - private readonly IUserService _userService; - private readonly IFeatureService _featureService; - private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly ICollectionRepository _collectionRepository; + private readonly IGroupsControllerService _groupsControllerService; public GroupsController( - IGroupRepository groupRepository, - IGroupService groupService, - IOrganizationRepository organizationRepository, - ICurrentContext currentContext, - ICreateGroupCommand createGroupCommand, - IUpdateGroupCommand updateGroupCommand, - IDeleteGroupCommand deleteGroupCommand, - IAuthorizationService authorizationService, - IApplicationCacheService applicationCacheService, - IUserService userService, - IFeatureService featureService, - IOrganizationUserRepository organizationUserRepository, - ICollectionRepository collectionRepository) + IGroupsControllerService groupsControllerService) { - _groupRepository = groupRepository; - _groupService = groupService; - _organizationRepository = organizationRepository; - _currentContext = currentContext; - _createGroupCommand = createGroupCommand; - _updateGroupCommand = updateGroupCommand; - _deleteGroupCommand = deleteGroupCommand; - _authorizationService = authorizationService; - _applicationCacheService = applicationCacheService; - _userService = userService; - _featureService = featureService; - _organizationUserRepository = organizationUserRepository; - _collectionRepository = collectionRepository; + _groupsControllerService = groupsControllerService; } [HttpGet("{id}")] public async Task Get(string orgId, string id) { - var group = await _groupRepository.GetByIdAsync(new Guid(id)); - if (group == null || !await _currentContext.ManageGroups(group.OrganizationId)) - { - throw new NotFoundException(); - } - - return new GroupResponseModel(group); + var group = await _groupsControllerService.GetOrganizationGroup(orgId, id); + return group; } [HttpGet("{id}/details")] public async Task GetDetails(string orgId, string id) { - var groupDetails = await _groupRepository.GetByIdWithCollectionsAsync(new Guid(id)); - if (groupDetails?.Item1 == null || !await _currentContext.ManageGroups(groupDetails.Item1.OrganizationId)) - { - throw new NotFoundException(); - } - - return new GroupDetailsResponseModel(groupDetails.Item1, groupDetails.Item2); + var groupDetails = await _groupsControllerService.GetOrganizationGroupDetail(orgId, id); + return groupDetails; } [HttpGet("")] public async Task> Get(Guid orgId) { - var authorized = - (await _authorizationService.AuthorizeAsync(User, GroupOperations.ReadAll(orgId))).Succeeded; - if (!authorized) - { - throw new NotFoundException(); - } - - var groups = await _groupRepository.GetManyWithCollectionsByOrganizationIdAsync(orgId); - var responses = groups.Select(g => new GroupDetailsResponseModel(g.Item1, g.Item2)); + var responses = await _groupsControllerService.GetOrganizationGroupsDetails(User, orgId); return new ListResponseModel(responses); } [HttpGet("{id}/users")] public async Task> GetUsers(string orgId, string id) { - var idGuid = new Guid(id); - var group = await _groupRepository.GetByIdAsync(idGuid); - if (group == null || !await _currentContext.ManageGroups(group.OrganizationId)) - { - throw new NotFoundException(); - } - - var groupIds = await _groupRepository.GetManyUserIdsByIdAsync(idGuid); - return groupIds; + var userIds = await _groupsControllerService.GetOrganizationUsers(orgId); + return userIds; } [HttpPost("")] public async Task Post(Guid orgId, [FromBody] GroupRequestModel model) { - if (!await _currentContext.ManageGroups(orgId)) - { - throw new NotFoundException(); - } - - // Flexible Collections - check the user has permission to grant access to the collections for the new group - if (_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) && model.Collections?.Any() == true) - { - var collections = await _collectionRepository.GetManyByManyIdsAsync(model.Collections.Select(a => a.Id)); - var authorized = - (await _authorizationService.AuthorizeAsync(User, collections, BulkCollectionOperations.ModifyGroupAccess)) - .Succeeded; - if (!authorized) - { - throw new NotFoundException("You are not authorized to grant access to these collections."); - } - } - - var organization = await _organizationRepository.GetByIdAsync(orgId); - var group = model.ToGroup(orgId); - await _createGroupCommand.CreateGroupAsync(group, organization, model.Collections?.Select(c => c.ToSelectionReadOnly()).ToList(), model.Users); - - return new GroupResponseModel(group); + var group = await _groupsControllerService.CreateGroup(User, orgId, model); + return group; } [HttpPut("{id}")] [HttpPost("{id}")] public async Task Put(Guid orgId, Guid id, [FromBody] GroupRequestModel model) { - if (_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1)) - { - // Use new Flexible Collections v1 logic - return await Put_vNext(orgId, id, model); - } - - // Pre-Flexible Collections v1 logic follows - var group = await _groupRepository.GetByIdAsync(id); - if (group == null || !await _currentContext.ManageGroups(group.OrganizationId)) - { - throw new NotFoundException(); - } - - var organization = await _organizationRepository.GetByIdAsync(orgId); - - await _updateGroupCommand.UpdateGroupAsync(model.ToGroup(group), organization, - model.Collections.Select(c => c.ToSelectionReadOnly()).ToList(), model.Users); - return new GroupResponseModel(group); - } - - /// - /// Put logic for Flexible Collections v1 - /// - private async Task Put_vNext(Guid orgId, Guid id, [FromBody] GroupRequestModel model) - { - var (group, currentAccess) = await _groupRepository.GetByIdWithCollectionsAsync(id); - if (group == null || !await _currentContext.ManageGroups(group.OrganizationId)) - { - throw new NotFoundException(); - } - - // Check whether the user is permitted to add themselves to the group - var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(orgId); - if (!orgAbility.AllowAdminAccessToAllCollectionItems) - { - var userId = _userService.GetProperUserId(User).Value; - var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(orgId, userId); - var currentGroupUsers = await _groupRepository.GetManyUserIdsByIdAsync(id); - // OrganizationUser may be null if the current user is a provider - if (organizationUser != null && !currentGroupUsers.Contains(organizationUser.Id) && model.Users.Contains(organizationUser.Id)) - { - throw new BadRequestException("You cannot add yourself to groups."); - } - } - - // The client only sends collections that the saving user has permissions to edit. - // On the server side, we need to (1) confirm this and (2) concat these with the collections that the user - // can't edit before saving to the database. - var currentCollections = await _collectionRepository - .GetManyByManyIdsAsync(currentAccess.Select(cas => cas.Id)); - - var readonlyCollectionIds = new HashSet(); - foreach (var collection in currentCollections) - { - if (!(await _authorizationService.AuthorizeAsync(User, collection, BulkCollectionOperations.ModifyGroupAccess)) - .Succeeded) - { - readonlyCollectionIds.Add(collection.Id); - } - } - - if (model.Collections.Any(c => readonlyCollectionIds.Contains(c.Id))) - { - throw new BadRequestException("You must have Can Manage permissions to edit a collection's membership"); - } - - var editedCollectionAccess = model.Collections - .Select(c => c.ToSelectionReadOnly()); - var readonlyCollectionAccess = currentAccess - .Where(ca => readonlyCollectionIds.Contains(ca.Id)); - var collectionsToSave = editedCollectionAccess - .Concat(readonlyCollectionAccess) - .ToList(); - - var organization = await _organizationRepository.GetByIdAsync(orgId); - - await _updateGroupCommand.UpdateGroupAsync(model.ToGroup(group), organization, collectionsToSave, model.Users); - return new GroupResponseModel(group); + var group = await _groupsControllerService.UpdateGroup(User, orgId, id, model); + return group; } [HttpDelete("{id}")] [HttpPost("{id}/delete")] public async Task Delete(string orgId, string id) { - var group = await _groupRepository.GetByIdAsync(new Guid(id)); - if (group == null || !await _currentContext.ManageGroups(group.OrganizationId)) - { - throw new NotFoundException(); - } - - await _deleteGroupCommand.DeleteAsync(group); + await _groupsControllerService.DeleteGroup(orgId, id); } [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); + await _groupsControllerService.BulkDeleteGroups(model); } [HttpDelete("{id}/user/{orgUserId}")] [HttpPost("{id}/delete-user/{orgUserId}")] public async Task Delete(string orgId, string id, string orgUserId) { - var group = await _groupRepository.GetByIdAsync(new Guid(id)); - if (group == null || !await _currentContext.ManageGroups(group.OrganizationId)) - { - throw new NotFoundException(); - } - - await _groupService.DeleteUserAsync(group, new Guid(orgUserId)); + await _groupsControllerService.DeleteGroupUser(orgId, id, orgUserId); } } diff --git a/src/Api/AdminConsole/Services/GroupsControllerService.cs b/src/Api/AdminConsole/Services/GroupsControllerService.cs new file mode 100644 index 000000000000..bf0bc5e2fa9b --- /dev/null +++ b/src/Api/AdminConsole/Services/GroupsControllerService.cs @@ -0,0 +1,319 @@ +using System.Security.Claims; +using Bit.Api.AdminConsole.Models.Request; +using Bit.Api.AdminConsole.Models.Response; +using Bit.Api.Utilities; +using Bit.Api.Vault.AuthorizationHandlers.Collections; +using Bit.Api.Vault.AuthorizationHandlers.Groups; +using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Authorization; + +namespace Api.AdminConsole.Services; + +public class GroupsControllerService : IGroupsControllerService +{ + private readonly IGroupRepository _groupRepository; + private readonly IGroupService _groupService; + private readonly IDeleteGroupCommand _deleteGroupCommand; + private readonly IOrganizationRepository _organizationRepository; + private readonly ICurrentContext _currentContext; + private readonly ICreateGroupCommand _createGroupCommand; + private readonly IUpdateGroupCommand _updateGroupCommand; + private readonly IAuthorizationService _authorizationService; + private readonly IApplicationCacheService _applicationCacheService; + private readonly IUserService _userService; + private readonly IFeatureService _featureService; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly ICollectionRepository _collectionRepository; + + public GroupsControllerService( + IGroupRepository groupRepository, + IGroupService groupService, + IOrganizationRepository organizationRepository, + ICurrentContext currentContext, + ICreateGroupCommand createGroupCommand, + IUpdateGroupCommand updateGroupCommand, + IDeleteGroupCommand deleteGroupCommand, + IAuthorizationService authorizationService, + IApplicationCacheService applicationCacheService, + IUserService userService, + IFeatureService featureService, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository) + { + _groupRepository = groupRepository; + _groupService = groupService; + _organizationRepository = organizationRepository; + _currentContext = currentContext; + _createGroupCommand = createGroupCommand; + _updateGroupCommand = updateGroupCommand; + _deleteGroupCommand = deleteGroupCommand; + _authorizationService = authorizationService; + _applicationCacheService = applicationCacheService; + _userService = userService; + _featureService = featureService; + _organizationUserRepository = organizationUserRepository; + _collectionRepository = collectionRepository; + } + + /// + /// Gets the basic group information of an organization + /// + /// Organization id/param> + /// Group id + /// GroupResponseModel + public async Task GetOrganizationGroup(string orgId, string groupId) + { + var group = await _groupRepository.GetByIdAsync(new Guid(groupId)); + if (group == null || !await _currentContext.ManageGroups(group.OrganizationId)) + { + throw new NotFoundException(); + } + + return new GroupResponseModel(group); + } + + /// + /// Gets the detailed group information of an organization + /// + /// Organization id + /// Group id + /// GroupDetailsResponseModel + public async Task GetOrganizationGroupDetail(string orgId, string groupId) + { + var groupDetails = await _groupRepository.GetByIdWithCollectionsAsync(new Guid(groupId)); + if (groupDetails?.Item1 == null || !await _currentContext.ManageGroups(groupDetails.Item1.OrganizationId)) + { + throw new NotFoundException(); + } + + return new GroupDetailsResponseModel(groupDetails.Item1, groupDetails.Item2); + } + + /// + /// Gets the detailed information on all groups in an organization + /// + /// + /// Requesting user. This user must have the ability to + /// view all groups in the organization + /// Organization id + /// List of GroupDetailsResponseModel + public async Task> GetOrganizationGroupsDetails(ClaimsPrincipal user, Guid orgId) + { + var authorized = + (await _authorizationService.AuthorizeAsync(user, GroupOperations.ReadAll(orgId))).Succeeded; + if (!authorized) + { + throw new NotFoundException(); + } + + var groups = await _groupRepository.GetManyWithCollectionsByOrganizationIdAsync(orgId); + var responses = groups.Select(g => new GroupDetailsResponseModel(g.Item1, g.Item2)); + return responses; + } + + /// + /// Gets a list of ids for all users in an organization + /// + /// Organization id + /// List of user Guids + public async Task> GetOrganizationUsers(string orgId) + { + var idGuid = new Guid(orgId); + var group = await _groupRepository.GetByIdAsync(idGuid); + if (group == null || !await _currentContext.ManageGroups(group.OrganizationId)) + { + throw new NotFoundException(); + } + + var groupIds = await _groupRepository.GetManyUserIdsByIdAsync(idGuid); + return groupIds; + } + + /// + /// Create a new group in an organization + /// + /// + /// Requesting user. The user must have permission to grant access + /// for the new group + /// + /// Organization id + /// The details for the new group + /// GroupResponseModel + public async Task CreateGroup(ClaimsPrincipal user, Guid orgId, GroupRequestModel model) + { + if (!await _currentContext.ManageGroups(orgId)) + { + throw new NotFoundException(); + } + + // Flexible Collections - check the user has permission to grant access to the collections for the new group + if (_featureService.IsEnabled(Bit.Core.FeatureFlagKeys.FlexibleCollectionsV1) && model.Collections?.Any() == true) + { + var collections = await _collectionRepository.GetManyByManyIdsAsync(model.Collections.Select(a => a.Id)); + var authorized = + (await _authorizationService.AuthorizeAsync(user, collections, BulkCollectionOperations.ModifyGroupAccess)) + .Succeeded; + if (!authorized) + { + throw new NotFoundException("You are not authorized to grant access to these collections."); + } + } + + var organization = await _organizationRepository.GetByIdAsync(orgId); + var group = model.ToGroup(orgId); + await _createGroupCommand.CreateGroupAsync(group, organization, model.Collections?.Select(c => c.ToSelectionReadOnly()).ToList(), model.Users); + + return new GroupResponseModel(group); + } + + /// + /// Updates a group in an organization + /// + /// + /// The requesting user. The requesting user must have the proper + /// permissions for editing items within the group. + /// + /// Organization id + /// + /// + /// Updated GroupResponseModel + public async Task UpdateGroup(ClaimsPrincipal user, Guid orgId, Guid groupId, GroupRequestModel model) + { + if (_featureService.IsEnabled(Bit.Core.FeatureFlagKeys.FlexibleCollectionsV1)) + { + // Use new Flexible Collections v1 logic + return await Put_vNext(user, orgId, groupId, model); + } + + // Pre-Flexible Collections v1 logic follows + var group = await _groupRepository.GetByIdAsync(groupId); + if (group == null || !await _currentContext.ManageGroups(group.OrganizationId)) + { + throw new NotFoundException(); + } + + var organization = await _organizationRepository.GetByIdAsync(orgId); + + await _updateGroupCommand.UpdateGroupAsync(model.ToGroup(group), organization, + model.Collections.Select(c => c.ToSelectionReadOnly()).ToList(), model.Users); + return new GroupResponseModel(group); + } + + /// + /// Delete a group from an organization + /// + /// Organization id + /// Group id + public async Task DeleteGroup(string orgId, string groupId) + { + var group = await _groupRepository.GetByIdAsync(new Guid(groupId)); + if (group == null || !await _currentContext.ManageGroups(group.OrganizationId)) + { + throw new NotFoundException(); + } + + await _deleteGroupCommand.DeleteAsync(group); + } + + /// + /// Deletes multiple groups from an organization + /// + /// The details for what groups to remove + public async Task BulkDeleteGroups(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); + } + + /// + /// Deletes a user from a group in an organization + /// + /// Organization id + /// Group id + /// User id for the user to delete + public async Task DeleteGroupUser(string orgId, string groupId, string orgUserId) + { + var group = await _groupRepository.GetByIdAsync(new Guid(groupId)); + if (group == null || !await _currentContext.ManageGroups(group.OrganizationId)) + { + throw new NotFoundException(); + } + + await _groupService.DeleteUserAsync(group, new Guid(orgUserId)); + } + + /// + /// Put logic for Flexible Collections v1 + /// + private async Task Put_vNext(ClaimsPrincipal user, Guid orgId, Guid id, GroupRequestModel model) + { + var (group, currentAccess) = await _groupRepository.GetByIdWithCollectionsAsync(id); + if (group == null || !await _currentContext.ManageGroups(group.OrganizationId)) + { + throw new NotFoundException(); + } + + // Check whether the user is permitted to add themselves to the group + var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(orgId); + if (!orgAbility.AllowAdminAccessToAllCollectionItems) + { + var userId = _userService.GetProperUserId(user).Value; + var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(orgId, userId); + var currentGroupUsers = await _groupRepository.GetManyUserIdsByIdAsync(id); + // OrganizationUser may be null if the current user is a provider + if (organizationUser != null && !currentGroupUsers.Contains(organizationUser.Id) && model.Users.Contains(organizationUser.Id)) + { + throw new BadRequestException("You cannot add yourself to groups."); + } + } + + // The client only sends collections that the saving user has permissions to edit. + // On the server side, we need to (1) confirm this and (2) concat these with the collections that the user + // can't edit before saving to the database. + var currentCollections = await _collectionRepository + .GetManyByManyIdsAsync(currentAccess.Select(cas => cas.Id)); + + var readonlyCollectionIds = new HashSet(); + foreach (var collection in currentCollections) + { + if (!(await _authorizationService.AuthorizeAsync(user, collection, BulkCollectionOperations.ModifyGroupAccess)) + .Succeeded) + { + readonlyCollectionIds.Add(collection.Id); + } + } + + if (model.Collections.Any(c => readonlyCollectionIds.Contains(c.Id))) + { + throw new BadRequestException("You must have Can Manage permissions to edit a collection's membership"); + } + + var editedCollectionAccess = model.Collections + .Select(c => c.ToSelectionReadOnly()); + var readonlyCollectionAccess = currentAccess + .Where(ca => readonlyCollectionIds.Contains(ca.Id)); + var collectionsToSave = editedCollectionAccess + .Concat(readonlyCollectionAccess) + .ToList(); + + var organization = await _organizationRepository.GetByIdAsync(orgId); + + await _updateGroupCommand.UpdateGroupAsync(model.ToGroup(group), organization, collectionsToSave, model.Users); + return new GroupResponseModel(group); + } +} diff --git a/src/Api/AdminConsole/Services/IGroupsControllerService.cs b/src/Api/AdminConsole/Services/IGroupsControllerService.cs new file mode 100644 index 000000000000..4b6f5e261257 --- /dev/null +++ b/src/Api/AdminConsole/Services/IGroupsControllerService.cs @@ -0,0 +1,18 @@ +using System.Security.Claims; +using Bit.Api.AdminConsole.Models.Request; +using Bit.Api.AdminConsole.Models.Response; + +namespace Api.AdminConsole.Services; + +public interface IGroupsControllerService +{ + Task GetOrganizationGroup(string orgId, string groupId); + Task GetOrganizationGroupDetail(string orgId, string groupId); + Task> GetOrganizationGroupsDetails(ClaimsPrincipal user, Guid orgId); + Task> GetOrganizationUsers(string orgId); + Task CreateGroup(ClaimsPrincipal user, Guid orgId, GroupRequestModel model); + Task UpdateGroup(ClaimsPrincipal user, Guid orgId, Guid groupId, GroupRequestModel model); + Task DeleteGroup(string orgId, string groupId); + Task BulkDeleteGroups(GroupBulkRequestModel model); + Task DeleteGroupUser(string orgId, string groupId, string orgUserId); +} diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index fd2a4dbe6f77..e74da5abca79 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -32,6 +32,8 @@ using Bit.Core.Vault.Entities; using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Core.Auth.Models.Data; +using Api.AdminConsole.Services; + #if !OSS using Bit.Commercial.Core.SecretsManager; @@ -93,6 +95,9 @@ public void ConfigureServices(IServiceCollection services) // BitPay services.AddSingleton(); + // Groups + services.AddScoped(); + if (!globalSettings.SelfHosted) { services.AddIpRateLimiting(globalSettings);