Skip to content

[PM-38136] centralize role escalation validation - #8218

Open
JaredScar wants to merge 3 commits into
mainfrom
ac/pm-38136-centralize-role-escalation-validation
Open

[PM-38136] centralize role escalation validation#8218
JaredScar wants to merge 3 commits into
mainfrom
ac/pm-38136-centralize-role-escalation-validation

Conversation

@JaredScar

Copy link
Copy Markdown
Contributor

🎟️ Tracking

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

📔 Objective

Will centralize the role checking into a single service rather than having it across 7+ different places

…permissions checks

- Introduced IOrganizationUserValidationService to centralize permission checks for managing organization users.
- Updated RemoveOrganizationUserCommand and RevokeOrganizationUserCommand to utilize the new validation service for role management.
- Refactored validation logic in DeleteClaimedOrganizationUserAccountValidator and RevokeOrganizationUsersValidator to streamline error handling.
- Removed deprecated error messages and replaced them with more accurate ones reflecting the new validation logic.
- Improved null safety and organization context handling across user management commands.
…and error handling

- Removed deprecated error messages related to user restoration permissions.
- Introduced IOrganizationUserValidationService for centralized validation of user management actions.
- Refactored RestoreOrganizationUserCommand to utilize the new validation service, enhancing clarity and maintainability.
- Improved error handling for user restoration scenarios, ensuring accurate feedback based on user roles and permissions.
…logic

- Introduced IOrganizationUserValidationService to streamline permission checks for recovering organization user accounts.
- Refactored RecoverAccountAuthorizationHandler to utilize the new validation service, improving clarity and maintainability.
- Updated authorization logic to align with the Owner > Admin > Custom > User hierarchy, specifically for ManageResetPassword permissions.
- Removed deprecated methods and improved error handling for account recovery scenarios, ensuring accurate feedback based on user roles and permissions.
@JaredScar
JaredScar requested a review from a team as a code owner August 14, 2026 17:54
@JaredScar
JaredScar requested a review from eliykat August 14, 2026 17:54
@JaredScar JaredScar added ai-review Request a Claude code review t:tech-debt Change Type - Tech debt labels Aug 14, 2026
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: REQUEST CHANGES

This PR centralizes the "can the acting user manage this target member" role-escalation rules into IOrganizationUserValidationService, adds a bulk CanManageAsync overload plus a customPermissionGate selector, and routes seven call sites (remove, revoke v1/v2, restore, delete-claimed-account, revoke-non-compliant, account recovery, and OrganizationService.ValidateOrganizationUserUpdatePermissions) through it. The consolidation is well documented and the provider-authority override is correctly preserved in the new paths. Two problems stand out: the policy-driven revoke path loses the acting user's role and now denies every target, and one bulk validator can throw on caller-supplied duplicate ids.

Code Review Details
  • ❌ : Policy-driven revoke denies all targets because PolicyUpdate.PerformedBy never carries the acting user's role/permissions, breaking Two-Step Login / Single Organization policy enablement and organization domain verification
    • src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeNonCompliantOrganizationUserCommand.cs:131
  • ⚠️ : ToDictionary on caller-supplied ids throws ArgumentException (500) when a bulk delete-account request contains duplicate ids
    • src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountValidator.cs:36

Also worth a look, but not blocking: several user-facing error messages change as a side effect of consolidation (e.g. "Your account does not have permission to manage users." now surfaces as "Custom users can not manage Admins or Owners." for a non-Custom actor). The updated tests show this is intentional, but the new message is misleading in that case and clients may match on the old strings.

Comment on lines +131 to +133
var actingUser = standardUser.OrganizationUserType.HasValue
? new OrganizationUserRole(standardUser.OrganizationUserType.Value, request.OrganizationId, standardUser.Permissions)
: null;

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: Every real caller reaches here with OrganizationUserType == null, so all non-compliant revocations are now denied.

Details and fix

The only StandardUser paths into this command are SingleOrgPolicyEventHandler / TwoFactorAuthenticationPolicyEventHandler, and both read policyUpdate.PerformedBy, not the new ?? new StandardUser(..., actingOrganization?.Type, ...) fallback added in this PR. PolicyUpdate.PerformedBy is populated in PolicyRequestModel.ToPolicyUpdateAsync (line 19) and VerifyOrganizationDomainCommand (line 43) as:

new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId))

— i.e. orgUserType/permissions are always null, so the fallback in the handlers is dead code on every request path.

Result: actingUser is nullIsAuthorizedByRole(null, ...) is falseIsProviderAsync is false for a normal Owner/Admin → CannotManageError for every target. commandResult.HasErrors is then true and the handler throws BadRequestException. Concretely, an Owner enabling the Two-Step Login or Single Organization policy (or verifying an organization domain, which auto-enables SingleOrg) in an org with any non-compliant member now fails with "Custom users can not manage Admins or Owners." repeated per member. Previously only Owner-typed targets were restricted, and those are already filtered out of the target list by the handlers.

