-
Notifications
You must be signed in to change notification settings - Fork 1.7k
[PM-38796] Create link confirmation endpoint #7907
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 all commits
6787271
e0fc69c
38b9bfe
468703b
f5579fc
c34980a
6397a25
27db29c
92afcf8
50bdddd
9d29fcd
5488c09
a8e3c0b
107c7d8
4db3ba3
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,29 @@ | ||
| ο»Ώusing System.ComponentModel.DataAnnotations; | ||
| using Bit.Core.Utilities; | ||
|
|
||
| namespace Bit.Api.AdminConsole.Models.Request.Organizations; | ||
|
|
||
| public class ConfirmOrganizationInviteLinkRequestModel | ||
| { | ||
| [Required] | ||
| public required Guid Code { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// The organization symmetric key encrypted to the user. | ||
| /// </summary> | ||
| [Required] | ||
| public required string OrgUserKey { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// The user's account recovery key, supplied when the organization enforces automatic enrollment. | ||
| /// </summary> | ||
| public string? ResetPasswordKey { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// The encrypted name of the default collection created when Organization Data Ownership applies. | ||
| /// </summary> | ||
| [Required] | ||
| [EncryptedString] | ||
| [EncryptedStringLength(1000)] | ||
| public required string DefaultUserCollectionName { get; set; } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,207 @@ | ||
| ο»Ώusing Bit.Core.AdminConsole.Entities; | ||
| using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; | ||
| using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUserResetPasswordEnrollment; | ||
| using Bit.Core.AdminConsole.OrganizationFeatures.Policies; | ||
| using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; | ||
| using Bit.Core.AdminConsole.Utilities.v2; | ||
| using Bit.Core.AdminConsole.Utilities.v2.Results; | ||
| using Bit.Core.Billing.Services; | ||
| using Bit.Core.Entities; | ||
| using Bit.Core.Enums; | ||
| using Bit.Core.Exceptions; | ||
| using Bit.Core.Platform.Push; | ||
| using Bit.Core.Repositories; | ||
| using Bit.Core.Services; | ||
| using Microsoft.Extensions.Logging; | ||
| using None = OneOf.Types.None; | ||
|
|
||
| namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; | ||
|
|
||
| /// <summary> | ||
| /// Confirms a user into an organization via an invite link. See | ||
| /// <see cref="IConfirmOrganizationInviteLinkCommand"/> for the behavior. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// Eligibility is delegated to <see cref="IConfirmOrganizationInviteLinkValidator"/>, which performs the | ||
| /// read-only prechecks. This command owns the write side effects: creating the membership when needed, | ||
| /// confirming it with the organization key, and running the policy-driven follow-ups. | ||
| /// </remarks> | ||
| public class ConfirmOrganizationInviteLinkCommand( | ||
| IConfirmOrganizationInviteLinkValidator confirmOrganizationInviteLinkValidator, | ||
| IOrganizationRepository organizationRepository, | ||
| IOrganizationUserRepository organizationUserRepository, | ||
| ICollectionRepository collectionRepository, | ||
| IPolicyRequirementQuery policyRequirementQuery, | ||
| IOrganizationService organizationService, | ||
| IStripePaymentService stripePaymentService, | ||
| IUpdateUserResetPasswordEnrollmentCommand updateUserResetPasswordEnrollmentCommand, | ||
| IPushNotificationService pushNotificationService, | ||
| ILogger<ConfirmOrganizationInviteLinkCommand> logger) | ||
| : IConfirmOrganizationInviteLinkCommand | ||
| { | ||
| public async Task<CommandResult> ConfirmAsync(ConfirmOrganizationInviteLinkRequest request) | ||
| { | ||
| var user = request.User; | ||
|
|
||
| var validationResult = await confirmOrganizationInviteLinkValidator.ValidateAsync( | ||
| new ConfirmOrganizationInviteLinkValidationRequest | ||
| { | ||
| Code = request.Code, | ||
| User = user, | ||
| }); | ||
| if (validationResult.IsError) | ||
| { | ||
| return validationResult.AsError; | ||
| } | ||
|
|
||
| var organization = validationResult.AsSuccess.Organization; | ||
| var existingOrganizationUser = validationResult.AsSuccess.ExistingOrganizationUser; | ||
|
|
||
| // Account recovery enrollment is validated before any writes so a missing key fails the request | ||
| // without leaving a partially confirmed membership behind. | ||
| var resetPasswordRequirement = await policyRequirementQuery.GetAsync<ResetPasswordPolicyRequirement>(user.Id); | ||
| var autoEnrollEnabled = resetPasswordRequirement.AutoEnrollEnabled(organization.Id); | ||
| if (autoEnrollEnabled && !OrganizationUser.IsValidResetPasswordKey(request.ResetPasswordKey)) | ||
| { | ||
| return new ConfirmResetPasswordKeyRequired(); | ||
| } | ||
|
|
||
| var membershipResult = await AddUserToOrganizationAsync(request, existingOrganizationUser, user, organization); | ||
| if (membershipResult.IsError) | ||
| { | ||
| return membershipResult.AsError; | ||
| } | ||
|
|
||
| var organizationUser = membershipResult.AsSuccess; | ||
|
|
||
| await CreateDefaultCollectionAsync(organization, organizationUser, request.DefaultUserCollectionName); | ||
|
|
||
| if (autoEnrollEnabled) | ||
| { | ||
| await updateUserResetPasswordEnrollmentCommand.UpdateUserResetPasswordEnrollmentAsync( | ||
| organization.Id, user.Id, request.ResetPasswordKey, user.Id); | ||
| } | ||
|
|
||
| // The membership now carries the organization key, so notify the user's other devices to sync it. | ||
| await pushNotificationService.PushSyncOrgKeysAsync(user.Id); | ||
|
|
||
| return new None(); | ||
| } | ||
|
|
||
| private async Task<CommandResult<OrganizationUser>> AddUserToOrganizationAsync(ConfirmOrganizationInviteLinkRequest request, | ||
| OrganizationUser? existingOrganizationUser, User user, Organization organization) | ||
| { | ||
| if (existingOrganizationUser is not null) | ||
| { | ||
| return await ConfirmExistingMembershipAsync(existingOrganizationUser, user, request.OrgUserKey); | ||
| } | ||
|
|
||
| return await CreateConfirmedMembershipAsync(organization, user, request.OrgUserKey); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Confirms an existing membership (a pending email invitation or an accepted membership) by linking | ||
| /// it to the user, releasing the org key, and moving it straight to <see cref="OrganizationUserStatusType.Confirmed"/>. | ||
| /// Persisting via <c>ReplaceAsync</c> bumps the user's account revision date so their other devices sync. | ||
| /// </summary> | ||
| private async Task<CommandResult<OrganizationUser>> ConfirmExistingMembershipAsync( | ||
| OrganizationUser existingOrganizationUser, User user, string orgUserKey) | ||
| { | ||
| existingOrganizationUser.Status = OrganizationUserStatusType.Confirmed; | ||
| existingOrganizationUser.UserId = user.Id; | ||
| existingOrganizationUser.Email = null; | ||
| existingOrganizationUser.Key = orgUserKey; | ||
|
|
||
| await organizationUserRepository.ReplaceAsync(existingOrganizationUser); | ||
|
|
||
| return existingOrganizationUser; | ||
| } | ||
|
Comment on lines
+107
to
+118
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. Details and fix
The sibling if (existingOrganizationUser.Type is OrganizationUserType.Owner or OrganizationUserType.Admin &&
organization.PlanType == PlanType.Free &&
await organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(user.Id) > 0)
{
return new ConfirmOnlyOneFreeOrganizationAdminAllowed();
} |
||
|
|
||
| /// <summary> | ||
| /// Creates a new membership for the user directly in <see cref="OrganizationUserStatusType.Confirmed"/> | ||
| /// status with the org key, expanding the organization's seats first so a billing or persistence failure | ||
| /// leaves no orphaned seat. The validator has already confirmed the plan permits an additional seat. | ||
| /// </summary> | ||
| private async Task<CommandResult<OrganizationUser>> CreateConfirmedMembershipAsync( | ||
| Organization organization, User user, string orgUserKey) | ||
| { | ||
| var occupiedSeatCount = (await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)).Total; | ||
|
|
||
| var seatExpansionError = await TryExpandSeatsAsync(organization, occupiedSeatCount); | ||
| if (seatExpansionError is not null) | ||
| { | ||
| return seatExpansionError; | ||
| } | ||
|
|
||
| var accessSecretsManager = await stripePaymentService.HasSecretsManagerStandalone(organization); | ||
| var organizationUser = new OrganizationUser | ||
| { | ||
| OrganizationId = organization.Id, | ||
| UserId = user.Id, | ||
| Status = OrganizationUserStatusType.Confirmed, | ||
| Type = OrganizationUserType.User, | ||
| AccessSecretsManager = accessSecretsManager, | ||
| Key = orgUserKey, | ||
| }; | ||
| organizationUser.SetNewId(); | ||
|
|
||
| await organizationUserRepository.CreateAsync(organizationUser); | ||
|
|
||
| return organizationUser; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Expands the organization's seats before the new membership is created. Only auto-adds when the | ||
| /// org is already at capacity. | ||
| /// </summary> | ||
| private async Task<Error?> TryExpandSeatsAsync(Organization organization, int occupiedSeatCount) | ||
| { | ||
| if (!organization.Seats.HasValue || occupiedSeatCount < organization.Seats.Value) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| try | ||
| { | ||
| await organizationService.AutoAddSeatsAsync(organization, 1); | ||
| return null; | ||
| } | ||
| catch (Exception ex) when (ex is BadRequestException or GatewayException) | ||
| { | ||
| // Known business failures (no payment method, autoscale cap, etc.) map to a 400. | ||
| // Infrastructure failures propagate so they surface as 5xx with a correlation id. | ||
| logger.LogWarning(ex, "Could not auto-add seat while confirming invite link for organization {OrganizationId}", organization.Id); | ||
| return new ConfirmSeatAddFailed(); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Creates the user's default collection when the Organization Data Ownership policy applies. Failures | ||
| /// are logged but not surfaced: the user is already confirmed and the collection can be recreated. | ||
| /// </summary> | ||
| private async Task CreateDefaultCollectionAsync(Organization organization, OrganizationUser organizationUser, string defaultUserCollectionName) | ||
| { | ||
| try | ||
| { | ||
| if (!await ShouldCreateDefaultCollectionAsync(organization, organizationUser, defaultUserCollectionName)) | ||
|
JimmyVo16 marked this conversation as resolved.
|
||
| { | ||
| return; | ||
| } | ||
|
|
||
| await collectionRepository.CreateDefaultCollectionsAsync( | ||
| organization.Id, | ||
| [organizationUser.Id], | ||
| defaultUserCollectionName); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| logger.LogError(ex, "Failed to create default collection for user confirmed via invite link."); | ||
| } | ||
|
JimmyVo16 marked this conversation as resolved.
Dismissed
|
||
| } | ||
|
|
||
| private async Task<bool> ShouldCreateDefaultCollectionAsync(Organization organization, OrganizationUser organizationUser, string defaultUserCollectionName) => | ||
| !string.IsNullOrWhiteSpace(defaultUserCollectionName) | ||
| && organization.UseMyItems | ||
| && (await policyRequirementQuery.GetAsync<OrganizationDataOwnershipPolicyRequirement>(organizationUser.UserId!.Value)) | ||
| .GetDefaultCollectionRequestOnConfirm(organization.Id).ShouldCreateDefaultCollection; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| ο»Ώusing Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AcceptMembership; | ||
| using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements.Errors; | ||
| using Bit.Core.AdminConsole.Utilities.v2.Validation; | ||
|
|
||
| namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; | ||
|
|
||
| // The link-confirm endpoint surfaces its 400 failures as RFC 7807 validation problems with a stable, | ||
| // client-localizable Type code. Each error below inherits the shared invite-link/policy error β reusing | ||
| // its message and preserving its type identity β and adds IValidationError so the API layer routes it | ||
| // through BitwardenValidationProblem. The accept endpoint keeps returning the plain base errors, so its | ||
| // responses are unchanged. | ||
|
|
||
| public record ConfirmInviteLinkNotAvailable() | ||
| : InviteLinkNotAvailable(), IValidationError | ||
| { | ||
| public string PropertyName => "code"; | ||
| public string Type => "invite_link_not_available"; | ||
| } | ||
|
|
||
| public record ConfirmEmailDomainNotAllowed() | ||
| : EmailDomainNotAllowed(), IValidationError | ||
| { | ||
| public string PropertyName => "code"; | ||
| public string Type => "email_domain_not_allowed"; | ||
| } | ||
|
|
||
| public record ConfirmProviderUsersCannotAcceptInviteLink() | ||
| : ProviderUsersCannotAcceptInviteLink(), IValidationError | ||
| { | ||
| public string PropertyName => "code"; | ||
| public string Type => "provider_users_cannot_join"; | ||
| } | ||
|
|
||
| public record ConfirmOrganizationAccessRevoked() | ||
| : OrganizationAccessRevoked(), IValidationError | ||
| { | ||
| public string PropertyName => "code"; | ||
| public string Type => "organization_access_revoked"; | ||
| } | ||
|
|
||
| public record ConfirmAlreadyOrganizationMember() | ||
| : AlreadyOrganizationMember(), IValidationError | ||
| { | ||
| public string PropertyName => "code"; | ||
| public string Type => "already_organization_member"; | ||
| } | ||
|
|
||
| public record ConfirmOrganizationHasNoAvailableSeats() | ||
| : OrganizationHasNoAvailableSeats(), IValidationError | ||
| { | ||
| public string PropertyName => "code"; | ||
| public string Type => "organization_has_no_available_seats"; | ||
| } | ||
|
|
||
| public record ConfirmSeatAddFailed() | ||
| : SeatAddFailed(), IValidationError | ||
| { | ||
| public string PropertyName => "code"; | ||
| public string Type => "seat_add_failed"; | ||
| } | ||
|
|
||
| public record ConfirmResetPasswordKeyRequired() | ||
| : ResetPasswordKeyRequired(), IValidationError | ||
| { | ||
| public string PropertyName => "resetPasswordKey"; | ||
| public string Type => "reset_password_key_required"; | ||
| } | ||
|
|
||
| public record ConfirmUserIsAMemberOfAnotherOrganization() | ||
| : UserIsAMemberOfAnotherOrganization(), IValidationError | ||
| { | ||
| public string PropertyName => "organizationId"; | ||
| public string Type => "member_of_another_organization"; | ||
| } | ||
|
|
||
| public record ConfirmUserIsAMemberOfAnOrganizationThatHasSingleOrgPolicy() | ||
| : UserIsAMemberOfAnOrganizationThatHasSingleOrgPolicy(), IValidationError | ||
| { | ||
| public string PropertyName => "organizationId"; | ||
| public string Type => "single_organization_policy"; | ||
| } | ||
|
|
||
| public record ConfirmTwoFactorRequiredForMembership() | ||
| : TwoFactorRequiredForMembership(), IValidationError | ||
| { | ||
| public string PropertyName => "organizationId"; | ||
| public string Type => "two_factor_required_for_membership"; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
β QUESTION: Is the omission of a device push-sync intentional here?
Details
The other confirm-to-
Confirmedflows push an org-keys sync so the user's already-signed-in devices pick up the new org key immediately:ConfirmOrganizationUserCommandβPushSyncOrgKeysAsync(plus device-registration cleanup)AutomaticallyConfirmOrganizationUserCommandβPushSyncOrganizationKeysAsyncThis command releases the org key (
Key = orgUserKey) and moves the membership straight toConfirmed, but injects noIPushNotificationService. The doc comment notes theReplaceAsyncrevision-date bump handles other-device sync, but that relies on the next poll rather than an immediate push, andCreateConfirmedMembershipAsync(new membership viaCreateAsync) has no equivalent note. Was relying solely on the revision-date bump β with noPushSyncOrgKeysAsyncβ a deliberate choice for this endpoint?