Skip to content

[PM-12473] feat: Add CollectionUser authorization handler and delta access endpoint - #8075

Draft
r-tome wants to merge 21 commits into
mainfrom
ac/pm-12473/collection-user-access-endpoint
Draft

[PM-12473] feat: Add CollectionUser authorization handler and delta access endpoint#8075
r-tome wants to merge 21 commits into
mainfrom
ac/pm-12473/collection-user-access-endpoint

Conversation

@r-tome

@r-tome r-tome commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

🎟️ Tracking

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

📔 Objective

This is part of the authz refactoring on server to decouple CollectionUser authorization handling from BulkCollectionAuthorizationHandler, which currently handles everything.

This PR ships the groundwork: CollectionUserAuthorizationHandler, ModifyCollectionUserAccessCommand, ModifyCollectionUserAccessValidator, ModifyUserAccessAsync on the repository (Dapper + EF Core), and a new CollectionUser_DeleteMany sproc so changes are applied atomically. The endpoint is deferred to PM-41448.

📸 Screenshots

N/A — server-only change.

r-tome added 4 commits July 28, 2026 15:43
…cross multiple collections

Generalizes ModifyUserAccessAsync to accept a list of collection ids instead
of one, and adds a CollectionUser_DeleteMany procedure so the same delta can
be applied to multiple collections in a single call, in both the Dapper and
EF Core implementations.
… access

Adds CollectionUserAuthorizationHandler and CollectionUserAuthorizationRules
to decide whether the caller can add, change, or remove another user's
access to one or more collections, reusing BulkAuthorizationHandler so the
same handler covers single- and multi-collection requests.
Adds the domain command and validator that turn an add/update/remove delta
into a validated, per-target set of user-access changes across one or more
collections, plus the feature flag gating the new endpoint.
Adds CollectionUserController with single- and bulk-collection PATCH routes
that apply an add/update/remove delta to a collection's user access, behind
the PM12473CollectionUserAccessEndpoint feature flag.
@r-tome
r-tome requested review from a team as code owners July 28, 2026 14:56
@r-tome r-tome added the ai-review Request a Claude code review label Jul 28, 2026
@r-tome
r-tome requested a review from eliykat July 28, 2026 14:56
@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

Re-reviewed after the commits that removed the CollectionUser endpoints, the minimal-API handler, and the exception filter — every finding from the earlier rounds that targeted those files is now obsolete, and the ICollectionRepository thread is addressed (an Infrastructure.IntegrationTest case for ModifyUserAccessAsync was added and the EF implementation now applies removals before upserts, matching Dapper). What remains is groundwork: a new CollectionUserAuthorizationHandler plus pure CollectionUserAuthorizationRules, a ModifyCollectionUserAccess command/validator pair, and a dual-ORM ModifyUserAccessAsync backed by the new CollectionUser_DeleteMany sproc. I verified the new rules against BulkCollectionAuthorizationHandler.CanUpdateUserAccessAsync and found no behavioral divergence, and confirmed that passing an empty @Groups TVP to Collection_CreateOrUpdateAccessForMany is a no-op (the MERGE has no NOT MATCHED BY SOURCE clause), so existing group access is not wiped. Nothing in the PR is reachable from an endpoint yet.

Code Review Details
  • ⚠️ : Validator omits the non-empty and same-organization Targets guards its sibling BulkAddCollectionAccessCommand has; empty targets throw InvalidOperationException and mixed-org targets would grant cross-organization access
    • src/Core/AdminConsole/OrganizationFeatures/Collections/ModifyUserAccess/ModifyCollectionUserAccessValidator.cs:55-58
  • 🎨 : CallerManagesCollectionAsync is evaluated eagerly, so every authorization issues a GetManyByUserIdAsync query even on the admin paths that short-circuit before using it
    • src/Api/AdminConsole/Authorization/Collections/CollectionUserAuthorizationHandler.cs:56-66

PR Metadata Assessment

  • QUESTION: The description still says "adds a new endpoint that will be called by clients to send a delta" and the title says "delta access endpoint", but the endpoints were removed in 54a28a9. Worth updating so reviewers know this ships as unreferenced groundwork.

