Skip to content

[PM-12475] Add GroupUserAuthorizationHandler and related context for group user …#7514

Open
JaredScar wants to merge 2 commits intomainfrom
ac/pm-12475-implement-auth-handler-for-assigning-users-to-groups
Open

[PM-12475] Add GroupUserAuthorizationHandler and related context for group user …#7514
JaredScar wants to merge 2 commits intomainfrom
ac/pm-12475-implement-auth-handler-for-assigning-users-to-groups

Conversation

@JaredScar
Copy link
Copy Markdown
Contributor

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-12475

📔 Objective

Instead of having duplicated code in places for authorization for assigning users to group authorization, implement an authorization service to be used in both places to de-duplicate (is that a word?) code.

…assignments

- Introduced GroupUserAuthorizationHandler to manage user assignment permissions within groups.
- Added GroupUserAssignmentContext to encapsulate the necessary data for authorization checks.
- Updated GroupsController and OrganizationUsersController to utilize the new authorization logic for user assignments.
- Removed unused dependencies and streamlined authorization checks for better clarity and performance.
@JaredScar JaredScar added the ai-review Request a Claude code review label Apr 20, 2026
@JaredScar JaredScar requested a review from a team as a code owner April 20, 2026 21:08
@JaredScar JaredScar requested a review from eliykat April 20, 2026 21:08
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 20, 2026

🤖 Bitwarden Claude Code Review

Overall Assessment: REQUEST CHANGES

This PR extracts the "admin can't self-assign to a group" check into a new GroupUserAuthorizationHandler and wires it into both GroupsController.Put and OrganizationUsersController.Put. The handler logic is a faithful refactor of the previous inline checks and the DI registration in AuthorizationHandlerCollectionExtensions looks correct. However, the "Run tests" CI job is failing and the existing controller-level tests for the self-assignment paths have not been updated to stub the new IAuthorizationService call, so the refactor currently regresses test coverage.

Code Review Details
  • ⚠️ : Existing tests for self-assignment scenarios not updated to stub the new IAuthorizationService.AuthorizeAsync(..., GroupUserOperations.AssignUsers) call — likely cause of the failing CI run.
    • src/Api/AdminConsole/Controllers/GroupsController.cs:146-152
  • ⚠️ : New GroupUserAuthorizationHandler ships without unit tests, and the controller-level coverage no longer exercises this logic end-to-end.
    • src/Core/AdminConsole/OrganizationFeatures/Groups/Authorization/GroupUserAuthorizationHandler.cs:11-78
  • ⚠️ : orgAbility is dereferenced without a null guard even though GetOrganizationAbilityAsync returns OrganizationAbility?; use the is { AllowAdminAccessToAllCollectionItems: true } pattern already established elsewhere.
    • src/Core/AdminConsole/OrganizationFeatures/Groups/Authorization/GroupUserAuthorizationHandler.cs:37-40

@sonarqubecloud
Copy link
Copy Markdown

Comment on lines +11 to +78
public class GroupUserAuthorizationHandler(
ICurrentContext currentContext,
IApplicationCacheService applicationCacheService,
IOrganizationUserRepository organizationUserRepository,
IGroupRepository groupRepository)
: AuthorizationHandler<GroupUserOperationRequirement, GroupUserAssignmentContext>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
GroupUserOperationRequirement requirement,
GroupUserAssignmentContext resource)
{
var authorized = requirement.Name switch
{
nameof(GroupUserOperations.AssignUsers) => await CanAssignUsersAsync(resource),
_ => false
};

if (authorized)
{
context.Succeed(requirement);
}
}

