diff --git a/Directory.Build.props b/Directory.Build.props index 858abb2bc8..e4712937ca 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.4.0 + 2025.4.1 Bit.$(MSBuildProjectName) enable diff --git a/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs b/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs index 990ac27ec2..295db790e3 100644 --- a/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs +++ b/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs @@ -1,8 +1,11 @@ using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.Enums; -using Bit.Core.Models.Business; +using Bit.Core.Exceptions; using Bit.Core.Models.Data; using Bit.Core.Utilities; +using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite; namespace Bit.Scim.Models; @@ -10,7 +13,8 @@ public class ScimUserRequestModel : BaseScimUserModel { public ScimUserRequestModel() : base(false) - { } + { + } public OrganizationUserInvite ToOrganizationUserInvite(ScimProviderType scimProvider) { @@ -25,6 +29,31 @@ public class ScimUserRequestModel : BaseScimUserModel }; } + public InviteOrganizationUsersRequest ToRequest( + ScimProviderType scimProvider, + InviteOrganization inviteOrganization, + DateTimeOffset performedAt) + { + var email = EmailForInvite(scimProvider); + + if (string.IsNullOrWhiteSpace(email) || !Active) + { + throw new BadRequestException(); + } + + return new InviteOrganizationUsersRequest( + invites: + [ + new Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite( + email: email, + externalId: ExternalIdForInvite() + ) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, // SCIM does not have a user id + performedAt: performedAt); + } + private string EmailForInvite(ScimProviderType scimProvider) { var email = PrimaryEmail?.ToLowerInvariant(); diff --git a/bitwarden_license/src/Scim/Users/PostUserCommand.cs b/bitwarden_license/src/Scim/Users/PostUserCommand.cs index 26ddd20512..46116a46ae 100644 --- a/bitwarden_license/src/Scim/Users/PostUserCommand.cs +++ b/bitwarden_license/src/Scim/Users/PostUserCommand.cs @@ -1,39 +1,99 @@ -using Bit.Core.Enums; +#nullable enable + +using Bit.Core; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.Billing.Pricing; +using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Commands; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Scim.Context; using Bit.Scim.Models; using Bit.Scim.Users.Interfaces; +using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors.ErrorMapper; namespace Bit.Scim.Users; -public class PostUserCommand : IPostUserCommand +public class PostUserCommand( + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationService organizationService, + IPaymentService paymentService, + IScimContext scimContext, + IFeatureService featureService, + IInviteOrganizationUsersCommand inviteOrganizationUsersCommand, + TimeProvider timeProvider, + IPricingClient pricingClient) + : IPostUserCommand { - private readonly IOrganizationRepository _organizationRepository; - private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IOrganizationService _organizationService; - private readonly IPaymentService _paymentService; - private readonly IScimContext _scimContext; - - public PostUserCommand( - IOrganizationRepository organizationRepository, - IOrganizationUserRepository organizationUserRepository, - IOrganizationService organizationService, - IPaymentService paymentService, - IScimContext scimContext) + public async Task PostUserAsync(Guid organizationId, ScimUserRequestModel model) { - _organizationRepository = organizationRepository; - _organizationUserRepository = organizationUserRepository; - _organizationService = organizationService; - _paymentService = paymentService; - _scimContext = scimContext; + if (featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization) is false) + { + return await InviteScimOrganizationUserAsync(model, organizationId, scimContext.RequestScimProvider); + } + + return await InviteScimOrganizationUserAsync_vNext(model, organizationId, scimContext.RequestScimProvider); } - public async Task PostUserAsync(Guid organizationId, ScimUserRequestModel model) + private async Task InviteScimOrganizationUserAsync_vNext( + ScimUserRequestModel model, + Guid organizationId, + ScimProviderType scimProvider) + { + var organization = await organizationRepository.GetByIdAsync(organizationId); + + if (organization is null) + { + throw new NotFoundException(); + } + + var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); + + var request = model.ToRequest( + scimProvider: scimProvider, + inviteOrganization: new InviteOrganization(organization, plan), + performedAt: timeProvider.GetUtcNow()); + + var orgUsers = await organizationUserRepository + .GetManyDetailsByOrganizationAsync(request.InviteOrganization.OrganizationId); + + if (orgUsers.Any(existingUser => + request.Invites.First().Email.Equals(existingUser.Email, StringComparison.OrdinalIgnoreCase) || + request.Invites.First().ExternalId.Equals(existingUser.ExternalId, StringComparison.OrdinalIgnoreCase))) + { + throw new ConflictException("User already exists."); + } + + var result = await inviteOrganizationUsersCommand.InviteScimOrganizationUserAsync(request); + + var invitedOrganizationUserId = result switch + { + Success success => success.Value.InvitedUser.Id, + Failure failure when failure.Errors + .Any(x => x.Message == NoUsersToInviteError.Code) => (Guid?)null, + Failure failure when failure.Errors.Length != 0 => throw MapToBitException(failure.Errors), + _ => throw new InvalidOperationException() + }; + + var organizationUser = invitedOrganizationUserId.HasValue + ? await organizationUserRepository.GetDetailsByIdAsync(invitedOrganizationUserId.Value) + : null; + + return organizationUser; + } + + private async Task InviteScimOrganizationUserAsync( + ScimUserRequestModel model, + Guid organizationId, + ScimProviderType scimProvider) { - var scimProvider = _scimContext.RequestScimProvider; var invite = model.ToOrganizationUserInvite(scimProvider); var email = invite.Emails.Single(); @@ -44,7 +104,7 @@ public class PostUserCommand : IPostUserCommand throw new BadRequestException(); } - var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); + var orgUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email); if (orgUserByEmail != null) { @@ -57,13 +117,21 @@ public class PostUserCommand : IPostUserCommand throw new ConflictException(); } - var organization = await _organizationRepository.GetByIdAsync(organizationId); - var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization); + var organization = await organizationRepository.GetByIdAsync(organizationId); + + if (organization == null) + { + throw new NotFoundException(); + } + + var hasStandaloneSecretsManager = await paymentService.HasSecretsManagerStandalone(organization); invite.AccessSecretsManager = hasStandaloneSecretsManager; - var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM, - invite, externalId); - var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id); + var invitedOrgUser = await organizationService.InviteUserAsync(organizationId, invitingUserId: null, + EventSystemUser.SCIM, + invite, + externalId); + var orgUser = await organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id); return orgUser; } diff --git a/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/UsersControllerTests.cs b/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/UsersControllerTests.cs index 4c4fda952a..1f86d99b63 100644 --- a/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/UsersControllerTests.cs +++ b/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/UsersControllerTests.cs @@ -1,9 +1,12 @@ using System.Text.Json; +using Bit.Core; using Bit.Core.Enums; +using Bit.Core.Services; using Bit.Scim.IntegrationTest.Factories; using Bit.Scim.Models; using Bit.Scim.Utilities; using Bit.Test.Common.Helpers; +using NSubstitute; using Xunit; namespace Bit.Scim.IntegrationTest.Controllers.v2; @@ -276,9 +279,18 @@ public class UsersControllerTests : IClassFixture, IAsyn AssertHelper.AssertPropertyEqual(expectedResponse, responseModel); } - [Fact] - public async Task Post_Success() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Post_Success(bool isScimInviteUserOptimizationEnabled) { + var localFactory = new ScimApplicationFactory(); + localFactory.SubstituteService((IFeatureService featureService) + => featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization) + .Returns(isScimInviteUserOptimizationEnabled)); + + localFactory.ReinitializeDbForTests(localFactory.GetDatabaseContext()); + var email = "user5@example.com"; var displayName = "Test User 5"; var externalId = "UE"; @@ -306,7 +318,7 @@ public class UsersControllerTests : IClassFixture, IAsyn Schemas = new List { ScimConstants.Scim2SchemaUser } }; - var context = await _factory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, inputModel); + var context = await localFactory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, inputModel); Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode); @@ -316,7 +328,7 @@ public class UsersControllerTests : IClassFixture, IAsyn var responseModel = JsonSerializer.Deserialize(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); AssertHelper.AssertPropertyEqual(expectedResponse, responseModel, "Id"); - var databaseContext = _factory.GetDatabaseContext(); + var databaseContext = localFactory.GetDatabaseContext(); Assert.Equal(_initialUserCount + 1, databaseContext.OrganizationUsers.Count()); } diff --git a/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs b/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs index 71ad6361bd..ac23e7ecc1 100644 --- a/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs +++ b/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs @@ -27,7 +27,7 @@ public class PostUserCommandTests ExternalId = externalId, Emails = emails, Active = true, - Schemas = new List { ScimConstants.Scim2SchemaUser } + Schemas = [ScimConstants.Scim2SchemaUser] }; sutProvider.GetDependency() @@ -39,13 +39,16 @@ public class PostUserCommandTests sutProvider.GetDependency().HasSecretsManagerStandalone(organization).Returns(true); sutProvider.GetDependency() - .InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM, + .InviteUserAsync(organizationId, + invitingUserId: null, + EventSystemUser.SCIM, Arg.Is(i => i.Emails.Single().Equals(scimUserRequestModel.PrimaryEmail.ToLowerInvariant()) && i.Type == OrganizationUserType.User && !i.Collections.Any() && !i.Groups.Any() && - i.AccessSecretsManager), externalId) + i.AccessSecretsManager), + externalId) .Returns(newUser); var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel); diff --git a/src/Admin/Controllers/UsersController.cs b/src/Admin/Controllers/UsersController.cs index cebb7d4b1e..71be19a041 100644 --- a/src/Admin/Controllers/UsersController.cs +++ b/src/Admin/Controllers/UsersController.cs @@ -184,7 +184,7 @@ public class UsersController : Controller private async Task AccountDeprovisioningEnabled(Guid userId) { return _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - ? await _userService.IsManagedByAnyOrganizationAsync(userId) + ? await _userService.IsClaimedByAnyOrganizationAsync(userId) : null; } } diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 5fd9109077..8a4cd54026 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -56,8 +56,8 @@ public class OrganizationUsersController : Controller private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; - private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand; - private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery; + private readonly IDeleteClaimedOrganizationUserAccountCommand _deleteClaimedOrganizationUserAccountCommand; + private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery; private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IFeatureService _featureService; private readonly IPricingClient _pricingClient; @@ -83,8 +83,8 @@ public class OrganizationUsersController : Controller IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IRemoveOrganizationUserCommand removeOrganizationUserCommand, - IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand, - IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery, + IDeleteClaimedOrganizationUserAccountCommand deleteClaimedOrganizationUserAccountCommand, + IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery, IPolicyRequirementQuery policyRequirementQuery, IFeatureService featureService, IPricingClient pricingClient, @@ -109,8 +109,8 @@ public class OrganizationUsersController : Controller _organizationUserUserDetailsQuery = organizationUserUserDetailsQuery; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _removeOrganizationUserCommand = removeOrganizationUserCommand; - _deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand; - _getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery; + _deleteClaimedOrganizationUserAccountCommand = deleteClaimedOrganizationUserAccountCommand; + _getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery; _policyRequirementQuery = policyRequirementQuery; _featureService = featureService; _pricingClient = pricingClient; @@ -127,11 +127,11 @@ public class OrganizationUsersController : Controller throw new NotFoundException(); } - var managedByOrganization = await GetManagedByOrganizationStatusAsync( + var claimedByOrganizationStatus = await GetClaimedByOrganizationStatusAsync( organizationUser.OrganizationId, [organizationUser.Id]); - var response = new OrganizationUserDetailsResponseModel(organizationUser, managedByOrganization[organizationUser.Id], collections); + var response = new OrganizationUserDetailsResponseModel(organizationUser, claimedByOrganizationStatus[organizationUser.Id], collections); if (includeGroups) { @@ -175,13 +175,13 @@ public class OrganizationUsersController : Controller } ); var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers); - var organizationUsersManagementStatus = await GetManagedByOrganizationStatusAsync(orgId, organizationUsers.Select(o => o.Id)); + var organizationUsersClaimedStatus = await GetClaimedByOrganizationStatusAsync(orgId, organizationUsers.Select(o => o.Id)); var responses = organizationUsers .Select(o => { var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled; - var managedByOrganization = organizationUsersManagementStatus[o.Id]; - var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled, managedByOrganization); + var claimedByOrganization = organizationUsersClaimedStatus[o.Id]; + var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled, claimedByOrganization); return orgUser; }); @@ -591,7 +591,7 @@ public class OrganizationUsersController : Controller throw new UnauthorizedAccessException(); } - await _deleteManagedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id); + await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id); } [RequireFeature(FeatureFlagKeys.AccountDeprovisioning)] @@ -610,7 +610,7 @@ public class OrganizationUsersController : Controller throw new UnauthorizedAccessException(); } - var results = await _deleteManagedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id); + var results = await _deleteClaimedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id); return new ListResponseModel(results.Select(r => new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage))); @@ -717,14 +717,14 @@ public class OrganizationUsersController : Controller new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2))); } - private async Task> GetManagedByOrganizationStatusAsync(Guid orgId, IEnumerable userIds) + private async Task> GetClaimedByOrganizationStatusAsync(Guid orgId, IEnumerable userIds) { if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) { return userIds.ToDictionary(kvp => kvp, kvp => false); } - var usersOrganizationManagementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(orgId, userIds); - return usersOrganizationManagementStatus; + var usersOrganizationClaimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(orgId, userIds); + return usersOrganizationClaimedStatus; } } diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 9fa9cb6672..6b7d031a00 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -140,10 +140,10 @@ public class OrganizationsController : Controller var organizations = await _organizationUserRepository.GetManyDetailsByUserAsync(userId, OrganizationUserStatusType.Confirmed); - var organizationManagingActiveUser = await _userService.GetOrganizationsManagingUserAsync(userId); - var organizationIdsManagingActiveUser = organizationManagingActiveUser.Select(o => o.Id); + var organizationsClaimingActiveUser = await _userService.GetOrganizationsClaimingUserAsync(userId); + var organizationIdsClaimingActiveUser = organizationsClaimingActiveUser.Select(o => o.Id); - var responses = organizations.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingActiveUser)); + var responses = organizations.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsClaimingActiveUser)); return new ListResponseModel(responses); } @@ -277,9 +277,9 @@ public class OrganizationsController : Controller } if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - && (await _userService.GetOrganizationsManagingUserAsync(user.Id)).Any(x => x.Id == id)) + && (await _userService.GetOrganizationsClaimingUserAsync(user.Id)).Any(x => x.Id == id)) { - throw new BadRequestException("Managed user account cannot leave managing organization. Contact your organization administrator for additional details."); + throw new BadRequestException("Claimed user account cannot leave claiming organization. Contact your organization administrator for additional details."); } await _removeOrganizationUserCommand.UserLeaveAsync(id, user.Id); diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs index 64dca73aaa..f9e5193045 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs @@ -66,24 +66,30 @@ public class OrganizationUserDetailsResponseModel : OrganizationUserResponseMode { public OrganizationUserDetailsResponseModel( OrganizationUser organizationUser, - bool managedByOrganization, + bool claimedByOrganization, IEnumerable collections) : base(organizationUser, "organizationUserDetails") { - ManagedByOrganization = managedByOrganization; + ClaimedByOrganization = claimedByOrganization; Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c)); } public OrganizationUserDetailsResponseModel(OrganizationUserUserDetails organizationUser, - bool managedByOrganization, + bool claimedByOrganization, IEnumerable collections) : base(organizationUser, "organizationUserDetails") { - ManagedByOrganization = managedByOrganization; + ClaimedByOrganization = claimedByOrganization; Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c)); } - public bool ManagedByOrganization { get; set; } + [Obsolete("Please use ClaimedByOrganization instead. This property will be removed in a future version.")] + public bool ManagedByOrganization + { + get => ClaimedByOrganization; + set => ClaimedByOrganization = value; + } + public bool ClaimedByOrganization { get; set; } public IEnumerable Collections { get; set; } @@ -117,7 +123,7 @@ public class OrganizationUserUserMiniDetailsResponseModel : ResponseModel public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel { public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser, - bool twoFactorEnabled, bool managedByOrganization, string obj = "organizationUserUserDetails") + bool twoFactorEnabled, bool claimedByOrganization, string obj = "organizationUserUserDetails") : base(organizationUser, obj) { if (organizationUser == null) @@ -134,7 +140,7 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse Groups = organizationUser.Groups; // Prevent reset password when using key connector. ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector; - ManagedByOrganization = managedByOrganization; + ClaimedByOrganization = claimedByOrganization; } public string Name { get; set; } @@ -142,11 +148,17 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse public string AvatarColor { get; set; } public bool TwoFactorEnabled { get; set; } public bool SsoBound { get; set; } + [Obsolete("Please use ClaimedByOrganization instead. This property will be removed in a future version.")] + public bool ManagedByOrganization + { + get => ClaimedByOrganization; + set => ClaimedByOrganization = value; + } /// - /// Indicates if the organization manages the user. If a user is "managed" by an organization, + /// Indicates if the organization claimed the user. If a user is "claimed" by an organization, /// the organization has greater control over their account, and some user actions are restricted. /// - public bool ManagedByOrganization { get; set; } + public bool ClaimedByOrganization { get; set; } public IEnumerable Collections { get; set; } public IEnumerable Groups { get; set; } } diff --git a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs index 3a901f11c4..437c30b8b9 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs @@ -18,7 +18,7 @@ public class ProfileOrganizationResponseModel : ResponseModel public ProfileOrganizationResponseModel( OrganizationUserOrganizationDetails organization, - IEnumerable organizationIdsManagingUser) + IEnumerable organizationIdsClaimingUser) : this("profileOrganization") { Id = organization.OrganizationId; @@ -51,7 +51,7 @@ public class ProfileOrganizationResponseModel : ResponseModel SsoBound = !string.IsNullOrWhiteSpace(organization.SsoExternalId); Identifier = organization.Identifier; Permissions = CoreHelpers.LoadClassFromJsonData(organization.Permissions); - ResetPasswordEnrolled = organization.ResetPasswordKey != null; + ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(organization.ResetPasswordKey); UserId = organization.UserId; OrganizationUserId = organization.OrganizationUserId; ProviderId = organization.ProviderId; @@ -70,7 +70,7 @@ public class ProfileOrganizationResponseModel : ResponseModel LimitCollectionDeletion = organization.LimitCollectionDeletion; LimitItemDeletion = organization.LimitItemDeletion; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; - UserIsManagedByOrganization = organizationIdsManagingUser.Contains(organization.OrganizationId); + UserIsClaimedByOrganization = organizationIdsClaimingUser.Contains(organization.OrganizationId); UseRiskInsights = organization.UseRiskInsights; if (organization.SsoConfig != null) @@ -133,15 +133,26 @@ public class ProfileOrganizationResponseModel : ResponseModel public bool LimitItemDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } /// - /// Indicates if the organization manages the user. + /// Obsolete. + /// + /// See + /// + [Obsolete("Please use UserIsClaimedByOrganization instead. This property will be removed in a future version.")] + public bool UserIsManagedByOrganization + { + get => UserIsClaimedByOrganization; + set => UserIsClaimedByOrganization = value; + } + /// + /// Indicates if the organization claims the user. /// /// - /// An organization manages a user if the user's email domain is verified by the organization and the user is a member of it. + /// An organization claims a user if the user's email domain is verified by the organization and the user is a member of it. /// The organization must be enabled and able to have verified domains. /// /// /// False if the Account Deprovisioning feature flag is disabled. /// - public bool UserIsManagedByOrganization { get; set; } + public bool UserIsClaimedByOrganization { get; set; } public bool UseRiskInsights { get; set; } } diff --git a/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs b/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs index 91e8788d01..933cda9dca 100644 --- a/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs +++ b/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs @@ -30,7 +30,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel Email = user.Email; Status = user.Status; Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c)); - ResetPasswordEnrolled = user.ResetPasswordKey != null; + ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(user.ResetPasswordKey); } [SetsRequiredMembers] @@ -49,7 +49,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel TwoFactorEnabled = twoFactorEnabled; Status = user.Status; Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c)); - ResetPasswordEnrolled = user.ResetPasswordKey != null; + ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(user.ResetPasswordKey); SsoExternalId = user.SsoExternalId; } diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 2555a6fe2d..b22d54fa55 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -124,11 +124,11 @@ public class AccountsController : Controller throw new BadRequestException("MasterPasswordHash", "Invalid password."); } - var managedUserValidationResult = await _userService.ValidateManagedUserDomainAsync(user, model.NewEmail); + var claimedUserValidationResult = await _userService.ValidateClaimedUserDomainAsync(user, model.NewEmail); - if (!managedUserValidationResult.Succeeded) + if (!claimedUserValidationResult.Succeeded) { - throw new BadRequestException(managedUserValidationResult.Errors); + throw new BadRequestException(claimedUserValidationResult.Errors); } await _userService.InitiateEmailChangeAsync(user, model.NewEmail); @@ -437,11 +437,11 @@ public class AccountsController : Controller var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user); - var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); + var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id); var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, twoFactorEnabled, - hasPremiumFromOrg, organizationIdsManagingActiveUser); + hasPremiumFromOrg, organizationIdsClaimingActiveUser); return response; } @@ -451,9 +451,9 @@ public class AccountsController : Controller var userId = _userService.GetProperUserId(User); var organizationUserDetails = await _organizationUserRepository.GetManyDetailsByUserAsync(userId.Value, OrganizationUserStatusType.Confirmed); - var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(userId.Value); + var organizationIdsClaimingUser = await GetOrganizationIdsClaimingUserAsync(userId.Value); - var responseData = organizationUserDetails.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingActiveUser)); + var responseData = organizationUserDetails.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsClaimingUser)); return new ListResponseModel(responseData); } @@ -471,9 +471,9 @@ public class AccountsController : Controller var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user); - var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); + var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id); - var response = new ProfileResponseModel(user, null, null, null, twoFactorEnabled, hasPremiumFromOrg, organizationIdsManagingActiveUser); + var response = new ProfileResponseModel(user, null, null, null, twoFactorEnabled, hasPremiumFromOrg, organizationIdsClaimingActiveUser); return response; } @@ -490,9 +490,9 @@ public class AccountsController : Controller var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user); - var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); + var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id); - var response = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsManagingActiveUser); + var response = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingActiveUser); return response; } @@ -560,9 +560,9 @@ public class AccountsController : Controller } else { - // If Account Deprovisioning is enabled, we need to check if the user is managed by any organization. + // If Account Deprovisioning is enabled, we need to check if the user is claimed by any organization. if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - && await _userService.IsManagedByAnyOrganizationAsync(user.Id)) + && await _userService.IsClaimedByAnyOrganizationAsync(user.Id)) { throw new BadRequestException("Cannot delete accounts owned by an organization. Contact your organization administrator for additional details."); } @@ -763,9 +763,9 @@ public class AccountsController : Controller await _userService.SaveUserAsync(user); } - private async Task> GetOrganizationIdsManagingUserAsync(Guid userId) + private async Task> GetOrganizationIdsClaimingUserAsync(Guid userId) { - var organizationManagingUser = await _userService.GetOrganizationsManagingUserAsync(userId); - return organizationManagingUser.Select(o => o.Id); + var organizationsClaimingUser = await _userService.GetOrganizationsClaimingUserAsync(userId); + return organizationsClaimingUser.Select(o => o.Id); } } diff --git a/src/Api/Billing/Controllers/AccountsController.cs b/src/Api/Billing/Controllers/AccountsController.cs index 9c5811b195..bc263691a8 100644 --- a/src/Api/Billing/Controllers/AccountsController.cs +++ b/src/Api/Billing/Controllers/AccountsController.cs @@ -58,10 +58,10 @@ public class AccountsController( var userTwoFactorEnabled = await userService.TwoFactorIsEnabledAsync(user); var userHasPremiumFromOrganization = await userService.HasPremiumFromOrganization(user); - var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); + var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id); var profile = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, - userHasPremiumFromOrganization, organizationIdsManagingActiveUser); + userHasPremiumFromOrganization, organizationIdsClaimingActiveUser); return new PaymentResponseModel { UserProfile = profile, @@ -229,9 +229,9 @@ public class AccountsController( await paymentService.SaveTaxInfoAsync(user, taxInfo); } - private async Task> GetOrganizationIdsManagingUserAsync(Guid userId) + private async Task> GetOrganizationIdsClaimingUserAsync(Guid userId) { - var organizationManagingUser = await userService.GetOrganizationsManagingUserAsync(userId); - return organizationManagingUser.Select(o => o.Id); + var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId); + return organizationsClaimingUser.Select(o => o.Id); } } diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index de14a8d798..510f6c2835 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -409,9 +409,9 @@ public class OrganizationsController( organizationId, OrganizationUserStatusType.Confirmed); - var organizationIdsManagingActiveUser = (await userService.GetOrganizationsManagingUserAsync(userId)) + var organizationIdsClaimingActiveUser = (await userService.GetOrganizationsClaimingUserAsync(userId)) .Select(o => o.Id); - return new ProfileOrganizationResponseModel(organizationUserDetails, organizationIdsManagingActiveUser); + return new ProfileOrganizationResponseModel(organizationUserDetails, organizationIdsClaimingActiveUser); } } diff --git a/src/Api/Models/Response/ProfileResponseModel.cs b/src/Api/Models/Response/ProfileResponseModel.cs index 82ffb05b0b..246b3c3227 100644 --- a/src/Api/Models/Response/ProfileResponseModel.cs +++ b/src/Api/Models/Response/ProfileResponseModel.cs @@ -15,7 +15,7 @@ public class ProfileResponseModel : ResponseModel IEnumerable providerUserOrganizationDetails, bool twoFactorEnabled, bool premiumFromOrganization, - IEnumerable organizationIdsManagingUser) : base("profile") + IEnumerable organizationIdsClaimingUser) : base("profile") { if (user == null) { @@ -38,7 +38,7 @@ public class ProfileResponseModel : ResponseModel AvatarColor = user.AvatarColor; CreationDate = user.CreationDate; VerifyDevices = user.VerifyDevices; - Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingUser)); + Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsClaimingUser)); Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p)); ProviderOrganizations = providerUserOrganizationDetails?.Select(po => new ProfileProviderOrganizationResponseModel(po)); diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 0f03f54be1..a9646acd1c 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -1091,9 +1091,9 @@ public class CiphersController : Controller throw new BadRequestException(ModelState); } - // If Account Deprovisioning is enabled, we need to check if the user is managed by any organization. + // If Account Deprovisioning is enabled, we need to check if the user is claimed by any organization. if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - && await _userService.IsManagedByAnyOrganizationAsync(user.Id)) + && await _userService.IsClaimedByAnyOrganizationAsync(user.Id)) { throw new BadRequestException("Cannot purge accounts owned by an organization. Contact your organization administrator for additional details."); } diff --git a/src/Api/Vault/Controllers/SyncController.cs b/src/Api/Vault/Controllers/SyncController.cs index 1b8978fc65..4b66c7f2bd 100644 --- a/src/Api/Vault/Controllers/SyncController.cs +++ b/src/Api/Vault/Controllers/SyncController.cs @@ -104,13 +104,13 @@ public class SyncController : Controller var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user); - var organizationManagingActiveUser = await _userService.GetOrganizationsManagingUserAsync(user.Id); - var organizationIdsManagingActiveUser = organizationManagingActiveUser.Select(o => o.Id); + var organizationClaimingActiveUser = await _userService.GetOrganizationsClaimingUserAsync(user.Id); + var organizationIdsClaimingActiveUser = organizationClaimingActiveUser.Select(o => o.Id); var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities, - organizationIdsManagingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, + organizationIdsClaimingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends); return response; } diff --git a/src/Api/Vault/Models/Response/SyncResponseModel.cs b/src/Api/Vault/Models/Response/SyncResponseModel.cs index f1465264f2..b9da786567 100644 --- a/src/Api/Vault/Models/Response/SyncResponseModel.cs +++ b/src/Api/Vault/Models/Response/SyncResponseModel.cs @@ -23,7 +23,7 @@ public class SyncResponseModel : ResponseModel bool userTwoFactorEnabled, bool userHasPremiumFromOrganization, IDictionary organizationAbilities, - IEnumerable organizationIdsManagingUser, + IEnumerable organizationIdsClaimingingUser, IEnumerable organizationUserDetails, IEnumerable providerUserDetails, IEnumerable providerUserOrganizationDetails, @@ -37,7 +37,7 @@ public class SyncResponseModel : ResponseModel : base("sync") { Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails, - providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsManagingUser); + providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingingUser); Folders = folders.Select(f => new FolderResponseModel(f)); Ciphers = ciphers.Select(cipher => new CipherDetailsResponseModel( diff --git a/src/Core/AdminConsole/Entities/OrganizationIntegration.cs b/src/Core/AdminConsole/Entities/OrganizationIntegration.cs index 18f8be8667..86de25ce9a 100644 --- a/src/Core/AdminConsole/Entities/OrganizationIntegration.cs +++ b/src/Core/AdminConsole/Entities/OrganizationIntegration.cs @@ -12,7 +12,7 @@ public class OrganizationIntegration : ITableObject public Guid OrganizationId { get; set; } public IntegrationType Type { get; set; } public string? Configuration { get; set; } - public DateTime CreationDate { get; set; } = DateTime.UtcNow; + public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; public DateTime RevisionDate { get; set; } = DateTime.UtcNow; public void SetNewId() => Id = CoreHelpers.GenerateComb(); } diff --git a/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs b/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs index 7592d0c763..25b669622f 100644 --- a/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs +++ b/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs @@ -13,7 +13,7 @@ public class OrganizationIntegrationConfiguration : ITableObject public EventType EventType { get; set; } public string? Configuration { get; set; } public string? Template { get; set; } - public DateTime CreationDate { get; set; } = DateTime.UtcNow; + public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; public DateTime RevisionDate { get; set; } = DateTime.UtcNow; public void SetNewId() => Id = CoreHelpers.GenerateComb(); } diff --git a/src/Core/AdminConsole/Errors/Error.cs b/src/Core/AdminConsole/Errors/Error.cs index 6c8eed41a4..7ad057d6ed 100644 --- a/src/Core/AdminConsole/Errors/Error.cs +++ b/src/Core/AdminConsole/Errors/Error.cs @@ -1,3 +1,8 @@ namespace Bit.Core.AdminConsole.Errors; public record Error(string Message, T ErroredValue); + +public static class ErrorMappers +{ + public static Error ToError(this Error errorA, B erroredValue) => new(errorA.Message, erroredValue); +} diff --git a/src/Core/AdminConsole/Errors/InvalidResultTypeError.cs b/src/Core/AdminConsole/Errors/InvalidResultTypeError.cs new file mode 100644 index 0000000000..67b5b634fb --- /dev/null +++ b/src/Core/AdminConsole/Errors/InvalidResultTypeError.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.AdminConsole.Errors; + +public record InvalidResultTypeError(T Value) : Error(Code, Value) +{ + public const string Code = "Invalid result type."; +}; diff --git a/src/Core/AdminConsole/Models/Business/InviteOrganization.cs b/src/Core/AdminConsole/Models/Business/InviteOrganization.cs new file mode 100644 index 0000000000..175ee07a9f --- /dev/null +++ b/src/Core/AdminConsole/Models/Business/InviteOrganization.cs @@ -0,0 +1,35 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Models.StaticStore; + +namespace Bit.Core.AdminConsole.Models.Business; + +public record InviteOrganization +{ + public Guid OrganizationId { get; init; } + public int? Seats { get; init; } + public int? MaxAutoScaleSeats { get; init; } + public int? SmSeats { get; init; } + public int? SmMaxAutoScaleSeats { get; init; } + public Plan Plan { get; init; } + public string GatewayCustomerId { get; init; } + public string GatewaySubscriptionId { get; init; } + public bool UseSecretsManager { get; init; } + + public InviteOrganization() + { + + } + + public InviteOrganization(Organization organization, Plan plan) + { + OrganizationId = organization.Id; + Seats = organization.Seats; + MaxAutoScaleSeats = organization.MaxAutoscaleSeats; + SmSeats = organization.SmSeats; + SmMaxAutoScaleSeats = organization.MaxAutoscaleSmSeats; + Plan = plan; + GatewayCustomerId = organization.GatewayCustomerId; + GatewaySubscriptionId = organization.GatewaySubscriptionId; + UseSecretsManager = organization.UseSecretsManager; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs index e011819f0f..ec635282f7 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs @@ -154,6 +154,6 @@ public class VerifyOrganizationDomainCommand( var organization = await organizationRepository.GetByIdAsync(domain.OrganizationId); - await mailService.SendClaimedDomainUserEmailAsync(new ManagedUserDomainClaimedEmails(domainUserEmails, organization)); + await mailService.SendClaimedDomainUserEmailAsync(new ClaimedUserDomainClaimedEmails(domainUserEmails, organization)); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs similarity index 89% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommand.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs index 7b7d8003a3..49ddf0a548 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs @@ -15,11 +15,11 @@ using Bit.Core.Tools.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; -public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganizationUserAccountCommand +public class DeleteClaimedOrganizationUserAccountCommand : IDeleteClaimedOrganizationUserAccountCommand { private readonly IUserService _userService; private readonly IEventService _eventService; - private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery; + private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IUserRepository _userRepository; private readonly ICurrentContext _currentContext; @@ -28,10 +28,10 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz private readonly IPushNotificationService _pushService; private readonly IOrganizationRepository _organizationRepository; private readonly IProviderUserRepository _providerUserRepository; - public DeleteManagedOrganizationUserAccountCommand( + public DeleteClaimedOrganizationUserAccountCommand( IUserService userService, IEventService eventService, - IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery, + IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery, IOrganizationUserRepository organizationUserRepository, IUserRepository userRepository, ICurrentContext currentContext, @@ -43,7 +43,7 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz { _userService = userService; _eventService = eventService; - _getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery; + _getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery; _organizationUserRepository = organizationUserRepository; _userRepository = userRepository; _currentContext = currentContext; @@ -62,10 +62,10 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz throw new NotFoundException("Member not found."); } - var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(organizationId, new[] { organizationUserId }); + var claimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, new[] { organizationUserId }); var hasOtherConfirmedOwners = await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, new[] { organizationUserId }, includeProvider: true); - await ValidateDeleteUserAsync(organizationId, organizationUser, deletingUserId, managementStatus, hasOtherConfirmedOwners); + await ValidateDeleteUserAsync(organizationId, organizationUser, deletingUserId, claimedStatus, hasOtherConfirmedOwners); var user = await _userRepository.GetByIdAsync(organizationUser.UserId!.Value); if (user == null) @@ -83,7 +83,7 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz var userIds = orgUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId!.Value).ToList(); var users = await _userRepository.GetManyAsync(userIds); - var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(organizationId, orgUserIds); + var claimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, orgUserIds); var hasOtherConfirmedOwners = await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, orgUserIds, includeProvider: true); var results = new List<(Guid OrganizationUserId, string? ErrorMessage)>(); @@ -97,7 +97,7 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz throw new NotFoundException("Member not found."); } - await ValidateDeleteUserAsync(organizationId, orgUser, deletingUserId, managementStatus, hasOtherConfirmedOwners); + await ValidateDeleteUserAsync(organizationId, orgUser, deletingUserId, claimedStatus, hasOtherConfirmedOwners); var user = users.FirstOrDefault(u => u.Id == orgUser.UserId); if (user == null) @@ -129,7 +129,7 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz return results; } - private async Task ValidateDeleteUserAsync(Guid organizationId, OrganizationUser orgUser, Guid? deletingUserId, IDictionary managementStatus, bool hasOtherConfirmedOwners) + private async Task ValidateDeleteUserAsync(Guid organizationId, OrganizationUser orgUser, Guid? deletingUserId, IDictionary claimedStatus, bool hasOtherConfirmedOwners) { if (!orgUser.UserId.HasValue || orgUser.Status == OrganizationUserStatusType.Invited) { @@ -159,10 +159,9 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz throw new BadRequestException("Custom users can not delete admins."); } - - if (!managementStatus.TryGetValue(orgUser.Id, out var isManaged) || !isManaged) + if (!claimedStatus.TryGetValue(orgUser.Id, out var isClaimed) || !isClaimed) { - throw new BadRequestException("Member is not managed by the organization."); + throw new BadRequestException("Member is not claimed by the organization."); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersManagementStatusQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersClaimedStatusQuery.cs similarity index 82% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersManagementStatusQuery.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersClaimedStatusQuery.cs index 4ff6b87443..1dda9483cd 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersManagementStatusQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersClaimedStatusQuery.cs @@ -4,12 +4,12 @@ using Bit.Core.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; -public class GetOrganizationUsersManagementStatusQuery : IGetOrganizationUsersManagementStatusQuery +public class GetOrganizationUsersClaimedStatusQuery : IGetOrganizationUsersClaimedStatusQuery { private readonly IApplicationCacheService _applicationCacheService; private readonly IOrganizationUserRepository _organizationUserRepository; - public GetOrganizationUsersManagementStatusQuery( + public GetOrganizationUsersClaimedStatusQuery( IApplicationCacheService applicationCacheService, IOrganizationUserRepository organizationUserRepository) { @@ -17,11 +17,11 @@ public class GetOrganizationUsersManagementStatusQuery : IGetOrganizationUsersMa _organizationUserRepository = organizationUserRepository; } - public async Task> GetUsersOrganizationManagementStatusAsync(Guid organizationId, IEnumerable organizationUserIds) + public async Task> GetUsersOrganizationClaimedStatusAsync(Guid organizationId, IEnumerable organizationUserIds) { if (organizationUserIds.Any()) { - // Users can only be managed by an Organization that is enabled and can have organization domains + // Users can only be claimed by an Organization that is enabled and can have organization domains var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); // TODO: Replace "UseSso" with a new organization ability like "UseOrganizationDomains" (PM-11622). @@ -31,7 +31,7 @@ public class GetOrganizationUsersManagementStatusQuery : IGetOrganizationUsersMa // Get all organization users with claimed domains by the organization var organizationUsersWithClaimedDomain = await _organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organizationId); - // Create a dictionary with the OrganizationUserId and a boolean indicating if the user is managed by the organization + // Create a dictionary with the OrganizationUserId and a boolean indicating if the user is claimed by the organization return organizationUserIds.ToDictionary(ouId => ouId, ouId => organizationUsersWithClaimedDomain.Any(ou => ou.Id == ouId)); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteManagedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteClaimedOrganizationUserAccountCommand.cs similarity index 92% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteManagedOrganizationUserAccountCommand.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteClaimedOrganizationUserAccountCommand.cs index d548966aaf..1c79687be9 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteManagedOrganizationUserAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteClaimedOrganizationUserAccountCommand.cs @@ -2,7 +2,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; -public interface IDeleteManagedOrganizationUserAccountCommand +public interface IDeleteClaimedOrganizationUserAccountCommand { /// /// Removes a user from an organization and deletes all of their associated user data. diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IGetOrganizationUsersManagementStatusQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IGetOrganizationUsersClaimedStatusQuery.cs similarity index 67% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IGetOrganizationUsersManagementStatusQuery.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IGetOrganizationUsersClaimedStatusQuery.cs index 694b44dd78..74a7d5fc0e 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IGetOrganizationUsersManagementStatusQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IGetOrganizationUsersClaimedStatusQuery.cs @@ -1,19 +1,19 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; -public interface IGetOrganizationUsersManagementStatusQuery +public interface IGetOrganizationUsersClaimedStatusQuery { /// - /// Checks whether each user in the provided list of organization user IDs is managed by the specified organization. + /// Checks whether each user in the provided list of organization user IDs is claimed by the specified organization. /// /// The unique identifier of the organization to check against. /// A list of OrganizationUserIds to be checked. /// - /// A managed user is a user whose email domain matches one of the Organization's verified domains. + /// A claimed user is a user whose email domain matches one of the Organization's verified domains. /// The organization must be enabled and be on an Enterprise plan. /// /// - /// A dictionary containing the OrganizationUserId and a boolean indicating if the user is managed by the organization. + /// A dictionary containing the OrganizationUserId and a boolean indicating if the user is claimed by the organization. /// - Task> GetUsersOrganizationManagementStatusAsync(Guid organizationId, + Task> GetUsersOrganizationClaimedStatusAsync(Guid organizationId, IEnumerable organizationUserIds); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/ErrorMapper.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/ErrorMapper.cs new file mode 100644 index 0000000000..c66d366de5 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/ErrorMapper.cs @@ -0,0 +1,37 @@ +using Bit.Core.AdminConsole.Errors; +using Bit.Core.Exceptions; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; + +public static class ErrorMapper +{ + + /// + /// Maps the ErrorT to a Bit.Exception class. + /// + /// + /// + /// + public static Exception MapToBitException(Error error) => + error switch + { + UserAlreadyExistsError alreadyExistsError => new ConflictException(alreadyExistsError.Message), + _ => new BadRequestException(error.Message) + }; + + /// + /// This maps the ErrorT object to the Bit.Exception class. + /// + /// This should be replaced by an IActionResult mapper when possible. + /// + /// + /// + /// + public static Exception MapToBitException(ICollection> errors) => + errors switch + { + not null when errors.Count == 1 => MapToBitException(errors.First()), + not null when errors.Count > 1 => new BadRequestException(string.Join(' ', errors.Select(e => e.Message))), + _ => new BadRequestException() + }; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/FailedToInviteUsersError.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/FailedToInviteUsersError.cs new file mode 100644 index 0000000000..810ef744c9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/FailedToInviteUsersError.cs @@ -0,0 +1,9 @@ +using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; + +public record FailedToInviteUsersError(InviteOrganizationUsersResponse Response) : Error(Code, Response) +{ + public const string Code = "Failed to invite users"; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/NoUsersToInviteError.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/NoUsersToInviteError.cs new file mode 100644 index 0000000000..52697572e6 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/NoUsersToInviteError.cs @@ -0,0 +1,9 @@ +using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; + +public record NoUsersToInviteError(InviteOrganizationUsersResponse Response) : Error(Code, Response) +{ + public const string Code = "No users to invite"; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/UserAlreadyExistsError.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/UserAlreadyExistsError.cs new file mode 100644 index 0000000000..475ad4a886 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/UserAlreadyExistsError.cs @@ -0,0 +1,9 @@ +using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; + +public record UserAlreadyExistsError(ScimInviteOrganizationUsersResponse Response) : Error(Code, Response) +{ + public const string Code = "User already exists"; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IInviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IInviteOrganizationUsersCommand.cs new file mode 100644 index 0000000000..3e4c7652a5 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IInviteOrganizationUsersCommand.cs @@ -0,0 +1,22 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.Models.Commands; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +/// +/// Defines the contract for inviting organization users via SCIM (System for Cross-domain Identity Management). +/// Provides functionality for handling single email invitation requests within an organization context. +/// +public interface IInviteOrganizationUsersCommand +{ + /// + /// Sends an invitation to add an organization user via SCIM (System for Cross-domain Identity Management) system. + /// This can be a Success or a Failure. Failure will contain the Error along with a representation of the errored value. + /// Success will be the successful return object. + /// + /// + /// Contains the details for inviting a single organization user via email. + /// + /// Response from InviteScimOrganiation + Task> InviteScimOrganizationUserAsync(InviteOrganizationUsersRequest request); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/ISendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/ISendOrganizationInvitesCommand.cs new file mode 100644 index 0000000000..090317640f --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/ISendOrganizationInvitesCommand.cs @@ -0,0 +1,16 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +/// +/// This is for sending the invite to an organization user. +/// +public interface ISendOrganizationInvitesCommand +{ + /// + /// This sends emails out to organization users for a given organization. + /// + /// + /// + Task SendInvitesAsync(SendInvitesRequest request); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs new file mode 100644 index 0000000000..4eacb9386a --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs @@ -0,0 +1,282 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.Interfaces; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Models.Business; +using Bit.Core.Models.Commands; +using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Services; +using Microsoft.Extensions.Logging; +using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +public class InviteOrganizationUsersCommand(IEventService eventService, + IOrganizationUserRepository organizationUserRepository, + IInviteUsersValidator inviteUsersValidator, + IPaymentService paymentService, + IOrganizationRepository organizationRepository, + IReferenceEventService referenceEventService, + ICurrentContext currentContext, + IApplicationCacheService applicationCacheService, + IMailService mailService, + ILogger logger, + IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, + ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, + IProviderOrganizationRepository providerOrganizationRepository, + IProviderUserRepository providerUserRepository + ) : IInviteOrganizationUsersCommand +{ + + public const string IssueNotifyingOwnersOfSeatLimitReached = "Error encountered notifying organization owners of seat limit reached."; + + public async Task> InviteScimOrganizationUserAsync(InviteOrganizationUsersRequest request) + { + var result = await InviteOrganizationUsersAsync(request); + + switch (result) + { + case Failure failure: + return new Failure( + failure.Errors.Select(error => new Error(error.Message, + new ScimInviteOrganizationUsersResponse + { + InvitedUser = error.ErroredValue.InvitedUsers.FirstOrDefault() + }))); + + case Success success when success.Value.InvitedUsers.Any(): + var user = success.Value.InvitedUsers.First(); + + await eventService.LogOrganizationUserEventAsync( + organizationUser: user, + type: EventType.OrganizationUser_Invited, + systemUser: EventSystemUser.SCIM, + date: request.PerformedAt.UtcDateTime); + + return new Success(new ScimInviteOrganizationUsersResponse + { + InvitedUser = user + }); + + default: + return new Failure( + new InvalidResultTypeError( + new ScimInviteOrganizationUsersResponse())); + } + } + + private async Task> InviteOrganizationUsersAsync(InviteOrganizationUsersRequest request) + { + var invitesToSend = (await FilterExistingUsersAsync(request)).ToArray(); + + if (invitesToSend.Length == 0) + { + return new Failure(new NoUsersToInviteError( + new InviteOrganizationUsersResponse(request.InviteOrganization.OrganizationId))); + } + + var validationResult = await inviteUsersValidator.ValidateAsync(new InviteOrganizationUsersValidationRequest + { + Invites = invitesToSend.ToArray(), + InviteOrganization = request.InviteOrganization, + PerformedBy = request.PerformedBy, + PerformedAt = request.PerformedAt, + OccupiedPmSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId), + OccupiedSmSeats = await organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId) + }); + + if (validationResult is Invalid invalid) + { + return invalid.MapToFailure(r => new InviteOrganizationUsersResponse(r)); + } + + var validatedRequest = validationResult as Valid; + + var organizationUserToInviteEntities = invitesToSend + .Select(x => x.MapToDataModel(request.PerformedAt, validatedRequest!.Value.InviteOrganization)) + .ToArray(); + + var organization = await organizationRepository.GetByIdAsync(validatedRequest!.Value.InviteOrganization.OrganizationId); + + try + { + await organizationUserRepository.CreateManyAsync(organizationUserToInviteEntities); + + await AdjustPasswordManagerSeatsAsync(validatedRequest, organization); + + await AdjustSecretsManagerSeatsAsync(validatedRequest); + + await SendAdditionalEmailsAsync(validatedRequest, organization); + + await SendInvitesAsync(organizationUserToInviteEntities, organization); + + await PublishReferenceEventAsync(validatedRequest, organization); + } + catch (Exception ex) + { + logger.LogError(ex, FailedToInviteUsersError.Code); + + await organizationUserRepository.DeleteManyAsync(organizationUserToInviteEntities.Select(x => x.OrganizationUser.Id)); + + // Do this first so that SmSeats never exceed PM seats (due to current billing requirements) + await RevertSecretsManagerChangesAsync(validatedRequest, organization, validatedRequest.Value.InviteOrganization.SmSeats); + + await RevertPasswordManagerChangesAsync(validatedRequest, organization); + + return new Failure( + new FailedToInviteUsersError( + new InviteOrganizationUsersResponse(validatedRequest.Value))); + } + + return new Success( + new InviteOrganizationUsersResponse( + invitedOrganizationUsers: organizationUserToInviteEntities.Select(x => x.OrganizationUser).ToArray(), + organizationId: organization!.Id)); + } + + private async Task> FilterExistingUsersAsync(InviteOrganizationUsersRequest request) + { + var existingEmails = new HashSet(await organizationUserRepository.SelectKnownEmailsAsync( + request.InviteOrganization.OrganizationId, request.Invites.Select(i => i.Email), false), + StringComparer.OrdinalIgnoreCase); + + return request.Invites + .Where(invite => !existingEmails.Contains(invite.Email)) + .ToArray(); + } + + private async Task RevertPasswordManagerChangesAsync(Valid validatedResult, Organization organization) + { + if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd > 0) + { + // When reverting seats, we have to tell payments service that the seats are going back down by what we attempted to add. + // However, this might lead to a problem if we don't actually update stripe but throw any ways. + // stripe could not be updated, and then we would decrement the number of seats in stripe accidentally. + var seatsToRemove = validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd; + await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, -seatsToRemove); + + organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats; + + await organizationRepository.ReplaceAsync(organization); + await applicationCacheService.UpsertOrganizationAbilityAsync(organization); + } + } + + private async Task RevertSecretsManagerChangesAsync(Valid validatedResult, Organization organization, int? initialSmSeats) + { + if (validatedResult.Value.SecretsManagerSubscriptionUpdate?.SmSeatsChanged is true) + { + var smSubscriptionUpdateRevert = new SecretsManagerSubscriptionUpdate( + organization: organization, + plan: validatedResult.Value.InviteOrganization.Plan, + autoscaling: false) + { + SmSeats = initialSmSeats + }; + + await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(smSubscriptionUpdateRevert); + } + } + + private async Task PublishReferenceEventAsync(Valid validatedResult, + Organization organization) => + await referenceEventService.RaiseEventAsync( + new ReferenceEvent(ReferenceEventType.InvitedUsers, organization, currentContext) + { + Users = validatedResult.Value.Invites.Length + }); + + private async Task SendInvitesAsync(IEnumerable users, Organization organization) => + await sendOrganizationInvitesCommand.SendInvitesAsync( + new SendInvitesRequest( + users.Select(x => x.OrganizationUser), + organization)); + + private async Task SendAdditionalEmailsAsync(Valid validatedResult, Organization organization) + { + await SendPasswordManagerMaxSeatLimitEmailsAsync(validatedResult, organization); + } + + private async Task SendPasswordManagerMaxSeatLimitEmailsAsync(Valid validatedResult, Organization organization) + { + if (!validatedResult.Value.PasswordManagerSubscriptionUpdate.MaxSeatsReached) + { + return; + } + + try + { + var ownerEmails = await GetOwnerEmailAddressesAsync(validatedResult.Value.InviteOrganization); + + await mailService.SendOrganizationMaxSeatLimitReachedEmailAsync(organization, + validatedResult.Value.PasswordManagerSubscriptionUpdate.MaxAutoScaleSeats!.Value, ownerEmails); + } + catch (Exception ex) + { + logger.LogError(ex, IssueNotifyingOwnersOfSeatLimitReached); + } + } + + private async Task> GetOwnerEmailAddressesAsync(InviteOrganization organization) + { + var providerOrganization = await providerOrganizationRepository + .GetByOrganizationId(organization.OrganizationId); + + if (providerOrganization == null) + { + return (await organizationUserRepository + .GetManyByMinimumRoleAsync(organization.OrganizationId, OrganizationUserType.Owner)) + .Select(x => x.Email) + .Distinct(); + } + + return (await providerUserRepository + .GetManyDetailsByProviderAsync(providerOrganization.ProviderId, ProviderUserStatusType.Confirmed)) + .Select(u => u.Email).Distinct(); + } + + private async Task AdjustSecretsManagerSeatsAsync(Valid validatedResult) + { + if (validatedResult.Value.SecretsManagerSubscriptionUpdate?.SmSeatsChanged is true) + { + await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(validatedResult.Value.SecretsManagerSubscriptionUpdate); + } + + } + + private async Task AdjustPasswordManagerSeatsAsync(Valid validatedResult, Organization organization) + { + if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd <= 0) + { + return; + } + + await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd); + + organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal; + + await organizationRepository.ReplaceAsync(organization); // could optimize this with only a property update + await applicationCacheService.UpsertOrganizationAbilityAsync(organization); + + await referenceEventService.RaiseEventAsync( + new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, currentContext) + { + PlanName = validatedResult.Value.InviteOrganization.Plan.Name, + PlanType = validatedResult.Value.InviteOrganization.Plan.Type, + Seats = validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal, + PreviousSeats = validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats + }); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUser.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUser.cs new file mode 100644 index 0000000000..a55db3958a --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUser.cs @@ -0,0 +1,15 @@ +using Bit.Core.Entities; +using Bit.Core.Models.Data; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +/// +/// Object for associating the with their assigned collections +/// and Group Ids. +/// +public class CreateOrganizationUser +{ + public OrganizationUser OrganizationUser { get; set; } + public CollectionAccessSelection[] Collections { get; set; } = []; + public Guid[] Groups { get; set; } = []; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUserExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUserExtensions.cs new file mode 100644 index 0000000000..23c38a51cb --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUserExtensions.cs @@ -0,0 +1,30 @@ +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +public static class CreateOrganizationUserExtensions +{ + public static CreateOrganizationUser MapToDataModel(this OrganizationUserInvite organizationUserInvite, + DateTimeOffset performedAt, + InviteOrganization organization) => + new() + { + OrganizationUser = new OrganizationUser + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.OrganizationId, + Email = organizationUserInvite.Email.ToLowerInvariant(), + Type = organizationUserInvite.Type, + Status = OrganizationUserStatusType.Invited, + AccessSecretsManager = organizationUserInvite.AccessSecretsManager, + ExternalId = string.IsNullOrWhiteSpace(organizationUserInvite.ExternalId) ? null : organizationUserInvite.ExternalId, + CreationDate = performedAt.UtcDateTime, + RevisionDate = performedAt.UtcDateTime + }, + Collections = organizationUserInvite.AssignedCollections, + Groups = organizationUserInvite.Groups + }; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUserErrorMessages.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUserErrorMessages.cs new file mode 100644 index 0000000000..bc75e244fc --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUserErrorMessages.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +public static class InviteOrganizationUserErrorMessages +{ + public const string InvalidEmailErrorMessage = "The email address is not valid."; + public const string InvalidCollectionConfigurationErrorMessage = "The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true."; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersRequest.cs new file mode 100644 index 0000000000..84b350c551 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersRequest.cs @@ -0,0 +1,22 @@ +using Bit.Core.AdminConsole.Models.Business; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +public class InviteOrganizationUsersRequest +{ + public OrganizationUserInvite[] Invites { get; } = []; + public InviteOrganization InviteOrganization { get; } + public Guid PerformedBy { get; } + public DateTimeOffset PerformedAt { get; } + + public InviteOrganizationUsersRequest(OrganizationUserInvite[] invites, + InviteOrganization inviteOrganization, + Guid performedBy, + DateTimeOffset performedAt) + { + Invites = invites; + InviteOrganization = inviteOrganization; + PerformedBy = performedBy; + PerformedAt = performedAt; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersResponse.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersResponse.cs new file mode 100644 index 0000000000..ac7d864dd4 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersResponse.cs @@ -0,0 +1,42 @@ +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +public class InviteOrganizationUsersResponse(Guid organizationId) +{ + public IEnumerable InvitedUsers { get; } = []; + public Guid OrganizationId { get; } = organizationId; + + public InviteOrganizationUsersResponse(InviteOrganizationUsersValidationRequest usersValidationRequest) + : this(usersValidationRequest.InviteOrganization.OrganizationId) + { + InvitedUsers = usersValidationRequest.Invites.Select(x => new OrganizationUser { Email = x.Email }); + } + + public InviteOrganizationUsersResponse(IEnumerable invitedOrganizationUsers, Guid organizationId) + : this(organizationId) + { + InvitedUsers = invitedOrganizationUsers; + } +} + +public class ScimInviteOrganizationUsersResponse +{ + public OrganizationUser InvitedUser { get; init; } + + public ScimInviteOrganizationUsersResponse() + { + + } + + public ScimInviteOrganizationUsersResponse(InviteOrganizationUsersRequest request) + { + var userToInvite = request.Invites.First(); + + InvitedUser = new OrganizationUser + { + Email = userToInvite.Email, + ExternalId = userToInvite.ExternalId + }; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersValidationRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersValidationRequest.cs new file mode 100644 index 0000000000..f45c705cab --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersValidationRequest.cs @@ -0,0 +1,40 @@ +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; +using Bit.Core.Models.Business; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +public class InviteOrganizationUsersValidationRequest +{ + public InviteOrganizationUsersValidationRequest() + { + } + + public InviteOrganizationUsersValidationRequest(InviteOrganizationUsersValidationRequest request) + { + Invites = request.Invites; + InviteOrganization = request.InviteOrganization; + PerformedBy = request.PerformedBy; + PerformedAt = request.PerformedAt; + OccupiedPmSeats = request.OccupiedPmSeats; + OccupiedSmSeats = request.OccupiedSmSeats; + } + + public InviteOrganizationUsersValidationRequest(InviteOrganizationUsersValidationRequest request, + PasswordManagerSubscriptionUpdate subscriptionUpdate, + SecretsManagerSubscriptionUpdate smSubscriptionUpdate) + : this(request) + { + PasswordManagerSubscriptionUpdate = subscriptionUpdate; + SecretsManagerSubscriptionUpdate = smSubscriptionUpdate; + } + + public OrganizationUserInvite[] Invites { get; init; } = []; + public InviteOrganization InviteOrganization { get; init; } + public Guid PerformedBy { get; init; } + public DateTimeOffset PerformedAt { get; init; } + public int OccupiedPmSeats { get; init; } + public int OccupiedSmSeats { get; init; } + public PasswordManagerSubscriptionUpdate PasswordManagerSubscriptionUpdate { get; set; } + public SecretsManagerSubscriptionUpdate SecretsManagerSubscriptionUpdate { get; set; } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInvite.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInvite.cs new file mode 100644 index 0000000000..0b83680aa5 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInvite.cs @@ -0,0 +1,77 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Bit.Core.Utilities; +using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.InviteOrganizationUserErrorMessages; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +public class OrganizationUserInvite +{ + public string Email { get; private init; } + public CollectionAccessSelection[] AssignedCollections { get; private init; } + public OrganizationUserType Type { get; private init; } + public Permissions Permissions { get; private init; } + public string ExternalId { get; private init; } + public bool AccessSecretsManager { get; private init; } + public Guid[] Groups { get; private init; } + + public OrganizationUserInvite(string email, string externalId) : + this( + email: email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + false) + { + } + + public OrganizationUserInvite(OrganizationUserInvite invite, bool accessSecretsManager) : + this(invite.Email, + invite.AssignedCollections, + invite.Groups, + invite.Type, + invite.Permissions, + invite.ExternalId, + accessSecretsManager) + { + + } + + public OrganizationUserInvite(string email, + IEnumerable assignedCollections, + IEnumerable groups, + OrganizationUserType type, + Permissions permissions, + string externalId, + bool accessSecretsManager) + { + ValidateEmailAddress(email); + + var collections = assignedCollections?.ToArray() ?? []; + + if (collections.Any(x => x.IsValidCollectionAccessConfiguration())) + { + throw new BadRequestException(InvalidCollectionConfigurationErrorMessage); + } + + Email = email; + AssignedCollections = collections; + Groups = groups.ToArray(); + Type = type; + Permissions = permissions ?? new Permissions(); + ExternalId = externalId; + AccessSecretsManager = accessSecretsManager; + } + + private static void ValidateEmailAddress(string email) + { + if (!email.IsValidEmail()) + { + throw new BadRequestException($"{email} {InvalidEmailErrorMessage}"); + } + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/SendInvitesRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/SendInvitesRequest.cs new file mode 100644 index 0000000000..2be6430512 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/SendInvitesRequest.cs @@ -0,0 +1,33 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +/// +/// Represents a request to send invitations to a group of organization users. +/// +public class SendInvitesRequest +{ + public SendInvitesRequest(IEnumerable users, Organization organization) => + (Users, Organization) = (users.ToArray(), organization); + + public SendInvitesRequest(IEnumerable users, Organization organization, bool initOrganization) => + (Users, Organization, InitOrganization) = (users.ToArray(), organization, initOrganization); + + /// + /// Organization Users to send emails to. + /// + public OrganizationUser[] Users { get; set; } = []; + + /// + /// The organization to invite the users to. + /// + public Organization Organization { get; init; } + + /// + /// This is for when the organization is being created and this is the owners initial invite + /// + public bool InitOrganization { get; init; } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs new file mode 100644 index 0000000000..ba85ce1d8a --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs @@ -0,0 +1,80 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Models.Business; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.Repositories; +using Bit.Core.Entities; +using Bit.Core.Models.Mail; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Tokens; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +public class SendOrganizationInvitesCommand( + IUserRepository userRepository, + ISsoConfigRepository ssoConfigurationRepository, + IPolicyRepository policyRepository, + IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory, + IDataProtectorTokenFactory dataProtectorTokenFactory, + IMailService mailService) : ISendOrganizationInvitesCommand +{ + public async Task SendInvitesAsync(SendInvitesRequest request) + { + var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(request.Users, request.Organization, request.InitOrganization); + + await mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo); + } + + private async Task BuildOrganizationInvitesInfoAsync(IEnumerable orgUsers, + Organization organization, bool initOrganization = false) + { + // Materialize the sequence into a list to avoid multiple enumeration warnings + var orgUsersList = orgUsers.ToList(); + + // Email links must include information about the org and user for us to make routing decisions client side + // Given an org user, determine if existing BW user exists + var orgUserEmails = orgUsersList.Select(ou => ou.Email).ToList(); + var existingUsers = await userRepository.GetManyByEmailsAsync(orgUserEmails); + + // hash existing users emails list for O(1) lookups + var existingUserEmailsHashSet = new HashSet(existingUsers.Select(u => u.Email)); + + // Create a dictionary of org user guids and bools for whether or not they have an existing BW user + var orgUserHasExistingUserDict = orgUsersList.ToDictionary( + ou => ou.Id, + ou => existingUserEmailsHashSet.Contains(ou.Email) + ); + + // Determine if org has SSO enabled and if user is required to login with SSO + // Note: we only want to call the DB after checking if the org can use SSO per plan and if they have any policies enabled. + var orgSsoEnabled = organization.UseSso && (await ssoConfigurationRepository.GetByOrganizationIdAsync(organization.Id))?.Enabled == true; + // Even though the require SSO policy can be turned on regardless of SSO being enabled, for this logic, we only + // need to check the policy if the org has SSO enabled. + var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled && + organization.UsePolicies && + (await policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true; + + // Generate the list of org users and expiring tokens + // create helper function to create expiring tokens + (OrganizationUser, ExpiringToken) MakeOrgUserExpiringTokenPair(OrganizationUser orgUser) + { + var orgUserInviteTokenable = orgUserInviteTokenableFactory.CreateToken(orgUser); + var protectedToken = dataProtectorTokenFactory.Protect(orgUserInviteTokenable); + return (orgUser, new ExpiringToken(protectedToken, orgUserInviteTokenable.ExpirationDate)); + } + + var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair); + + return new OrganizationInvitesInfo( + organization, + orgSsoEnabled, + orgSsoLoginRequiredPolicyEnabled, + orgUsersWithExpTokens, + orgUserHasExistingUserDict, + initOrganization + ); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/CollectionAccessSelectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/CollectionAccessSelectionExtensions.cs new file mode 100644 index 0000000000..7500ade672 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/CollectionAccessSelectionExtensions.cs @@ -0,0 +1,12 @@ +using Bit.Core.Models.Data; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + +public static class CollectionAccessSelectionExtensions +{ + /// + /// This validates the permissions on the given assigned collection + /// + public static bool IsValidCollectionAccessConfiguration(this CollectionAccessSelection collectionAccessSelection) => + collectionAccessSelection.Manage && (collectionAccessSelection.ReadOnly || collectionAccessSelection.HidePasswords); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/CannotAutoScaleOnSelfHostError.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/CannotAutoScaleOnSelfHostError.cs new file mode 100644 index 0000000000..0624ffe027 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/CannotAutoScaleOnSelfHostError.cs @@ -0,0 +1,8 @@ +using Bit.Core.AdminConsole.Errors; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings; + +public record CannotAutoScaleOnSelfHostError(EnvironmentRequest Invalid) : Error(Code, Invalid) +{ + public const string Code = "Cannot auto scale self-host."; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/EnvironmentRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/EnvironmentRequest.cs new file mode 100644 index 0000000000..9c1ff43d17 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/EnvironmentRequest.cs @@ -0,0 +1,18 @@ +#nullable enable + +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings; + +public class EnvironmentRequest +{ + public bool IsSelfHosted { get; init; } + public PasswordManagerSubscriptionUpdate PasswordManagerSubscriptionUpdate { get; init; } + + public EnvironmentRequest(IGlobalSettings globalSettings, PasswordManagerSubscriptionUpdate passwordManagerSubscriptionUpdate) + { + IsSelfHosted = globalSettings.SelfHosted; + PasswordManagerSubscriptionUpdate = passwordManagerSubscriptionUpdate; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/InviteUsersEnvironmentValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/InviteUsersEnvironmentValidator.cs new file mode 100644 index 0000000000..fd0441753a --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/InviteUsersEnvironmentValidator.cs @@ -0,0 +1,14 @@ +using Bit.Core.AdminConsole.Shared.Validation; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings; + +public interface IInviteUsersEnvironmentValidator : IValidator; + +public class InviteUsersEnvironmentValidator : IInviteUsersEnvironmentValidator +{ + public Task> ValidateAsync(EnvironmentRequest value) => + Task.FromResult>( + value.IsSelfHosted && value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd > 0 ? + new Invalid(new CannotAutoScaleOnSelfHostError(value)) : + new Valid(value)); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs new file mode 100644 index 0000000000..79a3487d19 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs @@ -0,0 +1,108 @@ +using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; +using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.Models.Business; +using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; +using Bit.Core.Repositories; +using Bit.Core.Services; +using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + +public interface IInviteUsersValidator : IValidator; + +public class InviteOrganizationUsersValidator( + IOrganizationRepository organizationRepository, + IInviteUsersPasswordManagerValidator inviteUsersPasswordManagerValidator, + IUpdateSecretsManagerSubscriptionCommand secretsManagerSubscriptionCommand, + IPaymentService paymentService) : IInviteUsersValidator +{ + public async Task> ValidateAsync( + InviteOrganizationUsersValidationRequest request) + { + var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(request); + + var passwordManagerValidationResult = + await inviteUsersPasswordManagerValidator.ValidateAsync(subscriptionUpdate); + + if (passwordManagerValidationResult is Invalid invalidSubscriptionUpdate) + { + return invalidSubscriptionUpdate.Map(request); + } + + // If the organization has the Secrets Manager Standalone Discount, all users are added to secrets manager. + // This is an expensive call, so we're doing it now to delay the check as long as possible. + if (await paymentService.HasSecretsManagerStandalone(request.InviteOrganization)) + { + request = new InviteOrganizationUsersValidationRequest(request) + { + Invites = request.Invites + .Select(x => new OrganizationUserInvite(x, accessSecretsManager: true)) + .ToArray() + }; + } + + if (request.InviteOrganization.UseSecretsManager && request.Invites.Any(x => x.AccessSecretsManager)) + { + return await ValidateSecretsManagerSubscriptionUpdateAsync(request, subscriptionUpdate); + } + + return new Valid(new InviteOrganizationUsersValidationRequest( + request, + subscriptionUpdate, + null)); + } + + private async Task> ValidateSecretsManagerSubscriptionUpdateAsync( + InviteOrganizationUsersValidationRequest request, + PasswordManagerSubscriptionUpdate subscriptionUpdate) + { + try + { + + var smSubscriptionUpdate = new SecretsManagerSubscriptionUpdate( + organization: await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId), + plan: request.InviteOrganization.Plan, + autoscaling: true); + + var seatsToAdd = GetSecretManagerSeatAdjustment(request); + + if (seatsToAdd > 0) + { + smSubscriptionUpdate.AdjustSeats(seatsToAdd); + + await secretsManagerSubscriptionCommand.ValidateUpdateAsync(smSubscriptionUpdate); + } + + return new Valid(new InviteOrganizationUsersValidationRequest( + request, + subscriptionUpdate, + smSubscriptionUpdate)); + } + catch (Exception ex) + { + return new Invalid( + new Error(ex.Message, request)); + } + } + + /// + /// This calculates the number of SM seats to add to the organization seat total. + /// + /// If they have a current seat limit (it can be null), we want to figure out how many are available (seats - + /// occupied seats). Then, we'll subtract the available seats from the number of users we're trying to invite. + /// + /// If it's negative, we have available seats and do not need to increase, so we go with 0. + /// + /// + /// + private static int GetSecretManagerSeatAdjustment(InviteOrganizationUsersValidationRequest request) => + request.InviteOrganization.SmSeats.HasValue + ? Math.Max( + request.Invites.Count(x => x.AccessSecretsManager) - + (request.InviteOrganization.SmSeats.Value - + request.OccupiedSmSeats), + 0) + : 0; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Organization/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Organization/Errors.cs new file mode 100644 index 0000000000..5d072ca17d --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Organization/Errors.cs @@ -0,0 +1,16 @@ +using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.Models.Business; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization; + +public record OrganizationNoPaymentMethodFoundError(InviteOrganization InvalidRequest) + : Error(Code, InvalidRequest) +{ + public const string Code = "No payment method found."; +} + +public record OrganizationNoSubscriptionFoundError(InviteOrganization InvalidRequest) + : Error(Code, InvalidRequest) +{ + public const string Code = "No subscription found."; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Organization/InviteUsersOrganizationValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Organization/InviteUsersOrganizationValidator.cs new file mode 100644 index 0000000000..9e2ca8d9a6 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Organization/InviteUsersOrganizationValidator.cs @@ -0,0 +1,32 @@ +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.Shared.Validation; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization; + +public interface IInviteUsersOrganizationValidator : IValidator; + +public class InviteUsersOrganizationValidator : IInviteUsersOrganizationValidator +{ + public Task> ValidateAsync(InviteOrganization inviteOrganization) + { + if (inviteOrganization.Seats is null) + { + return Task.FromResult>( + new Valid(inviteOrganization)); + } + + if (string.IsNullOrWhiteSpace(inviteOrganization.GatewayCustomerId)) + { + return Task.FromResult>( + new Invalid(new OrganizationNoPaymentMethodFoundError(inviteOrganization))); + } + + if (string.IsNullOrWhiteSpace(inviteOrganization.GatewaySubscriptionId)) + { + return Task.FromResult>( + new Invalid(new OrganizationNoSubscriptionFoundError(inviteOrganization))); + } + + return Task.FromResult>(new Valid(inviteOrganization)); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/Errors.cs new file mode 100644 index 0000000000..6ff7181456 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/Errors.cs @@ -0,0 +1,30 @@ +using Bit.Core.AdminConsole.Errors; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; + +public record PasswordManagerSeatLimitHasBeenReachedError(PasswordManagerSubscriptionUpdate InvalidRequest) + : Error(Code, InvalidRequest) +{ + public const string Code = "Seat limit has been reached."; +} + +public record PasswordManagerPlanDoesNotAllowAdditionalSeatsError(PasswordManagerSubscriptionUpdate InvalidRequest) + : Error(Code, InvalidRequest) +{ + public const string Code = "Plan does not allow additional seats."; +} + +public record PasswordManagerPlanOnlyAllowsMaxAdditionalSeatsError(PasswordManagerSubscriptionUpdate InvalidRequest) + : Error(GetErrorMessage(InvalidRequest), InvalidRequest) +{ + private static string GetErrorMessage(PasswordManagerSubscriptionUpdate invalidRequest) => + string.Format(Code, invalidRequest.PasswordManagerPlan.MaxAdditionalSeats); + + public const string Code = "Organization plan allows a maximum of {0} additional seats."; +} + +public record PasswordManagerMustHaveSeatsError(PasswordManagerSubscriptionUpdate InvalidRequest) + : Error(Code, InvalidRequest) +{ + public const string Code = "You do not have any Password Manager seats!"; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs new file mode 100644 index 0000000000..1867a2808e --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs @@ -0,0 +1,117 @@ +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; + +public interface IInviteUsersPasswordManagerValidator : IValidator; + +public class InviteUsersPasswordManagerValidator( + IGlobalSettings globalSettings, + IInviteUsersEnvironmentValidator inviteUsersEnvironmentValidator, + IInviteUsersOrganizationValidator inviteUsersOrganizationValidator, + IProviderRepository providerRepository, + IPaymentService paymentService, + IOrganizationRepository organizationRepository + ) : IInviteUsersPasswordManagerValidator +{ + /// + /// This is for validating if the organization can add additional users. + /// + /// + /// + public static ValidationResult ValidatePasswordManager(PasswordManagerSubscriptionUpdate subscriptionUpdate) + { + if (subscriptionUpdate.Seats is null) + { + return new Valid(subscriptionUpdate); + } + + if (subscriptionUpdate.SeatsRequiredToAdd == 0) + { + return new Valid(subscriptionUpdate); + } + + if (subscriptionUpdate.PasswordManagerPlan.BaseSeats + subscriptionUpdate.SeatsRequiredToAdd <= 0) + { + return new Invalid(new PasswordManagerMustHaveSeatsError(subscriptionUpdate)); + } + + if (subscriptionUpdate.MaxSeatsReached) + { + return new Invalid( + new PasswordManagerSeatLimitHasBeenReachedError(subscriptionUpdate)); + } + + if (subscriptionUpdate.PasswordManagerPlan.HasAdditionalSeatsOption is false) + { + return new Invalid( + new PasswordManagerPlanDoesNotAllowAdditionalSeatsError(subscriptionUpdate)); + } + + // Apparently MaxAdditionalSeats is never set. Can probably be removed. + if (subscriptionUpdate.UpdatedSeatTotal - subscriptionUpdate.PasswordManagerPlan.BaseSeats > subscriptionUpdate.PasswordManagerPlan.MaxAdditionalSeats) + { + return new Invalid( + new PasswordManagerPlanOnlyAllowsMaxAdditionalSeatsError(subscriptionUpdate)); + } + + return new Valid(subscriptionUpdate); + } + + public async Task> ValidateAsync(PasswordManagerSubscriptionUpdate request) + { + switch (ValidatePasswordManager(request)) + { + case Valid valid + when valid.Value.SeatsRequiredToAdd is 0: + return new Valid(request); + + case Invalid invalid: + return invalid; + } + + if (await inviteUsersEnvironmentValidator.ValidateAsync(new EnvironmentRequest(globalSettings, request)) is Invalid invalidEnvironment) + { + return invalidEnvironment.Map(request); + } + + var organizationValidationResult = await inviteUsersOrganizationValidator.ValidateAsync(request.InviteOrganization); + + if (organizationValidationResult is Invalid organizationValidation) + { + return organizationValidation.Map(request); + } + + var provider = await providerRepository.GetByOrganizationIdAsync(request.InviteOrganization.OrganizationId); + if (provider is not null) + { + var providerValidationResult = InvitingUserOrganizationProviderValidator.Validate(new InviteOrganizationProvider(provider)); + + if (providerValidationResult is Invalid invalidProviderValidation) + { + return invalidProviderValidation.Map(request); + } + } + + var paymentSubscription = await paymentService.GetSubscriptionAsync( + await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId)); + + var paymentValidationResult = InviteUserPaymentValidation.Validate( + new PaymentsSubscription(paymentSubscription, request.InviteOrganization)); + + if (paymentValidationResult is Invalid invalidPaymentValidation) + { + return invalidPaymentValidation.Map(request); + } + + return new Valid(request); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/PasswordManagerSubscriptionUpdate.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/PasswordManagerSubscriptionUpdate.cs new file mode 100644 index 0000000000..f6126fd8f5 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/PasswordManagerSubscriptionUpdate.cs @@ -0,0 +1,89 @@ +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.Models.StaticStore; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; + +public class PasswordManagerSubscriptionUpdate +{ + /// + /// Seats the organization has + /// + public int? Seats { get; } + + /// + /// Max number of seats that the organization can have + /// + public int? MaxAutoScaleSeats { get; } + + /// + /// Seats currently occupied by current users + /// + public int OccupiedSeats { get; } + + /// + /// Users to add to the organization seats + /// + public int NewUsersToAdd { get; } + + /// + /// Number of seats available for users + /// + public int? AvailableSeats => Seats - OccupiedSeats; + + /// + /// Number of seats to scale the organization by. + /// + /// If Organization has no seat limit (Seats is null), then there are no new seats to add. + /// + public int SeatsRequiredToAdd => AvailableSeats.HasValue ? Math.Max(NewUsersToAdd - AvailableSeats.Value, 0) : 0; + + /// + /// New total of seats for the organization + /// + public int? UpdatedSeatTotal => Seats + SeatsRequiredToAdd; + + /// + /// If the new seat total is equal to the organization's auto-scale seat count + /// + public bool MaxSeatsReached => UpdatedSeatTotal.HasValue && MaxAutoScaleSeats.HasValue && UpdatedSeatTotal.Value >= MaxAutoScaleSeats.Value; + + public Plan.PasswordManagerPlanFeatures PasswordManagerPlan { get; } + + public InviteOrganization InviteOrganization { get; } + + private PasswordManagerSubscriptionUpdate(int? organizationSeats, + int? organizationAutoScaleSeatLimit, + int currentSeats, + int newUsersToAdd, + Plan.PasswordManagerPlanFeatures plan, + InviteOrganization inviteOrganization) + { + Seats = organizationSeats; + MaxAutoScaleSeats = organizationAutoScaleSeatLimit; + OccupiedSeats = currentSeats; + NewUsersToAdd = newUsersToAdd; + PasswordManagerPlan = plan; + InviteOrganization = inviteOrganization; + } + + public PasswordManagerSubscriptionUpdate(InviteOrganization inviteOrganization, int occupiedSeats, int newUsersToAdd) : + this( + organizationSeats: inviteOrganization.Seats, + organizationAutoScaleSeatLimit: inviteOrganization.MaxAutoScaleSeats, + currentSeats: occupiedSeats, + newUsersToAdd: newUsersToAdd, + plan: inviteOrganization.Plan.PasswordManager, + inviteOrganization: inviteOrganization) + { } + + public PasswordManagerSubscriptionUpdate(InviteOrganizationUsersValidationRequest usersValidationRequest) : + this( + organizationSeats: usersValidationRequest.InviteOrganization.Seats, + organizationAutoScaleSeatLimit: usersValidationRequest.InviteOrganization.MaxAutoScaleSeats, + currentSeats: usersValidationRequest.OccupiedPmSeats, + newUsersToAdd: usersValidationRequest.Invites.Length, + plan: usersValidationRequest.InviteOrganization.Plan.PasswordManager, + inviteOrganization: usersValidationRequest.InviteOrganization) + { } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/Errors.cs new file mode 100644 index 0000000000..c74d1048ad --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/Errors.cs @@ -0,0 +1,10 @@ +using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments; + +public record PaymentCancelledSubscriptionError(PaymentsSubscription InvalidRequest) + : Error(Code, InvalidRequest) +{ + public const string Code = "You do not have an active subscription. Reinstate your subscription to make changes."; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/InviteUserPaymentValidation.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/InviteUserPaymentValidation.cs new file mode 100644 index 0000000000..cc17a673f9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/InviteUserPaymentValidation.cs @@ -0,0 +1,25 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments; +using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + +public static class InviteUserPaymentValidation +{ + public static ValidationResult Validate(PaymentsSubscription subscription) + { + if (subscription.ProductTierType is ProductTierType.Free) + { + return new Valid(subscription); + } + + if (subscription.SubscriptionStatus == StripeConstants.SubscriptionStatus.Canceled) + { + return new Invalid(new PaymentCancelledSubscriptionError(subscription)); + } + + return new Valid(subscription); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/PaymentsSubscription.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/PaymentsSubscription.cs new file mode 100644 index 0000000000..dea35c4ddd --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/PaymentsSubscription.cs @@ -0,0 +1,19 @@ +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.Billing.Enums; +using Bit.Core.Models.Business; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; + +public class PaymentsSubscription +{ + public ProductTierType ProductTierType { get; init; } + public string SubscriptionStatus { get; init; } + + public PaymentsSubscription() { } + + public PaymentsSubscription(SubscriptionInfo subscriptionInfo, InviteOrganization inviteOrganization) + { + SubscriptionStatus = subscriptionInfo?.Subscription?.Status ?? string.Empty; + ProductTierType = inviteOrganization.Plan.ProductTier; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/Errors.cs new file mode 100644 index 0000000000..104ce5cc7e --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/Errors.cs @@ -0,0 +1,13 @@ +using Bit.Core.AdminConsole.Errors; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider; + +public record ProviderBillableSeatLimitError(InviteOrganizationProvider InvalidRequest) : Error(Code, InvalidRequest) +{ + public const string Code = "Seat limit has been reached. Please contact your provider to add more seats."; +} + +public record ProviderResellerSeatLimitError(InviteOrganizationProvider InvalidRequest) : Error(Code, InvalidRequest) +{ + public const string Code = "Seat limit has been reached. Contact your provider to purchase additional seats."; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/InviteOrganizationProvider.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/InviteOrganizationProvider.cs new file mode 100644 index 0000000000..b52218307d --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/InviteOrganizationProvider.cs @@ -0,0 +1,19 @@ +using Bit.Core.AdminConsole.Enums.Provider; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider; + +public class InviteOrganizationProvider +{ + public Guid ProviderId { get; init; } + public ProviderType Type { get; init; } + public ProviderStatusType Status { get; init; } + public bool Enabled { get; init; } + + public InviteOrganizationProvider(Entities.Provider.Provider provider) + { + ProviderId = provider.Id; + Type = provider.Type; + Status = provider.Status; + Enabled = provider.Enabled; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/InvitingUserOrganizationProviderValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/InvitingUserOrganizationProviderValidator.cs new file mode 100644 index 0000000000..f84b25f76f --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/InvitingUserOrganizationProviderValidator.cs @@ -0,0 +1,28 @@ +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.Billing.Extensions; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider; + +public static class InvitingUserOrganizationProviderValidator +{ + public static ValidationResult Validate(InviteOrganizationProvider inviteOrganizationProvider) + { + if (inviteOrganizationProvider is not { Enabled: true }) + { + return new Valid(inviteOrganizationProvider); + } + + if (inviteOrganizationProvider.IsBillable()) + { + return new Invalid(new ProviderBillableSeatLimitError(inviteOrganizationProvider)); + } + + if (inviteOrganizationProvider.Type == ProviderType.Reseller) + { + return new Invalid(new ProviderResellerSeatLimitError(inviteOrganizationProvider)); + } + + return new Valid(inviteOrganizationProvider); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs index 3568a2a2b9..4de2cd0ea5 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs @@ -18,7 +18,7 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand private readonly IPushRegistrationService _pushRegistrationService; private readonly ICurrentContext _currentContext; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; - private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery; + private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery; private readonly IFeatureService _featureService; private readonly TimeProvider _timeProvider; @@ -38,7 +38,7 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand IPushRegistrationService pushRegistrationService, ICurrentContext currentContext, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, - IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery, + IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery, IFeatureService featureService, TimeProvider timeProvider) { @@ -49,7 +49,7 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand _pushRegistrationService = pushRegistrationService; _currentContext = currentContext; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; - _getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery; + _getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery; _featureService = featureService; _timeProvider = timeProvider; } @@ -161,8 +161,8 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null) { - var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(orgUser.OrganizationId, new[] { orgUser.Id }); - if (managementStatus.TryGetValue(orgUser.Id, out var isManaged) && isManaged) + var claimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(orgUser.OrganizationId, new[] { orgUser.Id }); + if (claimedStatus.TryGetValue(orgUser.Id, out var isClaimed) && isClaimed) { throw new BadRequestException(RemoveClaimedAccountErrorMessage); } @@ -214,8 +214,8 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand deletingUserIsOwner = await _currentContext.OrganizationOwner(organizationId); } - var managementStatus = _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null - ? await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(organizationId, filteredUsers.Select(u => u.Id)) + var claimedStatus = _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null + ? await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, filteredUsers.Select(u => u.Id)) : filteredUsers.ToDictionary(u => u.Id, u => false); var result = new List<(OrganizationUser OrganizationUser, string ErrorMessage)>(); foreach (var orgUser in filteredUsers) @@ -232,7 +232,7 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand throw new BadRequestException(RemoveOwnerByNonOwnerErrorMessage); } - if (managementStatus.TryGetValue(orgUser.Id, out var isManaged) && isManaged) + if (claimedStatus.TryGetValue(orgUser.Id, out var isClaimed) && isClaimed) { throw new BadRequestException(RemoveClaimedAccountErrorMessage); } diff --git a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs index 8825f9722a..108641b5e6 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.KeyManagement.UserKey; @@ -68,4 +69,6 @@ public interface IOrganizationUserRepository : IRepositoryThe role to search for /// A list of OrganizationUsersUserDetails with the specified role Task> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role); + + Task CreateManyAsync(IEnumerable organizationUserCollection); } diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 78f880b15c..c9027b8030 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -6,13 +6,13 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; -using Bit.Core.Auth.Models.Business; -using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Constants; @@ -26,18 +26,17 @@ using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; -using Bit.Core.Models.Mail; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Settings; -using Bit.Core.Tokens; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.Extensions.Logging; using Stripe; +using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite; namespace Bit.Core.Services; @@ -58,7 +57,6 @@ public class OrganizationService : IOrganizationService private readonly IPaymentService _paymentService; private readonly IPolicyRepository _policyRepository; private readonly IPolicyService _policyService; - private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoUserRepository _ssoUserRepository; private readonly IReferenceEventService _referenceEventService; private readonly IGlobalSettings _globalSettings; @@ -70,13 +68,12 @@ public class OrganizationService : IOrganizationService private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery; private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; private readonly IProviderRepository _providerRepository; - private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory; - private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; private readonly IFeatureService _featureService; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; private readonly IPricingClient _pricingClient; private readonly IPolicyRequirementQuery _policyRequirementQuery; + private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand; public OrganizationService( IOrganizationRepository organizationRepository, @@ -94,7 +91,6 @@ public class OrganizationService : IOrganizationService IPaymentService paymentService, IPolicyRepository policyRepository, IPolicyService policyService, - ISsoConfigRepository ssoConfigRepository, ISsoUserRepository ssoUserRepository, IReferenceEventService referenceEventService, IGlobalSettings globalSettings, @@ -104,15 +100,14 @@ public class OrganizationService : IOrganizationService IProviderOrganizationRepository providerOrganizationRepository, IProviderUserRepository providerUserRepository, ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery, - IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory, - IDataProtectorTokenFactory orgUserInviteTokenDataFactory, IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, IProviderRepository providerRepository, IFeatureService featureService, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, IPricingClient pricingClient, - IPolicyRequirementQuery policyRequirementQuery) + IPolicyRequirementQuery policyRequirementQuery, + ISendOrganizationInvitesCommand sendOrganizationInvitesCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -129,7 +124,6 @@ public class OrganizationService : IOrganizationService _paymentService = paymentService; _policyRepository = policyRepository; _policyService = policyService; - _ssoConfigRepository = ssoConfigRepository; _ssoUserRepository = ssoUserRepository; _referenceEventService = referenceEventService; _globalSettings = globalSettings; @@ -141,13 +135,12 @@ public class OrganizationService : IOrganizationService _countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery; _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; _providerRepository = providerRepository; - _orgUserInviteTokenableFactory = orgUserInviteTokenableFactory; - _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _featureService = featureService; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; _pricingClient = pricingClient; _policyRequirementQuery = policyRequirementQuery; + _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand; } public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken, @@ -1055,74 +1048,14 @@ public class OrganizationService : IOrganizationService await SendInviteAsync(orgUser, org, initOrganization); } - private async Task SendInvitesAsync(IEnumerable orgUsers, Organization organization) - { - var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(orgUsers, organization); + private async Task SendInvitesAsync(IEnumerable orgUsers, Organization organization) => + await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(orgUsers, organization)); - await _mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo); - } - - private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool initOrganization) - { - // convert single org user into array of 1 org user - var orgUsers = new[] { orgUser }; - - var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(orgUsers, organization, initOrganization); - - await _mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo); - } - - private async Task BuildOrganizationInvitesInfoAsync( - IEnumerable orgUsers, - Organization organization, - bool initOrganization = false) - { - // Materialize the sequence into a list to avoid multiple enumeration warnings - var orgUsersList = orgUsers.ToList(); - - // Email links must include information about the org and user for us to make routing decisions client side - // Given an org user, determine if existing BW user exists - var orgUserEmails = orgUsersList.Select(ou => ou.Email).ToList(); - var existingUsers = await _userRepository.GetManyByEmailsAsync(orgUserEmails); - - // hash existing users emails list for O(1) lookups - var existingUserEmailsHashSet = new HashSet(existingUsers.Select(u => u.Email)); - - // Create a dictionary of org user guids and bools for whether or not they have an existing BW user - var orgUserHasExistingUserDict = orgUsersList.ToDictionary( - ou => ou.Id, - ou => existingUserEmailsHashSet.Contains(ou.Email) - ); - - // Determine if org has SSO enabled and if user is required to login with SSO - // Note: we only want to call the DB after checking if the org can use SSO per plan and if they have any policies enabled. - var orgSsoEnabled = organization.UseSso && (await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id))?.Enabled == true; - // Even though the require SSO policy can be turned on regardless of SSO being enabled, for this logic, we only - // need to check the policy if the org has SSO enabled. - var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled && - organization.UsePolicies && - (await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true; - - // Generate the list of org users and expiring tokens - // create helper function to create expiring tokens - (OrganizationUser, ExpiringToken) MakeOrgUserExpiringTokenPair(OrganizationUser orgUser) - { - var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser); - var protectedToken = _orgUserInviteTokenDataFactory.Protect(orgUserInviteTokenable); - return (orgUser, new ExpiringToken(protectedToken, orgUserInviteTokenable.ExpirationDate)); - } - - var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair); - - return new OrganizationInvitesInfo( - organization, - orgSsoEnabled, - orgSsoLoginRequiredPolicyEnabled, - orgUsersWithExpTokens, - orgUserHasExistingUserDict, - initOrganization - ); - } + private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool initOrganization) => + await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest( + users: [orgUser], + organization: organization, + initOrganization: initOrganization)); internal async Task<(bool canScale, string failureReason)> CanScaleAsync( Organization organization, diff --git a/src/Core/AdminConsole/Shared/Validation/ValidationResult.cs b/src/Core/AdminConsole/Shared/Validation/ValidationResult.cs index e25103e701..ba78601637 100644 --- a/src/Core/AdminConsole/Shared/Validation/ValidationResult.cs +++ b/src/Core/AdminConsole/Shared/Validation/ValidationResult.cs @@ -6,10 +6,39 @@ public abstract record ValidationResult; public record Valid : ValidationResult { + public Valid() { } + + public Valid(T Value) + { + this.Value = Value; + } + public T Value { get; init; } } public record Invalid : ValidationResult { - public IEnumerable> Errors { get; init; } + public IEnumerable> Errors { get; init; } = []; + + public string ErrorMessageString => string.Join(" ", Errors.Select(e => e.Message)); + + public Invalid() { } + + public Invalid(Error error) : this([error]) { } + + public Invalid(IEnumerable> errors) + { + Errors = errors; + } +} + +public static class ValidationResultMappers +{ + public static ValidationResult Map(this ValidationResult validationResult, B invalidValue) => + validationResult switch + { + Valid => new Valid(invalidValue), + Invalid invalid => new Invalid(invalid.Errors.Select(x => x.ToError(invalidValue))), + _ => throw new ArgumentOutOfRangeException(nameof(validationResult), "Unhandled validation result type") + }; } diff --git a/src/Core/Auth/Models/Mail/CannotDeleteManagedAccountViewModel.cs b/src/Core/Auth/Models/Mail/CannotDeleteClaimedAccountViewModel.cs similarity index 53% rename from src/Core/Auth/Models/Mail/CannotDeleteManagedAccountViewModel.cs rename to src/Core/Auth/Models/Mail/CannotDeleteClaimedAccountViewModel.cs index 02549a9595..15fc9fd7f0 100644 --- a/src/Core/Auth/Models/Mail/CannotDeleteManagedAccountViewModel.cs +++ b/src/Core/Auth/Models/Mail/CannotDeleteClaimedAccountViewModel.cs @@ -2,6 +2,6 @@ namespace Bit.Core.Auth.Models.Mail; -public class CannotDeleteManagedAccountViewModel : BaseMailModel +public class CannotDeleteClaimedAccountViewModel : BaseMailModel { } diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs index 21a421b9d0..3f89e9ad0e 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs @@ -53,23 +53,10 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai var user = await _userRepository.GetByEmailAsync(email); var userExists = user != null; - // Delays enabled by default; flag must be enabled to remove the delays. - var delaysEnabled = !_featureService.IsEnabled(FeatureFlagKeys.EmailVerificationDisableTimingDelays); - if (!_globalSettings.EnableEmailVerification) { - if (userExists) { - - if (delaysEnabled) - { - // Add delay to prevent timing attacks - // Note: sub 140 ms feels responsive to users so we are using a random value between 100 - 130 ms - // as it should be long enough to prevent timing attacks but not too long to be noticeable to the user. - await Task.Delay(Random.Shared.Next(100, 130)); - } - throw new BadRequestException($"Email {email} is already taken"); } @@ -87,11 +74,6 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai await _mailService.SendRegistrationVerificationEmailAsync(email, token); } - if (delaysEnabled) - { - // Add random delay between 100ms-130ms to prevent timing attacks - await Task.Delay(Random.Shared.Next(100, 130)); - } // User exists but we will return a 200 regardless of whether the email was sent or not; so return null return null; } diff --git a/src/Core/Billing/Extensions/BillingExtensions.cs b/src/Core/Billing/Extensions/BillingExtensions.cs index f6e65861cd..4fb97e1db7 100644 --- a/src/Core/Billing/Extensions/BillingExtensions.cs +++ b/src/Core/Billing/Extensions/BillingExtensions.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; @@ -28,6 +29,13 @@ public static class BillingExtensions Status: ProviderStatusType.Billable }; + public static bool IsBillable(this InviteOrganizationProvider inviteOrganizationProvider) => + inviteOrganizationProvider is + { + Type: ProviderType.Msp or ProviderType.MultiOrganizationEnterprise, + Status: ProviderStatusType.Billable + }; + public static bool SupportsConsolidatedBilling(this ProviderType providerType) => providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise; diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs index a4d22cfa3e..ae9ed15e72 100644 --- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs +++ b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs @@ -42,7 +42,7 @@ public class OrganizationBillingService( var customer = string.IsNullOrEmpty(organization.GatewayCustomerId) && customerSetup != null ? await CreateCustomerAsync(organization, customerSetup) - : await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = ["tax"] }); + : await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = ["tax", "tax_ids"] }); var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup); diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index e4b0594433..1b0e5b665b 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -622,47 +622,45 @@ public class SubscriberService( await stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id); } - if (string.IsNullOrWhiteSpace(taxInformation.TaxId)) + if (!string.IsNullOrWhiteSpace(taxInformation.TaxId)) { - return; - } - - var taxIdType = taxInformation.TaxIdType; - if (string.IsNullOrWhiteSpace(taxIdType)) - { - taxIdType = taxService.GetStripeTaxCode(taxInformation.Country, - taxInformation.TaxId); - - if (taxIdType == null) + var taxIdType = taxInformation.TaxIdType; + if (string.IsNullOrWhiteSpace(taxIdType)) { - logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.", - taxInformation.Country, + taxIdType = taxService.GetStripeTaxCode(taxInformation.Country, taxInformation.TaxId); - throw new Exceptions.BadRequestException("billingTaxIdTypeInferenceError"); - } - } - try - { - await stripeAdapter.TaxIdCreateAsync(customer.Id, - new TaxIdCreateOptions { Type = taxIdType, Value = taxInformation.TaxId }); - } - catch (StripeException e) - { - switch (e.StripeError.Code) - { - case StripeConstants.ErrorCodes.TaxIdInvalid: - logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", - taxInformation.TaxId, - taxInformation.Country); - throw new Exceptions.BadRequestException("billingInvalidTaxIdError"); - default: - logger.LogError(e, - "Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.", - taxInformation.TaxId, + if (taxIdType == null) + { + logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.", taxInformation.Country, - customer.Id); - throw new Exceptions.BadRequestException("billingTaxIdCreationError"); + taxInformation.TaxId); + throw new Exceptions.BadRequestException("billingTaxIdTypeInferenceError"); + } + } + + try + { + await stripeAdapter.TaxIdCreateAsync(customer.Id, + new TaxIdCreateOptions { Type = taxIdType, Value = taxInformation.TaxId }); + } + catch (StripeException e) + { + switch (e.StripeError.Code) + { + case StripeConstants.ErrorCodes.TaxIdInvalid: + logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", + taxInformation.TaxId, + taxInformation.Country); + throw new Exceptions.BadRequestException("billingInvalidTaxIdError"); + default: + logger.LogError(e, + "Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.", + taxInformation.TaxId, + taxInformation.Country, + customer.Id); + throw new Exceptions.BadRequestException("billingTaxIdCreationError"); + } } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 310b917bf7..0eaa6cd85f 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -104,18 +104,16 @@ public static class FeatureFlagKeys /* Admin Console Team */ public const string AccountDeprovisioning = "pm-10308-account-deprovisioning"; public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint"; - public const string DeviceApprovalRequestAdminNotifications = "pm-15637-device-approval-request-admin-notifications"; public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission"; - public const string PushSyncOrgKeysOnRevokeRestore = "pm-17168-push-sync-org-keys-on-revoke-restore"; public const string PolicyRequirements = "pm-14439-policy-requirements"; public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility"; + public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast"; /* Auth Team */ public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence"; public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; public const string DuoRedirect = "duo-redirect"; public const string EmailVerification = "email-verification"; - public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays"; public const string DeviceTrustLogging = "pm-8285-device-trust-logging"; public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token"; public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh"; @@ -197,6 +195,7 @@ public static class FeatureFlagKeys public const string RestrictProviderAccess = "restrict-provider-access"; public const string SecurityTasks = "security-tasks"; public const string CipherKeyEncryption = "cipher-key-encryption"; + public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms"; public static List GetAllKeys() { diff --git a/src/Core/Enums/EncryptionType.cs b/src/Core/Enums/EncryptionType.cs index 776ca99a93..52231e047c 100644 --- a/src/Core/Enums/EncryptionType.cs +++ b/src/Core/Enums/EncryptionType.cs @@ -4,9 +4,13 @@ // EncryptedStringAttribute public enum EncryptionType : byte { + // symmetric AesCbc256_B64 = 0, AesCbc128_HmacSha256_B64 = 1, AesCbc256_HmacSha256_B64 = 2, + XChaCha20Poly1305_B64 = 7, + + // asymmetric Rsa2048_OaepSha256_B64 = 3, Rsa2048_OaepSha1_B64 = 4, Rsa2048_OaepSha256_HmacSha256_B64 = 5, diff --git a/src/Core/MailTemplates/Handlebars/AdminConsole/CannotDeleteManagedAccount.html.hbs b/src/Core/MailTemplates/Handlebars/AdminConsole/CannotDeleteClaimedAccount.html.hbs similarity index 100% rename from src/Core/MailTemplates/Handlebars/AdminConsole/CannotDeleteManagedAccount.html.hbs rename to src/Core/MailTemplates/Handlebars/AdminConsole/CannotDeleteClaimedAccount.html.hbs diff --git a/src/Core/MailTemplates/Handlebars/AdminConsole/CannotDeleteManagedAccount.text.hbs b/src/Core/MailTemplates/Handlebars/AdminConsole/CannotDeleteClaimedAccount.text.hbs similarity index 100% rename from src/Core/MailTemplates/Handlebars/AdminConsole/CannotDeleteManagedAccount.text.hbs rename to src/Core/MailTemplates/Handlebars/AdminConsole/CannotDeleteClaimedAccount.text.hbs diff --git a/src/Core/Models/Commands/CommandResult.cs b/src/Core/Models/Commands/CommandResult.cs index a8ec772fc1..4a9477067e 100644 --- a/src/Core/Models/Commands/CommandResult.cs +++ b/src/Core/Models/Commands/CommandResult.cs @@ -1,6 +1,7 @@ #nullable enable using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.Shared.Validation; namespace Bit.Core.Models.Commands; @@ -40,10 +41,23 @@ public class Success(T value) : CommandResult public class Failure(IEnumerable errorMessages) : CommandResult { public List ErrorMessages { get; } = errorMessages.ToList(); + public Error[] Errors { get; set; } = []; public string ErrorMessage => string.Join(" ", ErrorMessages); - public Failure(string error) : this([error]) { } + public Failure(string error) : this([error]) + { + } + + public Failure(IEnumerable> errors) : this(errors.Select(e => e.Message)) + { + Errors = errors.ToArray(); + } + + public Failure(Error error) : this([error.Message]) + { + Errors = [error]; + } } public class Partial : CommandResult @@ -57,3 +71,18 @@ public class Partial : CommandResult Failures = failedItems.ToArray(); } } + +public static class CommandResultExtensions +{ + /// + /// This is to help map between the InvalidT ValidationResult and the FailureT CommandResult types. + /// + /// + /// This is the invalid type from validating the object. + /// This function will map between the two types for the inner ErrorT + /// Invalid object's type + /// Failure object's type + /// + public static CommandResult MapToFailure(this Invalid invalidResult, Func mappingFunction) => + new Failure(invalidResult.Errors.Select(errorA => errorA.ToError(mappingFunction(errorA.ErroredValue)))); +} diff --git a/src/Core/Models/Data/Organizations/ManagedUserDomainClaimedEmails.cs b/src/Core/Models/Data/Organizations/ClaimedUserDomainClaimedEmails.cs similarity index 66% rename from src/Core/Models/Data/Organizations/ManagedUserDomainClaimedEmails.cs rename to src/Core/Models/Data/Organizations/ClaimedUserDomainClaimedEmails.cs index 429257e266..2b73fc1525 100644 --- a/src/Core/Models/Data/Organizations/ManagedUserDomainClaimedEmails.cs +++ b/src/Core/Models/Data/Organizations/ClaimedUserDomainClaimedEmails.cs @@ -2,4 +2,4 @@ namespace Bit.Core.Models.Data.Organizations; -public record ManagedUserDomainClaimedEmails(IEnumerable EmailList, Organization Organization); +public record ClaimedUserDomainClaimedEmails(IEnumerable EmailList, Organization Organization); diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 59cfdace65..96d9095c1a 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -13,6 +13,11 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.Models.Business.Tokenables; using Bit.Core.OrganizationFeatures.OrganizationCollections; @@ -116,7 +121,7 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); } @@ -167,13 +172,21 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } // TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/Interface/IUpdateSecretsManagerSubscriptionCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/Interface/IUpdateSecretsManagerSubscriptionCommand.cs index 5c6758fd17..947f66a821 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/Interface/IUpdateSecretsManagerSubscriptionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/Interface/IUpdateSecretsManagerSubscriptionCommand.cs @@ -5,4 +5,5 @@ namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; public interface IUpdateSecretsManagerSubscriptionCommand { Task UpdateSubscriptionAsync(SecretsManagerSubscriptionUpdate update); + Task ValidateUpdateAsync(SecretsManagerSubscriptionUpdate update); } diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs index 78ab35c38c..91f6516501 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs @@ -124,7 +124,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs } - private async Task ValidateUpdateAsync(SecretsManagerSubscriptionUpdate update) + public async Task ValidateUpdateAsync(SecretsManagerSubscriptionUpdate update) { if (_globalSettings.SelfHosted) { diff --git a/src/Core/Platform/X509ChainCustomization/PostConfigureX509ChainOptions.cs b/src/Core/Platform/X509ChainCustomization/PostConfigureX509ChainOptions.cs new file mode 100644 index 0000000000..963294e85f --- /dev/null +++ b/src/Core/Platform/X509ChainCustomization/PostConfigureX509ChainOptions.cs @@ -0,0 +1,96 @@ +#nullable enable + +using System.Security.Cryptography.X509Certificates; +using Bit.Core.Settings; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Bit.Core.Platform.X509ChainCustomization; + +internal sealed class PostConfigureX509ChainOptions : IPostConfigureOptions +{ + const string CertificateSearchPattern = "*.crt"; + + private readonly ILogger _logger; + private readonly IHostEnvironment _hostEnvironment; + private readonly GlobalSettings _globalSettings; + + public PostConfigureX509ChainOptions( + ILogger logger, + IHostEnvironment hostEnvironment, + GlobalSettings globalSettings) + { + _logger = logger; + _hostEnvironment = hostEnvironment; + _globalSettings = globalSettings; + } + + public void PostConfigure(string? name, X509ChainOptions options) + { + // We don't register or request a named instance of these options, + // so don't customize it. + if (name != Options.DefaultName) + { + return; + } + + // We only allow this setting to be configured on self host. + if (!_globalSettings.SelfHosted) + { + options.AdditionalCustomTrustCertificatesDirectory = null; + return; + } + + if (options.AdditionalCustomTrustCertificates != null) + { + // Additional certificates were added directly, this overwrites the need to + // read them from the directory. + _logger.LogInformation( + "Additional custom trust certificates were added directly, skipping loading them from '{Directory}'", + options.AdditionalCustomTrustCertificatesDirectory + ); + return; + } + + if (string.IsNullOrEmpty(options.AdditionalCustomTrustCertificatesDirectory)) + { + return; + } + + if (!Directory.Exists(options.AdditionalCustomTrustCertificatesDirectory)) + { + // The default directory is volume mounted via the default Bitwarden setup process. + // If the directory doesn't exist it could indicate a error in configuration but this + // directory is never expected in a normal development environment so lower the log + // level in that case. + var logLevel = _hostEnvironment.IsDevelopment() + ? LogLevel.Debug + : LogLevel.Warning; + _logger.Log( + logLevel, + "An additional custom trust certificate directory was given '{Directory}' but that directory does not exist.", + options.AdditionalCustomTrustCertificatesDirectory + ); + return; + } + + var certificates = new List(); + + foreach (var certFile in Directory.EnumerateFiles(options.AdditionalCustomTrustCertificatesDirectory, CertificateSearchPattern)) + { + certificates.Add(new X509Certificate2(certFile)); + } + + if (options.AdditionalCustomTrustCertificatesDirectory != X509ChainOptions.DefaultAdditionalCustomTrustCertificatesDirectory && certificates.Count == 0) + { + // They have intentionally given us a non-default directory but there weren't certificates, that is odd. + _logger.LogWarning( + "No additional custom trust certificates were found in '{Directory}'", + options.AdditionalCustomTrustCertificatesDirectory + ); + } + + options.AdditionalCustomTrustCertificates = certificates; + } +} diff --git a/src/Core/Platform/X509ChainCustomization/X509ChainCustomizationServiceCollectionExtensions.cs b/src/Core/Platform/X509ChainCustomization/X509ChainCustomizationServiceCollectionExtensions.cs new file mode 100644 index 0000000000..46bd5b37e6 --- /dev/null +++ b/src/Core/Platform/X509ChainCustomization/X509ChainCustomizationServiceCollectionExtensions.cs @@ -0,0 +1,53 @@ +using Bit.Core.Platform.X509ChainCustomization; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for setting up the ability to provide customization to how X509 chain validation works in an . +/// +public static class X509ChainCustomizationServiceCollectionExtensions +{ + /// + /// Configures X509ChainPolicy customization through the root level X509ChainOptions configuration section + /// and configures the primary to use custom certificate validation + /// when customized to do so. + /// + /// The . + /// The for additional chaining. + public static IServiceCollection AddX509ChainCustomization(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddOptions() + .BindConfiguration(nameof(X509ChainOptions)); + + // Use TryAddEnumerable to make sure `PostConfigureX509ChainOptions` isn't added multiple + // times even if this method is called multiple times. + services.TryAddEnumerable(ServiceDescriptor.Singleton, PostConfigureX509ChainOptions>()); + + services.AddHttpClient() + .ConfigureHttpClientDefaults(builder => + { + builder.ConfigurePrimaryHttpMessageHandler(sp => + { + var x509ChainOptions = sp.GetRequiredService>().Value; + + var handler = new HttpClientHandler(); + + if (x509ChainOptions.TryGetCustomRemoteCertificateValidationCallback(out var callback)) + { + handler.ServerCertificateCustomValidationCallback = (sender, certificate, chain, errors) => + { + return callback(certificate, chain, errors); + }; + } + + return handler; + }); + }); + + return services; + } +} diff --git a/src/Core/Platform/X509ChainCustomization/X509ChainOptions.cs b/src/Core/Platform/X509ChainCustomization/X509ChainOptions.cs new file mode 100644 index 0000000000..6cd06acf3c --- /dev/null +++ b/src/Core/Platform/X509ChainCustomization/X509ChainOptions.cs @@ -0,0 +1,81 @@ +#nullable enable + +using System.Diagnostics.CodeAnalysis; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; + +namespace Bit.Core.Platform.X509ChainCustomization; + +/// +/// Allows for customization of the and access to a custom server certificate validator +/// if customization has been made. +/// +public sealed class X509ChainOptions +{ + // This is the directory that we historically used to allow certificates be added inside our container + // and then on start of the container we would move them to `/usr/local/share/ca-certificates/` and call + // `update-ca-certificates` but since that operation requires root we can't do it in a rootless container. + // Ref: https://github.com/bitwarden/server/blob/67d7d685a619a5fc413f8532dacb09681ee5c956/src/Api/entrypoint.sh#L38-L41 + public const string DefaultAdditionalCustomTrustCertificatesDirectory = "/etc/bitwarden/ca-certificates/"; + + /// + /// A directory where additional certificates should be read from and included in . + /// + /// + /// Only certificates suffixed with *.crt will be read. If is + /// set, then this directory will not be read from. + /// + public string? AdditionalCustomTrustCertificatesDirectory { get; set; } = DefaultAdditionalCustomTrustCertificatesDirectory; + + /// + /// A list of additional certificates that should be included in . + /// + /// + /// If this value is set manually, then will be ignored. + /// + public List? AdditionalCustomTrustCertificates { get; set; } + + /// + /// Attempts to retrieve a custom remote certificate validation callback. + /// + /// + /// Returns when we have custom remote certification that should be added, + /// when no custom validation is needed and the default validation callback should + /// be used instead. + /// + [MemberNotNullWhen(true, nameof(AdditionalCustomTrustCertificates))] + public bool TryGetCustomRemoteCertificateValidationCallback( + [MaybeNullWhen(false)] out Func callback) + { + callback = null; + if (AdditionalCustomTrustCertificates == null || AdditionalCustomTrustCertificates.Count == 0) + { + return false; + } + + // Do this outside of the callback so that we aren't opening the root store every request. + using var store = new X509Store(StoreName.Root, StoreLocation.LocalMachine, OpenFlags.ReadOnly); + var rootCertificates = store.Certificates; + + // Ref: https://github.com/dotnet/runtime/issues/39835#issuecomment-663020581 + callback = (certificate, chain, errors) => + { + if (chain == null || certificate == null) + { + return false; + } + + chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + + // We want our additional certificates to be in addition to the machines root store. + chain.ChainPolicy.CustomTrustStore.AddRange(rootCertificates); + + foreach (var additionalCertificate in AdditionalCustomTrustCertificates) + { + chain.ChainPolicy.CustomTrustStore.Add(additionalCertificate); + } + return chain.Build(certificate); + }; + return true; + } +} diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index e61127c57a..48e0464905 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -21,7 +21,7 @@ public interface IMailService ProductTierType productTier, IEnumerable products); Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token); - Task SendCannotDeleteManagedAccountEmailAsync(string email); + Task SendCannotDeleteClaimedAccountEmailAsync(string email); Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail); Task SendChangeEmailEmailAsync(string newEmailAddress, string token); Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, bool authentication = true); @@ -97,7 +97,7 @@ public interface IMailService Task SendRequestSMAccessToAdminEmailAsync(IEnumerable adminEmails, string organizationName, string userRequestingAccess, string emailContent); Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, string offerAcceptanceDate, string organizationId, string organizationName); - Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList); + Task SendClaimedDomainUserEmailAsync(ClaimedUserDomainClaimedEmails emailList); Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable adminEmails, Guid organizationId, string email, string userName); Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable securityTaskNotifications, IEnumerable adminOwnerEmails); } diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index bd7efdbad4..ded9f4cfd3 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Api.Requests.Accounts; using Bit.Core.Billing.Models.Api.Requests.Organizations; @@ -38,9 +39,28 @@ public interface IPaymentService Task GetSubscriptionAsync(ISubscriber subscriber); Task GetTaxInfoAsync(ISubscriber subscriber); Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo); - Task AddSecretsManagerToSubscription(Organization org, Plan plan, int additionalSmSeats, - int additionalServiceAccount); + Task AddSecretsManagerToSubscription(Organization org, Plan plan, int additionalSmSeats, int additionalServiceAccount); + /// + /// Secrets Manager Standalone is a discount in Stripe that is used to give an organization access to Secrets Manager. + /// Usually, this also implies that when they invite a user to their organization, they are doing so for both Password + /// Manager and Secrets Manger. + /// + /// This will not call out to Stripe if they don't have a GatewayId or if they don't have Secrets Manager. + /// + /// Organization Entity + /// If the organization has Secrets Manager and has the Standalone Stripe Discount Task HasSecretsManagerStandalone(Organization organization); + + /// + /// Secrets Manager Standalone is a discount in Stripe that is used to give an organization access to Secrets Manager. + /// Usually, this also implies that when they invite a user to their organization, they are doing so for both Password + /// Manager and Secrets Manger. + /// + /// This will not call out to Stripe if they don't have a GatewayId or if they don't have Secrets Manager. + /// + /// Organization Representation used for Inviting Organization Users + /// If the organization has Secrets Manager and has the Standalone Stripe Discount + Task HasSecretsManagerStandalone(InviteOrganization organization); Task PreviewInvoiceAsync(PreviewIndividualInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId); Task PreviewInvoiceAsync(PreviewOrganizationInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId); diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index b6a1d1f05b..9b12713218 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -134,7 +134,7 @@ public interface IUserService /// /// False if the Account Deprovisioning feature flag is disabled. /// - Task IsManagedByAnyOrganizationAsync(Guid userId); + Task IsClaimedByAnyOrganizationAsync(Guid userId); /// /// Verify whether the new email domain meets the requirements for managed users. @@ -142,9 +142,9 @@ public interface IUserService /// /// /// - /// IdentityResult + /// IdentityResult /// - Task ValidateManagedUserDomainAsync(User user, string newEmail); + Task ValidateClaimedUserDomainAsync(User user, string newEmail); /// /// Gets the organizations that manage the user. @@ -152,6 +152,6 @@ public interface IUserService /// /// An empty collection if the Account Deprovisioning feature flag is disabled. /// - /// - Task> GetOrganizationsManagingUserAsync(Guid userId); + /// + Task> GetOrganizationsClaimingUserAsync(Guid userId); } diff --git a/src/Core/Services/Implementations/DeviceService.cs b/src/Core/Services/Implementations/DeviceService.cs index 99523d8e5e..165fab0237 100644 --- a/src/Core/Services/Implementations/DeviceService.cs +++ b/src/Core/Services/Implementations/DeviceService.cs @@ -77,6 +77,9 @@ public class DeviceService : IDeviceService device.Active = false; device.RevisionDate = DateTime.UtcNow; + device.EncryptedPrivateKey = null; + device.EncryptedPublicKey = null; + device.EncryptedUserKey = null; await _deviceRepository.UpsertAsync(device); await _pushRegistrationService.DeleteRegistrationAsync(device.Id.ToString()); diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index a551342324..7bcf2c0ef5 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -117,16 +117,16 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } - public async Task SendCannotDeleteManagedAccountEmailAsync(string email) + public async Task SendCannotDeleteClaimedAccountEmailAsync(string email) { var message = CreateDefaultMessage("Delete Your Account", email); - var model = new CannotDeleteManagedAccountViewModel + var model = new CannotDeleteClaimedAccountViewModel { WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, SiteName = _globalSettings.SiteName, }; - await AddMessageContentAsync(message, "AdminConsole.CannotDeleteManagedAccount", model); - message.Category = "CannotDeleteManagedAccount"; + await AddMessageContentAsync(message, "AdminConsole.CannotDeleteClaimedAccount", model); + message.Category = "CannotDeleteClaimedAccount"; await _mailDeliveryService.SendEmailAsync(message); } @@ -474,7 +474,7 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } - public async Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList) + public async Task SendClaimedDomainUserEmailAsync(ClaimedUserDomainClaimedEmails emailList) { await EnqueueMailAsync(emailList.EmailList.Select(email => CreateMessage(email, emailList.Organization))); @@ -804,12 +804,10 @@ public class HandlebarsMailService : IMailService return; } - var numeric = parameters[0]; - var singularText = parameters[1].ToString(); - var pluralText = parameters[2].ToString(); - - if (numeric is int number) + if (int.TryParse(parameters[0].ToString(), out var number)) { + var singularText = parameters[1].ToString(); + var pluralText = parameters[2].ToString(); writer.WriteSafeString(number == 1 ? singularText : pluralText); } else diff --git a/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs b/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs index dc4e89aa23..3a7cabd39e 100644 --- a/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs +++ b/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs @@ -1,7 +1,10 @@ -using Bit.Core.Settings; +using System.Security.Cryptography.X509Certificates; +using Bit.Core.Platform.X509ChainCustomization; +using Bit.Core.Settings; using Bit.Core.Utilities; using MailKit.Net.Smtp; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using MimeKit; namespace Bit.Core.Services; @@ -10,12 +13,14 @@ public class MailKitSmtpMailDeliveryService : IMailDeliveryService { private readonly GlobalSettings _globalSettings; private readonly ILogger _logger; + private readonly X509ChainOptions _x509ChainOptions; private readonly string _replyDomain; private readonly string _replyEmail; public MailKitSmtpMailDeliveryService( GlobalSettings globalSettings, - ILogger logger) + ILogger logger, + IOptions x509ChainOptions) { if (globalSettings.Mail?.Smtp?.Host == null) { @@ -31,6 +36,7 @@ public class MailKitSmtpMailDeliveryService : IMailDeliveryService _globalSettings = globalSettings; _logger = logger; + _x509ChainOptions = x509ChainOptions.Value; } public async Task SendEmailAsync(Models.Mail.MailMessage message) @@ -75,6 +81,13 @@ public class MailKitSmtpMailDeliveryService : IMailDeliveryService { client.ServerCertificateValidationCallback = (s, c, h, e) => true; } + else if (_x509ChainOptions.TryGetCustomRemoteCertificateValidationCallback(out var callback)) + { + client.ServerCertificateValidationCallback = (sender, cert, chain, errors) => + { + return callback(new X509Certificate2(cert), chain, errors); + }; + } if (!_globalSettings.Mail.Smtp.StartTls && !_globalSettings.Mail.Smtp.Ssl && _globalSettings.Mail.Smtp.Port == 25) diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index d8889bca26..d82a4d60a7 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; @@ -1110,14 +1111,27 @@ public class StripePaymentService : IPaymentService new SecretsManagerSubscribeUpdate(org, plan, additionalSmSeats, additionalServiceAccount), true); - public async Task HasSecretsManagerStandalone(Organization organization) + public async Task HasSecretsManagerStandalone(Organization organization) => + await HasSecretsManagerStandaloneAsync(gatewayCustomerId: organization.GatewayCustomerId, + organizationHasSecretsManager: organization.UseSecretsManager); + + public async Task HasSecretsManagerStandalone(InviteOrganization organization) => + await HasSecretsManagerStandaloneAsync(gatewayCustomerId: organization.GatewayCustomerId, + organizationHasSecretsManager: organization.UseSecretsManager); + + private async Task HasSecretsManagerStandaloneAsync(string gatewayCustomerId, bool organizationHasSecretsManager) { - if (string.IsNullOrEmpty(organization.GatewayCustomerId)) + if (string.IsNullOrEmpty(gatewayCustomerId)) { return false; } - var customer = await _stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId); + if (organizationHasSecretsManager is false) + { + return false; + } + + var customer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId); return customer?.Discount?.Coupon?.Id == SecretsManagerStandaloneDiscountId; } diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 5076c8282e..de0fa427ba 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -314,9 +314,9 @@ public class UserService : UserManager, IUserService, IDisposable return; } - if (await IsManagedByAnyOrganizationAsync(user.Id)) + if (await IsClaimedByAnyOrganizationAsync(user.Id)) { - await _mailService.SendCannotDeleteManagedAccountEmailAsync(user.Email); + await _mailService.SendCannotDeleteClaimedAccountEmailAsync(user.Email); return; } @@ -545,11 +545,11 @@ public class UserService : UserManager, IUserService, IDisposable return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); } - var managedUserValidationResult = await ValidateManagedUserDomainAsync(user, newEmail); + var claimedUserValidationResult = await ValidateClaimedUserDomainAsync(user, newEmail); - if (!managedUserValidationResult.Succeeded) + if (!claimedUserValidationResult.Succeeded) { - return managedUserValidationResult; + return claimedUserValidationResult; } if (!await base.VerifyUserTokenAsync(user, _identityOptions.Tokens.ChangeEmailTokenProvider, @@ -617,18 +617,18 @@ public class UserService : UserManager, IUserService, IDisposable return IdentityResult.Success; } - public async Task ValidateManagedUserDomainAsync(User user, string newEmail) + public async Task ValidateClaimedUserDomainAsync(User user, string newEmail) { - var managingOrganizations = await GetOrganizationsManagingUserAsync(user.Id); + var claimingOrganization = await GetOrganizationsClaimingUserAsync(user.Id); - if (!managingOrganizations.Any()) + if (!claimingOrganization.Any()) { return IdentityResult.Success; } var newDomain = CoreHelpers.GetEmailDomain(newEmail); - var verifiedDomains = await _organizationDomainRepository.GetVerifiedDomainsByOrganizationIdsAsync(managingOrganizations.Select(org => org.Id)); + var verifiedDomains = await _organizationDomainRepository.GetVerifiedDomainsByOrganizationIdsAsync(claimingOrganization.Select(org => org.Id)); if (verifiedDomains.Any(verifiedDomain => verifiedDomain.DomainName == newDomain)) { @@ -1366,13 +1366,13 @@ public class UserService : UserManager, IUserService, IDisposable return IsLegacyUser(user); } - public async Task IsManagedByAnyOrganizationAsync(Guid userId) + public async Task IsClaimedByAnyOrganizationAsync(Guid userId) { - var managingOrganizations = await GetOrganizationsManagingUserAsync(userId); - return managingOrganizations.Any(); + var organizationsClaimingUser = await GetOrganizationsClaimingUserAsync(userId); + return organizationsClaimingUser.Any(); } - public async Task> GetOrganizationsManagingUserAsync(Guid userId) + public async Task> GetOrganizationsClaimingUserAsync(Guid userId) { if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) { diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index d829fbbacb..f6b27b0670 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -103,7 +103,7 @@ public class NoopMailService : IMailService return Task.FromResult(0); } - public Task SendCannotDeleteManagedAccountEmailAsync(string email) + public Task SendCannotDeleteClaimedAccountEmailAsync(string email) { return Task.FromResult(0); } @@ -317,7 +317,7 @@ public class NoopMailService : IMailService { return Task.FromResult(0); } - public Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList) => Task.CompletedTask; + public Task SendClaimedDomainUserEmailAsync(ClaimedUserDomainClaimedEmails emailList) => Task.CompletedTask; public Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable adminEmails, Guid organizationId, string email, string userName) { diff --git a/src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs b/src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs index a08359a84f..0c165a7dc2 100644 --- a/src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs +++ b/src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.Entities; +using System.Collections.Concurrent; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Entities; @@ -59,22 +60,21 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(request.OrganizationId); var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers); - var memberAccessCipherDetails = GenerateAccessData( + var memberAccessCipherDetails = GenerateAccessDataParallel( orgGroups, orgCollectionsWithAccess, orgItems, organizationUsersTwoFactorEnabled, - orgAbility - ); + orgAbility); return memberAccessCipherDetails; } /// /// Generates a report for all members of an organization. Containing summary information - /// such as item, collection, and group counts. Including the cipherIds a member is assigned. + /// such as item, collection, and group counts. Including the cipherIds a member is assigned. /// Child collection includes detailed information on the user and group collections along - /// with their permissions. + /// with their permissions. /// /// Organization groups collection /// Collections for the organization and the groups/users and permissions @@ -82,72 +82,72 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery /// Organization users and two factor status /// Organization ability for account recovery status /// List of the MemberAccessCipherDetailsModel; - private IEnumerable GenerateAccessData( - ICollection orgGroups, - ICollection> orgCollectionsWithAccess, - IEnumerable orgItems, - IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled, - OrganizationAbility orgAbility) + private IEnumerable GenerateAccessDataParallel( + ICollection orgGroups, + ICollection> orgCollectionsWithAccess, + IEnumerable orgItems, + IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled, + OrganizationAbility orgAbility) { - var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user); - // Create a dictionary to lookup the group names later. + var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user).ToList(); var groupNameDictionary = orgGroups.ToDictionary(x => x.Id, x => x.Name); - - // Get collections grouped and into a dictionary for counts var collectionItems = orgItems .SelectMany(x => x.CollectionIds, (cipher, collectionId) => new { Cipher = cipher, CollectionId = collectionId }) .GroupBy(y => y.CollectionId, (key, ciphers) => new { CollectionId = key, Ciphers = ciphers }); - var itemLookup = collectionItems.ToDictionary(x => x.CollectionId.ToString(), x => x.Ciphers.Select(c => c.Cipher.Id.ToString())); + var itemLookup = collectionItems.ToDictionary(x => x.CollectionId.ToString(), x => x.Ciphers.Select(c => c.Cipher.Id.ToString()).ToList()); - // Loop through the org users and populate report and access data - var memberAccessCipherDetails = new List(); - foreach (var user in orgUsers) + var memberAccessCipherDetails = new ConcurrentBag(); + + Parallel.ForEach(orgUsers, user => { var groupAccessDetails = new List(); var userCollectionAccessDetails = new List(); + foreach (var tCollect in orgCollectionsWithAccess) { - var hasItems = itemLookup.TryGetValue(tCollect.Item1.Id.ToString(), out var items); - var collectionCiphers = hasItems ? items.Select(x => x) : null; - - var itemCounts = hasItems ? collectionCiphers.Count() : 0; - if (tCollect.Item2.Groups.Count() > 0) + if (itemLookup.TryGetValue(tCollect.Item1.Id.ToString(), out var items)) { + var itemCounts = items.Count; - var groupDetails = tCollect.Item2.Groups.Where((tCollectGroups) => user.Groups.Contains(tCollectGroups.Id)).Select(x => - new MemberAccessDetails - { - CollectionId = tCollect.Item1.Id, - CollectionName = tCollect.Item1.Name, - GroupId = x.Id, - GroupName = groupNameDictionary[x.Id], - ReadOnly = x.ReadOnly, - HidePasswords = x.HidePasswords, - Manage = x.Manage, - ItemCount = itemCounts, - CollectionCipherIds = items - }); + if (tCollect.Item2.Groups.Any()) + { + var groupDetails = tCollect.Item2.Groups + .Where(tCollectGroups => user.Groups.Contains(tCollectGroups.Id)) + .Select(x => new MemberAccessDetails + { + CollectionId = tCollect.Item1.Id, + CollectionName = tCollect.Item1.Name, + GroupId = x.Id, + GroupName = groupNameDictionary[x.Id], + ReadOnly = x.ReadOnly, + HidePasswords = x.HidePasswords, + Manage = x.Manage, + ItemCount = itemCounts, + CollectionCipherIds = items + }); - groupAccessDetails.AddRange(groupDetails); - } + groupAccessDetails.AddRange(groupDetails); + } - // All collections assigned to users and their permissions - if (tCollect.Item2.Users.Count() > 0) - { - var userCollectionDetails = tCollect.Item2.Users.Where((tCollectUser) => tCollectUser.Id == user.Id).Select(x => - new MemberAccessDetails - { - CollectionId = tCollect.Item1.Id, - CollectionName = tCollect.Item1.Name, - ReadOnly = x.ReadOnly, - HidePasswords = x.HidePasswords, - Manage = x.Manage, - ItemCount = itemCounts, - CollectionCipherIds = items - }); - userCollectionAccessDetails.AddRange(userCollectionDetails); + if (tCollect.Item2.Users.Any()) + { + var userCollectionDetails = tCollect.Item2.Users + .Where(tCollectUser => tCollectUser.Id == user.Id) + .Select(x => new MemberAccessDetails + { + CollectionId = tCollect.Item1.Id, + CollectionName = tCollect.Item1.Name, + ReadOnly = x.ReadOnly, + HidePasswords = x.HidePasswords, + Manage = x.Manage, + ItemCount = itemCounts, + CollectionCipherIds = items + }); + + userCollectionAccessDetails.AddRange(userCollectionDetails); + } } } @@ -156,7 +156,6 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery UserName = user.Name, Email = user.Email, TwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == user.Id).twoFactorIsEnabled, - // Both the user's ResetPasswordKey must be set and the organization can UseResetPassword AccountRecoveryEnabled = !string.IsNullOrEmpty(user.ResetPasswordKey) && orgAbility.UseResetPassword, UserGuid = user.Id, UsesKeyConnector = user.UsesKeyConnector @@ -169,9 +168,8 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery userAccessDetails.AddRange(userGroups); } - // There can be edge cases where groups don't have a collection var groupsWithoutCollections = user.Groups.Where(x => !userAccessDetails.Any(y => x == y.GroupId)); - if (groupsWithoutCollections.Count() > 0) + if (groupsWithoutCollections.Any()) { var emptyGroups = groupsWithoutCollections.Select(x => new MemberAccessDetails { @@ -189,20 +187,20 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery } report.AccessDetails = userAccessDetails; - var userCiphers = - report.AccessDetails - .Where(x => x.ItemCount > 0) - .SelectMany(y => y.CollectionCipherIds) - .Distinct(); + var userCiphers = report.AccessDetails + .Where(x => x.ItemCount > 0) + .SelectMany(y => y.CollectionCipherIds) + .Distinct(); report.CipherIds = userCiphers; report.TotalItemCount = userCiphers.Count(); - // Distinct items only var distinctItems = report.AccessDetails.Where(x => x.CollectionId.HasValue).Select(x => x.CollectionId).Distinct(); report.CollectionsCount = distinctItems.Count(); report.GroupsCount = report.AccessDetails.Select(x => x.GroupId).Where(y => y.HasValue).Distinct().Count(); + memberAccessCipherDetails.Add(report); - } + }); + return memberAccessCipherDetails; } } diff --git a/src/Core/Utilities/EmailValidation.cs b/src/Core/Utilities/EmailValidation.cs new file mode 100644 index 0000000000..f6832945af --- /dev/null +++ b/src/Core/Utilities/EmailValidation.cs @@ -0,0 +1,44 @@ +using System.Text.RegularExpressions; +using MimeKit; + +namespace Bit.Core.Utilities; + +public static class EmailValidation +{ + public static bool IsValidEmail(this string emailAddress) + { + if (string.IsNullOrWhiteSpace(emailAddress)) + { + return false; + } + + try + { + var parsedEmailAddress = MailboxAddress.Parse(emailAddress).Address; + if (parsedEmailAddress != emailAddress) + { + return false; + } + } + catch (ParseException) + { + return false; + } + + // The regex below is intended to catch edge cases that are not handled by the general parsing check above. + // This enforces the following rules: + // * Requires ASCII only in the local-part (code points 0-127) + // * Requires an @ symbol + // * Allows any char in second-level domain name, including unicode and symbols + // * Requires at least one period (.) separating SLD from TLD + // * Must end in a letter (including unicode) + // See the unit tests for examples of what is allowed. + var emailFormat = @"^[\x00-\x7F]+@.+\.\p{L}+$"; + if (!Regex.IsMatch(emailAddress, emailFormat)) + { + return false; + } + + return true; + } +} diff --git a/src/Core/Utilities/EncryptedStringAttribute.cs b/src/Core/Utilities/EncryptedStringAttribute.cs index 1fe06b4f58..9c59287df6 100644 --- a/src/Core/Utilities/EncryptedStringAttribute.cs +++ b/src/Core/Utilities/EncryptedStringAttribute.cs @@ -16,6 +16,7 @@ public class EncryptedStringAttribute : ValidationAttribute [EncryptionType.AesCbc256_B64] = 2, // iv|ct [EncryptionType.AesCbc128_HmacSha256_B64] = 3, // iv|ct|mac [EncryptionType.AesCbc256_HmacSha256_B64] = 3, // iv|ct|mac + [EncryptionType.XChaCha20Poly1305_B64] = 1, // cose bytes [EncryptionType.Rsa2048_OaepSha256_B64] = 1, // rsaCt [EncryptionType.Rsa2048_OaepSha1_B64] = 1, // rsaCt [EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64] = 2, // rsaCt|mac diff --git a/src/Core/Utilities/StrictEmailAddressAttribute.cs b/src/Core/Utilities/StrictEmailAddressAttribute.cs index eeb95093d0..fce732ec9e 100644 --- a/src/Core/Utilities/StrictEmailAddressAttribute.cs +++ b/src/Core/Utilities/StrictEmailAddressAttribute.cs @@ -1,6 +1,4 @@ using System.ComponentModel.DataAnnotations; -using System.Text.RegularExpressions; -using MimeKit; namespace Bit.Core.Utilities; @@ -12,39 +10,8 @@ public class StrictEmailAddressAttribute : ValidationAttribute public override bool IsValid(object value) { - var emailAddress = value?.ToString(); - if (emailAddress == null) - { - return false; - } + var emailAddress = value?.ToString() ?? string.Empty; - try - { - var parsedEmailAddress = MailboxAddress.Parse(emailAddress).Address; - if (parsedEmailAddress != emailAddress) - { - return false; - } - } - catch (ParseException) - { - return false; - } - - // The regex below is intended to catch edge cases that are not handled by the general parsing check above. - // This enforces the following rules: - // * Requires ASCII only in the local-part (code points 0-127) - // * Requires an @ symbol - // * Allows any char in second-level domain name, including unicode and symbols - // * Requires at least one period (.) separating SLD from TLD - // * Must end in a letter (including unicode) - // See the unit tests for examples of what is allowed. - var emailFormat = @"^[\x00-\x7F]+@.+\.\p{L}+$"; - if (!Regex.IsMatch(emailAddress, emailFormat)) - { - return false; - } - - return new EmailAddressAttribute().IsValid(emailAddress); + return emailAddress.IsValidEmail() && new EmailAddressAttribute().IsValid(emailAddress); } } diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index c840a7ddc5..9360da586c 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -121,8 +121,7 @@ public class AccountsController : Controller var user = model.ToUser(); var identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash, model.Token, model.OrganizationUserId); - // delaysEnabled false is only for the new registration with email verification process - return await ProcessRegistrationResult(identityResult, user, delaysEnabled: true); + return ProcessRegistrationResult(identityResult, user); } [HttpPost("register/send-verification-email")] @@ -188,7 +187,6 @@ public class AccountsController : Controller // Users will either have an emailed token or an email verification token - not both. IdentityResult identityResult = null; - var delaysEnabled = !_featureService.IsEnabled(FeatureFlagKeys.EmailVerificationDisableTimingDelays); switch (model.GetTokenType()) { @@ -197,32 +195,32 @@ public class AccountsController : Controller await _registerUserCommand.RegisterUserViaEmailVerificationToken(user, model.MasterPasswordHash, model.EmailVerificationToken); - return await ProcessRegistrationResult(identityResult, user, delaysEnabled); + return ProcessRegistrationResult(identityResult, user); break; case RegisterFinishTokenType.OrganizationInvite: identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash, model.OrgInviteToken, model.OrganizationUserId); - return await ProcessRegistrationResult(identityResult, user, delaysEnabled); + return ProcessRegistrationResult(identityResult, user); break; case RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan: identityResult = await _registerUserCommand.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, model.MasterPasswordHash, model.OrgSponsoredFreeFamilyPlanToken); - return await ProcessRegistrationResult(identityResult, user, delaysEnabled); + return ProcessRegistrationResult(identityResult, user); break; case RegisterFinishTokenType.EmergencyAccessInvite: Debug.Assert(model.AcceptEmergencyAccessId.HasValue); identityResult = await _registerUserCommand.RegisterUserViaAcceptEmergencyAccessInviteToken(user, model.MasterPasswordHash, model.AcceptEmergencyAccessInviteToken, model.AcceptEmergencyAccessId.Value); - return await ProcessRegistrationResult(identityResult, user, delaysEnabled); + return ProcessRegistrationResult(identityResult, user); break; case RegisterFinishTokenType.ProviderInvite: Debug.Assert(model.ProviderUserId.HasValue); identityResult = await _registerUserCommand.RegisterUserViaProviderInviteToken(user, model.MasterPasswordHash, model.ProviderInviteToken, model.ProviderUserId.Value); - return await ProcessRegistrationResult(identityResult, user, delaysEnabled); + return ProcessRegistrationResult(identityResult, user); break; default: @@ -230,7 +228,7 @@ public class AccountsController : Controller } } - private async Task ProcessRegistrationResult(IdentityResult result, User user, bool delaysEnabled) + private RegisterResponseModel ProcessRegistrationResult(IdentityResult result, User user) { if (result.Succeeded) { @@ -243,10 +241,6 @@ public class AccountsController : Controller ModelState.AddModelError(string.Empty, error.Description); } - if (delaysEnabled) - { - await Task.Delay(Random.Shared.Next(100, 130)); - } throw new BadRequestException(ModelState); } diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs index 07b55aa44a..8968d1d243 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.KeyManagement.UserKey; @@ -580,4 +581,32 @@ public class OrganizationUserRepository : Repository, IO return results.ToList(); } } + + public async Task CreateManyAsync(IEnumerable organizationUserCollection) + { + await using var connection = new SqlConnection(_marsConnectionString); + + await connection.ExecuteAsync( + $"[{Schema}].[OrganizationUser_CreateManyWithCollectionsAndGroups]", + new + { + OrganizationUserData = JsonSerializer.Serialize(organizationUserCollection.Select(x => x.OrganizationUser)), + CollectionData = JsonSerializer.Serialize(organizationUserCollection + .SelectMany(x => x.Collections, (user, collection) => new CollectionUser + { + CollectionId = collection.Id, + OrganizationUserId = user.OrganizationUser.Id, + ReadOnly = collection.ReadOnly, + HidePasswords = collection.HidePasswords, + Manage = collection.Manage + })), + GroupData = JsonSerializer.Serialize(organizationUserCollection + .SelectMany(x => x.Groups, (user, group) => new GroupUser + { + GroupId = group, + OrganizationUserId = user.OrganizationUser.Id + })) + }, + commandType: CommandType.StoredProcedure); + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationIntegrationConfigurationEntityTypeConfiguration.cs b/src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationIntegrationConfigurationEntityTypeConfiguration.cs index 29712f5e38..a5df0845bd 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationIntegrationConfigurationEntityTypeConfiguration.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationIntegrationConfigurationEntityTypeConfiguration.cs @@ -9,7 +9,7 @@ public class OrganizationIntegrationConfigurationEntityTypeConfiguration : IEnti public void Configure(EntityTypeBuilder builder) { builder - .Property(p => p.Id) + .Property(oic => oic.Id) .ValueGeneratedNever(); builder.ToTable(nameof(OrganizationIntegrationConfiguration)); diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationIntegrationEntityTypeConfiguration.cs b/src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationIntegrationEntityTypeConfiguration.cs index c2134c1b7d..3434d735d0 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationIntegrationEntityTypeConfiguration.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationIntegrationEntityTypeConfiguration.cs @@ -9,15 +9,15 @@ public class OrganizationIntegrationEntityTypeConfiguration : IEntityTypeConfigu public void Configure(EntityTypeBuilder builder) { builder - .Property(p => p.Id) + .Property(oi => oi.Id) .ValueGeneratedNever(); builder - .HasIndex(p => p.OrganizationId) + .HasIndex(oi => oi.OrganizationId) .IsClustered(false); builder - .HasIndex(p => new { p.OrganizationId, p.Type }) + .HasIndex(oi => new { oi.OrganizationId, oi.Type }) .IsUnique() .IsClustered(false); diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index c095b07030..a5f4f0bd9d 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -199,6 +199,8 @@ public class OrganizationRepository : Repository po.OrganizationId == organization.Id) .ExecuteDeleteAsync(); + await dbContext.OrganizationIntegrations.Where(oi => oi.OrganizationId == organization.Id) + .ExecuteDeleteAsync(); await dbContext.GroupServiceAccountAccessPolicy.Where(ap => ap.GrantedServiceAccount.OrganizationId == organization.Id) .ExecuteDeleteAsync(); diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs index 28e2f1a9e4..10d92357fe 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -1,5 +1,6 @@ using AutoMapper; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.Enums; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Models.Data; @@ -757,4 +758,28 @@ public class OrganizationUserRepository : Repository organizationUserCollection) + { + using var scope = ServiceScopeFactory.CreateScope(); + + await using var dbContext = GetDatabaseContext(scope); + + dbContext.OrganizationUsers.AddRange(Mapper.Map>(organizationUserCollection.Select(x => x.OrganizationUser))); + dbContext.CollectionUsers.AddRange(organizationUserCollection.SelectMany(x => x.Collections, (user, collection) => new CollectionUser + { + CollectionId = collection.Id, + HidePasswords = collection.HidePasswords, + OrganizationUserId = user.OrganizationUser.Id, + Manage = collection.Manage, + ReadOnly = collection.ReadOnly + })); + dbContext.GroupUsers.AddRange(organizationUserCollection.SelectMany(x => x.Groups, (user, group) => new GroupUser + { + GroupId = group, + OrganizationUserId = user.OrganizationUser.Id + })); + + await dbContext.SaveChangesAsync(); + } } diff --git a/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfiguration_Create.sql b/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfiguration_Create.sql new file mode 100644 index 0000000000..18160c9ea9 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfiguration_Create.sql @@ -0,0 +1,33 @@ +CREATE PROCEDURE [dbo].[OrganizationIntegrationConfiguration_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationIntegrationId UNIQUEIDENTIFIER, + @EventType SMALLINT, + @Configuration VARCHAR(MAX), + @Template VARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OrganizationIntegrationConfiguration] + ( + [Id], + [OrganizationIntegrationId], + [EventType], + [Configuration], + [Template], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @OrganizationIntegrationId, + @EventType, + @Configuration, + @Template, + @CreationDate, + @RevisionDate + ) +END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfiguration_DeleteById.sql b/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfiguration_DeleteById.sql new file mode 100644 index 0000000000..6e3c9ab83c --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfiguration_DeleteById.sql @@ -0,0 +1,12 @@ +CREATE PROCEDURE [dbo].[OrganizationIntegrationConfiguration_DeleteById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + DELETE + FROM + [dbo].[OrganizationIntegrationConfiguration] + WHERE + [Id] = @Id +END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfiguration_ReadById.sql b/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfiguration_ReadById.sql new file mode 100644 index 0000000000..2db9ba3bf8 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfiguration_ReadById.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[OrganizationIntegrationConfiguration_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[OrganizationIntegrationConfiguration] + WHERE + [Id] = @Id +END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfiguration_Update.sql b/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfiguration_Update.sql new file mode 100644 index 0000000000..b613774d04 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfiguration_Update.sql @@ -0,0 +1,24 @@ +CREATE PROCEDURE [dbo].[OrganizationIntegrationConfiguration_Update] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationIntegrationId UNIQUEIDENTIFIER, + @EventType SMALLINT, + @Configuration VARCHAR(MAX), + @Template VARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[OrganizationIntegrationConfiguration] + SET + [OrganizationIntegrationId] = @OrganizationIntegrationId, + [EventType] = @EventType, + [Configuration] = @Configuration, + [Template] = @Template, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationIntegration_Create.sql b/src/Sql/dbo/Stored Procedures/OrganizationIntegration_Create.sql new file mode 100644 index 0000000000..fe8ed7559b --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationIntegration_Create.sql @@ -0,0 +1,30 @@ +CREATE PROCEDURE [dbo].[OrganizationIntegration_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @Type SMALLINT, + @Configuration VARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OrganizationIntegration] + ( + [Id], + [OrganizationId], + [Type], + [Configuration], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @OrganizationId, + @Type, + @Configuration, + @CreationDate, + @RevisionDate + ) +END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationIntegration_DeleteById.sql b/src/Sql/dbo/Stored Procedures/OrganizationIntegration_DeleteById.sql new file mode 100644 index 0000000000..2ade1c074e --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationIntegration_DeleteById.sql @@ -0,0 +1,12 @@ +CREATE PROCEDURE [dbo].[OrganizationIntegration_DeleteById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + DELETE + FROM + [dbo].[OrganizationIntegration] + WHERE + [Id] = @Id +END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationIntegration_OrganizationDeleted.sql b/src/Sql/dbo/Stored Procedures/OrganizationIntegration_OrganizationDeleted.sql new file mode 100644 index 0000000000..2cdd77edc2 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationIntegration_OrganizationDeleted.sql @@ -0,0 +1,12 @@ +CREATE PROCEDURE [dbo].[OrganizationIntegration_OrganizationDeleted] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + DELETE + FROM + [dbo].[OrganizationIntegration] + WHERE + [OrganizationId] = @OrganizationId +END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationIntegration_ReadById.sql b/src/Sql/dbo/Stored Procedures/OrganizationIntegration_ReadById.sql new file mode 100644 index 0000000000..352c26bb24 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationIntegration_ReadById.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[OrganizationIntegration_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[OrganizationIntegration] + WHERE + [Id] = @Id +END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationIntegration_Update.sql b/src/Sql/dbo/Stored Procedures/OrganizationIntegration_Update.sql new file mode 100644 index 0000000000..8dd91cc3d7 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationIntegration_Update.sql @@ -0,0 +1,22 @@ +CREATE PROCEDURE [dbo].[OrganizationIntegration_Update] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @Type SMALLINT, + @Configuration VARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[OrganizationIntegration] + SET + [OrganizationId] = @OrganizationId, + [Type] = @Type, + [Configuration] = @Configuration, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql new file mode 100644 index 0000000000..78ff2933f6 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql @@ -0,0 +1,97 @@ +CREATE PROCEDURE [dbo].[OrganizationUser_CreateManyWithCollectionsAndGroups] + @organizationUserData NVARCHAR(MAX), + @collectionData NVARCHAR(MAX), + @groupData NVARCHAR(MAX) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OrganizationUser] + ( + [Id], + [OrganizationId], + [UserId], + [Email], + [Key], + [Status], + [Type], + [ExternalId], + [CreationDate], + [RevisionDate], + [Permissions], + [ResetPasswordKey], + [AccessSecretsManager] + ) + SELECT + OUI.[Id], + OUI.[OrganizationId], + OUI.[UserId], + OUI.[Email], + OUI.[Key], + OUI.[Status], + OUI.[Type], + OUI.[ExternalId], + OUI.[CreationDate], + OUI.[RevisionDate], + OUI.[Permissions], + OUI.[ResetPasswordKey], + OUI.[AccessSecretsManager] + FROM + OPENJSON(@organizationUserData) + WITH ( + [Id] UNIQUEIDENTIFIER '$.Id', + [OrganizationId] UNIQUEIDENTIFIER '$.OrganizationId', + [UserId] UNIQUEIDENTIFIER '$.UserId', + [Email] NVARCHAR(256) '$.Email', + [Key] VARCHAR(MAX) '$.Key', + [Status] SMALLINT '$.Status', + [Type] TINYINT '$.Type', + [ExternalId] NVARCHAR(300) '$.ExternalId', + [CreationDate] DATETIME2(7) '$.CreationDate', + [RevisionDate] DATETIME2(7) '$.RevisionDate', + [Permissions] NVARCHAR (MAX) '$.Permissions', + [ResetPasswordKey] VARCHAR (MAX) '$.ResetPasswordKey', + [AccessSecretsManager] BIT '$.AccessSecretsManager' + ) OUI + + INSERT INTO [dbo].[GroupUser] + ( + [OrganizationUserId], + [GroupId] + ) + SELECT + OUG.OrganizationUserId, + OUG.GroupId + FROM + OPENJSON(@groupData) + WITH( + [OrganizationUserId] UNIQUEIDENTIFIER '$.OrganizationUserId', + [GroupId] UNIQUEIDENTIFIER '$.GroupId' + ) OUG + + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + OUC.[CollectionId], + OUC.[OrganizationUserId], + OUC.[ReadOnly], + OUC.[HidePasswords], + OUC.[Manage] + FROM + OPENJSON(@collectionData) + WITH( + [CollectionId] UNIQUEIDENTIFIER '$.CollectionId', + [OrganizationUserId] UNIQUEIDENTIFIER '$.OrganizationUserId', + [ReadOnly] BIT '$.ReadOnly', + [HidePasswords] BIT '$.HidePasswords', + [Manage] BIT '$.Manage' + ) OUC +END +go + diff --git a/src/Sql/dbo/Stored Procedures/Organization_DeleteById.sql b/src/Sql/dbo/Stored Procedures/Organization_DeleteById.sql index e0b7d47469..2daa12209f 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_DeleteById.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_DeleteById.sql @@ -45,11 +45,11 @@ BEGIN [OrganizationId] = @Id DELETE CU - FROM + FROM [dbo].[CollectionUser] CU - INNER JOIN + INNER JOIN [dbo].[OrganizationUser] OU ON [CU].[OrganizationUserId] = [OU].[Id] - WHERE + WHERE [OU].[OrganizationId] = @Id DELETE AP @@ -69,9 +69,9 @@ BEGIN [OU].[OrganizationId] = @Id DELETE - FROM + FROM [dbo].[OrganizationUser] - WHERE + WHERE [OrganizationId] = @Id DELETE @@ -84,6 +84,7 @@ BEGIN EXEC [dbo].[OrganizationConnection_OrganizationDeleted] @Id EXEC [dbo].[OrganizationSponsorship_OrganizationDeleted] @Id EXEC [dbo].[OrganizationDomain_OrganizationDeleted] @Id + EXEC [dbo].[OrganizationIntegration_OrganizationDeleted] @Id DELETE FROM @@ -128,14 +129,14 @@ BEGIN [dbo].[Notification] N ON N.[Id] = NS.[NotificationId] WHERE N.[OrganizationId] = @Id - + -- Delete Notification DELETE FROM [dbo].[Notification] WHERE [OrganizationId] = @Id - + DELETE FROM [dbo].[Organization] diff --git a/src/Sql/dbo/Tables/OrganizationIntegrationConfiguration.sql b/src/Sql/dbo/Tables/OrganizationIntegrationConfiguration.sql index 9dbb2341a7..28283f8015 100644 --- a/src/Sql/dbo/Tables/OrganizationIntegrationConfiguration.sql +++ b/src/Sql/dbo/Tables/OrganizationIntegrationConfiguration.sql @@ -8,6 +8,6 @@ CREATE TABLE [dbo].[OrganizationIntegrationConfiguration] [CreationDate] DATETIME2 (7) NOT NULL, [RevisionDate] DATETIME2 (7) NOT NULL, CONSTRAINT [PK_OrganizationIntegrationConfiguration] PRIMARY KEY CLUSTERED ([Id] ASC), - CONSTRAINT [FK_OrganizationIntegrationConfiguration_OrganizationIntegration] FOREIGN KEY ([OrganizationIntegrationId]) REFERENCES [dbo].[OrganizationIntegration] ([Id]) + CONSTRAINT [FK_OrganizationIntegrationConfiguration_OrganizationIntegration] FOREIGN KEY ([OrganizationIntegrationId]) REFERENCES [dbo].[OrganizationIntegration] ([Id]) ON DELETE CASCADE ); GO diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index a19560ecee..107b9cdfb1 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -260,14 +260,15 @@ public class OrganizationUsersControllerTests .GetDetailsByIdWithCollectionsAsync(organizationUser.Id) .Returns((organizationUser, collections)); - sutProvider.GetDependency() - .GetUsersOrganizationManagementStatusAsync(organizationUser.OrganizationId, Arg.Is>(ids => ids.Contains(organizationUser.Id))) + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync(organizationUser.OrganizationId, Arg.Is>(ids => ids.Contains(organizationUser.Id))) .Returns(new Dictionary { { organizationUser.Id, true } }); var response = await sutProvider.Sut.Get(organizationUser.Id, false); Assert.Equal(organizationUser.Id, response.Id); Assert.Equal(accountDeprovisioningEnabled, response.ManagedByOrganization); + Assert.Equal(accountDeprovisioningEnabled, response.ClaimedByOrganization); } [Theory] @@ -331,7 +332,7 @@ public class OrganizationUsersControllerTests await sutProvider.Sut.DeleteAccount(orgId, id); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) .DeleteUserAsync(orgId, id, currentUser.Id); } @@ -367,7 +368,7 @@ public class OrganizationUsersControllerTests { sutProvider.GetDependency().ManageUsers(orgId).Returns(true); sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(currentUser); - sutProvider.GetDependency() + sutProvider.GetDependency() .DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id) .Returns(deleteResults); @@ -375,7 +376,7 @@ public class OrganizationUsersControllerTests Assert.Equal(deleteResults.Count, response.Data.Count()); Assert.True(response.Data.All(r => deleteResults.Any(res => res.Item1 == r.Id && res.Item2 == r.Error))); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) .DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id); } diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 8e6d2ce27b..3c06c78392 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -138,7 +138,7 @@ public class OrganizationsControllerTests : IDisposable _ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); - _userService.GetOrganizationsManagingUserAsync(user.Id).Returns(new List { null }); + _userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List { null }); var exception = await Assert.ThrowsAsync(() => _sut.Leave(orgId)); Assert.Contains("Your organization's Single Sign-On settings prevent you from leaving.", @@ -168,10 +168,10 @@ public class OrganizationsControllerTests : IDisposable _ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); - _userService.GetOrganizationsManagingUserAsync(user.Id).Returns(new List { { foundOrg } }); + _userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List { { foundOrg } }); var exception = await Assert.ThrowsAsync(() => _sut.Leave(orgId)); - Assert.Contains("Managed user account cannot leave managing organization. Contact your organization administrator for additional details.", + Assert.Contains("Claimed user account cannot leave claiming organization. Contact your organization administrator for additional details.", exception.Message); await _removeOrganizationUserCommand.DidNotReceiveWithAnyArgs().RemoveUserAsync(default, default); @@ -203,7 +203,7 @@ public class OrganizationsControllerTests : IDisposable _ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); - _userService.GetOrganizationsManagingUserAsync(user.Id).Returns(new List()); + _userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List()); await _sut.Leave(orgId); diff --git a/test/Api.Test/AdminConsole/Public/Models/Response/MemberResponseModelTests.cs b/test/Api.Test/AdminConsole/Public/Models/Response/MemberResponseModelTests.cs index a9193258b8..468e7850fb 100644 --- a/test/Api.Test/AdminConsole/Public/Models/Response/MemberResponseModelTests.cs +++ b/test/Api.Test/AdminConsole/Public/Models/Response/MemberResponseModelTests.cs @@ -25,11 +25,16 @@ public class MemberResponseModelTests Assert.True(sut.ResetPasswordEnrolled); } - [Fact] - public void ResetPasswordEnrolled_ShouldBeFalse_WhenUserDoesNotHaveResetPasswordKey() + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ResetPasswordEnrolled_ShouldBeFalse_WhenResetPasswordKeyIsInvalid(string? resetPasswordKey) { // Arrange var user = Substitute.For(); + user.ResetPasswordKey = resetPasswordKey; + var collections = Substitute.For>(); // Act diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index 15c7573aca..bd22fd9346 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -120,7 +120,7 @@ public class AccountsControllerTests : IDisposable ConfigureUserServiceToReturnValidPrincipalFor(user); ConfigureUserServiceToAcceptPasswordFor(user); const string newEmail = "example@user.com"; - _userService.ValidateManagedUserDomainAsync(user, newEmail).Returns(IdentityResult.Success); + _userService.ValidateClaimedUserDomainAsync(user, newEmail).Returns(IdentityResult.Success); // Act await _sut.PostEmailToken(new EmailTokenRequestModel { NewEmail = newEmail }); @@ -130,7 +130,7 @@ public class AccountsControllerTests : IDisposable } [Fact] - public async Task PostEmailToken_WhenValidateManagedUserDomainAsyncFails_ShouldReturnError() + public async Task PostEmailToken_WhenValidateClaimedUserDomainAsyncFails_ShouldReturnError() { // Arrange var user = GenerateExampleUser(); @@ -139,7 +139,7 @@ public class AccountsControllerTests : IDisposable const string newEmail = "example@user.com"; - _userService.ValidateManagedUserDomainAsync(user, newEmail) + _userService.ValidateClaimedUserDomainAsync(user, newEmail) .Returns(IdentityResult.Failed(new IdentityError { Code = "TestFailure", @@ -197,7 +197,7 @@ public class AccountsControllerTests : IDisposable _userService.ChangeEmailAsync(user, default, default, default, default, default) .Returns(Task.FromResult(IdentityResult.Success)); _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); - _userService.IsManagedByAnyOrganizationAsync(user.Id).Returns(false); + _userService.IsClaimedByAnyOrganizationAsync(user.Id).Returns(false); await _sut.PostEmail(new EmailRequestModel()); @@ -539,7 +539,7 @@ public class AccountsControllerTests : IDisposable ConfigureUserServiceToReturnValidPrincipalFor(user); ConfigureUserServiceToAcceptPasswordFor(user); _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); - _userService.IsManagedByAnyOrganizationAsync(user.Id).Returns(true); + _userService.IsClaimedByAnyOrganizationAsync(user.Id).Returns(true); var result = await Assert.ThrowsAsync(() => _sut.Delete(new SecretVerificationRequestModel())); @@ -553,7 +553,7 @@ public class AccountsControllerTests : IDisposable ConfigureUserServiceToReturnValidPrincipalFor(user); ConfigureUserServiceToAcceptPasswordFor(user); _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); - _userService.IsManagedByAnyOrganizationAsync(user.Id).Returns(false); + _userService.IsClaimedByAnyOrganizationAsync(user.Id).Returns(false); _userService.DeleteAsync(user).Returns(IdentityResult.Success); await _sut.Delete(new SecretVerificationRequestModel()); diff --git a/test/Billing.Test/Services/SubscriptionDeletedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionDeletedHandlerTests.cs new file mode 100644 index 0000000000..2797b2e589 --- /dev/null +++ b/test/Billing.Test/Services/SubscriptionDeletedHandlerTests.cs @@ -0,0 +1,176 @@ +using Bit.Billing.Constants; +using Bit.Billing.Services; +using Bit.Billing.Services.Implementations; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Services; +using NSubstitute; +using Stripe; +using Xunit; + +namespace Bit.Billing.Test.Services; + +public class SubscriptionDeletedHandlerTests +{ + private readonly IStripeEventService _stripeEventService; + private readonly IUserService _userService; + private readonly IStripeEventUtilityService _stripeEventUtilityService; + private readonly IOrganizationDisableCommand _organizationDisableCommand; + private readonly SubscriptionDeletedHandler _sut; + + public SubscriptionDeletedHandlerTests() + { + _stripeEventService = Substitute.For(); + _userService = Substitute.For(); + _stripeEventUtilityService = Substitute.For(); + _organizationDisableCommand = Substitute.For(); + _sut = new SubscriptionDeletedHandler( + _stripeEventService, + _userService, + _stripeEventUtilityService, + _organizationDisableCommand); + } + + [Fact] + public async Task HandleAsync_SubscriptionNotCanceled_DoesNothing() + { + // Arrange + var stripeEvent = new Event(); + var subscription = new Subscription + { + Status = "active", + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30), + Metadata = new Dictionary() + }; + + _stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(null, null, null)); + + // Act + await _sut.HandleAsync(stripeEvent); + + // Assert + await _organizationDisableCommand.DidNotReceiveWithAnyArgs().DisableAsync(default, default); + await _userService.DidNotReceiveWithAnyArgs().DisablePremiumAsync(default, default); + } + + [Fact] + public async Task HandleAsync_OrganizationSubscriptionCanceled_DisablesOrganization() + { + // Arrange + var stripeEvent = new Event(); + var organizationId = Guid.NewGuid(); + var subscription = new Subscription + { + Status = StripeSubscriptionStatus.Canceled, + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30), + Metadata = new Dictionary + { + { "organizationId", organizationId.ToString() } + } + }; + + _stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(organizationId, null, null)); + + // Act + await _sut.HandleAsync(stripeEvent); + + // Assert + await _organizationDisableCommand.Received(1) + .DisableAsync(organizationId, subscription.CurrentPeriodEnd); + } + + [Fact] + public async Task HandleAsync_UserSubscriptionCanceled_DisablesUserPremium() + { + // Arrange + var stripeEvent = new Event(); + var userId = Guid.NewGuid(); + var subscription = new Subscription + { + Status = StripeSubscriptionStatus.Canceled, + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30), + Metadata = new Dictionary + { + { "userId", userId.ToString() } + } + }; + + _stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(null, userId, null)); + + // Act + await _sut.HandleAsync(stripeEvent); + + // Assert + await _userService.Received(1) + .DisablePremiumAsync(userId, subscription.CurrentPeriodEnd); + } + + [Fact] + public async Task HandleAsync_ProviderMigrationCancellation_DoesNotDisableOrganization() + { + // Arrange + var stripeEvent = new Event(); + var organizationId = Guid.NewGuid(); + var subscription = new Subscription + { + Status = StripeSubscriptionStatus.Canceled, + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30), + Metadata = new Dictionary + { + { "organizationId", organizationId.ToString() } + }, + CancellationDetails = new SubscriptionCancellationDetails + { + Comment = "Cancelled as part of provider migration to Consolidated Billing" + } + }; + + _stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(organizationId, null, null)); + + // Act + await _sut.HandleAsync(stripeEvent); + + // Assert + await _organizationDisableCommand.DidNotReceiveWithAnyArgs() + .DisableAsync(default, default); + } + + [Fact] + public async Task HandleAsync_AddedToProviderCancellation_DoesNotDisableOrganization() + { + // Arrange + var stripeEvent = new Event(); + var organizationId = Guid.NewGuid(); + var subscription = new Subscription + { + Status = StripeSubscriptionStatus.Canceled, + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30), + Metadata = new Dictionary + { + { "organizationId", organizationId.ToString() } + }, + CancellationDetails = new SubscriptionCancellationDetails + { + Comment = "Organization was added to Provider" + } + }; + + _stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(organizationId, null, null)); + + // Act + await _sut.HandleAsync(stripeEvent); + + // Assert + await _organizationDisableCommand.DidNotReceiveWithAnyArgs() + .DisableAsync(default, default); + } +} diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs new file mode 100644 index 0000000000..a6ac7e9512 --- /dev/null +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -0,0 +1,361 @@ +using Bit.Billing.Constants; +using Bit.Billing.Services; +using Bit.Billing.Services.Implementations; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models.StaticStore.Plans; +using Bit.Core.Billing.Pricing; +using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Newtonsoft.Json.Linq; +using NSubstitute; +using Quartz; +using Stripe; +using Xunit; +using Event = Stripe.Event; + +namespace Bit.Billing.Test.Services; + +public class SubscriptionUpdatedHandlerTests +{ + private readonly IStripeEventService _stripeEventService; + private readonly IStripeEventUtilityService _stripeEventUtilityService; + private readonly IOrganizationService _organizationService; + private readonly IStripeFacade _stripeFacade; + private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand; + private readonly IUserService _userService; + private readonly IPushNotificationService _pushNotificationService; + private readonly IOrganizationRepository _organizationRepository; + private readonly ISchedulerFactory _schedulerFactory; + private readonly IFeatureService _featureService; + private readonly IOrganizationEnableCommand _organizationEnableCommand; + private readonly IOrganizationDisableCommand _organizationDisableCommand; + private readonly IPricingClient _pricingClient; + private readonly IScheduler _scheduler; + private readonly SubscriptionUpdatedHandler _sut; + + public SubscriptionUpdatedHandlerTests() + { + _stripeEventService = Substitute.For(); + _stripeEventUtilityService = Substitute.For(); + _organizationService = Substitute.For(); + _stripeFacade = Substitute.For(); + _organizationSponsorshipRenewCommand = Substitute.For(); + _userService = Substitute.For(); + _pushNotificationService = Substitute.For(); + _organizationRepository = Substitute.For(); + _schedulerFactory = Substitute.For(); + _featureService = Substitute.For(); + _organizationEnableCommand = Substitute.For(); + _organizationDisableCommand = Substitute.For(); + _pricingClient = Substitute.For(); + _scheduler = Substitute.For(); + + _schedulerFactory.GetScheduler().Returns(_scheduler); + + _sut = new SubscriptionUpdatedHandler( + _stripeEventService, + _stripeEventUtilityService, + _organizationService, + _stripeFacade, + _organizationSponsorshipRenewCommand, + _userService, + _pushNotificationService, + _organizationRepository, + _schedulerFactory, + _featureService, + _organizationEnableCommand, + _organizationDisableCommand, + _pricingClient); + } + + [Fact] + public async Task HandleAsync_UnpaidOrganizationSubscription_DisablesOrganizationAndSchedulesCancellation() + { + // Arrange + var organizationId = Guid.NewGuid(); + var subscriptionId = "sub_123"; + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + var subscription = new Subscription + { + Id = subscriptionId, + Status = StripeSubscriptionStatus.Unpaid, + CurrentPeriodEnd = currentPeriodEnd, + Metadata = new Dictionary { { "organizationId", organizationId.ToString() } }, + LatestInvoice = new Invoice { BillingReason = "subscription_cycle" } + }; + + var parsedEvent = new Event { Data = new EventData() }; + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(organizationId, null, null)); + + _featureService.IsEnabled(FeatureFlagKeys.ResellerManagedOrgAlert) + .Returns(true); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _organizationDisableCommand.Received(1) + .DisableAsync(organizationId, currentPeriodEnd); + await _scheduler.Received(1).ScheduleJob( + Arg.Is(j => j.Key.Name == $"cancel-sub-{subscriptionId}"), + Arg.Is(t => t.Key.Name == $"cancel-trigger-{subscriptionId}")); + } + + [Fact] + public async Task HandleAsync_UnpaidUserSubscription_DisablesPremiumAndCancelsSubscription() + { + // Arrange + var userId = Guid.NewGuid(); + var subscriptionId = "sub_123"; + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + var subscription = new Subscription + { + Id = subscriptionId, + Status = StripeSubscriptionStatus.Unpaid, + CurrentPeriodEnd = currentPeriodEnd, + Metadata = new Dictionary { { "userId", userId.ToString() } }, + Items = new StripeList + { + Data = new List + { + new() { Price = new Price { Id = IStripeEventUtilityService.PremiumPlanId } } + } + } + }; + + var parsedEvent = new Event { Data = new EventData() }; + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); + + _stripeFacade.ListInvoices(Arg.Any()) + .Returns(new StripeList { Data = new List() }); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _userService.Received(1) + .DisablePremiumAsync(userId, currentPeriodEnd); + await _stripeFacade.Received(1) + .CancelSubscription(subscriptionId, Arg.Any()); + await _stripeFacade.Received(1) + .ListInvoices(Arg.Is(o => + o.Status == StripeInvoiceStatus.Open && o.Subscription == subscriptionId)); + } + + [Fact] + public async Task HandleAsync_ActiveOrganizationSubscription_EnablesOrganizationAndUpdatesExpiration() + { + // Arrange + var organizationId = Guid.NewGuid(); + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + var subscription = new Subscription + { + Status = StripeSubscriptionStatus.Active, + CurrentPeriodEnd = currentPeriodEnd, + Metadata = new Dictionary { { "organizationId", organizationId.ToString() } } + }; + + var organization = new Organization + { + Id = organizationId, + PlanType = PlanType.EnterpriseAnnually2023 + }; + var parsedEvent = new Event { Data = new EventData() }; + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(organizationId, null, null)); + + _organizationRepository.GetByIdAsync(organizationId) + .Returns(organization); + + _stripeFacade.ListInvoices(Arg.Any()) + .Returns(new StripeList { Data = new List { new Invoice { Id = "inv_123" } } }); + + var plan = new Enterprise2023Plan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType) + .Returns(plan); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _organizationEnableCommand.Received(1) + .EnableAsync(organizationId); + await _organizationService.Received(1) + .UpdateExpirationDateAsync(organizationId, currentPeriodEnd); + await _pushNotificationService.Received(1) + .PushSyncOrganizationStatusAsync(organization); + } + + [Fact] + public async Task HandleAsync_ActiveUserSubscription_EnablesPremiumAndUpdatesExpiration() + { + // Arrange + var userId = Guid.NewGuid(); + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + var subscription = new Subscription + { + Status = StripeSubscriptionStatus.Active, + CurrentPeriodEnd = currentPeriodEnd, + Metadata = new Dictionary { { "userId", userId.ToString() } } + }; + + var parsedEvent = new Event { Data = new EventData() }; + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _userService.Received(1) + .EnablePremiumAsync(userId, currentPeriodEnd); + await _userService.Received(1) + .UpdatePremiumExpirationAsync(userId, currentPeriodEnd); + } + + [Fact] + public async Task HandleAsync_SponsoredSubscription_RenewsSponsorship() + { + // Arrange + var organizationId = Guid.NewGuid(); + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + var subscription = new Subscription + { + Status = StripeSubscriptionStatus.Active, + CurrentPeriodEnd = currentPeriodEnd, + Metadata = new Dictionary { { "organizationId", organizationId.ToString() } } + }; + + var parsedEvent = new Event { Data = new EventData() }; + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(organizationId, null, null)); + + _stripeEventUtilityService.IsSponsoredSubscription(subscription) + .Returns(true); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _organizationSponsorshipRenewCommand.Received(1) + .UpdateExpirationDateAsync(organizationId, currentPeriodEnd); + } + + [Fact] + public async Task HandleAsync_WhenSubscriptionIsActive_AndOrganizationHasSecretsManagerTrial_AndRemovingSecretsManagerTrial_RemovesPasswordManagerCoupon() + { + // Arrange + var organizationId = Guid.NewGuid(); + var subscription = new Subscription + { + Id = "sub_123", + Status = StripeSubscriptionStatus.Active, + CurrentPeriodEnd = DateTime.UtcNow.AddDays(10), + CustomerId = "cus_123", + Items = new StripeList + { + Data = new List + { + new() { Plan = new Stripe.Plan { Id = "2023-enterprise-org-seat-annually" } } + } + }, + Customer = new Customer + { + Balance = 0, + Discount = new Discount + { + Coupon = new Coupon { Id = "sm-standalone" } + } + }, + Discount = new Discount + { + Coupon = new Coupon { Id = "sm-standalone" } + }, + Metadata = new Dictionary + { + { "organizationId", organizationId.ToString() } + } + }; + + var organization = new Organization + { + Id = organizationId, + PlanType = PlanType.EnterpriseAnnually2023 + }; + + var plan = new Enterprise2023Plan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType) + .Returns(plan); + + var parsedEvent = new Event + { + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(new + { + items = new + { + data = new[] + { + new { plan = new { id = "secrets-manager-enterprise-seat-annually" } } + } + }, + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Plan = new Stripe.Plan { Id = "secrets-manager-enterprise-seat-annually" } + } + } + } + }) + } + }; + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(organizationId, null, null)); + + _organizationRepository.GetByIdAsync(organizationId) + .Returns(organization); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _stripeFacade.Received(1).DeleteCustomerDiscount(subscription.CustomerId); + await _stripeFacade.Received(1).DeleteSubscriptionDiscount(subscription.Id); + } +} diff --git a/test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs b/test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs index db2b945fda..38c18f26f9 100644 --- a/test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs +++ b/test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs @@ -1,11 +1,13 @@ using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Bit.Core.Models.Mail; +using Bit.Core.Platform.X509ChainCustomization; using Bit.Core.Services; using Bit.Core.Settings; using MailKit.Security; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; using Rnwood.SmtpServer; using Rnwood.SmtpServer.Extensions.Auth; using Xunit.Abstractions; @@ -14,6 +16,7 @@ namespace Bit.Core.IntegrationTest; public class MailKitSmtpMailDeliveryServiceTests { + private static int _loggingConfigured; private readonly X509Certificate2 _selfSignedCert; public MailKitSmtpMailDeliveryServiceTests(ITestOutputHelper testOutputHelper) @@ -30,13 +33,14 @@ public class MailKitSmtpMailDeliveryServiceTests return certRequest.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1)); } - private static async Task SaveCertAsync(string filePath, X509Certificate2 certificate) - { - await File.WriteAllBytesAsync(filePath, certificate.Export(X509ContentType.Cert)); - } - private static void ConfigureSmtpServerLogging(ITestOutputHelper testOutputHelper) { + // The logging in SmtpServer is configured statically so if we add it for each test it duplicates + // but we cant add the logger statically either because we need ITestOutputHelper + if (Interlocked.CompareExchange(ref _loggingConfigured, 1, 0) == 0) + { + return; + } // Unfortunately this package doesn't public expose its logging infrastructure // so we use private reflection to try and access it. try @@ -100,7 +104,8 @@ public class MailKitSmtpMailDeliveryServiceTests var mailKitDeliveryService = new MailKitSmtpMailDeliveryService( globalSettings, - NullLogger.Instance + NullLogger.Instance, + Options.Create(new X509ChainOptions()) ); await Assert.ThrowsAsync( @@ -113,7 +118,7 @@ public class MailKitSmtpMailDeliveryServiceTests ); } - [Fact(Skip = "Upcoming feature")] + [Fact] public async Task SendEmailAsync_SmtpServerUsingSelfSignedCert_CertInCustomLocation_Works() { // If an SMTP server is using a self signed cert we will in the future @@ -130,12 +135,18 @@ public class MailKitSmtpMailDeliveryServiceTests gs.Mail.Smtp.Ssl = true; }); - // TODO: Setup custom location and save self signed cert there. - // await SaveCertAsync("./my-location", _selfSignedCert); + var x509ChainOptions = new X509ChainOptions + { + AdditionalCustomTrustCertificates = + [ + _selfSignedCert, + ], + }; var mailKitDeliveryService = new MailKitSmtpMailDeliveryService( globalSettings, - NullLogger.Instance + NullLogger.Instance, + Options.Create(x509ChainOptions) ); var tcs = new TaskCompletionSource(); @@ -162,7 +173,7 @@ public class MailKitSmtpMailDeliveryServiceTests await tcs.Task; } - [Fact(Skip = "Upcoming feature")] + [Fact] public async Task SendEmailAsync_SmtpServerUsingSelfSignedCert_CertInCustomLocation_WithUnrelatedCerts_Works() { // If an SMTP server is using a self signed cert we will in the future @@ -179,15 +190,19 @@ public class MailKitSmtpMailDeliveryServiceTests gs.Mail.Smtp.Ssl = true; }); - // TODO: Setup custom location and save self signed cert there - // along with another self signed cert that is not related to - // the SMTP server. - // await SaveCertAsync("./my-location", _selfSignedCert); - // await SaveCertAsync("./my-location", CreateSelfSignedCert("example.com")); + var x509ChainOptions = new X509ChainOptions + { + AdditionalCustomTrustCertificates = + [ + _selfSignedCert, + CreateSelfSignedCert("example.com"), + ], + }; var mailKitDeliveryService = new MailKitSmtpMailDeliveryService( globalSettings, - NullLogger.Instance + NullLogger.Instance, + Options.Create(x509ChainOptions) ); var tcs = new TaskCompletionSource(); @@ -234,7 +249,8 @@ public class MailKitSmtpMailDeliveryServiceTests var mailKitDeliveryService = new MailKitSmtpMailDeliveryService( globalSettings, - NullLogger.Instance + NullLogger.Instance, + Options.Create(new X509ChainOptions()) ); var tcs = new TaskCompletionSource(); @@ -280,7 +296,8 @@ public class MailKitSmtpMailDeliveryServiceTests var mailKitDeliveryService = new MailKitSmtpMailDeliveryService( globalSettings, - NullLogger.Instance + NullLogger.Instance, + Options.Create(new X509ChainOptions()) ); var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); @@ -315,7 +332,8 @@ public class MailKitSmtpMailDeliveryServiceTests var mailKitDeliveryService = new MailKitSmtpMailDeliveryService( globalSettings, - NullLogger.Instance + NullLogger.Instance, + Options.Create(new X509ChainOptions()) ); var tcs = new TaskCompletionSource(); @@ -381,7 +399,8 @@ public class MailKitSmtpMailDeliveryServiceTests var mailKitDeliveryService = new MailKitSmtpMailDeliveryService( globalSettings, - NullLogger.Instance + NullLogger.Instance, + Options.Create(new X509ChainOptions()) ); var tcs = new TaskCompletionSource(); diff --git a/test/Core.Test/AdminConsole/Models/InviteOrganizationUsersRequestTests.cs b/test/Core.Test/AdminConsole/Models/InviteOrganizationUsersRequestTests.cs new file mode 100644 index 0000000000..71b2b9766c --- /dev/null +++ b/test/Core.Test/AdminConsole/Models/InviteOrganizationUsersRequestTests.cs @@ -0,0 +1,67 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.InviteOrganizationUserErrorMessages; + +namespace Bit.Core.Test.AdminConsole.Models; + +public class InviteOrganizationUsersRequestTests +{ + [Theory] + [BitAutoData] + public void Constructor_WhenPassedInvalidEmail_ThrowsException(string email, OrganizationUserType type, Permissions permissions, string externalId) + { + var exception = Assert.Throws(() => + new OrganizationUserInvite(email, [], [], type, permissions, externalId, false)); + + Assert.Contains(InvalidEmailErrorMessage, exception.Message); + } + + [Fact] + public void Constructor_WhenPassedInvalidCollectionAccessConfiguration_ThrowsException() + { + const string validEmail = "test@email.com"; + + var invalidCollectionConfiguration = new CollectionAccessSelection + { + Manage = true, + HidePasswords = true + }; + + var exception = Assert.Throws(() => + new OrganizationUserInvite( + email: validEmail, + assignedCollections: [invalidCollectionConfiguration], + groups: [], + type: default, + permissions: new Permissions(), + externalId: string.Empty, + accessSecretsManager: false)); + + Assert.Equal(InvalidCollectionConfigurationErrorMessage, exception.Message); + } + + [Fact] + public void Constructor_WhenPassedValidArguments_ReturnsInvite() + { + const string validEmail = "test@email.com"; + var validCollectionConfiguration = new CollectionAccessSelection { Id = Guid.NewGuid(), Manage = true }; + + var invite = new OrganizationUserInvite( + email: validEmail, + assignedCollections: [validCollectionConfiguration], + groups: [], + type: default, + permissions: null, + externalId: null, + accessSecretsManager: false); + + Assert.NotNull(invite); + Assert.Contains(validEmail, invite.Email); + Assert.Contains(validCollectionConfiguration, invite.AssignedCollections); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs index 6c6d0e35f0..daa560f3bc 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs @@ -317,7 +317,7 @@ public class VerifyOrganizationDomainCommandTests _ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain); await sutProvider.GetDependency().Received().SendClaimedDomainUserEmailAsync( - Arg.Is(x => + Arg.Is(x => x.EmailList.Count(e => e.EndsWith(domain.DomainName)) == mockedUsers.Count && x.Organization.Id == organization.Id)); } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommandTests.cs similarity index 91% rename from test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommandTests.cs rename to test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommandTests.cs index f8f6bdb60d..7f1b101d7a 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommandTests.cs @@ -15,12 +15,12 @@ using Xunit; namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers; [SutProviderCustomize] -public class DeleteManagedOrganizationUserAccountCommandTests +public class DeleteClaimedOrganizationUserAccountCommandTests { [Theory] [BitAutoData] public async Task DeleteUserAsync_WithValidUser_DeletesUserAndLogsEvent( - SutProvider sutProvider, User user, Guid deletingUserId, + SutProvider sutProvider, User user, Guid deletingUserId, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) { // Arrange @@ -34,8 +34,8 @@ public class DeleteManagedOrganizationUserAccountCommandTests .GetByIdAsync(organizationUser.Id) .Returns(organizationUser); - sutProvider.GetDependency() - .GetUsersOrganizationManagementStatusAsync( + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync( organizationUser.OrganizationId, Arg.Is>(ids => ids.Contains(organizationUser.Id))) .Returns(new Dictionary { { organizationUser.Id, true } }); @@ -59,7 +59,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteUserAsync_WithUserNotFound_ThrowsException( - SutProvider sutProvider, + SutProvider sutProvider, Guid organizationId, Guid organizationUserId) { // Arrange @@ -81,7 +81,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteUserAsync_DeletingYourself_ThrowsException( - SutProvider sutProvider, + SutProvider sutProvider, User user, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser, Guid deletingUserId) @@ -110,7 +110,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteUserAsync_WhenUserIsInvited_ThrowsException( - SutProvider sutProvider, + SutProvider sutProvider, [OrganizationUser(OrganizationUserStatusType.Invited, OrganizationUserType.User)] OrganizationUser organizationUser) { // Arrange @@ -134,7 +134,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteUserAsync_WhenCustomUserDeletesAdmin_ThrowsException( - SutProvider sutProvider, User user, + SutProvider sutProvider, User user, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Admin)] OrganizationUser organizationUser, Guid deletingUserId) { @@ -166,7 +166,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteUserAsync_DeletingOwnerWhenNotOwner_ThrowsException( - SutProvider sutProvider, User user, + SutProvider sutProvider, User user, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser, Guid deletingUserId) { @@ -198,7 +198,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteUserAsync_DeletingLastConfirmedOwner_ThrowsException( - SutProvider sutProvider, User user, + SutProvider sutProvider, User user, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser, Guid deletingUserId) { @@ -237,7 +237,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteUserAsync_WithUserNotManaged_ThrowsException( - SutProvider sutProvider, User user, + SutProvider sutProvider, User user, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) { // Arrange @@ -250,8 +250,8 @@ public class DeleteManagedOrganizationUserAccountCommandTests sutProvider.GetDependency().GetByIdAsync(user.Id) .Returns(user); - sutProvider.GetDependency() - .GetUsersOrganizationManagementStatusAsync(organizationUser.OrganizationId, Arg.Any>()) + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync(organizationUser.OrganizationId, Arg.Any>()) .Returns(new Dictionary { { organizationUser.Id, false } }); // Act @@ -259,7 +259,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, null)); // Assert - Assert.Equal("Member is not managed by the organization.", exception.Message); + Assert.Equal("Member is not claimed by the organization.", exception.Message); await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); await sutProvider.GetDependency().Received(0) .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); @@ -268,7 +268,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WithValidUsers_DeletesUsersAndLogsEvents( - SutProvider sutProvider, User user1, User user2, Guid organizationId, + SutProvider sutProvider, User user1, User user2, Guid organizationId, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser1, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser2) { @@ -285,8 +285,8 @@ public class DeleteManagedOrganizationUserAccountCommandTests .GetManyAsync(Arg.Is>(ids => ids.Contains(user1.Id) && ids.Contains(user2.Id))) .Returns(new[] { user1, user2 }); - sutProvider.GetDependency() - .GetUsersOrganizationManagementStatusAsync(organizationId, Arg.Any>()) + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync(organizationId, Arg.Any>()) .Returns(new Dictionary { { orgUser1.Id, true }, { orgUser2.Id, true } }); // Act @@ -308,7 +308,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WhenUserNotFound_ReturnsErrorMessage( - SutProvider sutProvider, + SutProvider sutProvider, Guid organizationId, Guid orgUserId) { @@ -329,7 +329,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WhenDeletingYourself_ReturnsErrorMessage( - SutProvider sutProvider, + SutProvider sutProvider, User user, [OrganizationUser] OrganizationUser orgUser, Guid deletingUserId) { // Arrange @@ -358,7 +358,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WhenUserIsInvited_ReturnsErrorMessage( - SutProvider sutProvider, + SutProvider sutProvider, [OrganizationUser(OrganizationUserStatusType.Invited, OrganizationUserType.User)] OrganizationUser orgUser) { // Arrange @@ -383,7 +383,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WhenDeletingOwnerAsNonOwner_ReturnsErrorMessage( - SutProvider sutProvider, User user, + SutProvider sutProvider, User user, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUser, Guid deletingUserId) { @@ -415,7 +415,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WhenDeletingLastOwner_ReturnsErrorMessage( - SutProvider sutProvider, User user, + SutProvider sutProvider, User user, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUser, Guid deletingUserId) { @@ -453,7 +453,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WhenUserNotManaged_ReturnsErrorMessage( - SutProvider sutProvider, User user, + SutProvider sutProvider, User user, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser) { // Arrange @@ -467,8 +467,8 @@ public class DeleteManagedOrganizationUserAccountCommandTests .GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser.UserId.Value))) .Returns(new[] { user }); - sutProvider.GetDependency() - .GetUsersOrganizationManagementStatusAsync(Arg.Any(), Arg.Any>()) + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync(Arg.Any(), Arg.Any>()) .Returns(new Dictionary { { orgUser.Id, false } }); // Act @@ -477,7 +477,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests // Assert Assert.Single(result); Assert.Equal(orgUser.Id, result.First().Item1); - Assert.Contains("Member is not managed by the organization.", result.First().Item2); + Assert.Contains("Member is not claimed by the organization.", result.First().Item2); await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); await sutProvider.GetDependency().Received(0) .LogOrganizationUserEventsAsync(Arg.Any>()); @@ -486,7 +486,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_MixedValidAndInvalidUsers_ReturnsAppropriateResults( - SutProvider sutProvider, User user1, User user3, + SutProvider sutProvider, User user1, User user3, Guid organizationId, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser1, [OrganizationUser(OrganizationUserStatusType.Invited, OrganizationUserType.User)] OrganizationUser orgUser2, @@ -506,8 +506,8 @@ public class DeleteManagedOrganizationUserAccountCommandTests .GetManyAsync(Arg.Is>(ids => ids.Contains(user1.Id) && ids.Contains(user3.Id))) .Returns(new[] { user1, user3 }); - sutProvider.GetDependency() - .GetUsersOrganizationManagementStatusAsync(organizationId, Arg.Any>()) + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync(organizationId, Arg.Any>()) .Returns(new Dictionary { { orgUser1.Id, true }, { orgUser3.Id, false } }); // Act @@ -517,7 +517,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests Assert.Equal(3, results.Count()); Assert.Empty(results.First(r => r.Item1 == orgUser1.Id).Item2); Assert.Equal("You cannot delete a member with Invited status.", results.First(r => r.Item1 == orgUser2.Id).Item2); - Assert.Equal("Member is not managed by the organization.", results.First(r => r.Item1 == orgUser3.Id).Item2); + Assert.Equal("Member is not claimed by the organization.", results.First(r => r.Item1 == orgUser3.Id).Item2); await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync( Arg.Is>(events => diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersManagementStatusQueryTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersClaimedStatusQueryTests.cs similarity index 80% rename from test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersManagementStatusQueryTests.cs rename to test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersClaimedStatusQueryTests.cs index dda9867fd2..fd6d827791 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersManagementStatusQueryTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersClaimedStatusQueryTests.cs @@ -12,14 +12,14 @@ using Xunit; namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers; [SutProviderCustomize] -public class GetOrganizationUsersManagementStatusQueryTests +public class GetOrganizationUsersClaimedStatusQueryTests { [Theory, BitAutoData] public async Task GetUsersOrganizationManagementStatusAsync_WithNoUsers_ReturnsEmpty( Organization organization, - SutProvider sutProvider) + SutProvider sutProvider) { - var result = await sutProvider.Sut.GetUsersOrganizationManagementStatusAsync(organization.Id, new List()); + var result = await sutProvider.Sut.GetUsersOrganizationClaimedStatusAsync(organization.Id, new List()); Assert.Empty(result); } @@ -28,7 +28,7 @@ public class GetOrganizationUsersManagementStatusQueryTests public async Task GetUsersOrganizationManagementStatusAsync_WithUseSsoEnabled_Success( Organization organization, ICollection usersWithClaimedDomain, - SutProvider sutProvider) + SutProvider sutProvider) { organization.Enabled = true; organization.UseSso = true; @@ -44,7 +44,7 @@ public class GetOrganizationUsersManagementStatusQueryTests .GetManyByOrganizationWithClaimedDomainsAsync(organization.Id) .Returns(usersWithClaimedDomain); - var result = await sutProvider.Sut.GetUsersOrganizationManagementStatusAsync(organization.Id, userIdsToCheck); + var result = await sutProvider.Sut.GetUsersOrganizationClaimedStatusAsync(organization.Id, userIdsToCheck); Assert.All(usersWithClaimedDomain, ou => Assert.True(result[ou.Id])); Assert.False(result[userIdWithoutClaimedDomain]); @@ -54,7 +54,7 @@ public class GetOrganizationUsersManagementStatusQueryTests public async Task GetUsersOrganizationManagementStatusAsync_WithUseSsoDisabled_ReturnsAllFalse( Organization organization, ICollection usersWithClaimedDomain, - SutProvider sutProvider) + SutProvider sutProvider) { organization.Enabled = true; organization.UseSso = false; @@ -70,7 +70,7 @@ public class GetOrganizationUsersManagementStatusQueryTests .GetManyByOrganizationWithClaimedDomainsAsync(organization.Id) .Returns(usersWithClaimedDomain); - var result = await sutProvider.Sut.GetUsersOrganizationManagementStatusAsync(organization.Id, userIdsToCheck); + var result = await sutProvider.Sut.GetUsersOrganizationClaimedStatusAsync(organization.Id, userIdsToCheck); Assert.All(result, r => Assert.False(r.Value)); } @@ -79,7 +79,7 @@ public class GetOrganizationUsersManagementStatusQueryTests public async Task GetUsersOrganizationManagementStatusAsync_WithDisabledOrganization_ReturnsAllFalse( Organization organization, ICollection usersWithClaimedDomain, - SutProvider sutProvider) + SutProvider sutProvider) { organization.Enabled = false; @@ -94,7 +94,7 @@ public class GetOrganizationUsersManagementStatusQueryTests .GetManyByOrganizationWithClaimedDomainsAsync(organization.Id) .Returns(usersWithClaimedDomain); - var result = await sutProvider.Sut.GetUsersOrganizationManagementStatusAsync(organization.Id, userIdsToCheck); + var result = await sutProvider.Sut.GetUsersOrganizationClaimedStatusAsync(organization.Id, userIdsToCheck); Assert.All(result, r => Assert.False(r.Value)); } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Helpers/InviteUserOrganizationValidationRequestHelpers.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Helpers/InviteUserOrganizationValidationRequestHelpers.cs new file mode 100644 index 0000000000..f4f7cd5662 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Helpers/InviteUserOrganizationValidationRequestHelpers.cs @@ -0,0 +1,51 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; +using Bit.Core.Models.Business; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Helpers; + +public static class InviteUserOrganizationValidationRequestHelpers +{ + public static InviteOrganizationUsersValidationRequest GetInviteValidationRequestMock(InviteOrganizationUsersRequest request, + InviteOrganization inviteOrganization, Organization organization) => + new() + { + Invites = request.Invites, + InviteOrganization = inviteOrganization, + PerformedBy = Guid.Empty, + PerformedAt = request.PerformedAt, + OccupiedPmSeats = 0, + OccupiedSmSeats = 0, + PasswordManagerSubscriptionUpdate = new PasswordManagerSubscriptionUpdate(inviteOrganization, 0, 0), + SecretsManagerSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, inviteOrganization.Plan, true) + .AdjustSeats(request.Invites.Count(x => x.AccessSecretsManager)) + }; + + public static InviteOrganizationUsersValidationRequest WithPasswordManagerUpdate(this InviteOrganizationUsersValidationRequest request, PasswordManagerSubscriptionUpdate passwordManagerSubscriptionUpdate) => + new() + { + Invites = request.Invites, + InviteOrganization = request.InviteOrganization, + PerformedBy = request.PerformedBy, + PerformedAt = request.PerformedAt, + OccupiedPmSeats = request.OccupiedPmSeats, + OccupiedSmSeats = request.OccupiedSmSeats, + PasswordManagerSubscriptionUpdate = passwordManagerSubscriptionUpdate, + SecretsManagerSubscriptionUpdate = request.SecretsManagerSubscriptionUpdate + }; + + public static InviteOrganizationUsersValidationRequest WithSecretsManagerUpdate(this InviteOrganizationUsersValidationRequest request, SecretsManagerSubscriptionUpdate secretsManagerSubscriptionUpdate) => + new() + { + Invites = request.Invites, + InviteOrganization = request.InviteOrganization, + PerformedBy = request.PerformedBy, + PerformedAt = request.PerformedAt, + OccupiedPmSeats = request.OccupiedPmSeats, + OccupiedSmSeats = request.OccupiedSmSeats, + PasswordManagerSubscriptionUpdate = request.PasswordManagerSubscriptionUpdate, + SecretsManagerSubscriptionUpdate = secretsManagerSubscriptionUpdate + }; +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs new file mode 100644 index 0000000000..ba7605d682 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs @@ -0,0 +1,613 @@ +using System.Net.Mail; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.Billing.Models.StaticStore.Plans; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Business; +using Bit.Core.Models.Commands; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Models.StaticStore; +using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; +using static Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Helpers.InviteUserOrganizationValidationRequestHelpers; +using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +[SutProviderCustomize] +public class InviteOrganizationUserCommandTests +{ + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenEmailAlreadyExists_ThenNoInviteIsSentAndNoSeatsAreAdjusted( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + + var inviteOrganization = new InviteOrganization(organization, new FreePlan()); + + var request = new InviteOrganizationUsersRequest( + invites: [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + sutProvider.GetDependency() + .SelectKnownEmailsAsync(organization.Id, Arg.Any>(), false) + .Returns([user.Email]); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid(GetInviteValidationRequestMock(request, inviteOrganization, organization))); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + Assert.Equal(NoUsersToInviteError.Code, (result as Failure).ErrorMessage); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .AdjustSeatsAsync(Arg.Any(), Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendInvitesAsync(Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateSubscriptionAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenEmailDoesNotExistAndRequestIsValid_ThenUserIsSavedAndInviteIsSent( + MailAddress address, + Organization organization, + OrganizationUser orgUser, + FakeTimeProvider timeProvider, + string externalId, + SutProvider sutProvider) + { + // Arrange + orgUser.Email = address.Address; + + var inviteOrganization = new InviteOrganization(organization, new FreePlan()); + + var request = new InviteOrganizationUsersRequest( + invites: [ + new OrganizationUserInvite( + email: orgUser.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + sutProvider.GetDependency() + .SelectKnownEmailsAsync(organization.Id, Arg.Any>(), false) + .Returns([]); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid(GetInviteValidationRequestMock(request, inviteOrganization, organization))); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + + await sutProvider.GetDependency() + .Received(1) + .CreateManyAsync(Arg.Is>(users => + users.Any(user => user.OrganizationUser.Email == request.Invites.First().Email))); + + await sutProvider.GetDependency() + .Received(1) + .SendInvitesAsync(Arg.Is(invite => + invite.Organization == organization && + invite.Users.Count(x => x.Email == orgUser.Email) == 1)); + } + + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenEmailIsNewAndRequestIsInvalid_ThenFailureIsReturnedWithValidationFailureReason( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + SutProvider sutProvider) + { + // Arrange + const string errorMessage = "Org cannot add user for some given reason"; + + user.Email = address.Address; + + var inviteOrganization = new InviteOrganization(organization, new FreePlan()); + + var request = new InviteOrganizationUsersRequest( + invites: [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var validationRequest = GetInviteValidationRequestMock(request, inviteOrganization, organization); + + sutProvider.GetDependency() + .SelectKnownEmailsAsync(organization.Id, Arg.Any>(), false) + .Returns([]); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Invalid( + new Error(errorMessage, validationRequest))); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + var failure = result as Failure; + + Assert.Equal(errorMessage, failure!.ErrorMessage); + + await sutProvider.GetDependency() + .DidNotReceive() + .CreateManyAsync(Arg.Any>()); + + await sutProvider.GetDependency() + .DidNotReceive() + .SendInvitesAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenValidInviteCausesOrganizationToReachMaxSeats_ThenOrganizationOwnersShouldBeNotified( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + OrganizationUserUserDetails ownerDetails, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + organization.Seats = 1; + organization.MaxAutoscaleSeats = 2; + ownerDetails.Type = OrganizationUserType.Owner; + + var inviteOrganization = new InviteOrganization(organization, new FreePlan()); + + var request = new InviteOrganizationUsersRequest( + invites: [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository + .SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false) + .Returns([]); + orgUserRepository + .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) + .Returns([ownerDetails]); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid(GetInviteValidationRequestMock(request, inviteOrganization, organization) + .WithPasswordManagerUpdate(new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1)))); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + + Assert.NotNull(inviteOrganization.MaxAutoScaleSeats); + + await sutProvider.GetDependency() + .Received(1) + .SendOrganizationMaxSeatLimitReachedEmailAsync(organization, + inviteOrganization.MaxAutoScaleSeats.Value, + Arg.Is>(emails => emails.Any(email => email == ownerDetails.Email))); + } + + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenValidInviteIncreasesSeats_ThenSeatTotalShouldBeUpdated( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + OrganizationUserUserDetails ownerDetails, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + organization.Seats = 1; + organization.MaxAutoscaleSeats = 2; + ownerDetails.Type = OrganizationUserType.Owner; + + var inviteOrganization = new InviteOrganization(organization, new FreePlan()); + + var request = new InviteOrganizationUsersRequest( + invites: [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var passwordManagerUpdate = new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1); + + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository + .SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false) + .Returns([]); + orgUserRepository + .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) + .Returns([ownerDetails]); + + var orgRepository = sutProvider.GetDependency(); + + orgRepository.GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid(GetInviteValidationRequestMock(request, inviteOrganization, organization) + .WithPasswordManagerUpdate(passwordManagerUpdate))); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + + await sutProvider.GetDependency() + .AdjustSeatsAsync(organization, inviteOrganization.Plan, passwordManagerUpdate.SeatsRequiredToAdd); + + await orgRepository.Received(1).ReplaceAsync(Arg.Is(x => x.Seats == passwordManagerUpdate.UpdatedSeatTotal)); + + await sutProvider.GetDependency() + .Received(1) + .UpsertOrganizationAbilityAsync(Arg.Is(x => x.Seats == passwordManagerUpdate.UpdatedSeatTotal)); + } + + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenValidInviteIncreasesSecretsManagerSeats_ThenSecretsManagerShouldBeUpdated( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + OrganizationUserUserDetails ownerDetails, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + organization.Seats = 1; + organization.SmSeats = 1; + organization.MaxAutoscaleSeats = 2; + organization.MaxAutoscaleSmSeats = 2; + ownerDetails.Type = OrganizationUserType.Owner; + + var inviteOrganization = new InviteOrganization(organization, new FreePlan()); + + var request = new InviteOrganizationUsersRequest( + invites: [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var secretsManagerSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, inviteOrganization.Plan, true) + .AdjustSeats(request.Invites.Count(x => x.AccessSecretsManager)); + + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository + .SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false) + .Returns([]); + orgUserRepository + .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) + .Returns([ownerDetails]); + orgUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(1); + orgUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id).Returns(1); + + var orgRepository = sutProvider.GetDependency(); + + orgRepository.GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid(GetInviteValidationRequestMock(request, inviteOrganization, organization) + .WithSecretsManagerUpdate(secretsManagerSubscriptionUpdate))); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert; + Assert.IsType>(result); + + await sutProvider.GetDependency() + .Received(1) + .UpdateSubscriptionAsync(secretsManagerSubscriptionUpdate); + } + + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenAnErrorOccursWhileInvitingUsers_ThenAnySeatChangesShouldBeReverted( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + OrganizationUserUserDetails ownerDetails, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + organization.Seats = 1; + organization.SmSeats = 1; + organization.MaxAutoscaleSeats = 2; + organization.MaxAutoscaleSmSeats = 2; + ownerDetails.Type = OrganizationUserType.Owner; + + var inviteOrganization = new InviteOrganization(organization, new FreePlan()); + + var request = new InviteOrganizationUsersRequest( + invites: [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var secretsManagerSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, inviteOrganization.Plan, true) + .AdjustSeats(request.Invites.Count(x => x.AccessSecretsManager)); + + var passwordManagerSubscriptionUpdate = + new PasswordManagerSubscriptionUpdate(inviteOrganization, 1, request.Invites.Length); + + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository + .SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false) + .Returns([]); + orgUserRepository + .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) + .Returns([ownerDetails]); + + var orgRepository = sutProvider.GetDependency(); + + orgRepository.GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid(GetInviteValidationRequestMock(request, inviteOrganization, organization) + .WithPasswordManagerUpdate(passwordManagerSubscriptionUpdate) + .WithSecretsManagerUpdate(secretsManagerSubscriptionUpdate))); + + sutProvider.GetDependency() + .SendInvitesAsync(Arg.Any()) + .Throws(new Exception("Something went wrong")); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + Assert.Equal(FailedToInviteUsersError.Code, (result as Failure)!.ErrorMessage); + + // org user revert + await orgUserRepository.Received(1).DeleteManyAsync(Arg.Is>(x => x.Count() == 1)); + + // SM revert + await sutProvider.GetDependency() + .Received(2) + .UpdateSubscriptionAsync(Arg.Any()); + + // PM revert + await sutProvider.GetDependency() + .Received(2) + .AdjustSeatsAsync(Arg.Any(), Arg.Any(), Arg.Any()); + + await orgRepository.Received(2).ReplaceAsync(Arg.Any()); + + await sutProvider.GetDependency().Received(2) + .UpsertOrganizationAbilityAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenAnOrganizationIsManagedByAProvider_ThenAnEmailShouldBeSentToTheProvider( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + OrganizationUserUserDetails ownerDetails, + ProviderOrganization providerOrganization, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + organization.Seats = 1; + organization.SmSeats = 1; + organization.MaxAutoscaleSeats = 2; + organization.MaxAutoscaleSmSeats = 2; + ownerDetails.Type = OrganizationUserType.Owner; + + providerOrganization.OrganizationId = organization.Id; + + var inviteOrganization = new InviteOrganization(organization, new FreePlan()); + + var request = new InviteOrganizationUsersRequest( + invites: [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var secretsManagerSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, inviteOrganization.Plan, true) + .AdjustSeats(request.Invites.Count(x => x.AccessSecretsManager)); + + var passwordManagerSubscriptionUpdate = + new PasswordManagerSubscriptionUpdate(inviteOrganization, 1, request.Invites.Length); + + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository + .SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false) + .Returns([]); + orgUserRepository + .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) + .Returns([ownerDetails]); + + var orgRepository = sutProvider.GetDependency(); + + orgRepository.GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid(GetInviteValidationRequestMock(request, inviteOrganization, organization) + .WithPasswordManagerUpdate(passwordManagerSubscriptionUpdate) + .WithSecretsManagerUpdate(secretsManagerSubscriptionUpdate))); + + sutProvider.GetDependency() + .GetByOrganizationId(organization.Id) + .Returns(providerOrganization); + + sutProvider.GetDependency() + .GetManyDetailsByProviderAsync(providerOrganization.ProviderId, ProviderUserStatusType.Confirmed) + .Returns(new List + { + new() + { + Email = "provider@email.com" + } + }); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + + sutProvider.GetDependency().Received(1) + .SendOrganizationMaxSeatLimitReachedEmailAsync(organization, 2, + Arg.Is>(emails => emails.Any(email => email == "provider@email.com"))); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommandTests.cs new file mode 100644 index 0000000000..23c1a32c03 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommandTests.cs @@ -0,0 +1,108 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Entities; +using Bit.Core.Models.Mail; +using Bit.Core.Services; +using Bit.Core.Test.AutoFixture.OrganizationFixtures; +using Bit.Core.Tokens; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Fakes; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +[SutProviderCustomize] +public class SendOrganizationInvitesCommandTests +{ + private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory = new FakeDataProtectorTokenFactory(); + + [Theory] + [OrganizationInviteCustomize, OrganizationCustomize, BitAutoData] + public async Task SendInvitesAsync_SsoOrgWithNeverEnabledRequireSsoPolicy_SendsEmailWithoutRequiringSso( + Organization organization, + SsoConfig ssoConfig, + OrganizationUser invite, + SutProvider sutProvider) + { + // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks + sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); + sutProvider.Create(); + + // Org must be able to use SSO and policies to trigger this test case + organization.UseSso = true; + organization.UsePolicies = true; + + ssoConfig.Enabled = true; + sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id).Returns(ssoConfig); + + // Return null policy to mimic new org that's never turned on the require sso policy + sutProvider.GetDependency().GetManyByOrganizationIdAsync(organization.Id).ReturnsNull(); + + // Mock tokenable factory to return a token that expires in 5 days + sutProvider.GetDependency() + .CreateToken(Arg.Any()) + .Returns( + info => new OrgUserInviteTokenable(info.Arg()) + { + ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) + }); + + // Act + await sutProvider.Sut.SendInvitesAsync(new SendInvitesRequest([invite], organization)); + + // Assert + await sutProvider.GetDependency().Received(1) + .SendOrganizationInviteEmailsAsync(Arg.Is(info => + info.OrgUserTokenPairs.Count() == 1 && + info.OrgUserTokenPairs.FirstOrDefault(x => x.OrgUser.Email == invite.Email).OrgUser == invite && + info.IsFreeOrg == (organization.PlanType == PlanType.Free) && + info.OrganizationName == organization.Name && + info.OrgSsoLoginRequiredPolicyEnabled == false)); + } + + [Theory] + [OrganizationInviteCustomize, OrganizationCustomize, BitAutoData] + public async Task InviteUsers_SsoOrgWithNullSsoConfig_SendsInvite( + Organization organization, + OrganizationUser invite, + SutProvider sutProvider) + { + // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks + sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); + sutProvider.Create(); + + // Org must be able to use SSO to trigger this proper test case as we currently only call to retrieve + // an org's SSO config if the org can use SSO + organization.UseSso = true; + + // Return null for sso config + sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id).ReturnsNull(); + + // Mock tokenable factory to return a token that expires in 5 days + sutProvider.GetDependency() + .CreateToken(Arg.Any()) + .Returns( + info => new OrgUserInviteTokenable(info.Arg()) + { + ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) + }); + + await sutProvider.Sut.SendInvitesAsync(new SendInvitesRequest([invite], organization)); + + await sutProvider.GetDependency().Received(1) + .SendOrganizationInviteEmailsAsync(Arg.Is(info => + info.OrgUserTokenPairs.Count() == 1 && + info.OrgUserTokenPairs.FirstOrDefault(x => x.OrgUser.Email == invite.Email).OrgUser == invite && + info.IsFreeOrg == (organization.PlanType == PlanType.Free) && + info.OrganizationName == organization.Name)); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUsersValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUsersValidatorTests.cs new file mode 100644 index 0000000000..191ef05603 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUsersValidatorTests.cs @@ -0,0 +1,161 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; +using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.Billing.Models.StaticStore.Plans; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; +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; +using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + +[SutProviderCustomize] +public class InviteOrganizationUsersValidatorTests +{ + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenOrganizationHasSecretsManagerInvitesAndDoesNotHaveEnoughSeatsAvailable_ThenShouldCorrectlyCalculateSeatsToAdd( + Organization organization, + SutProvider sutProvider + ) + { + organization.Seats = null; + organization.SmSeats = 10; + organization.UseSecretsManager = true; + + var request = new InviteOrganizationUsersValidationRequest + { + Invites = + [ + new OrganizationUserInvite( + email: "test@email.com", + externalId: "test-external-id"), + new OrganizationUserInvite( + email: "test2@email.com", + externalId: "test-external-id2"), + new OrganizationUserInvite( + email: "test3@email.com", + externalId: "test-external-id3") + ], + InviteOrganization = new InviteOrganization(organization, new Enterprise2023Plan(true)), + OccupiedPmSeats = 0, + OccupiedSmSeats = 9 + }; + + sutProvider.GetDependency() + .HasSecretsManagerStandalone(request.InviteOrganization) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + _ = await sutProvider.Sut.ValidateAsync(request); + + sutProvider.GetDependency() + .Received(1) + .ValidateUpdateAsync(Arg.Is(x => + x.SmSeatsChanged == true && x.SmSeats == 12)); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenOrganizationHasSecretsManagerInvitesAndHasSeatsAvailable_ThenShouldReturnValid( + Organization organization, + SutProvider sutProvider + ) + { + organization.Seats = null; + organization.SmSeats = 12; + organization.UseSecretsManager = true; + + var request = new InviteOrganizationUsersValidationRequest + { + Invites = + [ + new OrganizationUserInvite( + email: "test@email.com", + externalId: "test-external-id"), + new OrganizationUserInvite( + email: "test2@email.com", + externalId: "test-external-id2"), + new OrganizationUserInvite( + email: "test3@email.com", + externalId: "test-external-id3") + ], + InviteOrganization = new InviteOrganization(organization, new Enterprise2023Plan(true)), + OccupiedPmSeats = 0, + OccupiedSmSeats = 9 + }; + + sutProvider.GetDependency() + .HasSecretsManagerStandalone(request.InviteOrganization) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + var result = await sutProvider.Sut.ValidateAsync(request); + + Assert.IsType>(result); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenOrganizationHasSecretsManagerInvitesAndSmSeatUpdateFailsValidation_ThenShouldReturnInvalid( + Organization organization, + SutProvider sutProvider + ) + { + organization.Seats = null; + organization.SmSeats = 5; + organization.MaxAutoscaleSmSeats = 5; + organization.UseSecretsManager = true; + + var request = new InviteOrganizationUsersValidationRequest + { + Invites = + [ + new OrganizationUserInvite( + email: "test@email.com", + externalId: "test-external-id"), + new OrganizationUserInvite( + email: "test2@email.com", + externalId: "test-external-id2"), + new OrganizationUserInvite( + email: "test3@email.com", + externalId: "test-external-id3") + ], + InviteOrganization = new InviteOrganization(organization, new Enterprise2023Plan(true)), + OccupiedPmSeats = 0, + OccupiedSmSeats = 4 + }; + + sutProvider.GetDependency() + .HasSecretsManagerStandalone(request.InviteOrganization) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateUpdateAsync(Arg.Any()) + .Throws(new BadRequestException("Some Secrets Manager Failure")); + + var result = await sutProvider.Sut.ValidateAsync(request); + + Assert.IsType>(result); + Assert.Equal("Some Secrets Manager Failure", (result as Invalid)!.ErrorMessageString); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserOrganizationValidationTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserOrganizationValidationTests.cs new file mode 100644 index 0000000000..508b9f3cb0 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserOrganizationValidationTests.cs @@ -0,0 +1,58 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization; +using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.Billing.Models.StaticStore.Plans; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + +[SutProviderCustomize] +public class InviteUserOrganizationValidationTests +{ + [Theory] + [BitAutoData] + public async Task Validate_WhenOrganizationIsFreeTier_ShouldReturnValidResponse(Organization organization, SutProvider sutProvider) + { + var inviteOrganization = new InviteOrganization(organization, new FreePlan()); + + var result = await sutProvider.Sut.ValidateAsync(inviteOrganization); + + Assert.IsType>(result); + } + + [Theory] + [BitAutoData] + public async Task Validate_WhenOrganizationDoesNotHavePaymentMethod_ShouldReturnInvalidResponseWithPaymentMethodMessage( + Organization organization, SutProvider sutProvider) + { + organization.GatewayCustomerId = string.Empty; + organization.Seats = 3; + + var inviteOrganization = new InviteOrganization(organization, new FreePlan()); + + var result = await sutProvider.Sut.ValidateAsync(inviteOrganization); + + Assert.IsType>(result); + Assert.Equal(OrganizationNoPaymentMethodFoundError.Code, (result as Invalid)!.ErrorMessageString); + } + + [Theory] + [BitAutoData] + public async Task Validate_WhenOrganizationDoesNotHaveSubscription_ShouldReturnInvalidResponseWithSubscriptionMessage( + Organization organization, SutProvider sutProvider) + { + organization.GatewaySubscriptionId = string.Empty; + organization.Seats = 3; + organization.MaxAutoscaleSeats = 4; + + var inviteOrganization = new InviteOrganization(organization, new FreePlan()); + + var result = await sutProvider.Sut.ValidateAsync(inviteOrganization); + + Assert.IsType>(result); + Assert.Equal(OrganizationNoSubscriptionFoundError.Code, (result as Invalid)!.ErrorMessageString); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserPaymentValidationTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserPaymentValidationTests.cs new file mode 100644 index 0000000000..bcca89e1d2 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserPaymentValidationTests.cs @@ -0,0 +1,56 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments; +using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models.StaticStore.Plans; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + +public class InviteUserPaymentValidationTests +{ + [Theory] + [BitAutoData] + public void Validate_WhenPlanIsFree_ReturnsValidResponse(Organization organization) + { + organization.PlanType = PlanType.Free; + + var result = InviteUserPaymentValidation.Validate(new PaymentsSubscription + { + SubscriptionStatus = StripeConstants.SubscriptionStatus.Active, + ProductTierType = new InviteOrganization(organization, new FreePlan()).Plan.ProductTier + }); + + Assert.IsType>(result); + } + + [Fact] + public void Validate_WhenSubscriptionIsCanceled_ReturnsInvalidResponse() + { + var result = InviteUserPaymentValidation.Validate(new PaymentsSubscription + { + SubscriptionStatus = StripeConstants.SubscriptionStatus.Canceled, + ProductTierType = ProductTierType.Enterprise + }); + + Assert.IsType>(result); + Assert.Equal(PaymentCancelledSubscriptionError.Code, (result as Invalid)!.ErrorMessageString); + } + + [Fact] + public void Validate_WhenSubscriptionIsActive_ReturnsValidResponse() + { + var result = InviteUserPaymentValidation.Validate(new PaymentsSubscription + { + SubscriptionStatus = StripeConstants.SubscriptionStatus.Active, + ProductTierType = ProductTierType.Enterprise + }); + + Assert.IsType>(result); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManagerInviteUserValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManagerInviteUserValidatorTests.cs new file mode 100644 index 0000000000..c320ada8cb --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManagerInviteUserValidatorTests.cs @@ -0,0 +1,93 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; +using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models.StaticStore.Plans; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + + +[SutProviderCustomize] +public class InviteUsersPasswordManagerValidatorTests +{ + [Theory] + [BitAutoData] + public async Task Validate_OrganizationDoesNotHaveSeatsLimit_ShouldReturnValidResult(Organization organization, + SutProvider sutProvider) + { + organization.Seats = null; + + var organizationDto = new InviteOrganization(organization, new FreePlan()); + + var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(organizationDto, 0, 0); + + var result = await sutProvider.Sut.ValidateAsync(subscriptionUpdate); + + Assert.IsType>(result); + } + + [Theory] + [BitAutoData] + public async Task Validate_NumberOfSeatsToAddMatchesSeatsAvailable_ShouldReturnValidResult(Organization organization, + SutProvider sutProvider) + { + organization.Seats = 8; + organization.PlanType = PlanType.EnterpriseAnnually; + var seatsOccupiedByUsers = 4; + var additionalSeats = 4; + + var organizationDto = new InviteOrganization(organization, new Enterprise2023Plan(isAnnual: true)); + + var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(organizationDto, seatsOccupiedByUsers, additionalSeats); + + var result = await sutProvider.Sut.ValidateAsync(subscriptionUpdate); + + Assert.IsType>(result); + } + + [Theory] + [BitAutoData] + public async Task Validate_NumberOfSeatsToAddIsGreaterThanMaxSeatsAllowed_ShouldBeInvalidWithSeatLimitMessage(Organization organization, + SutProvider sutProvider) + { + organization.Seats = 4; + organization.MaxAutoscaleSeats = 4; + organization.PlanType = PlanType.EnterpriseAnnually; + var seatsOccupiedByUsers = 4; + var additionalSeats = 1; + + var organizationDto = new InviteOrganization(organization, new Enterprise2023Plan(isAnnual: true)); + + var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(organizationDto, seatsOccupiedByUsers, additionalSeats); + + var result = await sutProvider.Sut.ValidateAsync(subscriptionUpdate); + + Assert.IsType>(result); + Assert.Equal(PasswordManagerSeatLimitHasBeenReachedError.Code, (result as Invalid)!.ErrorMessageString); + } + + [Theory] + [BitAutoData] + public async Task Validate_GivenThePlanDoesNotAllowAdditionalSeats_ShouldBeInvalidMessageOfPlanNotAllowingSeats(Organization organization, + SutProvider sutProvider) + { + organization.Seats = 4; + organization.MaxAutoscaleSeats = 9; + var seatsOccupiedByUsers = 4; + var additionalSeats = 4; + organization.PlanType = PlanType.Free; + + var organizationDto = new InviteOrganization(organization, new FreePlan()); + + var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(organizationDto, seatsOccupiedByUsers, additionalSeats); + + var result = await sutProvider.Sut.ValidateAsync(subscriptionUpdate); + + Assert.IsType>(result); + Assert.Equal(PasswordManagerPlanDoesNotAllowAdditionalSeatsError.Code, (result as Invalid)!.ErrorMessageString); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommandTests.cs index a60850c5a9..3578706e47 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommandTests.cs @@ -41,9 +41,9 @@ public class RemoveOrganizationUserCommandTests await sutProvider.Sut.RemoveUserAsync(deletingUser.OrganizationId, organizationUser.Id, deletingUser.UserId); // Assert - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .GetUsersOrganizationManagementStatusAsync(default, default); + .GetUsersOrganizationClaimedStatusAsync(default, default); await sutProvider.GetDependency() .Received(1) .DeleteAsync(organizationUser); @@ -78,9 +78,9 @@ public class RemoveOrganizationUserCommandTests await sutProvider.Sut.RemoveUserAsync(deletingUser.OrganizationId, organizationUser.Id, deletingUser.UserId); // Assert - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .GetUsersOrganizationManagementStatusAsync( + .GetUsersOrganizationClaimedStatusAsync( organizationUser.OrganizationId, Arg.Is>(i => i.Contains(organizationUser.Id))); await sutProvider.GetDependency() @@ -247,17 +247,17 @@ public class RemoveOrganizationUserCommandTests sutProvider.GetDependency() .GetByIdAsync(orgUser.Id) .Returns(orgUser); - sutProvider.GetDependency() - .GetUsersOrganizationManagementStatusAsync(orgUser.OrganizationId, Arg.Is>(i => i.Contains(orgUser.Id))) + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync(orgUser.OrganizationId, Arg.Is>(i => i.Contains(orgUser.Id))) .Returns(new Dictionary { { orgUser.Id, true } }); // Act & Assert var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.RemoveUserAsync(orgUser.OrganizationId, orgUser.Id, deletingUserId)); Assert.Contains(RemoveOrganizationUserCommand.RemoveClaimedAccountErrorMessage, exception.Message); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .GetUsersOrganizationManagementStatusAsync(orgUser.OrganizationId, Arg.Is>(i => i.Contains(orgUser.Id))); + .GetUsersOrganizationClaimedStatusAsync(orgUser.OrganizationId, Arg.Is>(i => i.Contains(orgUser.Id))); } [Theory, BitAutoData] @@ -274,9 +274,9 @@ public class RemoveOrganizationUserCommandTests await sutProvider.Sut.RemoveUserAsync(organizationUser.OrganizationId, organizationUser.Id, eventSystemUser); // Assert - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .GetUsersOrganizationManagementStatusAsync(default, default); + .GetUsersOrganizationClaimedStatusAsync(default, default); await sutProvider.GetDependency() .Received(1) .DeleteAsync(organizationUser); @@ -302,9 +302,9 @@ public class RemoveOrganizationUserCommandTests await sutProvider.Sut.RemoveUserAsync(organizationUser.OrganizationId, organizationUser.Id, eventSystemUser); // Assert - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .GetUsersOrganizationManagementStatusAsync(default, default); + .GetUsersOrganizationClaimedStatusAsync(default, default); await sutProvider.GetDependency() .Received(1) .DeleteAsync(organizationUser); @@ -490,8 +490,8 @@ public class RemoveOrganizationUserCommandTests sutProvider.GetDependency() .OrganizationOwner(deletingUser.OrganizationId) .Returns(true); - sutProvider.GetDependency() - .GetUsersOrganizationManagementStatusAsync( + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync( deletingUser.OrganizationId, Arg.Is>(i => i.Contains(orgUser1.Id) && i.Contains(orgUser2.Id))) .Returns(new Dictionary { { orgUser1.Id, false }, { orgUser2.Id, false } }); @@ -502,9 +502,9 @@ public class RemoveOrganizationUserCommandTests // Assert Assert.Equal(2, result.Count()); Assert.All(result, r => Assert.Empty(r.ErrorMessage)); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .GetUsersOrganizationManagementStatusAsync(default, default); + .GetUsersOrganizationClaimedStatusAsync(default, default); await sutProvider.GetDependency() .Received(1) .DeleteManyAsync(Arg.Is>(i => i.Contains(orgUser1.Id) && i.Contains(orgUser2.Id))); @@ -544,8 +544,8 @@ public class RemoveOrganizationUserCommandTests sutProvider.GetDependency() .OrganizationOwner(deletingUser.OrganizationId) .Returns(true); - sutProvider.GetDependency() - .GetUsersOrganizationManagementStatusAsync( + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync( deletingUser.OrganizationId, Arg.Is>(i => i.Contains(orgUser1.Id) && i.Contains(orgUser2.Id))) .Returns(new Dictionary { { orgUser1.Id, false }, { orgUser2.Id, false } }); @@ -556,9 +556,9 @@ public class RemoveOrganizationUserCommandTests // Assert Assert.Equal(2, result.Count()); Assert.All(result, r => Assert.Empty(r.ErrorMessage)); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .GetUsersOrganizationManagementStatusAsync( + .GetUsersOrganizationClaimedStatusAsync( deletingUser.OrganizationId, Arg.Is>(i => i.Contains(orgUser1.Id) && i.Contains(orgUser2.Id))); await sutProvider.GetDependency() @@ -638,7 +638,7 @@ public class RemoveOrganizationUserCommandTests } [Theory, BitAutoData] - public async Task RemoveUsers_WithDeletingUserId_RemovingManagedUser_WithAccountDeprovisioningEnabled_ThrowsException( + public async Task RemoveUsers_WithDeletingUserId_RemovingClaimedUser_WithAccountDeprovisioningEnabled_ThrowsException( [OrganizationUser(status: OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser, OrganizationUser deletingUser, SutProvider sutProvider) @@ -658,8 +658,8 @@ public class RemoveOrganizationUserCommandTests .HasConfirmedOwnersExceptAsync(orgUser.OrganizationId, Arg.Any>()) .Returns(true); - sutProvider.GetDependency() - .GetUsersOrganizationManagementStatusAsync(orgUser.OrganizationId, Arg.Is>(i => i.Contains(orgUser.Id))) + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync(orgUser.OrganizationId, Arg.Is>(i => i.Contains(orgUser.Id))) .Returns(new Dictionary { { orgUser.Id, true } }); // Act @@ -723,9 +723,9 @@ public class RemoveOrganizationUserCommandTests // Assert Assert.Equal(2, result.Count()); Assert.All(result, r => Assert.Empty(r.ErrorMessage)); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .GetUsersOrganizationManagementStatusAsync(default, default); + .GetUsersOrganizationClaimedStatusAsync(default, default); await sutProvider.GetDependency() .Received(1) .DeleteManyAsync(Arg.Is>(i => i.Contains(orgUser1.Id) && i.Contains(orgUser2.Id))); @@ -768,9 +768,9 @@ public class RemoveOrganizationUserCommandTests // Assert Assert.Equal(2, result.Count()); Assert.All(result, r => Assert.Empty(r.ErrorMessage)); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .GetUsersOrganizationManagementStatusAsync(default, default); + .GetUsersOrganizationClaimedStatusAsync(default, default); await sutProvider.GetDependency() .Received(1) .DeleteManyAsync(Arg.Is>(i => i.Contains(orgUser1.Id) && i.Contains(orgUser2.Id))); diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index 53101900e5..e45643435d 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -2,10 +2,10 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Business.Tokenables; -using Bit.Core.Auth.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; using Bit.Core.Context; @@ -15,7 +15,6 @@ using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; -using Bit.Core.Models.Mail; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Platform.Push; using Bit.Core.Repositories; @@ -37,6 +36,7 @@ using NSubstitute.ReturnsExtensions; using Xunit; using Organization = Bit.Core.AdminConsole.Entities.Organization; using OrganizationUser = Bit.Core.Entities.OrganizationUser; +using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite; namespace Bit.Core.Test.Services; @@ -77,15 +77,6 @@ public class OrganizationServiceTests .Returns(true); sutProvider.GetDependency().ManageUsers(org.Id).Returns(true); - // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - } - ); await sutProvider.Sut.ImportAsync(org.Id, null, newUsers, null, false, EventSystemUser.PublicApi); @@ -100,9 +91,11 @@ public class OrganizationServiceTests await sutProvider.GetDependency().Received(1) .CreateManyAsync(Arg.Is>(users => users.Count() == expectedNewUsersCount)); - await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync( - Arg.Is(info => info.OrgUserTokenPairs.Count() == expectedNewUsersCount && info.IsFreeOrg == (org.PlanType == PlanType.Free) && info.OrganizationName == org.Name)); + await sutProvider.GetDependency().Received(1) + .SendInvitesAsync( + Arg.Is( + info => info.Users.Length == expectedNewUsersCount && + info.Organization == org)); // Send events await sutProvider.GetDependency().Received(1) @@ -152,16 +145,6 @@ public class OrganizationServiceTests var currentContext = sutProvider.GetDependency(); currentContext.ManageUsers(org.Id).Returns(true); - // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - } - ); - await sutProvider.Sut.ImportAsync(org.Id, null, newUsers, null, false, EventSystemUser.PublicApi); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() @@ -179,14 +162,15 @@ public class OrganizationServiceTests await sutProvider.GetDependency().Received(1) .CreateManyAsync(Arg.Is>(users => users.Count() == expectedNewUsersCount)); - await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(info => - info.OrgUserTokenPairs.Count() == expectedNewUsersCount && info.IsFreeOrg == (org.PlanType == PlanType.Free) && info.OrganizationName == org.Name)); + await sutProvider.GetDependency().Received(1) + .SendInvitesAsync(Arg.Is(request => + request.Users.Length == expectedNewUsersCount && + request.Organization == org)); // Sent events await sutProvider.GetDependency().Received(1) .LogOrganizationUserEventsAsync(Arg.Is>(events => - events.Where(e => e.Item2 == EventType.OrganizationUser_Invited).Count() == expectedNewUsersCount)); + events.Count(e => e.Item2 == EventType.OrganizationUser_Invited) == expectedNewUsersCount)); await sutProvider.GetDependency().Received(1) .RaiseEventAsync(Arg.Is(referenceEvent => referenceEvent.Type == ReferenceEventType.InvitedUsers && referenceEvent.Id == org.Id && @@ -270,125 +254,15 @@ public class OrganizationServiceTests // Must set guids in order for dictionary of guids to not throw aggregate exceptions SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); - // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - } - ); - - await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) }); - await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(info => - info.OrgUserTokenPairs.Count() == invite.Emails.Distinct().Count() && - info.IsFreeOrg == (organization.PlanType == PlanType.Free) && - info.OrganizationName == organization.Name)); + await sutProvider.GetDependency().Received(1) + .SendInvitesAsync(Arg.Is(request => + request.Users.DistinctBy(x => x.Email).Count() == invite.Emails.Distinct().Count() && + request.Organization == organization)); } - [Theory] - [OrganizationInviteCustomize, OrganizationCustomize, BitAutoData] - public async Task InviteUsers_SsoOrgWithNullSsoConfig_Passes(Organization organization, OrganizationUser invitor, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, - OrganizationUserInvite invite, SutProvider sutProvider) - { - // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks - sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); - sutProvider.Create(); - - // Org must be able to use SSO to trigger this proper test case as we currently only call to retrieve - // an org's SSO config if the org can use SSO - organization.UseSso = true; - - // Return null for sso config - sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id).ReturnsNull(); - - sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); - sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true); - sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); - var organizationUserRepository = sutProvider.GetDependency(); - organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner) - .Returns(new[] { owner }); - - // Must set guids in order for dictionary of guids to not throw aggregate exceptions - SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); - - // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - } - ); - - - - await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) }); - - await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(info => - info.OrgUserTokenPairs.Count() == invite.Emails.Distinct().Count() && - info.IsFreeOrg == (organization.PlanType == PlanType.Free) && - info.OrganizationName == organization.Name)); - } - - [Theory] - [OrganizationInviteCustomize, OrganizationCustomize, BitAutoData] - public async Task InviteUsers_SsoOrgWithNeverEnabledRequireSsoPolicy_Passes(Organization organization, SsoConfig ssoConfig, OrganizationUser invitor, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, -OrganizationUserInvite invite, SutProvider sutProvider) - { - // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks - sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); - sutProvider.Create(); - - // Org must be able to use SSO and policies to trigger this test case - organization.UseSso = true; - organization.UsePolicies = true; - - sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); - sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true); - sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); - var organizationUserRepository = sutProvider.GetDependency(); - organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner) - .Returns(new[] { owner }); - - ssoConfig.Enabled = true; - sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id).Returns(ssoConfig); - - - // Return null policy to mimic new org that's never turned on the require sso policy - sutProvider.GetDependency().GetManyByOrganizationIdAsync(organization.Id).ReturnsNull(); - - // Must set guids in order for dictionary of guids to not throw aggregate exceptions - SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); - - // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - } - ); - - await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) }); - - await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(info => - info.OrgUserTokenPairs.Count() == invite.Emails.Distinct().Count() && - info.IsFreeOrg == (organization.PlanType == PlanType.Free) && - info.OrganizationName == organization.Name)); - } - [Theory] [OrganizationInviteCustomize( InviteeUserType = OrganizationUserType.Admin, @@ -637,14 +511,14 @@ OrganizationUserInvite invite, SutProvider sutProvider) organizationRepository.GetByIdAsync(organization.Id).Returns(organization); // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - } - ); + // sutProvider.GetDependency() + // .CreateToken(Arg.Any()) + // .Returns( + // info => new OrgUserInviteTokenable(info.Arg()) + // { + // ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) + // } + // ); sutProvider.GetDependency() .HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any>()) @@ -655,11 +529,10 @@ OrganizationUserInvite invite, SutProvider sutProvider) await sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId); - await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(info => - info.OrgUserTokenPairs.Count() == 1 && - info.IsFreeOrg == (organization.PlanType == PlanType.Free) && - info.OrganizationName == organization.Name)); + await sutProvider.GetDependency().Received(1) + .SendInvitesAsync(Arg.Is(request => + request.Users.Length == 1 && + request.Organization == organization)); await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync(Arg.Any>()); } @@ -712,16 +585,6 @@ OrganizationUserInvite invite, SutProvider sutProvider) organizationRepository.GetByIdAsync(organization.Id).Returns(organization); - // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - } - ); - sutProvider.GetDependency() .HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any>()) .Returns(true); @@ -733,12 +596,11 @@ OrganizationUserInvite invite, SutProvider sutProvider) .InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId)); Assert.Contains("This user has already been invited", exception.Message); - // MailService and EventService are still called, but with no OrgUsers - await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(info => - !info.OrgUserTokenPairs.Any() && - info.IsFreeOrg == (organization.PlanType == PlanType.Free) && - info.OrganizationName == organization.Name)); + // SendOrganizationInvitesCommand and EventService are still called, but with no OrgUsers + await sutProvider.GetDependency().Received(1) + .SendInvitesAsync(Arg.Is(info => + info.Organization == organization && + info.Users.Length == 0)); await sutProvider.GetDependency().Received(1) .LogOrganizationUserEventsAsync(Arg.Is>(events => !events.Any())); } @@ -787,16 +649,6 @@ OrganizationUserInvite invite, SutProvider sutProvider) organizationRepository.GetByIdAsync(organization.Id).Returns(organization); - // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - } - ); - sutProvider.GetDependency() .HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any>()) .Returns(true); @@ -806,11 +658,10 @@ OrganizationUserInvite invite, SutProvider sutProvider) await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, invites); - await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(info => - info.OrgUserTokenPairs.Count() == invites.SelectMany(i => i.invite.Emails).Count() && - info.IsFreeOrg == (organization.PlanType == PlanType.Free) && - info.OrganizationName == organization.Name)); + await sutProvider.GetDependency().Received(1) + .SendInvitesAsync(Arg.Is(info => + info.Organization == organization && + info.Users.Length == invites.SelectMany(x => x.invite.Emails).Distinct().Count())); await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync(Arg.Any>()); } @@ -848,23 +699,12 @@ OrganizationUserInvite invite, SutProvider sutProvider) currentContext.ManageUsers(organization.Id).Returns(true); - // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - } - ); - await sutProvider.Sut.InviteUsersAsync(organization.Id, invitingUserId: null, eventSystemUser, invites); - await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(info => - info.OrgUserTokenPairs.Count() == invites.SelectMany(i => i.invite.Emails).Count() && - info.IsFreeOrg == (organization.PlanType == PlanType.Free) && - info.OrganizationName == organization.Name)); + await sutProvider.GetDependency().Received(1) + .SendInvitesAsync(Arg.Is(info => + info.Users.Length == invites.SelectMany(i => i.invite.Emails).Count() && + info.Organization == organization)); await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync(Arg.Any>()); } diff --git a/test/Core.Test/Auth/Services/AuthRequestServiceTests.cs b/test/Core.Test/Auth/Services/AuthRequestServiceTests.cs index eec6747c5f..5da0e78422 100644 --- a/test/Core.Test/Auth/Services/AuthRequestServiceTests.cs +++ b/test/Core.Test/Auth/Services/AuthRequestServiceTests.cs @@ -398,7 +398,7 @@ public class AuthRequestServiceTests [Theory, BitAutoData] - public async Task CreateAuthRequestAsync_AdminApproval_WithAdminNotifications_AndNoAdminEmails_ShouldNotSendNotificationEmails( + public async Task CreateAuthRequestAsync_AdminApproval_AndNoAdminEmails_ShouldNotSendNotificationEmails( SutProvider sutProvider, AuthRequestCreateRequestModel createModel, User user, @@ -408,10 +408,6 @@ public class AuthRequestServiceTests user.Email = createModel.Email; organizationUser1.UserId = user.Id; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.DeviceApprovalRequestAdminNotifications) - .Returns(true); - sutProvider.GetDependency() .GetByEmailAsync(user.Email) .Returns(user); diff --git a/test/Core.Test/Core.Test.csproj b/test/Core.Test/Core.Test.csproj index baace97710..cc19c50c35 100644 --- a/test/Core.Test/Core.Test.csproj +++ b/test/Core.Test/Core.Test.csproj @@ -10,6 +10,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/test/Core.Test/Platform/X509ChainCustomization/X509ChainCustomizationServiceCollectionExtensionsTests.cs b/test/Core.Test/Platform/X509ChainCustomization/X509ChainCustomizationServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000000..cec8a2a39d --- /dev/null +++ b/test/Core.Test/Platform/X509ChainCustomization/X509ChainCustomizationServiceCollectionExtensionsTests.cs @@ -0,0 +1,359 @@ +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Bit.Core.Platform.X509ChainCustomization; +using Bit.Core.Settings; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Https; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Platform.X509ChainCustomization; + +public class X509ChainCustomizationServiceCollectionExtensionsTests +{ + private static X509Certificate2 CreateSelfSignedCert(string commonName) + { + using var rsa = RSA.Create(2048); + var certRequest = new CertificateRequest($"CN={commonName}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return certRequest.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1)); + } + + [Fact] + public async Task OptionsPatternReturnsCachedValue() + { + var tempDir = Directory.CreateTempSubdirectory("certs"); + + var tempCert = Path.Combine(tempDir.FullName, "test.crt"); + await File.WriteAllBytesAsync(tempCert, CreateSelfSignedCert("localhost").Export(X509ContentType.Cert)); + + var services = CreateServices((gs, environment, config) => + { + config["X509ChainOptions:AdditionalCustomTrustCertificatesDirectory"] = tempDir.FullName; + }); + + // Create options once + var firstOptions = services.GetRequiredService>().Value; + + Assert.NotNull(firstOptions.AdditionalCustomTrustCertificates); + var cert = Assert.Single(firstOptions.AdditionalCustomTrustCertificates); + Assert.Equal("CN=localhost", cert.Subject); + + // Since the second resolution should have cached values, deleting the file during operation + // should have no impact. + File.Delete(tempCert); + + // This is expected to be a cached version and doesn't actually need to go and read the file system + var secondOptions = services.GetRequiredService>().Value; + Assert.Same(firstOptions, secondOptions); + + // This is the same reference as the first one so it shouldn't be different but just in case. + Assert.NotNull(secondOptions.AdditionalCustomTrustCertificates); + Assert.Single(secondOptions.AdditionalCustomTrustCertificates); + } + + [Fact] + public async Task DoesNotProvideCustomCallbackOnCloud() + { + var tempDir = Directory.CreateTempSubdirectory("certs"); + + var tempCert = Path.Combine(tempDir.FullName, "test.crt"); + await File.WriteAllBytesAsync(tempCert, CreateSelfSignedCert("localhost").Export(X509ContentType.Cert)); + + var options = CreateOptions((gs, environment, config) => + { + gs.SelfHosted = false; + + config["X509ChainOptions:AdditionalCustomTrustCertificatesDirectory"] = tempDir.FullName; + }); + + Assert.False(options.TryGetCustomRemoteCertificateValidationCallback(out _)); + } + + [Fact] + public async Task ManuallyAddingOptionsTakesPrecedence() + { + var tempDir = Directory.CreateTempSubdirectory("certs"); + + var tempCert = Path.Combine(tempDir.FullName, "test.crt"); + await File.WriteAllBytesAsync(tempCert, CreateSelfSignedCert("localhost").Export(X509ContentType.Cert)); + + var services = CreateServices((gs, environment, config) => + { + config["X509ChainOptions:AdditionalCustomTrustCertificatesDirectory"] = tempDir.FullName; + }, services => + { + services.Configure(options => + { + options.AdditionalCustomTrustCertificates = [CreateSelfSignedCert("example.com")]; + }); + }); + + var options = services.GetRequiredService>().Value; + + Assert.True(options.TryGetCustomRemoteCertificateValidationCallback(out var callback)); + var cert = Assert.Single(options.AdditionalCustomTrustCertificates); + Assert.Equal("CN=example.com", cert.Subject); + + var fakeLogCollector = services.GetFakeLogCollector(); + + Assert.Contains(fakeLogCollector.GetSnapshot(), + r => r.Message == $"Additional custom trust certificates were added directly, skipping loading them from '{tempDir}'"); + } + + [Fact] + public void NullCustomDirectory_SkipsTryingToLoad() + { + var services = CreateServices((gs, environment, config) => + { + config["X509ChainOptions:AdditionalCustomTrustCertificatesDirectory"] = null; + }); + + var options = services.GetRequiredService>().Value; + + Assert.False(options.TryGetCustomRemoteCertificateValidationCallback(out _)); + } + + [Theory] + [InlineData("Development", LogLevel.Debug)] + [InlineData("Production", LogLevel.Warning)] + public void CustomDirectoryDoesNotExist_Logs(string environment, LogLevel logLevel) + { + var fakeDir = "/fake/dir/that/does/not/exist"; + var services = CreateServices((gs, hostEnvironment, config) => + { + hostEnvironment.EnvironmentName = environment; + + config["X509ChainOptions:AdditionalCustomTrustCertificatesDirectory"] = fakeDir; + }); + + var options = services.GetRequiredService>().Value; + + Assert.False(options.TryGetCustomRemoteCertificateValidationCallback(out _)); + + var fakeLogCollector = services.GetFakeLogCollector(); + + Assert.Contains(fakeLogCollector.GetSnapshot(), + r => r.Message == $"An additional custom trust certificate directory was given '{fakeDir}' but that directory does not exist." + && r.Level == logLevel + ); + } + + [Fact] + public async Task NamedOptions_NotConfiguredAsync() + { + // To help make sure this fails for the right reason we should add certs to the directory + var tempDir = Directory.CreateTempSubdirectory("certs"); + + var tempCert = Path.Combine(tempDir.FullName, "test.crt"); + await File.WriteAllBytesAsync(tempCert, CreateSelfSignedCert("localhost").Export(X509ContentType.Cert)); + + var services = CreateServices((gs, environment, config) => + { + config["X509ChainOptions:AdditionalCustomTrustCertificatesDirectory"] = tempDir.FullName; + }); + + var options = services.GetRequiredService>(); + + var namedOptions = options.Get("SomeName"); + + Assert.Null(namedOptions.AdditionalCustomTrustCertificates); + } + + [Fact] + public void CustomLocation_NoCertificates_Logs() + { + var tempDir = Directory.CreateTempSubdirectory("certs"); + var services = CreateServices((gs, hostEnvironment, config) => + { + config["X509ChainOptions:AdditionalCustomTrustCertificatesDirectory"] = tempDir.FullName; + }); + + var options = services.GetRequiredService>().Value; + + Assert.False(options.TryGetCustomRemoteCertificateValidationCallback(out _)); + + var fakeLogCollector = services.GetFakeLogCollector(); + + Assert.Contains(fakeLogCollector.GetSnapshot(), + r => r.Message == $"No additional custom trust certificates were found in '{tempDir.FullName}'" + ); + } + + [Fact] + public async Task CallHttpWithSelfSignedCert_SelfSignedCertificateConfigured_Works() + { + var selfSignedCertificate = CreateSelfSignedCert("localhost"); + await using var app = await CreateServerAsync(55555, options => + { + options.ServerCertificate = selfSignedCertificate; + }); + + var services = CreateServices((gs, environment, config) => { }, services => + { + services.Configure(options => + { + options.AdditionalCustomTrustCertificates = [selfSignedCertificate]; + }); + }); + + var httpClient = services.GetRequiredService().CreateClient(); + + var response = await httpClient.GetStringAsync("https://localhost:55555"); + Assert.Equal("Hi", response); + } + + [Fact] + public async Task CallHttpWithSelfSignedCert_SelfSignedCertificateNotConfigured_Throws() + { + var selfSignedCertificate = CreateSelfSignedCert("localhost"); + await using var app = await CreateServerAsync(55556, options => + { + options.ServerCertificate = selfSignedCertificate; + }); + + var services = CreateServices((gs, environment, config) => { }, services => + { + services.Configure(options => + { + options.AdditionalCustomTrustCertificates = [CreateSelfSignedCert("example.com")]; + }); + }); + + var httpClient = services.GetRequiredService().CreateClient(); + + var requestException = await Assert.ThrowsAsync(async () => await httpClient.GetStringAsync("https://localhost:55556")); + Assert.NotNull(requestException.InnerException); + var authenticationException = Assert.IsAssignableFrom(requestException.InnerException); + Assert.Equal("The remote certificate was rejected by the provided RemoteCertificateValidationCallback.", authenticationException.Message); + } + + [Fact] + public async Task CallHttpWithSelfSignedCert_SelfSignedCertificateConfigured_WithExtraCert_Works() + { + var selfSignedCertificate = CreateSelfSignedCert("localhost"); + await using var app = await CreateServerAsync(55557, options => + { + options.ServerCertificate = selfSignedCertificate; + }); + + var services = CreateServices((gs, environment, config) => { }, services => + { + services.Configure(options => + { + options.AdditionalCustomTrustCertificates = [selfSignedCertificate, CreateSelfSignedCert("example.com")]; + }); + }); + + var httpClient = services.GetRequiredService().CreateClient(); + + var response = await httpClient.GetStringAsync("https://localhost:55557"); + Assert.Equal("Hi", response); + } + + [Fact] + public async Task CallHttp_ReachingOutToServerTrustedThroughSystemCA() + { + var services = CreateServices((gs, environment, config) => { }, services => + { + services.Configure(options => + { + options.AdditionalCustomTrustCertificates = []; + }); + }); + + var httpClient = services.GetRequiredService().CreateClient(); + + var response = await httpClient.GetAsync("https://example.com"); + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task CallHttpWithCustomTrustForSelfSigned_ReachingOutToServerTrustedThroughSystemCA() + { + var selfSignedCertificate = CreateSelfSignedCert("localhost"); + var services = CreateServices((gs, environment, config) => { }, services => + { + services.Configure(options => + { + options.AdditionalCustomTrustCertificates = [selfSignedCertificate]; + }); + }); + + var httpClient = services.GetRequiredService().CreateClient(); + + var response = await httpClient.GetAsync("https://example.com"); + response.EnsureSuccessStatusCode(); + } + + private static async Task CreateServerAsync(int port, Action configure) + { + var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions()); + builder.Services.AddRoutingCore(); + builder.WebHost.UseKestrelCore() + .ConfigureKestrel(options => + { + options.ListenLocalhost(port, listenOptions => + { + listenOptions.UseHttps(httpsOptions => + { + configure(httpsOptions); + }); + }); + }); + + var app = builder.Build(); + + app.MapGet("/", () => "Hi"); + + await app.StartAsync(); + + return app; + } + + private static X509ChainOptions CreateOptions(Action> configure, Action? after = null) + { + var services = CreateServices(configure, after); + return services.GetRequiredService>().Value; + } + + private static IServiceProvider CreateServices(Action> configure, Action? after = null) + { + var globalSettings = new GlobalSettings + { + // A solid default for these tests as these settings aren't allowed to work in cloud. + SelfHosted = true, + }; + var hostEnvironment = Substitute.For(); + hostEnvironment.EnvironmentName = "Development"; + var config = new Dictionary(); + + configure(globalSettings, hostEnvironment, config); + + var services = new ServiceCollection(); + services.AddLogging(logging => + { + logging.SetMinimumLevel(LogLevel.Debug); + logging.AddFakeLogging(); + }); + services.AddSingleton(globalSettings); + services.AddSingleton(hostEnvironment); + services.AddSingleton( + new ConfigurationBuilder() + .AddInMemoryCollection(config) + .Build() + ); + + services.AddX509ChainCustomization(); + + after?.Invoke(services); + + return services.BuildServiceProvider(); + } +} diff --git a/test/Core.Test/Services/HandlebarsMailServiceTests.cs b/test/Core.Test/Services/HandlebarsMailServiceTests.cs index 89d9a211e0..35c2f8fe3b 100644 --- a/test/Core.Test/Services/HandlebarsMailServiceTests.cs +++ b/test/Core.Test/Services/HandlebarsMailServiceTests.cs @@ -4,9 +4,11 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Business; using Bit.Core.Entities; +using Bit.Core.Platform.X509ChainCustomization; using Bit.Core.Services; using Bit.Core.Settings; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using NSubstitute; using Xunit; @@ -136,7 +138,7 @@ public class HandlebarsMailServiceTests SiteName = "Bitwarden", }; - var mailDeliveryService = new MailKitSmtpMailDeliveryService(globalSettings, Substitute.For>()); + var mailDeliveryService = new MailKitSmtpMailDeliveryService(globalSettings, Substitute.For>(), Options.Create(new X509ChainOptions())); var handlebarsService = new HandlebarsMailService(globalSettings, mailDeliveryService, new BlockingMailEnqueuingService()); diff --git a/test/Core.Test/Services/MailKitSmtpMailDeliveryServiceTests.cs b/test/Core.Test/Services/MailKitSmtpMailDeliveryServiceTests.cs index 4e7e36fe02..06ee99dbef 100644 --- a/test/Core.Test/Services/MailKitSmtpMailDeliveryServiceTests.cs +++ b/test/Core.Test/Services/MailKitSmtpMailDeliveryServiceTests.cs @@ -1,6 +1,8 @@ -using Bit.Core.Services; +using Bit.Core.Platform.X509ChainCustomization; +using Bit.Core.Services; using Bit.Core.Settings; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using NSubstitute; using Xunit; @@ -23,7 +25,8 @@ public class MailKitSmtpMailDeliveryServiceTests _sut = new MailKitSmtpMailDeliveryService( _globalSettings, - _logger + _logger, + Options.Create(new X509ChainOptions()) ); } diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index 3158c1595c..02ff24d9bf 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -341,19 +341,19 @@ public class UserServiceTests } [Theory, BitAutoData] - public async Task IsManagedByAnyOrganizationAsync_WithAccountDeprovisioningDisabled_ReturnsFalse( + public async Task IsClaimedByAnyOrganizationAsync_WithAccountDeprovisioningDisabled_ReturnsFalse( SutProvider sutProvider, Guid userId) { sutProvider.GetDependency() .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) .Returns(false); - var result = await sutProvider.Sut.IsManagedByAnyOrganizationAsync(userId); + var result = await sutProvider.Sut.IsClaimedByAnyOrganizationAsync(userId); Assert.False(result); } [Theory, BitAutoData] - public async Task IsManagedByAnyOrganizationAsync_WithAccountDeprovisioningEnabled_WithManagingEnabledOrganization_ReturnsTrue( + public async Task IsClaimedByAnyOrganizationAsync_WithAccountDeprovisioningEnabled_WithManagingEnabledOrganization_ReturnsTrue( SutProvider sutProvider, Guid userId, Organization organization) { organization.Enabled = true; @@ -367,12 +367,12 @@ public class UserServiceTests .GetByVerifiedUserEmailDomainAsync(userId) .Returns(new[] { organization }); - var result = await sutProvider.Sut.IsManagedByAnyOrganizationAsync(userId); + var result = await sutProvider.Sut.IsClaimedByAnyOrganizationAsync(userId); Assert.True(result); } [Theory, BitAutoData] - public async Task IsManagedByAnyOrganizationAsync_WithAccountDeprovisioningEnabled_WithManagingDisabledOrganization_ReturnsFalse( + public async Task IsClaimedByAnyOrganizationAsync_WithAccountDeprovisioningEnabled_WithManagingDisabledOrganization_ReturnsFalse( SutProvider sutProvider, Guid userId, Organization organization) { organization.Enabled = false; @@ -386,12 +386,12 @@ public class UserServiceTests .GetByVerifiedUserEmailDomainAsync(userId) .Returns(new[] { organization }); - var result = await sutProvider.Sut.IsManagedByAnyOrganizationAsync(userId); + var result = await sutProvider.Sut.IsClaimedByAnyOrganizationAsync(userId); Assert.False(result); } [Theory, BitAutoData] - public async Task IsManagedByAnyOrganizationAsync_WithAccountDeprovisioningEnabled_WithOrganizationUseSsoFalse_ReturnsFalse( + public async Task IsClaimedByAnyOrganizationAsync_WithAccountDeprovisioningEnabled_WithOrganizationUseSsoFalse_ReturnsFalse( SutProvider sutProvider, Guid userId, Organization organization) { organization.Enabled = true; @@ -405,7 +405,7 @@ public class UserServiceTests .GetByVerifiedUserEmailDomainAsync(userId) .Returns(new[] { organization }); - var result = await sutProvider.Sut.IsManagedByAnyOrganizationAsync(userId); + var result = await sutProvider.Sut.IsClaimedByAnyOrganizationAsync(userId); Assert.False(result); } diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs index 092ab95a14..14b6f50415 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs @@ -1,6 +1,9 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Utilities; using Xunit; @@ -424,4 +427,173 @@ public class OrganizationUserRepositoryTests Assert.Equal(createdOrgUserIds.ToHashSet(), readOrgUserIds.ToHashSet()); } + + [DatabaseTheory, DatabaseData] + public async Task CreateManyAsync_WithCollectionAndGroup_SaveSuccessfully( + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IGroupRepository groupRepository) + { + var requestTime = DateTime.UtcNow; + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + BillingEmail = "billing@test.com", // TODO: EF does not enfore this being NOT NULL + Plan = "Test", // TODO: EF does not enforce this being NOT NULl, + CreationDate = requestTime + }); + + var collection1 = await collectionRepository.CreateAsync(new Collection + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Name = "Test Collection", + ExternalId = "external-collection-1", + CreationDate = requestTime, + RevisionDate = requestTime + }); + var collection2 = await collectionRepository.CreateAsync(new Collection + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Name = "Test Collection", + ExternalId = "external-collection-1", + CreationDate = requestTime, + RevisionDate = requestTime + }); + var collection3 = await collectionRepository.CreateAsync(new Collection + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Name = "Test Collection", + ExternalId = "external-collection-1", + CreationDate = requestTime, + RevisionDate = requestTime + }); + + var group1 = await groupRepository.CreateAsync(new Group + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Name = "Test Group", + ExternalId = "external-group-1" + }); + var group2 = await groupRepository.CreateAsync(new Group + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Name = "Test Group", + ExternalId = "external-group-1" + }); + var group3 = await groupRepository.CreateAsync(new Group + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Name = "Test Group", + ExternalId = "external-group-1" + }); + + + var orgUserCollection = new List + { + new() + { + OrganizationUser = new OrganizationUser + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Email = "test-user@test.com", + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.Owner, + ExternalId = "externalid-1", + Permissions = CoreHelpers.ClassToJsonData(new Permissions()), + AccessSecretsManager = false + }, + Collections = + [ + new CollectionAccessSelection + { + Id = collection1.Id, + ReadOnly = true, + HidePasswords = false, + Manage = false + } + ], + Groups = [group1.Id] + }, + new() + { + OrganizationUser = new OrganizationUser + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Email = "test-user@test.com", + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.Owner, + ExternalId = "externalid-1", + Permissions = CoreHelpers.ClassToJsonData(new Permissions()), + AccessSecretsManager = false + }, + Collections = + [ + new CollectionAccessSelection + { + Id = collection2.Id, + ReadOnly = true, + HidePasswords = false, + Manage = false + } + ], + Groups = [group2.Id] + }, + new() + { + OrganizationUser = new OrganizationUser + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Email = "test-user@test.com", + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.Owner, + ExternalId = "externalid-1", + Permissions = CoreHelpers.ClassToJsonData(new Permissions()), + AccessSecretsManager = false + }, + Collections = + [ + new CollectionAccessSelection + { + Id = collection3.Id, + ReadOnly = true, + HidePasswords = false, + Manage = false + } + ], + Groups = [group3.Id] + } + }; + + await organizationUserRepository.CreateManyAsync(orgUserCollection); + + var orgUser1 = await organizationUserRepository.GetDetailsByIdWithCollectionsAsync(orgUserCollection[0].OrganizationUser.Id); + var group1Database = await groupRepository.GetManyIdsByUserIdAsync(orgUserCollection[0].OrganizationUser.Id); + Assert.Equal(orgUserCollection[0].OrganizationUser.Id, orgUser1.OrganizationUser.Id); + Assert.Equal(collection1.Id, orgUser1.Collections.First().Id); + Assert.Equal(group1.Id, group1Database.First()); + + + var orgUser2 = await organizationUserRepository.GetDetailsByIdWithCollectionsAsync(orgUserCollection[1].OrganizationUser.Id); + var group2Database = await groupRepository.GetManyIdsByUserIdAsync(orgUserCollection[1].OrganizationUser.Id); + Assert.Equal(orgUserCollection[1].OrganizationUser.Id, orgUser2.OrganizationUser.Id); + Assert.Equal(collection2.Id, orgUser2.Collections.First().Id); + Assert.Equal(group2.Id, group2Database.First()); + + var orgUser3 = await organizationUserRepository.GetDetailsByIdWithCollectionsAsync(orgUserCollection[2].OrganizationUser.Id); + var group3Database = await groupRepository.GetManyIdsByUserIdAsync(orgUserCollection[2].OrganizationUser.Id); + Assert.Equal(orgUserCollection[2].OrganizationUser.Id, orgUser3.OrganizationUser.Id); + Assert.Equal(collection3.Id, orgUser3.Collections.First().Id); + Assert.Equal(group3.Id, group3Database.First()); + } } diff --git a/util/Migrator/DbScripts/2025-02-17_00_OrgUsers_CreateManyUsersCollectionsGroups.sql b/util/Migrator/DbScripts/2025-02-17_00_OrgUsers_CreateManyUsersCollectionsGroups.sql new file mode 100644 index 0000000000..ab7ab9cc88 --- /dev/null +++ b/util/Migrator/DbScripts/2025-02-17_00_OrgUsers_CreateManyUsersCollectionsGroups.sql @@ -0,0 +1,97 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_CreateManyWithCollectionsAndGroups] + @organizationUserData NVARCHAR(MAX), + @collectionData NVARCHAR(MAX), + @groupData NVARCHAR(MAX) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OrganizationUser] + ( + [Id], + [OrganizationId], + [UserId], + [Email], + [Key], + [Status], + [Type], + [ExternalId], + [CreationDate], + [RevisionDate], + [Permissions], + [ResetPasswordKey], + [AccessSecretsManager] + ) + SELECT + OUI.[Id], + OUI.[OrganizationId], + OUI.[UserId], + OUI.[Email], + OUI.[Key], + OUI.[Status], + OUI.[Type], + OUI.[ExternalId], + OUI.[CreationDate], + OUI.[RevisionDate], + OUI.[Permissions], + OUI.[ResetPasswordKey], + OUI.[AccessSecretsManager] + FROM + OPENJSON(@organizationUserData) + WITH ( + [Id] UNIQUEIDENTIFIER '$.Id', + [OrganizationId] UNIQUEIDENTIFIER '$.OrganizationId', + [UserId] UNIQUEIDENTIFIER '$.UserId', + [Email] NVARCHAR(256) '$.Email', + [Key] VARCHAR(MAX) '$.Key', + [Status] SMALLINT '$.Status', + [Type] TINYINT '$.Type', + [ExternalId] NVARCHAR(300) '$.ExternalId', + [CreationDate] DATETIME2(7) '$.CreationDate', + [RevisionDate] DATETIME2(7) '$.RevisionDate', + [Permissions] NVARCHAR (MAX) '$.Permissions', + [ResetPasswordKey] VARCHAR (MAX) '$.ResetPasswordKey', + [AccessSecretsManager] BIT '$.AccessSecretsManager' + ) OUI + + INSERT INTO [dbo].[GroupUser] + ( + [OrganizationUserId], + [GroupId] + ) + SELECT + OUG.OrganizationUserId, + OUG.GroupId + FROM + OPENJSON(@groupData) + WITH( + [OrganizationUserId] UNIQUEIDENTIFIER '$.OrganizationUserId', + [GroupId] UNIQUEIDENTIFIER '$.GroupId' + ) OUG + + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + OUC.[CollectionId], + OUC.[OrganizationUserId], + OUC.[ReadOnly], + OUC.[HidePasswords], + OUC.[Manage] + FROM + OPENJSON(@collectionData) + WITH( + [CollectionId] UNIQUEIDENTIFIER '$.CollectionId', + [OrganizationUserId] UNIQUEIDENTIFIER '$.OrganizationUserId', + [ReadOnly] BIT '$.ReadOnly', + [HidePasswords] BIT '$.HidePasswords', + [Manage] BIT '$.Manage' + ) OUC +END +go + diff --git a/util/Migrator/DbScripts/2025-04-03_00_OrganizationIntegrationCUD.sql b/util/Migrator/DbScripts/2025-04-03_00_OrganizationIntegrationCUD.sql new file mode 100644 index 0000000000..35a8a1442d --- /dev/null +++ b/util/Migrator/DbScripts/2025-04-03_00_OrganizationIntegrationCUD.sql @@ -0,0 +1,351 @@ +-- Configure FK to cascade on delete +IF EXISTS(SELECT * +FROM information_schema.table_constraints +WHERE table_name='OrganizationIntegrationConfiguration' + AND constraint_name='FK_OrganizationIntegrationConfiguration_OrganizationIntegration') +BEGIN + ALTER TABLE [dbo].[OrganizationIntegrationConfiguration] DROP FK_OrganizationIntegrationConfiguration_OrganizationIntegration; + ALTER TABLE [dbo].[OrganizationIntegrationConfiguration] ADD CONSTRAINT [FK_OrganizationIntegrationConfiguration_OrganizationIntegration] FOREIGN KEY ([OrganizationIntegrationId]) REFERENCES [dbo].[OrganizationIntegration] ([Id]) ON DELETE CASCADE; +END +GO + +-- New procedures for CRUD on OrganizationIntegration and OrganizationIntegrationConfiguration +CREATE OR ALTER PROCEDURE [dbo].[OrganizationIntegration_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @Type SMALLINT, + @Configuration VARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OrganizationIntegration] + ( + [Id], + [OrganizationId], + [Type], + [Configuration], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @OrganizationId, + @Type, + @Configuration, + @CreationDate, + @RevisionDate + ) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationIntegrationConfiguration_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationIntegrationId UNIQUEIDENTIFIER, + @EventType SMALLINT, + @Configuration VARCHAR(MAX), + @Template VARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OrganizationIntegrationConfiguration] + ( + [Id], + [OrganizationIntegrationId], + [EventType], + [Configuration], + [Template], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @OrganizationIntegrationId, + @EventType, + @Configuration, + @Template, + @CreationDate, + @RevisionDate + ) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationIntegration_Update] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @Type SMALLINT, + @Configuration VARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[OrganizationIntegration] + SET + [OrganizationId] = @OrganizationId, + [Type] = @Type, + [Configuration] = @Configuration, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationIntegrationConfiguration_Update] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationIntegrationId UNIQUEIDENTIFIER, + @EventType SMALLINT, + @Configuration VARCHAR(MAX), + @Template VARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[OrganizationIntegrationConfiguration] + SET + [OrganizationIntegrationId] = @OrganizationIntegrationId, + [EventType] = @EventType, + [Configuration] = @Configuration, + [Template] = @Template, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationIntegration_DeleteById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + DELETE + FROM + [dbo].[OrganizationIntegration] + WHERE + [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationIntegrationConfiguration_DeleteById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + DELETE + FROM + [dbo].[OrganizationIntegrationConfiguration] + WHERE + [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationIntegration_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[OrganizationIntegration] + WHERE + [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationIntegrationConfiguration_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[OrganizationIntegrationConfiguration] + WHERE + [Id] = @Id +END +GO + +-- Organization cleanup +CREATE OR ALTER PROCEDURE [dbo].[Organization_DeleteById] + @Id UNIQUEIDENTIFIER +WITH + RECOMPILE +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @Id + + DECLARE @BatchSize INT = 100 + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION Organization_DeleteById_Ciphers + + DELETE TOP(@BatchSize) + FROM + [dbo].[Cipher] + WHERE + [UserId] IS NULL + AND [OrganizationId] = @Id + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION Organization_DeleteById_Ciphers + END + + BEGIN TRANSACTION Organization_DeleteById + + DELETE + FROM + [dbo].[AuthRequest] + WHERE + [OrganizationId] = @Id + + DELETE + FROM + [dbo].[SsoUser] + WHERE + [OrganizationId] = @Id + + DELETE + FROM + [dbo].[SsoConfig] + WHERE + [OrganizationId] = @Id + + DELETE CU + FROM + [dbo].[CollectionUser] CU + INNER JOIN + [dbo].[OrganizationUser] OU ON [CU].[OrganizationUserId] = [OU].[Id] + WHERE + [OU].[OrganizationId] = @Id + + DELETE AP + FROM + [dbo].[AccessPolicy] AP + INNER JOIN + [dbo].[OrganizationUser] OU ON [AP].[OrganizationUserId] = [OU].[Id] + WHERE + [OU].[OrganizationId] = @Id + + DELETE GU + FROM + [dbo].[GroupUser] GU + INNER JOIN + [dbo].[OrganizationUser] OU ON [GU].[OrganizationUserId] = [OU].[Id] + WHERE + [OU].[OrganizationId] = @Id + + DELETE + FROM + [dbo].[OrganizationUser] + WHERE + [OrganizationId] = @Id + + DELETE + FROM + [dbo].[ProviderOrganization] + WHERE + [OrganizationId] = @Id + + EXEC [dbo].[OrganizationApiKey_OrganizationDeleted] @Id + EXEC [dbo].[OrganizationConnection_OrganizationDeleted] @Id + EXEC [dbo].[OrganizationSponsorship_OrganizationDeleted] @Id + EXEC [dbo].[OrganizationDomain_OrganizationDeleted] @Id + EXEC [dbo].[OrganizationIntegration_OrganizationDeleted] @Id + + DELETE + FROM + [dbo].[Project] + WHERE + [OrganizationId] = @Id + + DELETE + FROM + [dbo].[Secret] + WHERE + [OrganizationId] = @Id + + DELETE AK + FROM + [dbo].[ApiKey] AK + INNER JOIN + [dbo].[ServiceAccount] SA ON [AK].[ServiceAccountId] = [SA].[Id] + WHERE + [SA].[OrganizationId] = @Id + + DELETE AP + FROM + [dbo].[AccessPolicy] AP + INNER JOIN + [dbo].[ServiceAccount] SA ON [AP].[GrantedServiceAccountId] = [SA].[Id] + WHERE + [SA].[OrganizationId] = @Id + + DELETE + FROM + [dbo].[ServiceAccount] + WHERE + [OrganizationId] = @Id + + -- Delete Notification Status + DELETE + NS + FROM + [dbo].[NotificationStatus] NS + INNER JOIN + [dbo].[Notification] N ON N.[Id] = NS.[NotificationId] + WHERE + N.[OrganizationId] = @Id + + -- Delete Notification + DELETE + FROM + [dbo].[Notification] + WHERE + [OrganizationId] = @Id + + DELETE + FROM + [dbo].[Organization] + WHERE + [Id] = @Id + + COMMIT TRANSACTION Organization_DeleteById +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationIntegration_OrganizationDeleted] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + DELETE + FROM + [dbo].[OrganizationIntegration] + WHERE + [OrganizationId] = @OrganizationId +END +GO