Skip to content
Draft
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
25 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
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
53 changes: 53 additions & 0 deletions src/Api/AdminConsole/Controllers/BaseAdminConsoleController.cs
Copy link
Member

Choose a reason for hiding this comment

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

๐ŸŽ‰ Thanks for starting this!

Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
๏ปฟusing Bit.Core.AdminConsole.Utilities.v2;
using Bit.Core.AdminConsole.Utilities.v2.Results;
using Bit.Core.Models.Api;
using Microsoft.AspNetCore.Mvc;

namespace Bit.Api.AdminConsole.Controllers;

public abstract class BaseAdminConsoleController : Controller
{
protected static IResult Handle<T>(CommandResult<T> commandResult) =>
commandResult.Match<IResult>(
error => error switch
{
BadRequestError badRequest => TypedResults.BadRequest(new ErrorResponseModel(badRequest.Message)),
NotFoundError notFound => TypedResults.NotFound(new ErrorResponseModel(notFound.Message)),
InternalError internalError => TypedResults.Json(
new ErrorResponseModel(internalError.Message),
statusCode: StatusCodes.Status500InternalServerError),
_ => TypedResults.Json(
new ErrorResponseModel(commandResult.AsError.Message),
Copy link
Member

Choose a reason for hiding this comment

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

AsError is not typesafe, sometimes it can't be helped but here you can use the inner variable error to access this in a typesafe way. (as is the intention of Match)

statusCode: StatusCodes.Status500InternalServerError
)
},
_ => TypedResults.NoContent()
Copy link
Member

Choose a reason for hiding this comment

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

This is the "non error" case, so presumably the command has succeeded; should this be an OK response instead?

Copy link
Member

Choose a reason for hiding this comment

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

๐Ÿ’ญ (non-blocking) Also, given that this is CommandResult<T>, it suggests that the response to the client should include content. Maybe it could take a Func<T, R> to handle the conversion to the api response model in the non-error case.

);

protected static IResult Handle(BulkCommandResult commandResult) =>
commandResult.Result.Match<IResult>(
error => error switch
{
NotFoundError notFoundError => TypedResults.NotFound(new ErrorResponseModel(notFoundError.Message)),
_ => TypedResults.BadRequest(new ErrorResponseModel(error.Message))
},
_ => TypedResults.NoContent()
);
Comment on lines +27 to +35
Copy link
Member

Choose a reason for hiding this comment

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

This is not generally how we'd handle a BulkCommandResponse; see the BulkDeleteAccount endpoint as a better example. (i.e. it's usually mapped to a BulkResponseModel with an OK response because there is no single success or failure.)

I see that you're currently using this on the (non-bulk) DeleteAccount endpoint which is why it "works", but I think that indicates that the non-bulk endpoint shouldn't be handling a BulkCommandResponse.


protected static IResult Handle(CommandResult commandResult) =>
Copy link
Member

Choose a reason for hiding this comment

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

Same feedback as above for Handle(CommandResult<T>).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For this one, its a CommandResult which returns a None. Do we want to return the none? or just return the Ok/NoContent directly since None implies no return object.

Also, for this one, would the NoContent be more accurate as its a 200 code (success) but gives the additional information of telling the client that there's no body to look for? (I know we don't do this else where but is a good API practice.)

I'll change for consistency if that's better.

commandResult.Match<IResult>(
error => error switch
{
BadRequestError badRequest => TypedResults.BadRequest(new ErrorResponseModel(badRequest.Message)),
NotFoundError notFound => TypedResults.NotFound(new ErrorResponseModel(notFound.Message)),
InternalError internalError => TypedResults.Json(
new ErrorResponseModel(internalError.Message),
statusCode: StatusCodes.Status500InternalServerError),
_ => TypedResults.Json(
new ErrorResponseModel(commandResult.AsError.Message),
statusCode: StatusCodes.Status500InternalServerError
)
},
_ => TypedResults.NoContent()
);
}
48 changes: 37 additions & 11 deletions src/Api/AdminConsole/Controllers/OrganizationUsersController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
Expand All @@ -24,7 +26,6 @@
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Api;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
Expand All @@ -41,7 +42,7 @@ namespace Bit.Api.AdminConsole.Controllers;

