Skip to content

[PM-12473] refactor: add CollectionAuthorizationService and wire it into the Update endpoint - #8211

Draft
r-tome wants to merge 8 commits into
mainfrom
ac/pm-12473/collection-user-authorization-service
Draft

[PM-12473] refactor: add CollectionAuthorizationService and wire it into the Update endpoint#8211
r-tome wants to merge 8 commits into
mainfrom
ac/pm-12473/collection-user-authorization-service

Conversation

@r-tome

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

Copy link
Copy Markdown
Contributor

🎟️ Tracking

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

📔 Objective

We are replacing BulkCollectionAuthorizationHandler with per-resource [Resource]AuthorizationService classes. Each service fetches its own data. Each service calls a shared static Rules class.

An earlier design used ASP.NET's IAuthorizationHandler. It made the controller fetch data first, then pass it to the handler. A reviewer flagged this problem on PR #8075. We dropped that design.

This PR adds the first service: ICollectionAuthorizationService. It has three methods:

  • AuthorizeUpdateAsync checks one collection. PUT organizations/{orgId}/collections/{id} calls it now, behind the pm-35160-authorization-services flag. The old handler check stays as the fallback.
  • AuthorizeModifyUserAccessManyAsync and AuthorizeModifyGroupAccessManyAsync check a batch of collections. No controller calls them yet. A follow-up PR will wire them into CollectionsController.PostBulkCollectionAccess. Later PRs will wire them into GroupsController and OrganizationUsersController.

The service calls a static CollectionRules class, split into two tiers:

  • OrganizationWide: rules that apply to every collection in the org (CanUpdate, CanModifyUserAccess, CanModifyGroupAccess).
  • PerCollection: the fallback for one collection. The caller manages that collection, or the caller is an Owner/Admin and the collection is orphaned.

This PR also fixes a bug. CanModifyGroupAccess checked Permissions.ManageUsers. It now checks Permissions.ManageGroups.

CollectionAuthorizationService matches the old handler in three ways:

  • It memoizes lookups for the life of the request, like the old handler.
  • A non-member skips straight to the provider-user check.
  • An unknown or cross-org collection ID is left out of the result, not treated as authorized. An empty request returns an empty result.

📸 Screenshots

N/A

@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.39623% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.33%. Comparing base (6fbf0f2) to head (51e2291).
⚠️ Report is 20 commits behind head on main.

Files with missing lines Patch % Lines
...tion/Collections/CollectionAuthorizationService.cs 91.56% 4 Missing and 3 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8211      +/-   ##
==========================================
+ Coverage   63.22%   63.33%   +0.10%     
==========================================
  Files        2381     2385       +4     
  Lines      103757   104002     +245     
  Branches     9385     9426      +41     
==========================================
+ Hits        65604    65867     +263     
+ Misses      35924    35888      -36     
- Partials     2229     2247      +18     

☔ 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.

@r-tome r-tome changed the title ac/pm 12473/collection user authorization service [PM-12473] Add CollectionAuthorizationService authorization groundwork Aug 14, 2026
@r-tome
r-tome force-pushed the ac/pm-12473/collection-user-authorization-service branch from 3a2cd25 to 56c0576 Compare August 14, 2026 09:55
r-tome added 2 commits August 14, 2026 11:10
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.
@r-tome
r-tome force-pushed the ac/pm-12473/collection-user-authorization-service branch from 56c0576 to 896ca86 Compare August 14, 2026 10:29
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.
@r-tome r-tome added the t:tech-debt Change Type - Tech debt label Aug 14, 2026
@r-tome r-tome changed the title [PM-12473] Add CollectionAuthorizationService authorization groundwork [PM-12473] refactor: add CollectionAuthorizationService authorization groundwork Aug 14, 2026
@r-tome r-tome added the ai-review Request a Claude code review label Aug 14, 2026
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

Re-reviewed at head commit 51e2291e3. The new CollectionAuthorizationService was traced against BulkCollectionAuthorizationHandler for all three operations (update metadata, modify user access, modify group access) and the authorization outcomes match for every role/permission combination, including the non-member provider-user path and the orphaned-collection fallback. The Put endpoint change is gated behind pm-35160-authorization-services with the existing handler retained as the fallback, and unit coverage across CollectionRulesTests, CollectionAuthorizationServiceTests, and CollectionsControllerTests exercises both branches.

No findings.

Code Review Details

Verification notes from this pass:

  • Previously open finding is resolved. The orphaned-collections query is now gated on hasUnmanagedCollections && CollectionRules.PerCollection.CanManageOrphanedCollections(organization) (src/Api/AdminConsole/Authorization/Collections/CollectionAuthorizationService.cs:65-69), restoring the short-circuit and covered by AuthorizeUpdateAsync_WhenOwnerManagesCollectionDirectly_SkipsOrphanedCollectionsQuery.
  • Apparent rule divergence is unreachable. The old handler let Permissions.EditAnyCollection custom users manage orphaned collections, while PerCollection.CanManageOrphanedCollections restricts that to Owner/Admin. For all three operations EditAnyCollection already grants authorization at the OrganizationWide tier, so the per-collection branch is never reached with that permission. No behavior change.
  • Org scoping is tightened, not loosened. The old handler derived the target org from the resource (resources.First().OrganizationId) and ignored the route orgId; the new service filters requested IDs to the route organizationId. A cross-org PUT now returns 404 instead of proceeding. This is documented in the ICollectionAuthorizationService remarks and is a security improvement.
  • Provider-user bypass preserves the previous all-or-nothing semantics, and the query is skipped entirely when every requested collection is already authorized.
  • DI registration uses TryAddScoped per ADR 0026, and scoped lifetime matches the per-request memoization the service relies on.
  • New files correctly omit #nullable enableDirectory.Build.props sets Nullable=enable for non-test projects.

Comment thread src/Api/AdminConsole/Authorization/Collections/CollectionRules.cs Outdated
Comment thread src/Api/AdminConsole/Authorization/Collections/CollectionAuthorizationService.cs Outdated
r-tome added 2 commits August 14, 2026 14:48
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.
Comment thread test/Api.Test/AdminConsole/Authorization/CollectionRulesTests.cs
@r-tome r-tome changed the title [PM-12473] refactor: add CollectionAuthorizationService authorization groundwork [PM-12473] refactor: add CollectionAuthorizationService and wire it into the Update endpoint behind a flag Aug 14, 2026
…, harden and memoize CollectionAuthorizationService

Adds per-request memoization of managed/orphaned/organization-id lookups, short-circuits
non-members to the provider bypass without extra queries, and trims the interface to the
three methods with real consumers.
@r-tome r-tome changed the title [PM-12473] refactor: add CollectionAuthorizationService and wire it into the Update endpoint behind a flag [PM-12473] refactor: add CollectionAuthorizationService and wire it into the Update endpoint Aug 18, 2026
Comment on lines +66 to +68
var orphanedCollectionIds = CollectionRules.PerCollection.CanManageOrphanedCollections(organization)
? await GetOrphanedCollectionIdsAsync(organizationId)
: new HashSet<Guid>();

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.

🎨 SUGGESTED: The orphaned-collections query runs for every Owner/Admin, even when callerManagedCollectionIds already authorizes all requested collections.

Details and fix

GetOrphanedCollectionIdsAsync calls Collection_ReadWithGroupsAndUsersByOrganizationId, which returns every collection in the organization plus every CollectionGroup and CollectionUser row for it — three result sets, then an O(collections × groups) FirstOrDefault join in Infrastructure.Dapper/AdminConsole/Repositories/CollectionRepository.cs:115.

BulkCollectionAuthorizationHandler.CanManageCollectionsAsync only reached that query after the managed-collection check had already failed:

var canManageTargetCollections = targetCollections.All(tc => _managedCollectionsIds.Contains(tc.Id));
if (canManageTargetCollections)
{
    return true;   // orphaned query never runs
}

Here the CanManageOrphanedCollections gate only filters out non-Owner/Admin callers, so the most common admin path — an Owner or Admin in an organization with AllowAdminAccessToAllCollectionItems = false editing a collection they are directly assigned to manage — now pays for the full-organization access read on every call, where it previously paid nothing.

Adding the managed-collection check to the gate restores the old short-circuit:

var callerManagedCollectionIds = await GetCallerManagedCollectionIdsAsync(currentContext.UserId.Value);
var hasUnmanagedCollections = requestedCollectionIds.Any(id => !callerManagedCollectionIds.Contains(id));
// Only Owners and Admins can manage orphaned collections, and only unmanaged collections need the check.
var orphanedCollectionIds = hasUnmanagedCollections && CollectionRules.PerCollection.CanManageOrphanedCollections(organization)
    ? await GetOrphanedCollectionIdsAsync(organizationId)
    : new HashSet<Guid>();

This matters more once GroupsController and OrganizationUsersController are wired to the batch methods, since those save paths are higher volume than a collection PUT.

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 t:tech-debt Change Type - Tech debt

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant