-
Notifications
You must be signed in to change notification settings - Fork 1.7k
[PM-41270] feat: add CollectionGroup authorization and access command #8132
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
r-tome
wants to merge
4
commits into
ac/pm-12473/collection-user-access-endpoint
from
ac/pm-41270/collection-group-auth-handler
Draft
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
948b753
[PM-41270] feat: extend collection repository to modify group access β¦
r-tome 61a3852
[PM-41270] feat: add authorization rules for changing collection grouβ¦
r-tome 664c77d
[PM-41270] feat: add ModifyCollectionGroupAccessCommand and validator
r-tome 6e27e01
[PM-41270] test: add unit tests for CollectionGroup authorization andβ¦
r-tome File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
8 changes: 8 additions & 0 deletions
8
src/Api/AdminConsole/Authorization/Collections/CollectionGroupAccessResource.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| ο»Ώusing Bit.Core.Entities; | ||
| using Bit.Core.Models.Data; | ||
|
|
||
| namespace Bit.Api.AdminConsole.Authorization.Collections; | ||
|
|
||
| public record CollectionGroupAccessResource( | ||
| Collection Collection, | ||
| CollectionAccessDetails AccessDetails); |
89 changes: 89 additions & 0 deletions
89
src/Api/AdminConsole/Authorization/Collections/CollectionGroupAuthorizationHandler.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| ο»Ώ#nullable enable | ||
| using Bit.Core.AdminConsole.AbilitiesCache; | ||
| using Bit.Core.Context; | ||
| using Bit.Core.Exceptions; | ||
| using Bit.Core.Repositories; | ||
| using Bit.Core.Utilities; | ||
| using Microsoft.AspNetCore.Authorization; | ||
|
|
||
| namespace Bit.Api.AdminConsole.Authorization.Collections; | ||
|
|
||
| /// <summary> | ||
| /// Checks whether the caller can change one or more groups' access to one or more collections. | ||
| /// All the collections must be in the same organization. | ||
| /// </summary> | ||
| public class CollectionGroupAuthorizationHandler | ||
| : BulkAuthorizationHandler<CollectionGroupOperationRequirement, CollectionGroupAccessResource> | ||
| { | ||
| private readonly ICurrentContext _currentContext; | ||
| private readonly ICollectionRepository _collectionRepository; | ||
| private readonly IOrganizationAbilityCacheService _organizationAbilityCacheService; | ||
| private HashSet<Guid>? _managedCollectionIds; | ||
|
|
||
| public CollectionGroupAuthorizationHandler( | ||
| ICurrentContext currentContext, | ||
| ICollectionRepository collectionRepository, | ||
| IOrganizationAbilityCacheService organizationAbilityCacheService) | ||
| { | ||
| _currentContext = currentContext; | ||
| _collectionRepository = collectionRepository; | ||
| _organizationAbilityCacheService = organizationAbilityCacheService; | ||
| } | ||
|
|
||
| protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, | ||
| CollectionGroupOperationRequirement requirement, ICollection<CollectionGroupAccessResource> resources) | ||
| { | ||
| if (resources.Count == 0) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| if (!_currentContext.UserId.HasValue) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| var organizationId = resources.First().Collection.OrganizationId; | ||
| if (resources.Any(r => r.Collection.OrganizationId != organizationId)) | ||
| { | ||
| throw new BadRequestException("Requested collections must belong to the same organization."); | ||
| } | ||
|
|
||
| var organization = _currentContext.GetOrganization(organizationId); | ||
| var organizationAbility = await _organizationAbilityCacheService.GetOrganizationAbilityAsync(organizationId); | ||
| var allowAdminAccessToAllCollectionItems = organizationAbility is { AllowAdminAccessToAllCollectionItems: true }; | ||
|
|
||
| var authorized = true; | ||
| foreach (var resource in resources) | ||
| { | ||
| var callerManagesCollection = await CallerManagesCollectionAsync(resource.Collection.Id); | ||
| if (!CollectionGroupAuthorizationRules.CanModifyGroupAccess( | ||
| resource.AccessDetails, organization, allowAdminAccessToAllCollectionItems, callerManagesCollection)) | ||
| { | ||
| authorized = false; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| if (!authorized) | ||
| { | ||
| authorized = await _currentContext.ProviderUserForOrgAsync(organizationId); | ||
| } | ||
|
|
||
| if (authorized) | ||
| { | ||
| context.Succeed(requirement); | ||
| } | ||
| } | ||
|
|
||
| private async Task<bool> CallerManagesCollectionAsync(Guid collectionId) | ||
| { | ||
| if (_managedCollectionIds == null) | ||
| { | ||
| var callerCollections = await _collectionRepository.GetManyByUserIdAsync(_currentContext.UserId!.Value); | ||
| _managedCollectionIds = callerCollections.Where(c => c.Manage).Select(c => c.Id).ToHashSet(); | ||
| } | ||
|
|
||
| return _managedCollectionIds.Contains(collectionId); | ||
| } | ||
| } |
52 changes: 52 additions & 0 deletions
52
src/Api/AdminConsole/Authorization/Collections/CollectionGroupAuthorizationRules.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| ο»Ώ#nullable enable | ||
| using Bit.Core.Context; | ||
| using Bit.Core.Models.Data; | ||
|
|
||
| namespace Bit.Api.AdminConsole.Authorization.Collections; | ||
|
|
||
| /// <summary> | ||
| /// Decides whether a user can change a group's access to a collection. | ||
| /// </summary> | ||
| public static class CollectionGroupAuthorizationRules | ||
| { | ||
| /// <summary> | ||
| /// Returns true if the acting user can add, change, or remove a group's access to this collection. | ||
| /// <paramref name="callerManagesCollection"/> covers <c>Manage</c> granted directly or through a group. | ||
| /// </summary> | ||
| public static bool CanModifyGroupAccess( | ||
| CollectionAccessDetails accessDetails, | ||
| CurrentContextOrganization? organization, | ||
| bool allowAdminAccessToAllCollectionItems, | ||
| bool callerManagesCollection) | ||
| { | ||
| if (organization is { Permissions.EditAnyCollection: true }) | ||
| { | ||
| return true; | ||
| } | ||
|
|
||
| if (allowAdminAccessToAllCollectionItems && organization is { Permissions.ManageUsers: true }) | ||
| { | ||
| return true; | ||
| } | ||
|
|
||
| if (allowAdminAccessToAllCollectionItems && organization is { IsAdminOrOwner: true }) | ||
| { | ||
| return true; | ||
| } | ||
|
|
||
| if (callerManagesCollection) | ||
| { | ||
| return true; | ||
| } | ||
|
|
||
| // Owners and Admins can still manage an orphaned collection even when | ||
| // AllowAdminAccessToAllCollectionItems is off. | ||
| if (organization is not { IsAdminOrOwner: true }) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| var isOrphaned = !accessDetails.Users.Any(u => u.Manage) && !accessDetails.Groups.Any(g => g.Manage); | ||
| return isOrphaned; | ||
| } | ||
| } | ||
12 changes: 12 additions & 0 deletions
12
src/Api/AdminConsole/Authorization/Collections/CollectionGroupOperations.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| ο»Ώusing Microsoft.AspNetCore.Authorization.Infrastructure; | ||
|
|
||
| namespace Bit.Api.AdminConsole.Authorization.Collections; | ||
|
|
||
| public class CollectionGroupOperationRequirement : OperationAuthorizationRequirement { } | ||
|
|
||
| public static class CollectionGroupOperations | ||
| { | ||
| public static readonly CollectionGroupOperationRequirement Create = new() { Name = nameof(Create) }; | ||
| public static readonly CollectionGroupOperationRequirement Update = new() { Name = nameof(Update) }; | ||
| public static readonly CollectionGroupOperationRequirement Delete = new() { Name = nameof(Delete) }; | ||
| } |
13 changes: 13 additions & 0 deletions
13
src/Core/AdminConsole/OrganizationFeatures/Collections/ModifyGroupAccess/Errors.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| ο»Ώusing Bit.Core.AdminConsole.Utilities.v2; | ||
|
|
||
| namespace Bit.Core.AdminConsole.OrganizationFeatures.Collections.ModifyGroupAccess; | ||
|
|
||
| public record DuplicateGroupId() : BadRequestError("A group id cannot be listed more than once within add or update."); | ||
| public record OverlappingGroupId() : BadRequestError("A group id cannot appear in more than one of add, update, or remove."); | ||
| public record CannotModifyDefaultUserCollectionAccess() : BadRequestError("You cannot modify group access on a collection with the type as DefaultUserCollection."); | ||
| public record GroupAlreadyHasAccess() : BadRequestError("Cannot add access for a group that already has access to this collection."); | ||
| public record GroupDoesNotHaveAccess() : BadRequestError("Cannot update access for a group that does not currently have access to this collection."); | ||
| public record GroupsNotFound() : BadRequestError("One or more groups do not exist."); | ||
| public record GroupsNotInOrganization() : BadRequestError("One or more groups do not belong to the same organization as the collection being assigned."); | ||
| public record NoRemainingManageAccess() : BadRequestError("At least one member or group must have can manage permission."); | ||
| public record InvalidManageAssociation() : BadRequestError("The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true."); |
14 changes: 14 additions & 0 deletions
14
...OrganizationFeatures/Collections/ModifyGroupAccess/IModifyCollectionGroupAccessCommand.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| ο»Ώusing Bit.Core.AdminConsole.Utilities.v2.Results; | ||
|
|
||
| namespace Bit.Core.AdminConsole.OrganizationFeatures.Collections.ModifyGroupAccess; | ||
|
|
||
| /// <summary> | ||
| /// Applies an add/update/remove delta to one or more collections' group access. | ||
| /// </summary> | ||
| public interface IModifyCollectionGroupAccessCommand | ||
| { | ||
| /// <summary> | ||
| /// Validates and persists the delta. | ||
| /// </summary> | ||
| Task<CommandResult> ModifyAsync(ModifyCollectionGroupAccessRequest request); | ||
| } |
11 changes: 11 additions & 0 deletions
11
...ganizationFeatures/Collections/ModifyGroupAccess/IModifyCollectionGroupAccessValidator.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| ο»Ώusing Bit.Core.AdminConsole.Utilities.v2.Validation; | ||
|
|
||
| namespace Bit.Core.AdminConsole.OrganizationFeatures.Collections.ModifyGroupAccess; | ||
|
|
||
| /// <summary> | ||
| /// Checks whether an add/update/remove delta to collection group access may be applied. | ||
| /// </summary> | ||
| public interface IModifyCollectionGroupAccessValidator | ||
| { | ||
| Task<ValidationResult<ModifyCollectionGroupAccessRequest>> ValidateAsync(ModifyCollectionGroupAccessRequest request); | ||
| } |
48 changes: 48 additions & 0 deletions
48
.../OrganizationFeatures/Collections/ModifyGroupAccess/ModifyCollectionGroupAccessCommand.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| ο»Ώusing Bit.Core.AdminConsole.Utilities.v2.Results; | ||
| using Bit.Core.Enums; | ||
| using Bit.Core.Repositories; | ||
| using Bit.Core.Services; | ||
| using OneOf.Types; | ||
|
|
||
| namespace Bit.Core.AdminConsole.OrganizationFeatures.Collections.ModifyGroupAccess; | ||
|
|
||
| public class ModifyCollectionGroupAccessCommand( | ||
| ICollectionRepository collectionRepository, | ||
| IModifyCollectionGroupAccessValidator validator, | ||
| IEventService eventService, | ||
| TimeProvider timeProvider) : IModifyCollectionGroupAccessCommand | ||
| { | ||
| public async Task<CommandResult> ModifyAsync(ModifyCollectionGroupAccessRequest request) | ||
| { | ||
| // Nothing to do, so skip saving and logging. | ||
| if (request.Add.Count == 0 && request.Update.Count == 0 && request.Remove.Count == 0) | ||
| { | ||
| return new None(); | ||
| } | ||
|
|
||
| var validationResult = await validator.ValidateAsync(request); | ||
| if (validationResult.IsError) | ||
| { | ||
| return validationResult.AsError; | ||
| } | ||
|
|
||
| var revisionDate = timeProvider.GetUtcNow().UtcDateTime; | ||
| var upserts = request.Add.Concat(request.Update).ToList(); | ||
|
|
||
| // Drop ids that aren't members, so we don't bump an unrelated group's revision date. | ||
| var existingGroupIds = request.Targets | ||
| .SelectMany(t => t.AccessDetails.Groups.Select(g => g.Id)) | ||
| .ToHashSet(); | ||
| var removeIds = request.Remove.Where(existingGroupIds.Contains).ToList(); | ||
|
|
||
| var organizationId = request.Targets.First().Collection.OrganizationId; | ||
| var collectionIds = request.Targets.Select(t => t.Collection.Id).ToList(); | ||
|
|
||
| await collectionRepository.ModifyGroupAccessAsync(organizationId, collectionIds, upserts, removeIds, revisionDate); | ||
|
|
||
| await eventService.LogCollectionEventsAsync( | ||
| request.Targets.Select(t => (t.Collection, EventType.Collection_Updated, (DateTime?)revisionDate))); | ||
|
|
||
| return new None(); | ||
| } | ||
| } |
14 changes: 14 additions & 0 deletions
14
.../OrganizationFeatures/Collections/ModifyGroupAccess/ModifyCollectionGroupAccessRequest.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| ο»Ώusing Bit.Core.Entities; | ||
| using Bit.Core.Models.Data; | ||
|
|
||
| namespace Bit.Core.AdminConsole.OrganizationFeatures.Collections.ModifyGroupAccess; | ||
|
|
||
| public record CollectionGroupAccessTarget(Collection Collection, CollectionAccessDetails AccessDetails); | ||
|
|
||
| public record ModifyCollectionGroupAccessRequest( | ||
| IReadOnlyCollection<CollectionGroupAccessTarget> Targets, | ||
| IReadOnlyCollection<CollectionAccessSelection> Add, | ||
| IReadOnlyCollection<CollectionAccessSelection> Update, | ||
| IReadOnlyCollection<Guid> Remove, | ||
| Guid? PerformingOrganizationUserId, | ||
| bool AllowAdminAccessToAllCollectionItems); |
105 changes: 105 additions & 0 deletions
105
...rganizationFeatures/Collections/ModifyGroupAccess/ModifyCollectionGroupAccessValidator.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| ο»Ώusing Bit.Core.AdminConsole.Repositories; | ||
| using Bit.Core.AdminConsole.Utilities.v2.Validation; | ||
| using Bit.Core.Enums; | ||
| using Bit.Core.Models.Data; | ||
| using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; | ||
|
|
||
| namespace Bit.Core.AdminConsole.OrganizationFeatures.Collections.ModifyGroupAccess; | ||
|
|
||
| public class ModifyCollectionGroupAccessValidator(IGroupRepository groupRepository) | ||
| : IModifyCollectionGroupAccessValidator | ||
| { | ||
| public async Task<ValidationResult<ModifyCollectionGroupAccessRequest>> ValidateAsync( | ||
| ModifyCollectionGroupAccessRequest request) | ||
| { | ||
| if (HasDuplicateIds(request.Add) || HasDuplicateIds(request.Update)) | ||
| { | ||
| return Invalid(request, new DuplicateGroupId()); | ||
| } | ||
|
|
||
| var addIds = request.Add.Select(a => a.Id).ToHashSet(); | ||
| var updateIds = request.Update.Select(u => u.Id).ToHashSet(); | ||
| var removeIds = request.Remove.ToHashSet(); | ||
|
|
||
| if (addIds.Overlaps(updateIds) || addIds.Overlaps(removeIds) || updateIds.Overlaps(removeIds)) | ||
| { | ||
| return Invalid(request, new OverlappingGroupId()); | ||
| } | ||
|
|
||
| if (request.Add.Concat(request.Update).Any(s => s.Manage && (s.ReadOnly || s.HidePasswords))) | ||
| { | ||
| return Invalid(request, new InvalidManageAssociation()); | ||
| } | ||
|
|
||
| if (request.Targets.Any(t => t.Collection.Type == CollectionType.DefaultUserCollection)) | ||
| { | ||
| return Invalid(request, new CannotModifyDefaultUserCollectionAccess()); | ||
| } | ||
|
|
||
| // Only meaningful for a single collection: across several, a group may already have access to one | ||
| // target but not another. | ||
| if (request.Targets.Count == 1) | ||
| { | ||
| var existingIds = request.Targets.Single().AccessDetails.Groups.Select(g => g.Id).ToHashSet(); | ||
| if (addIds.Any(existingIds.Contains)) | ||
| { | ||
| return Invalid(request, new GroupAlreadyHasAccess()); | ||
| } | ||
|
|
||
| if (updateIds.Any(id => !existingIds.Contains(id))) | ||
| { | ||
| return Invalid(request, new GroupDoesNotHaveAccess()); | ||
| } | ||
| } | ||
|
|
||
| var upsertIds = addIds.Concat(updateIds).ToList(); | ||
| if (upsertIds.Count > 0) | ||
| { | ||
| var organizationId = request.Targets.First().Collection.OrganizationId; | ||
| var groups = await groupRepository.GetManyByManyIds(upsertIds); | ||
| if (groups.Count != upsertIds.Count) | ||
| { | ||
| return Invalid(request, new GroupsNotFound()); | ||
| } | ||
|
|
||
| if (groups.Any(g => g.OrganizationId != organizationId)) | ||
| { | ||
| return Invalid(request, new GroupsNotInOrganization()); | ||
| } | ||
| } | ||
|
|
||
| if (!request.AllowAdminAccessToAllCollectionItems | ||
| && request.Targets.Any(t => !HasRemainingManageAccess(t, request, removeIds))) | ||
| { | ||
| return Invalid(request, new NoRemainingManageAccess()); | ||
| } | ||
|
|
||
| return Valid(request); | ||
| } | ||
|
|
||
| private static bool HasRemainingManageAccess( | ||
| CollectionGroupAccessTarget target, ModifyCollectionGroupAccessRequest request, HashSet<Guid> removeIds) | ||
| { | ||
| if (target.AccessDetails.Users.Any(u => u.Manage)) | ||
| { | ||
| return true; | ||
| } | ||
|
|
||
| var existingIds = target.AccessDetails.Groups.Select(g => g.Id).ToHashSet(); | ||
| var updatedById = request.Update.ToDictionary(u => u.Id); | ||
| var finalGroups = target.AccessDetails.Groups | ||
| .Where(g => !removeIds.Contains(g.Id)) | ||
| .Select(g => updatedById.GetValueOrDefault(g.Id, g)) | ||
| .Concat(request.Add) | ||
| // An Update entry grants access on targets the group isn't a member of, so it counts as an Add here. | ||
| .Concat(request.Update.Where(u => !existingIds.Contains(u.Id))); | ||
|
|
||
| return finalGroups.Any(g => g.Manage); | ||
| } | ||
|
|
||
| private static bool HasDuplicateIds(IReadOnlyCollection<CollectionAccessSelection> selections) | ||
| { | ||
| var ids = selections.Select(s => s.Id).ToList(); | ||
| return ids.Count != ids.Distinct().Count(); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
β CRITICAL: Group-access rule checks
Permissions.ManageUsersinstead ofPermissions.ManageGroupsDetails and fix
This looks like a copy-paste carry-over from
CollectionUserAuthorizationRules.CanModifyUserAccess(the two classes are otherwise byte-identical). The established rule for modifying a group's collection access isManageGroupsβ seeBulkCollectionAuthorizationHandler:As written, once PM-41448 wires up the endpoint:
ManageUsersbut notManageGroupsgains the ability to change group access (privilege escalation relative to the existingModifyGroupAccessrule);ManageGroupsloses it (regression).Suggested change:
CollectionGroupAuthorizationRulesTests.CanModifyGroupAccess_WithManageUsersPermission_AllowAdminAccessTrue_Successand..._CustomUserWithManageUsersPermission_AllowAdminAccessFalse_Failurewere also copied over and will need renaming/retargeting toManageGroups.Related:
CollectionGroupAuthorizationHandler,CollectionGroupAccessResource, andCollectionGroupAuthorizationRulesare exact duplicates of theirCollectionUser*counterparts apart from type names. Worth considering a shared generic base (e.g. parameterised on the permission to check) so a single authorization rule change can't silently diverge between the two paths again.