From 6787271bfafbc413a7414b33f9a7b8421bdb8dbc Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Tue, 30 Jun 2026 16:55:20 -0400 Subject: [PATCH 01/12] [PM-38796] wip --- .../OrganizationUsersController.cs | 27 +- ...nfirmOrganizationInviteLinkRequestModel.cs | 29 ++ .../ConfirmOrganizationInviteLinkCommand.cs | 205 +++++++++++ .../ConfirmOrganizationInviteLinkRequest.cs | 40 ++ .../IConfirmOrganizationInviteLinkCommand.cs | 15 + ...OrganizationServiceCollectionExtensions.cs | 1 + .../OrganizationUsersControllerTests.cs | 60 +++ ...nfirmOrganizationInviteLinkCommandTests.cs | 345 ++++++++++++++++++ 8 files changed, 721 insertions(+), 1 deletion(-) create mode 100644 src/Api/AdminConsole/Models/Request/Organizations/ConfirmOrganizationInviteLinkRequestModel.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkRequest.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IConfirmOrganizationInviteLinkCommand.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index d001411478e2..0742efcb8aa8 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -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, @@ -124,7 +125,8 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor ISelfRevokeOrganizationUserCommand selfRevokeOrganizationUserCommand, IUpdateUserResetPasswordEnrollmentCommand updateUserResetPasswordEnrollmentCommand, IGetPendingAutoConfirmUsersQuery getPendingAutoConfirmUsersQuery, - IAcceptOrganizationInviteLinkCommand acceptOrganizationInviteLinkCommand) + IAcceptOrganizationInviteLinkCommand acceptOrganizationInviteLinkCommand, + IConfirmOrganizationInviteLinkCommand confirmOrganizationInviteLinkCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -160,6 +162,7 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor _selfRevokeOrganizationUserCommand = selfRevokeOrganizationUserCommand; _updateUserResetPasswordEnrollmentCommand = updateUserResetPasswordEnrollmentCommand; _acceptOrganizationInviteLinkCommand = acceptOrganizationInviteLinkCommand; + _confirmOrganizationInviteLinkCommand = confirmOrganizationInviteLinkCommand; } [HttpGet("{id}")] @@ -871,4 +874,26 @@ public async Task AcceptInviteLink([FromBody] AcceptOrganizationInviteL return Handle(result, _ => TypedResults.Ok()); } + + [HttpPost("/organizations/users/invite-link/link-confirm")] + [RequireFeature(FeatureFlagKeys.GenerateInviteLink)] + public async Task 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()); + } } diff --git a/src/Api/AdminConsole/Models/Request/Organizations/ConfirmOrganizationInviteLinkRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/ConfirmOrganizationInviteLinkRequestModel.cs new file mode 100644 index 000000000000..ef89d76b56ce --- /dev/null +++ b/src/Api/AdminConsole/Models/Request/Organizations/ConfirmOrganizationInviteLinkRequestModel.cs @@ -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; } + + /// + /// The organization symmetric key encrypted to the user. Released to the user on confirmation. + /// + [Required] + public required string OrgUserKey { get; set; } + + /// + /// The user's account recovery key, supplied when the organization enforces automatic enrollment. + /// + public string? ResetPasswordKey { get; set; } + + /// + /// The encrypted name of the default collection created when Organization Data Ownership applies. + /// + [Required] + [EncryptedString] + [EncryptedStringLength(1000)] + public required string DefaultUserCollectionName { get; set; } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs new file mode 100644 index 000000000000..e5f6c0c5e589 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs @@ -0,0 +1,205 @@ +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.Auth.UserFeatures.EmergencyAccess.Interfaces; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using None = OneOf.Types.None; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; + +/// +/// Confirms a user into an organization via an invite link. See +/// for the behavior. +/// +/// +/// Eligibility is delegated to , 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. +/// +public class ConfirmOrganizationInviteLinkCommand( + IConfirmOrganizationInviteLinkValidator confirmOrganizationInviteLinkValidator, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository, + IPolicyRequirementQuery policyRequirementQuery, + IOrganizationService organizationService, + IStripePaymentService stripePaymentService, + IUpdateUserResetPasswordEnrollmentCommand updateUserResetPasswordEnrollmentCommand, + IDeleteEmergencyAccessCommand deleteEmergencyAccessCommand, + ILogger logger) + : IConfirmOrganizationInviteLinkCommand +{ + public async Task 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(user.Id); + var autoEnrollEnabled = resetPasswordRequirement.AutoEnrollEnabled(organization.Id); + if (autoEnrollEnabled && !OrganizationUser.IsValidResetPasswordKey(request.ResetPasswordKey)) + { + return new ResetPasswordKeyRequired(); + } + + CommandResult membershipResult; + if (existingOrganizationUser is not null) + { + membershipResult = await ConfirmExistingMembershipAsync(existingOrganizationUser, user, request.OrgUserKey); + } + else + { + membershipResult = await CreateConfirmedMembershipAsync(organization, user, request.OrgUserKey); + } + 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 user is now a confirmed member of an organization, so any emergency access they granted or + // were granted is removed. + await deleteEmergencyAccessCommand.DeleteAllByUserIdAsync(user.Id); + + return new None(); + } + + /// + /// 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 . + /// Persisting via ReplaceAsync bumps the user's account revision date so their other devices sync. + /// + private async Task> 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; + } + + /// + /// Creates a new membership for the user directly in + /// 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. + /// + private async Task> 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; + } + + /// + /// Expands the organization's seats before the new membership is created. Only auto-adds when the + /// org is already at capacity. + /// + private async Task 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 SeatAddFailed(); + } + } + + /// + /// 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. + /// + private async Task CreateDefaultCollectionAsync(Organization organization, OrganizationUser organizationUser, string defaultUserCollectionName) + { + try + { + if (!await ShouldCreateDefaultCollectionAsync(organization, organizationUser, defaultUserCollectionName)) + { + 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."); + } + } + + private async Task ShouldCreateDefaultCollectionAsync(Organization organization, OrganizationUser organizationUser, string defaultUserCollectionName) => + !string.IsNullOrWhiteSpace(defaultUserCollectionName) + && organization.UseMyItems + && (await policyRequirementQuery.GetAsync(organizationUser.UserId!.Value)) + .GetDefaultCollectionRequestOnConfirm(organization.Id).ShouldCreateDefaultCollection; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkRequest.cs new file mode 100644 index 000000000000..57a6959e36ae --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkRequest.cs @@ -0,0 +1,40 @@ +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; + +/// +/// The data required to confirm a user into an organization via an invite link. The confirmation +/// creates the membership when one does not already exist, releases the organization key to the user, +/// and runs the policy-driven side effects (Organization Data Ownership, account recovery enrollment, +/// and emergency access removal). +/// +public record ConfirmOrganizationInviteLinkRequest +{ + /// + /// The secret code embedded in the invite link the user is confirming with. + /// + public required Guid Code { get; init; } + + /// + /// The authenticated user being confirmed into the organization. + /// + public required User User { get; init; } + + /// + /// The organization symmetric key encrypted to the user, stored on the membership so the user can + /// decrypt organization data. + /// + public required string OrgUserKey { get; init; } + + /// + /// The user's account recovery (reset password) key, encrypted to the organization's public key. + /// Required only when the organization enforces automatic account recovery enrollment. + /// + public string? ResetPasswordKey { get; init; } + + /// + /// The encrypted name of the default collection to create for the user when the Organization Data + /// Ownership policy applies. + /// + public required string DefaultUserCollectionName { get; init; } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IConfirmOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IConfirmOrganizationInviteLinkCommand.cs new file mode 100644 index 000000000000..3d587dce2c15 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IConfirmOrganizationInviteLinkCommand.cs @@ -0,0 +1,15 @@ +using Bit.Core.AdminConsole.Utilities.v2.Results; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; + +/// +/// Confirms a user into an organization through an invite link. Runs the shared read-only precheck +/// (), creates the membership when the user is not +/// yet a member, confirms it with the supplied organization key, and performs the policy-driven side +/// effects: creating a default collection for Organization Data Ownership, enrolling the user in account +/// recovery when required, and removing the user's emergency access. +/// +public interface IConfirmOrganizationInviteLinkCommand +{ + Task ConfirmAsync(ConfirmOrganizationInviteLinkRequest request); +} diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 831a2eae17bd..d0634de92e2e 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -207,6 +207,7 @@ private static void AddOrganizationInviteLinkCommandsQueries(this IServiceCollec services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); } private static void AddOrganizationDomainCommandsQueries(this IServiceCollection services) diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index 8f31d2f94cd2..cc8935aa625d 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -7,6 +7,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; @@ -760,4 +762,62 @@ await sutProvider.GetDependency() .Received(1) .BulkResendInvitesAsync(organizationId, userId, bulkRequestModel.Ids); } + + [Theory, BitAutoData] + public async Task ConfirmInviteLink_WithValidInput_ReturnsOk( + User user, + ConfirmOrganizationInviteLinkRequestModel model, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + sutProvider.GetDependency() + .ConfirmAsync(Arg.Any()) + .Returns(new CommandResult(new None())); + + var result = await sutProvider.Sut.ConfirmInviteLink(model); + + Assert.IsType(result); + await sutProvider.GetDependency() + .Received(1) + .ConfirmAsync(Arg.Is(r => + r.Code == model.Code && + r.User == user && + r.OrgUserKey == model.OrgUserKey && + r.ResetPasswordKey == model.ResetPasswordKey && + r.DefaultUserCollectionName == model.DefaultUserCollectionName)); + } + + [Theory, BitAutoData] + public async Task ConfirmInviteLink_WhenCommandReturnsError_ReturnsBadRequest( + User user, + ConfirmOrganizationInviteLinkRequestModel model, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + sutProvider.GetDependency() + .ConfirmAsync(Arg.Any()) + .Returns(new CommandResult(new EmailDomainNotAllowed())); + + var result = await sutProvider.Sut.ConfirmInviteLink(model); + + Assert.IsType>(result); + } + + [Theory, BitAutoData] + public async Task ConfirmInviteLink_WhenUserNotFound_Throws( + ConfirmOrganizationInviteLinkRequestModel model, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns((User?)null); + + await Assert.ThrowsAsync(() => sutProvider.Sut.ConfirmInviteLink(model)); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs new file mode 100644 index 000000000000..14388ef34172 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs @@ -0,0 +1,345 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; +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.Auth.UserFeatures.EmergencyAccess.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.InviteLinks; + +[SutProviderCustomize] +public class ConfirmOrganizationInviteLinkCommandTests +{ + [Theory, BitAutoData] + public async Task ConfirmAsync_WhenValidationFails_ReturnsErrorAndDoesNotWrite( + ConfirmOrganizationInviteLinkRequest request, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new InviteLinkNotFound()); + + var result = await sutProvider.Sut.ConfirmAsync(request); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceAsync(Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CreateAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ConfirmAsync_WithExistingMembership_ConfirmsDirectlyWithoutCreatingMembership( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + OrganizationUser existingOrganizationUser, + SutProvider sutProvider) + { + SetupHappyPath(organization, inviteLink, user, existingOrganizationUser, sutProvider); + + var request = BuildRequest(inviteLink, user); + var result = await sutProvider.Sut.ConfirmAsync(request); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CreateAsync(Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .AutoAddSeatsAsync(Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(ou => + ou.Id == existingOrganizationUser.Id && + ou.Status == OrganizationUserStatusType.Confirmed && + ou.UserId == user.Id && + ou.Email == null && + ou.Key == request.OrgUserKey)); + } + + [Theory, BitAutoData] + public async Task ConfirmAsync_WithNewUser_CreatesMembershipDirectlyInConfirmedStatus( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + SutProvider sutProvider) + { + SetupHappyPath(organization, inviteLink, user, existingOrganizationUser: null, sutProvider); + organization.Seats = null; + + var request = BuildRequest(inviteLink, user); + var result = await sutProvider.Sut.ConfirmAsync(request); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(Arg.Is(ou => + ou.OrganizationId == organization.Id && + ou.UserId == user.Id && + ou.Status == OrganizationUserStatusType.Confirmed && + ou.Type == OrganizationUserType.User && + ou.Key == request.OrgUserKey)); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ConfirmAsync_WithNewUser_AtCapacity_AutoAddsSeat( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + SutProvider sutProvider) + { + SetupHappyPath(organization, inviteLink, user, existingOrganizationUser: null, sutProvider); + organization.Seats = 2; + + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) + .Returns(new OrganizationSeatCounts { Users = 2, Sponsored = 0 }); + + var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .Received(1) + .AutoAddSeatsAsync(organization, 1); + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ConfirmAsync_WithNewUser_SeatExpansionFails_ReturnsErrorAndDoesNotCreate( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + SutProvider sutProvider) + { + SetupHappyPath(organization, inviteLink, user, existingOrganizationUser: null, sutProvider); + organization.Seats = 2; + + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) + .Returns(new OrganizationSeatCounts { Users = 2, Sponsored = 0 }); + sutProvider.GetDependency() + .AutoAddSeatsAsync(organization, 1) + .ThrowsAsync(new BadRequestException("No payment method.")); + + var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CreateAsync(Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ConfirmAsync_WhenAutoEnrollEnabledAndKeyMissing_ReturnsResetPasswordKeyRequired( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + OrganizationUser existingOrganizationUser, + SutProvider sutProvider) + { + SetupHappyPath(organization, inviteLink, user, existingOrganizationUser, sutProvider); + SetupAutoEnrollPolicy(organization, user, sutProvider); + + var request = BuildRequest(inviteLink, user) with { ResetPasswordKey = null }; + var result = await sutProvider.Sut.ConfirmAsync(request); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ConfirmAsync_WhenAutoEnrollEnabledWithValidKey_EnrollsUser( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + OrganizationUser existingOrganizationUser, + SutProvider sutProvider) + { + SetupHappyPath(organization, inviteLink, user, existingOrganizationUser, sutProvider); + SetupAutoEnrollPolicy(organization, user, sutProvider); + + var request = BuildRequest(inviteLink, user) with { ResetPasswordKey = "2.validresetpasswordkey" }; + var result = await sutProvider.Sut.ConfirmAsync(request); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .Received(1) + .UpdateUserResetPasswordEnrollmentAsync(organization.Id, user.Id, request.ResetPasswordKey, user.Id); + } + + [Theory, BitAutoData] + public async Task ConfirmAsync_WhenDataOwnershipApplies_CreatesDefaultCollection( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + OrganizationUser existingOrganizationUser, + SutProvider sutProvider) + { + SetupHappyPath(organization, inviteLink, user, existingOrganizationUser, sutProvider); + organization.UseMyItems = true; + SetupDataOwnershipPolicy(organization, existingOrganizationUser, user, sutProvider); + + var request = BuildRequest(inviteLink, user); + var result = await sutProvider.Sut.ConfirmAsync(request); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .Received(1) + .CreateDefaultCollectionsAsync( + organization.Id, + Arg.Is>(ids => ids.Contains(existingOrganizationUser.Id)), + request.DefaultUserCollectionName); + } + + [Theory, BitAutoData] + public async Task ConfirmAsync_WhenDataOwnershipDoesNotApply_DoesNotCreateDefaultCollection( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + OrganizationUser existingOrganizationUser, + SutProvider sutProvider) + { + // No data ownership policy details are configured, so the policy does not apply. + SetupHappyPath(organization, inviteLink, user, existingOrganizationUser, sutProvider); + + var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ConfirmAsync_OnSuccess_RemovesEmergencyAccess( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + OrganizationUser existingOrganizationUser, + SutProvider sutProvider) + { + SetupHappyPath(organization, inviteLink, user, existingOrganizationUser, sutProvider); + + var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .Received(1) + .DeleteAllByUserIdAsync(user.Id); + } + + private static ConfirmOrganizationInviteLinkRequest BuildRequest(OrganizationInviteLink inviteLink, User user) => + new() + { + Code = inviteLink.Code, + User = user, + OrgUserKey = "4.orgUserKey", + DefaultUserCollectionName = "2.defaultCollectionName", + }; + + private static void SetupHappyPath( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + OrganizationUser? existingOrganizationUser, + SutProvider sutProvider) + { + inviteLink.OrganizationId = organization.Id; + if (existingOrganizationUser is not null) + { + existingOrganizationUser.OrganizationId = organization.Id; + existingOrganizationUser.UserId = user.Id; + existingOrganizationUser.Status = OrganizationUserStatusType.Accepted; + } + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new ConfirmOrganizationInviteLinkValidationResult + { + InviteLink = inviteLink, + Organization = organization, + ExistingOrganizationUser = existingOrganizationUser, + }); + + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) + .Returns(new OrganizationSeatCounts { Users = 0, Sponsored = 0 }); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new ResetPasswordPolicyRequirement([])); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Disabled, [])); + } + + private static void SetupAutoEnrollPolicy( + Organization organization, + User user, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new ResetPasswordPolicyRequirement( + [ + new PolicyDetails + { + OrganizationId = organization.Id, + PolicyType = PolicyType.ResetPassword, + OrganizationUserStatus = OrganizationUserStatusType.Confirmed, + PolicyData = "{\"autoEnrollEnabled\": true}" + } + ])); + } + + private static void SetupDataOwnershipPolicy( + Organization organization, + OrganizationUser organizationUser, + User user, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Enabled, + [ + new PolicyDetails + { + OrganizationId = organization.Id, + OrganizationUserId = organizationUser.Id, + PolicyType = PolicyType.OrganizationDataOwnership, + OrganizationUserStatus = OrganizationUserStatusType.Confirmed, + } + ])); + } +} From e0fc69c323decbe08754edd6862591bf35b10b10 Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Thu, 2 Jul 2026 11:14:11 -0400 Subject: [PATCH 02/12] [PM-38796] Remove Emergency Access logic --- .../ConfirmOrganizationInviteLinkCommand.cs | 6 ------ ...onfirmOrganizationInviteLinkCommandTests.cs | 18 ------------------ 2 files changed, 24 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs index e5f6c0c5e589..2c99c7e4052f 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs @@ -5,7 +5,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Utilities.v2; using Bit.Core.AdminConsole.Utilities.v2.Results; -using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; @@ -35,7 +34,6 @@ public class ConfirmOrganizationInviteLinkCommand( IOrganizationService organizationService, IStripePaymentService stripePaymentService, IUpdateUserResetPasswordEnrollmentCommand updateUserResetPasswordEnrollmentCommand, - IDeleteEmergencyAccessCommand deleteEmergencyAccessCommand, ILogger logger) : IConfirmOrganizationInviteLinkCommand { @@ -90,10 +88,6 @@ await updateUserResetPasswordEnrollmentCommand.UpdateUserResetPasswordEnrollment organization.Id, user.Id, request.ResetPasswordKey, user.Id); } - // The user is now a confirmed member of an organization, so any emergency access they granted or - // were granted is removed. - await deleteEmergencyAccessCommand.DeleteAllByUserIdAsync(user.Id); - return new None(); } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs index 14388ef34172..cfcd205b0338 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs @@ -6,7 +6,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUserResetPasswordEnrollment; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; -using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -240,23 +239,6 @@ await sutProvider.GetDependency() .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } - [Theory, BitAutoData] - public async Task ConfirmAsync_OnSuccess_RemovesEmergencyAccess( - Organization organization, - OrganizationInviteLink inviteLink, - User user, - OrganizationUser existingOrganizationUser, - SutProvider sutProvider) - { - SetupHappyPath(organization, inviteLink, user, existingOrganizationUser, sutProvider); - - var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); - - Assert.True(result.IsSuccess); - await sutProvider.GetDependency() - .Received(1) - .DeleteAllByUserIdAsync(user.Id); - } private static ConfirmOrganizationInviteLinkRequest BuildRequest(OrganizationInviteLink inviteLink, User user) => new() From 38b9bfe03c7669a275d0eb7a625d4921b8379c19 Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Thu, 2 Jul 2026 11:36:49 -0400 Subject: [PATCH 03/12] [PM-38796] wip --- .../ConfirmOrganizationInviteLinkCommand.cs | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs index 2c99c7e4052f..f3ed95e7c4f9 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs @@ -64,15 +64,7 @@ public async Task ConfirmAsync(ConfirmOrganizationInviteLinkReque return new ResetPasswordKeyRequired(); } - CommandResult membershipResult; - if (existingOrganizationUser is not null) - { - membershipResult = await ConfirmExistingMembershipAsync(existingOrganizationUser, user, request.OrgUserKey); - } - else - { - membershipResult = await CreateConfirmedMembershipAsync(organization, user, request.OrgUserKey); - } + var membershipResult = await AddUserToOrganizationAsync(request, existingOrganizationUser, user, organization); if (membershipResult.IsError) { return membershipResult.AsError; @@ -91,6 +83,17 @@ await updateUserResetPasswordEnrollmentCommand.UpdateUserResetPasswordEnrollment return new None(); } + private async Task> 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); + } + /// /// 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 . From f5579fcf2b967dff0e286ad5debf72b6cd8d8de9 Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Thu, 2 Jul 2026 18:12:33 -0400 Subject: [PATCH 04/12] [PM-38796] wip --- .../ConfirmOrganizationInviteLinkCommand.cs | 4 +- .../ConfirmOrganizationInviteLinkErrors.cs | 88 +++++++++ .../ConfirmOrganizationInviteLinkValidator.cs | 32 ++-- ...onUsersControllerConfirmInviteLinkTests.cs | 168 ++++++++++++++++++ ...nfirmOrganizationInviteLinkCommandTests.cs | 4 +- 5 files changed, 282 insertions(+), 14 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkErrors.cs create mode 100644 test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerConfirmInviteLinkTests.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs index f3ed95e7c4f9..d875f458c4b6 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs @@ -61,7 +61,7 @@ public async Task ConfirmAsync(ConfirmOrganizationInviteLinkReque var autoEnrollEnabled = resetPasswordRequirement.AutoEnrollEnabled(organization.Id); if (autoEnrollEnabled && !OrganizationUser.IsValidResetPasswordKey(request.ResetPasswordKey)) { - return new ResetPasswordKeyRequired(); + return new ConfirmResetPasswordKeyRequired(); } var membershipResult = await AddUserToOrganizationAsync(request, existingOrganizationUser, user, organization); @@ -166,7 +166,7 @@ private async Task> CreateConfirmedMembershipAsy // 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 SeatAddFailed(); + return new ConfirmSeatAddFailed(); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkErrors.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkErrors.cs new file mode 100644 index 000000000000..eed10fb0be9b --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkErrors.cs @@ -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"; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidator.cs index 6dd3174d0581..2ec8bae4cfeb 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidator.cs @@ -1,10 +1,11 @@ -using Bit.Core.AdminConsole.Entities; +using System.Diagnostics; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AcceptMembership; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements.Errors; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities; using Bit.Core.AdminConsole.Utilities.v2; @@ -56,18 +57,18 @@ public async Task> if (!organization.UseInviteLinks) { - return new InviteLinkNotAvailable(); + return new ConfirmInviteLinkNotAvailable(); } if (!InviteLinkDomainValidator.IsEmailDomainAllowed(user.Email, link.GetAllowedDomains())) { - return new EmailDomainNotAllowed(); + return new ConfirmEmailDomainNotAllowed(); } // Provider users cannot confirm via invite links. if ((await providerUserRepository.GetManyByUserAsync(user.Id)).Count != 0) { - return new ProviderUsersCannotAcceptInviteLink(); + return new ConfirmProviderUsersCannotAcceptInviteLink(); } var existingOrganizationUser = await ResolveExistingOrganizationUserAsync(organization, user); @@ -121,8 +122,8 @@ public async Task> private static Error? ValidateExistingMembershipStatus(OrganizationUser? existingOrganizationUser) => existingOrganizationUser switch { - { RevocationReason: not null } => new OrganizationAccessRevoked(), - { Status: OrganizationUserStatusType.Confirmed } => new AlreadyOrganizationMember(), + { RevocationReason: not null } => new ConfirmOrganizationAccessRevoked(), + { Status: OrganizationUserStatusType.Confirmed } => new ConfirmAlreadyOrganizationMember(), _ => null }; @@ -140,7 +141,18 @@ public async Task> var singleOrgError = singleOrgRequirement.CanJoinOrganization(organizationId, allOrganizationMemberships); if (singleOrgError is not null) { - return singleOrgError; + + // TODO: Jimmy reassess this + // Translate the shared policy error into the link-confirm validation-problem variant so the + // endpoint always returns the RFC 7807 shape. CanJoinOrganization only produces the two errors + // below; a new one must be mapped here rather than silently falling back to a plain 400. + return singleOrgError switch + { + UserIsAMemberOfAnotherOrganization => new ConfirmUserIsAMemberOfAnotherOrganization(), + UserIsAMemberOfAnOrganizationThatHasSingleOrgPolicy => new ConfirmUserIsAMemberOfAnOrganizationThatHasSingleOrgPolicy(), + _ => throw new UnreachableException( + $"Unmapped Single Organization policy error: {singleOrgError.GetType().Name}") + }; } if (!await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user)) @@ -149,7 +161,7 @@ public async Task> .GetAsync(user.Id); if (twoFactorRequirement.IsTwoFactorRequiredForOrganization(organizationId)) { - return new TwoFactorRequiredForMembership(); + return new ConfirmTwoFactorRequiredForMembership(); } } @@ -172,7 +184,7 @@ public async Task> return InviteUsersPasswordManagerValidator.ValidatePasswordManager(subscriptionUpdate) is PasswordManagerValidation.Invalid - ? new OrganizationHasNoAvailableSeats() + ? new ConfirmOrganizationHasNoAvailableSeats() : null; } } diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerConfirmInviteLinkTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerConfirmInviteLinkTests.cs new file mode 100644 index 000000000000..77af0f8f000e --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerConfirmInviteLinkTests.cs @@ -0,0 +1,168 @@ +using System.Net; +using System.Text.Json; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using NSubstitute; +using Xunit; + +namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; + +public class OrganizationUsersControllerConfirmInviteLinkTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + + private const string _validEncryptedKey = + "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk="; + + private Organization _organization = null!; + private string _ownerEmail = null!; + + public OrganizationUsersControllerConfirmInviteLinkTests(ApiApplicationFactory factory) + { + _factory = factory; + _factory.SubstituteService(featureService => + { + featureService + .IsEnabled(FeatureFlagKeys.GenerateInviteLink) + .Returns(true); + }); + _client = factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + } + + public async Task InitializeAsync() + { + _ownerEmail = $"integration-test{Guid.NewGuid()}@example.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + + (_organization, _) = await OrganizationTestHelpers.SignUpAsync( + _factory, + plan: PlanType.EnterpriseAnnually, + ownerEmail: _ownerEmail, + passwordManagerSeats: 10, + paymentMethod: PaymentMethodType.Card); + + var organizationRepository = _factory.GetService(); + _organization.UseInviteLinks = true; + await organizationRepository.ReplaceAsync(_organization); + + await _loginHelper.LoginAsync(_ownerEmail); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task ConfirmInviteLink_WithValidRequest_ReturnsOkAndConfirmsMembership() + { + // Arrange + var code = await CreateInviteLinkAsync(); + var (joinerClient, joinerEmail) = await CreateJoinerClientAsync(); + + // Act + var response = await joinerClient.PostAsJsonAsync( + "/organizations/users/invite-link/link-confirm", BuildConfirmRequest(code)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var userRepository = _factory.GetService(); + var organizationUserRepository = _factory.GetService(); + var joiner = await userRepository.GetByEmailAsync(joinerEmail); + Assert.NotNull(joiner); + + var organizationUser = await organizationUserRepository.GetByOrganizationAsync(_organization.Id, joiner.Id); + Assert.NotNull(organizationUser); + Assert.Equal(OrganizationUserStatusType.Confirmed, organizationUser.Status); + Assert.Equal(_validEncryptedKey, organizationUser.Key); + } + + [Fact] + public async Task ConfirmInviteLink_WithUnknownCode_ReturnsNotFound() + { + // Arrange + var (joinerClient, _) = await CreateJoinerClientAsync(); + + // Act + var response = await joinerClient.PostAsJsonAsync( + "/organizations/users/invite-link/link-confirm", BuildConfirmRequest(Guid.NewGuid())); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task ConfirmInviteLink_WhenAlreadyConfirmed_ReturnsValidationProblemWithTypedCode() + { + // Arrange + var code = await CreateInviteLinkAsync(); + var (joinerClient, _) = await CreateJoinerClientAsync(); + + var firstResponse = await joinerClient.PostAsJsonAsync( + "/organizations/users/invite-link/link-confirm", BuildConfirmRequest(code)); + Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); + + // Act + // Confirming again finds the already-confirmed membership and fails with a 400 validation problem. + var secondResponse = await joinerClient.PostAsJsonAsync( + "/organizations/users/invite-link/link-confirm", BuildConfirmRequest(code)); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, secondResponse.StatusCode); + + using var problem = JsonDocument.Parse(await secondResponse.Content.ReadAsStringAsync()); + var errorCode = problem.RootElement + .GetProperty("errors") + .GetProperty("code")[0] + .GetProperty("type") + .GetString(); + Assert.Equal("already_organization_member", errorCode); + } + + private async Task CreateInviteLinkAsync() + { + var createRequest = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = ["example.com"], + EncryptedInviteKey = _validEncryptedKey, + }; + var createResponse = await _client.PostAsJsonAsync( + $"/organizations/{_organization.Id}/invite-link", createRequest); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + var created = await createResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + return created.Code; + } + + private async Task<(HttpClient Client, string Email)> CreateJoinerClientAsync() + { + var joinerEmail = $"integration-test{Guid.NewGuid()}@example.com"; + await _factory.LoginWithNewAccount(joinerEmail); + var joinerClient = _factory.CreateClient(); + var joinerLoginHelper = new LoginHelper(_factory, joinerClient); + await joinerLoginHelper.LoginAsync(joinerEmail); + return (joinerClient, joinerEmail); + } + + private static ConfirmOrganizationInviteLinkRequestModel BuildConfirmRequest(Guid code) => + new() + { + Code = code, + OrgUserKey = _validEncryptedKey, + DefaultUserCollectionName = _validEncryptedKey, + }; +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs index cfcd205b0338..d4d80d0b8735 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs @@ -146,7 +146,7 @@ public async Task ConfirmAsync_WithNewUser_SeatExpansionFails_ReturnsErrorAndDoe var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); Assert.True(result.IsError); - Assert.IsType(result.AsError); + Assert.IsType(result.AsError); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .CreateAsync(Arg.Any()); @@ -170,7 +170,7 @@ public async Task ConfirmAsync_WhenAutoEnrollEnabledAndKeyMissing_ReturnsResetPa var result = await sutProvider.Sut.ConfirmAsync(request); Assert.True(result.IsError); - Assert.IsType(result.AsError); + Assert.IsType(result.AsError); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .ReplaceAsync(Arg.Any()); From c34980ad91c2905b7b4a8640344f1408c99ecbae Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Mon, 6 Jul 2026 09:42:23 -0400 Subject: [PATCH 05/12] [PM-38796] wip --- ...irmOrganizationInviteLinkValidatorTests.cs | 57 +++++++++++++++---- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidatorTests.cs index 1d457e3736b1..e3becef9a623 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidatorTests.cs @@ -1,11 +1,10 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AcceptMembership; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements.Errors; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Utilities.v2.Validation; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; @@ -111,7 +110,7 @@ public async Task ValidateAsync_WithOrganizationNotUsingInviteLinks_ReturnsInvit // Assert Assert.True(result.IsError); - Assert.IsType(result.AsError); + Assert.IsType(result.AsError); } [Theory, BitAutoData] @@ -132,7 +131,7 @@ public async Task ValidateAsync_WithEmailDomainNotAllowed_ReturnsEmailDomainNotA // Assert Assert.True(result.IsError); - Assert.IsType(result.AsError); + Assert.IsType(result.AsError); } [Theory, BitAutoData] @@ -156,7 +155,7 @@ public async Task ValidateAsync_WithProviderUser_ReturnsProviderUsersCannotAccep // Assert Assert.True(result.IsError); - Assert.IsType(result.AsError); + Assert.IsType(result.AsError); } [Theory, BitAutoData] @@ -181,7 +180,7 @@ public async Task ValidateAsync_WithRevokedMember_ReturnsOrganizationAccessRevok // Assert Assert.True(result.IsError); - Assert.IsType(result.AsError); + Assert.IsType(result.AsError); } [Theory, BitAutoData] @@ -208,7 +207,7 @@ public async Task ValidateAsync_WithRevokedEmailInvite_ReturnsOrganizationAccess // Assert Assert.True(result.IsError); - Assert.IsType(result.AsError); + Assert.IsType(result.AsError); } [Theory, BitAutoData] @@ -234,7 +233,7 @@ public async Task ValidateAsync_WithConfirmedMember_ReturnsAlreadyOrganizationMe // Assert Assert.True(result.IsError); - Assert.IsType(result.AsError); + Assert.IsType(result.AsError); } [Theory] @@ -293,7 +292,7 @@ public async Task ValidateAsync_WithNewUserAndNoSeatsAvailable_ReturnsOrganizati // Assert Assert.True(result.IsError); - Assert.IsType(result.AsError); + Assert.IsType(result.AsError); } [Theory, BitAutoData] @@ -323,7 +322,43 @@ public async Task ValidateAsync_WithSingleOrganizationPolicyViolation_ReturnsErr // Assert Assert.True(result.IsError); - Assert.IsType(result.AsError); + var error = Assert.IsType(result.AsError); + // Confirm errors must map to a validation problem so the endpoint returns the RFC 7807 shape. + Assert.IsAssignableFrom(error); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_WhenMemberOfAnotherOrganizationWithSingleOrgPolicy_ReturnsMappedValidationError( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + Guid otherOrganizationId, + SutProvider sutProvider) + { + // Arrange + SetupHappyPath(organization, inviteLink, user, sutProvider); + + // The target org does not enforce Single Org, but another org the user belongs to does. + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new SingleOrganizationPolicyRequirement( + [ + new PolicyDetails + { + OrganizationId = otherOrganizationId, + OrganizationUserStatus = OrganizationUserStatusType.Confirmed, + } + ])); + + // Act + var request = new ConfirmOrganizationInviteLinkValidationRequest { Code = inviteLink.Code, User = user }; + var result = await sutProvider.Sut.ValidateAsync(request); + + // Assert + Assert.True(result.IsError); + // The shared policy error is translated to the link-confirm variant, which is a validation problem. + var error = Assert.IsType(result.AsError); + Assert.IsAssignableFrom(error); } [Theory, BitAutoData] @@ -351,7 +386,7 @@ public async Task ValidateAsync_WithTwoFactorRequiredAndUserLacks2FA_ReturnsErro // Assert Assert.True(result.IsError); - Assert.IsType(result.AsError); + Assert.IsType(result.AsError); } [Theory, BitAutoData] From 27db29c79dcfdb20517d1995cc20b54d8bc1488f Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Tue, 7 Jul 2026 11:35:45 -0400 Subject: [PATCH 06/12] [PM-38796] Add ff --- src/Api/AdminConsole/Controllers/OrganizationUsersController.cs | 2 +- src/Core/Constants.cs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index e0a63bb5d025..e042725c890d 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -874,7 +874,7 @@ public async Task AcceptInviteLink([FromBody] AcceptOrganizationInviteL } [HttpPost("/organizations/users/invite-link/link-confirm")] - [RequireFeature(FeatureFlagKeys.GenerateInviteLink)] + [RequireFeature(FeatureFlagKeys.InviteLinkAutoConfirm)] public async Task ConfirmInviteLink([FromBody] ConfirmOrganizationInviteLinkRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index eb3e80e3fbf1..f7e56e1d6716 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -138,6 +138,7 @@ public static class FeatureFlagKeys public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users"; public const string BulkAutoConfirmOnLogin = "pm-35803-browser-auto-confirm-log-in"; public const string GenerateInviteLink = "pm-32497-generate-invite-link"; + public const string InviteLinkAutoConfirm = "pm-34429-invite-link-auto-confirm"; public const string PolicyDrawers = "pm-34804-policy-drawers"; public const string PM35153CollectionSdkDecryption = "pm-35153-collection-sdk-decryption"; public const string PoliciesInAcceptedState = "pm-34145-policies-in-accepted-state"; From 92afcf894b77b66400314c4cff61f58a47a2f77d Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Tue, 7 Jul 2026 14:42:06 -0400 Subject: [PATCH 07/12] [PM-38796] Fix tests from merges --- .../OrganizationUsersControllerConfirmInviteLinkTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerConfirmInviteLinkTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerConfirmInviteLinkTests.cs index 77af0f8f000e..d86eca2b6f09 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerConfirmInviteLinkTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerConfirmInviteLinkTests.cs @@ -35,6 +35,9 @@ public OrganizationUsersControllerConfirmInviteLinkTests(ApiApplicationFactory f featureService .IsEnabled(FeatureFlagKeys.GenerateInviteLink) .Returns(true); + featureService + .IsEnabled(FeatureFlagKeys.InviteLinkAutoConfirm) + .Returns(true); }); _client = factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); @@ -137,7 +140,8 @@ private async Task CreateInviteLinkAsync() var createRequest = new CreateOrganizationInviteLinkRequestModel { AllowedDomains = ["example.com"], - EncryptedInviteKey = _validEncryptedKey, + Invite = _validEncryptedKey, + SupportsConfirmation = true, }; var createResponse = await _client.PostAsJsonAsync( $"/organizations/{_organization.Id}/invite-link", createRequest); From 50bdddd36943131f5b6f3d998b13c7dd092a4d0e Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Tue, 7 Jul 2026 15:10:52 -0400 Subject: [PATCH 08/12] [PM-38796] clean up --- src/Api/AdminConsole/Controllers/OrganizationUsersController.cs | 2 +- .../InviteLinks/ConfirmOrganizationInviteLinkValidator.cs | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index e042725c890d..28590227ae22 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -873,7 +873,7 @@ public async Task AcceptInviteLink([FromBody] AcceptOrganizationInviteL return Handle(result, _ => TypedResults.Ok()); } - [HttpPost("/organizations/users/invite-link/link-confirm")] + [HttpPost("/organizations/users/invite-link/confirm")] [RequireFeature(FeatureFlagKeys.InviteLinkAutoConfirm)] public async Task ConfirmInviteLink([FromBody] ConfirmOrganizationInviteLinkRequestModel model) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidator.cs index 2ec8bae4cfeb..487416c7bc82 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidator.cs @@ -141,8 +141,6 @@ public async Task> var singleOrgError = singleOrgRequirement.CanJoinOrganization(organizationId, allOrganizationMemberships); if (singleOrgError is not null) { - - // TODO: Jimmy reassess this // Translate the shared policy error into the link-confirm validation-problem variant so the // endpoint always returns the RFC 7807 shape. CanJoinOrganization only produces the two errors // below; a new one must be mapped here rather than silently falling back to a plain 400. From 9d29fcded4662726ab7d242cfcab23c7c0cfe7a4 Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Tue, 7 Jul 2026 15:33:02 -0400 Subject: [PATCH 09/12] [PM-38796] wip --- .../OrganizationUsersControllerConfirmInviteLinkTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerConfirmInviteLinkTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerConfirmInviteLinkTests.cs index d86eca2b6f09..947fba935e44 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerConfirmInviteLinkTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerConfirmInviteLinkTests.cs @@ -77,7 +77,7 @@ public async Task ConfirmInviteLink_WithValidRequest_ReturnsOkAndConfirmsMembers // Act var response = await joinerClient.PostAsJsonAsync( - "/organizations/users/invite-link/link-confirm", BuildConfirmRequest(code)); + "/organizations/users/invite-link/confirm", BuildConfirmRequest(code)); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -101,7 +101,7 @@ public async Task ConfirmInviteLink_WithUnknownCode_ReturnsNotFound() // Act var response = await joinerClient.PostAsJsonAsync( - "/organizations/users/invite-link/link-confirm", BuildConfirmRequest(Guid.NewGuid())); + "/organizations/users/invite-link/confirm", BuildConfirmRequest(Guid.NewGuid())); // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -115,13 +115,13 @@ public async Task ConfirmInviteLink_WhenAlreadyConfirmed_ReturnsValidationProble var (joinerClient, _) = await CreateJoinerClientAsync(); var firstResponse = await joinerClient.PostAsJsonAsync( - "/organizations/users/invite-link/link-confirm", BuildConfirmRequest(code)); + "/organizations/users/invite-link/confirm", BuildConfirmRequest(code)); Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); // Act // Confirming again finds the already-confirmed membership and fails with a 400 validation problem. var secondResponse = await joinerClient.PostAsJsonAsync( - "/organizations/users/invite-link/link-confirm", BuildConfirmRequest(code)); + "/organizations/users/invite-link/confirm", BuildConfirmRequest(code)); // Assert Assert.Equal(HttpStatusCode.BadRequest, secondResponse.StatusCode); From 5488c093b96467d71c7d3e3f701eece75a2137f1 Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Tue, 7 Jul 2026 15:58:21 -0400 Subject: [PATCH 10/12] [PM-38796] Address AI reviews --- .../ConfirmOrganizationInviteLinkRequestModel.cs | 2 +- .../InviteLinks/ConfirmOrganizationInviteLinkRequest.cs | 6 +++--- .../Interfaces/IConfirmOrganizationInviteLinkCommand.cs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Api/AdminConsole/Models/Request/Organizations/ConfirmOrganizationInviteLinkRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/ConfirmOrganizationInviteLinkRequestModel.cs index ef89d76b56ce..569031192d38 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/ConfirmOrganizationInviteLinkRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/ConfirmOrganizationInviteLinkRequestModel.cs @@ -9,7 +9,7 @@ public class ConfirmOrganizationInviteLinkRequestModel public required Guid Code { get; set; } /// - /// The organization symmetric key encrypted to the user. Released to the user on confirmation. + /// The organization symmetric key encrypted to the user. /// [Required] public required string OrgUserKey { get; set; } diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkRequest.cs index 57a6959e36ae..dd2f6a574a09 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkRequest.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkRequest.cs @@ -4,9 +4,9 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; /// /// The data required to confirm a user into an organization via an invite link. The confirmation -/// creates the membership when one does not already exist, releases the organization key to the user, -/// and runs the policy-driven side effects (Organization Data Ownership, account recovery enrollment, -/// and emergency access removal). +/// creates the membership when one does not already exist, confirms it with the supplied organization +/// key, and runs the policy-driven side effects (Organization Data Ownership default collection and +/// account recovery enrollment). /// public record ConfirmOrganizationInviteLinkRequest { diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IConfirmOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IConfirmOrganizationInviteLinkCommand.cs index 3d587dce2c15..9da1278c4167 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IConfirmOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IConfirmOrganizationInviteLinkCommand.cs @@ -6,8 +6,8 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; /// Confirms a user into an organization through an invite link. Runs the shared read-only precheck /// (), creates the membership when the user is not /// yet a member, confirms it with the supplied organization key, and performs the policy-driven side -/// effects: creating a default collection for Organization Data Ownership, enrolling the user in account -/// recovery when required, and removing the user's emergency access. +/// effects: creating a default collection for Organization Data Ownership and enrolling the user in +/// account recovery when required. /// public interface IConfirmOrganizationInviteLinkCommand { From 107c7d8a6700c594385c65fca8fff3522ac8f9c6 Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Tue, 7 Jul 2026 18:21:22 -0400 Subject: [PATCH 11/12] [PM-38796] Add notifications --- .../ConfirmOrganizationInviteLinkCommand.cs | 5 ++ .../OrganizationUsersControllerTests.cs | 8 ++ ...nfirmOrganizationInviteLinkCommandTests.cs | 79 +++++++++++++++++-- 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs index d875f458c4b6..6e89f8f626a1 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs @@ -9,6 +9,7 @@ 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; @@ -34,6 +35,7 @@ public class ConfirmOrganizationInviteLinkCommand( IOrganizationService organizationService, IStripePaymentService stripePaymentService, IUpdateUserResetPasswordEnrollmentCommand updateUserResetPasswordEnrollmentCommand, + IPushNotificationService pushNotificationService, ILogger logger) : IConfirmOrganizationInviteLinkCommand { @@ -80,6 +82,9 @@ await updateUserResetPasswordEnrollmentCommand.UpdateUserResetPasswordEnrollment 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(); } diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index cc8935aa625d..295f2eb35c75 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -769,6 +769,7 @@ public async Task ConfirmInviteLink_WithValidInput_ReturnsOk( ConfirmOrganizationInviteLinkRequestModel model, SutProvider sutProvider) { + // Arrange sutProvider.GetDependency() .GetUserByPrincipalAsync(Arg.Any()) .Returns(user); @@ -777,8 +778,10 @@ public async Task ConfirmInviteLink_WithValidInput_ReturnsOk( .ConfirmAsync(Arg.Any()) .Returns(new CommandResult(new None())); + // Act var result = await sutProvider.Sut.ConfirmInviteLink(model); + // Assert Assert.IsType(result); await sutProvider.GetDependency() .Received(1) @@ -796,6 +799,7 @@ public async Task ConfirmInviteLink_WhenCommandReturnsError_ReturnsBadRequest( ConfirmOrganizationInviteLinkRequestModel model, SutProvider sutProvider) { + // Arrange sutProvider.GetDependency() .GetUserByPrincipalAsync(Arg.Any()) .Returns(user); @@ -804,8 +808,10 @@ public async Task ConfirmInviteLink_WhenCommandReturnsError_ReturnsBadRequest( .ConfirmAsync(Arg.Any()) .Returns(new CommandResult(new EmailDomainNotAllowed())); + // Act var result = await sutProvider.Sut.ConfirmInviteLink(model); + // Assert Assert.IsType>(result); } @@ -814,10 +820,12 @@ public async Task ConfirmInviteLink_WhenUserNotFound_Throws( ConfirmOrganizationInviteLinkRequestModel model, SutProvider sutProvider) { + // Arrange sutProvider.GetDependency() .GetUserByPrincipalAsync(Arg.Any()) .Returns((User?)null); + // Act & Assert await Assert.ThrowsAsync(() => sutProvider.Sut.ConfirmInviteLink(model)); } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs index d4d80d0b8735..a7ce1c83a2a0 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs @@ -10,6 +10,7 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Test.Common.AutoFixture; @@ -28,12 +29,15 @@ public async Task ConfirmAsync_WhenValidationFails_ReturnsErrorAndDoesNotWrite( ConfirmOrganizationInviteLinkRequest request, SutProvider sutProvider) { + // Arrange sutProvider.GetDependency() .ValidateAsync(Arg.Any()) .Returns(new InviteLinkNotFound()); + // Act var result = await sutProvider.Sut.ConfirmAsync(request); + // Assert Assert.True(result.IsError); Assert.IsType(result.AsError); await sutProvider.GetDependency() @@ -52,11 +56,14 @@ public async Task ConfirmAsync_WithExistingMembership_ConfirmsDirectlyWithoutCre OrganizationUser existingOrganizationUser, SutProvider sutProvider) { + // Arrange SetupHappyPath(organization, inviteLink, user, existingOrganizationUser, sutProvider); - var request = BuildRequest(inviteLink, user); + + // Act var result = await sutProvider.Sut.ConfirmAsync(request); + // Assert Assert.True(result.IsSuccess); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -81,12 +88,15 @@ public async Task ConfirmAsync_WithNewUser_CreatesMembershipDirectlyInConfirmedS User user, SutProvider sutProvider) { + // Arrange SetupHappyPath(organization, inviteLink, user, existingOrganizationUser: null, sutProvider); organization.Seats = null; - var request = BuildRequest(inviteLink, user); + + // Act var result = await sutProvider.Sut.ConfirmAsync(request); + // Assert Assert.True(result.IsSuccess); await sutProvider.GetDependency() .Received(1) @@ -108,6 +118,7 @@ public async Task ConfirmAsync_WithNewUser_AtCapacity_AutoAddsSeat( User user, SutProvider sutProvider) { + // Arrange SetupHappyPath(organization, inviteLink, user, existingOrganizationUser: null, sutProvider); organization.Seats = 2; @@ -115,8 +126,10 @@ public async Task ConfirmAsync_WithNewUser_AtCapacity_AutoAddsSeat( .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) .Returns(new OrganizationSeatCounts { Users = 2, Sponsored = 0 }); + // Act var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); + // Assert Assert.True(result.IsSuccess); await sutProvider.GetDependency() .Received(1) @@ -133,6 +146,7 @@ public async Task ConfirmAsync_WithNewUser_SeatExpansionFails_ReturnsErrorAndDoe User user, SutProvider sutProvider) { + // Arrange SetupHappyPath(organization, inviteLink, user, existingOrganizationUser: null, sutProvider); organization.Seats = 2; @@ -143,8 +157,10 @@ public async Task ConfirmAsync_WithNewUser_SeatExpansionFails_ReturnsErrorAndDoe .AutoAddSeatsAsync(organization, 1) .ThrowsAsync(new BadRequestException("No payment method.")); + // Act var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); + // Assert Assert.True(result.IsError); Assert.IsType(result.AsError); await sutProvider.GetDependency() @@ -163,12 +179,15 @@ public async Task ConfirmAsync_WhenAutoEnrollEnabledAndKeyMissing_ReturnsResetPa OrganizationUser existingOrganizationUser, SutProvider sutProvider) { + // Arrange SetupHappyPath(organization, inviteLink, user, existingOrganizationUser, sutProvider); SetupAutoEnrollPolicy(organization, user, sutProvider); - var request = BuildRequest(inviteLink, user) with { ResetPasswordKey = null }; + + // Act var result = await sutProvider.Sut.ConfirmAsync(request); + // Assert Assert.True(result.IsError); Assert.IsType(result.AsError); await sutProvider.GetDependency() @@ -184,12 +203,15 @@ public async Task ConfirmAsync_WhenAutoEnrollEnabledWithValidKey_EnrollsUser( OrganizationUser existingOrganizationUser, SutProvider sutProvider) { + // Arrange SetupHappyPath(organization, inviteLink, user, existingOrganizationUser, sutProvider); SetupAutoEnrollPolicy(organization, user, sutProvider); - var request = BuildRequest(inviteLink, user) with { ResetPasswordKey = "2.validresetpasswordkey" }; + + // Act var result = await sutProvider.Sut.ConfirmAsync(request); + // Assert Assert.True(result.IsSuccess); await sutProvider.GetDependency() .Received(1) @@ -204,13 +226,16 @@ public async Task ConfirmAsync_WhenDataOwnershipApplies_CreatesDefaultCollection OrganizationUser existingOrganizationUser, SutProvider sutProvider) { + // Arrange SetupHappyPath(organization, inviteLink, user, existingOrganizationUser, sutProvider); organization.UseMyItems = true; SetupDataOwnershipPolicy(organization, existingOrganizationUser, user, sutProvider); - var request = BuildRequest(inviteLink, user); + + // Act var result = await sutProvider.Sut.ConfirmAsync(request); + // Assert Assert.True(result.IsSuccess); await sutProvider.GetDependency() .Received(1) @@ -228,11 +253,14 @@ public async Task ConfirmAsync_WhenDataOwnershipDoesNotApply_DoesNotCreateDefaul OrganizationUser existingOrganizationUser, SutProvider sutProvider) { + // Arrange // No data ownership policy details are configured, so the policy does not apply. SetupHappyPath(organization, inviteLink, user, existingOrganizationUser, sutProvider); + // Act var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); + // Assert Assert.True(result.IsSuccess); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -240,6 +268,47 @@ await sutProvider.GetDependency() } + [Theory, BitAutoData] + public async Task ConfirmAsync_WithExistingMembership_PushesSyncOrgKeys( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + OrganizationUser existingOrganizationUser, + SutProvider sutProvider) + { + // Arrange + SetupHappyPath(organization, inviteLink, user, existingOrganizationUser, sutProvider); + + // Act + var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); + + // Assert + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .Received(1) + .PushSyncOrgKeysAsync(user.Id); + } + + [Theory, BitAutoData] + public async Task ConfirmAsync_WhenValidationFails_DoesNotPushSyncOrgKeys( + ConfirmOrganizationInviteLinkRequest request, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new InviteLinkNotFound()); + + // Act + var result = await sutProvider.Sut.ConfirmAsync(request); + + // Assert + Assert.True(result.IsError); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .PushSyncOrgKeysAsync(Arg.Any()); + } + private static ConfirmOrganizationInviteLinkRequest BuildRequest(OrganizationInviteLink inviteLink, User user) => new() { From 4db3ba3cb34c7f695c173b3d8b07f599a0a17ea9 Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Tue, 7 Jul 2026 19:51:16 -0400 Subject: [PATCH 12/12] [PM-38796] Add admin limit check --- .../ConfirmOrganizationInviteLinkValidator.cs | 21 ++++++ ...irmOrganizationInviteLinkValidatorTests.cs | 64 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidator.cs index 487416c7bc82..0280f3c1782d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidator.cs @@ -11,6 +11,7 @@ using Bit.Core.AdminConsole.Utilities.v2; using Bit.Core.AdminConsole.Utilities.v2.Results; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; using Bit.Core.Entities; using Bit.Core.Enums; @@ -79,6 +80,12 @@ public async Task> return membershipStatusError; } + var freeAdminError = await ValidateFreeOrganizationAdminLimitAsync(organization, existingOrganizationUser, user); + if (freeAdminError is not null) + { + return freeAdminError; + } + // A seat is only consumed when a brand-new membership will be created. An existing pending // invitation already occupies a seat, so confirming it adds no capacity pressure. if (existingOrganizationUser is null) @@ -127,6 +134,20 @@ public async Task> _ => null }; + private async Task ValidateFreeOrganizationAdminLimitAsync( + Organization targetOrganization, OrganizationUser? existingOrganizationUser, User user) + { + if (existingOrganizationUser?.Type is OrganizationUserType.Owner + or OrganizationUserType.Admin + && targetOrganization.PlanType == PlanType.Free + && await organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(user.Id) > 0) + { + return new OnlyOneFreeOrganizationAdminAllowed(); + } + + return null; + } + /// /// Enforces the membership policies that gate confirmation: Single Organization and Require Two-Factor /// Authentication. These are read-only checks; no enrollment or revocation side effects are performed. diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidatorTests.cs index e3becef9a623..658e70ccf4a1 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidatorTests.cs @@ -470,6 +470,70 @@ await sutProvider.GetDependency() .GetPlan(Arg.Any()); } + [Theory, BitAutoData] + public async Task ValidateAsync_WithFreeAdminMembership_AdminLimitReached_ReturnsError( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + OrganizationUser existingOrganizationUser, + SutProvider sutProvider) + { + // Arrange + SetupHappyPath(organization, inviteLink, user, sutProvider); + organization.PlanType = PlanType.Free; + existingOrganizationUser.Type = OrganizationUserType.Admin; + existingOrganizationUser.Status = OrganizationUserStatusType.Accepted; + existingOrganizationUser.RevocationReason = null; + + sutProvider.GetDependency() + .GetByOrganizationAsync(organization.Id, user.Id) + .Returns(existingOrganizationUser); + + sutProvider.GetDependency() + .GetCountByFreeOrganizationAdminUserAsync(user.Id) + .Returns(1); + + // Act + var request = new ConfirmOrganizationInviteLinkValidationRequest { Code = inviteLink.Code, User = user }; + var result = await sutProvider.Sut.ValidateAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_WithFreeAdminMembership_NotAtLimit_IsAllowed( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + OrganizationUser existingOrganizationUser, + SutProvider sutProvider) + { + // Arrange + SetupHappyPath(organization, inviteLink, user, sutProvider); + organization.PlanType = PlanType.Free; + existingOrganizationUser.Type = OrganizationUserType.Admin; + existingOrganizationUser.Status = OrganizationUserStatusType.Accepted; + existingOrganizationUser.RevocationReason = null; + + sutProvider.GetDependency() + .GetByOrganizationAsync(organization.Id, user.Id) + .Returns(existingOrganizationUser); + + sutProvider.GetDependency() + .GetCountByFreeOrganizationAdminUserAsync(user.Id) + .Returns(0); + + // Act + var request = new ConfirmOrganizationInviteLinkValidationRequest { Code = inviteLink.Code, User = user }; + var result = await sutProvider.Sut.ValidateAsync(request); + + // Assert + Assert.True(result.IsSuccess); + Assert.Same(existingOrganizationUser, result.AsSuccess.ExistingOrganizationUser); + } + private static void SetupHappyPath( Organization org, OrganizationInviteLink link,