Skip to content

[PM-41270] refactor: add GroupsAuthorizationService and wire it into GroupsController - #8270

Draft
r-tome wants to merge 1 commit into
ac/pm-42605/collection-bulk-access-authorizationfrom
ac/pm-41270/groups-authorization-service
Draft

[PM-41270] refactor: add GroupsAuthorizationService and wire it into GroupsController#8270
r-tome wants to merge 1 commit into
ac/pm-42605/collection-bulk-access-authorizationfrom
ac/pm-41270/groups-authorization-service

Conversation

@r-tome

@r-tome r-tome commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

🎟️ Tracking

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

Third in the stack, after #8211 (PM-12473) and #8268 (PM-42605).

📔 Objective

GroupsController authorized collection access with BulkCollectionAuthorizationHandler
in three places: a bulk check in Post, and in Put a per-collection loop over
the posted collections plus a second loop over the group's current collections.
Put also held the inline check that a caller cannot add themselves to a group.

Behind pm-35160-authorization-services all four move into
GroupsAuthorizationService. It calls
ICollectionAuthorizationService.AuthorizeModifyGroupAccessManyAsync once for the
posted collections and once for the current ones, so both loops become two batched
calls. It does not call CollectionRules itself — callerManagesCollection and
isOrphaned belong to the collection, not to the group.

Put is restructured so both paths share one tail: each returns the set of current
collections the caller cannot change, and collectionsToSave is built once. The
flag-off branch is the old body moved unchanged.

Behavior changes on the flag-on path

  • Unknown and cross-organization posted collection ids now return 404. They used
    to be dropped by GetManyByManyIdsAsync, never authorized, and then written to the
    group anyway. This is the tightening PM-41270 asks for. Post gains the same check.
    The web client filters read-only selections out before submitting, so it cannot trip
    this.
  • The route orgId is now authoritative. The old handler took its target
    organization from resources.First().OrganizationId, so a caller who managed a
    collection in another organization could have it written onto a group in this one.
  • A null OrganizationAbility threw a NullReferenceException and now takes the
    restrictive branch. A null posted user list threw and is now treated as empty.

Creating a group keeps its current behavior of letting the caller add themselves.
Post has never had that rule, and it is not watertight on Put either — a caller
can create an empty group with themselves in it, then add collections as an existing
member. Unchanged here, and worth its own ticket.

Tests

Put had no tests at all. Four flag-off characterization tests pin the existing
behavior first, including the self-add BadRequestException and the
preserve-unauthorized-access path. Nine service tests and five flag-on controller
tests follow.

📸 Screenshots

N/A

…roller

GroupsController authorized collection access with the old handler in three
places: a bulk check in Post, and in Put a per-collection loop over the posted
collections plus a second loop over the group's current collections. Put also
held the inline check that a caller cannot add themselves to a group.

Behind pm-35160-authorization-services all four move into
GroupsAuthorizationService. The service calls
ICollectionAuthorizationService.AuthorizeModifyGroupAccessManyAsync once for
the posted collections and once for the current ones, so both loops become two
batched calls. It does not call CollectionRules itself: callerManagesCollection
and isOrphaned belong to the collection, not to the group.

One deliberate behavior change on the flag-on path, per the ticket. A posted
collection id that does not exist, or that belongs to another organization, was
dropped by GetManyByManyIdsAsync, never authorized, and then saved anyway. It
now returns a 404. Post gains the same check.

Two smaller differences, both safer than the old path. A null OrganizationAbility
threw a NullReferenceException and now evaluates the rule instead. A null posted
user list threw and is now treated as empty.

Creating a group keeps its current behavior of allowing the caller to add
themselves. A new group can only reach collections the caller was already
authorized for, so joining it grants no access they did not have.
@r-tome r-tome added the ai-review Request a Claude code review label Aug 27, 2026
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

Reviewed the new GroupsAuthorizationService and its wiring into GroupsController.Post and Put behind pm-35160-authorization-services, against the stacked base ac/pm-42605/collection-bulk-access-authorization. I traced both flag branches of Put and confirmed the shared tail preserves the original ordering (self-add BadRequestException before the collection NotFoundException) and the original collectionsToSave composition; the flag-off branch is the prior body moved intact. The self-add rule in CanAddSelfToGroupAsync is logically equivalent to the inline version it replaces — currentContext.UserId and IUserService.GetProperUserId both resolve the sub claim — with the documented null-OrganizationAbility and null-user-list cases now handled instead of throwing. The tightened 404 on unknown and cross-organization posted collection ids, and the switch to the route orgId as the authorization target, are both narrowing changes and are covered by the new service and controller tests.