[Route("organizations/{orgId}/users")]
[Authorize("Application")]
public class OrganizationUsersController : Controller
public class OrganizationUsersController : BaseAdminConsoleController
{
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
Expand All @@ -66,6 +67,8 @@ public class OrganizationUsersController : Controller
private readonly IFeatureService _featureService;
private readonly IPricingClient _pricingClient;
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
private readonly IAutomaticallyConfirmOrganizationUserCommand _automaticallyConfirmOrganizationUserCommand;
private readonly TimeProvider _timeProvider;
private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
Expand Down Expand Up @@ -97,7 +100,9 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
IInitPendingOrganizationCommand initPendingOrganizationCommand,
IRevokeOrganizationUserCommand revokeOrganizationUserCommand,
IResendOrganizationInviteCommand resendOrganizationInviteCommand)
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand,
TimeProvider timeProvider)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
Expand All @@ -122,6 +127,8 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor
_featureService = featureService;
_pricingClient = pricingClient;
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
_automaticallyConfirmOrganizationUserCommand = automaticallyConfirmOrganizationUserCommand;
_timeProvider = timeProvider;
_confirmOrganizationUserCommand = confirmOrganizationUserCommand;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
_initPendingOrganizationCommand = initPendingOrganizationCommand;
Expand Down Expand Up @@ -544,14 +551,7 @@ public async Task<IResult> DeleteAccount(Guid orgId, Guid id)
return TypedResults.Unauthorized();
}

var commandResult = await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUserId.Value);

return commandResult.Result.Match<IResult>(
error => error is NotFoundError
? TypedResults.NotFound(new ErrorResponseModel(error.Message))
: TypedResults.BadRequest(new ErrorResponseModel(error.Message)),
TypedResults.Ok
);
return Handle(await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUserId.Value));
Copy link
Member

Choose a reason for hiding this comment

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

๐ŸŽจ (non-blocking) assigning the result to a var commandResult variable makes for easier debugging.

}

[HttpPost("{id}/delete-account")]
Expand Down Expand Up @@ -691,6 +691,32 @@ public async Task PatchBulkEnableSecretsManagerAsync(Guid orgId,
await BulkEnableSecretsManagerAsync(orgId, model);
}

[HttpPost("{id}/auto-confirm")]
[Authorize<ManageUsersRequirement>]
[RequireFeature(FeatureFlagKeys.AutomaticConfirmUsers)]
public async Task<IResult> AutomaticallyConfirmOrganizationUserAsync([FromRoute] Guid orgId,
[FromRoute] Guid id,
[FromBody] OrganizationUserConfirmRequestModel model)
{
var userId = _userService.GetProperUserId(User);

if (userId is null || userId.Value == Guid.Empty)
{
return TypedResults.Unauthorized();
}

return Handle(await _automaticallyConfirmOrganizationUserCommand.AutomaticallyConfirmOrganizationUserAsync(
new AutomaticallyConfirmOrganizationUserRequest
{
OrganizationId = orgId,
OrganizationUserId = id,
Key = model.Key,
DefaultUserCollectionName = model.DefaultUserCollectionName,
PerformedBy = new StandardUser(userId.Value, await _currentContext.OrganizationOwner(orgId)),
PerformedOn = _timeProvider.GetUtcNow()
Copy link
Member

Choose a reason for hiding this comment

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

Passing in the date from the controller level seems unexpected to me. It's not information from the request or otherwise from the API layer (like claims in currentContext). Is there any reason the command can't get this for itself? That would also make the date more accurate because it could be fetched at the time of performing the operation. (even though the difference is probably minimal)

}));
}