@r-tome
r-tome marked this pull request as draft July 28, 2026 14:59
@r-tome r-tome added the t:tech-debt Change Type - Tech debt label Jul 28, 2026
@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.69536% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.44%. Comparing base (2bdd35a) to head (9260d96).
⚠️ Report is 68 commits behind head on main.

Files with missing lines Patch % Lines
.../Collections/CollectionUserAuthorizationHandler.cs 92.00% 2 Missing and 2 partials ⚠️
.../AdminConsole/Repositories/CollectionRepository.cs 89.74% 4 Missing ⚠️
.../AdminConsole/Repositories/CollectionRepository.cs 94.36% 4 Missing ⚠️
...yUserAccess/ModifyCollectionUserAccessValidator.cs 98.55% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##             main    #8075       +/-   ##
===========================================
+ Coverage   14.96%   67.44%   +52.48%     
===========================================
  Files        1389     2317      +928     
  Lines       60360   100516    +40156     
  Branches     4793     9050     +4257     
===========================================
+ Hits         9030    67790    +58760     
+ Misses      51175    30453    -20722     
- Partials      155     2273     +2118     

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

Comment on lines +48 to +50
var addIds = add.Select(a => a.Id).ToHashSet();
var updateIds = update.Select(u => u.Id).ToHashSet();
var removeIds = remove.ToHashSet();

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: An explicit null for add/update/remove/collectionIds in the JSON body throws NullReferenceException (HTTP 500).

Details and fix

The = [] initializers on CollectionUserAccessDeltaRequestModel / BulkCollectionUserAccessDeltaRequestModel only apply when the property is absent from the payload. System.Text.Json overwrites the property with null when the JSON contains an explicit null, so:

PATCH /organizations/{orgId}/collections/{id}/users
{ "add": null }

reaches add.Select(a => a.Id).ToHashSet() on line 48 with add == null and produces a 500 rather than a 400. Same for model.CollectionIds.ToList() on line 41.

Either null-coalesce at the boundary:

=> ModifyUserAccessAsync(orgId, [id], model.Add ?? [], model.Update ?? [], model.Remove ?? []);

or make the properties null-tolerant/validated on the request models (e.g. [Required] on CollectionIds, ?? [] in a getter). Note PostBulkCollectionAccess in CollectionsController guards with model.Users?.Select(...) for the same reason.

var updateIds = update.Select(u => u.Id).ToHashSet();
var removeIds = remove.ToHashSet();

var organizationCollections = await collectionRepository.GetManyByOrganizationIdWithAccessAsync(orgId);

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 single-collection route reads the entire organization's collection access graph to resolve one collection.

Details and fix

GetManyByOrganizationIdWithAccessAsync runs Collection_ReadWithGroupsAndUsersByOrganizationId, which returns every Collection, every CollectionGroup, and every CollectionUser row in the org (three result sets). For an org with 5k collections and 100k access rows, changing one member on one collection transfers the whole graph — and it happens before any authorization check, so an unauthorized caller triggers it too.

GetByIdWithAccessAsync(id) already exists and returns exactly what CollectionUserAccessTarget needs, so the PatchCollectionUserAccessAsync path can be O(1) rows instead of O(org):

var (collection, accessDetails) = await collectionRepository.GetByIdWithAccessAsync(id);
if (collection is null || collection.OrganizationId != orgId) { throw new NotFoundException(); }

The bulk route has no equivalent targeted repository method today, so keeping the org-wide read there (or adding a GetManyByManyIdsWithAccessAsync) is a reasonable follow-up. The only current caller of the org-wide query on a hot path is BulkCollectionAuthorizationHandler, and it uses it only as an orphaned-collection fallback.

