-
Notifications
You must be signed in to change notification settings - Fork 1.5k
[PM-26636] - Auto Confirm Org User Command #6488
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 57 commits
14f3e88
4645222
6a29377
89b8d59
f60f0f5
0ba770d
e5f07de
5527ca6
27ce4b8
2069c9f
cb81d29
24a39d8
a1b2bac
7307454
1303b22
4bb8734
68afb17
aee2734
031c080
805eba9
ce596b2
6e5188f
d6a5813
9d47bf4
929ac41
f648953
f800119
2e80a4d
9b009b2
e1eeaf9
3af7ba4
cd1eca4
34a05f4
72fc706
781d6e6
e5ec8d7
eb5f98e
7fb8910
3ee2bf4
44a5813
a35c789
a9d4dc3
290f391
f02f761
fa18f3b
0487fb4
084849a
46b7bfd
b4d5d50
8dd84e2
b91def1
032b1f8
cdcff93
7a5fd87
c301185
4551891
6187cf0
2b0b1ee
80872b8
6b0af95
64d3b1e
3fb9810
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| ๏ปฟ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(CommandResult commandResult) => | ||
jrmccannon marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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() | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,15 +11,18 @@ | |
| 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; | ||
| using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; | ||
| using Bit.Core.AdminConsole.OrganizationFeatures.Policies; | ||
| using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; | ||
| using Bit.Core.AdminConsole.Repositories; | ||
| using Bit.Core.AdminConsole.Utilities.v2; | ||
| using Bit.Core.Auth.Enums; | ||
| using Bit.Core.Auth.Repositories; | ||
| using Bit.Core.Billing.Pricing; | ||
|
|
@@ -43,7 +46,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; | ||
|
|
@@ -68,6 +71,7 @@ public class OrganizationUsersController : Controller | |
| private readonly IFeatureService _featureService; | ||
| private readonly IPricingClient _pricingClient; | ||
| private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; | ||
| private readonly IAutomaticallyConfirmOrganizationUserCommand _automaticallyConfirmOrganizationUserCommand; | ||
| private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand; | ||
| private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; | ||
| private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand; | ||
|
|
@@ -101,7 +105,8 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor | |
| IInitPendingOrganizationCommand initPendingOrganizationCommand, | ||
| IRevokeOrganizationUserCommand revokeOrganizationUserCommand, | ||
| IResendOrganizationInviteCommand resendOrganizationInviteCommand, | ||
| IAdminRecoverAccountCommand adminRecoverAccountCommand) | ||
| IAdminRecoverAccountCommand adminRecoverAccountCommand, | ||
| IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand) | ||
| { | ||
| _organizationRepository = organizationRepository; | ||
| _organizationUserRepository = organizationUserRepository; | ||
|
|
@@ -126,6 +131,7 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor | |
| _featureService = featureService; | ||
| _pricingClient = pricingClient; | ||
| _resendOrganizationInviteCommand = resendOrganizationInviteCommand; | ||
| _automaticallyConfirmOrganizationUserCommand = automaticallyConfirmOrganizationUserCommand; | ||
| _confirmOrganizationUserCommand = confirmOrganizationUserCommand; | ||
| _restoreOrganizationUserCommand = restoreOrganizationUserCommand; | ||
| _initPendingOrganizationCommand = initPendingOrganizationCommand; | ||
|
|
@@ -738,6 +744,31 @@ 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)), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. โ Authorization question: The Is it intentional that only Owners (not Admins) are allowed to auto-confirm users? If Admins should also be able to auto-confirm, this check may be too restrictive. The
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. โ Authorization inconsistency: The However, the endpoint has This creates an inconsistency where:
Questions:
Note: This same pattern exists in other endpoints, but for auto-confirm specifically, the validator doesn't seem to check the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐ญ Authorization semantic question: The However, the endpoint has Questions:
The validator doesn't check
jrmccannon marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| })); | ||
| } | ||
|
|
||
| private async Task RestoreOrRevokeUserAsync( | ||
| Guid orgId, | ||
| Guid id, | ||
|
|
||
eliykat marked this conversation as resolved.
Show resolved
Hide resolved
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| ๏ปฟnamespace Bit.Core.AdminConsole.Models.Data.OrganizationUsers; | ||
|
|
||
| public record AcceptedOrganizationUserToConfirm | ||
| { | ||
| public required Guid OrganizationUserId { get; init; } | ||
| public required Guid UserId { get; init; } | ||
| public required string Key { get; init; } | ||
| } |
jrmccannon marked this conversation as resolved.
Show resolved
Hide resolved
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| ๏ปฟusing Bit.Core.AdminConsole.Models.Data.OrganizationUsers; | ||
| using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; | ||
| using Bit.Core.AdminConsole.OrganizationFeatures.Policies; | ||
| using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; | ||
| 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, | ||
| IPolicyRequirementQuery policyRequirementQuery, | ||
| ICollectionRepository collectionRepository, | ||
| TimeProvider timeProvider, | ||
| ILogger<AutomaticallyConfirmOrganizationUserCommand> logger) : IAutomaticallyConfirmOrganizationUserCommand | ||
| { | ||
| public async Task<CommandResult> AutomaticallyConfirmOrganizationUserAsync(AutomaticallyConfirmOrganizationUserRequest request) | ||
| { | ||
| var validatorRequest = await RetrieveDataAsync(request); | ||
|
|
||
| var validatedData = await validator.ValidateAsync(validatorRequest); | ||
|
|
||
| return await validatedData.Match<Task<CommandResult>>( | ||
| error => Task.FromResult(new CommandResult(error)), | ||
| async _ => | ||
| { | ||
| var userToConfirm = new AcceptedOrganizationUserToConfirm | ||
| { | ||
| OrganizationUserId = validatedData.Request.OrganizationUser!.Id, | ||
| UserId = validatedData.Request.OrganizationUser.UserId!.Value, | ||
| Key = validatedData.Request.Key | ||
| }; | ||
|
|
||
| // This operation is idempotent. If false, the user is already confirmed and no additional side effects are required. | ||
| if (!await organizationUserRepository.ConfirmOrganizationUserAsync(userToConfirm)) | ||
| { | ||
| return new None(); | ||
| } | ||
|
|
||
| await CreateDefaultCollectionsAsync(validatedData.Request); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐ Excellent work addressing the previous feedback! Creating the default collection before the parallel tasks ensures it exists before the sync notification is sent to clients. This prevents a race condition where clients might sync before the collection is created. |
||
|
|
||
| await Task.WhenAll( | ||
jrmccannon marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| LogOrganizationUserConfirmedEventAsync(validatedData.Request), | ||
| SendConfirmedOrganizationUserEmailAsync(validatedData.Request), | ||
| SyncOrganizationKeysAsync(validatedData.Request) | ||
| ); | ||
|
|
||
| return new None(); | ||
| } | ||
| ); | ||
| } | ||
|
|
||
| private async Task SyncOrganizationKeysAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐ญ Sequential operation question: This method enforces sequential execution: delete device registrations first, then push sync keys. Question: Is there a dependency between these operations? For example:
If there's no dependency, these could be parallelized with the other operations. If there is a dependency, a comment explaining why would be helpful for future maintainers. Given the method name |
||
| { | ||
| await DeleteDeviceRegistrationAsync(request); | ||
| await PushSyncOrganizationKeysAsync(request); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If there's a dependency between deleting device registrations and pushing sync keys (e.g., the push notification should only go to devices that haven't been unregistered), the parallel execution could cause the push to go to already-deleted registrations. Recommendation: Either:
|
||
| } | ||
|
|
||
| private async Task CreateDefaultCollectionsAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) | ||
| { | ||
| try | ||
| { | ||
| if (!await ShouldCreateDefaultCollectionAsync(request)) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| 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 | ||
| }]); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| logger.LogError(ex, "Failed to create default collection for user."); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Determines whether a default collection should be created for an organization user during the confirmation process. | ||
| /// </summary> | ||
| /// <param name="request"> | ||
| /// The validation request containing information about the user, organization, and collection settings. | ||
| /// </param> | ||
| /// <returns>The result is a boolean value indicating whether a default collection should be created.</returns> | ||
| private async Task<bool> ShouldCreateDefaultCollectionAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) => | ||
| !string.IsNullOrWhiteSpace(request.DefaultUserCollectionName) | ||
| && (await policyRequirementQuery.GetAsync<OrganizationDataOwnershipPolicyRequirement>(request.OrganizationUser!.UserId!.Value)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐ Code clarity: The null-forgiving operators ( // Safe to use ! operators: validator ensures OrganizationUser and UserId are non-null
&& (await policyRequirementQuery.GetAsync<OrganizationDataOwnershipPolicyRequirement>(request.OrganizationUser!.UserId!.Value))
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐จ Null-forgiving operator usage: The null-forgiving operators ( The compiler won't prevent you from accessing Non-blocking observation: This is a broader architectural consideration about the tradeoff between FP patterns (OneOf/Result types) and C#'s nullable reference types. The current approach works but requires discipline to ensure validation always precedes usage of these operators. |
||
| .RequiresDefaultCollectionOnConfirm(request.Organization!.Id); | ||
|
|
||
| private async Task PushSyncOrganizationKeysAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) | ||
| { | ||
| try | ||
| { | ||
| await pushNotificationService.PushSyncOrgKeysAsync(request.OrganizationUser!.UserId!.Value); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| logger.LogError(ex, "Failed to push organization keys."); | ||
| } | ||
| } | ||
|
|
||
| private async Task LogOrganizationUserConfirmedEventAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) | ||
| { | ||
| try | ||
| { | ||
| await eventService.LogOrganizationUserEventAsync(request.OrganizationUser, | ||
| EventType.OrganizationUser_AutomaticallyConfirmed, | ||
| timeProvider.GetUtcNow().UtcDateTime); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| logger.LogError(ex, "Failed to log OrganizationUser_AutomaticallyConfirmed event."); | ||
| } | ||
| } | ||
|
|
||
| private async Task SendConfirmedOrganizationUserEmailAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) | ||
| { | ||
| try | ||
| { | ||
| var user = await userRepository.GetByIdAsync(request.OrganizationUser!.UserId!.Value); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested fix: var user = await userRepository.GetByIdAsync(request.OrganizationUser!.UserId!.Value);
if (user == null)
{
logger.LogError("User not found when sending confirmation email.");
return;
}
await mailService.SendOrganizationConfirmedEmailAsync(request.Organization!.Name,
user.Email,
request.OrganizationUser.AccessSecretsManager);This is caught by the try-catch, but explicit null checking provides clearer error logging.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested fix: var user = await userRepository.GetByIdAsync(request.OrganizationUser!.UserId!.Value);
if (user == null)
{
logger.LogError("User {UserId} not found when sending confirmation email.", request.OrganizationUser.UserId.Value);
return;
}
await mailService.SendOrganizationConfirmedEmailAsync(request.Organization!.Name,
user.Email,
request.OrganizationUser.AccessSecretsManager);This is caught by the try-catch, but explicit null checking provides clearer error logging.
jrmccannon marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| await mailService.SendOrganizationConfirmedEmailAsync(request.Organization!.Name, | ||
| user!.Email, | ||
| request.OrganizationUser.AccessSecretsManager); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| logger.LogError(ex, "Failed to send OrganizationUserConfirmed."); | ||
| } | ||
| } | ||
|
|
||
| private async Task DeleteDeviceRegistrationAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) | ||
| { | ||
| try | ||
| { | ||
| var devices = (await deviceRepository.GetManyByUserIdAsync(request.OrganizationUser!.UserId!.Value)) | ||
| .Where(d => !string.IsNullOrWhiteSpace(d.PushToken)) | ||
| .Select(d => d.Id.ToString()); | ||
|
|
||
| await pushRegistrationService.DeleteUserRegistrationOrganizationAsync(devices, request.Organization!.Id.ToString()); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| logger.LogError(ex, "Failed to delete device registration."); | ||
| } | ||
| } | ||
|
|
||
| private async Task<AutomaticallyConfirmOrganizationUserValidationRequest> RetrieveDataAsync( | ||
| AutomaticallyConfirmOrganizationUserRequest request) | ||
| { | ||
| return new AutomaticallyConfirmOrganizationUserValidationRequest | ||
| { | ||
| OrganizationUserId = request.OrganizationUserId, | ||
| OrganizationId = request.OrganizationId, | ||
| Key = request.Key, | ||
| DefaultUserCollectionName = request.DefaultUserCollectionName, | ||
| PerformedBy = request.PerformedBy, | ||
| OrganizationUser = await organizationUserRepository.GetByIdAsync(request.OrganizationUserId), | ||
| Organization = await organizationRepository.GetByIdAsync(request.OrganizationId) | ||
| }; | ||
| } | ||
| } | ||
jrmccannon marked this conversation as resolved.
Show resolved
Hide resolved
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| ๏ปฟusing Bit.Core.AdminConsole.Entities; | ||
| using Bit.Core.AdminConsole.Models.Data; | ||
| using Bit.Core.Entities; | ||
|
|
||
| namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; | ||
|
|
||
| /// <summary> | ||
| /// Automatically Confirm User Command Request | ||
| /// </summary> | ||
| public record AutomaticallyConfirmOrganizationUserRequest | ||
| { | ||
| public required Guid OrganizationUserId { get; init; } | ||
| public required Guid OrganizationId { get; init; } | ||
| public required string Key { get; init; } | ||
| public required string DefaultUserCollectionName { get; init; } | ||
| public required IActingUser PerformedBy { get; init; } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Automatically Confirm User Validation Request | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// This is used to hold retrieved data and pass it to the validator | ||
| /// </remarks> | ||
| public record AutomaticallyConfirmOrganizationUserValidationRequest : AutomaticallyConfirmOrganizationUserRequest | ||
jrmccannon marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| { | ||
| public OrganizationUser? OrganizationUser { get; set; } | ||
| public Organization? Organization { get; set; } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.