private async Task RestoreOrRevokeUserAsync(
Guid orgId,
Guid id,
Expand Down
1 change: 1 addition & 0 deletions src/Core/AdminConsole/Enums/EventType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public enum EventType : int
OrganizationUser_RejectedAuthRequest = 1514,
OrganizationUser_Deleted = 1515, // Both user and organization user data were deleted
OrganizationUser_Left = 1516, // User voluntarily left the organization
OrganizationUser_AutomaticallyConfirmed = 1517,

Organization_Updated = 1600,
Organization_PurgedVault = 1601,
Expand Down
Copy link
Contributor

Choose a reason for hiding this comment

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

We should add unit tests for this command

Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
๏ปฟusing Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Utilities.v2.Results;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
using OneOf.Types;
using CommandResult = Bit.Core.AdminConsole.Utilities.v2.Results.CommandResult;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;

public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IAutomaticallyConfirmOrganizationUsersValidator validator,
IEventService eventService,
IMailService mailService,
IUserRepository userRepository,
IPushRegistrationService pushRegistrationService,
IDeviceRepository deviceRepository,
IPushNotificationService pushNotificationService,
IFeatureService featureService,
IPolicyRequirementQuery policyRequirementQuery,
ICollectionRepository collectionRepository,
ILogger<AutomaticallyConfirmOrganizationUserCommand> logger) : IAutomaticallyConfirmOrganizationUserCommand
{
public async Task<CommandResult> AutomaticallyConfirmOrganizationUserAsync(AutomaticallyConfirmOrganizationUserRequest request)
{
var requestData = await RetrieveDataAsync(request);

if (requestData.IsError) return requestData.AsError;
Copy link
Member

Choose a reason for hiding this comment

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

Here, I got you these: { and }

(Please avoid single line if statements.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

They should make a linter for this.


var validatedData = await validator.ValidateAsync(requestData.AsSuccess);

if (validatedData.IsError) return validatedData.AsError;

var validatedRequest = validatedData.Request;

var successfulConfirmation = await organizationUserRepository.ConfirmOrganizationUserAsync(validatedRequest.OrganizationUser);

if (!successfulConfirmation) return new None();
Copy link
Contributor

Choose a reason for hiding this comment

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

I would add a comment here to explain that the only reason this would not be successful is if the user is already confirmed. Otherwise it seems like silent failure.

Copy link
Member

Choose a reason for hiding this comment

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

I think it's also worth explaining here that this is idempotent and designed to handle multiple simultaneous requests from logged in clients.


return await validatedRequest.ToCommandResultAsync()
.MapAsync(CreateDefaultCollectionsAsync)
.MapAsync(LogOrganizationUserConfirmedEventAsync)
.MapAsync(SendConfirmedOrganizationUserEmailAsync)
.MapAsync(DeleteDeviceRegistrationAsync)
.MapAsync(PushSyncOrganizationKeysAsync)
.ToResultAsync();
Comment on lines +58 to +64
Copy link
Member

Choose a reason for hiding this comment

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

It's worth considering error handling here. You do handle all errors, but then each private method here is returning an Error object, which will shortcut all following steps. But do we want to skip sending emails just because the event log failed; or skip logging the event because we couldn't create the default collection?

I tend to think that all these methods here should log but otherwise swallow errors. We've already confirmed the user, we should do as many side effects that we can. (This should be noted in a comment.)

}

private async Task<CommandResult<AutomaticallyConfirmOrganizationUserRequestData>> CreateDefaultCollectionsAsync(
AutomaticallyConfirmOrganizationUserRequestData request)
{
try
{
if (!featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
Copy link
Contributor

Choose a reason for hiding this comment

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

Extract all these conditions into a private method

|| string.IsNullOrWhiteSpace(request.DefaultUserCollectionName)
|| !(await policyRequirementQuery.GetAsync<OrganizationDataOwnershipPolicyRequirement>(request.UserId))
.RequiresDefaultCollectionOnConfirm(request.Organization.Id))
{
return request;
}

await collectionRepository.CreateAsync(
new Collection
{
OrganizationId = request.Organization.Id,
Name = request.DefaultUserCollectionName,
Type = CollectionType.DefaultUserCollection
},
groups: null,
[new CollectionAccessSelection
{
Id = request.OrganizationUser.Id,
Manage = true
}]);
Comment on lines +80 to +92
Copy link
Member

Choose a reason for hiding this comment

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

๐Ÿ’ญ (non-blocking) Thoughts on making this an idempotent repository method as well? e.g. CreateDefaultCollectionAsync. Given that we are creating it in multiple places and creating default collections properly is quite important. (not this PR though)


return request;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create default collection for user.");
return new CommandResult<AutomaticallyConfirmOrganizationUserRequestData>(new FailedToCreateDefaultCollection());
}
}

private async Task<CommandResult<AutomaticallyConfirmOrganizationUserRequestData>> PushSyncOrganizationKeysAsync(AutomaticallyConfirmOrganizationUserRequestData request)
{
try
{
await pushNotificationService.PushSyncOrgKeysAsync(request.UserId);
return request;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to push organization keys.");
return new FailedToPushOrganizationSyncKeys();
}
}

private async Task<CommandResult<AutomaticallyConfirmOrganizationUserRequestData>> LogOrganizationUserConfirmedEventAsync(
AutomaticallyConfirmOrganizationUserRequestData request)
{
try
{
await eventService.LogOrganizationUserEventAsync(request.OrganizationUser,
EventType.OrganizationUser_AutomaticallyConfirmed,
request.PerformedOn.UtcDateTime);
return request;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to log OrganizationUser_AutomaticallyConfirmed event.");
return new FailedToWriteToEventLog();
}
}

private async Task<CommandResult<AutomaticallyConfirmOrganizationUserRequestData>> SendConfirmedOrganizationUserEmailAsync(
AutomaticallyConfirmOrganizationUserRequestData request)
{
try
{
var user = await userRepository.GetByIdAsync(request.UserId);

if (user is null) return new UserNotFoundError();

await mailService.SendOrganizationConfirmedEmailAsync(request.Organization.Name,
user.Email,
request.OrganizationUser.AccessSecretsManager);

return request;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send OrganizationUserConfirmed.");
return new FailedToSendConfirmedUserEmail();
}
}

private async Task<CommandResult<AutomaticallyConfirmOrganizationUserRequestData>> DeleteDeviceRegistrationAsync(
AutomaticallyConfirmOrganizationUserRequestData request)
{
try
{
var devices = (await deviceRepository.GetManyByUserIdAsync(request.UserId))
.Where(d => !string.IsNullOrWhiteSpace(d.PushToken))
.Select(d => d.Id.ToString());

await pushRegistrationService.DeleteUserRegistrationOrganizationAsync(devices, request.Organization.Id.ToString());
return request;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete device registration.");
return new FailedToDeleteDeviceRegistration();
}
}

private async Task<CommandResult<AutomaticallyConfirmOrganizationUserRequestData>> RetrieveDataAsync(
AutomaticallyConfirmOrganizationUserRequest request)
{
var organizationUser = await GetOrganizationUserAsync(request.OrganizationUserId);

if (organizationUser.IsError) return organizationUser.AsError;

var organization = await GetOrganizationAsync(request.OrganizationId);

if (organization.IsError) return organization.AsError;

return new AutomaticallyConfirmOrganizationUserRequestData
Copy link
Member

Choose a reason for hiding this comment

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

I suggest that it's clearer to call this the ValidationRequest; it's an enriched DTO required by the validator. The overall flow of data then looks like: API request -> Command Request -> Validation Request -> Validation Result -> Command Result -> API response... which is nice and symmetrical. The Data suffix is not very clear by comparison. (it's not a data model)

{
OrganizationUser = organizationUser.AsSuccess,
Organization = organization.AsSuccess,
PerformedBy = request.PerformedBy,
Key = request.Key,
DefaultUserCollectionName = request.DefaultUserCollectionName,
PerformedOn = request.PerformedOn
};
}

private async Task<CommandResult<OrganizationUser>> GetOrganizationUserAsync(Guid organizationUserId)
{
var organizationUser = await organizationUserRepository.GetByIdAsync(organizationUserId);

return organizationUser is { UserId: not null } ? organizationUser : new UserNotFoundError();
}

private async Task<CommandResult<Organization>> GetOrganizationAsync(Guid organizationId)
{
var organization = await organizationRepository.GetByIdAsync(organizationId);

return organization is not null ? organization : new OrganizationNotFound();
}
}
Loading
Loading