Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
81 commits
Select commit Hold shift + click to select a range
14f3e88
Adding auto confirm endpoint and initial command work.
jrmccannon Oct 23, 2025
4645222
Adding validator
jrmccannon Oct 23, 2025
6a29377
Finished command implementation.
jrmccannon Oct 23, 2025
89b8d59
Enabled the feature renomved used method. Enabled the policy in the tโ€ฆ
jrmccannon Oct 24, 2025
f60f0f5
Added extension functions to allow for railroad programming.
jrmccannon Oct 27, 2025
0ba770d
Removed guid from route template. Added xml docs
jrmccannon Oct 27, 2025
e5f07de
Merge branch 'refs/heads/main' into jmccannon/ac/pm-26636-auto-confirโ€ฆ
jrmccannon Oct 28, 2025
5527ca6
Added validation for command.
jrmccannon Oct 28, 2025
27ce4b8
Added default collection creation to command.
jrmccannon Oct 28, 2025
2069c9f
formatting.
jrmccannon Oct 28, 2025
cb81d29
Added additional error types and mapped to appropriate results.
jrmccannon Oct 29, 2025
24a39d8
Merge branch 'main' into jmccannon/ac/pm-26636-auto-confirm-user-command
jrmccannon Oct 29, 2025
a1b2bac
Added tests for auto confirm validator
jrmccannon Oct 30, 2025
7307454
Adding tests
jrmccannon Oct 30, 2025
1303b22
fixing file name
jrmccannon Oct 31, 2025
4bb8734
Cleaned up OrgUserController. Added integration tests.
jrmccannon Oct 31, 2025
68afb17
Consolidated CommandResult and validation result stuff into a v2 direโ€ฆ
jrmccannon Oct 31, 2025
aee2734
changing result to match handle method.
jrmccannon Oct 31, 2025
031c080
Moves validation thenasync method.
jrmccannon Oct 31, 2025
805eba9
Added brackets.
jrmccannon Nov 3, 2025
ce596b2
Updated XML comment
jrmccannon Nov 3, 2025
6e5188f
Adding idempotency comment.
jrmccannon Nov 3, 2025
d6a5813
Merge branch 'refs/heads/main' into jmccannon/ac/pm-26636-auto-confirโ€ฆ
jrmccannon Nov 3, 2025
9d47bf4
Fixed up merge problems. Fixed return types for handle.
jrmccannon Nov 3, 2025
929ac41
Renamed to ValidationRequest
jrmccannon Nov 3, 2025
f648953
I added some methods for CommandResult to cover some future use casesโ€ฆ
jrmccannon Nov 4, 2025
f800119
Fixed up logic around should create default colleciton. Added more meโ€ฆ
jrmccannon Nov 5, 2025
2e80a4d
Clearing nullable enable.
jrmccannon Nov 5, 2025
9b009b2
Fixed up validator tests.
jrmccannon Nov 5, 2025
e1eeaf9
Tests for auto confirm command
jrmccannon Nov 5, 2025
3af7ba4
Fixed up command result and AutoConfirmCommand.
jrmccannon Nov 5, 2025
cd1eca4
Merge branch 'main' into jmccannon/ac/pm-26636-auto-confirm-user-command
jrmccannon Nov 5, 2025
34a05f4
Removed some unused methods.
jrmccannon Nov 5, 2025
72fc706
Moved autoconfirm tests to their own class.
jrmccannon Nov 5, 2025
781d6e6
Moved some stuff around. Need to clean up creation of accepted org usโ€ฆ
jrmccannon Nov 6, 2025
e5ec8d7
Moved some more code around. Folded Key into accepted constructor. reโ€ฆ
jrmccannon Nov 6, 2025
eb5f98e
Merge branch 'main' into jmccannon/ac/pm-26636-auto-confirm-user-command
jrmccannon Nov 10, 2025
7fb8910
Merge branch 'refs/heads/main' into jmccannon/ac/pm-26636-auto-confirโ€ฆ
jrmccannon Nov 12, 2025
3ee2bf4
Clean up clean up everybody everywhere. Clean up clean up everybody dโ€ฆ
jrmccannon Nov 12, 2025
44a5813
Another quick one
jrmccannon Nov 12, 2025
a35c789
Removed aggregate Errors.cs
jrmccannon Nov 12, 2025
a9d4dc3
Cleaned up validator and fixed up tests.
jrmccannon Nov 12, 2025
290f391
Fixed auto confirm repo
jrmccannon Nov 12, 2025
f02f761
Cleaned up command tests.
jrmccannon Nov 12, 2025
fa18f3b
Unused method.
jrmccannon Nov 12, 2025
0487fb4
Restoring Bulk command back to what it was. deleted handle method forโ€ฆ
jrmccannon Nov 12, 2025
084849a
Remove unused method.
jrmccannon Nov 12, 2025
46b7bfd
removed unnecssary lines and comments
jrmccannon Nov 12, 2025
b4d5d50
fixed layout.
jrmccannon Nov 12, 2025
8dd84e2
Fixed test.
jrmccannon Nov 12, 2025
b91def1
fixed spelling mistake. removed unused import.
jrmccannon Nov 12, 2025
6d5cb55
Moved RevokeOrganizationUserCommand.cs to v1 directory
jrmccannon Nov 12, 2025
0f60466
Adding integration tests to make the migration easier.
jrmccannon Nov 13, 2025
f27d91c
Added interface
jrmccannon Nov 14, 2025
032b1f8
Merge branch 'main' into jmccannon/ac/pm-26636-auto-confirm-user-command
jrmccannon Nov 14, 2025
cdcff93
Update test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUโ€ฆ
jrmccannon Nov 14, 2025
7a5fd87
Merge remote-tracking branch 'origin/jmccannon/ac/pm-26636-auto-confiโ€ฆ
jrmccannon Nov 14, 2025
c301185
Ensuring collection is created before full sync. Cleaning up tests anโ€ฆ
jrmccannon Nov 14, 2025
4551891
Added org cleanup
jrmccannon Nov 14, 2025
6187cf0
Lowering to 5 to see if that helps the runner.
jrmccannon Nov 14, 2025
2b0b1ee
:shrug:
jrmccannon Nov 14, 2025
80872b8
Trying this
jrmccannon Nov 14, 2025
6b0af95
Maybe this time will be different.
jrmccannon Nov 14, 2025
64d3b1e
seeing if awaiting and checking independently will work in ci
jrmccannon Nov 14, 2025
3fb9810
I figured it out. Locally, it would be fast enough to all return NoCoโ€ฆ
jrmccannon Nov 14, 2025
0a75cc0
Added implementation and tests.
jrmccannon Nov 18, 2025
420b63e
Updated tests and validator
jrmccannon Nov 18, 2025
d0fdf44
Merge branch 'main' into jmccannon/ac/pm-26636-auto-confirm-user-command
jrmccannon Nov 18, 2025
448dd1a
Fixed name
jrmccannon Nov 18, 2025
540c843
Merge branch 'jmccannon/ac/pm-26636-auto-confirm-user-command' into jโ€ฆ
jrmccannon Nov 18, 2025
eda8486
Fix for integration tests.
jrmccannon Nov 18, 2025
b5ba1ad
Merge branch 'main' into jmccannon/ac/pm-18718-refactor-revoke-bulk-uโ€ฆ
jrmccannon Nov 19, 2025
6810137
Remove unused using
jrmccannon Nov 19, 2025
138f483
fixing feature flag
jrmccannon Nov 19, 2025
3029322
attempting to revoke owner as a provider.
jrmccannon Nov 19, 2025
bbd6bbb
Merge branch 'main' into jmccannon/ac/pm-18718-refactor-revoke-bulk-uโ€ฆ
jrmccannon Nov 20, 2025
bac5026
Removed call to check if there's other owners since its not possible โ€ฆ
jrmccannon Nov 20, 2025
7ffd23f
Added last owner check back in. Added tests to cover.
jrmccannon Nov 21, 2025
0cdc076
Merge branch 'main' into jmccannon/ac/pm-18718-refactor-revoke-bulk-uโ€ฆ
jrmccannon Dec 3, 2025
69a5fde
converted to records and removed unused param/field
jrmccannon Dec 3, 2025
6e050ea
fixed up a few spots i missed
jrmccannon Dec 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
Expand Down Expand Up @@ -46,7 +47,7 @@
}