Code Review Details

No findings.

@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.75281% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.39%. Comparing base (f4d5736) to head (0e07257).

Files with missing lines Patch % Lines
...c/Api/AdminConsole/Controllers/GroupsController.cs 96.15% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@                                  Coverage Diff                                  @@
##           ac/pm-42605/collection-bulk-access-authorization    #8270       +/-   ##
=====================================================================================
+ Coverage                                                  0   63.39%   +63.39%     
=====================================================================================
  Files                                                     0     2434     +2434     
  Lines                                                     0   105110   +105110     
  Branches                                                  0     9541     +9541     
=====================================================================================
+ Hits                                                      0    66630    +66630     
- Misses                                                    0    36199    +36199     
- Partials                                                  0     2281     +2281     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@eliykat eliykat left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is great!

/// Determines if the caller can save the group's collection access and its members.
/// </summary>
/// <param name="organizationId">The ID of the organization that owns the group.</param>
/// <param name="groupId">The ID of the group to update, or null when the group is being created.</param>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The nullable parameter is doing too much here: a caller could easily leave it out or pass a nullable Guid when they actually intend to update an existing collection, and then it bypasses permission checks.

I recommend separate interfaces: AuthorizeUpdateAsync and AuthorizeCreateAsync.

Comment on lines +39 to +44
// Creating a group has never had this rule. It is not watertight on update either: a caller can create an
// empty group with themselves in it, then add collections through update as an existing member.
if (groupId is null)
{
return true;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Example of where separate create vs. update paths would work better. The create path just never needs this logic.

return true;
}

// A provider is not an organization member, so it has no organization user to add.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Not true. A provider can still be an organization member, although to be honest I'm not sure if/how this rule applies to providers. Please double check.

Comment on lines +18 to +23
Task<GroupsAuthorizationResult> AuthorizeSaveAsync(
Guid organizationId,
Guid? groupId,
IReadOnlyCollection<Guid> postedCollectionIds,
IReadOnlyCollection<Guid> currentCollectionIds,
IReadOnlyCollection<Guid> postedUserIds);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

OK, so this is the hard part. We want to make this a good interface while still fitting the needs of the slightly weird controller logic/tech debt. But I also agree that fixing all that controller stuff is out of scope here - let's just do what we can.

I suggest:

  • inputs: remove currentCollectionIds - this is data the authz service should fetch for itself.
  • outputs: make the return object more straightforwardly useful. It could return:
    • if authz fails: Error authorizationError - a strongly typed error, either "cannot add yourself to a group" or a general NotFoundError. Covers all failure states, controller can just throw this if present.
    • if authz succeeds: the fully reconciled list of collectionIds to save. (That is: posted collectionIds + unauthorized collectionIds.) Figuring this out is basically an authz decision, so let's handle it within this service. Then the controller can pass this into the command.

You could use a OneOf for this, or just a simple record with the 2 fields that you check in order.

Comment on lines +17 to +18
Guid organizationId,
Guid? groupId,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We need to make sure that the group belongs to the organization. To help with this, I also think it would be OK to pass in the whole Group object.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This should also do the role check, i.e. you should have a role that lets you edit groups.

This is duplicative with the Authorize attribute and the collectionAuthzService logic, which also check the role. But each layer should stand on its own and fully encapsulate its authz logic, and checking claims is essentially free, so we may as well.

Comment on lines +184 to +193
// The client only sends collections that the saving user has permissions to edit.
// We need to combine these with collections that the user doesn't have permissions for, so that we don't
// accidentally overwrite those
var editedCollectionAccess = model.Collections
.Select(c => c.ToSelectionReadOnly());
var readonlyCollectionAccess = currentAccess
.Where(ca => readonlyCollectionIds.Contains(ca.Id));
var collectionsToSave = editedCollectionAccess
.Concat(readonlyCollectionAccess)
.ToList();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Further to my previous comment: this would all shift inside the authz service - at least for the new code path.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think integration tests are more useful for authorization, as it depends heavily on the state of the system. It's easy to mock the wrong thing and have your tests give you false negatives. It'll also be the most resilient to our refactoring.

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

2 participants