private async Task<bool> CanAssignUsersAsync(GroupUserAssignmentContext resource)
{
var orgAbility = await applicationCacheService.GetOrganizationAbilityAsync(resource.OrganizationId);

// When admins have unrestricted collection access, self-assignment to groups is permitted.
if (orgAbility.AllowAdminAccessToAllCollectionItems)
{
return true;
}

if (currentContext.UserId is null)
{
return false;
}

var organizationUser = await organizationUserRepository.GetByOrganizationAsync(
resource.OrganizationId, currentContext.UserId.Value);

// Providers are not org members and are exempt from this restriction.
if (organizationUser is null)
{
return true;
}

// If the caller's own OrganizationUser ID is not among the requested users, there is no self-assignment.
if (!resource.RequestedUserIds.Contains(organizationUser.Id))
{
return true;
}

// When a GroupId is provided, check whether the caller is already a member of the group.
// Keeping an existing membership is permitted; only newly adding oneself is blocked.
if (resource.GroupId.HasValue)
{
var currentGroupUsers = await groupRepository.GetManyUserIdsByIdAsync(resource.GroupId.Value);
if (currentGroupUsers.Contains(organizationUser.Id))
{
return true;
}
}

return false;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT: New handler ships without unit tests.

Details and fix

CONTRIBUTING / CLAUDE.md require unit tests for new feature development, and the existing controller-level coverage in GroupsControllerPutTests no longer exercises this logic end-to-end once the authorization check is delegated to IAuthorizationService. Please add a GroupUserAuthorizationHandlerTests class covering at least:

  • AllowAdminAccessToAllCollectionItems = true → succeeds
  • currentContext.UserId is null → fails
  • caller is a provider (org user not found) → succeeds
  • caller's org user id is not in RequestedUserIds → succeeds
  • caller's org user id is in RequestedUserIds, GroupId null → fails
  • caller's org user id is in RequestedUserIds, GroupId set, already a member → succeeds
  • caller's org user id is in RequestedUserIds, GroupId set, not a member → fails

This is also the natural place to move the scenarios currently in GroupsControllerPutTests that rely on mocked IApplicationCacheService / IGroupRepository / IOrganizationUserRepository — the controller tests can then just verify that it calls AuthorizeAsync with the right context and throws/short-circuits based on the result.

Comment on lines +37 to +40
var orgAbility = await applicationCacheService.GetOrganizationAbilityAsync(resource.OrganizationId);

// When admins have unrestricted collection access, self-assignment to groups is permitted.
if (orgAbility.AllowAdminAccessToAllCollectionItems)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT: orgAbility can be null and is dereferenced without a guard.

Details and fix

IApplicationCacheService.GetOrganizationAbilityAsync returns Task<OrganizationAbility?> and returns null when the org id is missing from the cached dictionary (see InMemoryApplicationCacheService.GetOrganizationAbilityAsync). In #nullable enable code, orgAbility.AllowAdminAccessToAllCollectionItems dereferences a possibly-null reference.

If the org ability is unexpectedly absent, the handler will throw NullReferenceException instead of making a deterministic authorization decision. Use the pattern already established in this codebase (e.g. GetCipherPermissionsForUserQuery, BulkCollectionAuthorizationHandler):

Suggested change
var orgAbility = await applicationCacheService.GetOrganizationAbilityAsync(resource.OrganizationId);
// When admins have unrestricted collection access, self-assignment to groups is permitted.
if (orgAbility.AllowAdminAccessToAllCollectionItems)
var orgAbility = await applicationCacheService.GetOrganizationAbilityAsync(resource.OrganizationId);
// When admins have unrestricted collection access, self-assignment to groups is permitted.
if (orgAbility is { AllowAdminAccessToAllCollectionItems: true })
{
return true;
}

This also treats "ability not found" as the restrictive branch rather than an exception, which is the safer default for an authorization decision.

Comment on lines 146 to 152
// Authorization check:
// If admins are not allowed access to all collections, you cannot add yourself to a group.
// No error is thrown for this, we just don't update groups.
var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(orgId);
if (!orgAbility.AllowAdminAccessToAllCollectionItems)
var groupUserAssignment = new GroupUserAssignmentContext(orgId, model.Users, GroupId: id);
if (!(await _authorizationService.AuthorizeAsync(User, groupUserAssignment, GroupUserOperations.AssignUsers)).Succeeded)
{
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.");
}
throw new BadRequestException("You cannot add yourself to groups.");
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT: Existing unit tests for this self-assignment path were not updated and will fail after this change.

Details and fix

test/Api.Test/AdminConsole/Controllers/GroupsControllerPutTests.cs covers the four self-assignment scenarios (Put_UpdateMembers_NoAdminAccess_CannotAddSelfToGroup, ..._AlreadyInGroup_Success, ..._ProviderUser_Success, ..._WithAdminAccess_CanAddSelfToGroup) by mocking IApplicationCacheService, IGroupRepository, IUserService, and IOrganizationUserRepository directly on the controller.

Now that the controller calls _authorizationService.AuthorizeAsync(User, groupUserAssignment, GroupUserOperations.AssignUsers), those mocks are never read by this logic and IAuthorizationService returns a default Task<AuthorizationResult> whose result is null, causing .Succeeded to NRE. The same issue applies to OrganizationUserControllerPutTests.Put_NoAdminAccess_CannotAddSelfToGroups / Put_WithAdminAccess_CanAddSelfToGroups. This is likely why the "Run tests" CI job is failing.

Please either stub the new AuthorizeAsync call in each affected test or migrate the scenarios into a new GroupUserAuthorizationHandlerTests class (preferred — keeps the coverage where the logic now lives).

Comment on lines +11 to +78
public class GroupUserAuthorizationHandler(
ICurrentContext currentContext,
IApplicationCacheService applicationCacheService,
IOrganizationUserRepository organizationUserRepository,
IGroupRepository groupRepository)
: AuthorizationHandler<GroupUserOperationRequirement, GroupUserAssignmentContext>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
GroupUserOperationRequirement requirement,
GroupUserAssignmentContext resource)
{
var authorized = requirement.Name switch
{
nameof(GroupUserOperations.AssignUsers) => await CanAssignUsersAsync(resource),
_ => false
};

if (authorized)
{
context.Succeed(requirement);
}
}