[HttpGet("{id}")]
public async Task<IActionResult> Get(Guid organizationId, Guid id)

Check warning on line 50 in bitwarden_license/src/Scim/Controllers/v2/UsersController.cs

View workflow job for this annotation

GitHub Actions / Sonar / Quality scan

ModelState.IsValid should be checked in controller actions. (https://rules.sonarsource.com/csharp/RSPEC-6967)
{
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(id);
if (orgUser == null || orgUser.OrganizationId != organizationId)
Expand All @@ -57,7 +58,7 @@
}

[HttpGet("")]
public async Task<IActionResult> Get(

Check warning on line 61 in bitwarden_license/src/Scim/Controllers/v2/UsersController.cs

View workflow job for this annotation

GitHub Actions / Sonar / Quality scan

ModelState.IsValid should be checked in controller actions. (https://rules.sonarsource.com/csharp/RSPEC-6967)
Guid organizationId,
[FromQuery] GetUsersQueryParamModel model)
{
Expand All @@ -73,7 +74,7 @@
}

[HttpPost("")]
public async Task<IActionResult> Post(Guid organizationId, [FromBody] ScimUserRequestModel model)

Check warning on line 77 in bitwarden_license/src/Scim/Controllers/v2/UsersController.cs

View workflow job for this annotation

GitHub Actions / Sonar / Quality scan

ModelState.IsValid should be checked in controller actions. (https://rules.sonarsource.com/csharp/RSPEC-6967)
{
var orgUser = await _postUserCommand.PostUserAsync(organizationId, model);
var scimUserResponseModel = new ScimUserResponseModel(orgUser);
Expand All @@ -81,7 +82,7 @@
}

[HttpPut("{id}")]
public async Task<IActionResult> Put(Guid organizationId, Guid id, [FromBody] ScimUserRequestModel model)

Check warning on line 85 in bitwarden_license/src/Scim/Controllers/v2/UsersController.cs

View workflow job for this annotation

GitHub Actions / Sonar / Quality scan

ModelState.IsValid should be checked in controller actions. (https://rules.sonarsource.com/csharp/RSPEC-6967)
{
var orgUser = await _organizationUserRepository.GetByIdAsync(id);
if (orgUser == null || orgUser.OrganizationId != organizationId)
Expand All @@ -108,14 +109,14 @@
}

[HttpPatch("{id}")]
public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model)

Check warning on line 112 in bitwarden_license/src/Scim/Controllers/v2/UsersController.cs

View workflow job for this annotation

GitHub Actions / Sonar / Quality scan

ModelState.IsValid should be checked in controller actions. (https://rules.sonarsource.com/csharp/RSPEC-6967)
{
await _patchUserCommand.PatchUserAsync(organizationId, id, model);
return new NoContentResult();
}

[HttpDelete("{id}")]
public async Task<IActionResult> Delete(Guid organizationId, Guid id)

Check warning on line 119 in bitwarden_license/src/Scim/Controllers/v2/UsersController.cs

View workflow job for this annotation

GitHub Actions / Sonar / Quality scan

ModelState.IsValid should be checked in controller actions. (https://rules.sonarsource.com/csharp/RSPEC-6967)
{
await _removeOrganizationUserCommand.RemoveUserAsync(organizationId, id, EventSystemUser.SCIM);
return new NoContentResult();
Expand Down
4 changes: 2 additions & 2 deletions bitwarden_license/src/Scim/Users/PatchUserCommand.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
๏ปฟusing Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
๏ปฟusing Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
๏ปฟusing System.Text.Json;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
Expand Down
35 changes: 31 additions & 4 deletions src/Api/AdminConsole/Controllers/OrganizationUsersController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using V1_RevokeOrganizationUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1.IRevokeOrganizationUserCommand;
using V2_RevokeOrganizationUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;

namespace Bit.Api.AdminConsole.Controllers;

Expand Down Expand Up @@ -72,10 +74,11 @@ public class OrganizationUsersController : BaseAdminConsoleController
private readonly IPricingClient _pricingClient;
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
private readonly IAutomaticallyConfirmOrganizationUserCommand _automaticallyConfirmOrganizationUserCommand;
private readonly V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand _revokeOrganizationUserCommandVNext;
private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand;
private readonly V1_RevokeOrganizationUserCommand _revokeOrganizationUserCommand;
private readonly IAdminRecoverAccountCommand _adminRecoverAccountCommand;

public OrganizationUsersController(IOrganizationRepository organizationRepository,
Expand Down Expand Up @@ -103,10 +106,11 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor
IConfirmOrganizationUserCommand confirmOrganizationUserCommand,
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
IInitPendingOrganizationCommand initPendingOrganizationCommand,
IRevokeOrganizationUserCommand revokeOrganizationUserCommand,
V1_RevokeOrganizationUserCommand revokeOrganizationUserCommand,
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
IAdminRecoverAccountCommand adminRecoverAccountCommand,
IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand)
IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand,
V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand revokeOrganizationUserCommandVNext)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
Expand All @@ -132,6 +136,7 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor
_pricingClient = pricingClient;
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
_automaticallyConfirmOrganizationUserCommand = automaticallyConfirmOrganizationUserCommand;
_revokeOrganizationUserCommandVNext = revokeOrganizationUserCommandVNext;
_confirmOrganizationUserCommand = confirmOrganizationUserCommand;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
_initPendingOrganizationCommand = initPendingOrganizationCommand;
Expand Down Expand Up @@ -629,7 +634,29 @@ public async Task PatchRevokeAsync(Guid orgId, Guid id)
[Authorize<ManageUsersRequirement>]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{
return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync);
if (!_featureService.IsEnabled(FeatureFlagKeys.BulkRevokeUsersV2))
{
return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync);
}

var currentUserId = _userService.GetProperUserId(User);
if (currentUserId == null)
{
throw new UnauthorizedAccessException();
}

var results = await _revokeOrganizationUserCommandVNext.RevokeUsersAsync(
new V2_RevokeOrganizationUserCommand.RevokeOrganizationUsersRequest(
orgId,
model.Ids.ToArray(),
new StandardUser(currentUserId.Value, await _currentContext.OrganizationOwner(orgId))));

return new ListResponseModel<OrganizationUserBulkResponseModel>(results
.Select(result => new OrganizationUserBulkResponseModel(result.Id,
result.Result.Match(
error => error.Message,
_ => string.Empty
))));
}

[HttpPatch("revoke")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ public class OrganizationUserResetPasswordEnrollmentRequestModel

public class OrganizationUserBulkRequestModel
{
[Required]
[Required, MinLength(1)]
public IEnumerable<Guid> Ids { get; set; }
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
๏ปฟusing Bit.Core.Entities;
using Bit.Core.Enums;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;

public interface IRevokeOrganizationUserCommand
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
using Bit.Core.Repositories;
using Bit.Core.Services;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;

public class RevokeOrganizationUserCommand(
IEventService eventService,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
๏ปฟusing Bit.Core.AdminConsole.Utilities.v2;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;

public record UserAlreadyRevoked() : BadRequestError("Already revoked.");
public record CannotRevokeYourself() : BadRequestError("You cannot revoke yourself.");
public record OnlyOwnersCanRevokeOwners() : BadRequestError("Only owners can revoke other owners.");
public record MustHaveConfirmedOwner() : BadRequestError("Organization must have at least one confirmed owner.");
Copy link
Contributor

Choose a reason for hiding this comment

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

๐Ÿ’ญ The MustHaveConfirmedOwner error is defined but never used in the codebase. The v1 implementation checked for this condition with HasConfirmedOwnersExceptAsync, but the v2 implementation removed this check entirely.

Why this matters:
This could allow revoking the last owner of an organization, which would leave the organization in an unmanageable state. The v1 implementation prevented this at lines 62-66 and 84-87.

Recommendation:
Either implement the check in the validator and use this error, or remove the unused error definition. Based on the v1 implementation and business logic, the check should be implemented.

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
๏ปฟusing Bit.Core.AdminConsole.Utilities.v2.Results;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;

public interface IRevokeOrganizationUserCommand
{
Task<IEnumerable<BulkCommandResult>> RevokeUsersAsync(RevokeOrganizationUsersRequest request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
๏ปฟusing Bit.Core.AdminConsole.Utilities.v2.Validation;
using Bit.Core.Entities;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;

public interface IRevokeOrganizationUserValidator
{
Task<ICollection<ValidationResult<OrganizationUser>>> ValidateAsync(RevokeOrganizationUsersValidationRequest request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
๏ปฟusing Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.Utilities.v2.Results;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
using OneOf.Types;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;

public class RevokeOrganizationUserCommand(
IOrganizationUserRepository organizationUserRepository,
IEventService eventService,
IPushNotificationService pushNotificationService,
IRevokeOrganizationUserValidator validator,
TimeProvider timeProvider,
ILogger<RevokeOrganizationUserCommand> logger)
: IRevokeOrganizationUserCommand
{
public async Task<IEnumerable<BulkCommandResult>> RevokeUsersAsync(RevokeOrganizationUsersRequest request)
{
var validationRequest = await CreateValidationRequestsAsync(request);

var results = await validator.ValidateAsync(validationRequest);

var validUsers = results.Where(r => r.IsValid).Select(r => r.Request).ToList();

await RevokeValidUsersAsync(validUsers);

await Task.WhenAll(
LogRevokedOrganizationUsersAsync(validUsers, request.PerformedBy),
SendPushNotificationsAsync(validUsers)
);

return results.Select(r => r.Match(
error => new BulkCommandResult(r.Request.Id, error),
_ => new BulkCommandResult(r.Request.Id, new None())
));
}

private async Task<RevokeOrganizationUsersValidationRequest> CreateValidationRequestsAsync(
Copy link
Contributor

Choose a reason for hiding this comment

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

๐ŸŽจ Method can be made static or simplified

The CreateValidationRequestsAsync method only uses injected dependencies to fetch data and doesn't use any instance state. Consider:

  1. Making the repository calls directly in RevokeUsersAsync to reduce indirection
  2. Or making this method static if you prefer the separation

This would improve code clarity and reduce unnecessary method complexity.

RevokeOrganizationUsersRequest request)
{
var organizationUserToRevoke = await organizationUserRepository
.GetManyAsync(request.OrganizationUserIdsToRevoke);

return new RevokeOrganizationUsersValidationRequest(
request.OrganizationId,
request.OrganizationUserIdsToRevoke,
request.PerformedBy,
organizationUserToRevoke);
}

private async Task RevokeValidUsersAsync(ICollection<OrganizationUser> validUsers)
{
if (validUsers.Count == 0)
{
return;
}

await organizationUserRepository.RevokeManyByIdAsync(validUsers.Select(u => u.Id));
Copy link
Contributor

Choose a reason for hiding this comment

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

โš ๏ธ Potential performance issue with batched operations

The v1 implementation called RevokeAsync individually for each user (line 117 in v1), but v2 uses RevokeManyByIdAsync for all valid users at once. While this is generally more efficient, it creates a problem:

Issue:
If the database operation fails midway through a batch update, some users may be revoked while others aren't, but all would be marked as success in the results since validation passed.

The v1 approach:

  • Revoked users one at a time in a try-catch
  • Could partially succeed and return mixed results
  • Each user's success/failure was independent

Recommendation:
Consider whether the all-or-nothing batch approach is appropriate, or if partial success handling is needed. If partial failures are possible, wrap this in transaction handling or add error handling that updates the results accordingly.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It would not be marked as a success because the repository would throw the exception and the transaction would be rolled back. This would be an "all-or-nothing" batch and the user would get a 500 error.

}

private async Task LogRevokedOrganizationUsersAsync(
ICollection<OrganizationUser> revokedUsers,
IActingUser actingUser)
{
if (revokedUsers.Count == 0)
{
return;
}

var eventDate = timeProvider.GetUtcNow().UtcDateTime;

if (actingUser is SystemUser { SystemUserType: not null })
{
var revokeEventsWithSystem = revokedUsers
.Select(user => (user, EventType.OrganizationUser_Revoked, actingUser.SystemUserType!.Value,
(DateTime?)eventDate))
.ToList();
await eventService.LogOrganizationUserEventsAsync(revokeEventsWithSystem);
}
else
{
var revokeEvents = revokedUsers
.Select(user => (user, EventType.OrganizationUser_Revoked, (DateTime?)eventDate))
.ToList();
await eventService.LogOrganizationUserEventsAsync(revokeEvents);
}
}

private async Task SendPushNotificationsAsync(ICollection<OrganizationUser> revokedUsers)
{
var userIdsToNotify = revokedUsers
.Where(user => user.UserId.HasValue)
.Select(user => user.UserId!.Value)
.Distinct()
.ToList();

foreach (var userId in userIdsToNotify)
{
try
{
await pushNotificationService.PushSyncOrgKeysAsync(userId);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send push notification for user {UserId}.", userId);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
๏ปฟusing Bit.Core.AdminConsole.Models.Data;
using Bit.Core.Entities;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;

public record RevokeOrganizationUsersRequest(
Guid OrganizationId,
ICollection<Guid> OrganizationUserIdsToRevoke,
IActingUser PerformedBy
);

public record RevokeOrganizationUsersValidationRequest(
Guid OrganizationId,
ICollection<Guid> OrganizationUserIdsToRevoke,
IActingUser PerformedBy,
ICollection<OrganizationUser> OrganizationUsersToRevoke
) : RevokeOrganizationUsersRequest(OrganizationId, OrganizationUserIdsToRevoke, PerformedBy);
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
๏ปฟusing Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Utilities.v2.Validation;
using Bit.Core.Entities;
using Bit.Core.Enums;
using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;

public class RevokeOrganizationUsersValidator(IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery)
: IRevokeOrganizationUserValidator
{
public async Task<ICollection<ValidationResult<OrganizationUser>>> ValidateAsync(
RevokeOrganizationUsersValidationRequest request)
{
var hasRemainingOwner = await hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(request.OrganizationId,
request.OrganizationUsersToRevoke.Select(x => x.Id) // users excluded because they are going to be revoked
);

return request.OrganizationUsersToRevoke.Select(x =>
{
return x switch
{
_ when request.PerformedBy is not SystemUser
&& x.UserId is not null
&& x.UserId == request.PerformedBy.UserId =>
Invalid(x, new CannotRevokeYourself()),
{ Status: OrganizationUserStatusType.Revoked } =>
Invalid(x, new UserAlreadyRevoked()),
{ Type: OrganizationUserType.Owner } when !hasRemainingOwner =>
Invalid(x, new MustHaveConfirmedOwner()),
{ Type: OrganizationUserType.Owner } when !request.PerformedBy.IsOrganizationOwnerOrProvider =>
Invalid(x, new OnlyOwnersCanRevokeOwners()),

_ => Valid(x)
};
}).ToList();
}
}
1 change: 1 addition & 0 deletions src/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ public static class FeatureFlagKeys
public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache";
public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration";
public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud";
public const string BulkRevokeUsersV2 = "pm-28456-bulk-revoke-users-v2";

/* Architecture */
public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

using V1_RevokeUsersCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
using V2_RevokeUsersCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;

namespace Bit.Core.OrganizationFeatures;

public static class OrganizationServiceCollectionExtensions
Expand Down Expand Up @@ -133,7 +136,6 @@ private static void AddOrganizationUserCommands(this IServiceCollection services
{
services.AddScoped<IRemoveOrganizationUserCommand, RemoveOrganizationUserCommand>();
services.AddScoped<IRevokeNonCompliantOrganizationUserCommand, RevokeNonCompliantOrganizationUserCommand>();
services.AddScoped<IRevokeOrganizationUserCommand, RevokeOrganizationUserCommand>();
services.AddScoped<IUpdateOrganizationUserCommand, UpdateOrganizationUserCommand>();
services.AddScoped<IUpdateOrganizationUserGroupsCommand, UpdateOrganizationUserGroupsCommand>();
services.AddScoped<IConfirmOrganizationUserCommand, ConfirmOrganizationUserCommand>();
Expand All @@ -143,6 +145,11 @@ private static void AddOrganizationUserCommands(this IServiceCollection services

services.AddScoped<IDeleteClaimedOrganizationUserAccountCommand, DeleteClaimedOrganizationUserAccountCommand>();
services.AddScoped<IDeleteClaimedOrganizationUserAccountValidator, DeleteClaimedOrganizationUserAccountValidator>();

services.AddScoped<V1_RevokeUsersCommand.IRevokeOrganizationUserCommand, V1_RevokeUsersCommand.RevokeOrganizationUserCommand>();

services.AddScoped<V2_RevokeUsersCommand.IRevokeOrganizationUserCommand, V2_RevokeUsersCommand.RevokeOrganizationUserCommand>();
services.AddScoped<V2_RevokeUsersCommand.IRevokeOrganizationUserValidator, V2_RevokeUsersCommand.RevokeOrganizationUsersValidator>();
}

private static void AddOrganizationApiKeyCommandsQueries(this IServiceCollection services)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -625,7 +625,11 @@ public async Task RevokeManyByIdAsync(IEnumerable<Guid> organizationUserIds)

await connection.ExecuteAsync(
"[dbo].[OrganizationUser_SetStatusForUsersByGuidIdArray]",
new { OrganizationUserIds = organizationUserIds.ToGuidIdArrayTVP(), Status = OrganizationUserStatusType.Revoked },
new
{
OrganizationUserIds = organizationUserIds.ToGuidIdArrayTVP(),
Status = OrganizationUserStatusType.Revoked
},
commandType: CommandType.StoredProcedure);
}

Expand Down
Loading
Loading