Two fixes are needed:

  1. Populate the acting user's role in PolicyRequestModel.ToPolicyUpdateAsync, SavePolicyRequest.ToSavePolicyModelAsync, and VerifyOrganizationDomainCommand (as was done for the controller at OrganizationUsersController.cs:709).
  2. Keep a safe fallback here so a StandardUser with no role claim isn't silently denied — e.g. honour standardUser.IsOrganizationOwnerOrProvider when OrganizationUserType is null.

The existing tests don't catch this: CreateActingUser in RevokeNonCompliantOrganizationUserCommandTests always supplies an explicit OrganizationUserType, so the null-type path is untested.

Comment on lines +36 to +38
var targetsById = requests
.Where(r => r.OrganizationUser is not null)
.ToDictionary(r => r.OrganizationUserId, r => (IOrganizationUserRole)r.OrganizationUser!);

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: ToDictionary throws on duplicate ids in a bulk delete request, turning a previously-tolerated payload into a 500.

Details and fix

DeleteClaimedOrganizationUserAccountCommand.CreateInternalRequests yields one request per entry of the caller-supplied orgUserIds without de-duplicating, and OrganizationUserBulkRequestModel.Ids is an unvalidated IEnumerable<Guid> from the request body. So DELETE /organizations/{orgId}/users/delete-account with {"ids": ["<same-guid>", "<same-guid>"]} reaches this line with two requests sharing the same OrganizationUserId and throws ArgumentException: An item with the same key has already been added. Before this PR each duplicate was simply validated independently.

Suggested fix:

var targetsById = requests
    .Where(r => r.OrganizationUser is not null)
    .GroupBy(r => r.OrganizationUserId)
    .ToDictionary(g => g.Key, g => (IOrganizationUserRole)g.First().OrganizationUser!);

(Or de-duplicate orgUserIds in the command.) The same pattern in RemoveOrganizationUserCommand / RestoreOrganizationUserCommand is safe because those lists come from a repository query.

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

I had a quick look over this, at a glance the service logic is already straining trying to accommodate these different flows with an easy-to-use interface. @jrmccannon can you please take a close look at this while I'm OOO? tyvm

Comment on lines +276 to +280
/// <summary>
/// Swaps the auto-mocked <see cref="IOrganizationUserValidationService"/> for a real instance backed by
/// mocked repositories, so the handler's role-hierarchy logic (Owner/Admin/Custom/User + provider override)
/// actually runs instead of just returning a NSubstitute default.
/// </summary>

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 shouldn't do this in unit tests; this is what integration tests are for.


if (!canRecoverOrganizationMember)
var manageError = await organizationUserValidationService.CanManageAsync(
currentContext.UserId ?? Guid.Empty, actingUser, targetOrganizationUser,

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.

Do not pass Guid.Empty to comply with a non-nullable parameter. This interface expects a real userId; if the caller can't provide one, the caller needs to handle that itself.

Comment on lines +44 to +46
var actingUser = actingOrganization is null
? null
: new OrganizationUserRole(actingOrganization.Type, targetOrganizationUser.OrganizationId, actingOrganization.Permissions);

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.

Could CurrentContextOrganization just implement this interface directly?

Comment on lines +37 to +38
Task<Error?> CanManageAsync(Guid actingUserId, IOrganizationUserRole? actingUser, IOrganizationUserRole targetUser,
Func<Permissions, bool>? customPermissionGate = null);

@eliykat eliykat Aug 15, 2026

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.

customPermissionGate is not very intuitive and is poor encapsulation - it's easy for the caller to misunderstand this or pass the wrong callback, and it's tightly coupled with the logic of the method. Is there a way to redraw the interface to avoid this?

/// <param name="targetUsersById">The members being managed, keyed by their <c>OrganizationUserId</c>.</param>
/// <param name="customPermissionGate">See <see cref="CanManageAsync(Guid, IOrganizationUserRole?, IOrganizationUserRole, Func{Permissions, bool}?)"/>.</param>
/// <returns>A per-target result, keyed by <c>OrganizationUserId</c>; <c>null</c> means allowed.</returns>
Task<IReadOnlyDictionary<Guid, Error?>> CanManageAsync(Guid actingUserId, IOrganizationUserRole? actingUser,

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.

IReadOnlyDictionary<Guid, Error?> seems like it's recreating something like BulkCommandResult, or IEnumerable<ValidationResult>. It's neither a command nor a validator, so presumably it shouldn't use those, but we should have something more strongly typed than this.

@eliykat
eliykat requested a review from jrmccannon August 15, 2026 05:43
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.

2 participants