diff --git a/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj b/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj index a84813fd7c..4fc79f2025 100644 --- a/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj +++ b/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj @@ -9,7 +9,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index 7bfd13c408..4a1becc0bf 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -16,6 +16,7 @@ using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Mvc; +using AdminConsoleEntities = Bit.Core.AdminConsole.Entities; namespace Bit.Api.AdminConsole.Controllers; @@ -55,17 +56,16 @@ public class PoliciesController : Controller } [HttpGet("{type}")] - public async Task Get(string orgId, int type) + public async Task Get(Guid orgId, int type) { - var orgIdGuid = new Guid(orgId); - if (!await _currentContext.ManagePolicies(orgIdGuid)) + if (!await _currentContext.ManagePolicies(orgId)) { throw new NotFoundException(); } - var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgIdGuid, (PolicyType)type); + var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type); if (policy == null) { - throw new NotFoundException(); + return new PolicyResponseModel(new AdminConsoleEntities.Policy() { Type = (PolicyType)type, Enabled = false }); } return new PolicyResponseModel(policy); diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index a0c01752a8..d9dfbafc79 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -148,6 +148,13 @@ public class AccountsController : Controller throw new BadRequestException("MasterPasswordHash", "Invalid password."); } + // If Account Deprovisioning is enabled, we need to check if the user is managed by any organization. + if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + && await _userService.IsManagedByAnyOrganizationAsync(user.Id)) + { + throw new BadRequestException("Cannot change emails for accounts owned by an organization. Contact your organization administrator for additional details."); + } + await _userService.InitiateEmailChangeAsync(user, model.NewEmail); } @@ -165,6 +172,13 @@ public class AccountsController : Controller throw new BadRequestException("You cannot change your email when using Key Connector."); } + // If Account Deprovisioning is enabled, we need to check if the user is managed by any organization. + if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + && await _userService.IsManagedByAnyOrganizationAsync(user.Id)) + { + throw new BadRequestException("Cannot change emails for accounts owned by an organization. Contact your organization administrator for additional details."); + } + var result = await _userService.ChangeEmailAsync(user, model.MasterPasswordHash, model.NewEmail, model.NewMasterPasswordHash, model.Token, model.Key); if (result.Succeeded) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index a1cb3e2c66..b64d46b5b1 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -148,6 +148,7 @@ public static class FeatureFlagKeys public const string Pm13322AddPolicyDefinitions = "pm-13322-add-policy-definitions"; public const string LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split"; public const string GeneratorToolsModernization = "generator-tools-modernization"; + public const string NewDeviceVerification = "new-device-verification"; public static List GetAllKeys() { diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 5174543c6f..8d8822b34a 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -21,11 +21,11 @@ - - + + - + @@ -35,15 +35,15 @@ - - + + - + - + @@ -58,8 +58,8 @@ - - + + diff --git a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs index b6a0ccbedb..6dd7f42c63 100644 --- a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs +++ b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs @@ -1,6 +1,15 @@ -using System.Net.Http.Headers; +using System.Net; +using System.Net.Http.Headers; +using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; using Bit.Api.Models.Response; +using Bit.Core; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Services; +using NSubstitute; using Xunit; namespace Bit.Api.IntegrationTest.Controllers; @@ -35,4 +44,82 @@ public class AccountsControllerTest : IClassFixture Assert.Null(content.PrivateKey); Assert.NotNull(content.SecurityStamp); } + + [Fact] + public async Task PostEmailToken_WhenAccountDeprovisioningEnabled_WithManagedAccount_ThrowsBadRequest() + { + var email = await SetupOrganizationManagedAccount(); + + var tokens = await _factory.LoginAsync(email); + var client = _factory.CreateClient(); + + var model = new EmailTokenRequestModel + { + NewEmail = $"{Guid.NewGuid()}@example.com", + MasterPasswordHash = "master_password_hash" + }; + + using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/email-token") + { + Content = JsonContent.Create(model) + }; + message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token); + var response = await client.SendAsync(message); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("Cannot change emails for accounts owned by an organization", content); + } + + [Fact] + public async Task PostEmail_WhenAccountDeprovisioningEnabled_WithManagedAccount_ThrowsBadRequest() + { + var email = await SetupOrganizationManagedAccount(); + + var tokens = await _factory.LoginAsync(email); + var client = _factory.CreateClient(); + + var model = new EmailRequestModel + { + NewEmail = $"{Guid.NewGuid()}@example.com", + MasterPasswordHash = "master_password_hash", + NewMasterPasswordHash = "master_password_hash", + Token = "validtoken", + Key = "key" + }; + + using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/email") + { + Content = JsonContent.Create(model) + }; + message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token); + var response = await client.SendAsync(message); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("Cannot change emails for accounts owned by an organization", content); + } + + private async Task SetupOrganizationManagedAccount() + { + _factory.SubstituteService(featureService => + featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true)); + + // Create the owner account + var ownerEmail = $"{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(ownerEmail); + + // Create the organization + var (_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually2023, + ownerEmail: ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card); + + // Create a new organization member + var (email, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, + OrganizationUserType.Custom, new Permissions { AccessReports = true, ManageScim = true }); + + // Add a verified domain + await OrganizationTestHelpers.CreateVerifiedDomainAsync(_factory, _organization.Id, "bitwarden.com"); + + return email; + } } diff --git a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs index 83b345e784..64f719e82e 100644 --- a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs +++ b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs @@ -105,4 +105,22 @@ public static class OrganizationTestHelpers return (email, organizationUser); } + + /// + /// Creates a VerifiedDomain for the specified organization. + /// + public static async Task CreateVerifiedDomainAsync(ApiApplicationFactory factory, Guid organizationId, string domain) + { + var organizationDomainRepository = factory.GetService(); + + var verifiedDomain = new OrganizationDomain + { + OrganizationId = organizationId, + DomainName = domain, + Txt = "btw+test18383838383" + }; + verifiedDomain.SetVerifiedDate(); + + await organizationDomainRepository.CreateAsync(verifiedDomain); + } } diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index a16a9cb55f..4127c92eed 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -7,6 +7,7 @@ using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Api.Auth.Validators; using Bit.Api.Tools.Models.Request; using Bit.Api.Vault.Models.Request; +using Bit.Core; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Entities; @@ -143,6 +144,21 @@ public class AccountsControllerTests : IDisposable await _userService.Received(1).InitiateEmailChangeAsync(user, newEmail); } + [Fact] + public async Task PostEmailToken_WithAccountDeprovisioningEnabled_WhenUserIsNotManagedByAnOrganization_ShouldInitiateEmailChange() + { + var user = GenerateExampleUser(); + ConfigureUserServiceToReturnValidPrincipalFor(user); + ConfigureUserServiceToAcceptPasswordFor(user); + _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); + _userService.IsManagedByAnyOrganizationAsync(user.Id).Returns(false); + var newEmail = "example@user.com"; + + await _sut.PostEmailToken(new EmailTokenRequestModel { NewEmail = newEmail }); + + await _userService.Received(1).InitiateEmailChangeAsync(user, newEmail); + } + [Fact] public async Task PostEmailToken_WhenNotAuthorized_ShouldThrowUnauthorizedAccessException() { @@ -165,6 +181,22 @@ public class AccountsControllerTests : IDisposable ); } + [Fact] + public async Task PostEmailToken_WithAccountDeprovisioningEnabled_WhenUserIsManagedByAnOrganization_ShouldThrowBadRequestException() + { + var user = GenerateExampleUser(); + ConfigureUserServiceToReturnValidPrincipalFor(user); + ConfigureUserServiceToAcceptPasswordFor(user); + _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); + _userService.IsManagedByAnyOrganizationAsync(user.Id).Returns(true); + + var result = await Assert.ThrowsAsync( + () => _sut.PostEmailToken(new EmailTokenRequestModel()) + ); + + Assert.Equal("Cannot change emails for accounts owned by an organization. Contact your organization administrator for additional details.", result.Message); + } + [Fact] public async Task PostEmail_ShouldChangeUserEmail() { @@ -178,6 +210,21 @@ public class AccountsControllerTests : IDisposable await _userService.Received(1).ChangeEmailAsync(user, default, default, default, default, default); } + [Fact] + public async Task PostEmail_WithAccountDeprovisioningEnabled_WhenUserIsNotManagedByAnOrganization_ShouldChangeUserEmail() + { + var user = GenerateExampleUser(); + ConfigureUserServiceToReturnValidPrincipalFor(user); + _userService.ChangeEmailAsync(user, default, default, default, default, default) + .Returns(Task.FromResult(IdentityResult.Success)); + _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); + _userService.IsManagedByAnyOrganizationAsync(user.Id).Returns(false); + + await _sut.PostEmail(new EmailRequestModel()); + + await _userService.Received(1).ChangeEmailAsync(user, default, default, default, default, default); + } + [Fact] public async Task PostEmail_WhenNotAuthorized_ShouldThrownUnauthorizedAccessException() { @@ -201,6 +248,21 @@ public class AccountsControllerTests : IDisposable ); } + [Fact] + public async Task PostEmail_WithAccountDeprovisioningEnabled_WhenUserIsManagedByAnOrganization_ShouldThrowBadRequestException() + { + var user = GenerateExampleUser(); + ConfigureUserServiceToReturnValidPrincipalFor(user); + _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); + _userService.IsManagedByAnyOrganizationAsync(user.Id).Returns(true); + + var result = await Assert.ThrowsAsync( + () => _sut.PostEmail(new EmailRequestModel()) + ); + + Assert.Equal("Cannot change emails for accounts owned by an organization. Contact your organization administrator for additional details.", result.Message); + } + [Fact] public async Task PostVerifyEmail_ShouldSendEmailVerification() { diff --git a/test/Api.Test/Controllers/PoliciesControllerTests.cs b/test/Api.Test/Controllers/PoliciesControllerTests.cs index ec69104e52..77cc5ea02c 100644 --- a/test/Api.Test/Controllers/PoliciesControllerTests.cs +++ b/test/Api.Test/Controllers/PoliciesControllerTests.cs @@ -3,8 +3,10 @@ using System.Text.Json; using Bit.Api.AdminConsole.Controllers; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Api.Response; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -132,4 +134,71 @@ public class PoliciesControllerTests // Act & Assert await Assert.ThrowsAsync(() => sutProvider.Sut.GetMasterPasswordPolicy(orgId)); } + + [Theory] + [BitAutoData] + public async Task Get_WhenUserCanManagePolicies_WithExistingType_ReturnsExistingPolicy( + SutProvider sutProvider, Guid orgId, Policy policy, int type) + { + // Arrange + sutProvider.GetDependency() + .ManagePolicies(orgId) + .Returns(true); + + policy.Type = (PolicyType)type; + policy.Enabled = true; + policy.Data = null; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(orgId, (PolicyType)type) + .Returns(policy); + + // Act + var result = await sutProvider.Sut.Get(orgId, type); + + // Assert + Assert.IsType(result); + Assert.Equal(policy.Id, result.Id); + Assert.Equal(policy.Type, result.Type); + Assert.Equal(policy.Enabled, result.Enabled); + Assert.Equal(policy.OrganizationId, result.OrganizationId); + } + + [Theory] + [BitAutoData] + public async Task Get_WhenUserCanManagePolicies_WithNonExistingType_ReturnsDefaultPolicy( + SutProvider sutProvider, Guid orgId, int type) + { + // Arrange + sutProvider.GetDependency() + .ManagePolicies(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(orgId, (PolicyType)type) + .Returns((Policy)null); + + // Act + var result = await sutProvider.Sut.Get(orgId, type); + + // Assert + Assert.IsType(result); + Assert.Equal(result.Type, (PolicyType)type); + Assert.False(result.Enabled); + } + + [Theory] + [BitAutoData] + public async Task Get_WhenUserCannotManagePolicies_ThrowsNotFoundException( + SutProvider sutProvider, Guid orgId, int type) + { + // Arrange + sutProvider.GetDependency() + .ManagePolicies(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => sutProvider.Sut.Get(orgId, type)); + } + } diff --git a/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj b/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj index 10240727c7..d7a7bb9a01 100644 --- a/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj +++ b/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj @@ -10,7 +10,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj b/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj index fd4c3be765..159572f387 100644 --- a/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj +++ b/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj @@ -1,4 +1,4 @@ - + enable @@ -8,10 +8,10 @@ - - - - + + + + diff --git a/test/IntegrationTestCommon/IntegrationTestCommon.csproj b/test/IntegrationTestCommon/IntegrationTestCommon.csproj index 6647105609..3e8e55524b 100644 --- a/test/IntegrationTestCommon/IntegrationTestCommon.csproj +++ b/test/IntegrationTestCommon/IntegrationTestCommon.csproj @@ -5,7 +5,7 @@ - + diff --git a/util/Migrator/Migrator.csproj b/util/Migrator/Migrator.csproj index 7893a81c0d..25f5f255a2 100644 --- a/util/Migrator/Migrator.csproj +++ b/util/Migrator/Migrator.csproj @@ -1,4 +1,4 @@ - + @@ -7,7 +7,7 @@ - + diff --git a/util/MsSqlMigratorUtility/MsSqlMigratorUtility.csproj b/util/MsSqlMigratorUtility/MsSqlMigratorUtility.csproj index ebf0d05d8e..d316e56161 100644 --- a/util/MsSqlMigratorUtility/MsSqlMigratorUtility.csproj +++ b/util/MsSqlMigratorUtility/MsSqlMigratorUtility.csproj @@ -1,18 +1,18 @@ - - - - Exe - true - - - - - - - - - - - - - + + + + Exe + true + + + + + + + + + + + + + diff --git a/util/Setup/Setup.csproj b/util/Setup/Setup.csproj index 13897d6374..6366d46d3d 100644 --- a/util/Setup/Setup.csproj +++ b/util/Setup/Setup.csproj @@ -1,4 +1,4 @@ - + Exe @@ -11,7 +11,7 @@ - +