Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -90,6 +90,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
private readonly ISelfRevokeOrganizationUserCommand _selfRevokeOrganizationUserCommand;
private readonly IUpdateUserResetPasswordEnrollmentCommand _updateUserResetPasswordEnrollmentCommand;
private readonly IAcceptOrganizationInviteLinkCommand _acceptOrganizationInviteLinkCommand;
private readonly IConfirmOrganizationInviteLinkCommand _confirmOrganizationInviteLinkCommand;

public OrganizationUsersController(IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
Expand Down Expand Up @@ -124,7 +125,8 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor
ISelfRevokeOrganizationUserCommand selfRevokeOrganizationUserCommand,
IUpdateUserResetPasswordEnrollmentCommand updateUserResetPasswordEnrollmentCommand,
IGetPendingAutoConfirmUsersQuery getPendingAutoConfirmUsersQuery,
IAcceptOrganizationInviteLinkCommand acceptOrganizationInviteLinkCommand)
IAcceptOrganizationInviteLinkCommand acceptOrganizationInviteLinkCommand,
IConfirmOrganizationInviteLinkCommand confirmOrganizationInviteLinkCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
Expand Down Expand Up @@ -160,6 +162,7 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor
_selfRevokeOrganizationUserCommand = selfRevokeOrganizationUserCommand;
_updateUserResetPasswordEnrollmentCommand = updateUserResetPasswordEnrollmentCommand;
_acceptOrganizationInviteLinkCommand = acceptOrganizationInviteLinkCommand;
_confirmOrganizationInviteLinkCommand = confirmOrganizationInviteLinkCommand;
}

[HttpGet("{id}")]
Expand Down Expand Up @@ -869,4 +872,26 @@ public async Task<IResult> AcceptInviteLink([FromBody] AcceptOrganizationInviteL

return Handle(result, _ => TypedResults.Ok());
}

[HttpPost("/organizations/users/invite-link/confirm")]
[RequireFeature(FeatureFlagKeys.InviteLinkAutoConfirm)]
public async Task<IResult> ConfirmInviteLink([FromBody] ConfirmOrganizationInviteLinkRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}

var result = await _confirmOrganizationInviteLinkCommand.ConfirmAsync(new ConfirmOrganizationInviteLinkRequest
{
Code = model.Code,
User = user,
OrgUserKey = model.OrgUserKey,
ResetPasswordKey = model.ResetPasswordKey,
DefaultUserCollectionName = model.DefaultUserCollectionName,
});

return Handle(result, _ => TypedResults.Ok());
}
}
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 +102 to +118

Copy link
Copy Markdown
Contributor

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-Confirmed flows 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 β†’ PushSyncOrganizationKeysAsync

This command releases the org key (Key = orgUserKey) and moves the membership straight to Confirmed, but injects no IPushNotificationService. The doc comment notes the ReplaceAsync revision-date bump handles other-device sync, but that relies on the next poll rather than an immediate push, and CreateConfirmedMembershipAsync (new membership via CreateAsync) has no equivalent note. Was relying solely on the revision-date bump β€” with no PushSyncOrgKeysAsync β€” a deliberate choice for this endpoint?

Comment on lines +107 to +118

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ IMPORTANT: Confirming an existing membership skips the "one free-org admin" limit that every other confirm/accept path enforces.

Details and fix

ConfirmExistingMembershipAsync moves any existing membership straight to Confirmed without checking the Free-plan admin limit. An email invitation can carry Admin/Owner Type, so a user who already administers one Free org could confirm a second Admin/Owner invite here and bypass the invariant.

The sibling AcceptOrganizationInviteLinkCommand.AcceptExistingInviteAsync guards exactly this case via ValidateFreeOrganizationAdminLimitAsync, with the comment "so the invite link can't bypass it." ConfirmOrganizationUserCommand enforces the same rule (GetCountByFreeOrganizationAdminUserAsync). Consider mirroring that check before confirming an existing Admin/Owner membership on a Free plan:

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))
Comment thread
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.");
}
Comment thread
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";
}
Loading
Loading