diff --git a/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs b/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs index 109d9e660a9a..9560e173b410 100644 --- a/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs +++ b/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs @@ -1,4 +1,5 @@ using Bit.Api.AdminConsole.Authorization.Collections; +using Bit.Api.AdminConsole.Authorization.Groups; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -23,5 +24,6 @@ public static void AddAdminConsoleAuthorizationHandlers(this IServiceCollection // Fine-grained, relationship-dependent authorization for collection access is handled by plain injected // services rather than IAuthorizationHandler - see ICollectionAuthorizationService. services.TryAddScoped(); + services.TryAddScoped(); } } diff --git a/src/Api/AdminConsole/Authorization/Groups/GroupsAuthorizationResult.cs b/src/Api/AdminConsole/Authorization/Groups/GroupsAuthorizationResult.cs new file mode 100644 index 000000000000..1e3f32df15dc --- /dev/null +++ b/src/Api/AdminConsole/Authorization/Groups/GroupsAuthorizationResult.cs @@ -0,0 +1,14 @@ +#nullable enable + +namespace Bit.Api.AdminConsole.Authorization.Groups; + +/// +/// The outcome of an check. +/// +public record GroupsAuthorizationResult( + bool CanAddSelfToGroup, + IReadOnlySet UnauthorizedPostedCollectionIds, + IReadOnlySet ReadonlyCurrentCollectionIds) +{ + public bool IsSuccess => CanAddSelfToGroup && UnauthorizedPostedCollectionIds.Count == 0; +} diff --git a/src/Api/AdminConsole/Authorization/Groups/GroupsAuthorizationService.cs b/src/Api/AdminConsole/Authorization/Groups/GroupsAuthorizationService.cs new file mode 100644 index 000000000000..ee8922dfaac8 --- /dev/null +++ b/src/Api/AdminConsole/Authorization/Groups/GroupsAuthorizationService.cs @@ -0,0 +1,106 @@ +#nullable enable +using Bit.Api.AdminConsole.Authorization.Collections; +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Context; +using Bit.Core.Repositories; + +namespace Bit.Api.AdminConsole.Authorization.Groups; + +public class GroupsAuthorizationService( + ICurrentContext currentContext, + IGroupRepository groupRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository, + IOrganizationAbilityCacheService organizationAbilityCacheService) : IGroupsAuthorizationService +{ + public async Task AuthorizeUpdateAsync( + Guid organizationId, + Guid groupId, + IReadOnlyCollection postedCollectionIds, + IReadOnlyCollection postedUserIds) + { + var (group, currentAccess) = await groupRepository.GetByIdWithCollectionsAsync(groupId); + if (group is null || group.OrganizationId != organizationId) + { + return new GroupsAuthorizationResult(false, new HashSet(), new HashSet()); + } + + if (!currentContext.UserId.HasValue) + { + return new GroupsAuthorizationResult(false, new HashSet(), new HashSet()); + } + + var organization = currentContext.GetOrganization(organizationId); + var organizationAbility = await organizationAbilityCacheService.GetOrganizationAbilityAsync(organizationId); + var allowAdminAccessToAllCollectionItems = organizationAbility is { AllowAdminAccessToAllCollectionItems: true }; + + var canAddSelfToGroup = await CanAddSelfToGroupAsync(organizationId, groupId, postedUserIds, allowAdminAccessToAllCollectionItems); + + var managedCollectionIds = await GetManagedCollectionIdsAsync(currentContext.UserId.Value); + + var unauthorizedPostedCollectionIds = await GetUnauthorizedCollectionIdsAsync( + postedCollectionIds, organizationId, organization, allowAdminAccessToAllCollectionItems, managedCollectionIds); + var readonlyCurrentCollectionIds = await GetUnauthorizedCollectionIdsAsync( + currentAccess.Select(ca => ca.Id), organizationId, organization, allowAdminAccessToAllCollectionItems, managedCollectionIds); + + if ((!canAddSelfToGroup || unauthorizedPostedCollectionIds.Count > 0) + && await currentContext.ProviderUserForOrgAsync(organizationId)) + { + return new GroupsAuthorizationResult(true, new HashSet(), new HashSet()); + } + + return new GroupsAuthorizationResult(canAddSelfToGroup, unauthorizedPostedCollectionIds, readonlyCurrentCollectionIds); + } + + private async Task CanAddSelfToGroupAsync( + Guid organizationId, Guid groupId, IReadOnlyCollection postedUserIds, bool allowAdminAccessToAllCollectionItems) + { + if (allowAdminAccessToAllCollectionItems) + { + return true; + } + + // The caller may be a provider rather than an organization member, in which case there's no self to add. + var callerOrganizationUser = await organizationUserRepository.GetByOrganizationAsync(organizationId, currentContext.UserId!.Value); + if (callerOrganizationUser is null) + { + return true; + } + + var currentGroupUserIds = await groupRepository.GetManyUserIdsByIdAsync(groupId); + return currentGroupUserIds.Contains(callerOrganizationUser.Id) || !postedUserIds.Contains(callerOrganizationUser.Id); + } + + private async Task> GetManagedCollectionIdsAsync(Guid userId) + { + var callerCollections = await collectionRepository.GetManyByUserIdAsync(userId); + return callerCollections.Where(c => c.Manage).Select(c => c.Id).ToHashSet(); + } + + private async Task> GetUnauthorizedCollectionIdsAsync( + IEnumerable collectionIds, + Guid organizationId, + CurrentContextOrganization? organization, + bool allowAdminAccessToAllCollectionItems, + HashSet managedCollectionIds) + { + var unauthorized = new HashSet(); + foreach (var collectionId in collectionIds) + { + var (collection, accessDetails) = await collectionRepository.GetByIdWithAccessAsync(collectionId); + if (collection is null || collection.OrganizationId != organizationId) + { + continue; + } + + var callerManagesCollection = managedCollectionIds.Contains(collectionId); + if (!CollectionRules.CanModifyGroupAccess(accessDetails, organization, allowAdminAccessToAllCollectionItems, callerManagesCollection)) + { + unauthorized.Add(collectionId); + } + } + + return unauthorized; + } +} diff --git a/src/Api/AdminConsole/Authorization/Groups/IGroupsAuthorizationService.cs b/src/Api/AdminConsole/Authorization/Groups/IGroupsAuthorizationService.cs new file mode 100644 index 000000000000..90d884fd490e --- /dev/null +++ b/src/Api/AdminConsole/Authorization/Groups/IGroupsAuthorizationService.cs @@ -0,0 +1,15 @@ +#nullable enable + +namespace Bit.Api.AdminConsole.Authorization.Groups; + +/// +/// Decides whether the caller may update a group's member list and collection access. +/// +public interface IGroupsAuthorizationService +{ + Task AuthorizeUpdateAsync( + Guid organizationId, + Guid groupId, + IReadOnlyCollection postedCollectionIds, + IReadOnlyCollection postedUserIds); +} diff --git a/src/Api/AdminConsole/Controllers/GroupsController.cs b/src/Api/AdminConsole/Controllers/GroupsController.cs index b1331b9b0b9b..5bcc89a4f139 100644 --- a/src/Api/AdminConsole/Controllers/GroupsController.cs +++ b/src/Api/AdminConsole/Controllers/GroupsController.cs @@ -3,10 +3,12 @@ using Bit.Api.AdminConsole.Authorization; using Bit.Api.AdminConsole.Authorization.Collections; +using Bit.Api.AdminConsole.Authorization.Groups; using Bit.Api.AdminConsole.Authorization.Requirements; using Bit.Api.AdminConsole.Models.Request; using Bit.Api.AdminConsole.Models.Response; using Bit.Api.Models.Response; +using Bit.Core; using Bit.Core.AdminConsole.AbilitiesCache; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.Repositories; @@ -34,6 +36,7 @@ public class GroupsController : Controller private readonly IUserService _userService; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly ICollectionRepository _collectionRepository; + private readonly IGroupsAuthorizationService _groupsAuthorizationService; public GroupsController( IGroupRepository groupRepository, @@ -46,7 +49,8 @@ public GroupsController( IOrganizationAbilityCacheService organizationAbilityCacheService, IUserService userService, IOrganizationUserRepository organizationUserRepository, - ICollectionRepository collectionRepository) + ICollectionRepository collectionRepository, + IGroupsAuthorizationService groupsAuthorizationService) { _groupRepository = groupRepository; _groupService = groupService; @@ -59,6 +63,7 @@ public GroupsController( _userService = userService; _organizationUserRepository = organizationUserRepository; _collectionRepository = collectionRepository; + _groupsAuthorizationService = groupsAuthorizationService; } [HttpGet("{id}")] @@ -221,6 +226,43 @@ public async Task PostPut(Guid orgId, Guid id, [FromBody] Gr return await Put(orgId, id, model); } + /// + /// Behaves like , but authorizes via . + /// + [HttpPatch("{id}")] + [Authorize] + [Bitwarden.Server.Sdk.Features.RequireFeature(FeatureFlagKeys.GroupsAuthorizationServiceEndpoint)] + public async Task PatchWithNewAuthorization(Guid orgId, Guid id, [FromBody] GroupRequestModel model) + { + var (group, currentAccess) = await _groupRepository.GetByIdWithCollectionsAsync(id); + if (group == null || group.OrganizationId != orgId) + { + throw new NotFoundException(); + } + + var postedCollectionIds = model.Collections.Select(c => c.Id).ToList(); + var authorizationResult = await _groupsAuthorizationService.AuthorizeUpdateAsync(orgId, id, postedCollectionIds, model.Users.ToList()); + + if (!authorizationResult.CanAddSelfToGroup) + { + throw new BadRequestException("You cannot add yourself to groups."); + } + + if (authorizationResult.UnauthorizedPostedCollectionIds.Count > 0) + { + throw new NotFoundException(); + } + + var editedCollectionAccess = model.Collections.Select(c => c.ToSelectionReadOnly()); + var readonlyCollectionAccess = currentAccess.Where(ca => authorizationResult.ReadonlyCurrentCollectionIds.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); + } + [HttpDelete("{id}")] [Authorize] public async Task Delete(Guid orgId, Guid id) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index eb38f80f26b4..bb69b445b1a8 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -146,6 +146,7 @@ public static partial class FeatureFlagKeys public const string ChangeMemberEmailNoMp = "pm-28365-change-member-email-no-mp"; public const string PM34423StagedStatus = "pm-34423-staged-status"; public const string PM12473CollectionUserAccessEndpoint = "pm-12473-collection-user-access-endpoint"; + public const string GroupsAuthorizationServiceEndpoint = "groups-authorization-service-endpoint"; /* Architecture */ public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1"; diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPatchWithNewAuthorizationTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPatchWithNewAuthorizationTests.cs new file mode 100644 index 000000000000..d01b02b1dba8 --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPatchWithNewAuthorizationTests.cs @@ -0,0 +1,84 @@ +using System.Net; +using Bit.Api.AdminConsole.Models.Request; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using NSubstitute; +using Xunit; + +namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; + +public class GroupsControllerPatchWithNewAuthorizationTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + + private string _ownerEmail = null!; + private Organization _organization = null!; + + public GroupsControllerPatchWithNewAuthorizationTests(ApiApplicationFactory factory) + { + _factory = factory; + _factory.SubstituteService(_ => { }); + _factory.SubstituteService(_ => { }); + _factory.SubstituteService(featureService => + featureService.IsEnabled(FeatureFlagKeys.GroupsAuthorizationServiceEndpoint, Arg.Any()) + .Returns(true)); + _client = factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + } + + public async Task InitializeAsync() + { + _ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + + (_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, + plan: PlanType.EnterpriseAnnually, + ownerEmail: _ownerEmail, + passwordManagerSeats: 10, + paymentMethod: PaymentMethodType.Card); + + // The owner has no direct Manage access on the group's collections, so this is what authorizes + // them to change the group's collection access. + _organization.AllowAdminAccessToAllCollectionItems = true; + await _factory.GetService().UpsertAsync(_organization); + + await _loginHelper.LoginAsync(_ownerEmail); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task PatchWithNewAuthorization_UpdatesGroup_Success() + { + var group = await OrganizationTestHelpers.CreateGroup(_factory, _organization.Id); + + var model = new GroupRequestModel + { + Name = "renamed-via-new-authorization", + Collections = [], + Users = [] + }; + + var response = await _client.PatchAsJsonAsync( + $"organizations/{_organization.Id}/groups/{group.Id}", model); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var updatedGroup = await _factory.GetService().GetByIdAsync(group.Id); + Assert.NotNull(updatedGroup); + Assert.Equal("renamed-via-new-authorization", updatedGroup.Name); + } +} diff --git a/test/Api.Test/AdminConsole/Authorization/GroupsAuthorizationServiceTests.cs b/test/Api.Test/AdminConsole/Authorization/GroupsAuthorizationServiceTests.cs new file mode 100644 index 000000000000..828a1cec2401 --- /dev/null +++ b/test/Api.Test/AdminConsole/Authorization/GroupsAuthorizationServiceTests.cs @@ -0,0 +1,306 @@ +using Bit.Api.AdminConsole.Authorization.Groups; +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Authorization; + +[SutProviderCustomize] +public class GroupsAuthorizationServiceTests +{ + [Theory, BitAutoData] + public async Task AuthorizeUpdateAsync_GroupNotFound_AllUnauthorized( + SutProvider sutProvider, + Guid organizationId, + Guid groupId) + { + sutProvider.GetDependency() + .GetByIdWithCollectionsAsync(groupId) + .Returns(new Tuple>(null, new List())); + + var result = await sutProvider.Sut.AuthorizeUpdateAsync(organizationId, groupId, [], []); + + Assert.False(result.IsSuccess); + } + + [Theory, BitAutoData] + public async Task AuthorizeUpdateAsync_GroupBelongsToDifferentOrganization_AllUnauthorized( + SutProvider sutProvider, + Group group, + Guid organizationId, + Guid groupId) + { + sutProvider.GetDependency() + .GetByIdWithCollectionsAsync(groupId) + .Returns(new Tuple>(group, new List())); + + var result = await sutProvider.Sut.AuthorizeUpdateAsync(organizationId, groupId, [], []); + + Assert.False(result.IsSuccess); + } + + [Theory, BitAutoData] + public async Task AuthorizeUpdateAsync_MissingUserId_AllUnauthorized( + SutProvider sutProvider, + Group group, + Guid groupId) + { + sutProvider.GetDependency() + .GetByIdWithCollectionsAsync(groupId) + .Returns(new Tuple>(group, new List())); + sutProvider.GetDependency().UserId.Returns((Guid?)null); + + var result = await sutProvider.Sut.AuthorizeUpdateAsync(group.OrganizationId, groupId, [], []); + + Assert.False(result.IsSuccess); + } + + [Theory, BitAutoData] + public async Task AuthorizeUpdateAsync_SelfAddToGroup_NotAlreadyMember_Unauthorized( + SutProvider sutProvider, + Group group, + Guid groupId, + Guid userId, + OrganizationUser callerOrganizationUser) + { + sutProvider.GetDependency() + .GetByIdWithCollectionsAsync(groupId) + .Returns(new Tuple>(group, new List())); + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency() + .GetByOrganizationAsync(group.OrganizationId, userId) + .Returns(callerOrganizationUser); + sutProvider.GetDependency() + .GetManyUserIdsByIdAsync(groupId) + .Returns(new List()); + + var result = await sutProvider.Sut.AuthorizeUpdateAsync(group.OrganizationId, groupId, [], [callerOrganizationUser.Id]); + + Assert.False(result.CanAddSelfToGroup); + Assert.False(result.IsSuccess); + } + + [Theory, BitAutoData] + public async Task AuthorizeUpdateAsync_SelfAddToGroup_AllowAdminAccessTrue_Authorized( + SutProvider sutProvider, + Group group, + Guid groupId, + Guid userId, + Guid postedUserId) + { + sutProvider.GetDependency() + .GetByIdWithCollectionsAsync(groupId) + .Returns(new Tuple>(group, new List())); + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(group.OrganizationId) + .Returns(new OrganizationAbility { AllowAdminAccessToAllCollectionItems = true }); + + var result = await sutProvider.Sut.AuthorizeUpdateAsync(group.OrganizationId, groupId, [], [postedUserId]); + + Assert.True(result.CanAddSelfToGroup); + } + + [Theory, BitAutoData] + public async Task AuthorizeUpdateAsync_SelfAlreadyMember_Authorized( + SutProvider sutProvider, + Group group, + Guid groupId, + Guid userId, + OrganizationUser callerOrganizationUser) + { + sutProvider.GetDependency() + .GetByIdWithCollectionsAsync(groupId) + .Returns(new Tuple>(group, new List())); + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency() + .GetByOrganizationAsync(group.OrganizationId, userId) + .Returns(callerOrganizationUser); + sutProvider.GetDependency() + .GetManyUserIdsByIdAsync(groupId) + .Returns(new List { callerOrganizationUser.Id }); + + var result = await sutProvider.Sut.AuthorizeUpdateAsync(group.OrganizationId, groupId, [], [callerOrganizationUser.Id]); + + Assert.True(result.CanAddSelfToGroup); + } + + [Theory, BitAutoData] + public async Task AuthorizeUpdateAsync_CallerIsProviderNotOrgUser_SelfAddCheckSkipped( + SutProvider sutProvider, + Group group, + Guid groupId, + Guid userId, + Guid postedUserId) + { + sutProvider.GetDependency() + .GetByIdWithCollectionsAsync(groupId) + .Returns(new Tuple>(group, new List())); + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency() + .GetByOrganizationAsync(group.OrganizationId, userId) + .Returns((OrganizationUser)null); + + var result = await sutProvider.Sut.AuthorizeUpdateAsync(group.OrganizationId, groupId, [], [postedUserId]); + + Assert.True(result.CanAddSelfToGroup); + } + + [Theory, BitAutoData] + public async Task AuthorizeUpdateAsync_PostedCollectionUnauthorized_RejectsRequest( + SutProvider sutProvider, + Group group, + Guid groupId, + Guid userId, + Guid collectionId, + Collection collection, + CurrentContextOrganization organization) + { + collection.Id = collectionId; + collection.OrganizationId = group.OrganizationId; + organization.Type = OrganizationUserType.User; + organization.Permissions = new Permissions(); + + sutProvider.GetDependency() + .GetByIdWithCollectionsAsync(groupId) + .Returns(new Tuple>(group, new List())); + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(group.OrganizationId).Returns(organization); + sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); + sutProvider.GetDependency() + .GetByIdWithAccessAsync(collectionId) + .Returns(new Tuple(collection, new CollectionAccessDetails { Users = [], Groups = [] })); + + var result = await sutProvider.Sut.AuthorizeUpdateAsync(group.OrganizationId, groupId, [collectionId], []); + + Assert.Contains(collectionId, result.UnauthorizedPostedCollectionIds); + Assert.False(result.IsSuccess); + } + + [Theory, BitAutoData] + public async Task AuthorizeUpdateAsync_CurrentCollectionUnauthorized_PreservedNotRejected( + SutProvider sutProvider, + Group group, + Guid groupId, + Guid userId, + Guid collectionId, + Collection collection, + CurrentContextOrganization organization) + { + collection.Id = collectionId; + collection.OrganizationId = group.OrganizationId; + organization.Type = OrganizationUserType.User; + organization.Permissions = new Permissions(); + + var currentAccess = new List { new() { Id = collectionId } }; + + sutProvider.GetDependency() + .GetByIdWithCollectionsAsync(groupId) + .Returns(new Tuple>(group, currentAccess)); + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(group.OrganizationId).Returns(organization); + sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); + sutProvider.GetDependency() + .GetByIdWithAccessAsync(collectionId) + .Returns(new Tuple(collection, new CollectionAccessDetails { Users = [], Groups = [] })); + + var result = await sutProvider.Sut.AuthorizeUpdateAsync(group.OrganizationId, groupId, [], []); + + Assert.Contains(collectionId, result.ReadonlyCurrentCollectionIds); + Assert.Empty(result.UnauthorizedPostedCollectionIds); + Assert.True(result.IsSuccess); + } + + [Theory, BitAutoData] + public async Task AuthorizeUpdateAsync_NonexistentPostedCollectionId_SilentlySkipped( + SutProvider sutProvider, + Group group, + Guid groupId, + Guid userId, + Guid collectionId) + { + sutProvider.GetDependency() + .GetByIdWithCollectionsAsync(groupId) + .Returns(new Tuple>(group, new List())); + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency() + .GetByIdWithAccessAsync(collectionId) + .Returns(new Tuple(null, new CollectionAccessDetails { Users = [], Groups = [] })); + + var result = await sutProvider.Sut.AuthorizeUpdateAsync(group.OrganizationId, groupId, [collectionId], []); + + Assert.Empty(result.UnauthorizedPostedCollectionIds); + Assert.True(result.IsSuccess); + } + + [Theory, BitAutoData] + public async Task AuthorizeUpdateAsync_WhenCallerManagesCollection_Authorized( + SutProvider sutProvider, + Group group, + Guid groupId, + Guid userId, + Guid collectionId, + Collection collection, + CurrentContextOrganization organization) + { + collection.Id = collectionId; + collection.OrganizationId = group.OrganizationId; + organization.Type = OrganizationUserType.User; + organization.Permissions = new Permissions(); + + sutProvider.GetDependency() + .GetByIdWithCollectionsAsync(groupId) + .Returns(new Tuple>(group, new List())); + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(group.OrganizationId).Returns(organization); + sutProvider.GetDependency() + .GetByIdWithAccessAsync(collectionId) + .Returns(new Tuple(collection, new CollectionAccessDetails { Users = [], Groups = [] })); + sutProvider.GetDependency().GetManyByUserIdAsync(userId) + .Returns(new List { new() { Id = collectionId, Manage = true } }); + + var result = await sutProvider.Sut.AuthorizeUpdateAsync(group.OrganizationId, groupId, [collectionId], []); + + Assert.True(result.IsSuccess); + } + + [Theory, BitAutoData] + public async Task AuthorizeUpdateAsync_WhenProviderUser_FullyAuthorized( + SutProvider sutProvider, + Group group, + Guid groupId, + Guid userId, + Guid collectionId, + Collection collection) + { + collection.Id = collectionId; + collection.OrganizationId = group.OrganizationId; + + sutProvider.GetDependency() + .GetByIdWithCollectionsAsync(groupId) + .Returns(new Tuple>(group, new List())); + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(group.OrganizationId).Returns((CurrentContextOrganization)null); + sutProvider.GetDependency().ProviderUserForOrgAsync(group.OrganizationId).Returns(true); + sutProvider.GetDependency() + .GetByOrganizationAsync(group.OrganizationId, userId) + .Returns((OrganizationUser)null); + sutProvider.GetDependency() + .GetByIdWithAccessAsync(collectionId) + .Returns(new Tuple(collection, new CollectionAccessDetails { Users = [], Groups = [] })); + + var result = await sutProvider.Sut.AuthorizeUpdateAsync(group.OrganizationId, groupId, [collectionId], []); + + Assert.True(result.IsSuccess); + } +} diff --git a/test/Api.Test/AdminConsole/Controllers/GroupsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/GroupsControllerTests.cs index 2ed4ffa5c98d..7a64ebda0ce3 100644 --- a/test/Api.Test/AdminConsole/Controllers/GroupsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/GroupsControllerTests.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using Bit.Api.AdminConsole.Authorization.Collections; +using Bit.Api.AdminConsole.Authorization.Groups; using Bit.Api.AdminConsole.Controllers; using Bit.Api.AdminConsole.Models.Request; using Bit.Core.AdminConsole.AbilitiesCache; @@ -287,4 +288,85 @@ public async Task Post_NotAuthorizedToGiveAccessToCollections_Throws(Organizatio await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .CreateGroupAsync(default, default, default, default); } + + [Theory] + [BitAutoData] + public async Task PatchWithNewAuthorization_GroupNotFound_ThrowsNotFound(Guid orgId, Guid id, GroupRequestModel model, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetByIdWithCollectionsAsync(id) + .Returns(new Tuple>(null, new List())); + + await Assert.ThrowsAsync(() => sutProvider.Sut.PatchWithNewAuthorization(orgId, id, model)); + } + + [Theory] + [BitAutoData] + public async Task PatchWithNewAuthorization_OrgIdMismatch_ThrowsNotFound(Guid orgId, Group group, GroupRequestModel model, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetByIdWithCollectionsAsync(group.Id) + .Returns(new Tuple>(group, new List())); + + await Assert.ThrowsAsync(() => sutProvider.Sut.PatchWithNewAuthorization(orgId, group.Id, model)); + } + + [Theory] + [BitAutoData] + public async Task PatchWithNewAuthorization_CannotAddSelfToGroup_ThrowsBadRequest(Group group, GroupRequestModel model, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetByIdWithCollectionsAsync(group.Id) + .Returns(new Tuple>(group, new List())); + sutProvider.GetDependency() + .AuthorizeUpdateAsync(group.OrganizationId, group.Id, Arg.Any>(), Arg.Any>()) + .Returns(new GroupsAuthorizationResult(false, new HashSet(), new HashSet())); + + await Assert.ThrowsAsync(() => sutProvider.Sut.PatchWithNewAuthorization(group.OrganizationId, group.Id, model)); + } + + [Theory] + [BitAutoData] + public async Task PatchWithNewAuthorization_UnauthorizedPostedCollection_ThrowsNotFound(Group group, GroupRequestModel model, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetByIdWithCollectionsAsync(group.Id) + .Returns(new Tuple>(group, new List())); + sutProvider.GetDependency() + .AuthorizeUpdateAsync(group.OrganizationId, group.Id, Arg.Any>(), Arg.Any>()) + .Returns(new GroupsAuthorizationResult(true, new HashSet { Guid.NewGuid() }, new HashSet())); + + await Assert.ThrowsAsync(() => sutProvider.Sut.PatchWithNewAuthorization(group.OrganizationId, group.Id, model)); + } + + [Theory] + [BitAutoData] + public async Task PatchWithNewAuthorization_Success_MergesReadonlyCollectionsAndSaves(Group group, Organization organization, + GroupRequestModel model, SutProvider sutProvider) + { + var readonlyCollectionAccess = new CollectionAccessSelection { Id = Guid.NewGuid(), ReadOnly = true }; + var currentAccess = new List { readonlyCollectionAccess }; + organization.Id = group.OrganizationId; + + sutProvider.GetDependency().GetByIdWithCollectionsAsync(group.Id) + .Returns(new Tuple>(group, currentAccess)); + sutProvider.GetDependency() + .AuthorizeUpdateAsync(group.OrganizationId, group.Id, Arg.Any>(), Arg.Any>()) + .Returns(new GroupsAuthorizationResult(true, new HashSet(), new HashSet { readonlyCollectionAccess.Id })); + sutProvider.GetDependency().GetByIdAsync(group.OrganizationId).Returns(organization); + + var response = await sutProvider.Sut.PatchWithNewAuthorization(group.OrganizationId, group.Id, model); + + var postedCollectionIds = model.Collections.Select(c => c.Id).ToHashSet(); + + await sutProvider.GetDependency().Received(1).UpdateGroupAsync( + Arg.Is(g => g.Id == group.Id && g.Name == model.Name), + organization, + Arg.Is>(access => + access.Any(a => a.Id == readonlyCollectionAccess.Id) && + postedCollectionIds.All(id => access.Any(a => a.Id == id))), + model.Users); + + Assert.Equal(group.Id, response.Id); + } }