Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 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
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, Func<T, IActionResult> resultSelector) =>
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(error.Message),
statusCode: StatusCodes.Status500InternalServerError
)
},
success => Results.Ok(resultSelector(success))
);

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(error.Message),
statusCode: StatusCodes.Status500InternalServerError
)
},
_ => TypedResults.NoContent()
);
}
47 changes: 37 additions & 10 deletions src/Api/AdminConsole/Controllers/OrganizationUsersController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
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.AccountRecovery;
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 Down Expand Up @@ -43,7 +45,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 @@ -68,6 +70,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 @@ -101,7 +105,9 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor
IInitPendingOrganizationCommand initPendingOrganizationCommand,
IRevokeOrganizationUserCommand revokeOrganizationUserCommand,
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
IAdminRecoverAccountCommand adminRecoverAccountCommand)
IAdminRecoverAccountCommand adminRecoverAccountCommand,
IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand,
TimeProvider timeProvider)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
Expand All @@ -126,6 +132,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 @@ -591,14 +599,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 @@ -738,6 +739,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
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
๏ปฟusing Bit.Core.Entities;
using Bit.Core.Enums;

namespace Bit.Core.AdminConsole.Models.Data.OrganizationUsers;

public class AcceptedOrganizationUser : OrganizationUser
{
public AcceptedOrganizationUser(OrganizationUser organizationUser, string key) : this(organizationUser)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
Key = key;
}

public AcceptedOrganizationUser(OrganizationUser organizationUser)
{
ArgumentNullException.ThrowIfNull(organizationUser);
ArgumentNullException.ThrowIfNull(organizationUser.UserId);

if (organizationUser.Status != OrganizationUserStatusType.Accepted)
{
throw new ArgumentException("The organization user must be accepted", nameof(organizationUser));
}

Id = organizationUser.Id;
OrganizationId = organizationUser.OrganizationId;
UserId = organizationUser.UserId.Value;
Email = organizationUser.Email;
Key = organizationUser.Key;
Type = organizationUser.Type;
ExternalId = organizationUser.ExternalId;
CreationDate = organizationUser.CreationDate;
RevisionDate = organizationUser.RevisionDate;
Permissions = organizationUser.Permissions;
ResetPasswordKey = organizationUser.ResetPasswordKey;
AccessSecretsManager = organizationUser.AccessSecretsManager;
}

public new Guid UserId { get; init; }
}
Loading
Loading