[PM-12473] refactor: add CollectionAuthorizationService and wire it into the Update endpoint behind a flag - #8211
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #8211 +/- ##
==========================================
+ Coverage 63.22% 63.25% +0.02%
==========================================
Files 2381 2383 +2
Lines 103757 103845 +88
Branches 9385 9409 +24
==========================================
+ Hits 65604 65684 +80
- Misses 35924 35928 +4
- Partials 2229 2233 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
3a2cd25 to
56c0576
Compare
Introduces ICollectionAuthorizationService/CollectionAuthorizationService and the shared static CollectionRules class, replacing ASP.NET's IAuthorizationHandler for fine-grained collection-access authorization with a plain constructor-injected service that fetches its own data and calls static rules, per architecture review on PR #8075. Ships as groundwork only -- no controller or endpoint wires it in yet. Also fixes a latent bug where CollectionRules.CanModifyGroupAccess checked Permissions.ManageUsers instead of Permissions.ManageGroups.
Use organization.Type is Owner or Admin directly at call sites instead of the wrapper property. Also trims XML doc comments in the new authorization files down to single-line summaries, matching sibling files like CollectionPermissions.cs.
56c0576 to
896ca86
Compare
CanModifyUserAccess/CanModifyGroupAccess are structural supersets of CanUpdate in CollectionRules, so their AND (IsSuccess) was always exactly equal to CanUpdateCollection alone, and nothing read the individual flags. Drops CollectionAuthorizationResult and returns Task<bool> directly, matching CollectionPermissions.CanCreate's existing bool-returning precedent. Still computes all three checks internally, since they remain three distinct permissions even though today's rule definitions happen to collapse their AND.
🤖 Bitwarden Claude Code ReviewOverall Assessment: APPROVE Re-reviewed after commit Code Review DetailsNo new findings. Previously raised and still open:
Resolved by earlier commits: the unconditional PR Metadata Assessment
|
| if (callerManagesCollection) | ||
| { | ||
| return true; | ||
| } | ||
|
|
||
| // Owners/Admins can still manage an orphaned collection even when AllowAdminAccessToAllCollectionItems is off. | ||
| if (organization is not { Type: OrganizationUserType.Owner or OrganizationUserType.Admin }) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| var isOrphaned = !accessDetails.Users.Any(u => u.Manage) && !accessDetails.Groups.Any(g => g.Manage); | ||
| return isOrphaned; |
There was a problem hiding this comment.
♻️ DEBT: The shared tail is copied into all three rules, which also makes the service's three-way && equivalent to CanUpdate alone.
Details and fix
The callerManagesCollection check, the Owner/Admin gate, and the isOrphaned computation appear verbatim in CanUpdate, CanModifyUserAccess, and CanModifyGroupAccess. Any future change to "orphaned" semantics or to the Owner/Admin fallback has to land in three places to stay consistent.
BulkCollectionAuthorizationHandler composed these instead — CanUpdateUserAccessAsync was bypass || CanUpdateCollectionAsync. The same shape works here:
public static bool CanModifyUserAccess(
CollectionAccessDetails accessDetails,
CurrentContextOrganization? organization,
bool allowAdminAccessToAllCollectionItems,
bool callerManagesCollection)
{
if (allowAdminAccessToAllCollectionItems && organization is { Permissions.ManageUsers: true })
{
return true;
}
return CanUpdate(accessDetails, organization, allowAdminAccessToAllCollectionItems, callerManagesCollection);
}Related consequence at CollectionAuthorizationService.cs:36: every branch that makes CanUpdate return true is also a branch of the other two rules, so canUpdate && canModifyUserAccess && canModifyGroupAccess can never differ from canUpdate. That's fine if the AND is deliberate future-proofing, but as written it reads as though the ManageUsers/ManageGroups bypasses are reachable through this service when they never are.
| var organizationAbility = await organizationAbilityCacheService.GetOrganizationAbilityAsync(organizationId); | ||
| var allowAdminAccessToAllCollectionItems = organizationAbility is { AllowAdminAccessToAllCollectionItems: true }; | ||
|
|
||
| var callerManagesCollection = await CallerManagesCollectionAsync(currentContext.UserId.Value, collectionId); |
There was a problem hiding this comment.
🎨 SUGGESTED: This issues an extra query on every authorization call, even when the org-level branches already decide the outcome.
Details and fix
CallerManagesCollectionAsync runs Collection_ReadByUserId, which returns every collection the caller can access across all organizations, before any rule is evaluated. For the common admin paths — EditAnyCollection, or Owner/Admin with AllowAdminAccessToAllCollectionItems — the value is never consulted, so this is a second round trip per request with no effect on the result.
BulkCollectionAuthorizationHandler.CanManageCollectionsAsync deferred that query until the earlier short-circuits had failed. Deferring it here too (for example, evaluating the organization-level branches first and only resolving callerManagesCollection when they don't already grant access) keeps the hot path at one query.
Note that GetByIdWithPermissionsAsync looks like a one-query alternative but is not a drop-in: Collection_ReadByIdWithPermissions does not filter on OrganizationUser.Status = Confirmed or Organization.Enabled, whereas UserCollectionDetails does.
Nullable reference types are already enabled project-wide via
Directory.Build.props, so the per-file directive was redundant. Also fixes
CollectionAuthorizationServiceTests names to match the established
{Method}_When{Condition}_{Success|NoSuccess} convention used by sibling
files (BulkCollectionAuthorizationHandlerTests, CollectionPermissionsTests).
…ides AuthorizeUpdateAsync always fetched the caller's managed collections before evaluating any rule, even when EditAnyCollection or an admin-access bypass already made the result true. Try the rules with callerManagesCollection false first and only fetch the real value when that pass isn't enough.
| namespace Bit.Api.Test.AdminConsole.Authorization; | ||
|
|
||
| public class CollectionRulesTests | ||
| { |
There was a problem hiding this comment.
♻️ DEBT: CollectionRules.CanUpdate has no direct tests, leaving its two admin grant paths uncovered everywhere.
Details and fix
CanUpdate is the rule that effectively decides AuthorizeUpdateAsync (the other two rules are supersets of it), but this file only covers CanModifyUserAccess and CanModifyGroupAccess — there is no CanUpdate_* test.
CollectionAuthorizationServiceTests doesn't fill the gap: GetOrganizationAbilityAsync is never stubbed, so organizationAbility is null and allowAdminAccessToAllCollectionItems is false in every service test. That leaves two grant branches with no coverage in either suite:
allowAdminAccessToAllCollectionItems: truewithOwner/Admin(CollectionRules.cs:26)- orphaned collection with
Owner/AdminwhileallowAdminAccessToAllCollectionItems: false(CollectionRules.cs:42)
Mirroring the existing CanModifyUserAccess theories for CanUpdate covers both, and would also close the codecov gap reported on this PR. Adding one service-level test with an OrganizationAbility { AllowAdminAccessToAllCollectionItems = true } would additionally verify the ability lookup is wired to the right flag.
…nc into Put behind a flag
🎟️ Tracking
https://bitwarden.atlassian.net/browse/PM-12473
📔 Objective
Our first goal was different. We wanted to split
BulkCollectionAuthorizationHandlerinto smaller pieces. One piece would checkCollectionUseraccess. Another piece would checkCollectionGroupaccess.We built these as two new classes:
CollectionUserAuthorizationHandlerandCollectionGroupAuthorizationHandler. Both use ASP.NET'sIAuthorizationHandlerframework.This design became too complex. The controller had to fetch all the data first. Then it had to pass pieces of that data to each handler. A reviewer flagged this problem on PR #8075.
So we built a new pattern instead. We call it
[Resource]AuthorizationService. Each service fetches its own data. Then it calls a shared staticRulesclass. This class is the single source of truth for the decision.This PR adds the first service in this new pattern:
ICollectionAuthorizationService. It calls a shared class namedCollectionRules.CollectionRuleshas three methods:CanUpdate,CanModifyUserAccess, andCanModifyGroupAccess.This PR does not change the
Authorize<T>role checks.This PR also fixes a bug. The
CanModifyGroupAccessmethod checkedPermissions.ManageUsers. It should checkPermissions.ManageGroups.PUT organizations/{orgId}/collections/{id}now callsAuthorizeUpdateAsync. This sits behind a new feature flag,pm-35160-authorization-services. The oldIAuthorizationHandlercheck stays as the fallback when the flag is off.AuthorizeModifyUserAccessAsyncandAuthorizeModifyGroupAccessAsynchave no caller yet. The plan is to wire them into the endpoints that still callBulkCollectionAuthorizationHandlerforModifyUserAccess/ModifyGroupAccesstoday, inCollectionsController,GroupsController, andOrganizationUsersController. That's follow-up work in a later PR.📸 Screenshots
N/A