From 2c3cce332641261720d7105f594357b2436e7262 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Wed, 3 Apr 2024 09:26:30 -0400 Subject: [PATCH 01/17] Apply scan filter to include all results (#3954) --- .github/workflows/scan.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index 89d75ccf0f..49ef7a708d 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -40,7 +40,9 @@ jobs: base_uri: https://ast.checkmarx.net/ cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }} cx_client_secret: ${{ secrets.CHECKMARX_SECRET }} - additional_params: --report-format sarif --output-path . ${{ env.INCREMENTAL }} + additional_params: --report-format sarif \ + --file-filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \ + --output-path . ${{ env.INCREMENTAL }} - name: Upload Checkmarx results to GitHub uses: github/codeql-action/upload-sarif@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9 From 6b599b889e489a480f55bb6586598959f11c8cae Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Wed, 3 Apr 2024 09:38:45 -0400 Subject: [PATCH 02/17] Parameter typo (#3955) --- .github/workflows/scan.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index 49ef7a708d..438fe8becb 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -41,7 +41,7 @@ jobs: cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }} cx_client_secret: ${{ secrets.CHECKMARX_SECRET }} additional_params: --report-format sarif \ - --file-filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \ + --filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \ --output-path . ${{ env.INCREMENTAL }} - name: Upload Checkmarx results to GitHub From e9784e4a170af9ad4bf872a11034c0b85c950f8b Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Wed, 3 Apr 2024 09:44:29 -0400 Subject: [PATCH 03/17] Pipe scanning parameters --- .github/workflows/scan.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index 438fe8becb..df01a46461 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -40,7 +40,8 @@ jobs: base_uri: https://ast.checkmarx.net/ cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }} cx_client_secret: ${{ secrets.CHECKMARX_SECRET }} - additional_params: --report-format sarif \ + additional_params: | + --report-format sarif \ --filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \ --output-path . ${{ env.INCREMENTAL }} From 9b24dfc1609e06fa2a47c96e2afb47b2cc372563 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Wed, 3 Apr 2024 10:46:03 -0400 Subject: [PATCH 04/17] Attempt without scan parameter piping --- .github/workflows/scan.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index df01a46461..b5c8627975 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -40,10 +40,7 @@ jobs: base_uri: https://ast.checkmarx.net/ cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }} cx_client_secret: ${{ secrets.CHECKMARX_SECRET }} - additional_params: | - --report-format sarif \ - --filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \ - --output-path . ${{ env.INCREMENTAL }} + additional_params: --report-format sarif --filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" --output-path . ${{ env.INCREMENTAL }} - name: Upload Checkmarx results to GitHub uses: github/codeql-action/upload-sarif@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9 From a8ddb3af8e425f2da19babd6ed3c1bbd610d3a99 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Wed, 3 Apr 2024 13:20:58 -0400 Subject: [PATCH 05/17] Remove scan state filter entirely --- .github/workflows/scan.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index b5c8627975..89d75ccf0f 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -40,7 +40,7 @@ jobs: base_uri: https://ast.checkmarx.net/ cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }} cx_client_secret: ${{ secrets.CHECKMARX_SECRET }} - additional_params: --report-format sarif --filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" --output-path . ${{ env.INCREMENTAL }} + additional_params: --report-format sarif --output-path . ${{ env.INCREMENTAL }} - name: Upload Checkmarx results to GitHub uses: github/codeql-action/upload-sarif@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9 From 6242c25393b1f854f3524ac29c6c3e2fae7b4673 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Wed, 3 Apr 2024 14:13:49 -0400 Subject: [PATCH 06/17] Keep scan parameters as piped --- .github/workflows/scan.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index 89d75ccf0f..df01a46461 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -40,7 +40,10 @@ jobs: base_uri: https://ast.checkmarx.net/ cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }} cx_client_secret: ${{ secrets.CHECKMARX_SECRET }} - additional_params: --report-format sarif --output-path . ${{ env.INCREMENTAL }} + additional_params: | + --report-format sarif \ + --filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \ + --output-path . ${{ env.INCREMENTAL }} - name: Upload Checkmarx results to GitHub uses: github/codeql-action/upload-sarif@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9 From b164f24c99fce18d6964bbb47b79a3858187abd1 Mon Sep 17 00:00:00 2001 From: Colton Hurst Date: Fri, 5 Apr 2024 08:54:36 -0400 Subject: [PATCH 07/17] SM-1119: Rename service accounts to machine accounts (#3958) * SM-1119: Rename service accounts to machine accounts * SM-1119: Undo system management portal changes --- .../PeopleAccessPoliciesRequestModel.cs | 2 +- .../Implementations/OrganizationService.cs | 4 +-- ...zationSmServiceAccountsMaxReached.html.hbs | 2 +- ...zationSmServiceAccountsMaxReached.text.hbs | 2 +- ...UpdateSecretsManagerSubscriptionCommand.cs | 26 +++++++++---------- .../UpgradeOrganizationPlanCommand.cs | 6 ++--- .../Implementations/HandlebarsMailService.cs | 2 +- .../Services/OrganizationServiceTests.cs | 8 +++--- ...eSecretsManagerSubscriptionCommandTests.cs | 16 ++++++------ .../UpgradeOrganizationPlanCommandTests.cs | 2 +- 10 files changed, 35 insertions(+), 35 deletions(-) diff --git a/src/Api/SecretsManager/Models/Request/PeopleAccessPoliciesRequestModel.cs b/src/Api/SecretsManager/Models/Request/PeopleAccessPoliciesRequestModel.cs index cfe2c23236..b792b8ef2e 100644 --- a/src/Api/SecretsManager/Models/Request/PeopleAccessPoliciesRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/PeopleAccessPoliciesRequestModel.cs @@ -85,7 +85,7 @@ public class PeopleAccessPoliciesRequestModel if (!policies.All(ap => ap.Read && ap.Write)) { - throw new BadRequestException("Service account access must be Can read, write"); + throw new BadRequestException("Machine account access must be Can read, write"); } return new ServiceAccountPeopleAccessPolicies diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 742b4a2cb6..d322add42c 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -2037,7 +2037,7 @@ public class OrganizationService : IOrganizationService if (!plan.SecretsManager.HasAdditionalServiceAccountOption && upgrade.AdditionalServiceAccounts > 0) { - throw new BadRequestException("Plan does not allow additional Service Accounts."); + throw new BadRequestException("Plan does not allow additional Machine Accounts."); } if ((plan.Product == ProductType.TeamsStarter && @@ -2050,7 +2050,7 @@ public class OrganizationService : IOrganizationService if (upgrade.AdditionalServiceAccounts.GetValueOrDefault() < 0) { - throw new BadRequestException("You can't subtract Service Accounts!"); + throw new BadRequestException("You can't subtract Machine Accounts!"); } switch (plan.SecretsManager.HasAdditionalSeatsOption) diff --git a/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs index 6376d72826..507fdc33a9 100644 --- a/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs +++ b/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs @@ -6,7 +6,7 @@ - Your organization has reached the Secrets Manager service accounts limit of {{MaxServiceAccountsCount}}. New service accounts cannot be created + Your organization has reached the Secrets Manager machine accounts limit of {{MaxServiceAccountsCount}}. New machine accounts cannot be created BasicTextLayout}} -Your organization has reached the Secrets Manager service accounts limit of {{MaxServiceAccountsCount}}. New service accounts cannot be created +Your organization has reached the Secrets Manager machine accounts limit of {{MaxServiceAccountsCount}}. New machine accounts cannot be created For more information, please refer to the following help article: https://bitwarden.com/help/managing-users {{/BasicTextLayout}} diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs index d696a2950e..9eab58ff0a 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs @@ -118,7 +118,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs } catch (Exception e) { - _logger.LogError(e, $"Error encountered notifying organization owners of service accounts limit reached."); + _logger.LogError(e, $"Error encountered notifying organization owners of machine accounts limit reached."); } } @@ -253,12 +253,12 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs // Check if the organization has unlimited service accounts if (organization.SmServiceAccounts == null) { - throw new BadRequestException("Organization has no service accounts limit, no need to adjust service accounts"); + throw new BadRequestException("Organization has no machine accounts limit, no need to adjust machine accounts"); } if (update.Autoscaling && update.SmServiceAccounts.Value < organization.SmServiceAccounts.Value) { - throw new BadRequestException("Cannot use autoscaling to subtract service accounts."); + throw new BadRequestException("Cannot use autoscaling to subtract machine accounts."); } // Check plan maximum service accounts @@ -267,7 +267,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs { var planMaxServiceAccounts = plan.SecretsManager.BaseServiceAccount + plan.SecretsManager.MaxAdditionalServiceAccount.GetValueOrDefault(); - throw new BadRequestException($"You have reached the maximum number of service accounts ({planMaxServiceAccounts}) for this plan."); + throw new BadRequestException($"You have reached the maximum number of machine accounts ({planMaxServiceAccounts}) for this plan."); } // Check autoscale maximum service accounts @@ -275,21 +275,21 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs update.SmServiceAccounts.Value > update.MaxAutoscaleSmServiceAccounts.Value) { var message = update.Autoscaling - ? "Secrets Manager service account limit has been reached." - : "Cannot set max service accounts autoscaling below service account amount."; + ? "Secrets Manager machine account limit has been reached." + : "Cannot set max machine accounts autoscaling below machine account amount."; throw new BadRequestException(message); } // Check minimum service accounts included with plan if (plan.SecretsManager.BaseServiceAccount > update.SmServiceAccounts.Value) { - throw new BadRequestException($"Plan has a minimum of {plan.SecretsManager.BaseServiceAccount} service accounts."); + throw new BadRequestException($"Plan has a minimum of {plan.SecretsManager.BaseServiceAccount} machine accounts."); } // Check minimum service accounts required by business logic if (update.SmServiceAccounts.Value <= 0) { - throw new BadRequestException("You must have at least 1 service account."); + throw new BadRequestException("You must have at least 1 machine account."); } // Check minimum service accounts currently in use by the organization @@ -298,8 +298,8 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs var currentServiceAccounts = await _serviceAccountRepository.GetServiceAccountCountByOrganizationIdAsync(organization.Id); if (currentServiceAccounts > update.SmServiceAccounts) { - throw new BadRequestException($"Your organization currently has {currentServiceAccounts} service accounts. " + - $"You cannot decrease your subscription below your current service account usage."); + throw new BadRequestException($"Your organization currently has {currentServiceAccounts} machine accounts. " + + $"You cannot decrease your subscription below your current machine account usage."); } } } @@ -346,18 +346,18 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs if (update.SmServiceAccounts.HasValue && update.MaxAutoscaleSmServiceAccounts.Value < update.SmServiceAccounts.Value) { throw new BadRequestException( - $"Cannot set max service accounts autoscaling below current service accounts count."); + $"Cannot set max machine accounts autoscaling below current machine accounts count."); } if (!plan.SecretsManager.AllowServiceAccountsAutoscale) { - throw new BadRequestException("Your plan does not allow service accounts autoscaling."); + throw new BadRequestException("Your plan does not allow machine accounts autoscaling."); } if (plan.SecretsManager.MaxServiceAccounts.HasValue && update.MaxAutoscaleSmServiceAccounts.Value > plan.SecretsManager.MaxServiceAccounts) { throw new BadRequestException(string.Concat( - $"Your plan has a service account limit of {plan.SecretsManager.MaxServiceAccounts}, ", + $"Your plan has a machine account limit of {plan.SecretsManager.MaxServiceAccounts}, ", $"but you have specified a max autoscale count of {update.MaxAutoscaleSmServiceAccounts}.", "Reduce your max autoscale count.")); } diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs index bd198ded3c..7d91ed7372 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs @@ -330,9 +330,9 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand if (currentServiceAccounts > newPlanServiceAccounts) { throw new BadRequestException( - $"Your organization currently has {currentServiceAccounts} service accounts. " + - $"Your new plan only allows {newSecretsManagerPlan.SecretsManager.MaxServiceAccounts} service accounts. " + - "Remove some service accounts or increase your subscription."); + $"Your organization currently has {currentServiceAccounts} machine accounts. " + + $"Your new plan only allows {newSecretsManagerPlan.SecretsManager.MaxServiceAccounts} machine accounts. " + + "Remove some machine accounts or increase your subscription."); } } } diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 93f427c362..64758c1e88 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -951,7 +951,7 @@ public class HandlebarsMailService : IMailService public async Task SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable ownerEmails) { - var message = CreateDefaultMessage($"{organization.DisplayName()} Secrets Manager Service Accounts Limit Reached", ownerEmails); + var message = CreateDefaultMessage($"{organization.DisplayName()} Secrets Manager Machine Accounts Limit Reached", ownerEmails); var model = new OrganizationServiceAccountsMaxReachedViewModel { OrganizationId = organization.Id, diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index 79ba296f28..fd249a4ad1 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -410,7 +410,7 @@ public class OrganizationServiceTests var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SignUpAsync(signup)); - Assert.Contains("Plan does not allow additional Service Accounts.", exception.Message); + Assert.Contains("Plan does not allow additional Machine Accounts.", exception.Message); } [Theory] @@ -444,7 +444,7 @@ public class OrganizationServiceTests var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SignUpAsync(signup)); - Assert.Contains("You can't subtract Service Accounts!", exception.Message); + Assert.Contains("You can't subtract Machine Accounts!", exception.Message); } [Theory] @@ -2208,7 +2208,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) AdditionalSeats = 3 }; var exception = Assert.Throws(() => sutProvider.Sut.ValidateSecretsManagerPlan(plan, signup)); - Assert.Contains("Plan does not allow additional Service Accounts.", exception.Message); + Assert.Contains("Plan does not allow additional Machine Accounts.", exception.Message); } [Theory] @@ -2249,7 +2249,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) AdditionalSeats = 5 }; var exception = Assert.Throws(() => sutProvider.Sut.ValidateSecretsManagerPlan(plan, signup)); - Assert.Contains("You can't subtract Service Accounts!", exception.Message); + Assert.Contains("You can't subtract Machine Accounts!", exception.Message); } [Theory] diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs index 0d6db888cc..4b5037bcfa 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs @@ -447,7 +447,7 @@ public class UpdateSecretsManagerSubscriptionCommandTests var update = new SecretsManagerSubscriptionUpdate(organization, false).AdjustServiceAccounts(1); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSubscriptionAsync(update)); - Assert.Contains("Organization has no service accounts limit, no need to adjust service accounts", exception.Message); + Assert.Contains("Organization has no machine accounts limit, no need to adjust machine accounts", exception.Message); await VerifyDependencyNotCalledAsync(sutProvider); } @@ -460,7 +460,7 @@ public class UpdateSecretsManagerSubscriptionCommandTests var update = new SecretsManagerSubscriptionUpdate(organization, true).AdjustServiceAccounts(-2); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSubscriptionAsync(update)); - Assert.Contains("Cannot use autoscaling to subtract service accounts.", exception.Message); + Assert.Contains("Cannot use autoscaling to subtract machine accounts.", exception.Message); await VerifyDependencyNotCalledAsync(sutProvider); } @@ -475,7 +475,7 @@ public class UpdateSecretsManagerSubscriptionCommandTests var update = new SecretsManagerSubscriptionUpdate(organization, false).AdjustServiceAccounts(1); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSubscriptionAsync(update)); - Assert.Contains("You have reached the maximum number of service accounts (3) for this plan", + Assert.Contains("You have reached the maximum number of machine accounts (3) for this plan", exception.Message, StringComparison.InvariantCultureIgnoreCase); await VerifyDependencyNotCalledAsync(sutProvider); } @@ -492,7 +492,7 @@ public class UpdateSecretsManagerSubscriptionCommandTests var update = new SecretsManagerSubscriptionUpdate(organization, true).AdjustServiceAccounts(2); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSubscriptionAsync(update)); - Assert.Contains("Secrets Manager service account limit has been reached.", exception.Message); + Assert.Contains("Secrets Manager machine account limit has been reached.", exception.Message); await VerifyDependencyNotCalledAsync(sutProvider); } @@ -516,7 +516,7 @@ public class UpdateSecretsManagerSubscriptionCommandTests var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.UpdateSubscriptionAsync(update)); - Assert.Contains("Cannot set max service accounts autoscaling below service account amount", exception.Message); + Assert.Contains("Cannot set max machine accounts autoscaling below machine account amount", exception.Message); await VerifyDependencyNotCalledAsync(sutProvider); } @@ -537,7 +537,7 @@ public class UpdateSecretsManagerSubscriptionCommandTests var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.UpdateSubscriptionAsync(update)); - Assert.Contains("Plan has a minimum of 200 service accounts", exception.Message); + Assert.Contains("Plan has a minimum of 200 machine accounts", exception.Message); await VerifyDependencyNotCalledAsync(sutProvider); } @@ -570,7 +570,7 @@ public class UpdateSecretsManagerSubscriptionCommandTests .Returns(currentServiceAccounts); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSubscriptionAsync(update)); - Assert.Contains("Your organization currently has 301 service accounts. You cannot decrease your subscription below your current service account usage", exception.Message); + Assert.Contains("Your organization currently has 301 machine accounts. You cannot decrease your subscription below your current machine account usage", exception.Message); await VerifyDependencyNotCalledAsync(sutProvider); } @@ -648,7 +648,7 @@ public class UpdateSecretsManagerSubscriptionCommandTests var update = new SecretsManagerSubscriptionUpdate(organization, false) { MaxAutoscaleSmServiceAccounts = 3 }; var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSubscriptionAsync(update)); - Assert.Contains("Your plan does not allow service accounts autoscaling.", exception.Message); + Assert.Contains("Your plan does not allow machine accounts autoscaling.", exception.Message); await VerifyDependencyNotCalledAsync(sutProvider); } diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs index d0d11acf76..ac75f36405 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs @@ -192,7 +192,7 @@ public class UpgradeOrganizationPlanCommandTests .GetServiceAccountCountByOrganizationIdAsync(organization.Id).Returns(currentServiceAccounts); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade)); - Assert.Contains($"Your organization currently has {currentServiceAccounts} service accounts. Your new plan only allows", exception.Message); + Assert.Contains($"Your organization currently has {currentServiceAccounts} machine accounts. Your new plan only allows", exception.Message); sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAndUpdateCacheAsync(default); } From 4af7780bb85b8f625cf06da1d430d09819a2adcf Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Fri, 5 Apr 2024 09:23:33 -0400 Subject: [PATCH 08/17] Prevent Stripe call when creating org from reseller in admin (#3953) --- .../AdminConsole/Services/ProviderService.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index bad44cb3c2..23e8cee4b3 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -382,10 +382,14 @@ public class ProviderService : IProviderService organization.BillingEmail = provider.BillingEmail; await _organizationRepository.ReplaceAsync(organization); - await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions + + if (!string.IsNullOrEmpty(organization.GatewayCustomerId)) { - Email = provider.BillingEmail - }); + await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions + { + Email = provider.BillingEmail + }); + } await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Added); } From 108d22f48463e2ce69595c112f5d2b15bfc6cc78 Mon Sep 17 00:00:00 2001 From: Jake Fink Date: Fri, 5 Apr 2024 09:30:42 -0400 Subject: [PATCH 09/17] [BEEEP] begin 2fa integration tests for identity (#3843) * begin 2fa integration tests for identity - fix org mappings and query * add key length to doc * lint --- .../AdminConsole/Models/Organization.cs | 15 +- .../Repositories/OrganizationRepository.cs | 7 +- .../Endpoints/IdentityServerTwoFactorTests.cs | 141 ++++++++++++++++++ .../OrganizationRepositoryTests.cs | 11 +- 4 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Models/Organization.cs b/src/Infrastructure.EntityFramework/AdminConsole/Models/Organization.cs index 3da462a09b..d7f83d829d 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Models/Organization.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Models/Organization.cs @@ -26,7 +26,20 @@ public class OrganizationMapperProfile : Profile { public OrganizationMapperProfile() { - CreateMap().ReverseMap(); + CreateMap() + .ForMember(org => org.Ciphers, opt => opt.Ignore()) + .ForMember(org => org.OrganizationUsers, opt => opt.Ignore()) + .ForMember(org => org.Groups, opt => opt.Ignore()) + .ForMember(org => org.Policies, opt => opt.Ignore()) + .ForMember(org => org.Collections, opt => opt.Ignore()) + .ForMember(org => org.SsoConfigs, opt => opt.Ignore()) + .ForMember(org => org.SsoUsers, opt => opt.Ignore()) + .ForMember(org => org.Transactions, opt => opt.Ignore()) + .ForMember(org => org.ApiKeys, opt => opt.Ignore()) + .ForMember(org => org.Connections, opt => opt.Ignore()) + .ForMember(org => org.Domains, opt => opt.Ignore()) + .ReverseMap(); + CreateProjection() .ForMember(sd => sd.CollectionCount, opt => opt.MapFrom(o => o.Collections.Count)) .ForMember(sd => sd.GroupCount, opt => opt.MapFrom(o => o.Groups.Count)) diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index a27e2a66bc..9a4573e771 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -50,9 +50,10 @@ public class OrganizationRepository : Repository e.OrganizationUsers - .Where(ou => ou.UserId == userId) - .Select(ou => ou.Organization)) + .SelectMany(e => e.OrganizationUsers + .Where(ou => ou.UserId == userId)) + .Include(ou => ou.Organization) + .Select(ou => ou.Organization) .ToListAsync(); return Mapper.Map>(organizations); } diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs new file mode 100644 index 0000000000..c9e6825988 --- /dev/null +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs @@ -0,0 +1,141 @@ +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.IntegrationTestCommon.Factories; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Helpers; +using Microsoft.AspNetCore.TestHost; +using Xunit; + +namespace Bit.Identity.IntegrationTest.Endpoints; + +public class IdentityServerTwoFactorTests : IClassFixture +{ + private readonly IdentityApplicationFactory _factory; + private readonly IUserRepository _userRepository; + private readonly IUserService _userService; + + public IdentityServerTwoFactorTests(IdentityApplicationFactory factory) + { + _factory = factory; + _userRepository = _factory.GetService(); + _userService = _factory.GetService(); + } + + [Theory, BitAutoData] + public async Task TokenEndpoint_UserTwoFactorRequired_NoTwoFactorProvided_Fails(string deviceId) + { + // Arrange + var username = "test+2farequired@email.com"; + var twoFactor = """{"1": { "Enabled": true, "MetaData": { "Email": "test+2farequired@email.com"}}}"""; + + await CreateUserAsync(_factory.Server, username, deviceId, async () => + { + var user = await _userRepository.GetByEmailAsync(username); + user.TwoFactorProviders = twoFactor; + await _userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.Email); + }); + + // Act + var context = await PostLoginAsync(_factory.Server, username, deviceId); + + // Assert + var body = await AssertHelper.AssertResponseTypeIs(context); + var root = body.RootElement; + + var error = AssertHelper.AssertJsonProperty(root, "error_description", JsonValueKind.String).GetString(); + Assert.Equal("Two factor required.", error); + } + + [Theory, BitAutoData] + public async Task TokenEndpoint_OrgTwoFactorRequired_NoTwoFactorProvided_Fails(string deviceId) + { + // Arrange + var username = "test+org2farequired@email.com"; + // use valid length keys so DuoWeb.SignRequest doesn't throw + // ikey: 20, skey: 40, akey: 40 + var orgTwoFactor = + """{"6":{"Enabled":true,"MetaData":{"IKey":"DIEFB13LB49IEB3459N2","SKey":"0ZnsZHav0KcNPBZTS6EOUwqLPoB0sfMd5aJeWExQ","Host":"api-example.duosecurity.com"}}}"""; + + var server = _factory.WithWebHostBuilder(builder => + { + builder.UseSetting("globalSettings:Duo:AKey", "WJHB374KM3N5hglO9hniwbkibg$789EfbhNyLpNq1"); + }).Server; + + + await CreateUserAsync(server, username, deviceId, async () => + { + var user = await _userRepository.GetByEmailAsync(username); + + var organizationRepository = _factory.Services.GetService(); + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + Use2fa = true, + TwoFactorProviders = orgTwoFactor, + }); + + await _factory.Services.GetService() + .CreateAsync(new OrganizationUser + { + UserId = user.Id, + OrganizationId = organization.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User, + }); + }); + + // Act + var context = await PostLoginAsync(server, username, deviceId); + + // Assert + var body = await AssertHelper.AssertResponseTypeIs(context); + var root = body.RootElement; + + var error = AssertHelper.AssertJsonProperty(root, "error_description", JsonValueKind.String).GetString(); + Assert.Equal("Two factor required.", error); + } + + private async Task CreateUserAsync(TestServer server, string username, string deviceId, + Func twoFactorSetup) + { + // Register user + await _factory.RegisterAsync(new RegisterRequestModel + { + Email = username, + MasterPasswordHash = "master_password_hash" + }); + + // Add two factor + if (twoFactorSetup != null) + { + await twoFactorSetup(); + } + } + + private async Task PostLoginAsync(TestServer server, string username, string deviceId, + Action extraConfiguration = null) + { + return await server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary + { + { "scope", "api offline_access" }, + { "client_id", "web" }, + { "deviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) }, + { "deviceIdentifier", deviceId }, + { "deviceName", "firefox" }, + { "grant_type", "password" }, + { "username", username }, + { "password", "master_password_hash" }, + }), context => context.SetAuthEmail(username)); + } + + private static string DeviceTypeAsString(DeviceType deviceType) + { + return ((int)deviceType).ToString(); + } +} diff --git a/test/Infrastructure.EFIntegration.Test/AdminConsole/Repositories/OrganizationRepositoryTests.cs b/test/Infrastructure.EFIntegration.Test/AdminConsole/Repositories/OrganizationRepositoryTests.cs index a62f7531d0..eb7c3d2753 100644 --- a/test/Infrastructure.EFIntegration.Test/AdminConsole/Repositories/OrganizationRepositoryTests.cs +++ b/test/Infrastructure.EFIntegration.Test/AdminConsole/Repositories/OrganizationRepositoryTests.cs @@ -1,9 +1,11 @@ -using Bit.Core.Entities; +using AutoMapper; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data.Organizations; using Bit.Core.Test.AutoFixture.Attributes; using Bit.Infrastructure.EFIntegration.Test.AutoFixture; using Bit.Infrastructure.EFIntegration.Test.Repositories.EqualityComparers; +using Bit.Infrastructure.EntityFramework.AdminConsole.Models; using Xunit; using EfRepo = Bit.Infrastructure.EntityFramework.Repositories; using Organization = Bit.Core.AdminConsole.Entities.Organization; @@ -13,6 +15,13 @@ namespace Bit.Infrastructure.EFIntegration.Test.Repositories; public class OrganizationRepositoryTests { + [Fact] + public void ValidateOrganizationMappings_ReturnsSuccess() + { + var config = new MapperConfiguration(cfg => cfg.AddProfile()); + config.AssertConfigurationIsValid(); + } + [CiSkippedTheory, EfOrganizationAutoData] public async Task CreateAsync_Works_DataMatches( Organization organization, From 5bd2c424aab1e1cbe3e604284c0d2769958a740b Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Fri, 5 Apr 2024 15:50:28 +0100 Subject: [PATCH 10/17] [AC-2262] As a Bitwarden Admin, I need a ways to set and update an MSP's minimum seats (#3956) * initial commit Signed-off-by: Cy Okeke * add the feature flag Signed-off-by: Cy Okeke * Add featureflag for create and edit html pages Signed-off-by: Cy Okeke --------- Signed-off-by: Cy Okeke --- .../Providers/CreateProviderCommand.cs | 46 +++++++++++++++++-- .../CreateProviderCommandTests.cs | 4 +- .../Controllers/ProvidersController.cs | 35 ++++++++++++-- .../Models/CreateProviderModel.cs | 16 +++++++ .../AdminConsole/Models/ProviderEditModel.cs | 33 ++++++++++++- .../Views/Providers/Create.cshtml | 19 ++++++++ .../AdminConsole/Views/Providers/Edit.cshtml | 19 ++++++++ .../Interfaces/ICreateProviderCommand.cs | 2 +- 8 files changed, 162 insertions(+), 12 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs index 738723a819..720317578f 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs @@ -1,10 +1,15 @@ -using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core; +using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Repositories; +using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; +using Bit.Core.Services; namespace Bit.Commercial.Core.AdminConsole.Providers; @@ -14,21 +19,28 @@ public class CreateProviderCommand : ICreateProviderCommand private readonly IProviderUserRepository _providerUserRepository; private readonly IProviderService _providerService; private readonly IUserRepository _userRepository; + private readonly IProviderPlanRepository _providerPlanRepository; + private readonly IFeatureService _featureService; public CreateProviderCommand( IProviderRepository providerRepository, IProviderUserRepository providerUserRepository, IProviderService providerService, - IUserRepository userRepository) + IUserRepository userRepository, + IProviderPlanRepository providerPlanRepository, + IFeatureService featureService) { _providerRepository = providerRepository; _providerUserRepository = providerUserRepository; _providerService = providerService; _userRepository = userRepository; + _providerPlanRepository = providerPlanRepository; + _featureService = featureService; } - public async Task CreateMspAsync(Provider provider, string ownerEmail) + public async Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats) { + var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); var owner = await _userRepository.GetByEmailAsync(ownerEmail); if (owner == null) { @@ -44,8 +56,24 @@ public class CreateProviderCommand : ICreateProviderCommand Type = ProviderUserType.ProviderAdmin, Status = ProviderUserStatusType.Confirmed, }; + + if (isConsolidatedBillingEnabled) + { + var providerPlans = new List + { + CreateProviderPlan(provider.Id, PlanType.TeamsMonthly, teamsMinimumSeats), + CreateProviderPlan(provider.Id, PlanType.EnterpriseMonthly, enterpriseMinimumSeats) + }; + + foreach (var providerPlan in providerPlans) + { + await _providerPlanRepository.CreateAsync(providerPlan); + } + } + await _providerUserRepository.CreateAsync(providerUser); await _providerService.SendProviderSetupInviteEmailAsync(provider, owner.Email); + } public async Task CreateResellerAsync(Provider provider) @@ -60,4 +88,16 @@ public class CreateProviderCommand : ICreateProviderCommand provider.UseEvents = true; await _providerRepository.CreateAsync(provider); } + + private ProviderPlan CreateProviderPlan(Guid providerId, PlanType planType, int seatMinimum) + { + return new ProviderPlan + { + ProviderId = providerId, + PlanType = planType, + SeatMinimum = seatMinimum, + PurchasedSeats = 0, + AllocatedSeats = 0 + }; + } } diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/CreateProviderCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/CreateProviderCommandTests.cs index 399ed6ea1e..787d5a17b3 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/CreateProviderCommandTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/CreateProviderCommandTests.cs @@ -22,7 +22,7 @@ public class CreateProviderCommandTests provider.Type = ProviderType.Msp; var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.CreateMspAsync(provider, default)); + () => sutProvider.Sut.CreateMspAsync(provider, default, default, default)); Assert.Contains("Invalid owner.", exception.Message); } @@ -34,7 +34,7 @@ public class CreateProviderCommandTests var userRepository = sutProvider.GetDependency(); userRepository.GetByEmailAsync(user.Email).Returns(user); - await sutProvider.Sut.CreateMspAsync(provider, user.Email); + await sutProvider.Sut.CreateMspAsync(provider, user.Email, default, default); await sutProvider.GetDependency().ReceivedWithAnyArgs().CreateAsync(default); await sutProvider.GetDependency().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email); diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index 47631829ed..59b4ef6584 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -8,6 +8,8 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Repositories; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -34,6 +36,7 @@ public class ProvidersController : Controller private readonly IUserService _userService; private readonly ICreateProviderCommand _createProviderCommand; private readonly IFeatureService _featureService; + private readonly IProviderPlanRepository _providerPlanRepository; public ProvidersController( IOrganizationRepository organizationRepository, @@ -47,7 +50,8 @@ public class ProvidersController : Controller IReferenceEventService referenceEventService, IUserService userService, ICreateProviderCommand createProviderCommand, - IFeatureService featureService) + IFeatureService featureService, + IProviderPlanRepository providerPlanRepository) { _organizationRepository = organizationRepository; _organizationService = organizationService; @@ -61,6 +65,7 @@ public class ProvidersController : Controller _userService = userService; _createProviderCommand = createProviderCommand; _featureService = featureService; + _providerPlanRepository = providerPlanRepository; } [RequirePermission(Permission.Provider_List_View)] @@ -90,11 +95,13 @@ public class ProvidersController : Controller }); } - public IActionResult Create(string ownerEmail = null) + public IActionResult Create(int teamsMinimumSeats, int enterpriseMinimumSeats, string ownerEmail = null) { return View(new CreateProviderModel { - OwnerEmail = ownerEmail + OwnerEmail = ownerEmail, + TeamsMinimumSeats = teamsMinimumSeats, + EnterpriseMinimumSeats = enterpriseMinimumSeats }); } @@ -112,7 +119,8 @@ public class ProvidersController : Controller switch (provider.Type) { case ProviderType.Msp: - await _createProviderCommand.CreateMspAsync(provider, model.OwnerEmail); + await _createProviderCommand.CreateMspAsync(provider, model.OwnerEmail, model.TeamsMinimumSeats, + model.EnterpriseMinimumSeats); break; case ProviderType.Reseller: await _createProviderCommand.CreateResellerAsync(provider); @@ -139,6 +147,7 @@ public class ProvidersController : Controller [SelfHosted(NotSelfHostedOnly = true)] public async Task Edit(Guid id) { + var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); var provider = await _providerRepository.GetByIdAsync(id); if (provider == null) { @@ -147,7 +156,12 @@ public class ProvidersController : Controller var users = await _providerUserRepository.GetManyDetailsByProviderAsync(id); var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(id); - return View(new ProviderEditModel(provider, users, providerOrganizations)); + if (isConsolidatedBillingEnabled) + { + var providerPlan = await _providerPlanRepository.GetByProviderId(id); + return View(new ProviderEditModel(provider, users, providerOrganizations, providerPlan)); + } + return View(new ProviderEditModel(provider, users, providerOrganizations, new List())); } [HttpPost] @@ -156,6 +170,8 @@ public class ProvidersController : Controller [RequirePermission(Permission.Provider_Edit)] public async Task Edit(Guid id, ProviderEditModel model) { + var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); + var providerPlans = await _providerPlanRepository.GetByProviderId(id); var provider = await _providerRepository.GetByIdAsync(id); if (provider == null) { @@ -165,6 +181,15 @@ public class ProvidersController : Controller model.ToProvider(provider); await _providerRepository.ReplaceAsync(provider); await _applicationCacheService.UpsertProviderAbilityAsync(provider); + if (isConsolidatedBillingEnabled) + { + model.ToProviderPlan(providerPlans); + foreach (var providerPlan in providerPlans) + { + await _providerPlanRepository.ReplaceAsync(providerPlan); + } + } + return RedirectToAction("Edit", new { id }); } diff --git a/src/Admin/AdminConsole/Models/CreateProviderModel.cs b/src/Admin/AdminConsole/Models/CreateProviderModel.cs index 7efd34feb1..2efbbb54f6 100644 --- a/src/Admin/AdminConsole/Models/CreateProviderModel.cs +++ b/src/Admin/AdminConsole/Models/CreateProviderModel.cs @@ -24,6 +24,12 @@ public class CreateProviderModel : IValidatableObject [Display(Name = "Primary Billing Email")] public string BillingEmail { get; set; } + [Display(Name = "Teams minimum seats")] + public int TeamsMinimumSeats { get; set; } + + [Display(Name = "Enterprise minimum seats")] + public int EnterpriseMinimumSeats { get; set; } + public virtual Provider ToProvider() { return new Provider() @@ -45,6 +51,16 @@ public class CreateProviderModel : IValidatableObject var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute()?.GetName() ?? nameof(OwnerEmail); yield return new ValidationResult($"The {ownerEmailDisplayName} field is required."); } + if (TeamsMinimumSeats < 0) + { + var teamsMinimumSeatsDisplayName = nameof(TeamsMinimumSeats).GetDisplayAttribute()?.GetName() ?? nameof(TeamsMinimumSeats); + yield return new ValidationResult($"The {teamsMinimumSeatsDisplayName} field can not be negative."); + } + if (EnterpriseMinimumSeats < 0) + { + var enterpriseMinimumSeatsDisplayName = nameof(EnterpriseMinimumSeats).GetDisplayAttribute()?.GetName() ?? nameof(EnterpriseMinimumSeats); + yield return new ValidationResult($"The {enterpriseMinimumSeatsDisplayName} field can not be negative."); + } break; case ProviderType.Reseller: if (string.IsNullOrWhiteSpace(Name)) diff --git a/src/Admin/AdminConsole/Models/ProviderEditModel.cs b/src/Admin/AdminConsole/Models/ProviderEditModel.cs index 7480a24b35..1055d0cba4 100644 --- a/src/Admin/AdminConsole/Models/ProviderEditModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderEditModel.cs @@ -1,6 +1,8 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.Billing.Entities; +using Bit.Core.Enums; namespace Bit.Admin.AdminConsole.Models; @@ -8,13 +10,16 @@ public class ProviderEditModel : ProviderViewModel { public ProviderEditModel() { } - public ProviderEditModel(Provider provider, IEnumerable providerUsers, IEnumerable organizations) + public ProviderEditModel(Provider provider, IEnumerable providerUsers, + IEnumerable organizations, IEnumerable providerPlans) : base(provider, providerUsers, organizations) { Name = provider.DisplayName(); BusinessName = provider.DisplayBusinessName(); BillingEmail = provider.BillingEmail; BillingPhone = provider.BillingPhone; + TeamsMinimumSeats = GetMinimumSeats(providerPlans, PlanType.TeamsMonthly); + EnterpriseMinimumSeats = GetMinimumSeats(providerPlans, PlanType.EnterpriseMonthly); } [Display(Name = "Billing Email")] @@ -24,12 +29,38 @@ public class ProviderEditModel : ProviderViewModel [Display(Name = "Business Name")] public string BusinessName { get; set; } public string Name { get; set; } + [Display(Name = "Teams minimum seats")] + public int TeamsMinimumSeats { get; set; } + + [Display(Name = "Enterprise minimum seats")] + public int EnterpriseMinimumSeats { get; set; } [Display(Name = "Events")] + public IEnumerable ToProviderPlan(IEnumerable existingProviderPlans) + { + var providerPlans = existingProviderPlans.ToList(); + foreach (var existingProviderPlan in providerPlans) + { + existingProviderPlan.SeatMinimum = existingProviderPlan.PlanType switch + { + PlanType.TeamsMonthly => TeamsMinimumSeats, + PlanType.EnterpriseMonthly => EnterpriseMinimumSeats, + _ => existingProviderPlan.SeatMinimum + }; + } + return providerPlans; + } + public Provider ToProvider(Provider existingProvider) { existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant()?.Trim(); existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant()?.Trim(); return existingProvider; } + + + private int GetMinimumSeats(IEnumerable providerPlans, PlanType planType) + { + return (from providerPlan in providerPlans where providerPlan.PlanType == planType select (int)providerPlan.SeatMinimum).FirstOrDefault(); + } } diff --git a/src/Admin/AdminConsole/Views/Providers/Create.cshtml b/src/Admin/AdminConsole/Views/Providers/Create.cshtml index 2e69da3ad7..7b10de3724 100644 --- a/src/Admin/AdminConsole/Views/Providers/Create.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/Create.cshtml @@ -1,6 +1,8 @@ @using Bit.SharedWeb.Utilities @using Bit.Core.AdminConsole.Enums.Provider +@using Bit.Core @model CreateProviderModel +@inject Bit.Core.Services.IFeatureService FeatureService @{ ViewData["Title"] = "Create Provider"; } @@ -39,6 +41,23 @@ + @if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)) + { +
+
+
+ + +
+
+
+
+ + +
+
+
+ }
diff --git a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml index cca0a2af28..2f652aaac7 100644 --- a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml @@ -1,5 +1,7 @@ @using Bit.Admin.Enums; +@using Bit.Core @inject Bit.Admin.Services.IAccessControlService AccessControlService +@inject Bit.Core.Services.IFeatureService FeatureService @model ProviderEditModel @{ @@ -41,6 +43,23 @@
+ @if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)) + { +
+
+
+ + +
+
+
+
+ + +
+
+
+ } @await Html.PartialAsync("Organizations", Model) @if (canEdit) diff --git a/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs b/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs index 93b3e387a3..800ec14055 100644 --- a/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs +++ b/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs @@ -4,6 +4,6 @@ namespace Bit.Core.AdminConsole.Providers.Interfaces; public interface ICreateProviderCommand { - Task CreateMspAsync(Provider provider, string ownerEmail); + Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats); Task CreateResellerAsync(Provider provider); } From 03e65f6d1dcc5af91987758ed17dded0489e713a Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Mon, 8 Apr 2024 09:40:43 -0400 Subject: [PATCH 11/17] [AC-2416] Resolved Stripe refunds not creating a transaction (#3962) * Resolved NullReferenceException when refunding a charge * Downgraded log message for PayPal to warning --- src/Billing/Controllers/PayPalController.cs | 2 +- src/Billing/Controllers/StripeController.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Billing/Controllers/PayPalController.cs b/src/Billing/Controllers/PayPalController.cs index cd52e017ff..a1300e61c6 100644 --- a/src/Billing/Controllers/PayPalController.cs +++ b/src/Billing/Controllers/PayPalController.cs @@ -75,7 +75,7 @@ public class PayPalController : Controller if (string.IsNullOrEmpty(transactionModel.TransactionId)) { - _logger.LogError("PayPal IPN: Transaction ID is missing"); + _logger.LogWarning("PayPal IPN: Transaction ID is missing"); return Ok(); } diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index 679dea15ce..7fc5189e5d 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -453,7 +453,7 @@ public class StripeController : Controller } else if (parsedEvent.Type.Equals(HandledStripeWebhook.ChargeRefunded)) { - var charge = await _stripeEventService.GetCharge(parsedEvent); + var charge = await _stripeEventService.GetCharge(parsedEvent, true, ["refunds"]); var chargeTransaction = await _transactionRepository.GetByGatewayIdAsync( GatewayType.Stripe, charge.Id); if (chargeTransaction == null) From d9658ce3fe7f0461aa4c98ea97c82cc359bc6924 Mon Sep 17 00:00:00 2001 From: Bitwarden DevOps <106330231+bitwarden-devops-bot@users.noreply.github.com> Date: Mon, 8 Apr 2024 11:25:45 -0400 Subject: [PATCH 12/17] Bumped version to 2024.4.0 (#3963) --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index b130621fa8..e74f8a5a1c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2024.3.1 + 2024.4.0 Bit.$(MSBuildProjectName) enable From 9a2d3834171e6cef878946ba77fd10e5e1801cca Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Mon, 8 Apr 2024 14:42:01 -0400 Subject: [PATCH 13/17] [AC-2211] SM Changes (#3938) * SM changes * Teams starter bugs --- src/Core/Enums/PlanType.cs | 20 +++- .../StaticStore/Plans/EnterprisePlan.cs | 12 +-- .../StaticStore/Plans/EnterprisePlan2023.cs | 102 ++++++++++++++++++ .../Models/StaticStore/Plans/TeamsPlan.cs | 12 +-- .../Models/StaticStore/Plans/TeamsPlan2023.cs | 96 +++++++++++++++++ .../StaticStore/Plans/TeamsStarterPlan.cs | 6 +- .../StaticStore/Plans/TeamsStarterPlan2023.cs | 72 +++++++++++++ src/Core/Utilities/StaticStore.cs | 5 + ...eSecretsManagerSubscriptionCommandTests.cs | 4 +- test/Core.Test/Utilities/StaticStoreTests.cs | 2 +- 10 files changed, 308 insertions(+), 23 deletions(-) create mode 100644 src/Core/Models/StaticStore/Plans/EnterprisePlan2023.cs create mode 100644 src/Core/Models/StaticStore/Plans/TeamsPlan2023.cs create mode 100644 src/Core/Models/StaticStore/Plans/TeamsStarterPlan2023.cs diff --git a/src/Core/Enums/PlanType.cs b/src/Core/Enums/PlanType.cs index 57fcd3090e..0fe72a4c45 100644 --- a/src/Core/Enums/PlanType.cs +++ b/src/Core/Enums/PlanType.cs @@ -28,14 +28,24 @@ public enum PlanType : byte EnterpriseMonthly2020 = 10, [Display(Name = "Enterprise (Annually) 2020")] EnterpriseAnnually2020 = 11, + [Display(Name = "Teams (Monthly) 2023")] + TeamsMonthly2023 = 12, + [Display(Name = "Teams (Annually) 2023")] + TeamsAnnually2023 = 13, + [Display(Name = "Enterprise (Monthly) 2023")] + EnterpriseMonthly2023 = 14, + [Display(Name = "Enterprise (Annually) 2023")] + EnterpriseAnnually2023 = 15, + [Display(Name = "Teams Starter 2023")] + TeamsStarter2023 = 16, [Display(Name = "Teams (Monthly)")] - TeamsMonthly = 12, + TeamsMonthly = 17, [Display(Name = "Teams (Annually)")] - TeamsAnnually = 13, + TeamsAnnually = 18, [Display(Name = "Enterprise (Monthly)")] - EnterpriseMonthly = 14, + EnterpriseMonthly = 19, [Display(Name = "Enterprise (Annually)")] - EnterpriseAnnually = 15, + EnterpriseAnnually = 20, [Display(Name = "Teams Starter")] - TeamsStarter = 16, + TeamsStarter = 21, } diff --git a/src/Core/Models/StaticStore/Plans/EnterprisePlan.cs b/src/Core/Models/StaticStore/Plans/EnterprisePlan.cs index 30242f49cf..4e256bff25 100644 --- a/src/Core/Models/StaticStore/Plans/EnterprisePlan.cs +++ b/src/Core/Models/StaticStore/Plans/EnterprisePlan.cs @@ -2,7 +2,7 @@ namespace Bit.Core.Models.StaticStore.Plans; -public record EnterprisePlan : Models.StaticStore.Plan +public record EnterprisePlan : Plan { public EnterprisePlan(bool isAnnual) { @@ -44,7 +44,7 @@ public record EnterprisePlan : Models.StaticStore.Plan { BaseSeats = 0; BasePrice = 0; - BaseServiceAccount = 200; + BaseServiceAccount = 50; HasAdditionalSeatsOption = true; HasAdditionalServiceAccountOption = true; @@ -55,16 +55,16 @@ public record EnterprisePlan : Models.StaticStore.Plan if (isAnnual) { StripeSeatPlanId = "secrets-manager-enterprise-seat-annually"; - StripeServiceAccountPlanId = "secrets-manager-service-account-annually"; + StripeServiceAccountPlanId = "secrets-manager-service-account-2024-annually"; SeatPrice = 144; - AdditionalPricePerServiceAccount = 6; + AdditionalPricePerServiceAccount = 12; } else { StripeSeatPlanId = "secrets-manager-enterprise-seat-monthly"; - StripeServiceAccountPlanId = "secrets-manager-service-account-monthly"; + StripeServiceAccountPlanId = "secrets-manager-service-account-2024-monthly"; SeatPrice = 13; - AdditionalPricePerServiceAccount = 0.5M; + AdditionalPricePerServiceAccount = 1; } } } diff --git a/src/Core/Models/StaticStore/Plans/EnterprisePlan2023.cs b/src/Core/Models/StaticStore/Plans/EnterprisePlan2023.cs new file mode 100644 index 0000000000..9e448199f6 --- /dev/null +++ b/src/Core/Models/StaticStore/Plans/EnterprisePlan2023.cs @@ -0,0 +1,102 @@ +using Bit.Core.Enums; + +namespace Bit.Core.Models.StaticStore.Plans; + +public record Enterprise2023Plan : Plan +{ + public Enterprise2023Plan(bool isAnnual) + { + Type = isAnnual ? PlanType.EnterpriseAnnually2023 : PlanType.EnterpriseMonthly2023; + Product = ProductType.Enterprise; + Name = isAnnual ? "Enterprise (Annually)" : "Enterprise (Monthly)"; + IsAnnual = isAnnual; + NameLocalizationKey = "planNameEnterprise"; + DescriptionLocalizationKey = "planDescEnterprise"; + CanBeUsedByBusiness = true; + + TrialPeriodDays = 7; + + HasPolicies = true; + HasSelfHost = true; + HasGroups = true; + HasDirectory = true; + HasEvents = true; + HasTotp = true; + Has2fa = true; + HasApi = true; + HasSso = true; + HasKeyConnector = true; + HasScim = true; + HasResetPassword = true; + UsersGetPremium = true; + HasCustomPermissions = true; + + UpgradeSortOrder = 4; + DisplaySortOrder = 4; + + LegacyYear = 2024; + + PasswordManager = new Enterprise2023PasswordManagerFeatures(isAnnual); + SecretsManager = new Enterprise2023SecretsManagerFeatures(isAnnual); + } + + private record Enterprise2023SecretsManagerFeatures : SecretsManagerPlanFeatures + { + public Enterprise2023SecretsManagerFeatures(bool isAnnual) + { + BaseSeats = 0; + BasePrice = 0; + BaseServiceAccount = 200; + + HasAdditionalSeatsOption = true; + HasAdditionalServiceAccountOption = true; + + AllowSeatAutoscale = true; + AllowServiceAccountsAutoscale = true; + + if (isAnnual) + { + StripeSeatPlanId = "secrets-manager-enterprise-seat-annually"; + StripeServiceAccountPlanId = "secrets-manager-service-account-annually"; + SeatPrice = 144; + AdditionalPricePerServiceAccount = 6; + } + else + { + StripeSeatPlanId = "secrets-manager-enterprise-seat-monthly"; + StripeServiceAccountPlanId = "secrets-manager-service-account-monthly"; + SeatPrice = 13; + AdditionalPricePerServiceAccount = 0.5M; + } + } + } + + private record Enterprise2023PasswordManagerFeatures : PasswordManagerPlanFeatures + { + public Enterprise2023PasswordManagerFeatures(bool isAnnual) + { + BaseSeats = 0; + BaseStorageGb = 1; + + HasAdditionalStorageOption = true; + HasAdditionalSeatsOption = true; + + AllowSeatAutoscale = true; + + if (isAnnual) + { + AdditionalStoragePricePerGb = 4; + StripeStoragePlanId = "storage-gb-annually"; + StripeSeatPlanId = "2023-enterprise-org-seat-annually"; + SeatPrice = 72; + } + else + { + StripeSeatPlanId = "2023-enterprise-seat-monthly"; + StripeStoragePlanId = "storage-gb-monthly"; + SeatPrice = 7; + AdditionalStoragePricePerGb = 0.5M; + } + } + } +} diff --git a/src/Core/Models/StaticStore/Plans/TeamsPlan.cs b/src/Core/Models/StaticStore/Plans/TeamsPlan.cs index d181f62747..84ce6d4fde 100644 --- a/src/Core/Models/StaticStore/Plans/TeamsPlan.cs +++ b/src/Core/Models/StaticStore/Plans/TeamsPlan.cs @@ -2,7 +2,7 @@ namespace Bit.Core.Models.StaticStore.Plans; -public record TeamsPlan : Models.StaticStore.Plan +public record TeamsPlan : Plan { public TeamsPlan(bool isAnnual) { @@ -37,7 +37,7 @@ public record TeamsPlan : Models.StaticStore.Plan { BaseSeats = 0; BasePrice = 0; - BaseServiceAccount = 50; + BaseServiceAccount = 20; HasAdditionalSeatsOption = true; HasAdditionalServiceAccountOption = true; @@ -48,16 +48,16 @@ public record TeamsPlan : Models.StaticStore.Plan if (isAnnual) { StripeSeatPlanId = "secrets-manager-teams-seat-annually"; - StripeServiceAccountPlanId = "secrets-manager-service-account-annually"; + StripeServiceAccountPlanId = "secrets-manager-service-account-2024-annually"; SeatPrice = 72; - AdditionalPricePerServiceAccount = 6; + AdditionalPricePerServiceAccount = 12; } else { StripeSeatPlanId = "secrets-manager-teams-seat-monthly"; - StripeServiceAccountPlanId = "secrets-manager-service-account-monthly"; + StripeServiceAccountPlanId = "secrets-manager-service-account-2024-monthly"; SeatPrice = 7; - AdditionalPricePerServiceAccount = 0.5M; + AdditionalPricePerServiceAccount = 1; } } } diff --git a/src/Core/Models/StaticStore/Plans/TeamsPlan2023.cs b/src/Core/Models/StaticStore/Plans/TeamsPlan2023.cs new file mode 100644 index 0000000000..c0b3190104 --- /dev/null +++ b/src/Core/Models/StaticStore/Plans/TeamsPlan2023.cs @@ -0,0 +1,96 @@ +using Bit.Core.Enums; + +namespace Bit.Core.Models.StaticStore.Plans; + +public record Teams2023Plan : Plan +{ + public Teams2023Plan(bool isAnnual) + { + Type = isAnnual ? PlanType.TeamsAnnually2023 : PlanType.TeamsMonthly2023; + Product = ProductType.Teams; + Name = isAnnual ? "Teams (Annually)" : "Teams (Monthly)"; + IsAnnual = isAnnual; + NameLocalizationKey = "planNameTeams"; + DescriptionLocalizationKey = "planDescTeams"; + CanBeUsedByBusiness = true; + + TrialPeriodDays = 7; + + HasGroups = true; + HasDirectory = true; + HasEvents = true; + HasTotp = true; + Has2fa = true; + HasApi = true; + UsersGetPremium = true; + + UpgradeSortOrder = 3; + DisplaySortOrder = 3; + + LegacyYear = 2024; + + PasswordManager = new Teams2023PasswordManagerFeatures(isAnnual); + SecretsManager = new Teams2023SecretsManagerFeatures(isAnnual); + } + + private record Teams2023SecretsManagerFeatures : SecretsManagerPlanFeatures + { + public Teams2023SecretsManagerFeatures(bool isAnnual) + { + BaseSeats = 0; + BasePrice = 0; + BaseServiceAccount = 50; + + HasAdditionalSeatsOption = true; + HasAdditionalServiceAccountOption = true; + + AllowSeatAutoscale = true; + AllowServiceAccountsAutoscale = true; + + if (isAnnual) + { + StripeSeatPlanId = "secrets-manager-teams-seat-annually"; + StripeServiceAccountPlanId = "secrets-manager-service-account-annually"; + SeatPrice = 72; + AdditionalPricePerServiceAccount = 6; + } + else + { + StripeSeatPlanId = "secrets-manager-teams-seat-monthly"; + StripeServiceAccountPlanId = "secrets-manager-service-account-monthly"; + SeatPrice = 7; + AdditionalPricePerServiceAccount = 0.5M; + } + } + } + + private record Teams2023PasswordManagerFeatures : PasswordManagerPlanFeatures + { + public Teams2023PasswordManagerFeatures(bool isAnnual) + { + BaseSeats = 0; + BaseStorageGb = 1; + BasePrice = 0; + + HasAdditionalStorageOption = true; + HasAdditionalSeatsOption = true; + + AllowSeatAutoscale = true; + + if (isAnnual) + { + StripeStoragePlanId = "storage-gb-annually"; + StripeSeatPlanId = "2023-teams-org-seat-annually"; + SeatPrice = 48; + AdditionalStoragePricePerGb = 4; + } + else + { + StripeSeatPlanId = "2023-teams-org-seat-monthly"; + StripeStoragePlanId = "storage-gb-monthly"; + SeatPrice = 5; + AdditionalStoragePricePerGb = 0.5M; + } + } + } +} diff --git a/src/Core/Models/StaticStore/Plans/TeamsStarterPlan.cs b/src/Core/Models/StaticStore/Plans/TeamsStarterPlan.cs index d00fec8f83..b1919376ea 100644 --- a/src/Core/Models/StaticStore/Plans/TeamsStarterPlan.cs +++ b/src/Core/Models/StaticStore/Plans/TeamsStarterPlan.cs @@ -36,7 +36,7 @@ public record TeamsStarterPlan : Plan { BaseSeats = 0; BasePrice = 0; - BaseServiceAccount = 50; + BaseServiceAccount = 20; HasAdditionalSeatsOption = true; HasAdditionalServiceAccountOption = true; @@ -45,9 +45,9 @@ public record TeamsStarterPlan : Plan AllowServiceAccountsAutoscale = true; StripeSeatPlanId = "secrets-manager-teams-seat-monthly"; - StripeServiceAccountPlanId = "secrets-manager-service-account-monthly"; + StripeServiceAccountPlanId = "secrets-manager-service-account-2024-monthly"; SeatPrice = 7; - AdditionalPricePerServiceAccount = 0.5M; + AdditionalPricePerServiceAccount = 1; } } diff --git a/src/Core/Models/StaticStore/Plans/TeamsStarterPlan2023.cs b/src/Core/Models/StaticStore/Plans/TeamsStarterPlan2023.cs new file mode 100644 index 0000000000..77b70b8317 --- /dev/null +++ b/src/Core/Models/StaticStore/Plans/TeamsStarterPlan2023.cs @@ -0,0 +1,72 @@ +using Bit.Core.Enums; + +namespace Bit.Core.Models.StaticStore.Plans; + +public record TeamsStarterPlan2023 : Plan +{ + public TeamsStarterPlan2023() + { + Type = PlanType.TeamsStarter2023; + Product = ProductType.TeamsStarter; + Name = "Teams (Starter)"; + NameLocalizationKey = "planNameTeamsStarter"; + DescriptionLocalizationKey = "planDescTeams"; + CanBeUsedByBusiness = true; + + TrialPeriodDays = 7; + + HasGroups = true; + HasDirectory = true; + HasEvents = true; + HasTotp = true; + Has2fa = true; + HasApi = true; + UsersGetPremium = true; + + UpgradeSortOrder = 2; + DisplaySortOrder = 2; + + PasswordManager = new TeamsStarter2023PasswordManagerFeatures(); + SecretsManager = new TeamsStarter2023SecretsManagerFeatures(); + LegacyYear = 2024; + } + + private record TeamsStarter2023SecretsManagerFeatures : SecretsManagerPlanFeatures + { + public TeamsStarter2023SecretsManagerFeatures() + { + BaseSeats = 0; + BasePrice = 0; + BaseServiceAccount = 50; + + HasAdditionalSeatsOption = true; + HasAdditionalServiceAccountOption = true; + + AllowSeatAutoscale = true; + AllowServiceAccountsAutoscale = true; + + StripeSeatPlanId = "secrets-manager-teams-seat-monthly"; + StripeServiceAccountPlanId = "secrets-manager-service-account-monthly"; + SeatPrice = 7; + AdditionalPricePerServiceAccount = 0.5M; + } + } + + private record TeamsStarter2023PasswordManagerFeatures : PasswordManagerPlanFeatures + { + public TeamsStarter2023PasswordManagerFeatures() + { + BaseSeats = 10; + BaseStorageGb = 1; + BasePrice = 20; + + MaxSeats = 10; + + HasAdditionalStorageOption = true; + + StripePlanId = "teams-org-starter"; + StripeStoragePlanId = "storage-gb-monthly"; + AdditionalStoragePricePerGb = 0.5M; + } + } +} diff --git a/src/Core/Utilities/StaticStore.cs b/src/Core/Utilities/StaticStore.cs index 007f3374e0..51c8fdd0ca 100644 --- a/src/Core/Utilities/StaticStore.cs +++ b/src/Core/Utilities/StaticStore.cs @@ -114,8 +114,13 @@ public static class StaticStore new TeamsPlan(true), new TeamsPlan(false), + new Enterprise2023Plan(true), + new Enterprise2023Plan(false), new Enterprise2020Plan(true), new Enterprise2020Plan(false), + new TeamsStarterPlan2023(), + new Teams2023Plan(true), + new Teams2023Plan(false), new Teams2020Plan(true), new Teams2020Plan(false), new FamiliesPlan(), diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs index 4b5037bcfa..fa457186bd 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs @@ -526,7 +526,7 @@ public class UpdateSecretsManagerSubscriptionCommandTests Organization organization, SutProvider sutProvider) { - const int newSmServiceAccounts = 199; + const int newSmServiceAccounts = 49; organization.SmServiceAccounts = newSmServiceAccounts - 10; @@ -537,7 +537,7 @@ public class UpdateSecretsManagerSubscriptionCommandTests var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.UpdateSubscriptionAsync(update)); - Assert.Contains("Plan has a minimum of 200 machine accounts", exception.Message); + Assert.Contains("Plan has a minimum of 50 machine accounts", exception.Message); await VerifyDependencyNotCalledAsync(sutProvider); } diff --git a/test/Core.Test/Utilities/StaticStoreTests.cs b/test/Core.Test/Utilities/StaticStoreTests.cs index 4b16ec96f9..79cf7304b0 100644 --- a/test/Core.Test/Utilities/StaticStoreTests.cs +++ b/test/Core.Test/Utilities/StaticStoreTests.cs @@ -13,7 +13,7 @@ public class StaticStoreTests var plans = StaticStore.Plans.ToList(); Assert.NotNull(plans); Assert.NotEmpty(plans); - Assert.Equal(17, plans.Count); + Assert.Equal(22, plans.Count); } [Theory] From de8b7b14b836ddeb76722494f752ac9b4bf37f2b Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Mon, 8 Apr 2024 14:32:20 -0500 Subject: [PATCH 14/17] feat: generate txt record server-side and remove initial domain verification, refs AC-2350 (#3940) --- .../OrganizationDomainController.cs | 1 - .../Request/OrganizationDomainRequestModel.cs | 3 - .../CreateOrganizationDomainCommand.cs | 21 ++----- .../CreateOrganizationDomainCommandTests.cs | 58 +------------------ 4 files changed, 9 insertions(+), 74 deletions(-) diff --git a/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs b/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs index 92feb9a44c..35c927d5a9 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs @@ -80,7 +80,6 @@ public class OrganizationDomainController : Controller var organizationDomain = new OrganizationDomain { OrganizationId = orgId, - Txt = model.Txt, DomainName = model.DomainName.ToLower() }; diff --git a/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs b/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs index c34c017834..8bf1ebe39a 100644 --- a/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs @@ -4,9 +4,6 @@ namespace Bit.Api.AdminConsole.Models.Request; public class OrganizationDomainRequestModel { - [Required] - public string Txt { get; set; } - [Required] public string DomainName { get; set; } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/CreateOrganizationDomainCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/CreateOrganizationDomainCommand.cs index 35fa54faa7..be8ed0e640 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/CreateOrganizationDomainCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/CreateOrganizationDomainCommand.cs @@ -5,6 +5,7 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Utilities; using Microsoft.Extensions.Logging; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; @@ -50,26 +51,16 @@ public class CreateOrganizationDomainCommand : ICreateOrganizationDomainCommand throw new ConflictException("A domain already exists for this organization."); } - try - { - if (await _dnsResolverService.ResolveAsync(organizationDomain.DomainName, organizationDomain.Txt)) - { - organizationDomain.SetVerifiedDate(); - } - } - catch (Exception e) - { - _logger.LogError(e, "Error verifying Organization domain."); - } - + // Generate and set DNS TXT Record + // DNS-Based Service Discovery RFC: https://www.ietf.org/rfc/rfc6763.txt; see section 6.1 + // Google uses 43 chars for their TXT record value: https://support.google.com/a/answer/2716802 + // A random 44 character string was used here to keep parity with prior client-side generation of 47 characters + organizationDomain.Txt = string.Join("=", "bw", CoreHelpers.RandomString(44)); organizationDomain.SetNextRunDate(_globalSettings.DomainVerification.VerificationInterval); - organizationDomain.SetLastCheckedDate(); var orgDomain = await _organizationDomainRepository.CreateAsync(organizationDomain); await _eventService.LogOrganizationDomainEventAsync(orgDomain, EventType.OrganizationDomain_Added); - await _eventService.LogOrganizationDomainEventAsync(orgDomain, - orgDomain.VerifiedDate != null ? EventType.OrganizationDomain_Verified : EventType.OrganizationDomain_NotVerified); return orgDomain; } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/CreateOrganizationDomainCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/CreateOrganizationDomainCommandTests.cs index a63aadd06c..d6f2c94e9f 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/CreateOrganizationDomainCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/CreateOrganizationDomainCommandTests.cs @@ -7,7 +7,6 @@ using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; -using NSubstitute.ExceptionExtensions; using NSubstitute.ReturnsExtensions; using Xunit; @@ -25,9 +24,6 @@ public class CreateOrganizationDomainCommandTests sutProvider.GetDependency() .GetDomainByOrgIdAndDomainNameAsync(orgDomain.OrganizationId, orgDomain.DomainName) .ReturnsNull(); - sutProvider.GetDependency() - .ResolveAsync(orgDomain.DomainName, orgDomain.Txt) - .Returns(false); orgDomain.SetNextRunDate(12); sutProvider.GetDependency() .CreateAsync(orgDomain) @@ -38,12 +34,12 @@ public class CreateOrganizationDomainCommandTests Assert.Equal(orgDomain.Id, result.Id); Assert.Equal(orgDomain.OrganizationId, result.OrganizationId); - Assert.NotNull(result.LastCheckedDate); + Assert.Null(result.LastCheckedDate); + Assert.Equal(orgDomain.Txt, result.Txt); + Assert.Equal(orgDomain.Txt.Length == 47, result.Txt.Length == 47); Assert.Equal(orgDomain.NextRunDate, result.NextRunDate); await sutProvider.GetDependency().Received(1) .LogOrganizationDomainEventAsync(Arg.Any(), EventType.OrganizationDomain_Added); - await sutProvider.GetDependency().Received(1) - .LogOrganizationDomainEventAsync(Arg.Any(), Arg.Is(x => x == EventType.OrganizationDomain_NotVerified)); } [Theory, BitAutoData] @@ -79,52 +75,4 @@ public class CreateOrganizationDomainCommandTests var exception = await Assert.ThrowsAsync(requestAction); Assert.Contains("A domain already exists for this organization.", exception.Message); } - - [Theory, BitAutoData] - public async Task CreateAsync_ShouldNotSetVerifiedDate_WhenDomainCannotBeResolved(OrganizationDomain orgDomain, - SutProvider sutProvider) - { - sutProvider.GetDependency() - .GetClaimedDomainsByDomainNameAsync(orgDomain.DomainName) - .Returns(new List()); - sutProvider.GetDependency() - .GetDomainByOrgIdAndDomainNameAsync(orgDomain.OrganizationId, orgDomain.DomainName) - .ReturnsNull(); - sutProvider.GetDependency() - .ResolveAsync(orgDomain.DomainName, orgDomain.Txt) - .Throws(new DnsQueryException("")); - sutProvider.GetDependency() - .CreateAsync(orgDomain) - .Returns(orgDomain); - - await sutProvider.Sut.CreateAsync(orgDomain); - - Assert.Null(orgDomain.VerifiedDate); - } - - [Theory, BitAutoData] - public async Task CreateAsync_ShouldSetVerifiedDateAndLogEvent_WhenDomainIsResolved(OrganizationDomain orgDomain, - SutProvider sutProvider) - { - sutProvider.GetDependency() - .GetClaimedDomainsByDomainNameAsync(orgDomain.DomainName) - .Returns(new List()); - sutProvider.GetDependency() - .GetDomainByOrgIdAndDomainNameAsync(orgDomain.OrganizationId, orgDomain.DomainName) - .ReturnsNull(); - sutProvider.GetDependency() - .ResolveAsync(orgDomain.DomainName, orgDomain.Txt) - .Returns(true); - sutProvider.GetDependency() - .CreateAsync(orgDomain) - .Returns(orgDomain); - - var result = await sutProvider.Sut.CreateAsync(orgDomain); - - Assert.NotNull(result.VerifiedDate); - await sutProvider.GetDependency().Received(1) - .LogOrganizationDomainEventAsync(Arg.Any(), EventType.OrganizationDomain_Added); - await sutProvider.GetDependency().Received(1) - .LogOrganizationDomainEventAsync(Arg.Any(), Arg.Is(x => x == EventType.OrganizationDomain_Verified)); - } } From 40221f578ff0777b9f5113931cafe6b36e1aa425 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Mon, 8 Apr 2024 15:39:44 -0400 Subject: [PATCH 15/17] [PM-6339] Shard notification hub clients across multiple accounts (#3812) * WIP registration updates * fix deviceHubs * addHub inline in ctor * adjust setttings for hub reg * send to all clients * fix multiservice push * use notification hub type * feedback --------- Co-authored-by: Matt Bishop --- src/Api/Controllers/PushController.cs | 12 +- .../Implementations/OrganizationService.cs | 14 ++- src/Core/Enums/NotificationHubType.cs | 11 ++ .../Api/Request/PushDeviceRequestModel.cs | 12 ++ .../Api/Request/PushUpdateRequestModel.cs | 7 +- src/Core/Services/IPushRegistrationService.cs | 6 +- .../Services/Implementations/DeviceService.cs | 4 +- .../MultiServicePushNotificationService.cs | 3 +- .../NotificationHubPushNotificationService.cs | 54 ++++++--- .../NotificationHubPushRegistrationService.cs | 111 ++++++++++++++---- .../RelayPushRegistrationService.cs | 23 ++-- .../NoopPushRegistrationService.cs | 6 +- src/Core/Settings/GlobalSettings.cs | 9 +- ...ficationHubPushRegistrationServiceTests.cs | 6 +- 14 files changed, 208 insertions(+), 70 deletions(-) create mode 100644 src/Core/Enums/NotificationHubType.cs create mode 100644 src/Core/Models/Api/Request/PushDeviceRequestModel.cs diff --git a/src/Api/Controllers/PushController.cs b/src/Api/Controllers/PushController.cs index 7312cb7b85..c83eb200b8 100644 --- a/src/Api/Controllers/PushController.cs +++ b/src/Api/Controllers/PushController.cs @@ -42,11 +42,11 @@ public class PushController : Controller Prefix(model.UserId), Prefix(model.Identifier), model.Type); } - [HttpDelete("{id}")] - public async Task Delete(string id) + [HttpPost("delete")] + public async Task PostDelete([FromBody] PushDeviceRequestModel model) { CheckUsage(); - await _pushRegistrationService.DeleteRegistrationAsync(Prefix(id)); + await _pushRegistrationService.DeleteRegistrationAsync(Prefix(model.Id), model.Type); } [HttpPut("add-organization")] @@ -54,7 +54,8 @@ public class PushController : Controller { CheckUsage(); await _pushRegistrationService.AddUserRegistrationOrganizationAsync( - model.DeviceIds.Select(d => Prefix(d)), Prefix(model.OrganizationId)); + model.Devices.Select(d => new KeyValuePair(Prefix(d.Id), d.Type)), + Prefix(model.OrganizationId)); } [HttpPut("delete-organization")] @@ -62,7 +63,8 @@ public class PushController : Controller { CheckUsage(); await _pushRegistrationService.DeleteUserRegistrationOrganizationAsync( - model.DeviceIds.Select(d => Prefix(d)), Prefix(model.OrganizationId)); + model.Devices.Select(d => new KeyValuePair(Prefix(d.Id), d.Type)), + Prefix(model.OrganizationId)); } [HttpPost("send")] diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index d322add42c..9c87ff40a0 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -681,8 +681,8 @@ public class OrganizationService : IOrganizationService await _organizationUserRepository.CreateAsync(orgUser); - var deviceIds = await GetUserDeviceIdsAsync(orgUser.UserId.Value); - await _pushRegistrationService.AddUserRegistrationOrganizationAsync(deviceIds, + var devices = await GetUserDeviceIdsAsync(orgUser.UserId.Value); + await _pushRegistrationService.AddUserRegistrationOrganizationAsync(devices, organization.Id.ToString()); await _pushNotificationService.PushSyncOrgKeysAsync(ownerId); } @@ -1932,17 +1932,19 @@ public class OrganizationService : IOrganizationService private async Task DeleteAndPushUserRegistrationAsync(Guid organizationId, Guid userId) { - var deviceIds = await GetUserDeviceIdsAsync(userId); - await _pushRegistrationService.DeleteUserRegistrationOrganizationAsync(deviceIds, + var devices = await GetUserDeviceIdsAsync(userId); + await _pushRegistrationService.DeleteUserRegistrationOrganizationAsync(devices, organizationId.ToString()); await _pushNotificationService.PushSyncOrgKeysAsync(userId); } - private async Task> GetUserDeviceIdsAsync(Guid userId) + private async Task>> GetUserDeviceIdsAsync(Guid userId) { var devices = await _deviceRepository.GetManyByUserIdAsync(userId); - return devices.Where(d => !string.IsNullOrWhiteSpace(d.PushToken)).Select(d => d.Id.ToString()); + return devices + .Where(d => !string.IsNullOrWhiteSpace(d.PushToken)) + .Select(d => new KeyValuePair(d.Id.ToString(), d.Type)); } public async Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null) diff --git a/src/Core/Enums/NotificationHubType.cs b/src/Core/Enums/NotificationHubType.cs new file mode 100644 index 0000000000..d8c31176b9 --- /dev/null +++ b/src/Core/Enums/NotificationHubType.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.Enums; + +public enum NotificationHubType +{ + General = 0, + Android = 1, + iOS = 2, + GeneralWeb = 3, + GeneralBrowserExtension = 4, + GeneralDesktop = 5 +} diff --git a/src/Core/Models/Api/Request/PushDeviceRequestModel.cs b/src/Core/Models/Api/Request/PushDeviceRequestModel.cs new file mode 100644 index 0000000000..e1866b6f27 --- /dev/null +++ b/src/Core/Models/Api/Request/PushDeviceRequestModel.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Enums; + +namespace Bit.Core.Models.Api; + +public class PushDeviceRequestModel +{ + [Required] + public string Id { get; set; } + [Required] + public DeviceType Type { get; set; } +} diff --git a/src/Core/Models/Api/Request/PushUpdateRequestModel.cs b/src/Core/Models/Api/Request/PushUpdateRequestModel.cs index 2ccbf6eb00..9f7ed5f288 100644 --- a/src/Core/Models/Api/Request/PushUpdateRequestModel.cs +++ b/src/Core/Models/Api/Request/PushUpdateRequestModel.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Bit.Core.Enums; namespace Bit.Core.Models.Api; @@ -7,14 +8,14 @@ public class PushUpdateRequestModel public PushUpdateRequestModel() { } - public PushUpdateRequestModel(IEnumerable deviceIds, string organizationId) + public PushUpdateRequestModel(IEnumerable> devices, string organizationId) { - DeviceIds = deviceIds; + Devices = devices.Select(d => new PushDeviceRequestModel { Id = d.Key, Type = d.Value }); OrganizationId = organizationId; } [Required] - public IEnumerable DeviceIds { get; set; } + public IEnumerable Devices { get; set; } [Required] public string OrganizationId { get; set; } } diff --git a/src/Core/Services/IPushRegistrationService.cs b/src/Core/Services/IPushRegistrationService.cs index 985246de0c..83bbed4854 100644 --- a/src/Core/Services/IPushRegistrationService.cs +++ b/src/Core/Services/IPushRegistrationService.cs @@ -6,7 +6,7 @@ public interface IPushRegistrationService { Task CreateOrUpdateRegistrationAsync(string pushToken, string deviceId, string userId, string identifier, DeviceType type); - Task DeleteRegistrationAsync(string deviceId); - Task AddUserRegistrationOrganizationAsync(IEnumerable deviceIds, string organizationId); - Task DeleteUserRegistrationOrganizationAsync(IEnumerable deviceIds, string organizationId); + Task DeleteRegistrationAsync(string deviceId, DeviceType type); + Task AddUserRegistrationOrganizationAsync(IEnumerable> devices, string organizationId); + Task DeleteUserRegistrationOrganizationAsync(IEnumerable> devices, string organizationId); } diff --git a/src/Core/Services/Implementations/DeviceService.cs b/src/Core/Services/Implementations/DeviceService.cs index 5b1e4b0f01..9d8315f691 100644 --- a/src/Core/Services/Implementations/DeviceService.cs +++ b/src/Core/Services/Implementations/DeviceService.cs @@ -38,13 +38,13 @@ public class DeviceService : IDeviceService public async Task ClearTokenAsync(Device device) { await _deviceRepository.ClearPushTokenAsync(device.Id); - await _pushRegistrationService.DeleteRegistrationAsync(device.Id.ToString()); + await _pushRegistrationService.DeleteRegistrationAsync(device.Id.ToString(), device.Type); } public async Task DeleteAsync(Device device) { await _deviceRepository.DeleteAsync(device); - await _pushRegistrationService.DeleteRegistrationAsync(device.Id.ToString()); + await _pushRegistrationService.DeleteRegistrationAsync(device.Id.ToString(), device.Type); } public async Task UpdateDevicesTrustAsync(string currentDeviceIdentifier, diff --git a/src/Core/Services/Implementations/MultiServicePushNotificationService.cs b/src/Core/Services/Implementations/MultiServicePushNotificationService.cs index b683c05d0f..92e29908f5 100644 --- a/src/Core/Services/Implementations/MultiServicePushNotificationService.cs +++ b/src/Core/Services/Implementations/MultiServicePushNotificationService.cs @@ -43,7 +43,8 @@ public class MultiServicePushNotificationService : IPushNotificationService } else { - if (CoreHelpers.SettingHasValue(globalSettings.NotificationHub.ConnectionString)) + var generalHub = globalSettings.NotificationHubs?.FirstOrDefault(h => h.HubType == NotificationHubType.General); + if (CoreHelpers.SettingHasValue(generalHub?.ConnectionString)) { _services.Add(new NotificationHubPushNotificationService(installationDeviceRepository, globalSettings, httpContextAccessor, hubLogger)); diff --git a/src/Core/Services/Implementations/NotificationHubPushNotificationService.cs b/src/Core/Services/Implementations/NotificationHubPushNotificationService.cs index 96c50ca93a..480f0dfa9e 100644 --- a/src/Core/Services/Implementations/NotificationHubPushNotificationService.cs +++ b/src/Core/Services/Implementations/NotificationHubPushNotificationService.cs @@ -20,8 +20,9 @@ public class NotificationHubPushNotificationService : IPushNotificationService private readonly IInstallationDeviceRepository _installationDeviceRepository; private readonly GlobalSettings _globalSettings; private readonly IHttpContextAccessor _httpContextAccessor; - private NotificationHubClient _client = null; - private ILogger _logger; + private readonly List _clients = []; + private readonly bool _enableTracing = false; + private readonly ILogger _logger; public NotificationHubPushNotificationService( IInstallationDeviceRepository installationDeviceRepository, @@ -32,10 +33,18 @@ public class NotificationHubPushNotificationService : IPushNotificationService _installationDeviceRepository = installationDeviceRepository; _globalSettings = globalSettings; _httpContextAccessor = httpContextAccessor; - _client = NotificationHubClient.CreateClientFromConnectionString( - _globalSettings.NotificationHub.ConnectionString, - _globalSettings.NotificationHub.HubName, - _globalSettings.NotificationHub.EnableSendTracing); + + foreach (var hub in globalSettings.NotificationHubs) + { + var client = NotificationHubClient.CreateClientFromConnectionString( + hub.ConnectionString, + hub.HubName, + hub.EnableSendTracing); + _clients.Add(client); + + _enableTracing = _enableTracing || hub.EnableSendTracing; + } + _logger = logger; } @@ -255,16 +264,31 @@ public class NotificationHubPushNotificationService : IPushNotificationService private async Task SendPayloadAsync(string tag, PushType type, object payload) { - var outcome = await _client.SendTemplateNotificationAsync( - new Dictionary - { - { "type", ((byte)type).ToString() }, - { "payload", JsonSerializer.Serialize(payload) } - }, tag); - if (_globalSettings.NotificationHub.EnableSendTracing) + var tasks = new List>(); + foreach (var client in _clients) { - _logger.LogInformation("Azure Notification Hub Tracking ID: {id} | {type} push notification with {success} successes and {failure} failures with a payload of {@payload} and result of {@results}", - outcome.TrackingId, type, outcome.Success, outcome.Failure, payload, outcome.Results); + var task = client.SendTemplateNotificationAsync( + new Dictionary + { + { "type", ((byte)type).ToString() }, + { "payload", JsonSerializer.Serialize(payload) } + }, tag); + tasks.Add(task); + } + + await Task.WhenAll(tasks); + + if (_enableTracing) + { + for (var i = 0; i < tasks.Count; i++) + { + if (_clients[i].EnableTestSend) + { + var outcome = await tasks[i]; + _logger.LogInformation("Azure Notification Hub Tracking ID: {id} | {type} push notification with {success} successes and {failure} failures with a payload of {@payload} and result of {@results}", + outcome.TrackingId, type, outcome.Success, outcome.Failure, payload, outcome.Results); + } + } } } diff --git a/src/Core/Services/Implementations/NotificationHubPushRegistrationService.cs b/src/Core/Services/Implementations/NotificationHubPushRegistrationService.cs index 6f09375398..9a31a2a879 100644 --- a/src/Core/Services/Implementations/NotificationHubPushRegistrationService.cs +++ b/src/Core/Services/Implementations/NotificationHubPushRegistrationService.cs @@ -3,6 +3,7 @@ using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Settings; using Microsoft.Azure.NotificationHubs; +using Microsoft.Extensions.Logging; namespace Bit.Core.Services; @@ -10,18 +11,36 @@ public class NotificationHubPushRegistrationService : IPushRegistrationService { private readonly IInstallationDeviceRepository _installationDeviceRepository; private readonly GlobalSettings _globalSettings; - - private NotificationHubClient _client = null; + private readonly ILogger _logger; + private Dictionary _clients = []; public NotificationHubPushRegistrationService( IInstallationDeviceRepository installationDeviceRepository, - GlobalSettings globalSettings) + GlobalSettings globalSettings, + ILogger logger) { _installationDeviceRepository = installationDeviceRepository; _globalSettings = globalSettings; - _client = NotificationHubClient.CreateClientFromConnectionString( - _globalSettings.NotificationHub.ConnectionString, - _globalSettings.NotificationHub.HubName); + _logger = logger; + + // Is this dirty to do in the ctor? + void addHub(NotificationHubType type) + { + var hubRegistration = globalSettings.NotificationHubs.FirstOrDefault( + h => h.HubType == type && h.EnableRegistration); + if (hubRegistration != null) + { + var client = NotificationHubClient.CreateClientFromConnectionString( + hubRegistration.ConnectionString, + hubRegistration.HubName, + hubRegistration.EnableSendTracing); + _clients.Add(type, client); + } + } + + addHub(NotificationHubType.General); + addHub(NotificationHubType.iOS); + addHub(NotificationHubType.Android); } public async Task CreateOrUpdateRegistrationAsync(string pushToken, string deviceId, string userId, @@ -84,7 +103,7 @@ public class NotificationHubPushRegistrationService : IPushRegistrationService BuildInstallationTemplate(installation, "badgeMessage", badgeMessageTemplate ?? messageTemplate, userId, identifier); - await _client.CreateOrUpdateInstallationAsync(installation); + await GetClient(type).CreateOrUpdateInstallationAsync(installation); if (InstallationDeviceEntity.IsInstallationDeviceId(deviceId)) { await _installationDeviceRepository.UpsertAsync(new InstallationDeviceEntity(deviceId)); @@ -119,11 +138,11 @@ public class NotificationHubPushRegistrationService : IPushRegistrationService installation.Templates.Add(fullTemplateId, template); } - public async Task DeleteRegistrationAsync(string deviceId) + public async Task DeleteRegistrationAsync(string deviceId, DeviceType deviceType) { try { - await _client.DeleteInstallationAsync(deviceId); + await GetClient(deviceType).DeleteInstallationAsync(deviceId); if (InstallationDeviceEntity.IsInstallationDeviceId(deviceId)) { await _installationDeviceRepository.DeleteAsync(new InstallationDeviceEntity(deviceId)); @@ -135,31 +154,31 @@ public class NotificationHubPushRegistrationService : IPushRegistrationService } } - public async Task AddUserRegistrationOrganizationAsync(IEnumerable deviceIds, string organizationId) + public async Task AddUserRegistrationOrganizationAsync(IEnumerable> devices, string organizationId) { - await PatchTagsForUserDevicesAsync(deviceIds, UpdateOperationType.Add, $"organizationId:{organizationId}"); - if (deviceIds.Any() && InstallationDeviceEntity.IsInstallationDeviceId(deviceIds.First())) + await PatchTagsForUserDevicesAsync(devices, UpdateOperationType.Add, $"organizationId:{organizationId}"); + if (devices.Any() && InstallationDeviceEntity.IsInstallationDeviceId(devices.First().Key)) { - var entities = deviceIds.Select(e => new InstallationDeviceEntity(e)); + var entities = devices.Select(e => new InstallationDeviceEntity(e.Key)); await _installationDeviceRepository.UpsertManyAsync(entities.ToList()); } } - public async Task DeleteUserRegistrationOrganizationAsync(IEnumerable deviceIds, string organizationId) + public async Task DeleteUserRegistrationOrganizationAsync(IEnumerable> devices, string organizationId) { - await PatchTagsForUserDevicesAsync(deviceIds, UpdateOperationType.Remove, + await PatchTagsForUserDevicesAsync(devices, UpdateOperationType.Remove, $"organizationId:{organizationId}"); - if (deviceIds.Any() && InstallationDeviceEntity.IsInstallationDeviceId(deviceIds.First())) + if (devices.Any() && InstallationDeviceEntity.IsInstallationDeviceId(devices.First().Key)) { - var entities = deviceIds.Select(e => new InstallationDeviceEntity(e)); + var entities = devices.Select(e => new InstallationDeviceEntity(e.Key)); await _installationDeviceRepository.UpsertManyAsync(entities.ToList()); } } - private async Task PatchTagsForUserDevicesAsync(IEnumerable deviceIds, UpdateOperationType op, + private async Task PatchTagsForUserDevicesAsync(IEnumerable> devices, UpdateOperationType op, string tag) { - if (!deviceIds.Any()) + if (!devices.Any()) { return; } @@ -179,11 +198,11 @@ public class NotificationHubPushRegistrationService : IPushRegistrationService operation.Path += $"/{tag}"; } - foreach (var id in deviceIds) + foreach (var device in devices) { try { - await _client.PatchInstallationAsync(id, new List { operation }); + await GetClient(device.Value).PatchInstallationAsync(device.Key, new List { operation }); } catch (Exception e) when (e.InnerException == null || !e.InnerException.Message.Contains("(404) Not Found")) { @@ -191,4 +210,54 @@ public class NotificationHubPushRegistrationService : IPushRegistrationService } } } + + private NotificationHubClient GetClient(DeviceType deviceType) + { + var hubType = NotificationHubType.General; + switch (deviceType) + { + case DeviceType.Android: + hubType = NotificationHubType.Android; + break; + case DeviceType.iOS: + hubType = NotificationHubType.iOS; + break; + case DeviceType.ChromeExtension: + case DeviceType.FirefoxExtension: + case DeviceType.OperaExtension: + case DeviceType.EdgeExtension: + case DeviceType.VivaldiExtension: + case DeviceType.SafariExtension: + hubType = NotificationHubType.GeneralBrowserExtension; + break; + case DeviceType.WindowsDesktop: + case DeviceType.MacOsDesktop: + case DeviceType.LinuxDesktop: + hubType = NotificationHubType.GeneralDesktop; + break; + case DeviceType.ChromeBrowser: + case DeviceType.FirefoxBrowser: + case DeviceType.OperaBrowser: + case DeviceType.EdgeBrowser: + case DeviceType.IEBrowser: + case DeviceType.UnknownBrowser: + case DeviceType.SafariBrowser: + case DeviceType.VivaldiBrowser: + hubType = NotificationHubType.GeneralWeb; + break; + default: + break; + } + + if (!_clients.ContainsKey(hubType)) + { + _logger.LogWarning("No hub client for '{0}'. Using general hub instead.", hubType); + hubType = NotificationHubType.General; + if (!_clients.ContainsKey(hubType)) + { + throw new Exception("No general hub client found."); + } + } + return _clients[hubType]; + } } diff --git a/src/Core/Services/Implementations/RelayPushRegistrationService.cs b/src/Core/Services/Implementations/RelayPushRegistrationService.cs index f661af537d..d9df7d04dc 100644 --- a/src/Core/Services/Implementations/RelayPushRegistrationService.cs +++ b/src/Core/Services/Implementations/RelayPushRegistrationService.cs @@ -38,30 +38,37 @@ public class RelayPushRegistrationService : BaseIdentityClientService, IPushRegi await SendAsync(HttpMethod.Post, "push/register", requestModel); } - public async Task DeleteRegistrationAsync(string deviceId) + public async Task DeleteRegistrationAsync(string deviceId, DeviceType type) { - await SendAsync(HttpMethod.Delete, string.Concat("push/", deviceId)); + var requestModel = new PushDeviceRequestModel + { + Id = deviceId, + Type = type, + }; + await SendAsync(HttpMethod.Post, "push/delete", requestModel); } - public async Task AddUserRegistrationOrganizationAsync(IEnumerable deviceIds, string organizationId) + public async Task AddUserRegistrationOrganizationAsync( + IEnumerable> devices, string organizationId) { - if (!deviceIds.Any()) + if (!devices.Any()) { return; } - var requestModel = new PushUpdateRequestModel(deviceIds, organizationId); + var requestModel = new PushUpdateRequestModel(devices, organizationId); await SendAsync(HttpMethod.Put, "push/add-organization", requestModel); } - public async Task DeleteUserRegistrationOrganizationAsync(IEnumerable deviceIds, string organizationId) + public async Task DeleteUserRegistrationOrganizationAsync( + IEnumerable> devices, string organizationId) { - if (!deviceIds.Any()) + if (!devices.Any()) { return; } - var requestModel = new PushUpdateRequestModel(deviceIds, organizationId); + var requestModel = new PushUpdateRequestModel(devices, organizationId); await SendAsync(HttpMethod.Put, "push/delete-organization", requestModel); } } diff --git a/src/Core/Services/NoopImplementations/NoopPushRegistrationService.cs b/src/Core/Services/NoopImplementations/NoopPushRegistrationService.cs index f6279c9467..fcd0889248 100644 --- a/src/Core/Services/NoopImplementations/NoopPushRegistrationService.cs +++ b/src/Core/Services/NoopImplementations/NoopPushRegistrationService.cs @@ -4,7 +4,7 @@ namespace Bit.Core.Services; public class NoopPushRegistrationService : IPushRegistrationService { - public Task AddUserRegistrationOrganizationAsync(IEnumerable deviceIds, string organizationId) + public Task AddUserRegistrationOrganizationAsync(IEnumerable> devices, string organizationId) { return Task.FromResult(0); } @@ -15,12 +15,12 @@ public class NoopPushRegistrationService : IPushRegistrationService return Task.FromResult(0); } - public Task DeleteRegistrationAsync(string deviceId) + public Task DeleteRegistrationAsync(string deviceId, DeviceType deviceType) { return Task.FromResult(0); } - public Task DeleteUserRegistrationOrganizationAsync(IEnumerable deviceIds, string organizationId) + public Task DeleteUserRegistrationOrganizationAsync(IEnumerable> devices, string organizationId) { return Task.FromResult(0); } diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 50b4efe6fb..f883422221 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -1,4 +1,5 @@ using Bit.Core.Auth.Settings; +using Bit.Core.Enums; using Bit.Core.Settings.LoggingSettings; namespace Bit.Core.Settings; @@ -64,7 +65,7 @@ public class GlobalSettings : IGlobalSettings public virtual SentrySettings Sentry { get; set; } = new SentrySettings(); public virtual SyslogSettings Syslog { get; set; } = new SyslogSettings(); public virtual ILogLevelSettings MinLogLevel { get; set; } = new LogLevelSettings(); - public virtual NotificationHubSettings NotificationHub { get; set; } = new NotificationHubSettings(); + public virtual List NotificationHubs { get; set; } = new(); public virtual YubicoSettings Yubico { get; set; } = new YubicoSettings(); public virtual DuoSettings Duo { get; set; } = new DuoSettings(); public virtual BraintreeSettings Braintree { get; set; } = new BraintreeSettings(); @@ -416,12 +417,16 @@ public class GlobalSettings : IGlobalSettings set => _connectionString = value.Trim('"'); } public string HubName { get; set; } - /// /// Enables TestSend on the Azure Notification Hub, which allows tracing of the request through the hub and to the platform-specific push notification service (PNS). /// Enabling this will result in delayed responses because the Hub must wait on delivery to the PNS. This should ONLY be enabled in a non-production environment, as results are throttled. /// public bool EnableSendTracing { get; set; } = false; + /// + /// At least one hub configuration should have registration enabled, preferably the General hub as a safety net. + /// + public bool EnableRegistration { get; set; } + public NotificationHubType HubType { get; set; } } public class YubicoSettings diff --git a/test/Core.Test/Services/NotificationHubPushRegistrationServiceTests.cs b/test/Core.Test/Services/NotificationHubPushRegistrationServiceTests.cs index 8e2a19d7b9..0b9c64121b 100644 --- a/test/Core.Test/Services/NotificationHubPushRegistrationServiceTests.cs +++ b/test/Core.Test/Services/NotificationHubPushRegistrationServiceTests.cs @@ -1,6 +1,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -11,16 +12,19 @@ public class NotificationHubPushRegistrationServiceTests private readonly NotificationHubPushRegistrationService _sut; private readonly IInstallationDeviceRepository _installationDeviceRepository; + private readonly ILogger _logger; private readonly GlobalSettings _globalSettings; public NotificationHubPushRegistrationServiceTests() { _installationDeviceRepository = Substitute.For(); + _logger = Substitute.For>(); _globalSettings = new GlobalSettings(); _sut = new NotificationHubPushRegistrationService( _installationDeviceRepository, - _globalSettings + _globalSettings, + _logger ); } From c15574721d8067b56917bf8b0307e10faf6f9374 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Tue, 9 Apr 2024 10:39:26 -0400 Subject: [PATCH 16/17] AC-2330 add response to put method for updating cipher collections (#3964) Co-authored-by: gbubemismith --- src/Api/Vault/Controllers/CiphersController.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 80a453dfc4..cd70d7a6c0 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -560,7 +560,7 @@ public class CiphersController : Controller [HttpPut("{id}/collections")] [HttpPost("{id}/collections")] - public async Task PutCollections(Guid id, [FromBody] CipherCollectionsRequestModel model) + public async Task PutCollections(Guid id, [FromBody] CipherCollectionsRequestModel model) { var userId = _userService.GetProperUserId(User).Value; var cipher = await GetByIdAsync(id, userId); @@ -572,6 +572,10 @@ public class CiphersController : Controller await _cipherService.SaveCollectionsAsync(cipher, model.CollectionIds.Select(c => new Guid(c)), userId, false); + + var updatedCipherCollections = await GetByIdAsync(id, userId); + var response = new CipherResponseModel(updatedCipherCollections, _globalSettings); + return response; } [HttpPut("{id}/collections-admin")] From 2c36784cdaf240d072672f36accd38b6fca08c41 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Thu, 11 Apr 2024 00:06:43 +1000 Subject: [PATCH 17/17] [AC-2436] Show unassigned items banner (#3967) * Add endpoint * Add feature flag * Only show banner for flexible collections orgs (to avoid affecting self-host) --- .../Vault/Controllers/CiphersController.cs | 27 +++++++++++++++++++ src/Core/Constants.cs | 1 + 2 files changed, 28 insertions(+) diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index cd70d7a6c0..efc9a0eb88 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -1110,6 +1110,33 @@ public class CiphersController : Controller }); } + /// + /// Returns true if the user is an admin or owner of an organization with unassigned ciphers (i.e. ciphers that + /// are not assigned to a collection). + /// + /// + [HttpGet("has-unassigned-ciphers")] + public async Task HasUnassignedCiphers() + { + var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + + var adminOrganizations = _currentContext.Organizations + .Where(o => o.Type is OrganizationUserType.Admin or OrganizationUserType.Owner && + orgAbilities.ContainsKey(o.Id) && orgAbilities[o.Id].FlexibleCollections); + + foreach (var org in adminOrganizations) + { + var unassignedCiphers = await _cipherRepository.GetManyUnassignedOrganizationDetailsByOrganizationIdAsync(org.Id); + // We only care about non-deleted ciphers + if (unassignedCiphers.Any(c => c.DeletedDate == null)) + { + return true; + } + } + + return false; + } + private void ValidateAttachment() { if (!Request?.ContentType.Contains("multipart/") ?? true) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 8f5cc0773b..749419644a 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -132,6 +132,7 @@ public static class FeatureFlagKeys public const string ShowPaymentMethodWarningBanners = "show-payment-method-warning-banners"; public const string EnableConsolidatedBilling = "enable-consolidated-billing"; public const string AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section"; + public const string UnassignedItemsBanner = "unassigned-items-banner"; public static List GetAllKeys() {