From dc18834aed525a80a3b246e37ea86deaaa0be478 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Fri, 30 Jan 2026 16:28:53 +0000 Subject: [PATCH] Implement InitPendingOrganizationValidator for improved organization initialization validation - Introduced IInitPendingOrganizationValidator interface and its implementation to encapsulate validation logic for organization initialization. - Refactored InitPendingOrganizationCommand to utilize the new validator for token validation, user email matching, organization state checks, and policy enforcement. - Enhanced dependency injection in OrganizationServiceCollectionExtensions to include the new validator. - Added comprehensive unit tests for the validator to ensure robust validation logic and error handling. --- .../InitPendingOrganizationCommand.cs | 113 +---- .../InitPendingOrganizationValidator.cs | 164 +++++++ ...OrganizationServiceCollectionExtensions.cs | 1 + .../InitPendingOrganizationCommandTests.cs | 191 ++++---- .../InitPendingOrganizationValidatorTests.cs | 435 ++++++++++++++++++ 5 files changed, 730 insertions(+), 174 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationValidator.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationValidatorTests.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs index 7ae957c0f2..698b10821a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs @@ -7,7 +7,6 @@ using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Utilities.v2.Results; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; -using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -21,7 +20,6 @@ using Bit.Core.Tokens; using Bit.Core.Utilities; using Microsoft.AspNetCore.DataProtection; using OneOf.Types; -using Error = Bit.Core.AdminConsole.Utilities.v2.Error; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; @@ -44,6 +42,7 @@ public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand private readonly IPushNotificationService _pushNotificationService; private readonly IPushRegistrationService _pushRegistrationService; private readonly IDeviceRepository _deviceRepository; + private readonly IInitPendingOrganizationValidator _validator; public InitPendingOrganizationCommand( IOrganizationService organizationService, @@ -62,7 +61,8 @@ public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand IUserRepository userRepository, IPushNotificationService pushNotificationService, IPushRegistrationService pushRegistrationService, - IDeviceRepository deviceRepository + IDeviceRepository deviceRepository, + IInitPendingOrganizationValidator validator ) { _organizationService = organizationService; @@ -82,6 +82,7 @@ public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand _pushNotificationService = pushNotificationService; _pushRegistrationService = pushRegistrationService; _deviceRepository = deviceRepository; + _validator = validator; } public async Task InitPendingOrganizationAsync(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken) @@ -94,7 +95,7 @@ public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand throw new BadRequestException("User invalid."); } - var tokenValid = ValidateInviteToken(orgUser, user, emailToken); + var tokenValid = _validator.ValidateInviteToken(orgUser, user, emailToken); if (!tokenValid) { @@ -170,14 +171,6 @@ public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand } } - private bool ValidateInviteToken(OrganizationUser orgUser, User user, string emailToken) - { - var tokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken( - _orgUserInviteTokenDataFactory, emailToken, orgUser); - - return tokenValid; - } - public async Task InitPendingOrganizationVNextAsync(InitPendingOrganizationRequest request) { var orgUser = await _organizationUserRepository.GetByIdAsync(request.OrganizationUserId); @@ -186,12 +179,12 @@ public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand return new OrganizationUserNotFoundError(); } - if (!ValidateInviteToken(orgUser, request.User, request.EmailToken)) + if (!_validator.ValidateInviteToken(orgUser, request.User, request.EmailToken)) { return new InvalidTokenError(); } - var validationError = ValidateUserEmail(orgUser, request.User); + var validationError = _validator.ValidateUserEmail(orgUser, request.User); if (validationError != null) { return validationError; @@ -203,18 +196,25 @@ public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand return new OrganizationNotFoundError(); } - if (orgUser.OrganizationId != request.OrganizationId) - { - return new OrganizationMismatchError(); - } - - validationError = ValidateOrganizationState(org); + validationError = _validator.ValidateOrganizationMatch(orgUser, request.OrganizationId); if (validationError != null) { return validationError; } - validationError = await ValidatePoliciesAsync(request.User, request.OrganizationId, org, orgUser); + validationError = _validator.ValidateOrganizationState(org); + if (validationError != null) + { + return validationError; + } + + validationError = await _validator.ValidatePoliciesAsync(request.User, request.OrganizationId); + if (validationError != null) + { + return validationError; + } + + validationError = await _validator.ValidateBusinessRulesAsync(request.User, org, orgUser); if (validationError != null) { return validationError; @@ -224,7 +224,6 @@ public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand PrepareOrganizationUserForConfirmation(orgUser, request); var updateActions = BuildDatabaseUpdateActions(org, orgUser, request); - await _organizationRepository.ExecuteOrganizationInitializationUpdatesAsync(updateActions); await SendNotificationsAsync(org, orgUser, request.User, request.OrganizationId); @@ -232,76 +231,6 @@ public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand return new None(); } - private static Error? ValidateUserEmail(OrganizationUser orgUser, User user) - { - if (string.IsNullOrWhiteSpace(orgUser.Email) || - !orgUser.Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase)) - { - return new EmailMismatchError(); - } - - return null; - } - - private static Error? ValidateOrganizationState(Organization org) - { - if (org.Enabled) - { - return new OrganizationAlreadyEnabledError(); - } - - if (org.Status != OrganizationStatusType.Pending) - { - return new OrganizationNotPendingError(); - } - - if (!string.IsNullOrEmpty(org.PublicKey) || !string.IsNullOrEmpty(org.PrivateKey)) - { - return new OrganizationHasKeysError(); - } - - return null; - } - - private async Task ValidatePoliciesAsync(User user, Guid organizationId, Organization org, OrganizationUser orgUser) - { - // Enforce Automatic User Confirmation Policy (when feature flag is enabled) - if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) - { - var autoConfirmReq = await _policyRequirementQuery.GetAsync(user.Id); - if (autoConfirmReq.CannotCreateNewOrganization()) - { - return new SingleOrgPolicyViolationError(); - } - } - - // Enforce Single Organization Policy - var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg); - if (anySingleOrgPolicies) - { - return new SingleOrgPolicyViolationError(); - } - - var twoFactorReq = await _policyRequirementQuery.GetAsync(user.Id); - if (twoFactorReq.IsTwoFactorRequiredForOrganization(organizationId) && - !await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user)) - { - return new TwoFactorRequiredError(); - } - - if (org.PlanType == PlanType.Free && - (orgUser.Type == OrganizationUserType.Owner || orgUser.Type == OrganizationUserType.Admin)) - { - var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(user.Id); - if (adminCount > 0) - { - return new FreeOrgAdminLimitError(); - } - } - - return null; - } - private static void PrepareOrganizationForInitialization(Organization org, InitPendingOrganizationRequest request) { org.Enabled = true; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationValidator.cs new file mode 100644 index 0000000000..e614c323e5 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationValidator.cs @@ -0,0 +1,164 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Enums; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Tokens; +using Error = Bit.Core.AdminConsole.Utilities.v2.Error; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; + +public interface IInitPendingOrganizationValidator +{ + /// + /// Validates the invite token for an organization user. + /// + bool ValidateInviteToken(OrganizationUser orgUser, User user, string emailToken); + + /// + /// Validates that the user's email matches the organization user's email. + /// + Error? ValidateUserEmail(OrganizationUser orgUser, User user); + + /// + /// Validates that the organization is in the correct state for initialization. + /// + Error? ValidateOrganizationState(Organization org); + + /// + /// Validates that the organization user's organization ID matches the expected organization ID. + /// + Error? ValidateOrganizationMatch(OrganizationUser orgUser, Guid organizationId); + + /// + /// Validates policy requirements for the user joining the organization. + /// + Task ValidatePoliciesAsync(User user, Guid organizationId); + + /// + /// Validates business rules for the user joining the organization (e.g., free org admin limits). + /// + Task ValidateBusinessRulesAsync(User user, Organization org, OrganizationUser orgUser); +} + +public class InitPendingOrganizationValidator : IInitPendingOrganizationValidator +{ + private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; + private readonly IFeatureService _featureService; + private readonly IPolicyService _policyService; + private readonly IPolicyRequirementQuery _policyRequirementQuery; + private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; + private readonly IOrganizationUserRepository _organizationUserRepository; + + public InitPendingOrganizationValidator( + IDataProtectorTokenFactory orgUserInviteTokenDataFactory, + IFeatureService featureService, + IPolicyService policyService, + IPolicyRequirementQuery policyRequirementQuery, + ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, + IOrganizationUserRepository organizationUserRepository) + { + _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; + _featureService = featureService; + _policyService = policyService; + _policyRequirementQuery = policyRequirementQuery; + _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; + _organizationUserRepository = organizationUserRepository; + } + + public bool ValidateInviteToken(OrganizationUser orgUser, User user, string emailToken) + { + return OrgUserInviteTokenable.ValidateOrgUserInviteStringToken( + _orgUserInviteTokenDataFactory, emailToken, orgUser); + } + + public Error? ValidateUserEmail(OrganizationUser orgUser, User user) + { + if (string.IsNullOrWhiteSpace(orgUser.Email) || + !orgUser.Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase)) + { + return new EmailMismatchError(); + } + + return null; + } + + public Error? ValidateOrganizationState(Organization org) + { + if (org.Enabled) + { + return new OrganizationAlreadyEnabledError(); + } + + if (org.Status != OrganizationStatusType.Pending) + { + return new OrganizationNotPendingError(); + } + + if (!string.IsNullOrEmpty(org.PublicKey) || !string.IsNullOrEmpty(org.PrivateKey)) + { + return new OrganizationHasKeysError(); + } + + return null; + } + + public Error? ValidateOrganizationMatch(OrganizationUser orgUser, Guid organizationId) + { + if (orgUser.OrganizationId != organizationId) + { + return new OrganizationMismatchError(); + } + + return null; + } + + public async Task ValidatePoliciesAsync(User user, Guid organizationId) + { + if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var autoConfirmReq = await _policyRequirementQuery.GetAsync(user.Id); + if (autoConfirmReq.CannotCreateNewOrganization()) + { + return new SingleOrgPolicyViolationError(); + } + } + + var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg); + if (anySingleOrgPolicies) + { + return new SingleOrgPolicyViolationError(); + } + + var twoFactorReq = await _policyRequirementQuery.GetAsync(user.Id); + if (twoFactorReq.IsTwoFactorRequiredForOrganization(organizationId) && + !await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user)) + { + return new TwoFactorRequiredError(); + } + + return null; + } + + public async Task ValidateBusinessRulesAsync(User user, Organization org, OrganizationUser orgUser) + { + if (org.PlanType == PlanType.Free && + (orgUser.Type == OrganizationUserType.Owner || orgUser.Type == OrganizationUserType.Admin)) + { + var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(user.Id); + if (adminCount > 0) + { + return new FreeOrgAdminLimitError(); + } + } + + return null; + } +} diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index c1ebc65d44..bd315486eb 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -214,6 +214,7 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommandTests.cs index 5809aa8c05..24e6fa85f9 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommandTests.cs @@ -1,13 +1,7 @@ using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; -using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Business.Tokenables; -using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; @@ -161,6 +155,11 @@ public class InitPendingOrganizationCommandTests var protectedToken = _orgUserInviteTokenDataFactory.Protect(orgUserInviteTokenable); sutProvider.GetDependency().GetByIdAsync(orgUserId).Returns(orgUser); + // Setup validator to accept the token for old InitPendingOrganizationAsync method + sutProvider.GetDependency() + .ValidateInviteToken(orgUser, Arg.Any(), protectedToken) + .Returns(true); + return protectedToken; } @@ -248,13 +247,14 @@ public class InitPendingOrganizationCommandTests orgUser.Email = user.Email; var requestWithInvalidToken = request with { User = user, EmailToken = "invalid-token" }; - sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); - sutProvider.Create(); - sutProvider.GetDependency() .GetByIdAsync(requestWithInvalidToken.OrganizationUserId) .Returns(orgUser); + sutProvider.GetDependency() + .ValidateInviteToken(orgUser, user, "invalid-token") + .Returns(false); + // Act var result = await sutProvider.Sut.InitPendingOrganizationVNextAsync(requestWithInvalidToken); @@ -274,13 +274,20 @@ public class InitPendingOrganizationCommandTests orgUser.Email = "different@email.com"; user.Email = "user@email.com"; - var token = CreateToken(orgUser, request.OrganizationUserId, sutProvider); - var requestWithUser = request with { User = user, EmailToken = token }; + var requestWithUser = request with { User = user, EmailToken = "valid-token" }; sutProvider.GetDependency() .GetByIdAsync(requestWithUser.OrganizationUserId) .Returns(orgUser); + sutProvider.GetDependency() + .ValidateInviteToken(orgUser, user, Arg.Any()) + .Returns(true); + + sutProvider.GetDependency() + .ValidateUserEmail(orgUser, user) + .Returns(new EmailMismatchError()); + // Act var result = await sutProvider.Sut.InitPendingOrganizationVNextAsync(requestWithUser); @@ -299,13 +306,20 @@ public class InitPendingOrganizationCommandTests // Arrange orgUser.Email = user.Email; - var token = CreateToken(orgUser, request.OrganizationUserId, sutProvider); - var requestWithUser = request with { User = user, EmailToken = token }; + var requestWithUser = request with { User = user, EmailToken = "valid-token" }; sutProvider.GetDependency() .GetByIdAsync(requestWithUser.OrganizationUserId) .Returns(orgUser); + sutProvider.GetDependency() + .ValidateInviteToken(orgUser, user, Arg.Any()) + .Returns(true); + + sutProvider.GetDependency() + .ValidateUserEmail(orgUser, user) + .Returns((Bit.Core.AdminConsole.Utilities.v2.Error)null); + sutProvider.GetDependency() .GetByIdAsync(requestWithUser.OrganizationId) .Returns((Organization)null); @@ -330,17 +344,28 @@ public class InitPendingOrganizationCommandTests orgUser.Email = user.Email; orgUser.OrganizationId = Guid.NewGuid(); // Different from request - var token = CreateToken(orgUser, request.OrganizationUserId, sutProvider); - var requestWithUser = request with { User = user, EmailToken = token }; + var requestWithUser = request with { User = user, EmailToken = "valid-token" }; sutProvider.GetDependency() .GetByIdAsync(requestWithUser.OrganizationUserId) .Returns(orgUser); + sutProvider.GetDependency() + .ValidateInviteToken(orgUser, user, Arg.Any()) + .Returns(true); + + sutProvider.GetDependency() + .ValidateUserEmail(orgUser, user) + .Returns((Bit.Core.AdminConsole.Utilities.v2.Error)null); + sutProvider.GetDependency() .GetByIdAsync(requestWithUser.OrganizationId) .Returns(org); + sutProvider.GetDependency() + .ValidateOrganizationMatch(orgUser, requestWithUser.OrganizationId) + .Returns(new OrganizationMismatchError()); + // Act var result = await sutProvider.Sut.InitPendingOrganizationVNextAsync(requestWithUser); @@ -370,6 +395,14 @@ public class InitPendingOrganizationCommandTests .GetByIdAsync(updatedRequest.OrganizationId) .Returns(org); + sutProvider.GetDependency() + .ValidateOrganizationMatch(orgUser, updatedRequest.OrganizationId) + .Returns((Bit.Core.AdminConsole.Utilities.v2.Error)null); + + sutProvider.GetDependency() + .ValidateOrganizationState(org) + .Returns(new OrganizationAlreadyEnabledError()); + // Act var result = await sutProvider.Sut.InitPendingOrganizationVNextAsync(updatedRequest); @@ -399,6 +432,14 @@ public class InitPendingOrganizationCommandTests .GetByIdAsync(updatedRequest.OrganizationId) .Returns(org); + sutProvider.GetDependency() + .ValidateOrganizationMatch(orgUser, updatedRequest.OrganizationId) + .Returns((Bit.Core.AdminConsole.Utilities.v2.Error)null); + + sutProvider.GetDependency() + .ValidateOrganizationState(org) + .Returns(new OrganizationNotPendingError()); + // Act var result = await sutProvider.Sut.InitPendingOrganizationVNextAsync(updatedRequest); @@ -428,6 +469,14 @@ public class InitPendingOrganizationCommandTests .GetByIdAsync(updatedRequest.OrganizationId) .Returns(org); + sutProvider.GetDependency() + .ValidateOrganizationMatch(orgUser, updatedRequest.OrganizationId) + .Returns((Bit.Core.AdminConsole.Utilities.v2.Error)null); + + sutProvider.GetDependency() + .ValidateOrganizationState(org) + .Returns(new OrganizationHasKeysError()); + // Act var result = await sutProvider.Sut.InitPendingOrganizationVNextAsync(updatedRequest); @@ -457,6 +506,14 @@ public class InitPendingOrganizationCommandTests .GetByIdAsync(updatedRequest.OrganizationId) .Returns(org); + sutProvider.GetDependency() + .ValidateOrganizationMatch(orgUser, updatedRequest.OrganizationId) + .Returns((Bit.Core.AdminConsole.Utilities.v2.Error)null); + + sutProvider.GetDependency() + .ValidateOrganizationState(org) + .Returns(new OrganizationHasKeysError()); + // Act var result = await sutProvider.Sut.InitPendingOrganizationVNextAsync(updatedRequest); @@ -476,9 +533,9 @@ public class InitPendingOrganizationCommandTests // Arrange var updatedRequest = SetupValidOrgAndOrgUser(user, org, orgUser, request, sutProvider); - sutProvider.GetDependency() - .AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg) - .Returns(true); + sutProvider.GetDependency() + .ValidatePoliciesAsync(user, updatedRequest.OrganizationId) + .Returns(new SingleOrgPolicyViolationError()); // Act var result = await sutProvider.Sut.InitPendingOrganizationVNextAsync(updatedRequest); @@ -499,33 +556,9 @@ public class InitPendingOrganizationCommandTests // Arrange var updatedRequest = SetupValidOrgAndOrgUser(user, org, orgUser, request, sutProvider); - sutProvider.GetDependency() - .IsEnabled(Arg.Any()) - .Returns(false); - - sutProvider.GetDependency() - .AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg) - .Returns(false); - - // Create a PolicyDetails that requires 2FA for this organization - var policyDetails = new PolicyDetails - { - OrganizationId = updatedRequest.OrganizationId, - OrganizationUserId = updatedRequest.OrganizationUserId, - PolicyType = PolicyType.TwoFactorAuthentication, - OrganizationUserType = OrganizationUserType.Owner, - OrganizationUserStatus = OrganizationUserStatusType.Invited - }; - - var twoFactorReq = new RequireTwoFactorPolicyRequirement(new[] { policyDetails }); - - sutProvider.GetDependency() - .GetAsync(user.Id) - .Returns(twoFactorReq); - - sutProvider.GetDependency() - .TwoFactorIsEnabledAsync(user) - .Returns(false); + sutProvider.GetDependency() + .ValidatePoliciesAsync(user, updatedRequest.OrganizationId) + .Returns(new TwoFactorRequiredError()); // Act var result = await sutProvider.Sut.InitPendingOrganizationVNextAsync(updatedRequest); @@ -549,24 +582,13 @@ public class InitPendingOrganizationCommandTests org.PlanType = PlanType.Free; orgUser.Type = OrganizationUserType.Owner; - sutProvider.GetDependency() - .IsEnabled(Arg.Any()) - .Returns(false); + sutProvider.GetDependency() + .ValidatePoliciesAsync(user, updatedRequest.OrganizationId) + .Returns((Bit.Core.AdminConsole.Utilities.v2.Error)null); - sutProvider.GetDependency() - .AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg) - .Returns(false); - - // Create a RequireTwoFactorPolicyRequirement with no policies (2FA not required) - var twoFactorReq = new RequireTwoFactorPolicyRequirement(Enumerable.Empty()); - - sutProvider.GetDependency() - .GetAsync(user.Id) - .Returns(twoFactorReq); - - sutProvider.GetDependency() - .GetCountByFreeOrganizationAdminUserAsync(user.Id) - .Returns(1); + sutProvider.GetDependency() + .ValidateBusinessRulesAsync(user, org, orgUser) + .Returns(new FreeOrgAdminLimitError()); // Act var result = await sutProvider.Sut.InitPendingOrganizationVNextAsync(updatedRequest); @@ -585,25 +607,14 @@ public class InitPendingOrganizationCommandTests { var updatedRequest = SetupValidOrgAndOrgUser(user, org, orgUser, request, sutProvider); - // Setup policy checks to pass - sutProvider.GetDependency() - .IsEnabled(Arg.Any()) - .Returns(false); + // Setup validator to pass all policy and business rule checks + sutProvider.GetDependency() + .ValidatePoliciesAsync(user, updatedRequest.OrganizationId) + .Returns((Bit.Core.AdminConsole.Utilities.v2.Error)null); - sutProvider.GetDependency() - .AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg) - .Returns(false); - - // Create a RequireTwoFactorPolicyRequirement with no policies (2FA not required) - var twoFactorReq = new RequireTwoFactorPolicyRequirement(Enumerable.Empty()); - - sutProvider.GetDependency() - .GetAsync(user.Id) - .Returns(twoFactorReq); - - sutProvider.GetDependency() - .GetCountByFreeOrganizationAdminUserAsync(user.Id) - .Returns(0); + sutProvider.GetDependency() + .ValidateBusinessRulesAsync(user, org, orgUser) + .Returns((Bit.Core.AdminConsole.Utilities.v2.Error)null); // Setup repositories to return update delegates sutProvider.GetDependency() @@ -649,6 +660,15 @@ public class InitPendingOrganizationCommandTests .GetByIdAsync(updatedRequest.OrganizationId) .Returns(org); + // Setup validator for organization match and state + sutProvider.GetDependency() + .ValidateOrganizationMatch(orgUser, updatedRequest.OrganizationId) + .Returns((Bit.Core.AdminConsole.Utilities.v2.Error)null); + + sutProvider.GetDependency() + .ValidateOrganizationState(org) + .Returns((Bit.Core.AdminConsole.Utilities.v2.Error)null); + return updatedRequest; } @@ -660,13 +680,20 @@ public class InitPendingOrganizationCommandTests { orgUser.Email = user.Email; - var token = CreateToken(orgUser, request.OrganizationUserId, sutProvider); - var updatedRequest = request with { User = user, EmailToken = token }; + var updatedRequest = request with { User = user, EmailToken = "valid-token" }; sutProvider.GetDependency() .GetByIdAsync(updatedRequest.OrganizationUserId) .Returns(orgUser); + sutProvider.GetDependency() + .ValidateInviteToken(orgUser, user, Arg.Any()) + .Returns(true); + + sutProvider.GetDependency() + .ValidateUserEmail(orgUser, user) + .Returns((Bit.Core.AdminConsole.Utilities.v2.Error)null); + return updatedRequest; } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationValidatorTests.cs new file mode 100644 index 0000000000..7f5fb6deb1 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationValidatorTests.cs @@ -0,0 +1,435 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Enums; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Tokens; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Fakes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations; + +[SutProviderCustomize] +public class InitPendingOrganizationValidatorTests +{ + private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory = Substitute.For(); + private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory = new FakeDataProtectorTokenFactory(); + + [Theory, BitAutoData] + public void ValidateInviteToken_ValidToken_ReturnsTrue( + User user, + OrganizationUser orgUser, + SutProvider sutProvider) + { + // Arrange + orgUser.Email = user.Email; + sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); + sutProvider.Create(); + + _orgUserInviteTokenableFactory.CreateToken(orgUser).Returns(new OrgUserInviteTokenable(orgUser) + { + ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) + }); + + var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser); + var protectedToken = _orgUserInviteTokenDataFactory.Protect(orgUserInviteTokenable); + + // Act + var result = sutProvider.Sut.ValidateInviteToken(orgUser, user, protectedToken); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public void ValidateInviteToken_InvalidToken_ReturnsFalse( + User user, + OrganizationUser orgUser, + SutProvider sutProvider) + { + // Arrange + sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); + sutProvider.Create(); + + // Act + var result = sutProvider.Sut.ValidateInviteToken(orgUser, user, "invalid-token"); + + // Assert + Assert.False(result); + } + + [Theory, BitAutoData] + public void ValidateUserEmail_MatchingEmail_ReturnsNull( + User user, + OrganizationUser orgUser, + SutProvider sutProvider) + { + // Arrange + orgUser.Email = user.Email; + + // Act + var result = sutProvider.Sut.ValidateUserEmail(orgUser, user); + + // Assert + Assert.Null(result); + } + + [Theory, BitAutoData] + public void ValidateUserEmail_MismatchedEmail_ReturnsError( + User user, + OrganizationUser orgUser, + SutProvider sutProvider) + { + // Arrange + orgUser.Email = "different@example.com"; + user.Email = "user@example.com"; + + // Act + var result = sutProvider.Sut.ValidateUserEmail(orgUser, user); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public void ValidateUserEmail_NullOrgUserEmail_ReturnsError( + User user, + OrganizationUser orgUser, + SutProvider sutProvider) + { + // Arrange + orgUser.Email = null; + + // Act + var result = sutProvider.Sut.ValidateUserEmail(orgUser, user); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public void ValidateOrganizationState_ValidState_ReturnsNull( + Organization org, + SutProvider sutProvider) + { + // Arrange + org.Enabled = false; + org.Status = OrganizationStatusType.Pending; + org.PublicKey = null; + org.PrivateKey = null; + + // Act + var result = sutProvider.Sut.ValidateOrganizationState(org); + + // Assert + Assert.Null(result); + } + + [Theory, BitAutoData] + public void ValidateOrganizationState_OrgEnabled_ReturnsError( + Organization org, + SutProvider sutProvider) + { + // Arrange + org.Enabled = true; + org.Status = OrganizationStatusType.Pending; + org.PublicKey = null; + org.PrivateKey = null; + + // Act + var result = sutProvider.Sut.ValidateOrganizationState(org); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public void ValidateOrganizationState_OrgNotPending_ReturnsError( + Organization org, + SutProvider sutProvider) + { + // Arrange + org.Enabled = false; + org.Status = OrganizationStatusType.Created; + org.PublicKey = null; + org.PrivateKey = null; + + // Act + var result = sutProvider.Sut.ValidateOrganizationState(org); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public void ValidateOrganizationState_OrgHasKeys_ReturnsError( + Organization org, + SutProvider sutProvider) + { + // Arrange + org.Enabled = false; + org.Status = OrganizationStatusType.Pending; + org.PublicKey = "existing-public-key"; + org.PrivateKey = "existing-private-key"; + + // Act + var result = sutProvider.Sut.ValidateOrganizationState(org); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public void ValidateOrganizationMatch_Matching_ReturnsNull( + OrganizationUser orgUser, + Guid organizationId, + SutProvider sutProvider) + { + // Arrange + orgUser.OrganizationId = organizationId; + + // Act + var result = sutProvider.Sut.ValidateOrganizationMatch(orgUser, organizationId); + + // Assert + Assert.Null(result); + } + + [Theory, BitAutoData] + public void ValidateOrganizationMatch_NotMatching_ReturnsError( + OrganizationUser orgUser, + Guid organizationId, + SutProvider sutProvider) + { + // Arrange + orgUser.OrganizationId = Guid.NewGuid(); + + // Act + var result = sutProvider.Sut.ValidateOrganizationMatch(orgUser, organizationId); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task ValidatePoliciesAsync_AllPoliciesPass_ReturnsNull( + User user, + Guid organizationId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(Arg.Any()) + .Returns(false); + + sutProvider.GetDependency() + .AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg) + .Returns(false); + + var twoFactorReq = new RequireTwoFactorPolicyRequirement(Enumerable.Empty()); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(twoFactorReq); + + // Act + var result = await sutProvider.Sut.ValidatePoliciesAsync(user, organizationId); + + // Assert + Assert.Null(result); + } + + [Theory, BitAutoData] + public async Task ValidatePoliciesAsync_SingleOrgPolicyViolation_ReturnsError( + User user, + Guid organizationId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(Arg.Any()) + .Returns(false); + + sutProvider.GetDependency() + .AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg) + .Returns(true); + + // Act + var result = await sutProvider.Sut.ValidatePoliciesAsync(user, organizationId); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task ValidatePoliciesAsync_TwoFactorRequired_UserDoesNotHave2FA_ReturnsError( + User user, + Guid organizationId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(Arg.Any()) + .Returns(false); + + sutProvider.GetDependency() + .AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg) + .Returns(false); + + var policyDetails = new PolicyDetails + { + OrganizationId = organizationId, + PolicyType = PolicyType.TwoFactorAuthentication, + OrganizationUserType = OrganizationUserType.Owner, + OrganizationUserStatus = OrganizationUserStatusType.Invited + }; + + var twoFactorReq = new RequireTwoFactorPolicyRequirement(new[] { policyDetails }); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(twoFactorReq); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(user) + .Returns(false); + + // Act + var result = await sutProvider.Sut.ValidatePoliciesAsync(user, organizationId); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task ValidateBusinessRulesAsync_PaidOrg_ReturnsNull( + User user, + Organization org, + OrganizationUser orgUser, + SutProvider sutProvider) + { + // Arrange + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.Type = OrganizationUserType.Owner; + + // Act + var result = await sutProvider.Sut.ValidateBusinessRulesAsync(user, org, orgUser); + + // Assert + Assert.Null(result); + } + + [Theory, BitAutoData] + public async Task ValidateBusinessRulesAsync_FreeOrgNonAdmin_ReturnsNull( + User user, + Organization org, + OrganizationUser orgUser, + SutProvider sutProvider) + { + // Arrange + org.PlanType = PlanType.Free; + orgUser.Type = OrganizationUserType.User; + + // Act + var result = await sutProvider.Sut.ValidateBusinessRulesAsync(user, org, orgUser); + + // Assert + Assert.Null(result); + } + + [Theory, BitAutoData] + public async Task ValidateBusinessRulesAsync_FreeOrgAdminNoExisting_ReturnsNull( + User user, + Organization org, + OrganizationUser orgUser, + SutProvider sutProvider) + { + // Arrange + org.PlanType = PlanType.Free; + orgUser.Type = OrganizationUserType.Owner; + + sutProvider.GetDependency() + .GetCountByFreeOrganizationAdminUserAsync(user.Id) + .Returns(0); + + // Act + var result = await sutProvider.Sut.ValidateBusinessRulesAsync(user, org, orgUser); + + // Assert + Assert.Null(result); + } + + [Theory, BitAutoData] + public async Task ValidateBusinessRulesAsync_FreeOrgAdminLimitExceeded_ReturnsError( + User user, + Organization org, + OrganizationUser orgUser, + SutProvider sutProvider) + { + // Arrange + org.PlanType = PlanType.Free; + orgUser.Type = OrganizationUserType.Owner; + + sutProvider.GetDependency() + .GetCountByFreeOrganizationAdminUserAsync(user.Id) + .Returns(1); + + // Act + var result = await sutProvider.Sut.ValidateBusinessRulesAsync(user, org, orgUser); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task ValidatePoliciesAsync_AutoConfirmPolicyViolation_ReturnsError( + User user, + Guid organizationId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + var policyDetails = new PolicyDetails + { + OrganizationId = organizationId, + PolicyType = PolicyType.AutomaticUserConfirmation, + OrganizationUserType = OrganizationUserType.Owner, + OrganizationUserStatus = OrganizationUserStatusType.Invited + }; + + var autoConfirmReq = new AutomaticUserConfirmationPolicyRequirement(new[] { policyDetails }); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(autoConfirmReq); + + // Act + var result = await sutProvider.Sut.ValidatePoliciesAsync(user, organizationId); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } +}