diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index 3d18e95f7b..4e8a23cf4e 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -9,6 +9,9 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; +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.Billing.Enums; @@ -60,6 +63,7 @@ public class ProviderService : IProviderService private readonly IProviderBillingService _providerBillingService; private readonly IPricingClient _pricingClient; private readonly IProviderClientOrganizationSignUpCommand _providerClientOrganizationSignUpCommand; + private readonly IPolicyRequirementQuery _policyRequirementQuery; public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository, IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository, @@ -69,7 +73,8 @@ public class ProviderService : IProviderService ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService, IDataProtectorTokenFactory providerDeleteTokenDataFactory, IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService, IPricingClient pricingClient, - IProviderClientOrganizationSignUpCommand providerClientOrganizationSignUpCommand) + IProviderClientOrganizationSignUpCommand providerClientOrganizationSignUpCommand, + IPolicyRequirementQuery policyRequirementQuery) { _providerRepository = providerRepository; _providerUserRepository = providerUserRepository; @@ -90,6 +95,7 @@ public class ProviderService : IProviderService _providerBillingService = providerBillingService; _pricingClient = pricingClient; _providerClientOrganizationSignUpCommand = providerClientOrganizationSignUpCommand; + _policyRequirementQuery = policyRequirementQuery; } public async Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress) @@ -117,6 +123,18 @@ public class ProviderService : IProviderService throw new BadRequestException("Invalid owner."); } + if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var organizationAutoConfirmPolicyRequirement = await _policyRequirementQuery + .GetAsync(ownerUserId); + + if (organizationAutoConfirmPolicyRequirement + .CannotCreateProvider()) + { + throw new BadRequestException(new UserCannotJoinProvider().Message); + } + } + var customer = await _providerBillingService.SetupCustomer(provider, paymentMethod, billingAddress); provider.GatewayCustomerId = customer.Id; var subscription = await _providerBillingService.SetupSubscription(provider); @@ -249,6 +267,18 @@ public class ProviderService : IProviderService throw new BadRequestException("User email does not match invite."); } + if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var organizationAutoConfirmPolicyRequirement = await _policyRequirementQuery + .GetAsync(user.Id); + + if (organizationAutoConfirmPolicyRequirement + .CannotJoinProvider()) + { + throw new BadRequestException(new UserCannotJoinProvider().Message); + } + } + providerUser.Status = ProviderUserStatusType.Accepted; providerUser.UserId = user.Id; providerUser.Email = null; @@ -294,6 +324,19 @@ public class ProviderService : IProviderService throw new BadRequestException("Invalid user."); } + if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var organizationAutoConfirmPolicyRequirement = await _policyRequirementQuery + .GetAsync(user.Id); + + if (organizationAutoConfirmPolicyRequirement + .CannotJoinProvider()) + { + result.Add(Tuple.Create(providerUser, new UserCannotJoinProvider().Message)); + continue; + } + } + providerUser.Status = ProviderUserStatusType.Confirmed; providerUser.Key = keys[providerUser.Id]; providerUser.Email = null; diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs index 11ffe115e2..7ec11894ad 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -1,12 +1,17 @@ using Bit.Commercial.Core.AdminConsole.Services; using Bit.Commercial.Core.Test.AdminConsole.AutoFixture; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; using Bit.Core.AdminConsole.Models.Business.Tokenables; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Payment.Models; @@ -101,6 +106,57 @@ public class ProviderServiceTests .ReplaceAsync(Arg.Is(pu => pu.UserId == user.Id && pu.ProviderId == provider.Id && pu.Key == key)); } + [Theory, BitAutoData] + public async Task CompleteSetupAsync_WithAutoConfirmEnabled_ThrowsUserCannotJoinProviderError(User user, Provider provider, + string key, + TokenizedPaymentMethod tokenizedPaymentMethod, BillingAddress billingAddress, + [ProviderUser] ProviderUser providerUser, + SutProvider sutProvider) + { + providerUser.ProviderId = provider.Id; + providerUser.UserId = user.Id; + var userService = sutProvider.GetDependency(); + userService.GetUserByIdAsync(user.Id).Returns(user); + + var providerUserRepository = sutProvider.GetDependency(); + providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser); + + var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName"); + var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector"); + sutProvider.GetDependency().CreateProtector("ProviderServiceDataProtector") + .Returns(protector); + + var providerBillingService = sutProvider.GetDependency(); + + var customer = new Customer { Id = "customer_id" }; + providerBillingService.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress).Returns(customer); + + var subscription = new Subscription { Id = "subscription_id" }; + providerBillingService.SetupSubscription(provider).Returns(subscription); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + var policyDetails = new List { new() { OrganizationId = Guid.NewGuid(), IsProvider = false } }; + var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(policyDetails); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(policyRequirement); + + sutProvider.Create(); + + var token = protector.Protect( + $"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, tokenizedPaymentMethod, + billingAddress)); + + Assert.Equal(new UserCannotJoinProvider().Message, exception.Message); + } + [Theory, BitAutoData] public async Task UpdateAsync_ProviderIdIsInvalid_Throws(Provider provider, SutProvider sutProvider) { @@ -580,6 +636,132 @@ public class ProviderServiceTests Assert.Equal(user.Id, pu.UserId); } + [Theory, BitAutoData] + public async Task AcceptUserAsync_WithAutoConfirmEnabledAndPolicyExists_Throws( + [ProviderUser(ProviderUserStatusType.Invited)] ProviderUser providerUser, + User user, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(providerUser.Id) + .Returns(providerUser); + + var protector = DataProtectionProvider + .Create("ApplicationName") + .CreateProtector("ProviderServiceDataProtector"); + + sutProvider.GetDependency() + .CreateProtector("ProviderServiceDataProtector") + .Returns(protector); + + sutProvider.Create(); + + providerUser.Email = user.Email; + var token = protector.Protect($"ProviderUserInvite {providerUser.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + var policyDetails = new List + { + new() { OrganizationId = Guid.NewGuid(), IsProvider = false } + }; + var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(policyDetails); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(policyRequirement); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.AcceptUserAsync(providerUser.Id, user, token)); + + Assert.Equal(new UserCannotJoinProvider().Message, exception.Message); + } + + [Theory, BitAutoData] + public async Task AcceptUserAsync_WithAutoConfirmEnabledButNoPolicyExists_Success( + [ProviderUser(ProviderUserStatusType.Invited)] ProviderUser providerUser, + User user, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(providerUser.Id) + .Returns(providerUser); + + var protector = DataProtectionProvider + .Create("ApplicationName") + .CreateProtector("ProviderServiceDataProtector"); + + sutProvider.GetDependency() + .CreateProtector("ProviderServiceDataProtector") + .Returns(protector); + sutProvider.Create(); + + providerUser.Email = user.Email; + var token = protector.Protect($"ProviderUserInvite {providerUser.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + var policyRequirement = new AutomaticUserConfirmationPolicyRequirement([]); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(policyRequirement); + + // Act + var pu = await sutProvider.Sut.AcceptUserAsync(providerUser.Id, user, token); + + // Assert + Assert.Null(pu.Email); + Assert.Equal(ProviderUserStatusType.Accepted, pu.Status); + Assert.Equal(user.Id, pu.UserId); + } + + [Theory, BitAutoData] + public async Task AcceptUserAsync_WithAutoConfirmDisabled_Success( + [ProviderUser(ProviderUserStatusType.Invited)] ProviderUser providerUser, + User user, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(providerUser.Id) + .Returns(providerUser); + + var protector = DataProtectionProvider + .Create("ApplicationName") + .CreateProtector("ProviderServiceDataProtector"); + + sutProvider.GetDependency() + .CreateProtector("ProviderServiceDataProtector") + .Returns(protector); + sutProvider.Create(); + + providerUser.Email = user.Email; + var token = protector.Protect($"ProviderUserInvite {providerUser.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(false); + + // Act + var pu = await sutProvider.Sut.AcceptUserAsync(providerUser.Id, user, token); + + // Assert + Assert.Null(pu.Email); + Assert.Equal(ProviderUserStatusType.Accepted, pu.Status); + Assert.Equal(user.Id, pu.UserId); + + // Verify that policy check was never called when feature flag is disabled + await sutProvider.GetDependency() + .DidNotReceive() + .GetAsync(user.Id); + } + [Theory, BitAutoData] public async Task ConfirmUsersAsync_NoValid( [ProviderUser(ProviderUserStatusType.Invited)] ProviderUser pu1, @@ -626,13 +808,131 @@ public class ProviderServiceTests Assert.Equal("Invalid user.", result[2].Item2); } + [Theory, BitAutoData] + public async Task ConfirmUsersAsync_WithAutoConfirmEnabledAndPolicyExists_ReturnsError( + [ProviderUser(ProviderUserStatusType.Accepted)] ProviderUser pu1, User u1, + Provider provider, User confirmingUser, SutProvider sutProvider) + { + // Arrange + pu1.ProviderId = provider.Id; + pu1.UserId = u1.Id; + var providerUsers = new[] { pu1 }; + + var providerUserRepository = sutProvider.GetDependency(); + providerUserRepository.GetManyAsync([]).ReturnsForAnyArgs(providerUsers); + sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); + sutProvider.GetDependency().GetManyAsync([]).ReturnsForAnyArgs([u1]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + var policyDetails = new List + { + new() { OrganizationId = Guid.NewGuid(), IsProvider = false } + }; + var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(policyDetails); + sutProvider.GetDependency() + .GetAsync(u1.Id) + .Returns(policyRequirement); + + var dict = providerUsers.ToDictionary(pu => pu.Id, _ => "key"); + + // Act + var result = await sutProvider.Sut.ConfirmUsersAsync(pu1.ProviderId, dict, confirmingUser.Id); + + // Assert + Assert.Single(result); + Assert.Equal(new UserCannotJoinProvider().Message, result[0].Item2); + + // Verify user was not confirmed + await providerUserRepository.DidNotReceive().ReplaceAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ConfirmUsersAsync_WithAutoConfirmEnabledButNoPolicyExists_Success( + [ProviderUser(ProviderUserStatusType.Accepted)] ProviderUser pu1, User u1, + Provider provider, User confirmingUser, SutProvider sutProvider) + { + // Arrange + pu1.ProviderId = provider.Id; + pu1.UserId = u1.Id; + var providerUsers = new[] { pu1 }; + + var providerUserRepository = sutProvider.GetDependency(); + providerUserRepository.GetManyAsync([]).ReturnsForAnyArgs(providerUsers); + sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); + sutProvider.GetDependency().GetManyAsync([]).ReturnsForAnyArgs([u1]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(new List()); + sutProvider.GetDependency() + .GetAsync(u1.Id) + .Returns(policyRequirement); + + var dict = providerUsers.ToDictionary(pu => pu.Id, _ => "key"); + + // Act + var result = await sutProvider.Sut.ConfirmUsersAsync(pu1.ProviderId, dict, confirmingUser.Id); + + // Assert + Assert.Single(result); + Assert.Equal("", result[0].Item2); + + // Verify user was confirmed + await providerUserRepository.Received(1).ReplaceAsync(Arg.Is(pu => + pu.Status == ProviderUserStatusType.Confirmed)); + } + + [Theory, BitAutoData] + public async Task ConfirmUsersAsync_WithAutoConfirmDisabled_Success( + [ProviderUser(ProviderUserStatusType.Accepted)] ProviderUser pu1, User u1, + Provider provider, User confirmingUser, SutProvider sutProvider) + { + // Arrange + pu1.ProviderId = provider.Id; + pu1.UserId = u1.Id; + var providerUsers = new[] { pu1 }; + + var providerUserRepository = sutProvider.GetDependency(); + providerUserRepository.GetManyAsync([]).ReturnsForAnyArgs(providerUsers); + + sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); + sutProvider.GetDependency().GetManyAsync([]).ReturnsForAnyArgs([u1]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(false); + + var dict = providerUsers.ToDictionary(pu => pu.Id, _ => "key"); + + // Act + var result = await sutProvider.Sut.ConfirmUsersAsync(pu1.ProviderId, dict, confirmingUser.Id); + + // Assert + Assert.Single(result); + Assert.Equal("", result[0].Item2); + + // Verify user was confirmed + await providerUserRepository.Received(1).ReplaceAsync(Arg.Is(pu => + pu.Status == ProviderUserStatusType.Confirmed)); + + // Verify that policy check was never called when feature flag is disabled + await sutProvider.GetDependency() + .DidNotReceive() + .GetAsync(Arg.Any()); + } + [Theory, BitAutoData] public async Task SaveUserAsync_UserIdIsInvalid_Throws(ProviderUser providerUser, SutProvider sutProvider) { - providerUser.Id = default; - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveUserAsync(providerUser, default)); + providerUser.Id = Guid.Empty; + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.SaveUserAsync(providerUser, Guid.Empty)); Assert.Equal("Invite the user first.", exception.Message); } diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index ecf49c18c8..839d00f7a1 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -18,6 +18,7 @@ using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Kdf; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.Queries.Interfaces; using Bit.Core.Models.Api.Response; using Bit.Core.Repositories; @@ -44,6 +45,7 @@ public class AccountsController : Controller private readonly IUserAccountKeysQuery _userAccountKeysQuery; private readonly ITwoFactorEmailService _twoFactorEmailService; private readonly IChangeKdfCommand _changeKdfCommand; + private readonly IUserRepository _userRepository; public AccountsController( IOrganizationService organizationService, @@ -57,7 +59,8 @@ public class AccountsController : Controller IFeatureService featureService, IUserAccountKeysQuery userAccountKeysQuery, ITwoFactorEmailService twoFactorEmailService, - IChangeKdfCommand changeKdfCommand + IChangeKdfCommand changeKdfCommand, + IUserRepository userRepository ) { _organizationService = organizationService; @@ -72,6 +75,7 @@ public class AccountsController : Controller _userAccountKeysQuery = userAccountKeysQuery; _twoFactorEmailService = twoFactorEmailService; _changeKdfCommand = changeKdfCommand; + _userRepository = userRepository; } @@ -432,16 +436,36 @@ public class AccountsController : Controller throw new UnauthorizedAccessException(); } - if (_featureService.IsEnabled(FeatureFlagKeys.ReturnErrorOnExistingKeypair)) + if (!string.IsNullOrWhiteSpace(user.PrivateKey) || !string.IsNullOrWhiteSpace(user.PublicKey)) { - if (!string.IsNullOrWhiteSpace(user.PrivateKey) || !string.IsNullOrWhiteSpace(user.PublicKey)) - { - throw new BadRequestException("User has existing keypair"); - } + throw new BadRequestException("User has existing keypair"); + } + + if (model.AccountKeys != null) + { + var accountKeysData = model.AccountKeys.ToAccountKeysData(); + if (!accountKeysData.IsV2Encryption()) + { + throw new BadRequestException("AccountKeys are only supported for V2 encryption."); + } + await _userRepository.SetV2AccountCryptographicStateAsync(user.Id, accountKeysData); + return new KeysResponseModel(accountKeysData, user.Key); + } + else + { + // Todo: Drop this after a transition period. This will drop no-account-keys requests. + // The V1 check in the other branch should persist + // https://bitwarden.atlassian.net/browse/PM-27329 + await _userService.SaveUserAsync(model.ToUser(user)); + return new KeysResponseModel(new UserAccountKeysData + { + PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData( + user.PrivateKey, + user.PublicKey + ) + }, user.Key); } - await _userService.SaveUserAsync(model.ToUser(user)); - return new KeysResponseModel(user); } [HttpGet("keys")] @@ -453,7 +477,8 @@ public class AccountsController : Controller throw new UnauthorizedAccessException(); } - return new KeysResponseModel(user); + var accountKeys = await _userAccountKeysQuery.Run(user); + return new KeysResponseModel(accountKeys, user.Key); } [HttpDelete] diff --git a/src/Api/Controllers/PhishingDomainsController.cs b/src/Api/Controllers/PhishingDomainsController.cs deleted file mode 100644 index f0c1a65648..0000000000 --- a/src/Api/Controllers/PhishingDomainsController.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Bit.Core; -using Bit.Core.Repositories; -using Bit.Core.Services; -using Microsoft.AspNetCore.Mvc; - -namespace Bit.Api.Controllers; - -[Route("phishing-domains")] -public class PhishingDomainsController(IPhishingDomainRepository phishingDomainRepository, IFeatureService featureService) : Controller -{ - [HttpGet] - public async Task>> GetPhishingDomainsAsync() - { - if (!featureService.IsEnabled(FeatureFlagKeys.PhishingDetection)) - { - return NotFound(); - } - - var domains = await phishingDomainRepository.GetActivePhishingDomainsAsync(); - return Ok(domains); - } - - [HttpGet("checksum")] - public async Task> GetChecksumAsync() - { - if (!featureService.IsEnabled(FeatureFlagKeys.PhishingDetection)) - { - return NotFound(); - } - - var checksum = await phishingDomainRepository.GetCurrentChecksumAsync(); - return Ok(checksum); - } -} diff --git a/src/Api/Jobs/JobsHostedService.cs b/src/Api/Jobs/JobsHostedService.cs index 0178f6d68b..a9626dc90e 100644 --- a/src/Api/Jobs/JobsHostedService.cs +++ b/src/Api/Jobs/JobsHostedService.cs @@ -59,13 +59,6 @@ public class JobsHostedService : BaseJobsHostedService .StartNow() .WithCronSchedule("0 0 * * * ?") .Build(); - var updatePhishingDomainsTrigger = TriggerBuilder.Create() - .WithIdentity("UpdatePhishingDomainsTrigger") - .StartNow() - .WithSimpleSchedule(x => x - .WithIntervalInHours(24) - .RepeatForever()) - .Build(); var updateOrgSubscriptionsTrigger = TriggerBuilder.Create() .WithIdentity("UpdateOrgSubscriptionsTrigger") .StartNow() @@ -81,7 +74,6 @@ public class JobsHostedService : BaseJobsHostedService new Tuple(typeof(ValidateUsersJob), everyTopOfTheSixthHourTrigger), new Tuple(typeof(ValidateOrganizationsJob), everyTwelfthHourAndThirtyMinutesTrigger), new Tuple(typeof(ValidateOrganizationDomainJob), validateOrganizationDomainTrigger), - new Tuple(typeof(UpdatePhishingDomainsJob), updatePhishingDomainsTrigger), new (typeof(OrganizationSubscriptionUpdateJob), updateOrgSubscriptionsTrigger), }; @@ -111,7 +103,6 @@ public class JobsHostedService : BaseJobsHostedService services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); services.AddTransient(); } diff --git a/src/Api/Jobs/UpdatePhishingDomainsJob.cs b/src/Api/Jobs/UpdatePhishingDomainsJob.cs deleted file mode 100644 index 355f2af69b..0000000000 --- a/src/Api/Jobs/UpdatePhishingDomainsJob.cs +++ /dev/null @@ -1,97 +0,0 @@ -using Bit.Core; -using Bit.Core.Jobs; -using Bit.Core.PhishingDomainFeatures.Interfaces; -using Bit.Core.Repositories; -using Bit.Core.Services; -using Bit.Core.Settings; -using Quartz; - -namespace Bit.Api.Jobs; - -public class UpdatePhishingDomainsJob : BaseJob -{ - private readonly GlobalSettings _globalSettings; - private readonly IPhishingDomainRepository _phishingDomainRepository; - private readonly ICloudPhishingDomainQuery _cloudPhishingDomainQuery; - private readonly IFeatureService _featureService; - public UpdatePhishingDomainsJob( - GlobalSettings globalSettings, - IPhishingDomainRepository phishingDomainRepository, - ICloudPhishingDomainQuery cloudPhishingDomainQuery, - IFeatureService featureService, - ILogger logger) - : base(logger) - { - _globalSettings = globalSettings; - _phishingDomainRepository = phishingDomainRepository; - _cloudPhishingDomainQuery = cloudPhishingDomainQuery; - _featureService = featureService; - } - - protected override async Task ExecuteJobAsync(IJobExecutionContext context) - { - if (!_featureService.IsEnabled(FeatureFlagKeys.PhishingDetection)) - { - _logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. Feature flag is disabled."); - return; - } - - if (string.IsNullOrWhiteSpace(_globalSettings.PhishingDomain?.UpdateUrl)) - { - _logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. No URL configured."); - return; - } - - if (_globalSettings.SelfHosted && !_globalSettings.EnableCloudCommunication) - { - _logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. Cloud communication is disabled in global settings."); - return; - } - - var remoteChecksum = await _cloudPhishingDomainQuery.GetRemoteChecksumAsync(); - if (string.IsNullOrWhiteSpace(remoteChecksum)) - { - _logger.LogWarning(Constants.BypassFiltersEventId, "Could not retrieve remote checksum. Skipping update."); - return; - } - - var currentChecksum = await _phishingDomainRepository.GetCurrentChecksumAsync(); - - if (string.Equals(currentChecksum, remoteChecksum, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogInformation(Constants.BypassFiltersEventId, - "Phishing domains list is up to date (checksum: {Checksum}). Skipping update.", - currentChecksum); - return; - } - - _logger.LogInformation(Constants.BypassFiltersEventId, - "Checksums differ (current: {CurrentChecksum}, remote: {RemoteChecksum}). Fetching updated domains from {Source}.", - currentChecksum, remoteChecksum, _globalSettings.SelfHosted ? "Bitwarden cloud API" : "external source"); - - try - { - var domains = await _cloudPhishingDomainQuery.GetPhishingDomainsAsync(); - if (!domains.Contains("phishing.testcategory.com", StringComparer.OrdinalIgnoreCase)) - { - domains.Add("phishing.testcategory.com"); - } - - if (domains.Count > 0) - { - _logger.LogInformation(Constants.BypassFiltersEventId, "Updating {Count} phishing domains with checksum {Checksum}.", - domains.Count, remoteChecksum); - await _phishingDomainRepository.UpdatePhishingDomainsAsync(domains, remoteChecksum); - _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated phishing domains."); - } - else - { - _logger.LogWarning(Constants.BypassFiltersEventId, "No valid domains found in the response. Skipping update."); - } - } - catch (Exception ex) - { - _logger.LogError(Constants.BypassFiltersEventId, ex, "Error updating phishing domains."); - } - } -} diff --git a/src/Api/Models/Response/KeysResponseModel.cs b/src/Api/Models/Response/KeysResponseModel.cs index cfc1a6a0a1..4c877e0bfc 100644 --- a/src/Api/Models/Response/KeysResponseModel.cs +++ b/src/Api/Models/Response/KeysResponseModel.cs @@ -1,27 +1,32 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Api.Response; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Models.Api; namespace Bit.Api.Models.Response; public class KeysResponseModel : ResponseModel { - public KeysResponseModel(User user) + public KeysResponseModel(UserAccountKeysData accountKeys, string? masterKeyWrappedUserKey) : base("keys") { - if (user == null) + if (masterKeyWrappedUserKey != null) { - throw new ArgumentNullException(nameof(user)); + Key = masterKeyWrappedUserKey; } - Key = user.Key; - PublicKey = user.PublicKey; - PrivateKey = user.PrivateKey; + PublicKey = accountKeys.PublicKeyEncryptionKeyPairData.PublicKey; + PrivateKey = accountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey; + AccountKeys = new PrivateKeysResponseModel(accountKeys); } - public string Key { get; set; } + /// + /// The master key wrapped user key. The master key can either be a master-password master key or a + /// key-connector master key. + /// + public string? Key { get; set; } + [Obsolete("Use AccountKeys.PublicKeyEncryptionKeyPair.PublicKey instead")] public string PublicKey { get; set; } + [Obsolete("Use AccountKeys.PublicKeyEncryptionKeyPair.WrappedPrivateKey instead")] public string PrivateKey { get; set; } + public PrivateKeysResponseModel AccountKeys { get; set; } } diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index bdbc2f8edc..2f16470cd4 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -187,7 +187,6 @@ public class Startup services.AddBillingOperations(); services.AddReportingServices(); services.AddImportServices(); - services.AddPhishingDomainServices(globalSettings); services.AddSendServices(); diff --git a/src/Api/Utilities/ServiceCollectionExtensions.cs b/src/Api/Utilities/ServiceCollectionExtensions.cs index c90fc82d56..b773abf6ef 100644 --- a/src/Api/Utilities/ServiceCollectionExtensions.cs +++ b/src/Api/Utilities/ServiceCollectionExtensions.cs @@ -1,9 +1,5 @@ using Bit.Api.AdminConsole.Authorization; using Bit.Api.Tools.Authorization; -using Bit.Core.PhishingDomainFeatures; -using Bit.Core.PhishingDomainFeatures.Interfaces; -using Bit.Core.Repositories; -using Bit.Core.Repositories.Implementations; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Core.Vault.Authorization.SecurityTasks; @@ -103,25 +99,4 @@ public static class ServiceCollectionExtensions // Admin Console authorization handlers services.AddAdminConsoleAuthorizationHandlers(); } - - public static void AddPhishingDomainServices(this IServiceCollection services, GlobalSettings globalSettings) - { - services.AddHttpClient("PhishingDomains", client => - { - client.DefaultRequestHeaders.Add("User-Agent", globalSettings.SelfHosted ? "Bitwarden Self-Hosted" : "Bitwarden"); - client.Timeout = TimeSpan.FromSeconds(1000); // the source list is very slow - }); - - services.AddSingleton(); - services.AddSingleton(); - - if (globalSettings.SelfHosted) - { - services.AddScoped(); - } - else - { - services.AddScoped(); - } - } } diff --git a/src/Api/appsettings.Development.json b/src/Api/appsettings.Development.json index 87e92c4516..deb0a35d84 100644 --- a/src/Api/appsettings.Development.json +++ b/src/Api/appsettings.Development.json @@ -38,10 +38,6 @@ "storage": { "connectionString": "UseDevelopmentStorage=true" }, - "phishingDomain": { - "updateUrl": "https://phish.co.za/latest/phishing-domains-ACTIVE.txt", - "checksumUrl": "https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-domains-ACTIVE.txt.sha256" - }, "pricingUri": "https://billingpricing.qa.bitwarden.pw" } } diff --git a/src/Api/appsettings.json b/src/Api/appsettings.json index a503070d8d..8850c3d269 100644 --- a/src/Api/appsettings.json +++ b/src/Api/appsettings.json @@ -69,9 +69,6 @@ "accessKeySecret": "SECRET", "region": "SECRET" }, - "phishingDomain": { - "updateUrl": "SECRET" - }, "distributedIpRateLimiting": { "enabled": true, "maxRedisTimeoutsThreshold": 10, diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs index 63f177b3f3..c763cc0cc2 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs @@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Business.Tokenables; @@ -34,6 +35,7 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; private readonly IFeatureService _featureService; private readonly IPolicyRequirementQuery _policyRequirementQuery; + private readonly IAutomaticUserConfirmationPolicyEnforcementValidator _automaticUserConfirmationPolicyEnforcementValidator; public AcceptOrgUserCommand( IDataProtectionProvider dataProtectionProvider, @@ -46,7 +48,8 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IDataProtectorTokenFactory orgUserInviteTokenDataFactory, IFeatureService featureService, - IPolicyRequirementQuery policyRequirementQuery) + IPolicyRequirementQuery policyRequirementQuery, + IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator) { // TODO: remove data protector when old token validation removed _dataProtector = dataProtectionProvider.CreateProtector(OrgUserInviteTokenable.DataProtectorPurpose); @@ -60,6 +63,7 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _featureService = featureService; _policyRequirementQuery = policyRequirementQuery; + _automaticUserConfirmationPolicyEnforcementValidator = automaticUserConfirmationPolicyEnforcementValidator; } public async Task AcceptOrgUserByEmailTokenAsync(Guid organizationUserId, User user, string emailToken, @@ -186,13 +190,19 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand } } - // Enforce Single Organization Policy of organization user is trying to join var allOrgUsers = await _organizationUserRepository.GetManyByUserAsync(user.Id); - var hasOtherOrgs = allOrgUsers.Any(ou => ou.OrganizationId != orgUser.OrganizationId); + + if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + await ValidateAutomaticUserConfirmationPolicyAsync(orgUser, allOrgUsers, user); + } + + // Enforce Single Organization Policy of organization user is trying to join var invitedSingleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg, OrganizationUserStatusType.Invited); - if (hasOtherOrgs && invitedSingleOrgPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId)) + if (allOrgUsers.Any(ou => ou.OrganizationId != orgUser.OrganizationId) + && invitedSingleOrgPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId)) { throw new BadRequestException("You may not join this organization until you leave or remove all other organizations."); } @@ -255,4 +265,20 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand } } } + + private async Task ValidateAutomaticUserConfirmationPolicyAsync(OrganizationUser orgUser, + ICollection allOrgUsers, User user) + { + var error = (await _automaticUserConfirmationPolicyEnforcementValidator.IsCompliantAsync( + new AutomaticUserConfirmationPolicyEnforcementRequest(orgUser.OrganizationId, allOrgUsers, user))) + .Match( + error => error.Message, + _ => string.Empty + ); + + if (!string.IsNullOrEmpty(error)) + { + throw new BadRequestException(error); + } + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUsersValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUsersValidator.cs index 11b89de680..3375120516 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUsersValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUsersValidator.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities.v2; @@ -8,6 +9,7 @@ using Bit.Core.AdminConsole.Utilities.v2.Validation; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Enums; using Bit.Core.Repositories; +using Bit.Core.Services; using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; @@ -16,6 +18,8 @@ public class AutomaticallyConfirmOrganizationUsersValidator( IOrganizationUserRepository organizationUserRepository, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IPolicyRequirementQuery policyRequirementQuery, + IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator, + IUserService userService, IPolicyRepository policyRepository) : IAutomaticallyConfirmOrganizationUsersValidator { public async Task> ValidateAsync( @@ -61,7 +65,7 @@ public class AutomaticallyConfirmOrganizationUsersValidator( return Invalid(request, new UserDoesNotHaveTwoFactorEnabled()); } - if (await OrganizationUserConformsToSingleOrgPolicyAsync(request) is { } error) + if (await OrganizationUserConformsToAutomaticUserConfirmationPolicyAsync(request) is { } error) { return Invalid(request, error); } @@ -69,10 +73,8 @@ public class AutomaticallyConfirmOrganizationUsersValidator( return Valid(request); } - private async Task OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync( - AutomaticallyConfirmOrganizationUserValidationRequest request) => - await policyRepository.GetByOrganizationIdTypeAsync(request.OrganizationId, - PolicyType.AutomaticUserConfirmation) is { Enabled: true } + private async Task OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) => + await policyRepository.GetByOrganizationIdTypeAsync(request.OrganizationId, PolicyType.AutomaticUserConfirmation) is { Enabled: true } && request.Organization is { UseAutomaticUserConfirmation: true }; private async Task OrganizationUserConformsToTwoFactorRequiredPolicyAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) @@ -87,30 +89,37 @@ public class AutomaticallyConfirmOrganizationUsersValidator( .IsTwoFactorRequiredForOrganization(request.Organization!.Id); } - private async Task OrganizationUserConformsToSingleOrgPolicyAsync( + /// + /// Validates whether the specified organization user complies with the automatic user confirmation policy. + /// This includes checks across all organizations the user is associated with to ensure they meet the compliance criteria. + /// + /// We are not checking single organization policy compliance here because automatically confirm users policy enforces + /// a stricter version and applies to all users. If you are compliant with Auto Confirm, you'll be in compliance with + /// Single Org. + /// + /// + /// The request model encapsulates the current organization, the user being validated, and all organization users associated + /// with that user. + /// + /// + /// An if the user fails to meet the automatic user confirmation policy, or null if the validation succeeds. + /// + private async Task OrganizationUserConformsToAutomaticUserConfirmationPolicyAsync( AutomaticallyConfirmOrganizationUserValidationRequest request) { var allOrganizationUsersForUser = await organizationUserRepository .GetManyByUserAsync(request.OrganizationUser!.UserId!.Value); - if (allOrganizationUsersForUser.Count == 1) - { - return null; - } + var user = await userService.GetUserByIdAsync(request.OrganizationUser!.UserId!.Value); - var policyRequirement = await policyRequirementQuery - .GetAsync(request.OrganizationUser!.UserId!.Value); - - if (policyRequirement.IsSingleOrgEnabledForThisOrganization(request.Organization!.Id)) - { - return new OrganizationEnforcesSingleOrgPolicy(); - } - - if (policyRequirement.IsSingleOrgEnabledForOrganizationsOtherThan(request.Organization.Id)) - { - return new OtherOrganizationEnforcesSingleOrgPolicy(); - } - - return null; + return (await automaticUserConfirmationPolicyEnforcementValidator.IsCompliantAsync( + new AutomaticUserConfirmationPolicyEnforcementRequest( + request.OrganizationId, + allOrganizationUsersForUser, + user))) + .Match( + error => error, + _ => null + ); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/Errors.cs index 1564daca6c..e65db00f73 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/Errors.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/Errors.cs @@ -8,6 +8,9 @@ public record UserIsNotUserType() : BadRequestError("Only organization users wit public record UserIsNotAccepted() : BadRequestError("Cannot confirm user that has not accepted the invitation."); public record OrganizationUserIdIsInvalid() : BadRequestError("Invalid organization user id."); public record UserDoesNotHaveTwoFactorEnabled() : BadRequestError("User does not have two-step login enabled."); -public record OrganizationEnforcesSingleOrgPolicy() : BadRequestError("Cannot confirm this member to the organization until they leave or remove all other organizations"); -public record OtherOrganizationEnforcesSingleOrgPolicy() : BadRequestError("Cannot confirm this member to the organization because they are in another organization which forbids it."); +public record UserCannotBelongToAnotherOrganization() : BadRequestError("Cannot confirm this member to the organization until they leave or remove all other organizations"); +public record OtherOrganizationDoesNotAllowOtherMembership() : BadRequestError("Cannot confirm this member to the organization because they are in another organization which forbids it."); public record AutomaticallyConfirmUsersPolicyIsNotEnabled() : BadRequestError("Cannot confirm this member because the Automatically Confirm Users policy is not enabled."); +public record ProviderUsersCannotJoin() : BadRequestError("An organization the user is a part of has enabled Automatic User Confirmation policy, and it does not support provider users joining."); +public record UserCannotJoinProvider() : BadRequestError("An organization the user is a part of has enabled Automatic User Confirmation policy, and it does not support the user joining a provider."); +public record CurrentOrganizationUserIsNotPresentInRequest() : BadRequestError("The current organization user does not exist in the request."); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs index 2fbe6be5c6..b6b49e93e9 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; @@ -33,6 +34,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IFeatureService _featureService; private readonly ICollectionRepository _collectionRepository; + private readonly IAutomaticUserConfirmationPolicyEnforcementValidator _automaticUserConfirmationPolicyEnforcementValidator; public ConfirmOrganizationUserCommand( IOrganizationRepository organizationRepository, @@ -47,7 +49,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand IDeviceRepository deviceRepository, IPolicyRequirementQuery policyRequirementQuery, IFeatureService featureService, - ICollectionRepository collectionRepository) + ICollectionRepository collectionRepository, + IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -62,6 +65,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand _policyRequirementQuery = policyRequirementQuery; _featureService = featureService; _collectionRepository = collectionRepository; + _automaticUserConfirmationPolicyEnforcementValidator = automaticUserConfirmationPolicyEnforcementValidator; } public async Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, @@ -127,6 +131,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand var organization = await _organizationRepository.GetByIdAsync(organizationId); var allUsersOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(validSelectedUserIds); + var users = await _userRepository.GetManyAsync(validSelectedUserIds); var usersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(validSelectedUserIds); @@ -188,6 +193,25 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand await ValidateTwoFactorAuthenticationPolicyAsync(user, organizationId, userTwoFactorEnabled); var hasOtherOrgs = userOrgs.Any(ou => ou.OrganizationId != organizationId); + + if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var error = (await _automaticUserConfirmationPolicyEnforcementValidator.IsCompliantAsync( + new AutomaticUserConfirmationPolicyEnforcementRequest( + organizationId, + userOrgs, + user))) + .Match( + error => new BadRequestException(error.Message), + _ => null + ); + + if (error is not null) + { + throw error; + } + } + var singleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg); var otherSingleOrgPolicies = singleOrgPolicies.Where(p => p.OrganizationId != organizationId); @@ -267,8 +291,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand return; } - var organizationDataOwnershipPolicy = - await _policyRequirementQuery.GetAsync(organizationUser.UserId!.Value); + var organizationDataOwnershipPolicy = await _policyRequirementQuery.GetAsync(organizationUser.UserId!.Value); if (!organizationDataOwnershipPolicy.RequiresDefaultCollectionOnConfirm(organizationUser.OrganizationId)) { return; @@ -311,8 +334,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand return; } - var policyEligibleOrganizationUserIds = - await _policyRequirementQuery.GetManyByOrganizationIdAsync(organizationId); + var policyEligibleOrganizationUserIds = await _policyRequirementQuery + .GetManyByOrganizationIdAsync(organizationId); var eligibleOrganizationUserIds = confirmedOrganizationUsers .Where(ou => policyEligibleOrganizationUserIds.Contains(ou.Id)) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs index 651a9225b4..ec42c8b402 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; @@ -29,7 +30,8 @@ public class RestoreOrganizationUserCommand( IUserRepository userRepository, IOrganizationService organizationService, IFeatureService featureService, - IPolicyRequirementQuery policyRequirementQuery) : IRestoreOrganizationUserCommand + IPolicyRequirementQuery policyRequirementQuery, + IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator) : IRestoreOrganizationUserCommand { public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId) { @@ -300,6 +302,25 @@ public class RestoreOrganizationUserCommand( { throw new BadRequestException(user.Email + " is not compliant with the two-step login policy"); } + + if (featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var validationResult = await automaticUserConfirmationPolicyEnforcementValidator.IsCompliantAsync( + new AutomaticUserConfirmationPolicyEnforcementRequest(orgUser.OrganizationId, + allOrgUsers, + user!)); + + var badRequestException = validationResult.Match( + error => new BadRequestException(user.Email + + " is not compliant with the automatic user confirmation policy: " + + error.Message), + _ => null); + + if (badRequestException is not null) + { + throw badRequestException; + } + } } private async Task IsTwoFactorRequiredForOrganizationAsync(Guid userId, Guid organizationId) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs index 154c3b7319..7f24c4acd7 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs @@ -3,6 +3,8 @@ 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.Billing.Enums; using Bit.Core.Billing.Organizations.Models; @@ -43,7 +45,9 @@ public class CloudOrganizationSignUpCommand( IPushNotificationService pushNotificationService, ICollectionRepository collectionRepository, IDeviceRepository deviceRepository, - IPricingClient pricingClient) : ICloudOrganizationSignUpCommand + IPricingClient pricingClient, + IPolicyRequirementQuery policyRequirementQuery, + IFeatureService featureService) : ICloudOrganizationSignUpCommand { public async Task SignUpOrganizationAsync(OrganizationSignup signup) { @@ -237,6 +241,17 @@ public class CloudOrganizationSignUpCommand( private async Task ValidateSignUpPoliciesAsync(Guid ownerId) { + if (featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var requirement = await policyRequirementQuery.GetAsync(ownerId); + + if (requirement.CannotCreateNewOrganization()) + { + throw new BadRequestException("You may not create an organization. You belong to an organization " + + "which has a policy that prohibits you from being a member of any other organization."); + } + } + var anySingleOrgPolicies = await policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg); if (anySingleOrgPolicies) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs index 6474914b48..da678ece71 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs @@ -2,6 +2,8 @@ #nullable disable 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.Entities; @@ -28,6 +30,8 @@ public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand private readonly IGlobalSettings _globalSettings; private readonly IPolicyService _policyService; private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IFeatureService _featureService; + private readonly IPolicyRequirementQuery _policyRequirementQuery; public InitPendingOrganizationCommand( IOrganizationService organizationService, @@ -37,7 +41,9 @@ public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand IDataProtectionProvider dataProtectionProvider, IGlobalSettings globalSettings, IPolicyService policyService, - IOrganizationUserRepository organizationUserRepository + IOrganizationUserRepository organizationUserRepository, + IFeatureService featureService, + IPolicyRequirementQuery policyRequirementQuery ) { _organizationService = organizationService; @@ -48,6 +54,8 @@ public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand _globalSettings = globalSettings; _policyService = policyService; _organizationUserRepository = organizationUserRepository; + _featureService = featureService; + _policyRequirementQuery = policyRequirementQuery; } public async Task InitPendingOrganizationAsync(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken) @@ -113,6 +121,17 @@ public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand private async Task ValidateSignUpPoliciesAsync(Guid ownerId) { + if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var requirement = await _policyRequirementQuery.GetAsync(ownerId); + + if (requirement.CannotCreateNewOrganization()) + { + throw new BadRequestException("You may not create an organization. You belong to an organization " + + "which has a policy that prohibits you from being a member of any other organization."); + } + } + var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg); if (anySingleOrgPolicies) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs index 6a7d068ae1..9abce991c3 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs @@ -2,6 +2,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Services; @@ -31,6 +33,8 @@ public class SelfHostedOrganizationSignUpCommand : ISelfHostedOrganizationSignUp private readonly IPolicyService _policyService; private readonly IGlobalSettings _globalSettings; private readonly IStripePaymentService _paymentService; + private readonly IFeatureService _featureService; + private readonly IPolicyRequirementQuery _policyRequirementQuery; public SelfHostedOrganizationSignUpCommand( IOrganizationRepository organizationRepository, @@ -44,7 +48,9 @@ public class SelfHostedOrganizationSignUpCommand : ISelfHostedOrganizationSignUp ILicensingService licensingService, IPolicyService policyService, IGlobalSettings globalSettings, - IStripePaymentService paymentService) + IStripePaymentService paymentService, + IFeatureService featureService, + IPolicyRequirementQuery policyRequirementQuery) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -58,6 +64,8 @@ public class SelfHostedOrganizationSignUpCommand : ISelfHostedOrganizationSignUp _policyService = policyService; _globalSettings = globalSettings; _paymentService = paymentService; + _featureService = featureService; + _policyRequirementQuery = policyRequirementQuery; } public async Task<(Organization organization, OrganizationUser? organizationUser)> SignUpAsync( @@ -103,6 +111,17 @@ public class SelfHostedOrganizationSignUpCommand : ISelfHostedOrganizationSignUp private async Task ValidateSignUpPoliciesAsync(Guid ownerId) { + if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var requirement = await _policyRequirementQuery.GetAsync(ownerId); + + if (requirement.CannotCreateNewOrganization()) + { + throw new BadRequestException("You may not create an organization. You belong to an organization " + + "which has a policy that prohibits you from being a member of any other organization."); + } + } + var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg); if (anySingleOrgPolicies) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementRequest.cs new file mode 100644 index 0000000000..962da4bef7 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementRequest.cs @@ -0,0 +1,44 @@ +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; + +/// +/// Request object for +/// +public record AutomaticUserConfirmationPolicyEnforcementRequest +{ + /// + /// Organization to be validated + /// + public Guid OrganizationId { get; } + + /// + /// All organization users that match the provided user. + /// + public ICollection AllOrganizationUsers { get; } + + /// + /// User associated with the organization user to be confirmed + /// + public User User { get; } + + /// + /// Request object for . + /// + /// + /// This record is used to encapsulate the data required for handling the automatic confirmation policy enforcement. + /// + /// The organization to be validated. + /// All organization users that match the provided user. + /// The user entity connecting all org users provided. + public AutomaticUserConfirmationPolicyEnforcementRequest( + Guid organizationId, + IEnumerable organizationUsers, + User user) + { + OrganizationId = organizationId; + AllOrganizationUsers = organizationUsers.ToArray(); + User = user; + } +} + diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidator.cs new file mode 100644 index 0000000000..633b84d2b9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidator.cs @@ -0,0 +1,49 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Utilities.v2.Validation; +using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; + +public class AutomaticUserConfirmationPolicyEnforcementValidator( + IPolicyRequirementQuery policyRequirementQuery, + IProviderUserRepository providerUserRepository) + : IAutomaticUserConfirmationPolicyEnforcementValidator +{ + public async Task> IsCompliantAsync( + AutomaticUserConfirmationPolicyEnforcementRequest request) + { + var automaticUserConfirmationPolicyRequirement = await policyRequirementQuery + .GetAsync(request.User.Id); + + var currentOrganizationUser = request.AllOrganizationUsers + .FirstOrDefault(x => x.OrganizationId == request.OrganizationId + && x.UserId == request.User.Id); + + if (currentOrganizationUser is null) + { + return Invalid(request, new CurrentOrganizationUserIsNotPresentInRequest()); + } + + if (automaticUserConfirmationPolicyRequirement.IsEnabled(request.OrganizationId)) + { + if ((await providerUserRepository.GetManyByUserAsync(request.User.Id)).Count != 0) + { + return Invalid(request, new ProviderUsersCannotJoin()); + } + + if (request.AllOrganizationUsers.Count > 1) + { + return Invalid(request, new UserCannotBelongToAnotherOrganization()); + } + } + + if (automaticUserConfirmationPolicyRequirement.IsEnabledForOrganizationsOtherThan(currentOrganizationUser.OrganizationId)) + { + return Invalid(request, new OtherOrganizationDoesNotAllowOtherMembership()); + } + + return Valid(request); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/IAutomaticUserConfirmationPolicyEnforcementValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/IAutomaticUserConfirmationPolicyEnforcementValidator.cs new file mode 100644 index 0000000000..7bc1664140 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/IAutomaticUserConfirmationPolicyEnforcementValidator.cs @@ -0,0 +1,28 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Utilities.v2.Validation; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; + +/// +/// Used to enforce the Automatic User Confirmation policy. It uses the to retrieve +/// the . It is used to check to make sure the given user is +/// valid for the Automatic User Confirmation policy. It also validates that the given user is not a provider +/// or a member of another organization regardless of status or type. +/// +public interface IAutomaticUserConfirmationPolicyEnforcementValidator +{ + + /// + /// Checks if the given user is compliant with the Automatic User Confirmation policy. + /// + /// To be compliant, a user must + /// - not be a member of a provider + /// - not be a member of another organization + /// + /// + /// + /// This uses the validation result pattern to avoid throwing exceptions. + /// + /// A validation result with the error message if applicable. + Task> IsCompliantAsync(AutomaticUserConfirmationPolicyEnforcementRequest request); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs new file mode 100644 index 0000000000..3430f33a77 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs @@ -0,0 +1,48 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; + +/// +/// Represents the enforcement status of the Automatic User Confirmation policy. +/// +/// +/// The Automatic User Confirmation policy is enforced against all types of users regardless of status or type. +/// +/// Users cannot: +///
    +///
  • Be a member of another organization (similar to Single Organization Policy)
  • +///
  • Cannot be a provider
  • +///
+///
+/// Collection of policy details that apply to this user id +public class AutomaticUserConfirmationPolicyRequirement(IEnumerable policyDetails) : IPolicyRequirement +{ + public bool CannotBeGrantedEmergencyAccess() => policyDetails.Any(); + + public bool CannotJoinProvider() => policyDetails.Any(); + + public bool CannotCreateProvider() => policyDetails.Any(); + + public bool CannotCreateNewOrganization() => policyDetails.Any(); + + public bool IsEnabled(Guid organizationId) => policyDetails.Any(p => p.OrganizationId == organizationId); + + public bool IsEnabledForOrganizationsOtherThan(Guid organizationId) => + policyDetails.Any(p => p.OrganizationId != organizationId); +} + +public class AutomaticUserConfirmationPolicyRequirementFactory : BasePolicyRequirementFactory +{ + public override PolicyType PolicyType => PolicyType.AutomaticUserConfirmation; + + protected override IEnumerable ExemptRoles => []; + + protected override IEnumerable ExemptStatuses => []; + + protected override bool ExemptProviders => false; + + public override AutomaticUserConfirmationPolicyRequirement Create(IEnumerable policyDetails) => + new(policyDetails); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index 272fd8cee4..f69935715d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; @@ -23,6 +24,8 @@ public static class PolicyServiceCollectionExtensions services.AddPolicyRequirements(); services.AddPolicySideEffects(); services.AddPolicyUpdateEvents(); + + services.AddScoped(); } [Obsolete("Use AddPolicyUpdateEvents instead.")] @@ -69,5 +72,6 @@ public static class PolicyServiceCollectionExtensions services.AddScoped, RequireTwoFactorPolicyRequirementFactory>(); services.AddScoped, MasterPasswordPolicyRequirementFactory>(); services.AddScoped, SingleOrganizationPolicyRequirementFactory>(); + services.AddScoped, AutomaticUserConfirmationPolicyRequirementFactory>(); } } diff --git a/src/Core/Auth/Models/Api/Request/Accounts/KeysRequestModel.cs b/src/Core/Auth/Models/Api/Request/Accounts/KeysRequestModel.cs index f89b67f3c5..85ddef44ce 100644 --- a/src/Core/Auth/Models/Api/Request/Accounts/KeysRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/Accounts/KeysRequestModel.cs @@ -3,17 +3,22 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Core.Utilities; namespace Bit.Core.Auth.Models.Api.Request.Accounts; public class KeysRequestModel { + [Obsolete("Use AccountKeys.AccountPublicKey instead")] [Required] public string PublicKey { get; set; } + [Obsolete("Use AccountKeys.UserKeyEncryptedAccountPrivateKey instead")] [Required] public string EncryptedPrivateKey { get; set; } + public AccountKeysRequestModel AccountKeys { get; set; } + [Obsolete("Use SetAccountKeysForUserCommand instead")] public User ToUser(User existingUser) { if (string.IsNullOrWhiteSpace(PublicKey) || string.IsNullOrWhiteSpace(EncryptedPrivateKey)) diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index be85a858a3..4a0e9c2cf5 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -99,6 +99,9 @@ public class RegisterUserCommand : IRegisterUserCommand public async Task RegisterSSOAutoProvisionedUserAsync(User user, Organization organization) { + // Validate that the email domain is not blocked by another organization's policy + await ValidateEmailDomainNotBlockedAsync(user.Email, organization.Id); + var result = await _userService.CreateUserAsync(user); if (result == IdentityResult.Success) { diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 6d2c2a1673..cf3f40ec80 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -198,10 +198,8 @@ public static class FeatureFlagKeys public const string PM28265_ReconcileAdditionalStorageJobEnableLiveMode = "pm-28265-reconcile-additional-storage-job-enable-live-mode"; /* Key Management Team */ - public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration"; public const string Argon2Default = "argon2-default"; - public const string UserkeyRotationV2 = "userkey-rotation-v2"; public const string SSHKeyItemVaultItem = "ssh-key-vault-item"; public const string EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation"; public const string ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings"; @@ -211,6 +209,7 @@ public static class FeatureFlagKeys public const string NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change"; public const string DisableType0Decryption = "pm-25174-disable-type-0-decryption"; public const string ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component"; + public const string V2RegistrationTDEJIT = "pm-27279-v2-registration-tde-jit"; public const string DataRecoveryTool = "pm-28813-data-recovery-tool"; /* Mobile Team */ diff --git a/src/Core/PhishingDomainFeatures/AzurePhishingDomainStorageService.cs b/src/Core/PhishingDomainFeatures/AzurePhishingDomainStorageService.cs deleted file mode 100644 index 6b76bc35f0..0000000000 --- a/src/Core/PhishingDomainFeatures/AzurePhishingDomainStorageService.cs +++ /dev/null @@ -1,95 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.Text; -using Azure.Storage.Blobs; -using Azure.Storage.Blobs.Models; -using Bit.Core.Settings; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.PhishingDomainFeatures; - -public class AzurePhishingDomainStorageService -{ - private const string _containerName = "phishingdomains"; - private const string _domainsFileName = "domains.txt"; - private const string _checksumFileName = "checksum.txt"; - - private readonly BlobServiceClient _blobServiceClient; - private readonly ILogger _logger; - private BlobContainerClient _containerClient; - - public AzurePhishingDomainStorageService( - GlobalSettings globalSettings, - ILogger logger) - { - _blobServiceClient = new BlobServiceClient(globalSettings.Storage.ConnectionString); - _logger = logger; - } - - public async Task> GetDomainsAsync() - { - await InitAsync(); - - var blobClient = _containerClient.GetBlobClient(_domainsFileName); - if (!await blobClient.ExistsAsync()) - { - return []; - } - - var response = await blobClient.DownloadAsync(); - using var streamReader = new StreamReader(response.Value.Content); - var content = await streamReader.ReadToEndAsync(); - - return [.. content - .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) - .Select(line => line.Trim()) - .Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith('#'))]; - } - - public async Task GetChecksumAsync() - { - await InitAsync(); - - var blobClient = _containerClient.GetBlobClient(_checksumFileName); - if (!await blobClient.ExistsAsync()) - { - return string.Empty; - } - - var response = await blobClient.DownloadAsync(); - using var streamReader = new StreamReader(response.Value.Content); - return (await streamReader.ReadToEndAsync()).Trim(); - } - - public async Task UpdateDomainsAsync(IEnumerable domains, string checksum) - { - await InitAsync(); - - var domainsContent = string.Join(Environment.NewLine, domains); - var domainsStream = new MemoryStream(Encoding.UTF8.GetBytes(domainsContent)); - var domainsBlobClient = _containerClient.GetBlobClient(_domainsFileName); - - await domainsBlobClient.UploadAsync(domainsStream, new BlobUploadOptions - { - HttpHeaders = new BlobHttpHeaders { ContentType = "text/plain" } - }, CancellationToken.None); - - var checksumStream = new MemoryStream(Encoding.UTF8.GetBytes(checksum)); - var checksumBlobClient = _containerClient.GetBlobClient(_checksumFileName); - - await checksumBlobClient.UploadAsync(checksumStream, new BlobUploadOptions - { - HttpHeaders = new BlobHttpHeaders { ContentType = "text/plain" } - }, CancellationToken.None); - } - - private async Task InitAsync() - { - if (_containerClient is null) - { - _containerClient = _blobServiceClient.GetBlobContainerClient(_containerName); - await _containerClient.CreateIfNotExistsAsync(); - } - } -} diff --git a/src/Core/PhishingDomainFeatures/CloudPhishingDomainDirectQuery.cs b/src/Core/PhishingDomainFeatures/CloudPhishingDomainDirectQuery.cs deleted file mode 100644 index 420948e310..0000000000 --- a/src/Core/PhishingDomainFeatures/CloudPhishingDomainDirectQuery.cs +++ /dev/null @@ -1,100 +0,0 @@ -using Bit.Core.PhishingDomainFeatures.Interfaces; -using Bit.Core.Settings; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.PhishingDomainFeatures; - -/// -/// Implementation of ICloudPhishingDomainQuery for cloud environments -/// that directly calls the external phishing domain source -/// -public class CloudPhishingDomainDirectQuery : ICloudPhishingDomainQuery -{ - private readonly IGlobalSettings _globalSettings; - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger _logger; - - public CloudPhishingDomainDirectQuery( - IGlobalSettings globalSettings, - IHttpClientFactory httpClientFactory, - ILogger logger) - { - _globalSettings = globalSettings; - _httpClientFactory = httpClientFactory; - _logger = logger; - } - - public async Task> GetPhishingDomainsAsync() - { - if (string.IsNullOrWhiteSpace(_globalSettings.PhishingDomain?.UpdateUrl)) - { - throw new InvalidOperationException("Phishing domain update URL is not configured."); - } - - var httpClient = _httpClientFactory.CreateClient("PhishingDomains"); - var response = await httpClient.GetAsync(_globalSettings.PhishingDomain.UpdateUrl); - response.EnsureSuccessStatusCode(); - - var content = await response.Content.ReadAsStringAsync(); - return ParseDomains(content); - } - - /// - /// Gets the SHA256 checksum of the remote phishing domains list - /// - /// The SHA256 checksum as a lowercase hex string - public async Task GetRemoteChecksumAsync() - { - if (string.IsNullOrWhiteSpace(_globalSettings.PhishingDomain?.ChecksumUrl)) - { - _logger.LogWarning("Phishing domain checksum URL is not configured."); - return string.Empty; - } - - try - { - var httpClient = _httpClientFactory.CreateClient("PhishingDomains"); - var response = await httpClient.GetAsync(_globalSettings.PhishingDomain.ChecksumUrl); - response.EnsureSuccessStatusCode(); - - var content = await response.Content.ReadAsStringAsync(); - return ParseChecksumResponse(content); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving phishing domain checksum from {Url}", - _globalSettings.PhishingDomain.ChecksumUrl); - return string.Empty; - } - } - - /// - /// Parses a checksum response in the format "hash *filename" - /// - private static string ParseChecksumResponse(string checksumContent) - { - if (string.IsNullOrWhiteSpace(checksumContent)) - { - return string.Empty; - } - - // Format is typically "hash *filename" - var parts = checksumContent.Split(' ', 2); - - return parts.Length > 0 ? parts[0].Trim() : string.Empty; - } - - private static List ParseDomains(string content) - { - if (string.IsNullOrWhiteSpace(content)) - { - return []; - } - - return content - .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) - .Select(line => line.Trim()) - .Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith("#")) - .ToList(); - } -} diff --git a/src/Core/PhishingDomainFeatures/CloudPhishingDomainRelayQuery.cs b/src/Core/PhishingDomainFeatures/CloudPhishingDomainRelayQuery.cs deleted file mode 100644 index 6b0027062c..0000000000 --- a/src/Core/PhishingDomainFeatures/CloudPhishingDomainRelayQuery.cs +++ /dev/null @@ -1,69 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.PhishingDomainFeatures.Interfaces; -using Bit.Core.Services; -using Bit.Core.Settings; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.PhishingDomainFeatures; - -/// -/// Implementation of ICloudPhishingDomainQuery for self-hosted environments -/// that relays the request to the Bitwarden cloud API -/// -public class CloudPhishingDomainRelayQuery : BaseIdentityClientService, ICloudPhishingDomainQuery -{ - private readonly IGlobalSettings _globalSettings; - - public CloudPhishingDomainRelayQuery( - IHttpClientFactory httpFactory, - IGlobalSettings globalSettings, - ILogger logger) - : base( - httpFactory, - globalSettings.Installation.ApiUri, - globalSettings.Installation.IdentityUri, - "api.licensing", - $"installation.{globalSettings.Installation.Id}", - globalSettings.Installation.Key, - logger) - { - _globalSettings = globalSettings; - } - - public async Task> GetPhishingDomainsAsync() - { - if (!_globalSettings.SelfHosted || !_globalSettings.EnableCloudCommunication) - { - throw new InvalidOperationException("This query is only for self-hosted installations with cloud communication enabled."); - } - - var result = await SendAsync(HttpMethod.Get, "phishing-domains", null, true); - return result?.ToList() ?? new List(); - } - - /// - /// Gets the SHA256 checksum of the remote phishing domains list - /// - /// The SHA256 checksum as a lowercase hex string - public async Task GetRemoteChecksumAsync() - { - if (!_globalSettings.SelfHosted || !_globalSettings.EnableCloudCommunication) - { - throw new InvalidOperationException("This query is only for self-hosted installations with cloud communication enabled."); - } - - try - { - // For self-hosted environments, we get the checksum from the Bitwarden cloud API - var result = await SendAsync(HttpMethod.Get, "phishing-domains/checksum", null, true); - return result ?? string.Empty; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving phishing domain checksum from Bitwarden cloud API"); - return string.Empty; - } - } -} diff --git a/src/Core/PhishingDomainFeatures/Interfaces/ICloudPhishingDomainQuery.cs b/src/Core/PhishingDomainFeatures/Interfaces/ICloudPhishingDomainQuery.cs deleted file mode 100644 index dac91747f7..0000000000 --- a/src/Core/PhishingDomainFeatures/Interfaces/ICloudPhishingDomainQuery.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Bit.Core.PhishingDomainFeatures.Interfaces; - -public interface ICloudPhishingDomainQuery -{ - Task> GetPhishingDomainsAsync(); - Task GetRemoteChecksumAsync(); -} diff --git a/src/Core/Repositories/IPhishingDomainRepository.cs b/src/Core/Repositories/IPhishingDomainRepository.cs deleted file mode 100644 index 2d653b0a43..0000000000 --- a/src/Core/Repositories/IPhishingDomainRepository.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Bit.Core.Repositories; - -public interface IPhishingDomainRepository -{ - Task> GetActivePhishingDomainsAsync(); - Task UpdatePhishingDomainsAsync(IEnumerable domains, string checksum); - Task GetCurrentChecksumAsync(); -} diff --git a/src/Core/Repositories/Implementations/AzurePhishingDomainRepository.cs b/src/Core/Repositories/Implementations/AzurePhishingDomainRepository.cs deleted file mode 100644 index 2d4ea15b7e..0000000000 --- a/src/Core/Repositories/Implementations/AzurePhishingDomainRepository.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System.Text.Json; -using Bit.Core.PhishingDomainFeatures; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.Repositories.Implementations; - -public class AzurePhishingDomainRepository : IPhishingDomainRepository -{ - private readonly AzurePhishingDomainStorageService _storageService; - private readonly IDistributedCache _cache; - private readonly ILogger _logger; - private const string _domainsCacheKey = "PhishingDomains_v1"; - private const string _checksumCacheKey = "PhishingDomains_Checksum_v1"; - private static readonly DistributedCacheEntryOptions _cacheOptions = new() - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24), - SlidingExpiration = TimeSpan.FromHours(1) - }; - - public AzurePhishingDomainRepository( - AzurePhishingDomainStorageService storageService, - IDistributedCache cache, - ILogger logger) - { - _storageService = storageService; - _cache = cache; - _logger = logger; - } - - public async Task> GetActivePhishingDomainsAsync() - { - try - { - var cachedDomains = await _cache.GetStringAsync(_domainsCacheKey); - if (!string.IsNullOrEmpty(cachedDomains)) - { - _logger.LogDebug("Retrieved phishing domains from cache"); - return JsonSerializer.Deserialize>(cachedDomains) ?? []; - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to retrieve phishing domains from cache"); - } - - var domains = await _storageService.GetDomainsAsync(); - - try - { - await _cache.SetStringAsync( - _domainsCacheKey, - JsonSerializer.Serialize(domains), - _cacheOptions); - _logger.LogDebug("Stored {Count} phishing domains in cache", domains.Count); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to store phishing domains in cache"); - } - - return domains; - } - - public async Task GetCurrentChecksumAsync() - { - try - { - var cachedChecksum = await _cache.GetStringAsync(_checksumCacheKey); - if (!string.IsNullOrEmpty(cachedChecksum)) - { - _logger.LogDebug("Retrieved phishing domain checksum from cache"); - return cachedChecksum; - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to retrieve phishing domain checksum from cache"); - } - - var checksum = await _storageService.GetChecksumAsync(); - - try - { - if (!string.IsNullOrEmpty(checksum)) - { - await _cache.SetStringAsync( - _checksumCacheKey, - checksum, - _cacheOptions); - _logger.LogDebug("Stored phishing domain checksum in cache"); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to store phishing domain checksum in cache"); - } - - return checksum; - } - - public async Task UpdatePhishingDomainsAsync(IEnumerable domains, string checksum) - { - var domainsList = domains.ToList(); - await _storageService.UpdateDomainsAsync(domainsList, checksum); - - try - { - await _cache.SetStringAsync( - _domainsCacheKey, - JsonSerializer.Serialize(domainsList), - _cacheOptions); - - await _cache.SetStringAsync( - _checksumCacheKey, - checksum, - _cacheOptions); - - _logger.LogDebug("Updated phishing domains cache after update operation"); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to update phishing domains in cache"); - } - } -} diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index ddc48521e3..f030c73809 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -81,7 +81,6 @@ public class GlobalSettings : IGlobalSettings public virtual ILaunchDarklySettings LaunchDarkly { get; set; } = new LaunchDarklySettings(); public virtual string DevelopmentDirectory { get; set; } public virtual IWebPushSettings WebPush { get; set; } = new WebPushSettings(); - public virtual IPhishingDomainSettings PhishingDomain { get; set; } = new PhishingDomainSettings(); public virtual int SendAccessTokenLifetimeInMinutes { get; set; } = 5; public virtual bool EnableEmailVerification { get; set; } @@ -690,12 +689,6 @@ public class GlobalSettings : IGlobalSettings public int MaxNetworkRetries { get; set; } = 2; } - public class PhishingDomainSettings : IPhishingDomainSettings - { - public string UpdateUrl { get; set; } - public string ChecksumUrl { get; set; } - } - public class DistributedIpRateLimitingSettings { public string RedisConnectionString { get; set; } diff --git a/src/Core/Settings/IGlobalSettings.cs b/src/Core/Settings/IGlobalSettings.cs index 20b832c678..06dece3394 100644 --- a/src/Core/Settings/IGlobalSettings.cs +++ b/src/Core/Settings/IGlobalSettings.cs @@ -28,5 +28,4 @@ public interface IGlobalSettings string DevelopmentDirectory { get; set; } IWebPushSettings WebPush { get; set; } GlobalSettings.EventLoggingSettings EventLogging { get; set; } - IPhishingDomainSettings PhishingDomain { get; set; } } diff --git a/src/Core/Settings/IPhishingDomainSettings.cs b/src/Core/Settings/IPhishingDomainSettings.cs deleted file mode 100644 index 2e4a901a5a..0000000000 --- a/src/Core/Settings/IPhishingDomainSettings.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Bit.Core.Settings; - -public interface IPhishingDomainSettings -{ - string UpdateUrl { get; set; } - string ChecksumUrl { get; set; } -} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/readme.md b/src/Identity/IdentityServer/RequestValidators/SendAccess/readme.md index afab13a156..2a6ea66857 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/readme.md +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/readme.md @@ -1,17 +1,15 @@ -Send Access Request Validation -=== +# Send Access Request Validation This feature supports the ability of Tools to require specific claims for access to sends. In order to access Send data a user must meet the requirements laid out in these request validators. -# ***Important: String Constants*** - -The string constants contained herein are used in conjunction with the Auth module in the SDK. Any change to these string values _must_ be intentional and _must_ have a corresponding change in the SDK. +> [!IMPORTANT] +> The string constants contained herein are used in conjunction with the Auth module in the SDK. Any change to these string values _must_ be intentional and _must_ have a corresponding change in the SDK. There is snapshot testing that will fail if the strings change to help detect unintended changes to the string constants. -# Custom Claims +## Custom Claims Send access tokens contain custom claims specific to the Send the Send grant type. @@ -19,41 +17,41 @@ Send access tokens contain custom claims specific to the Send the Send grant typ 1. `send_email` - only set when the Send requires `EmailOtp` authentication type. 1. `type` - this will always be `Send` -# Authentication methods +## Authentication methods -## `NeverAuthenticate` +### `NeverAuthenticate` For a Send to be in this state two things can be true: 1. The Send has been modified and no longer allows access. 2. The Send does not exist. -## `NotAuthenticated` +### `NotAuthenticated` In this scenario the Send is not protected by any added authentication or authorization and the access token is issued to the requesting user. -## `ResourcePassword` +### `ResourcePassword` In this scenario the Send is password protected and a user must supply the correct password hash to be issued an access token. -## `EmailOtp` +### `EmailOtp` In this scenario the Send is only accessible to owners of specific email addresses. The user must submit a correct email. Once the email has been entered then ownership of the email must be established via OTP. The Otp is sent to the aforementioned email and must be supplied, along with the email, to be issued an access token. -# Send Access Request Validation +## Send Access Request Validation -## Required Parameters +### Required Parameters -### All Requests +#### All Requests - `send_id` - Base64 URL-encoded GUID of the send being accessed -### Password Protected Sends +#### Password Protected Sends - `password_hash_b64` - client hashed Base64-encoded password. -### Email OTP Protected Sends +#### Email OTP Protected Sends - `email` - Email address associated with the send - `otp` - One-time password (optional - if missing, OTP is generated and sent) -## Error Responses +### Error Responses All errors include a custom response field: ```json @@ -62,5 +60,4 @@ All errors include a custom response field: "error_description": "Human readable description", "send_access_error_type": "specific_error_code" } -``` - +``` \ No newline at end of file diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index f1aa11d068..300a4d823d 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -11,6 +11,7 @@ using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Kdf; +using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.Queries.Interfaces; using Bit.Core.Repositories; @@ -38,6 +39,7 @@ public class AccountsControllerTests : IDisposable private readonly IUserAccountKeysQuery _userAccountKeysQuery; private readonly ITwoFactorEmailService _twoFactorEmailService; private readonly IChangeKdfCommand _changeKdfCommand; + private readonly IUserRepository _userRepository; public AccountsControllerTests() { @@ -53,6 +55,7 @@ public class AccountsControllerTests : IDisposable _userAccountKeysQuery = Substitute.For(); _twoFactorEmailService = Substitute.For(); _changeKdfCommand = Substitute.For(); + _userRepository = Substitute.For(); _sut = new AccountsController( _organizationService, @@ -66,7 +69,8 @@ public class AccountsControllerTests : IDisposable _featureService, _userAccountKeysQuery, _twoFactorEmailService, - _changeKdfCommand + _changeKdfCommand, + _userRepository ); } @@ -688,6 +692,37 @@ public class AccountsControllerTests : IDisposable await _sut.PostKdf(model); } + [Theory] + [BitAutoData] + public async Task PostKeys_NoUser_Errors(KeysRequestModel model) + { + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(null)); + + await Assert.ThrowsAsync(() => _sut.PostKeys(model)); + } + + [Theory] + [BitAutoData("existing", "existing")] + [BitAutoData((string)null, "existing")] + [BitAutoData("", "existing")] + [BitAutoData(" ", "existing")] + [BitAutoData("existing", null)] + [BitAutoData("existing", "")] + [BitAutoData("existing", " ")] + public async Task PostKeys_UserAlreadyHasKeys_Errors(string? existingPrivateKey, string? existingPublicKey, + KeysRequestModel model) + { + var user = GenerateExampleUser(); + user.PrivateKey = existingPrivateKey; + user.PublicKey = existingPublicKey; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); + + var exception = await Assert.ThrowsAsync(() => _sut.PostKeys(model)); + + Assert.NotNull(exception.Message); + Assert.Contains("User has existing keypair", exception.Message); + } + // Below are helper functions that currently belong to this // test class, but ultimately may need to be split out into // something greater in order to share common test steps with @@ -738,5 +773,77 @@ public class AccountsControllerTests : IDisposable _userService.GetUserByIdAsync(Arg.Any()) .Returns(Task.FromResult((User)null)); } + + [Theory, BitAutoData] + public async Task PostKeys_WithAccountKeys_CallsSetV2AccountCryptographicState( + User user, + KeysRequestModel model) + { + // Arrange + user.PublicKey = null; + user.PrivateKey = null; + model.AccountKeys = new AccountKeysRequestModel + { + UserKeyEncryptedAccountPrivateKey = "wrapped-private-key", + AccountPublicKey = "public-key", + PublicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairRequestModel + { + PublicKey = "public-key", + WrappedPrivateKey = "wrapped-private-key", + SignedPublicKey = "signed-public-key" + }, + SignatureKeyPair = new SignatureKeyPairRequestModel + { + VerifyingKey = "verifying-key", + SignatureAlgorithm = "ed25519", + WrappedSigningKey = "wrapped-signing-key" + }, + SecurityState = new SecurityStateModel + { + SecurityState = "security-state", + SecurityVersion = 2 + } + }; + + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + + // Act + var result = await _sut.PostKeys(model); + + // Assert + await _userRepository.Received(1).SetV2AccountCryptographicStateAsync( + user.Id, + Arg.Any()); + await _userService.DidNotReceiveWithAnyArgs().SaveUserAsync(Arg.Any()); + Assert.NotNull(result); + Assert.Equal("keys", result.Object); + } + + [Theory, BitAutoData] + public async Task PostKeys_WithoutAccountKeys_CallsSaveUser( + User user, + KeysRequestModel model) + { + // Arrange + user.PublicKey = null; + user.PrivateKey = null; + model.AccountKeys = null; + model.PublicKey = "public-key"; + model.EncryptedPrivateKey = "encrypted-private-key"; + + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + + // Act + var result = await _sut.PostKeys(model); + + // Assert + await _userService.Received(1).SaveUserAsync(Arg.Is(u => + u.PublicKey == model.PublicKey && + u.PrivateKey == model.EncryptedPrivateKey)); + await _userRepository.DidNotReceiveWithAnyArgs() + .SetV2AccountCryptographicStateAsync(Arg.Any(), Arg.Any()); + Assert.NotNull(result); + Assert.Equal("keys", result.Object); + } } diff --git a/test/Core.Test/AdminConsole/AutoFixture/OrganizationUserPolicyDetailsFixtures.cs b/test/Core.Test/AdminConsole/AutoFixture/OrganizationUserPolicyDetailsFixtures.cs index 634b234e70..53511de550 100644 --- a/test/Core.Test/AdminConsole/AutoFixture/OrganizationUserPolicyDetailsFixtures.cs +++ b/test/Core.Test/AdminConsole/AutoFixture/OrganizationUserPolicyDetailsFixtures.cs @@ -2,6 +2,7 @@ using AutoFixture; using AutoFixture.Xunit2; using Bit.Core.AdminConsole.Enums; +using Bit.Core.Enums; using Bit.Core.Models.Data.Organizations.OrganizationUsers; namespace Bit.Core.Test.AdminConsole.AutoFixture; @@ -9,10 +10,16 @@ namespace Bit.Core.Test.AdminConsole.AutoFixture; internal class OrganizationUserPolicyDetailsCustomization : ICustomization { public PolicyType Type { get; set; } + public OrganizationUserStatusType Status { get; set; } + public OrganizationUserType UserType { get; set; } + public bool IsProvider { get; set; } - public OrganizationUserPolicyDetailsCustomization(PolicyType type) + public OrganizationUserPolicyDetailsCustomization(PolicyType type, OrganizationUserStatusType status, OrganizationUserType userType, bool isProvider) { Type = type; + Status = status; + UserType = userType; + IsProvider = isProvider; } public void Customize(IFixture fixture) @@ -20,6 +27,9 @@ internal class OrganizationUserPolicyDetailsCustomization : ICustomization fixture.Customize(composer => composer .With(o => o.OrganizationId, Guid.NewGuid()) .With(o => o.PolicyType, Type) + .With(o => o.OrganizationUserStatus, Status) + .With(o => o.OrganizationUserType, UserType) + .With(o => o.IsProvider, IsProvider) .With(o => o.PolicyEnabled, true)); } } @@ -27,14 +37,25 @@ internal class OrganizationUserPolicyDetailsCustomization : ICustomization public class OrganizationUserPolicyDetailsAttribute : CustomizeAttribute { private readonly PolicyType _type; + private readonly OrganizationUserStatusType _status; + private readonly OrganizationUserType _userType; + private readonly bool _isProvider; - public OrganizationUserPolicyDetailsAttribute(PolicyType type) + public OrganizationUserPolicyDetailsAttribute(PolicyType type) : this(type, OrganizationUserStatusType.Accepted, OrganizationUserType.User, false) { _type = type; } + public OrganizationUserPolicyDetailsAttribute(PolicyType type, OrganizationUserStatusType status, OrganizationUserType userType, bool isProvider) + { + _type = type; + _status = status; + _userType = userType; + _isProvider = isProvider; + } + public override ICustomization GetCustomization(ParameterInfo parameter) { - return new OrganizationUserPolicyDetailsCustomization(_type); + return new OrganizationUserPolicyDetailsCustomization(_type, _status, _userType, _isProvider); } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs index 540bac4d1c..82d4eceaed 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs @@ -1,7 +1,9 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Business.Tokenables; @@ -24,6 +26,7 @@ using Bit.Test.Common.Fakes; using Microsoft.AspNetCore.DataProtection; using NSubstitute; using Xunit; +using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; namespace Bit.Core.Test.OrganizationFeatures.OrganizationUsers; @@ -673,6 +676,79 @@ public class AcceptOrgUserCommandTests Assert.Equal("User not found within organization.", exception.Message); } + // Auto-confirm policy validation tests -------------------------------------------------------------------------- + + [Theory] + [BitAutoData] + public async Task AcceptOrgUserAsync_WithAutoConfirmIsNotEnabled_DoesNotCheckCompliance( + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) + { + // Arrange + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + + // Act + var resultOrgUser = await sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService); + + // Assert + AssertValidAcceptedOrgUser(resultOrgUser, orgUser, user); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .IsCompliantAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task AcceptOrgUserAsync_WithUserThatIsCompliantWithAutoConfirm_AcceptsUser( + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) + { + // Arrange + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + + // Mock auto-confirm enforcement query to return valid (no auto-confirm restrictions) + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser], user))); + + // Act + var resultOrgUser = await sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService); + + // Assert + AssertValidAcceptedOrgUser(resultOrgUser, orgUser, user); + + await sutProvider.GetDependency().Received(1).ReplaceAsync( + Arg.Is(ou => ou.Id == orgUser.Id && ou.Status == OrganizationUserStatusType.Accepted)); + } + + [Theory] + [BitAutoData] + public async Task AcceptOrgUserAsync_WithAutoConfirmIsEnabledAndFailsCompliance_ThrowsBadRequestException( + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails, + OrganizationUser otherOrgUser) + { + // Arrange + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Invalid( + new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser, otherOrgUser], user), + new UserCannotBelongToAnotherOrganization())); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService)); + + // Should get auto-confirm error + Assert.Equal(new UserCannotBelongToAnotherOrganization().Message, exception.Message); + } + // Private helpers ------------------------------------------------------------------------------------------------- /// @@ -716,7 +792,7 @@ public class AcceptOrgUserCommandTests /// - Provides mock data for an admin to validate email functionality. /// - Returns the corresponding organization for the given org ID. /// - private void SetupCommonAcceptOrgUserMocks(SutProvider sutProvider, User user, + private static void SetupCommonAcceptOrgUserMocks(SutProvider sutProvider, User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) { @@ -729,18 +805,12 @@ public class AcceptOrgUserCommandTests // User is not part of any other orgs sutProvider.GetDependency() .GetManyByUserAsync(user.Id) - .Returns( - Task.FromResult>(new List()) - ); + .Returns([]); // Org they are trying to join does not have single org policy sutProvider.GetDependency() .GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg, OrganizationUserStatusType.Invited) - .Returns( - Task.FromResult>( - new List() - ) - ); + .Returns([]); // User is not part of any organization that applies the single org policy sutProvider.GetDependency() @@ -750,20 +820,24 @@ public class AcceptOrgUserCommandTests // Org does not require 2FA sutProvider.GetDependency().GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Invited) - .Returns(Task.FromResult>( - new List())); + .Returns([]); // Provide at least 1 admin to test email functionality sutProvider.GetDependency() .GetManyByMinimumRoleAsync(orgUser.OrganizationId, OrganizationUserType.Admin) - .Returns(Task.FromResult>( - new List() { adminUserDetails } - )); + .Returns([adminUserDetails]); // Return org sutProvider.GetDependency() .GetByIdAsync(org.Id) - .Returns(Task.FromResult(org)); + .Returns(org); + + // Auto-confirm enforcement query returns valid by default (no restrictions) + var request = new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser], user); + + sutProvider.GetDependency() + .IsCompliantAsync(request) + .Returns(Valid(request)); } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmOrganizationUsersValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmOrganizationUsersValidatorTests.cs index eb377a8d08..c3fb52ecbe 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmOrganizationUsersValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmOrganizationUsersValidatorTests.cs @@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; @@ -12,6 +13,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Core.Test.AutoFixture.OrganizationFixtures; using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; @@ -19,6 +21,7 @@ using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; +using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUsers; @@ -116,11 +119,11 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests SutProvider sutProvider, [Organization(useAutomaticUserConfirmation: true, planType: PlanType.EnterpriseAnnually)] Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, - Guid userId, + User user, [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) { // Arrange - organizationUser.UserId = userId; + organizationUser.UserId = user.Id; organizationUser.OrganizationId = organization.Id; var request = new AutomaticallyConfirmOrganizationUserValidationRequest @@ -140,12 +143,23 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests sutProvider.GetDependency() .TwoFactorIsEnabledAsync(Arg.Any>()) - .Returns([(userId, true)]); + .Returns([(user.Id, true)]); sutProvider.GetDependency() - .GetManyByUserAsync(userId) + .GetManyByUserAsync(user.Id) .Returns([organizationUser]); + sutProvider.GetDependency() + .GetUserByIdAsync(user.Id) + .Returns(user); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Valid( + new AutomaticUserConfirmationPolicyEnforcementRequest(organization.Id, + [organizationUser], + user))); + // Act var result = await sutProvider.Sut.ValidateAsync(request); @@ -319,11 +333,11 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests SutProvider sutProvider, [Organization(useAutomaticUserConfirmation: true)] Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, - Guid userId, + User user, [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) { // Arrange - organizationUser.UserId = userId; + organizationUser.UserId = user.Id; organizationUser.OrganizationId = organization.Id; var request = new AutomaticallyConfirmOrganizationUserValidationRequest @@ -343,12 +357,24 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests sutProvider.GetDependency() .TwoFactorIsEnabledAsync(Arg.Any>()) - .Returns([(userId, true)]); + .Returns([(user.Id, true)]); sutProvider.GetDependency() - .GetManyByUserAsync(userId) + .GetManyByUserAsync(user.Id) .Returns([organizationUser]); + sutProvider.GetDependency() + .GetUserByIdAsync(user.Id) + .Returns(user); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Valid( + new AutomaticUserConfirmationPolicyEnforcementRequest(organization.Id, + [organizationUser], + user))); + + // Act var result = await sutProvider.Sut.ValidateAsync(request); @@ -362,11 +388,11 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests SutProvider sutProvider, [Organization(useAutomaticUserConfirmation: true)] Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, - Guid userId, + User user, [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) { // Arrange - organizationUser.UserId = userId; + organizationUser.UserId = user.Id; organizationUser.OrganizationId = organization.Id; var request = new AutomaticallyConfirmOrganizationUserValidationRequest @@ -386,16 +412,28 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests sutProvider.GetDependency() .TwoFactorIsEnabledAsync(Arg.Any>()) - .Returns([(userId, false)]); + .Returns([(user.Id, false)]); sutProvider.GetDependency() - .GetAsync(userId) + .GetAsync(user.Id) .Returns(new RequireTwoFactorPolicyRequirement([])); // No 2FA policy sutProvider.GetDependency() - .GetManyByUserAsync(userId) + .GetManyByUserAsync(user.Id) .Returns([organizationUser]); + sutProvider.GetDependency() + .GetUserByIdAsync(user.Id) + .Returns(user); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Valid( + new AutomaticUserConfirmationPolicyEnforcementRequest(organization.Id, + [organizationUser], + user))); + + // Act var result = await sutProvider.Sut.ValidateAsync(request); @@ -403,128 +441,17 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests Assert.True(result.IsValid); } - [Theory] - [BitAutoData] - public async Task ValidateAsync_UserInMultipleOrgs_WithSingleOrgPolicyOnThisOrg_ReturnsError( - SutProvider sutProvider, - [Organization(useAutomaticUserConfirmation: true)] Organization organization, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, - OrganizationUser otherOrgUser, - Guid userId, - [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) - { - // Arrange - organizationUser.UserId = userId; - organizationUser.OrganizationId = organization.Id; - - var request = new AutomaticallyConfirmOrganizationUserValidationRequest - { - PerformedBy = Substitute.For(), - DefaultUserCollectionName = "test-collection", - OrganizationUser = organizationUser, - OrganizationUserId = organizationUser.Id, - Organization = organization, - OrganizationId = organization.Id, - Key = "test-key" - }; - - var singleOrgPolicyDetails = new PolicyDetails - { - OrganizationId = organization.Id, - PolicyType = PolicyType.SingleOrg - }; - - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) - .Returns(autoConfirmPolicy); - - sutProvider.GetDependency() - .TwoFactorIsEnabledAsync(Arg.Any>()) - .Returns([(userId, true)]); - - sutProvider.GetDependency() - .GetManyByUserAsync(userId) - .Returns([organizationUser, otherOrgUser]); - - sutProvider.GetDependency() - .GetAsync(userId) - .Returns(new SingleOrganizationPolicyRequirement([singleOrgPolicyDetails])); - - // Act - var result = await sutProvider.Sut.ValidateAsync(request); - - // Assert - Assert.True(result.IsError); - Assert.IsType(result.AsError); - } - - [Theory] - [BitAutoData] - public async Task ValidateAsync_UserInMultipleOrgs_WithSingleOrgPolicyOnOtherOrg_ReturnsError( - SutProvider sutProvider, - [Organization(useAutomaticUserConfirmation: true)] Organization organization, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, - OrganizationUser otherOrgUser, - Guid userId, - [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) - { - // Arrange - organizationUser.UserId = userId; - organizationUser.OrganizationId = organization.Id; - - var request = new AutomaticallyConfirmOrganizationUserValidationRequest - { - PerformedBy = Substitute.For(), - DefaultUserCollectionName = "test-collection", - OrganizationUser = organizationUser, - OrganizationUserId = organizationUser.Id, - Organization = organization, - OrganizationId = organization.Id, - Key = "test-key" - }; - - var otherOrgId = Guid.NewGuid(); // Different org - var singleOrgPolicyDetails = new PolicyDetails - { - OrganizationId = otherOrgId, - PolicyType = PolicyType.SingleOrg, - }; - - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) - .Returns(autoConfirmPolicy); - - sutProvider.GetDependency() - .TwoFactorIsEnabledAsync(Arg.Any>()) - .Returns([(userId, true)]); - - sutProvider.GetDependency() - .GetManyByUserAsync(userId) - .Returns([organizationUser, otherOrgUser]); - - sutProvider.GetDependency() - .GetAsync(userId) - .Returns(new SingleOrganizationPolicyRequirement([singleOrgPolicyDetails])); - - // Act - var result = await sutProvider.Sut.ValidateAsync(request); - - // Assert - Assert.True(result.IsError); - Assert.IsType(result.AsError); - } - [Theory] [BitAutoData] public async Task ValidateAsync_UserInSingleOrg_ReturnsValidResult( SutProvider sutProvider, [Organization(useAutomaticUserConfirmation: true)] Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, - Guid userId, + User user, [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) { // Arrange - organizationUser.UserId = userId; + organizationUser.UserId = user.Id; organizationUser.OrganizationId = organization.Id; var request = new AutomaticallyConfirmOrganizationUserValidationRequest @@ -544,61 +471,22 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests sutProvider.GetDependency() .TwoFactorIsEnabledAsync(Arg.Any>()) - .Returns([(userId, true)]); + .Returns([(user.Id, true)]); sutProvider.GetDependency() - .GetManyByUserAsync(userId) + .GetManyByUserAsync(user.Id) .Returns([organizationUser]); // Single org - // Act - var result = await sutProvider.Sut.ValidateAsync(request); + sutProvider.GetDependency() + .GetUserByIdAsync(user.Id) + .Returns(user); - // Assert - Assert.True(result.IsValid); - } - - [Theory] - [BitAutoData] - public async Task ValidateAsync_UserInMultipleOrgs_WithNoSingleOrgPolicy_ReturnsValidResult( - SutProvider sutProvider, - [Organization(useAutomaticUserConfirmation: true)] Organization organization, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, - OrganizationUser otherOrgUser, - Guid userId, - Policy autoConfirmPolicy) - { - // Arrange - organizationUser.UserId = userId; - organizationUser.OrganizationId = organization.Id; - autoConfirmPolicy.Type = PolicyType.AutomaticUserConfirmation; - autoConfirmPolicy.Enabled = true; - - var request = new AutomaticallyConfirmOrganizationUserValidationRequest - { - PerformedBy = Substitute.For(), - DefaultUserCollectionName = "test-collection", - OrganizationUser = organizationUser, - OrganizationUserId = organizationUser.Id, - Organization = organization, - OrganizationId = organization.Id, - Key = "test-key" - }; - - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) - .Returns(autoConfirmPolicy); - - sutProvider.GetDependency() - .TwoFactorIsEnabledAsync(Arg.Any>()) - .Returns([(userId, true)]); - - sutProvider.GetDependency() - .GetManyByUserAsync(userId) - .Returns([organizationUser, otherOrgUser]); - - sutProvider.GetDependency() - .GetAsync(userId) - .Returns(new SingleOrganizationPolicyRequirement([])); + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Valid( + new AutomaticUserConfirmationPolicyEnforcementRequest(organization.Id, + [organizationUser], + user))); // Act var result = await sutProvider.Sut.ValidateAsync(request); @@ -693,4 +581,59 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests Assert.True(result.IsError); Assert.IsType(result.AsError); } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithNonProviderUser_ReturnsValidResult( + SutProvider sutProvider, + [Organization(useAutomaticUserConfirmation: true)] Organization organization, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, + User user, + [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) + { + // Arrange + organizationUser.UserId = user.Id; + organizationUser.OrganizationId = organization.Id; + + var request = new AutomaticallyConfirmOrganizationUserValidationRequest + { + PerformedBy = Substitute.For(), + DefaultUserCollectionName = "test-collection", + OrganizationUser = organizationUser, + OrganizationUserId = organizationUser.Id, + Organization = organization, + OrganizationId = organization.Id, + Key = "test-key" + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) + .Returns(autoConfirmPolicy); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Any>()) + .Returns([(user.Id, true)]); + + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns([organizationUser]); + + sutProvider.GetDependency() + .GetUserByIdAsync(user.Id) + .Returns(user); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Valid( + new AutomaticUserConfirmationPolicyEnforcementRequest(organization.Id, + [organizationUser], + user))); + + + // Act + var result = await sutProvider.Sut.ValidateAsync(request); + + // Assert + Assert.True(result.IsValid); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs index 86b068b88f..5528ecb2a2 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs @@ -2,7 +2,9 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; @@ -21,6 +23,7 @@ using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; +using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers; @@ -559,4 +562,256 @@ public class ConfirmOrganizationUserCommandTests .DidNotReceive() .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } + + [Theory, BitAutoData] + public async Task ConfirmUserAsync_WithAutoConfirmEnabledAndUserBelongsToAnotherOrg_ThrowsBadRequest( + Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + OrganizationUser otherOrgUser, string key, SutProvider sutProvider) + { + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = user.Id; + otherOrgUser.UserId = user.Id; + otherOrgUser.OrganizationId = Guid.NewGuid(); // Different org + + sutProvider.GetDependency() + .GetManyAsync([]).ReturnsForAnyArgs([orgUser]); + sutProvider.GetDependency() + .GetManyByManyUsersAsync([]) + .ReturnsForAnyArgs([orgUser, otherOrgUser]); + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + sutProvider.GetDependency().GetManyAsync([]).ReturnsForAnyArgs([user]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Invalid( + new AutomaticUserConfirmationPolicyEnforcementRequest(orgUser.Id, [orgUser, otherOrgUser], user), + new UserCannotBelongToAnotherOrganization())); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); + + Assert.Equal(new UserCannotBelongToAnotherOrganization().Message, exception.Message); + } + + [Theory, BitAutoData] + public async Task ConfirmUserAsync_WithAutoConfirmEnabledForOtherOrg_ThrowsBadRequest( + Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + OrganizationUser otherOrgUser, string key, SutProvider sutProvider) + { + // Arrange + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = user.Id; + otherOrgUser.UserId = user.Id; + otherOrgUser.OrganizationId = Guid.NewGuid(); + + sutProvider.GetDependency() + .GetManyAsync([]).ReturnsForAnyArgs([orgUser]); + sutProvider.GetDependency() + .GetManyByManyUsersAsync([]) + .ReturnsForAnyArgs([orgUser, otherOrgUser]); + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + sutProvider.GetDependency().GetManyAsync([]).ReturnsForAnyArgs([user]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Invalid( + new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser, otherOrgUser], user), + new OtherOrganizationDoesNotAllowOtherMembership())); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); + + Assert.Equal(new OtherOrganizationDoesNotAllowOtherMembership().Message, exception.Message); + } + + [Theory, BitAutoData] + public async Task ConfirmUserAsync_WithAutoConfirmEnabledAndUserIsProvider_ThrowsBadRequest( + Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + string key, SutProvider sutProvider) + { + // Arrange + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = user.Id; + + sutProvider.GetDependency() + .GetManyAsync([]).ReturnsForAnyArgs([orgUser]); + sutProvider.GetDependency() + .GetManyByManyUsersAsync([]) + .ReturnsForAnyArgs([orgUser]); + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + sutProvider.GetDependency().GetManyAsync([]).ReturnsForAnyArgs([user]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Invalid( + new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser], user), + new ProviderUsersCannotJoin())); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); + + Assert.Equal(new ProviderUsersCannotJoin().Message, exception.Message); + } + + [Theory, BitAutoData] + public async Task ConfirmUserAsync_WithAutoConfirmNotApplicable_Succeeds( + Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + string key, SutProvider sutProvider) + { + // Arrange + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = user.Id; + + sutProvider.GetDependency() + .GetManyAsync([]).ReturnsForAnyArgs([orgUser]); + sutProvider.GetDependency() + .GetManyByManyUsersAsync([]) + .ReturnsForAnyArgs([orgUser]); + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + sutProvider.GetDependency().GetManyAsync([]).ReturnsForAnyArgs([user]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser], user))); + + // Act + await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id); + + // Assert + await sutProvider.GetDependency() + .Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); + await sutProvider.GetDependency() + .Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email, orgUser.AccessSecretsManager); + } + + [Theory, BitAutoData] + public async Task ConfirmUserAsync_WithAutoConfirmValidationBeforeSingleOrgPolicy_ChecksAutoConfirmFirst( + Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + OrganizationUser otherOrgUser, + [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, + string key, SutProvider sutProvider) + { + // Arrange - Setup conditions that would fail BOTH auto-confirm AND single org policy + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = user.Id; + otherOrgUser.UserId = user.Id; + otherOrgUser.OrganizationId = Guid.NewGuid(); + + sutProvider.GetDependency() + .GetManyAsync([]).ReturnsForAnyArgs([orgUser]); + sutProvider.GetDependency() + .GetManyByManyUsersAsync([]) + .ReturnsForAnyArgs([orgUser, otherOrgUser]); + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + sutProvider.GetDependency().GetManyAsync([]).ReturnsForAnyArgs([user]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + singleOrgPolicy.OrganizationId = org.Id; + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg) + .Returns([singleOrgPolicy]); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Invalid( + new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser, otherOrgUser], user), + new UserCannotBelongToAnotherOrganization())); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); + + Assert.Equal(new UserCannotBelongToAnotherOrganization().Message, exception.Message); + Assert.NotEqual("Cannot confirm this member to the organization until they leave or remove all other organizations.", + exception.Message); + } + + [Theory, BitAutoData] + public async Task ConfirmUsersAsync_WithAutoConfirmEnabled_MixedResults( + Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser2, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser3, + OrganizationUser otherOrgUser, User user1, User user2, User user3, + string key, SutProvider sutProvider) + { + // Arrange + org.PlanType = PlanType.EnterpriseAnnually; + orgUser1.OrganizationId = orgUser2.OrganizationId = orgUser3.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser1.UserId = user1.Id; + orgUser2.UserId = user2.Id; + orgUser3.UserId = user3.Id; + otherOrgUser.UserId = user3.Id; + otherOrgUser.OrganizationId = Guid.NewGuid(); + + var orgUsers = new[] { orgUser1, orgUser2, orgUser3 }; + sutProvider.GetDependency() + .GetManyAsync([]).ReturnsForAnyArgs(orgUsers); + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + sutProvider.GetDependency() + .GetManyAsync([]).ReturnsForAnyArgs([user1, user2, user3]); + sutProvider.GetDependency() + .GetManyByManyUsersAsync([]) + .ReturnsForAnyArgs([orgUser1, orgUser2, orgUser3, otherOrgUser]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Is(r => r.User.Id == user1.Id)) + .Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser1], user1))); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Is(r => r.User.Id == user2.Id)) + .Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser2], user2))); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Is(r => r.User.Id == user3.Id)) + .Returns(Invalid( + new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser3, otherOrgUser], user3), + new OtherOrganizationDoesNotAllowOtherMembership())); + + var keys = orgUsers.ToDictionary(ou => ou.Id, _ => key); + + // Act + var result = await sutProvider.Sut.ConfirmUsersAsync(confirmingUser.OrganizationId, keys, confirmingUser.Id); + + // Assert + Assert.Equal(3, result.Count); + Assert.Empty(result[0].Item2); + Assert.Empty(result[1].Item2); + Assert.Equal(new OtherOrganizationDoesNotAllowOtherMembership().Message, result[2].Item2); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidatorTests.cs new file mode 100644 index 0000000000..f2e6adbfa9 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidatorTests.cs @@ -0,0 +1,306 @@ +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Entities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; + +[SutProviderCustomize] +public class AutomaticUserConfirmationPolicyEnforcementValidatorTests +{ + [Theory] + [BitAutoData] + public async Task IsCompliantAsync_WithPolicyEnabledAndUserIsProviderMember_ReturnsProviderUsersCannotJoinError( + SutProvider sutProvider, + OrganizationUser organizationUser, + ProviderUser providerUser, + User user) + { + // Arrange + organizationUser.UserId = providerUser.UserId = user.Id; + + var policyDetails = new PolicyDetails + { + OrganizationId = organizationUser.OrganizationId, + PolicyType = PolicyType.AutomaticUserConfirmation + }; + + var request = new AutomaticUserConfirmationPolicyEnforcementRequest( + organizationUser.OrganizationId, + [organizationUser], + user); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([policyDetails])); + + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns([providerUser]); + + // Act + var result = await sutProvider.Sut.IsCompliantAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory] + [BitAutoData] + public async Task IsCompliantAsync_WithPolicyEnabledOnOtherOrganization_ReturnsOtherOrganizationDoesNotAllowOtherMembershipError( + SutProvider sutProvider, + OrganizationUser organizationUser, + OrganizationUser otherOrganizationUser, + User user) + { + // Arrange + organizationUser.UserId = user.Id; + otherOrganizationUser.UserId = user.Id; + + var otherOrgId = Guid.NewGuid(); + var policyDetails = new PolicyDetails + { + OrganizationId = otherOrgId, // Different from organizationUser.OrganizationId + PolicyType = PolicyType.AutomaticUserConfirmation + }; + + var request = new AutomaticUserConfirmationPolicyEnforcementRequest( + organizationUser.OrganizationId, + [organizationUser, otherOrganizationUser], + user); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([policyDetails])); + + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns([]); + + // Act + var result = await sutProvider.Sut.IsCompliantAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory] + [BitAutoData] + public async Task IsCompliantAsync_WithPolicyDisabledUserIsAMemberOfAnotherOrgReturnsValid( + SutProvider sutProvider, + OrganizationUser organizationUser, + OrganizationUser otherOrgUser, + User user) + { + // Arrange + organizationUser.UserId = user.Id; + otherOrgUser.UserId = user.Id; + + var request = new AutomaticUserConfirmationPolicyEnforcementRequest( + organizationUser.OrganizationId, + [organizationUser, otherOrgUser], + user); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([])); + + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns([]); + + // Act + var result = await sutProvider.Sut.IsCompliantAsync(request); + + // Assert + Assert.True(result.IsValid); + } + + [Theory] + [BitAutoData] + public async Task IsCompliantAsync_WithPolicyEnabledUserIsAMemberOfAnotherOrg_ReturnsCannotBeMemberOfAnotherOrgError( + SutProvider sutProvider, + OrganizationUser organizationUser, + OrganizationUser otherOrgUser, + User user) + { + // Arrange + organizationUser.UserId = user.Id; + otherOrgUser.UserId = user.Id; + + var request = new AutomaticUserConfirmationPolicyEnforcementRequest( + organizationUser.OrganizationId, + [organizationUser, otherOrgUser], + user); + + var policyDetails = new PolicyDetails + { + OrganizationId = organizationUser.OrganizationId, + PolicyType = PolicyType.AutomaticUserConfirmation + }; + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([policyDetails])); + + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns([]); + + // Act + var result = await sutProvider.Sut.IsCompliantAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory] + [BitAutoData] + public async Task IsCompliantAsync_WithPolicyEnabledAndChecksConditionsInCorrectOrder_ReturnsFirstFailure( + SutProvider sutProvider, + OrganizationUser organizationUser, + OrganizationUser otherOrgUser, + ProviderUser providerUser, + User user) + { + // Arrange + var policyDetails = new PolicyDetails + { + OrganizationId = organizationUser.OrganizationId, + PolicyType = PolicyType.AutomaticUserConfirmation, + OrganizationUserId = organizationUser.Id + }; + + var request = new AutomaticUserConfirmationPolicyEnforcementRequest( + organizationUser.OrganizationId, + [organizationUser, otherOrgUser], + user); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([policyDetails])); + + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns([providerUser]); + + // Act + var result = await sutProvider.Sut.IsCompliantAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory] + [BitAutoData] + public async Task IsCompliantAsync_WithPolicyIsEnabledNoOtherOrganizationsAndNotAProvider_ReturnsValid( + SutProvider sutProvider, + OrganizationUser organizationUser, + User user) + { + // Arrange + organizationUser.UserId = user.Id; + + var request = new AutomaticUserConfirmationPolicyEnforcementRequest( + organizationUser.OrganizationId, + [organizationUser], + user); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([ + new PolicyDetails + { + OrganizationUserId = organizationUser.Id, + OrganizationId = organizationUser.OrganizationId, + PolicyType = PolicyType.AutomaticUserConfirmation, + } + ])); + + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns([]); + + // Act + var result = await sutProvider.Sut.IsCompliantAsync(request); + + // Assert + Assert.True(result.IsValid); + } + + [Theory] + [BitAutoData] + public async Task IsCompliantAsync_WithPolicyDisabledForCurrentAndOtherOrg_ReturnsValid( + SutProvider sutProvider, + OrganizationUser organizationUser, + OrganizationUser otherOrgUser, + User user) + { + // Arrange + otherOrgUser.UserId = organizationUser.UserId = user.Id; + + var request = new AutomaticUserConfirmationPolicyEnforcementRequest( + organizationUser.OrganizationId, + [organizationUser], + user); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([])); + + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns([]); + + // Act + var result = await sutProvider.Sut.IsCompliantAsync(request); + + // Assert + Assert.True(result.IsValid); + } + + [Theory] + [BitAutoData] + public async Task IsCompliantAsync_WithPolicyDisabledForCurrentAndOtherOrgAndIsProvider_ReturnsValid( + SutProvider sutProvider, + OrganizationUser organizationUser, + OrganizationUser otherOrgUser, + ProviderUser providerUser, + User user) + { + // Arrange + providerUser.UserId = otherOrgUser.UserId = organizationUser.UserId = user.Id; + + var request = new AutomaticUserConfirmationPolicyEnforcementRequest( + organizationUser.OrganizationId, + [organizationUser], + user); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([])); + + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns([providerUser]); + + // Act + var result = await sutProvider.Sut.IsCompliantAsync(request); + + // Assert + Assert.True(result.IsValid); + } +} diff --git a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs index 92a3f3fb10..ae669398c5 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs @@ -1382,4 +1382,90 @@ public class RegisterUserCommandTests .Received(1) .SendOrganizationUserWelcomeEmailAsync(user, organization.DisplayName()); } + + [Theory, BitAutoData] + public async Task RegisterSSOAutoProvisionedUserAsync_WithBlockedDomain_ThrowsException( + User user, + Organization organization, + SutProvider sutProvider) + { + // Arrange + user.Email = "user@blocked-domain.com"; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com", organization.Id) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization)); + Assert.Equal("This email address is claimed by an organization using Bitwarden.", exception.Message); + } + + [Theory, BitAutoData] + public async Task RegisterSSOAutoProvisionedUserAsync_WithOwnClaimedDomain_Succeeds( + User user, + Organization organization, + SutProvider sutProvider) + { + // Arrange + user.Email = "user@company-domain.com"; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + // Domain is claimed by THIS organization, so it should be allowed + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("company-domain.com", organization.Id) + .Returns(false); // Not blocked because organization.Id is excluded + + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Success); + + // Act + var result = await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization); + + // Assert + Assert.True(result.Succeeded); + await sutProvider.GetDependency() + .Received(1) + .CreateUserAsync(user); + } + + [Theory, BitAutoData] + public async Task RegisterSSOAutoProvisionedUserAsync_WithNonClaimedDomain_Succeeds( + User user, + Organization organization, + SutProvider sutProvider) + { + // Arrange + user.Email = "user@unclaimed-domain.com"; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("unclaimed-domain.com", organization.Id) + .Returns(false); // Domain is not claimed by any org + + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Success); + + // Act + var result = await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization); + + // Assert + Assert.True(result.Succeeded); + await sutProvider.GetDependency() + .Received(1) + .CreateUserAsync(user); + } } diff --git a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs index 8325dcf1bb..79da4d0aae 100644 --- a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs +++ b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs @@ -139,6 +139,7 @@ public class AccountsControllerTests : IClassFixture [StringLength(1000), Required] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, [Required] string userSymmetricKey, [Required] KeysRequestModel userAsymmetricKeys, int kdfMemory, int kdfParallelism) { + userAsymmetricKeys.AccountKeys = null; // Localize substitutions to this test. var localFactory = new IdentityApplicationFactory(); @@ -202,6 +203,7 @@ public class AccountsControllerTests : IClassFixture [StringLength(1000), Required] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, [Required] string userSymmetricKey, [Required] KeysRequestModel userAsymmetricKeys, int kdfMemory, int kdfParallelism) { + userAsymmetricKeys.AccountKeys = null; // Localize substitutions to this test. var localFactory = new IdentityApplicationFactory(); localFactory.UpdateConfiguration("globalSettings:disableUserRegistration", "true"); @@ -233,6 +235,7 @@ public class AccountsControllerTests : IClassFixture [StringLength(1000)] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, string userSymmetricKey, KeysRequestModel userAsymmetricKeys, int kdfMemory, int kdfParallelism) { + userAsymmetricKeys.AccountKeys = null; // Localize factory to just this test. var localFactory = new IdentityApplicationFactory(); @@ -310,6 +313,7 @@ public class AccountsControllerTests : IClassFixture [StringLength(1000)] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, string userSymmetricKey, KeysRequestModel userAsymmetricKeys, int kdfMemory, int kdfParallelism, Guid orgSponsorshipId) { + userAsymmetricKeys.AccountKeys = null; // Localize factory to just this test. var localFactory = new IdentityApplicationFactory(); @@ -386,6 +390,7 @@ public class AccountsControllerTests : IClassFixture [StringLength(1000)] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, string userSymmetricKey, KeysRequestModel userAsymmetricKeys, int kdfMemory, int kdfParallelism, EmergencyAccess emergencyAccess) { + userAsymmetricKeys.AccountKeys = null; // Localize factory to just this test. var localFactory = new IdentityApplicationFactory(); @@ -455,6 +460,7 @@ public class AccountsControllerTests : IClassFixture [StringLength(1000)] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, string userSymmetricKey, KeysRequestModel userAsymmetricKeys, int kdfMemory, int kdfParallelism) { + userAsymmetricKeys.AccountKeys = null; // Localize factory to just this test. var localFactory = new IdentityApplicationFactory(); diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs index 8e6079c036..a4e6c6798e 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs @@ -24,6 +24,13 @@ namespace Bit.Identity.IntegrationTest.Endpoints; [SutProviderCustomize] public class IdentityServerTests : IClassFixture { + private static readonly KeysRequestModel TEST_ACCOUNT_KEYS = new KeysRequestModel + { + AccountKeys = null, + PublicKey = "public-key", + EncryptedPrivateKey = "encrypted-private-key", + }; + private const int SecondsInMinute = 60; private const int MinutesInHour = 60; private const int SecondsInHour = SecondsInMinute * MinutesInHour; @@ -64,6 +71,7 @@ public class IdentityServerTests : IClassFixture [Theory, BitAutoData, RegisterFinishRequestModelCustomize] public async Task TokenEndpoint_GrantTypePassword_Success(RegisterFinishRequestModel requestModel) { + requestModel.UserAsymmetricKeys = TEST_ACCOUNT_KEYS; var localFactory = new IdentityApplicationFactory(); var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); @@ -89,6 +97,7 @@ public class IdentityServerTests : IClassFixture public async Task TokenEndpoint_GrantTypePassword_WithAllUserTypes_WithSsoPolicyDisabled_WithEnforceSsoPolicyForAllUsersTrue_Success( OrganizationUserType organizationUserType, RegisterFinishRequestModel requestModel, Guid organizationId, int generatedUsername) { + requestModel.UserAsymmetricKeys = TEST_ACCOUNT_KEYS; requestModel.Email = $"{generatedUsername}@example.com"; var localFactory = new IdentityApplicationFactory(); @@ -114,6 +123,7 @@ public class IdentityServerTests : IClassFixture public async Task TokenEndpoint_GrantTypePassword_WithAllUserTypes_WithSsoPolicyDisabled_WithEnforceSsoPolicyForAllUsersFalse_Success( OrganizationUserType organizationUserType, RegisterFinishRequestModel requestModel, Guid organizationId, int generatedUsername) { + requestModel.UserAsymmetricKeys = TEST_ACCOUNT_KEYS; requestModel.Email = $"{generatedUsername}@example.com"; var localFactory = new IdentityApplicationFactory(); @@ -140,6 +150,7 @@ public class IdentityServerTests : IClassFixture public async Task TokenEndpoint_GrantTypePassword_WithAllUserTypes_WithSsoPolicyEnabled_WithEnforceSsoPolicyForAllUsersTrue_Throw( OrganizationUserType organizationUserType, RegisterFinishRequestModel requestModel, Guid organizationId, int generatedUsername) { + requestModel.UserAsymmetricKeys = TEST_ACCOUNT_KEYS; requestModel.Email = $"{generatedUsername}@example.com"; var localFactory = new IdentityApplicationFactory(); @@ -163,6 +174,7 @@ public class IdentityServerTests : IClassFixture public async Task TokenEndpoint_GrantTypePassword_WithOwnerOrAdmin_WithSsoPolicyEnabled_WithEnforceSsoPolicyForAllUsersFalse_Success( OrganizationUserType organizationUserType, RegisterFinishRequestModel requestModel, Guid organizationId, int generatedUsername) { + requestModel.UserAsymmetricKeys = TEST_ACCOUNT_KEYS; requestModel.Email = $"{generatedUsername}@example.com"; var localFactory = new IdentityApplicationFactory(); @@ -186,6 +198,7 @@ public class IdentityServerTests : IClassFixture public async Task TokenEndpoint_GrantTypePassword_WithNonOwnerOrAdmin_WithSsoPolicyEnabled_WithEnforceSsoPolicyForAllUsersFalse_Throws( OrganizationUserType organizationUserType, RegisterFinishRequestModel requestModel, Guid organizationId, int generatedUsername) { + requestModel.UserAsymmetricKeys = TEST_ACCOUNT_KEYS; requestModel.Email = $"{generatedUsername}@example.com"; var localFactory = new IdentityApplicationFactory(); @@ -207,6 +220,7 @@ public class IdentityServerTests : IClassFixture [Theory, BitAutoData, RegisterFinishRequestModelCustomize] public async Task TokenEndpoint_GrantTypeRefreshToken_Success(RegisterFinishRequestModel requestModel) { + requestModel.UserAsymmetricKeys = TEST_ACCOUNT_KEYS; var localFactory = new IdentityApplicationFactory(); var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); @@ -229,6 +243,7 @@ public class IdentityServerTests : IClassFixture [Theory, BitAutoData, RegisterFinishRequestModelCustomize] public async Task TokenEndpoint_GrantTypeClientCredentials_Success(RegisterFinishRequestModel model) { + model.UserAsymmetricKeys = TEST_ACCOUNT_KEYS; var localFactory = new IdentityApplicationFactory(); var user = await localFactory.RegisterNewIdentityFactoryUserAsync(model); @@ -253,6 +268,7 @@ public class IdentityServerTests : IClassFixture RegisterFinishRequestModel model, string deviceId) { + model.UserAsymmetricKeys.AccountKeys = null; var localFactory = new IdentityApplicationFactory(); var server = localFactory.WithWebHostBuilder(builder => { @@ -456,6 +472,7 @@ public class IdentityServerTests : IClassFixture public async Task TokenEndpoint_TooQuickInOneSecond_BlockRequest( RegisterFinishRequestModel requestModel) { + requestModel.UserAsymmetricKeys = TEST_ACCOUNT_KEYS; const int AmountInOneSecondAllowed = 10; // The rule we are testing is 10 requests in 1 second