/// <param name="upserts">The user access selections to create or update.</param>
/// <param name="removeOrganizationUserIds">The Organization User IDs to remove access for.</param>
/// <param name="revisionDate">The revision date to use for the collections.</param>
Task ModifyUserAccessAsync(Guid organizationId, IEnumerable<Guid> collectionIds,

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.

♻️ DEBT: New dual-ORM method has no Infrastructure.IntegrationTest coverage, and the two implementations apply operations in opposite order.

Details and parity risk

The Dapper path runs CollectionUser_DeleteMany (removals) then Collection_CreateOrUpdateAccessForMany (upserts). The EF path runs upserts then removals. For an organization user id present in both upserts and removeOrganizationUserIds, MSSQL ends with the user having access while PostgreSQL/MySQL/SQLite end with the user removed.

ModifyCollectionUserAccessValidator rejects overlapping ids today (OverlappingOrganizationUserId), so the divergence isn't reachable through the new endpoints — but this is public interface surface and the next caller won't know that invariant. Either document the disjointness requirement on this XML doc comment, or align the order across both implementations.

Either way, the sibling CreateOrUpdateAccessForManyAsync is covered by test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryTests.cs:613, which is what validates behavior across all four supported databases. ModifyUserAccessAsync — a new stored procedure plus ~90 lines of hand-written EF logic — has none.


public bool IsAdminOrOwner => Type is OrganizationUserType.Owner or OrganizationUserType.Admin;

public bool HasPermission(Func<Permissions, bool> permissionPicker) => permissionPicker(Permissions);

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.

♻️ DEBT: HasPermission(Func<Permissions, bool>) adds delegate-based indirection to a core context class for a single call site.

Details

Permissions is already public, and the codebase consistently uses property pattern matching for this (org is { Permissions.EditAnyCollection: true } in BulkCollectionAuthorizationHandler, and on the line directly above the only consumer in CollectionUserAuthorizationRules.cs:23). That file ends up mixing both idioms in the same method:

if (organization is { Permissions.EditAnyCollection: true }) { ... }          // line 23
if (... && organization?.HasPermission(p => p.ManageUsers) == true) { ... }   // line 28

organization is { Permissions.ManageUsers: true } is equivalent, needs no new public surface on CurrentContextOrganization, and keeps the rules file internally consistent. IsAdminOrOwner is a worthwhile addition (it replaces a genuinely repeated two-way type check); HasPermission is not carrying its weight yet.

@r-tome r-tome changed the title [PM-12473] Add PATCH endpoints for collection user access [PM-12473] feat: Add CollectionUser authorization handler and delta access endpoint Jul 28, 2026
r-tome added 5 commits July 29, 2026 14:45
…esultExtensions

Pulls the CommandResult -> HTTP response mapping out of
BaseAdminConsoleController into extension methods on CommandResult, so it
can be shared with Minimal API handlers instead of only MVC controllers.
…ller to Minimal API

Replaces CollectionUserController with Minimal API endpoint groups
(CollectionUserEndpoints, mapped from the new AdminConsoleEndpoints umbrella)
and a plain DI-injected handler class, matching the same pattern PAM already
uses in this codebase. Feature-gating uses the SDK-native RequireFeature()
endpoint-convention-builder extension, which already returns RFC 7807
ProblemDetails on a disabled flag via the existing UseFeatureFlagChecks()
middleware. A new AdminConsoleExceptionHandlerEndpointFilter, scoped to
admin console endpoints, translates NotFoundException and unexpected
exceptions into ProblemDetails, since Minimal API doesn't run MVC exception
filters.
…extension

Pulls the parts of the endpoint chain that are the same for every admin
console Minimal API group (authorization policy, exception filter) into a
shared extension, matching PAM's WithPamDefaults. Feature-flag gating stays
a separate per-group call since each admin console feature has its own flag.
Adds a sweep test over AdminConsoleEndpoints.MapAdminConsoleEndpoints that
asserts every mapped endpoint has authorization or allow-anonymous metadata.
The existing controller-based authorization sweep only reflects over
ControllerBase types, so it can't see Minimal API endpoints. Handler types
are discovered by reflection, so future admin console endpoint groups are
covered automatically.
CannotAddSelfToCollection only blocks joining a collection you're not
already in. Documents that raising your own access on a collection you
already belong to is intentionally left to the authorization layer, which
already requires you to manage that collection before this validator runs.
Comment on lines +19 to +29
group.MapPatch("{id:guid}/users",
(Guid orgId, Guid id, CollectionUserAccessDeltaRequestModel model, ClaimsPrincipal user,
CollectionUserEndpointsHandler handler) =>
handler.PatchUserAccessAsync(orgId, [id], model.Add, model.Update, model.Remove, user))
.WithName("PatchCollectionUserAccess");

group.MapPatch("users",
(Guid orgId, BulkCollectionUserAccessDeltaRequestModel model, ClaimsPrincipal user,
CollectionUserEndpointsHandler handler) =>
handler.PatchUserAccessAsync(orgId, model.CollectionIds.ToList(), model.Add, model.Update, model.Remove, user))
.WithName("PatchBulkCollectionUserAccess");

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.

CRITICAL: CollectionUserEndpointsHandler is never registered in DI, so Minimal API binds it from the request body and endpoint construction throws.

Details and fix

RequestDelegateFactory resolves a complex parameter from DI only when IServiceProviderIsService.IsService(type) returns true; otherwise it infers a body parameter. CollectionUserEndpointsHandler has no registration anywhere in src/:

$ grep -rn "CollectionUserEndpointsHandler" --include=*.cs src/
src/Api/AdminConsole/Endpoints/CollectionUserEndpoints.cs:21
src/Api/AdminConsole/Endpoints/CollectionUserEndpoints.cs:27
src/Api/AdminConsole/Endpoints/Handlers/CollectionUserEndpointsHandler.cs:15

So both model and handler are inferred as body parameters and the delegate factory throws InvalidOperationException: Failure to infer how parameters ... should be bound. Endpoints are built lazily by EndpointRoutingMiddleware over the composite data source, so this takes down routing for the whole API on the first request, not just these two routes — and the feature flag does not protect against it, since RequireFeature only adds metadata checked at request time after the endpoint has been built.

The unit tests pass because they register it themselves (CollectionUserEndpointsTests.cs:20, and AdminConsoleEndpointsAuthorizationTests.cs:16-21 reflects over the Handlers namespace and registers everything), so no test exercises the real container.

PAM handles this with bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs:

services.AddScoped<LeaseEndpointsHandler>();
services.AddScoped<AccessRequestEndpointsHandler>();
services.AddScoped<AccessRuleEndpointsHandler>();

wired up via services.AddPamServices() in Startup.cs:211. An equivalent AddAdminConsoleEndpointHandlers() (or a single services.AddScoped<CollectionUserEndpointsHandler>()) needs to be called from Startup.ConfigureServices.

Comment on lines +21 to +26
public static RouteGroupBuilder WithAdminConsoleDefaults(this RouteGroupBuilder group)
{
group.RequireAuthorization(Policies.Application);
group.AddEndpointFilter<AdminConsoleExceptionHandlerEndpointFilter>();
return group;
}

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: Without WithGroupName("internal"), these endpoints are emitted into the published public OpenAPI spec.

Details and fix

Swashbuckle's default DocInclusionPredicate is apiDescription.GroupName == null || apiDescription.GroupName == documentName, so an endpoint with no group name lands in every SwaggerDoc — here both internal and public (src/Api/Utilities/ServiceCollectionExtensions.cs:23,48). The public document is the customer-facing Bitwarden Public API spec shipped to the help center and used for SDK generation.

MVC controllers avoid this via ApiExplorerGroupConvention (Startup.cs:218), but that is an IControllerModelConvention and does not apply to Minimal APIs. AddEndpointsApiExplorer() is called (Startup.cs:223), so these endpoints do reach ApiExplorer. PAM sets the group name explicitly for exactly this reason (PamEndpointsExtensions.cs:33).

Since WithAdminConsoleDefaults is the shared chain every future admin console group will use, fixing it here covers all of them:

group.RequireAuthorization(Policies.Application);
group.AddEndpointFilter<AdminConsoleExceptionHandlerEndpointFilter>();
group.WithGroupName("internal");
return group;

Comment on lines +16 to +27
catch (NotFoundException)
{
return TypedResults.Problem(title: "Resource not found.", statusCode: StatusCodes.Status404NotFound);
}
catch (Exception exception)
{
var endpointName = context.HttpContext.GetEndpoint()?.DisplayName;
context.HttpContext.RequestServices.GetRequiredService<ILogger<AdminConsoleExceptionHandlerEndpointFilter>>()
.LogError(exception, "Unhandled exception in {EndpointName}", endpointName);
return TypedResults.Problem(
title: "An error has occurred.", statusCode: StatusCodes.Status500InternalServerError);
}

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.

♻️ DEBT: These endpoints return two different error body shapes — ProblemDetails for thrown exceptions, ErrorResponseModel for command errors.

Details

CommandResultExtensions.MapError returns new ErrorResponseModel(...) ({"object":"error","message":"..."}), which is the contract Bitwarden clients parse. This filter returns RFC 7807 ProblemDetails ({"type":...,"title":...,"status":...}) with no message field, so the same endpoint answers a 404 two different ways depending on whether it came from NotFoundError or from AuthorizeOrThrowAsync.

The 404-from-authorization path is the common one here (AuthorizeOrThrowAsync throws NotFoundException on every failed check), so in practice clients get the shape they can't read for the most frequent failure.

PamExceptionHandlerEndpointFilter was written for this exact problem and documents it: "this filter translates thrown exceptions into Bitwarden's ErrorResponseModel with the same status codes the controllers produced." It also maps BadRequestException → 400, UnauthorizedAccessException → 401, and ConflictException → 409; this filter sends all of those to 500. CollectionUserAuthorizationHandler throws BadRequestException for a cross-organization resource set — not reachable through today's two routes, but it will be as soon as a group is added whose handler doesn't pre-filter by orgId.

Consider reusing the PAM mapping (lifting it into a shared filter) rather than a second, narrower translation layer.

r-tome added 4 commits July 29, 2026 15:36
Shortens XML docs and inline comments throughout, removing restatements of
what the code already says.
Minimal API's RequestDelegateFactory only resolves a complex parameter from
DI when it's registered as a service; otherwise it infers a body parameter.
CollectionUserEndpointsHandler was never registered, so both the model and
the handler parameter were inferred as body, and endpoint construction
threw InvalidOperationException for both PATCH routes. Unit tests missed
this because they register the handler themselves.
… requests

The = [] initializers on the request models only apply when a property is
absent from the JSON body; an explicit null overwrites them, and the
handler called .Select(...) on it with no null-check, producing a 500
instead of a 400. Matches the existing precedent in CollectionsController
(model.Users?.Select(...) ?? new List<...>()).
…ublic OpenAPI spec

Without a group name, Swashbuckle's default inclusion predicate emits an
endpoint into every SwaggerDoc, including the customer-facing public API
document shipped to the help center and used for SDK generation. MVC
controllers avoid this via ApiExplorerGroupConvention, which doesn't apply
to Minimal API. PAM sets the same group name for the same reason. Fixing it
in the shared WithAdminConsoleDefaults chain covers every current and
future admin console group.
r-tome added 8 commits July 29, 2026 16:34
…ide fetch

Both routes called GetManyByOrganizationIdWithAccessAsync, pulling every
collection, group, and user-access row in the organization before any
authorization check, just to resolve 1 (or a few) requested collections.
GetByIdWithAccessAsync already returns exactly the shape needed, so the
single-collection route (the common case) now resolves in O(1) rows
instead of O(org). This is a single branch inside the one shared handler
method, so single and bulk still share the same validation, authorization,
and command path.
…rder

Dapper ran removes then upserts; EF Core ran upserts then removes. Not
reachable today because the validator rejects an id present in both lists,
but it's public interface surface with nothing to catch a future
regression: for an id present in both lists, MSSQL and
PostgreSQL/MySQL/SQLite would have ended in opposite states. Reorders EF
Core to match Dapper, and adds an integration test exercising add, update,
and remove in one call across all four supported databases — the sibling
CreateOrUpdateAccessForManyAsync already had this kind of coverage, this
method had none.
Had exactly one call site. The codebase's established idiom for this is
property-pattern matching, which the same file already uses one check
above (organization is { Permissions.EditAnyCollection: true }); the
delegate-based HasPermission added indirection without a second consumer
to justify it.
…andlerEndpointFilter

Thrown exceptions returned RFC 7807 ProblemDetails while CommandResult
validation failures returned ErrorResponseModel, so the same two endpoints
answered a 404 two different ways depending on which layer caught it. The
exception path is the common one here, since AuthorizeOrThrowAsync throws
NotFoundException on every failed authorization check, so most callers hit
the shape without a message field. ErrorResponseModel is what every other
Bitwarden endpoint already returns; changing CommandResultExtensions
instead would ripple into every MVC controller that shares it. Also adds a
BadRequestException case (400), since CollectionUserAuthorizationHandler's
cross-organization guard throws it and a future admin console group could
reach it.
…n user access

None of the existing tests exercise the real DI container end-to-end
through actual HTTP, which is exactly why the missing handler registration
shipped past unit tests that register the handler themselves. Adds
CollectionUserEndpointsTests using the same ApiApplicationFactory pattern
as CollectionsControllerTests: signs up an org, adds/updates a real
collection's user access via a real PATCH request, and verifies the
resulting access through the repository.
The feature flag key belongs to the parent epic PM-35160, not PM-12473.
…roundwork for unified endpoint

The team decided the final API shape is a single unified endpoint handling
collection metadata + users delta + groups delta in one call. Shipping
separate CollectionUser PATCH endpoints now would create temporary API
surface to retire later. The groundwork (repository, auth handler,
command, validator) is all reusable for the unified endpoint.
…mandResultExtensions

CommandResultExtensions was extracted to share HTTP mapping between MVC
controllers and Minimal API handlers. With the endpoints removed there is
only one consumer, so the extraction adds indirection with no benefit.
Revert BaseAdminConsoleController to its original self-contained form.
Comment on lines +55 to +58
var upsertIds = addIds.Concat(updateIds).ToList();
if (upsertIds.Count > 0)
{
var organizationId = request.Targets.First().Collection.OrganizationId;

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: The validator never checks that Targets is non-empty or that all targets share one organization — the two guards its sibling BulkAddCollectionAccessCommand does have.

Details and fix

Two consequences:

  1. Empty Targets with a non-empty delta reaches line 58 and request.Targets.First() throws InvalidOperationException ("Sequence contains no elements") rather than returning a BadRequestError. ModifyCollectionUserAccessCommand.ModifyAsync has the same Targets.First() on the organization id.
  2. Targets spanning organizations: organizationId is taken from Targets.First(), so OrganizationUsersNotInOrganization only validates users against the first target's org. Collection_CreateOrUpdateAccessForMany then CROSS JOINs @CollectionIds with @Users and only org-scopes the users side — org A's members would be granted access to org B's collection.

CollectionUserAuthorizationHandler does throw on mixed-organization resources today, so this is only reachable if a future caller passes a different set to the command than it authorized. Given this is the domain-layer guard for a multi-tenant boundary, the check belongs here too:

if (request.Targets.Count == 0)
{
    return Invalid(request, new NoCollectionsProvided());
}

var organizationId = request.Targets.First().Collection.OrganizationId;
if (request.Targets.Any(t => t.Collection.OrganizationId != organizationId))
{
    return Invalid(request, new TargetsMustBelongToSameOrganization());
}

Compare src/Core/AdminConsole/OrganizationFeatures/Collections/BulkAddCollectionAccessCommand.cs lines 56-71.

Comment on lines +56 to +66
var authorized = true;
foreach (var resource in resources)
{
var callerManagesCollection = await CallerManagesCollectionAsync(resource.Collection.Id);
if (!CollectionUserAuthorizationRules.CanModifyUserAccess(
resource.AccessDetails, organization, allowAdminAccessToAllCollectionItems, callerManagesCollection))
{
authorized = false;
break;
}
}

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: CallerManagesCollectionAsync is awaited before the rules run, so every authorization call issues a GetManyByUserIdAsync query — including the admin paths that never need it.

Details and fix

CanModifyUserAccess short-circuits on EditAnyCollection, AllowAdminAccess + ManageUsers, and AllowAdminAccess + IsAdminOrOwner before it ever looks at callerManagesCollection. But because the value is computed eagerly at line 59, the repository call always happens. The handler this replaces (BulkCollectionAuthorizationHandler.CanUpdateUserAccessAsyncCanUpdateCollectionAsync) returned before touching CanManageCollectionsAsync for those cases, so this adds one DB round-trip per authorization for the most common Admin Console caller.

Checking the cheap org-level rules first keeps the query on the path that actually needs it:

var authorized = true;
foreach (var resource in resources)
{
    if (CollectionUserAuthorizationRules.CanModifyUserAccess(
            resource.AccessDetails, organization, allowAdminAccessToAllCollectionItems,
            callerManagesCollection: false))
    {
        continue;
    }

    if (!CollectionUserAuthorizationRules.CanModifyUserAccess(
            resource.AccessDetails, organization, allowAdminAccessToAllCollectionItems,
            await CallerManagesCollectionAsync(resource.Collection.Id)))
    {
        authorized = false;
        break;
    }
}

Passing a Func<Task<bool>> into the rules instead would read better if you'd rather not evaluate twice.

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