diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index d436b1a7ad3f..28590227ae22 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}")] @@ -869,4 +872,26 @@ public async Task AcceptInviteLink([FromBody] AcceptOrganizationInviteL return Handle(result, _ => TypedResults.Ok()); } + + [HttpPost("/organizations/users/invite-link/confirm")] + [RequireFeature(FeatureFlagKeys.InviteLinkAutoConfirm)] + 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..569031192d38 --- /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. + /// + [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..6e89f8f626a1 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs @@ -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; + +/// +/// 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, + IPushNotificationService pushNotificationService, + 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 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> 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 . + /// 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 ConfirmSeatAddFailed(); + } + } + + /// + /// 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/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/ConfirmOrganizationInviteLinkRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkRequest.cs new file mode 100644 index 000000000000..dd2f6a574a09 --- /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, 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 +{ + /// + /// 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/ConfirmOrganizationInviteLinkValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidator.cs index 6dd3174d0581..0280f3c1782d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidator.cs @@ -1,15 +1,17 @@ -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; 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; @@ -56,18 +58,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); @@ -78,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) @@ -121,11 +129,25 @@ 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 }; + 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. @@ -140,7 +162,16 @@ public async Task> var singleOrgError = singleOrgRequirement.CanJoinOrganization(organizationId, allOrganizationMemberships); if (singleOrgError is not null) { - return singleOrgError; + // 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 +180,7 @@ public async Task> .GetAsync(user.Id); if (twoFactorRequirement.IsTwoFactorRequiredForOrganization(organizationId)) { - return new TwoFactorRequiredForMembership(); + return new ConfirmTwoFactorRequiredForMembership(); } } @@ -172,7 +203,7 @@ public async Task> return InviteUsersPasswordManagerValidator.ValidatePasswordManager(subscriptionUpdate) is PasswordManagerValidation.Invalid - ? new OrganizationHasNoAvailableSeats() + ? new ConfirmOrganizationHasNoAvailableSeats() : null; } } 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..9da1278c4167 --- /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 and enrolling the user in +/// account recovery when required. +/// +public interface IConfirmOrganizationInviteLinkCommand +{ + Task ConfirmAsync(ConfirmOrganizationInviteLinkRequest request); +} 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"; diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 1c2390f9f7d6..2ee7bd641af4 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -209,6 +209,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.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerConfirmInviteLinkTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerConfirmInviteLinkTests.cs new file mode 100644 index 000000000000..947fba935e44 --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerConfirmInviteLinkTests.cs @@ -0,0 +1,172 @@ +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); + featureService + .IsEnabled(FeatureFlagKeys.InviteLinkAutoConfirm) + .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/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/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/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/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"], + Invite = _validEncryptedKey, + SupportsConfirmation = true, + }; + 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/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index 8f31d2f94cd2..295f2eb35c75 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,70 @@ await sutProvider.GetDependency() .Received(1) .BulkResendInvitesAsync(organizationId, userId, bulkRequestModel.Ids); } + + [Theory, BitAutoData] + public async Task ConfirmInviteLink_WithValidInput_ReturnsOk( + User user, + ConfirmOrganizationInviteLinkRequestModel model, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + sutProvider.GetDependency() + .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) + .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) + { + // Arrange + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + sutProvider.GetDependency() + .ConfirmAsync(Arg.Any()) + .Returns(new CommandResult(new EmailDomainNotAllowed())); + + // Act + var result = await sutProvider.Sut.ConfirmInviteLink(model); + + // Assert + Assert.IsType>(result); + } + + [Theory, BitAutoData] + 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 new file mode 100644 index 000000000000..a7ce1c83a2a0 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs @@ -0,0 +1,396 @@ +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.Entities; +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; +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) + { + // 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() + .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) + { + // 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() + .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) + { + // 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) + .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) + { + // Arrange + SetupHappyPath(organization, inviteLink, user, existingOrganizationUser: null, sutProvider); + organization.Seats = 2; + + sutProvider.GetDependency() + .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) + .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) + { + // Arrange + 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.")); + + // Act + var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); + + // Assert + 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) + { + // 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() + .DidNotReceiveWithAnyArgs() + .ReplaceAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ConfirmAsync_WhenAutoEnrollEnabledWithValidKey_EnrollsUser( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + 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) + .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) + { + // 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) + .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) + { + // 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() + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + + [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() + { + 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, + } + ])); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkValidatorTests.cs index 1d457e3736b1..658e70ccf4a1 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] @@ -435,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,