private async Task<bool> CanAssignUsersAsync(GroupUserAssignmentContext resource)
{
var orgAbility = await applicationCacheService.GetOrganizationAbilityAsync(resource.OrganizationId);

// When admins have unrestricted collection access, self-assignment to groups is permitted.
if (orgAbility.AllowAdminAccessToAllCollectionItems)
{
return true;
}

if (currentContext.UserId is null)
{
return false;
}

var organizationUser = await organizationUserRepository.GetByOrganizationAsync(
resource.OrganizationId, currentContext.UserId.Value);

// Providers are not org members and are exempt from this restriction.
if (organizationUser is null)
{
return true;
}

// If the caller's own OrganizationUser ID is not among the requested users, there is no self-assignment.
if (!resource.RequestedUserIds.Contains(organizationUser.Id))
{
return true;
}

// When a GroupId is provided, check whether the caller is already a member of the group.
// Keeping an existing membership is permitted; only newly adding oneself is blocked.
if (resource.GroupId.HasValue)
{
var currentGroupUsers = await groupRepository.GetManyUserIdsByIdAsync(resource.GroupId.Value);
if (currentGroupUsers.Contains(organizationUser.Id))
{
return true;
}
}

return false;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT: New handler ships without unit tests.

Details and fix

CONTRIBUTING / CLAUDE.md require unit tests for new feature development, and the existing controller-level coverage in GroupsControllerPutTests no longer exercises this logic end-to-end once the authorization check is delegated to IAuthorizationService. Please add a GroupUserAuthorizationHandlerTests class covering at least:

  • AllowAdminAccessToAllCollectionItems = true → succeeds
  • currentContext.UserId is null → fails
  • caller is a provider (org user not found) → succeeds
  • caller's org user id is not in RequestedUserIds → succeeds
  • caller's org user id is in RequestedUserIds, GroupId null → fails
  • caller's org user id is in RequestedUserIds, GroupId set, already a member → succeeds
  • caller's org user id is in RequestedUserIds, GroupId set, not a member → fails

This is also the natural place to move the scenarios currently in GroupsControllerPutTests that rely on mocked IApplicationCacheService / IGroupRepository / IOrganizationUserRepository — the controller tests can then just verify that it calls AuthorizeAsync with the right context and throws/short-circuits based on the result.

Comment on lines +37 to +40
var orgAbility = await applicationCacheService.GetOrganizationAbilityAsync(resource.OrganizationId);

// When admins have unrestricted collection access, self-assignment to groups is permitted.
if (orgAbility.AllowAdminAccessToAllCollectionItems)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT: orgAbility can be null and is dereferenced without a guard.

Details and fix

IApplicationCacheService.GetOrganizationAbilityAsync returns Task<OrganizationAbility?> and returns null when the org id is missing from the cached dictionary (see InMemoryApplicationCacheService.GetOrganizationAbilityAsync). In #nullable enable code, orgAbility.AllowAdminAccessToAllCollectionItems dereferences a possibly-null reference.

If the org ability is unexpectedly absent, the handler will throw NullReferenceException instead of making a deterministic authorization decision, and the previous call site in GroupsController.Put had the same latent issue. Use the pattern already established in this codebase (e.g. GetCipherPermissionsForUserQuery, BulkCollectionAuthorizationHandler):

Suggested change
var orgAbility = await applicationCacheService.GetOrganizationAbilityAsync(resource.OrganizationId);
// When admins have unrestricted collection access, self-assignment to groups is permitted.
if (orgAbility.AllowAdminAccessToAllCollectionItems)
var orgAbility = await applicationCacheService.GetOrganizationAbilityAsync(resource.OrganizationId);
// When admins have unrestricted collection access, self-assignment to groups is permitted.
if (orgAbility is { AllowAdminAccessToAllCollectionItems: true })
{
return true;
}

This also treats "ability not found" as the restrictive branch rather than an exception, which is the safer default for an authorization decision.

@github-actions
Copy link
Copy Markdown
Contributor

Logo
Checkmarx One – Scan Summary & Detailse2112969-f961-4c1a-9eb1-8f24d9dd5ee8

Great job! No new security vulnerabilities introduced in this pull request

Copy link
Copy Markdown
Member

@eliykat eliykat left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our resource-based authorization patterns need some improvement. This is already being discussed over in #7442, but isn't fully figured out yet. Let's hold on this until @r-tome has finalized a better pattern, then we can implement it here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Request a Claude code review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants