diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 973405dea5..9f3048a340 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -43,8 +43,16 @@ src/Core/IdentityServer @bitwarden/team-auth-dev # Key Management team **/KeyManagement @bitwarden/team-key-management-dev +# Tools team **/Tools @bitwarden/team-tools-dev +# Dirt (Data Insights & Reporting) team +src/Api/Controllers/Dirt @bitwarden/team-data-insights-and-reporting-dev +src/Core/Dirt @bitwarden/team-data-insights-and-reporting-dev +src/Infrastructure.Dapper/Dirt @bitwarden/team-data-insights-and-reporting-dev +test/Api.Test/Dirt @bitwarden/team-data-insights-and-reporting-dev +test/Core.Test/Dirt @bitwarden/team-data-insights-and-reporting-dev + # Vault team **/Vault @bitwarden/team-vault-dev **/Vault/AuthorizationHandlers @bitwarden/team-vault-dev @bitwarden/team-admin-console-dev # joint ownership over authorization handlers that affect organization users diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 344a326519..ac34903c1b 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -20,7 +20,7 @@ ], commitMessagePrefix: "[deps] BRE:", reviewers: ["team:dept-bre"], - addLabels: ["hold"] + addLabels: ["hold"], }, { groupName: "dockerfile minor", @@ -37,6 +37,16 @@ matchManagers: ["github-actions"], matchUpdateTypes: ["minor"], }, + { + // For any Microsoft.Extensions.* and Microsoft.AspNetCore.* packages, we want to create PRs for patch updates. + // This overrides the default that ignores patch updates for nuget dependencies. + matchPackageNames: [ + "/^Microsoft\\.Extensions\\./", + "/^Microsoft\\.AspNetCore\\./", + ], + matchUpdateTypes: ["patch"], + dependencyDashboardApproval: false, + }, { matchManagers: ["dockerfile", "docker-compose"], commitMessagePrefix: "[deps] BRE:", @@ -59,6 +69,7 @@ "DuoUniversal", "Fido2.AspNet", "Duende.IdentityServer", + "Microsoft.AspNetCore.Authentication.JwtBearer", "Microsoft.Extensions.Identity.Stores", "Otp.NET", "Sustainsys.Saml2.AspNetCore2", @@ -79,8 +90,6 @@ "CsvHelper", "Kralizek.AutoFixture.Extensions.MockHttp", "Microsoft.AspNetCore.Mvc.Testing", - "Microsoft.Extensions.Logging", - "Microsoft.Extensions.Logging.Console", "Newtonsoft.Json", "NSubstitute", "Sentry.Serilog", @@ -100,9 +109,9 @@ reviewers: ["team:team-billing-dev"], }, { - matchPackagePatterns: ["^Microsoft.Extensions.Logging"], - groupName: "Microsoft.Extensions.Logging", - description: "Group Microsoft.Extensions.Logging to exclude them from the dotnet monorepo preset", + matchPackageNames: ["/^Microsoft\\.EntityFrameworkCore\\./", "/^dotnet-ef/"], + groupName: "EntityFrameworkCore", + description: "Group EntityFrameworkCore to exclude them from the dotnet monorepo preset", }, { matchPackageNames: [ @@ -117,9 +126,6 @@ "Microsoft.EntityFrameworkCore.Relational", "Microsoft.EntityFrameworkCore.Sqlite", "Microsoft.EntityFrameworkCore.SqlServer", - "Microsoft.Extensions.Caching.Cosmos", - "Microsoft.Extensions.Caching.SqlServer", - "Microsoft.Extensions.Caching.StackExchangeRedis", "Npgsql.EntityFrameworkCore.PostgreSQL", "Pomelo.EntityFrameworkCore.MySql", ], @@ -142,56 +148,40 @@ "Azure.Messaging.ServiceBus", "Azure.Storage.Blobs", "Azure.Storage.Queues", - "Microsoft.AspNetCore.Authentication.JwtBearer", + "LaunchDarkly.ServerSdk", "Microsoft.AspNetCore.Http", + "Microsoft.AspNetCore.SignalR.Protocols.MessagePack", + "Microsoft.AspNetCore.SignalR.StackExchangeRedis", + "Microsoft.Extensions.Configuration.EnvironmentVariables", + "Microsoft.Extensions.Configuration.UserSecrets", + "Microsoft.Extensions.Configuration", + "Microsoft.Extensions.DependencyInjection.Abstractions", + "Microsoft.Extensions.DependencyInjection", + "Microsoft.Extensions.Logging", + "Microsoft.Extensions.Logging.Console", + "Microsoft.Extensions.Caching.Cosmos", + "Microsoft.Extensions.Caching.SqlServer", + "Microsoft.Extensions.Caching.StackExchangeRedis", "Quartz", ], description: "Platform owned dependencies", commitMessagePrefix: "[deps] Platform:", reviewers: ["team:team-platform-dev"], }, - { - matchPackagePatterns: ["EntityFrameworkCore", "^dotnet-ef"], - groupName: "EntityFrameworkCore", - description: "Group EntityFrameworkCore to exclude them from the dotnet monorepo preset", - }, { matchPackageNames: [ "AutoMapper.Extensions.Microsoft.DependencyInjection", "AWSSDK.SimpleEmail", "AWSSDK.SQS", "Handlebars.Net", - "LaunchDarkly.ServerSdk", "MailKit", - "Microsoft.AspNetCore.SignalR.Protocols.MessagePack", - "Microsoft.AspNetCore.SignalR.StackExchangeRedis", "Microsoft.Azure.NotificationHubs", - "Microsoft.Extensions.Configuration.EnvironmentVariables", - "Microsoft.Extensions.Configuration.UserSecrets", - "Microsoft.Extensions.Configuration", - "Microsoft.Extensions.DependencyInjection.Abstractions", - "Microsoft.Extensions.DependencyInjection", "SendGrid", ], description: "Tools owned dependencies", commitMessagePrefix: "[deps] Tools:", reviewers: ["team:team-tools-dev"], }, - { - matchPackagePatterns: ["^Microsoft.AspNetCore.SignalR"], - groupName: "SignalR", - description: "Group SignalR to exclude them from the dotnet monorepo preset", - }, - { - matchPackagePatterns: ["^Microsoft.Extensions.Configuration"], - groupName: "Microsoft.Extensions.Configuration", - description: "Group Microsoft.Extensions.Configuration to exclude them from the dotnet monorepo preset", - }, - { - matchPackagePatterns: ["^Microsoft.Extensions.DependencyInjection"], - groupName: "Microsoft.Extensions.DependencyInjection", - description: "Group Microsoft.Extensions.DependencyInjection to exclude them from the dotnet monorepo preset", - }, { matchPackageNames: [ "AngleSharp", diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1caa425401..cad5b5eeae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,6 +14,7 @@ on: env: _AZ_REGISTRY: "bitwardenprod.azurecr.io" + _GITHUB_PR_REPO_NAME: ${{ github.event.pull_request.head.repo.full_name }} jobs: lint: @@ -128,12 +129,18 @@ jobs: - name: Generate container image tag id: tag run: | - if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then - IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g") + if [[ "${GITHUB_EVENT_NAME}" == "pull_request" || "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then + IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s/[^a-zA-Z0-9]/-/g") # Sanitize branch name to alphanumeric only else IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g") fi + if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then + SANITIZED_REPO_NAME=$(echo "$_GITHUB_PR_REPO_NAME" | sed "s/[^a-zA-Z0-9]/-/g") # Sanitize repo name to alphanumeric only + IMAGE_TAG=$SANITIZED_REPO_NAME-$IMAGE_TAG # Add repo name to the tag + IMAGE_TAG=${IMAGE_TAG:0:128} # Limit to 128 characters, as that's the max length for Docker image tags + fi + if [[ "$IMAGE_TAG" == "main" ]]; then IMAGE_TAG=dev fi diff --git a/.github/workflows/code-references.yml b/.github/workflows/code-references.yml index ce8cb8e467..30fbff32ed 100644 --- a/.github/workflows/code-references.yml +++ b/.github/workflows/code-references.yml @@ -1,7 +1,10 @@ name: Collect code references -on: - pull_request: +on: + push: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: check-ld-secret: @@ -37,12 +40,10 @@ jobs: - name: Collect id: collect - uses: launchdarkly/find-code-references-in-pull-request@30f4c4ab2949bbf258b797ced2fbf6dea34df9ce # v2.1.0 + uses: launchdarkly/find-code-references@e3e9da201b87ada54eb4c550c14fb783385c5c8a # v2.13.0 with: - project-key: default - environment-key: dev - access-token: ${{ secrets.LD_ACCESS_TOKEN }} - repo-token: ${{ secrets.GITHUB_TOKEN }} + accessToken: ${{ secrets.LD_ACCESS_TOKEN }} + projKey: default - name: Add label if: steps.collect.outputs.any-changed == 'true' diff --git a/Directory.Build.props b/Directory.Build.props index d9f0430a6a..b7147f2c67 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.4.3 + 2025.5.0 Bit.$(MSBuildProjectName) enable diff --git a/bitwarden-server.sln b/bitwarden-server.sln index 892d2f4255..2ec8d86e0e 100644 --- a/bitwarden-server.sln +++ b/bitwarden-server.sln @@ -129,6 +129,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Events.IntegrationTest", "t EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.IntegrationTest", "test\Core.IntegrationTest\Core.IntegrationTest.csproj", "{3631BA42-6731-4118-A917-DAA43C5032B9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Seeder", "util\Seeder\Seeder.csproj", "{9A612EBA-1C0E-42B8-982B-62F0EE81000A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbSeederUtility", "util\DbSeederUtility\DbSeederUtility.csproj", "{17A89266-260A-4A03-81AE-C0468C6EE06E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -325,6 +329,14 @@ Global {3631BA42-6731-4118-A917-DAA43C5032B9}.Debug|Any CPU.Build.0 = Debug|Any CPU {3631BA42-6731-4118-A917-DAA43C5032B9}.Release|Any CPU.ActiveCfg = Release|Any CPU {3631BA42-6731-4118-A917-DAA43C5032B9}.Release|Any CPU.Build.0 = Release|Any CPU + {9A612EBA-1C0E-42B8-982B-62F0EE81000A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9A612EBA-1C0E-42B8-982B-62F0EE81000A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9A612EBA-1C0E-42B8-982B-62F0EE81000A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9A612EBA-1C0E-42B8-982B-62F0EE81000A}.Release|Any CPU.Build.0 = Release|Any CPU + {17A89266-260A-4A03-81AE-C0468C6EE06E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {17A89266-260A-4A03-81AE-C0468C6EE06E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {17A89266-260A-4A03-81AE-C0468C6EE06E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {17A89266-260A-4A03-81AE-C0468C6EE06E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -377,6 +389,8 @@ Global {4A725DB3-BE4F-4C23-9087-82D0610D67AF} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {4F4C63A9-AEE2-48C4-AB86-A5BCD665E401} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {3631BA42-6731-4118-A917-DAA43C5032B9} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} + {9A612EBA-1C0E-42B8-982B-62F0EE81000A} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} + {17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F} diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs index 9a62be8dd5..22a2e93642 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs @@ -8,7 +8,8 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Services.Implementations.AutomaticTax; +using Bit.Core.Billing.Tax.Services; +using Bit.Core.Billing.Tax.Services.Implementations; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index fff6b5271d..2fc44937a7 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Context; @@ -82,7 +83,7 @@ public class ProviderService : IProviderService _pricingClient = pricingClient; } - public async Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo = null) + public async Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null) { var owner = await _userService.GetUserByIdAsync(ownerUserId); if (owner == null) @@ -111,7 +112,20 @@ public class ProviderService : IProviderService { throw new BadRequestException("Both address and postal code are required to set up your provider."); } - var customer = await _providerBillingService.SetupCustomer(provider, taxInfo); + + var requireProviderPaymentMethodDuringSetup = + _featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup); + + if (requireProviderPaymentMethodDuringSetup && tokenizedPaymentSource is not + { + Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal, + Token: not null and not "" + }) + { + throw new BadRequestException("A payment method is required to set up your provider."); + } + + var customer = await _providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); provider.GatewayCustomerId = customer.Id; var subscription = await _providerBillingService.SetupSubscription(provider); provider.GatewaySubscriptionId = subscription.Id; diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs index bdfff079cf..99831aa3f1 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs @@ -6,29 +6,39 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing; +using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Contracts; -using Bit.Core.Billing.Services.Implementations.AutomaticTax; +using Bit.Core.Billing.Tax.Models; +using Bit.Core.Billing.Tax.Services; +using Bit.Core.Billing.Tax.Services.Implementations; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Braintree; using CsvHelper; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Stripe; +using static Bit.Core.Billing.Utilities; +using Customer = Stripe.Customer; +using Subscription = Stripe.Subscription; + namespace Bit.Commercial.Core.Billing; public class ProviderBillingService( + IBraintreeGateway braintreeGateway, IEventService eventService, IFeatureService featureService, IGlobalSettings globalSettings, @@ -39,6 +49,7 @@ public class ProviderBillingService( IProviderOrganizationRepository providerOrganizationRepository, IProviderPlanRepository providerPlanRepository, IProviderUserRepository providerUserRepository, + ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, ISubscriberService subscriberService, ITaxService taxService, @@ -463,7 +474,8 @@ public class ProviderBillingService( public async Task SetupCustomer( Provider provider, - TaxInfo taxInfo) + TaxInfo taxInfo, + TokenizedPaymentSource tokenizedPaymentSource = null) { if (taxInfo is not { @@ -532,13 +544,97 @@ public class ProviderBillingService( options.Coupon = provider.DiscountId; } + var requireProviderPaymentMethodDuringSetup = + featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup); + + var braintreeCustomerId = ""; + + if (requireProviderPaymentMethodDuringSetup) + { + if (tokenizedPaymentSource is not + { + Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal, + Token: not null and not "" + }) + { + logger.LogError("Cannot create customer for provider ({ProviderID}) without a payment method", provider.Id); + throw new BillingException(); + } + + var (type, token) = tokenizedPaymentSource; + + // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault + switch (type) + { + case PaymentMethodType.BankAccount: + { + var setupIntent = + (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token })) + .FirstOrDefault(); + + if (setupIntent == null) + { + logger.LogError("Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account", provider.Id); + throw new BillingException(); + } + + await setupIntentCache.Set(provider.Id, setupIntent.Id); + break; + } + case PaymentMethodType.Card: + { + options.PaymentMethod = token; + options.InvoiceSettings.DefaultPaymentMethod = token; + break; + } + case PaymentMethodType.PayPal: + { + braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, token); + options.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId; + break; + } + } + } + try { return await stripeAdapter.CustomerCreateAsync(options); } - catch (StripeException stripeException) when (stripeException.StripeError?.Code == StripeConstants.ErrorCodes.TaxIdInvalid) + catch (StripeException stripeException) when (stripeException.StripeError?.Code == + StripeConstants.ErrorCodes.TaxIdInvalid) { - throw new BadRequestException("Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid."); + await Revert(); + throw new BadRequestException( + "Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid."); + } + catch + { + await Revert(); + throw; + } + + async Task Revert() + { + if (requireProviderPaymentMethodDuringSetup && tokenizedPaymentSource != null) + { + // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault + switch (tokenizedPaymentSource.Type) + { + case PaymentMethodType.BankAccount: + { + var setupIntentId = await setupIntentCache.Get(provider.Id); + await stripeAdapter.SetupIntentCancel(setupIntentId, + new SetupIntentCancelOptions { CancellationReason = "abandoned" }); + await setupIntentCache.Remove(provider.Id); + break; + } + case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): + { + await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId); + break; + } + } + } } } @@ -580,18 +676,38 @@ public class ProviderBillingService( }); } + var requireProviderPaymentMethodDuringSetup = + featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup); + + var setupIntentId = await setupIntentCache.Get(provider.Id); + + var setupIntent = !string.IsNullOrEmpty(setupIntentId) + ? await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions + { + Expand = ["payment_method"] + }) + : null; + + var usePaymentMethod = + requireProviderPaymentMethodDuringSetup && + (!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) || + customer.Metadata.ContainsKey(BraintreeCustomerIdKey) || + setupIntent.IsUnverifiedBankAccount()); + var subscriptionCreateOptions = new SubscriptionCreateOptions { - CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, + CollectionMethod = usePaymentMethod ? + StripeConstants.CollectionMethod.ChargeAutomatically : StripeConstants.CollectionMethod.SendInvoice, Customer = customer.Id, - DaysUntilDue = 30, + DaysUntilDue = usePaymentMethod ? null : 30, Items = subscriptionItemOptionsList, Metadata = new Dictionary { { "providerId", provider.Id.ToString() } }, OffSession = true, - ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations + ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, + TrialPeriodDays = usePaymentMethod ? 14 : null }; if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) @@ -607,7 +723,10 @@ public class ProviderBillingService( { var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); - if (subscription.Status == StripeConstants.SubscriptionStatus.Active) + if (subscription is + { + Status: StripeConstants.SubscriptionStatus.Active or StripeConstants.SubscriptionStatus.Trialing + }) { return subscription; } diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs index d9a7d4a2ce..7e8857e5d7 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs @@ -1,9 +1,10 @@ using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Queries.Projects.Interfaces; using Bit.Core.SecretsManager.Repositories; -using Bit.Core.Utilities; +using Bit.Core.Settings; namespace Bit.Commercial.Core.SecretsManager.Queries.Projects; @@ -11,36 +12,43 @@ public class MaxProjectsQuery : IMaxProjectsQuery { private readonly IOrganizationRepository _organizationRepository; private readonly IProjectRepository _projectRepository; + private readonly IGlobalSettings _globalSettings; + private readonly IPricingClient _pricingClient; public MaxProjectsQuery( IOrganizationRepository organizationRepository, - IProjectRepository projectRepository) + IProjectRepository projectRepository, + IGlobalSettings globalSettings, + IPricingClient pricingClient) { _organizationRepository = organizationRepository; _projectRepository = projectRepository; + _globalSettings = globalSettings; + _pricingClient = pricingClient; } public async Task<(short? max, bool? overMax)> GetByOrgIdAsync(Guid organizationId, int projectsToAdd) { + // "MaxProjects" only applies to free 2-person organizations, which can't be self-hosted. + if (_globalSettings.SelfHosted) + { + return (null, null); + } + var org = await _organizationRepository.GetByIdAsync(organizationId); if (org == null) { throw new NotFoundException(); } - // TODO: PRICING -> https://bitwarden.atlassian.net/browse/PM-17122 - var plan = StaticStore.GetPlan(org.PlanType); - if (plan?.SecretsManager == null) + var plan = await _pricingClient.GetPlan(org.PlanType); + + if (plan is not { SecretsManager: not null, Type: PlanType.Free }) { - throw new BadRequestException("Existing plan not found."); + return (null, null); } - if (plan.Type == PlanType.Free) - { - var projects = await _projectRepository.GetProjectCountByOrganizationIdAsync(organizationId); - return ((short? max, bool? overMax))(projects + projectsToAdd > plan.SecretsManager.MaxProjects ? (plan.SecretsManager.MaxProjects, true) : (plan.SecretsManager.MaxProjects, false)); - } - - return (null, null); + var projects = await _projectRepository.GetProjectCountByOrganizationIdAsync(organizationId); + return ((short? max, bool? overMax))(projects + projectsToAdd > plan.SecretsManager.MaxProjects ? (plan.SecretsManager.MaxProjects, true) : (plan.SecretsManager.MaxProjects, false)); } } diff --git a/bitwarden_license/src/Scim/Users/PostUserCommand.cs b/bitwarden_license/src/Scim/Users/PostUserCommand.cs index 46116a46ae..5b4a0c29cd 100644 --- a/bitwarden_license/src/Scim/Users/PostUserCommand.cs +++ b/bitwarden_license/src/Scim/Users/PostUserCommand.cs @@ -6,10 +6,10 @@ using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.Utilities.Commands; using Bit.Core.Billing.Pricing; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Commands; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; @@ -76,9 +76,8 @@ public class PostUserCommand( var invitedOrganizationUserId = result switch { Success success => success.Value.InvitedUser.Id, - Failure failure when failure.Errors - .Any(x => x.Message == NoUsersToInviteError.Code) => (Guid?)null, - Failure failure when failure.Errors.Length != 0 => throw MapToBitException(failure.Errors), + Failure { Error.Message: NoUsersToInviteError.Code } => (Guid?)null, + Failure failure => throw MapToBitException(failure.Error), _ => throw new InvalidOperationException() }; diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs index 48eda094e8..b450bf5d7f 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs @@ -8,6 +8,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Tax.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs index d2d82f47de..c66acfa8ce 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -1,5 +1,6 @@ using Bit.Commercial.Core.AdminConsole.Services; using Bit.Commercial.Core.Test.AdminConsole.AutoFixture; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; @@ -7,6 +8,7 @@ using Bit.Core.AdminConsole.Models.Business.Provider; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Context; @@ -38,7 +40,7 @@ public class ProviderServiceTests public async Task CompleteSetupAsync_UserIdIsInvalid_Throws(SutProvider sutProvider) { var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.CompleteSetupAsync(default, default, default, default)); + () => sutProvider.Sut.CompleteSetupAsync(default, default, default, default, null)); Assert.Contains("Invalid owner.", exception.Message); } @@ -50,12 +52,85 @@ public class ProviderServiceTests userService.GetUserByIdAsync(user.Id).Returns(user); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default)); + () => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default, null)); Assert.Contains("Invalid token.", exception.Message); } [Theory, BitAutoData] - public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo, + public async Task CompleteSetupAsync_InvalidTaxInfo_ThrowsBadRequestException( + User user, + Provider provider, + string key, + TaxInfo taxInfo, + TokenizedPaymentSource tokenizedPaymentSource, + [ProviderUser] ProviderUser providerUser, + SutProvider sutProvider) + { + providerUser.ProviderId = provider.Id; + providerUser.UserId = user.Id; + var userService = sutProvider.GetDependency(); + userService.GetUserByIdAsync(user.Id).Returns(user); + + var providerUserRepository = sutProvider.GetDependency(); + providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser); + + var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName"); + var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector"); + sutProvider.GetDependency().CreateProtector("ProviderServiceDataProtector") + .Returns(protector); + + sutProvider.Create(); + + var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); + + taxInfo.BillingAddressCountry = null; + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource)); + + Assert.Equal("Both address and postal code are required to set up your provider.", exception.Message); + } + + [Theory, BitAutoData] + public async Task CompleteSetupAsync_InvalidTokenizedPaymentSource_ThrowsBadRequestException( + User user, + Provider provider, + string key, + TaxInfo taxInfo, + TokenizedPaymentSource tokenizedPaymentSource, + [ProviderUser] ProviderUser providerUser, + SutProvider sutProvider) + { + providerUser.ProviderId = provider.Id; + providerUser.UserId = user.Id; + var userService = sutProvider.GetDependency(); + userService.GetUserByIdAsync(user.Id).Returns(user); + + var providerUserRepository = sutProvider.GetDependency(); + providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser); + + var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName"); + var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector"); + sutProvider.GetDependency().CreateProtector("ProviderServiceDataProtector") + .Returns(protector); + + sutProvider.Create(); + + var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); + + tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay }; + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource)); + + Assert.Equal("A payment method is required to set up your provider.", exception.Message); + } + + [Theory, BitAutoData] + public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource, [ProviderUser] ProviderUser providerUser, SutProvider sutProvider) { @@ -75,7 +150,7 @@ public class ProviderServiceTests var providerBillingService = sutProvider.GetDependency(); var customer = new Customer { Id = "customer_id" }; - providerBillingService.SetupCustomer(provider, taxInfo).Returns(customer); + providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource).Returns(customer); var subscription = new Subscription { Id = "subscription_id" }; providerBillingService.SetupSubscription(provider).Returns(subscription); @@ -84,7 +159,7 @@ public class ProviderServiceTests var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); - await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo); + await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource); await sutProvider.GetDependency().Received().UpsertAsync(Arg.Is( p => diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs index 2661a0eff6..2199bc4bfe 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs @@ -2,18 +2,22 @@ using System.Net; using Bit.Commercial.Core.Billing; using Bit.Commercial.Core.Billing.Models; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Tax.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -24,11 +28,17 @@ using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; +using Braintree; using CsvHelper; using NSubstitute; +using NSubstitute.ExceptionExtensions; using Stripe; using Xunit; using static Bit.Core.Test.Billing.Utilities; +using Address = Stripe.Address; +using Customer = Stripe.Customer; +using PaymentMethod = Stripe.PaymentMethod; +using Subscription = Stripe.Subscription; namespace Bit.Commercial.Core.Test.Billing; @@ -833,7 +843,7 @@ public class ProviderBillingServiceTests } [Theory, BitAutoData] - public async Task SetupCustomer_Success( + public async Task SetupCustomer_NoPaymentMethod_Success( SutProvider sutProvider, Provider provider, TaxInfo taxInfo) @@ -877,6 +887,301 @@ public class ProviderBillingServiceTests Assert.Equivalent(expected, actual); } + [Theory, BitAutoData] + public async Task SetupCustomer_InvalidRequiredPaymentMethod_ThrowsBillingException( + SutProvider sutProvider, + Provider provider, + TaxInfo taxInfo, + TokenizedPaymentSource tokenizedPaymentSource) + { + provider.Name = "MSP"; + + sutProvider.GetDependency() + .GetStripeTaxCode(Arg.Is( + p => p == taxInfo.BillingAddressCountry), + Arg.Is(p => p == taxInfo.TaxIdNumber)) + .Returns(taxInfo.TaxIdType); + + taxInfo.BillingAddressCountry = "AD"; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); + + tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay }; + + await ThrowsBillingExceptionAsync(() => + sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); + } + + [Theory, BitAutoData] + public async Task SetupCustomer_WithBankAccount_Error_Reverts( + SutProvider sutProvider, + Provider provider, + TaxInfo taxInfo) + { + provider.Name = "MSP"; + + sutProvider.GetDependency() + .GetStripeTaxCode(Arg.Is( + p => p == taxInfo.BillingAddressCountry), + Arg.Is(p => p == taxInfo.TaxIdNumber)) + .Returns(taxInfo.TaxIdType); + + taxInfo.BillingAddressCountry = "AD"; + + var stripeAdapter = sutProvider.GetDependency(); + + var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token"); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); + + stripeAdapter.SetupIntentList(Arg.Is(options => + options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([ + new SetupIntent { Id = "setup_intent_id" } + ]); + + stripeAdapter.CustomerCreateAsync(Arg.Is(o => + o.Address.Country == taxInfo.BillingAddressCountry && + o.Address.PostalCode == taxInfo.BillingAddressPostalCode && + o.Address.Line1 == taxInfo.BillingAddressLine1 && + o.Address.Line2 == taxInfo.BillingAddressLine2 && + o.Address.City == taxInfo.BillingAddressCity && + o.Address.State == taxInfo.BillingAddressState && + o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Email == provider.BillingEmail && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.Metadata["region"] == "" && + o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && + o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + .Throws(); + + sutProvider.GetDependency().Get(provider.Id).Returns("setup_intent_id"); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); + + await sutProvider.GetDependency().Received(1).Set(provider.Id, "setup_intent_id"); + + await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_id", Arg.Is(options => + options.CancellationReason == "abandoned")); + + await sutProvider.GetDependency().Received(1).Remove(provider.Id); + } + + [Theory, BitAutoData] + public async Task SetupCustomer_WithPayPal_Error_Reverts( + SutProvider sutProvider, + Provider provider, + TaxInfo taxInfo) + { + provider.Name = "MSP"; + + sutProvider.GetDependency() + .GetStripeTaxCode(Arg.Is( + p => p == taxInfo.BillingAddressCountry), + Arg.Is(p => p == taxInfo.TaxIdNumber)) + .Returns(taxInfo.TaxIdType); + + taxInfo.BillingAddressCountry = "AD"; + + var stripeAdapter = sutProvider.GetDependency(); + + var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token"); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); + + sutProvider.GetDependency().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token) + .Returns("braintree_customer_id"); + + stripeAdapter.CustomerCreateAsync(Arg.Is(o => + o.Address.Country == taxInfo.BillingAddressCountry && + o.Address.PostalCode == taxInfo.BillingAddressPostalCode && + o.Address.Line1 == taxInfo.BillingAddressLine1 && + o.Address.Line2 == taxInfo.BillingAddressLine2 && + o.Address.City == taxInfo.BillingAddressCity && + o.Address.State == taxInfo.BillingAddressState && + o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Email == provider.BillingEmail && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.Metadata["region"] == "" && + o.Metadata["btCustomerId"] == "braintree_customer_id" && + o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && + o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + .Throws(); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); + + await sutProvider.GetDependency().Customer.Received(1).DeleteAsync("braintree_customer_id"); + } + + [Theory, BitAutoData] + public async Task SetupCustomer_WithBankAccount_Success( + SutProvider sutProvider, + Provider provider, + TaxInfo taxInfo) + { + provider.Name = "MSP"; + + sutProvider.GetDependency() + .GetStripeTaxCode(Arg.Is( + p => p == taxInfo.BillingAddressCountry), + Arg.Is(p => p == taxInfo.TaxIdNumber)) + .Returns(taxInfo.TaxIdType); + + taxInfo.BillingAddressCountry = "AD"; + + var stripeAdapter = sutProvider.GetDependency(); + + var expected = new Customer + { + Id = "customer_id", + Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + }; + + var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token"); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); + + stripeAdapter.SetupIntentList(Arg.Is(options => + options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([ + new SetupIntent { Id = "setup_intent_id" } + ]); + + stripeAdapter.CustomerCreateAsync(Arg.Is(o => + o.Address.Country == taxInfo.BillingAddressCountry && + o.Address.PostalCode == taxInfo.BillingAddressPostalCode && + o.Address.Line1 == taxInfo.BillingAddressLine1 && + o.Address.Line2 == taxInfo.BillingAddressLine2 && + o.Address.City == taxInfo.BillingAddressCity && + o.Address.State == taxInfo.BillingAddressState && + o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Email == provider.BillingEmail && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.Metadata["region"] == "" && + o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && + o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + .Returns(expected); + + var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + + Assert.Equivalent(expected, actual); + + await sutProvider.GetDependency().Received(1).Set(provider.Id, "setup_intent_id"); + } + + [Theory, BitAutoData] + public async Task SetupCustomer_WithPayPal_Success( + SutProvider sutProvider, + Provider provider, + TaxInfo taxInfo) + { + provider.Name = "MSP"; + + sutProvider.GetDependency() + .GetStripeTaxCode(Arg.Is( + p => p == taxInfo.BillingAddressCountry), + Arg.Is(p => p == taxInfo.TaxIdNumber)) + .Returns(taxInfo.TaxIdType); + + taxInfo.BillingAddressCountry = "AD"; + + var stripeAdapter = sutProvider.GetDependency(); + + var expected = new Customer + { + Id = "customer_id", + Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + }; + + var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token"); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); + + sutProvider.GetDependency().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token) + .Returns("braintree_customer_id"); + + stripeAdapter.CustomerCreateAsync(Arg.Is(o => + o.Address.Country == taxInfo.BillingAddressCountry && + o.Address.PostalCode == taxInfo.BillingAddressPostalCode && + o.Address.Line1 == taxInfo.BillingAddressLine1 && + o.Address.Line2 == taxInfo.BillingAddressLine2 && + o.Address.City == taxInfo.BillingAddressCity && + o.Address.State == taxInfo.BillingAddressState && + o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Email == provider.BillingEmail && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.Metadata["region"] == "" && + o.Metadata["btCustomerId"] == "braintree_customer_id" && + o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && + o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + .Returns(expected); + + var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + + Assert.Equivalent(expected, actual); + } + + [Theory, BitAutoData] + public async Task SetupCustomer_WithCard_Success( + SutProvider sutProvider, + Provider provider, + TaxInfo taxInfo) + { + provider.Name = "MSP"; + + sutProvider.GetDependency() + .GetStripeTaxCode(Arg.Is( + p => p == taxInfo.BillingAddressCountry), + Arg.Is(p => p == taxInfo.TaxIdNumber)) + .Returns(taxInfo.TaxIdType); + + taxInfo.BillingAddressCountry = "AD"; + + var stripeAdapter = sutProvider.GetDependency(); + + var expected = new Customer + { + Id = "customer_id", + Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + }; + + var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token"); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); + + stripeAdapter.CustomerCreateAsync(Arg.Is(o => + o.Address.Country == taxInfo.BillingAddressCountry && + o.Address.PostalCode == taxInfo.BillingAddressPostalCode && + o.Address.Line1 == taxInfo.BillingAddressLine1 && + o.Address.Line2 == taxInfo.BillingAddressLine2 && + o.Address.City == taxInfo.BillingAddressCity && + o.Address.State == taxInfo.BillingAddressState && + o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Email == provider.BillingEmail && + o.PaymentMethod == tokenizedPaymentSource.Token && + o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.Metadata["region"] == "" && + o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && + o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + .Returns(expected); + + var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + + Assert.Equivalent(expected, actual); + } + [Theory, BitAutoData] public async Task SetupCustomer_Throws_BadRequestException_WhenTaxIdIsInvalid( SutProvider sutProvider, @@ -1044,7 +1349,7 @@ public class ProviderBillingServiceTests } [Theory, BitAutoData] - public async Task SetupSubscription_Succeeds( + public async Task SetupSubscription_SendInvoice_Succeeds( SutProvider sutProvider, Provider provider) { @@ -1127,6 +1432,303 @@ public class ProviderBillingServiceTests Assert.Equivalent(expected, actual); } + [Theory, BitAutoData] + public async Task SetupSubscription_ChargeAutomatically_HasCard_Succeeds( + SutProvider sutProvider, + Provider provider) + { + provider.Type = ProviderType.Msp; + provider.GatewaySubscriptionId = null; + + var customer = new Customer + { + Id = "customer_id", + InvoiceSettings = new CustomerInvoiceSettings + { + DefaultPaymentMethodId = "pm_123" + }, + Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + }; + + sutProvider.GetDependency() + .GetCustomerOrThrow( + provider, + Arg.Is(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))).Returns(customer); + + var providerPlans = new List + { + new() + { + Id = Guid.NewGuid(), + ProviderId = provider.Id, + PlanType = PlanType.TeamsMonthly, + SeatMinimum = 100, + PurchasedSeats = 0, + AllocatedSeats = 0 + }, + new() + { + Id = Guid.NewGuid(), + ProviderId = provider.Id, + PlanType = PlanType.EnterpriseMonthly, + SeatMinimum = 100, + PurchasedSeats = 0, + AllocatedSeats = 0 + } + }; + + foreach (var plan in providerPlans) + { + sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) + .Returns(StaticStore.GetPlan(plan.PlanType)); + } + + sutProvider.GetDependency().GetByProviderId(provider.Id) + .Returns(providerPlans); + + var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; + + sutProvider.GetDependency() + .When(x => x.SetCreateOptions( + Arg.Is(options => + options.Customer == "customer_id") + , Arg.Is(p => p == customer))) + .Do(x => + { + x.Arg().AutomaticTax = new SubscriptionAutomaticTaxOptions + { + Enabled = true + }; + }); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); + + sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Is( + sub => + sub.AutomaticTax.Enabled == true && + sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically && + sub.Customer == "customer_id" && + sub.DaysUntilDue == null && + sub.Items.Count == 2 && + sub.Items.ElementAt(0).Price == ProviderPriceAdapter.MSP.Active.Teams && + sub.Items.ElementAt(0).Quantity == 100 && + sub.Items.ElementAt(1).Price == ProviderPriceAdapter.MSP.Active.Enterprise && + sub.Items.ElementAt(1).Quantity == 100 && + sub.Metadata["providerId"] == provider.Id.ToString() && + sub.OffSession == true && + sub.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations && + sub.TrialPeriodDays == 14)).Returns(expected); + + var actual = await sutProvider.Sut.SetupSubscription(provider); + + Assert.Equivalent(expected, actual); + } + + [Theory, BitAutoData] + public async Task SetupSubscription_ChargeAutomatically_HasBankAccount_Succeeds( + SutProvider sutProvider, + Provider provider) + { + provider.Type = ProviderType.Msp; + provider.GatewaySubscriptionId = null; + + var customer = new Customer + { + Id = "customer_id", + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary(), + Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + }; + + sutProvider.GetDependency() + .GetCustomerOrThrow( + provider, + Arg.Is(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))).Returns(customer); + + var providerPlans = new List + { + new() + { + Id = Guid.NewGuid(), + ProviderId = provider.Id, + PlanType = PlanType.TeamsMonthly, + SeatMinimum = 100, + PurchasedSeats = 0, + AllocatedSeats = 0 + }, + new() + { + Id = Guid.NewGuid(), + ProviderId = provider.Id, + PlanType = PlanType.EnterpriseMonthly, + SeatMinimum = 100, + PurchasedSeats = 0, + AllocatedSeats = 0 + } + }; + + foreach (var plan in providerPlans) + { + sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) + .Returns(StaticStore.GetPlan(plan.PlanType)); + } + + sutProvider.GetDependency().GetByProviderId(provider.Id) + .Returns(providerPlans); + + var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; + + sutProvider.GetDependency() + .When(x => x.SetCreateOptions( + Arg.Is(options => + options.Customer == "customer_id") + , Arg.Is(p => p == customer))) + .Do(x => + { + x.Arg().AutomaticTax = new SubscriptionAutomaticTaxOptions + { + Enabled = true + }; + }); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); + + const string setupIntentId = "seti_123"; + + sutProvider.GetDependency().Get(provider.Id).Returns(setupIntentId); + + sutProvider.GetDependency().SetupIntentGet(setupIntentId, Arg.Is(options => + options.Expand.Contains("payment_method"))).Returns(new SetupIntent + { + Id = setupIntentId, + Status = "requires_action", + NextAction = new SetupIntentNextAction + { + VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() + }, + PaymentMethod = new PaymentMethod + { + UsBankAccount = new PaymentMethodUsBankAccount() + } + }); + + sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Is( + sub => + sub.AutomaticTax.Enabled == true && + sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically && + sub.Customer == "customer_id" && + sub.DaysUntilDue == null && + sub.Items.Count == 2 && + sub.Items.ElementAt(0).Price == ProviderPriceAdapter.MSP.Active.Teams && + sub.Items.ElementAt(0).Quantity == 100 && + sub.Items.ElementAt(1).Price == ProviderPriceAdapter.MSP.Active.Enterprise && + sub.Items.ElementAt(1).Quantity == 100 && + sub.Metadata["providerId"] == provider.Id.ToString() && + sub.OffSession == true && + sub.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations && + sub.TrialPeriodDays == 14)).Returns(expected); + + var actual = await sutProvider.Sut.SetupSubscription(provider); + + Assert.Equivalent(expected, actual); + } + + [Theory, BitAutoData] + public async Task SetupSubscription_ChargeAutomatically_HasPayPal_Succeeds( + SutProvider sutProvider, + Provider provider) + { + provider.Type = ProviderType.Msp; + provider.GatewaySubscriptionId = null; + + var customer = new Customer + { + Id = "customer_id", + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary + { + ["btCustomerId"] = "braintree_customer_id" + }, + Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + }; + + sutProvider.GetDependency() + .GetCustomerOrThrow( + provider, + Arg.Is(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))).Returns(customer); + + var providerPlans = new List + { + new() + { + Id = Guid.NewGuid(), + ProviderId = provider.Id, + PlanType = PlanType.TeamsMonthly, + SeatMinimum = 100, + PurchasedSeats = 0, + AllocatedSeats = 0 + }, + new() + { + Id = Guid.NewGuid(), + ProviderId = provider.Id, + PlanType = PlanType.EnterpriseMonthly, + SeatMinimum = 100, + PurchasedSeats = 0, + AllocatedSeats = 0 + } + }; + + foreach (var plan in providerPlans) + { + sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) + .Returns(StaticStore.GetPlan(plan.PlanType)); + } + + sutProvider.GetDependency().GetByProviderId(provider.Id) + .Returns(providerPlans); + + var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; + + sutProvider.GetDependency() + .When(x => x.SetCreateOptions( + Arg.Is(options => + options.Customer == "customer_id") + , Arg.Is(p => p == customer))) + .Do(x => + { + x.Arg().AutomaticTax = new SubscriptionAutomaticTaxOptions + { + Enabled = true + }; + }); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); + + sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Is( + sub => + sub.AutomaticTax.Enabled == true && + sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically && + sub.Customer == "customer_id" && + sub.DaysUntilDue == null && + sub.Items.Count == 2 && + sub.Items.ElementAt(0).Price == ProviderPriceAdapter.MSP.Active.Teams && + sub.Items.ElementAt(0).Quantity == 100 && + sub.Items.ElementAt(1).Price == ProviderPriceAdapter.MSP.Active.Enterprise && + sub.Items.ElementAt(1).Quantity == 100 && + sub.Metadata["providerId"] == provider.Id.ToString() && + sub.OffSession == true && + sub.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations && + sub.TrialPeriodDays == 14)).Returns(expected); + + var actual = await sutProvider.Sut.SetupSubscription(provider); + + Assert.Equivalent(expected, actual); + } + #endregion #region UpdateSeatMinimums diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs index 3995fb9de6..0a20b34818 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Tax.Services.Implementations; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Xunit; diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs index 347f5b2128..16ae8f7f2c 100644 --- a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs @@ -1,9 +1,12 @@ using Bit.Commercial.Core.SecretsManager.Queries.Projects; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Settings; +using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -15,11 +18,26 @@ namespace Bit.Commercial.Core.Test.SecretsManager.Queries.Projects; [SutProviderCustomize] public class MaxProjectsQueryTests { + [Theory] + [BitAutoData] + public async Task GetByOrgIdAsync_SelfHosted_ReturnsNulls(SutProvider sutProvider, + Guid organizationId) + { + sutProvider.GetDependency().SelfHosted.Returns(true); + + var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organizationId, 1); + + Assert.Null(max); + Assert.Null(overMax); + } + [Theory] [BitAutoData] public async Task GetByOrgIdAsync_OrganizationIsNull_ThrowsNotFound(SutProvider sutProvider, Guid organizationId) { + sutProvider.GetDependency().SelfHosted.Returns(false); + sutProvider.GetDependency().GetByIdAsync(default).ReturnsNull(); await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetByOrgIdAsync(organizationId, 1)); @@ -28,26 +46,6 @@ public class MaxProjectsQueryTests .GetProjectCountByOrganizationIdAsync(organizationId); } - [Theory] - [BitAutoData(PlanType.FamiliesAnnually2019)] - [BitAutoData(PlanType.Custom)] - [BitAutoData(PlanType.FamiliesAnnually)] - public async Task GetByOrgIdAsync_SmPlanIsNull_ThrowsBadRequest(PlanType planType, - SutProvider sutProvider, Organization organization) - { - organization.PlanType = planType; - sutProvider.GetDependency() - .GetByIdAsync(organization.Id) - .Returns(organization); - - await Assert.ThrowsAsync( - async () => await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1)); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .GetProjectCountByOrganizationIdAsync(organization.Id); - } - [Theory] [BitAutoData(PlanType.TeamsMonthly2019)] [BitAutoData(PlanType.TeamsMonthly2020)] @@ -65,9 +63,14 @@ public class MaxProjectsQueryTests public async Task GetByOrgIdAsync_SmNoneFreePlans_ReturnsNull(PlanType planType, SutProvider sutProvider, Organization organization) { + sutProvider.GetDependency().SelfHosted.Returns(false); + organization.PlanType = planType; sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency().GetPlan(organization.PlanType) + .Returns(StaticStore.GetPlan(organization.PlanType)); + var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1); Assert.Null(limit); @@ -110,6 +113,9 @@ public class MaxProjectsQueryTests sutProvider.GetDependency().GetProjectCountByOrganizationIdAsync(organization.Id) .Returns(projects); + sutProvider.GetDependency().GetPlan(organization.PlanType) + .Returns(StaticStore.GetPlan(organization.PlanType)); + var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, projectsToAdd); Assert.NotNull(max); diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index 1bfbe0a9d7..a21f1ac6b8 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -124,8 +124,20 @@ services: profiles: - servicebus + redis: + image: redis:alpine + container_name: bw-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + command: redis-server --appendonly yes + profiles: + - redis + volumes: mssql_dev_data: postgres_dev_data: mysql_dev_data: rabbitmq_data: + redis_data: diff --git a/global.json b/global.json index 0c1d58f410..d04c13bbb5 100644 --- a/global.json +++ b/global.json @@ -4,6 +4,7 @@ "rollForward": "latestFeature" }, "msbuild-sdks": { - "Microsoft.Build.Traversal": "4.1.0" + "Microsoft.Build.Traversal": "4.1.0", + "Microsoft.Build.Sql": "0.1.9-preview" } } diff --git a/src/Admin/Controllers/UsersController.cs b/src/Admin/Controllers/UsersController.cs index 71be19a041..b85a91719c 100644 --- a/src/Admin/Controllers/UsersController.cs +++ b/src/Admin/Controllers/UsersController.cs @@ -4,7 +4,6 @@ using Bit.Admin.Enums; using Bit.Admin.Models; using Bit.Admin.Services; using Bit.Admin.Utilities; -using Bit.Core; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; @@ -89,7 +88,7 @@ public class UsersController : Controller var ciphers = await _cipherRepository.GetManyByUserIdAsync(id); var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); - var verifiedDomain = await AccountDeprovisioningEnabled(user.Id); + var verifiedDomain = await _userService.IsClaimedByAnyOrganizationAsync(user.Id); return View(UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers, verifiedDomain)); } @@ -106,7 +105,7 @@ public class UsersController : Controller var billingInfo = await _paymentService.GetBillingAsync(user); var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user); var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); - var verifiedDomain = await AccountDeprovisioningEnabled(user.Id); + var verifiedDomain = await _userService.IsClaimedByAnyOrganizationAsync(user.Id); var deviceVerificationRequired = await _userService.ActiveNewDeviceVerificationException(user.Id); return View(new UserEditModel(user, isTwoFactorEnabled, ciphers, billingInfo, billingHistoryInfo, _globalSettings, verifiedDomain, deviceVerificationRequired)); @@ -167,7 +166,6 @@ public class UsersController : Controller [HttpPost] [ValidateAntiForgeryToken] [RequirePermission(Permission.User_NewDeviceException_Edit)] - [RequireFeature(FeatureFlagKeys.NewDeviceVerification)] public async Task ToggleNewDeviceVerification(Guid id) { var user = await _userRepository.GetByIdAsync(id); @@ -179,12 +177,4 @@ public class UsersController : Controller await _userService.ToggleNewDeviceVerificationException(user.Id); return RedirectToAction("Edit", new { id }); } - - // TODO: Feature flag to be removed in PM-14207 - private async Task AccountDeprovisioningEnabled(Guid userId) - { - return _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - ? await _userService.IsClaimedByAnyOrganizationAsync(userId) - : null; - } } diff --git a/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs b/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs index ba00ea6c18..accb9539fa 100644 --- a/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs +++ b/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs @@ -9,7 +9,7 @@ namespace Bit.Api.AdminConsole.Authorization; public static class HttpContextExtensions { public const string NoOrgIdError = - "A route decorated with with '[Authorize]' must include a route value named 'orgId' either through the [Controller] attribute or through a '[Http*]' attribute."; + "A route decorated with with '[Authorize]' must include a route value named 'orgId' or 'organizationId' either through the [Controller] attribute or through a '[Http*]' attribute."; /// /// Returns the result of the callback, caching it in HttpContext.Features for the lifetime of the request. @@ -61,19 +61,27 @@ public static class HttpContextExtensions /// - /// Parses the {orgId} route parameter into a Guid, or throws if the {orgId} is not present or not a valid guid. + /// Parses the {orgId} or {organizationId} route parameter into a Guid, or throws if neither are present or are not valid guids. /// /// /// /// public static Guid GetOrganizationId(this HttpContext httpContext) { - httpContext.GetRouteData().Values.TryGetValue("orgId", out var orgIdParam); - if (orgIdParam == null || !Guid.TryParse(orgIdParam.ToString(), out var orgId)) + var routeValues = httpContext.GetRouteData().Values; + + routeValues.TryGetValue("orgId", out var orgIdParam); + if (orgIdParam != null && Guid.TryParse(orgIdParam.ToString(), out var orgId)) { - throw new InvalidOperationException(NoOrgIdError); + return orgId; } - return orgId; + routeValues.TryGetValue("organizationId", out var organizationIdParam); + if (organizationIdParam != null && Guid.TryParse(organizationIdParam.ToString(), out var organizationId)) + { + return organizationId; + } + + throw new InvalidOperationException(NoOrgIdError); } } diff --git a/src/Api/AdminConsole/Authorization/Requirements/ManageAccountRecoveryRequirement.cs b/src/Api/AdminConsole/Authorization/Requirements/ManageAccountRecoveryRequirement.cs new file mode 100644 index 0000000000..268fee5d95 --- /dev/null +++ b/src/Api/AdminConsole/Authorization/Requirements/ManageAccountRecoveryRequirement.cs @@ -0,0 +1,20 @@ +#nullable enable + +using Bit.Core.Context; +using Bit.Core.Enums; + +namespace Bit.Api.AdminConsole.Authorization.Requirements; + +public class ManageAccountRecoveryRequirement : IOrganizationRequirement +{ + public async Task AuthorizeAsync( + CurrentContextOrganization? organizationClaims, + Func> isProviderUserForOrg) + => organizationClaims switch + { + { Type: OrganizationUserType.Owner } => true, + { Type: OrganizationUserType.Admin } => true, + { Permissions.ManageResetPassword: true } => true, + _ => await isProviderUserForOrg() + }; +} diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 5a714943f0..6b23edf347 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -1,4 +1,5 @@ -using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Authorization.Requirements; +using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Response; @@ -63,6 +64,7 @@ public class OrganizationUsersController : Controller private readonly IPricingClient _pricingClient; private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand; private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; + private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand; public OrganizationUsersController( IOrganizationRepository organizationRepository, @@ -89,7 +91,8 @@ public class OrganizationUsersController : Controller IFeatureService featureService, IPricingClient pricingClient, IConfirmOrganizationUserCommand confirmOrganizationUserCommand, - IRestoreOrganizationUserCommand restoreOrganizationUserCommand) + IRestoreOrganizationUserCommand restoreOrganizationUserCommand, + IInitPendingOrganizationCommand initPendingOrganizationCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -116,6 +119,7 @@ public class OrganizationUsersController : Controller _pricingClient = pricingClient; _confirmOrganizationUserCommand = confirmOrganizationUserCommand; _restoreOrganizationUserCommand = restoreOrganizationUserCommand; + _initPendingOrganizationCommand = initPendingOrganizationCommand; } [HttpGet("{id}")] @@ -159,6 +163,12 @@ public class OrganizationUsersController : Controller [HttpGet("")] public async Task> Get(Guid orgId, bool includeGroups = false, bool includeCollections = false) { + + if (_featureService.IsEnabled(FeatureFlagKeys.SeparateCustomRolePermissions)) + { + return await GetvNextAsync(orgId, includeGroups, includeCollections); + } + var authorized = (await _authorizationService.AuthorizeAsync( User, new OrganizationScope(orgId), OrganizationUserUserDetailsOperations.ReadAll)).Succeeded; if (!authorized) @@ -188,6 +198,37 @@ public class OrganizationUsersController : Controller return new ListResponseModel(responses); } + private async Task> GetvNextAsync(Guid orgId, bool includeGroups = false, bool includeCollections = false) + { + var request = new OrganizationUserUserDetailsQueryRequest + { + OrganizationId = orgId, + IncludeGroups = includeGroups, + IncludeCollections = includeCollections, + }; + + if ((await _authorizationService.AuthorizeAsync(User, new ManageUsersRequirement())).Succeeded) + { + return GetResultListResponseModel(await _organizationUserUserDetailsQuery.Get(request)); + } + + if ((await _authorizationService.AuthorizeAsync(User, new ManageAccountRecoveryRequirement())).Succeeded) + { + return GetResultListResponseModel(await _organizationUserUserDetailsQuery.GetAccountRecoveryEnrolledUsers(request)); + } + + throw new NotFoundException(); + } + + private ListResponseModel GetResultListResponseModel(IEnumerable<(OrganizationUserUserDetails OrgUser, + bool TwoFactorEnabled, bool ClaimedByOrganization)> results) + { + return new ListResponseModel(results + .Select(result => new OrganizationUserUserDetailsResponseModel(result)) + .ToList()); + } + + [HttpGet("{id}/groups")] public async Task> GetGroups(string orgId, string id) { @@ -313,7 +354,7 @@ public class OrganizationUsersController : Controller throw new UnauthorizedAccessException(); } - await _organizationService.InitPendingOrganization(user, orgId, organizationUserId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName, model.Token); + await _initPendingOrganizationCommand.InitPendingOrganizationAsync(user, orgId, organizationUserId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName, model.Token); await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService); await _confirmOrganizationUserCommand.ConfirmUserAsync(orgId, organizationUserId, model.Key, user.Id); } @@ -575,7 +616,6 @@ public class OrganizationUsersController : Controller new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage))); } - [RequireFeature(FeatureFlagKeys.AccountDeprovisioning)] [HttpDelete("{id}/delete-account")] [HttpPost("{id}/delete-account")] public async Task DeleteAccount(Guid orgId, Guid id) @@ -594,7 +634,6 @@ public class OrganizationUsersController : Controller await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id); } - [RequireFeature(FeatureFlagKeys.AccountDeprovisioning)] [HttpDelete("delete-account")] [HttpPost("delete-account")] public async Task> BulkDeleteAccount(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) @@ -719,11 +758,6 @@ public class OrganizationUsersController : Controller private async Task> GetClaimedByOrganizationStatusAsync(Guid orgId, IEnumerable userIds) { - if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) - { - return userIds.ToDictionary(kvp => kvp, kvp => false); - } - var usersOrganizationClaimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(orgId, userIds); return usersOrganizationClaimedStatus; } diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index c856c8ab91..f402c927e0 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -279,8 +279,7 @@ public class OrganizationsController : Controller throw new BadRequestException("Your organization's Single Sign-On settings prevent you from leaving."); } - if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - && (await _userService.GetOrganizationsClaimingUserAsync(user.Id)).Any(x => x.Id == id)) + if ((await _userService.GetOrganizationsClaimingUserAsync(user.Id)).Any(x => x.Id == id)) { throw new BadRequestException("Claimed user account cannot leave claiming organization. Contact your organization administrator for additional details."); } diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index 7de6f6e730..86a1609ee6 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -2,7 +2,6 @@ using Bit.Api.AdminConsole.Models.Response.Helpers; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Models.Response; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; @@ -79,7 +78,7 @@ public class PoliciesController : Controller return new PolicyDetailResponseModel(new Policy { Type = (PolicyType)type }); } - if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && policy.Type is PolicyType.SingleOrg) + if (policy.Type is PolicyType.SingleOrg) { return await policy.GetSingleOrgPolicyDetailResponseAsync(_organizationHasVerifiedDomainsQuery); } diff --git a/src/Api/AdminConsole/Controllers/ProvidersController.cs b/src/Api/AdminConsole/Controllers/ProvidersController.cs index be119744b3..b6933da0c9 100644 --- a/src/Api/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Api/AdminConsole/Controllers/ProvidersController.cs @@ -84,22 +84,22 @@ public class ProvidersController : Controller var userId = _userService.GetProperUserId(User).Value; - var taxInfo = model.TaxInfo != null - ? new TaxInfo - { - BillingAddressCountry = model.TaxInfo.Country, - BillingAddressPostalCode = model.TaxInfo.PostalCode, - TaxIdNumber = model.TaxInfo.TaxId, - BillingAddressLine1 = model.TaxInfo.Line1, - BillingAddressLine2 = model.TaxInfo.Line2, - BillingAddressCity = model.TaxInfo.City, - BillingAddressState = model.TaxInfo.State - } - : null; + var taxInfo = new TaxInfo + { + BillingAddressCountry = model.TaxInfo.Country, + BillingAddressPostalCode = model.TaxInfo.PostalCode, + TaxIdNumber = model.TaxInfo.TaxId, + BillingAddressLine1 = model.TaxInfo.Line1, + BillingAddressLine2 = model.TaxInfo.Line2, + BillingAddressCity = model.TaxInfo.City, + BillingAddressState = model.TaxInfo.State + }; + + var tokenizedPaymentSource = model.PaymentSource?.ToDomain(); var response = await _providerService.CompleteSetupAsync(model.ToProvider(provider), userId, model.Token, model.Key, - taxInfo); + taxInfo, tokenizedPaymentSource); return new ProviderResponseModel(response); } diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs index 539260a312..e18122fd2b 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs @@ -75,6 +75,8 @@ public class OrganizationCreateRequestModel : IValidatableObject public string InitiationPath { get; set; } + public bool SkipTrial { get; set; } + public virtual OrganizationSignup ToOrganizationSignup(User user) { var orgSignup = new OrganizationSignup @@ -107,6 +109,7 @@ public class OrganizationCreateRequestModel : IValidatableObject BillingAddressCountry = BillingAddressCountry, }, InitiationPath = InitiationPath, + SkipTrial = SkipTrial }; Keys?.ToOrganizationSignup(orgSignup); diff --git a/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs b/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs index 5e10807c69..697077c9b6 100644 --- a/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; +using Bit.Api.Billing.Models.Requests; using Bit.Api.Models.Request; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Utilities; @@ -23,7 +24,9 @@ public class ProviderSetupRequestModel public string Token { get; set; } [Required] public string Key { get; set; } + [Required] public ExpandedTaxInfoUpdateRequestModel TaxInfo { get; set; } + public TokenizedPaymentSourceRequestBody PaymentSource { get; set; } public virtual Provider ToProvider(Provider provider) { diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs index 4e869f59b1..057841c7d2 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs @@ -126,6 +126,26 @@ public class OrganizationUserUserMiniDetailsResponseModel : ResponseModel public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel { + public OrganizationUserUserDetailsResponseModel((OrganizationUserUserDetails OrgUser, bool TwoFactorEnabled, bool ClaimedByOrganization) data, string obj = "organizationUserUserDetails") + : base(data.OrgUser, obj) + { + if (data.OrgUser == null) + { + throw new ArgumentNullException(nameof(data.OrgUser)); + } + + Name = data.OrgUser.Name; + Email = data.OrgUser.Email; + AvatarColor = data.OrgUser.AvatarColor; + TwoFactorEnabled = data.TwoFactorEnabled; + SsoBound = !string.IsNullOrWhiteSpace(data.OrgUser.SsoExternalId); + Collections = data.OrgUser.Collections.Select(c => new SelectionReadOnlyResponseModel(c)); + Groups = data.OrgUser.Groups; + // Prevent reset password when using key connector. + ResetPasswordEnrolled = ResetPasswordEnrolled && !data.OrgUser.UsesKeyConnector; + ClaimedByOrganization = data.ClaimedByOrganization; + } + public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser, bool twoFactorEnabled, bool claimedByOrganization, string obj = "organizationUserUserDetails") : base(organizationUser, obj) diff --git a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs index c74599a70e..259ce3e795 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs @@ -58,7 +58,8 @@ public class ProfileOrganizationResponseModel : ResponseModel ProviderName = organization.ProviderName; ProviderType = organization.ProviderType; FamilySponsorshipFriendlyName = organization.FamilySponsorshipFriendlyName; - FamilySponsorshipAvailable = FamilySponsorshipFriendlyName == null && + IsAdminInitiated = organization.IsAdminInitiated ?? false; + FamilySponsorshipAvailable = (FamilySponsorshipFriendlyName == null || IsAdminInitiated) && StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise) .UsersCanSponsor(organization); ProductTierType = organization.PlanType.GetProductTier(); @@ -135,7 +136,6 @@ public class ProfileOrganizationResponseModel : ResponseModel public bool AllowAdminAccessToAllCollectionItems { get; set; } /// /// Obsolete. - /// /// See /// [Obsolete("Please use UserIsClaimedByOrganization instead. This property will be removed in a future version.")] @@ -145,16 +145,14 @@ public class ProfileOrganizationResponseModel : ResponseModel set => UserIsClaimedByOrganization = value; } /// - /// Indicates if the organization claims the user. + /// Indicates if the user is claimed by the organization. /// /// - /// An organization claims a user if the user's email domain is verified by the organization and the user is a member of it. + /// A user is claimed by an organization if the user's email domain is verified by the organization and the user is a member. /// The organization must be enabled and able to have verified domains. /// - /// - /// False if the Account Deprovisioning feature flag is disabled. - /// public bool UserIsClaimedByOrganization { get; set; } public bool UseRiskInsights { get; set; } public bool UseAdminSponsoredFamilies { get; set; } + public bool IsAdminInitiated { get; set; } } diff --git a/src/Api/AdminConsole/Public/Controllers/MembersController.cs b/src/Api/AdminConsole/Public/Controllers/MembersController.cs index 92e5071801..6552684ca3 100644 --- a/src/Api/AdminConsole/Public/Controllers/MembersController.cs +++ b/src/Api/AdminConsole/Public/Controllers/MembersController.cs @@ -76,7 +76,7 @@ public class MembersController : Controller { return new NotFoundResult(); } - var response = new MemberResponseModel(orgUser, await _userService.TwoFactorIsEnabledAsync(orgUser), + var response = new MemberResponseModel(orgUser, await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUser), collections); return new JsonResult(response); } @@ -185,7 +185,7 @@ public class MembersController : Controller { var existingUserDetails = await _organizationUserRepository.GetDetailsByIdAsync(id); response = new MemberResponseModel(existingUserDetails, - await _userService.TwoFactorIsEnabledAsync(existingUserDetails), associations); + await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(existingUserDetails), associations); } else { diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj index 6505fdab5b..c490e90150 100644 --- a/src/Api/Api.csproj +++ b/src/Api/Api.csproj @@ -4,8 +4,6 @@ false bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml true - - $(WarningsNotAsErrors);CS8604 diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 621524228a..2499b269f5 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -16,6 +16,7 @@ using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; @@ -45,6 +46,7 @@ public class AccountsController : Controller private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand; private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand; private readonly IRotateUserKeyCommand _rotateUserKeyCommand; + private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IFeatureService _featureService; private readonly IRotationValidator, IEnumerable> _cipherValidator; @@ -68,6 +70,7 @@ public class AccountsController : Controller ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand, ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand, IRotateUserKeyCommand rotateUserKeyCommand, + ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IFeatureService featureService, IRotationValidator, IEnumerable> cipherValidator, IRotationValidator, IEnumerable> folderValidator, @@ -87,6 +90,7 @@ public class AccountsController : Controller _setInitialMasterPasswordCommand = setInitialMasterPasswordCommand; _tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand; _rotateUserKeyCommand = rotateUserKeyCommand; + _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _featureService = featureService; _cipherValidator = cipherValidator; _folderValidator = folderValidator; @@ -389,7 +393,7 @@ public class AccountsController : Controller await _providerUserRepository.GetManyOrganizationDetailsByUserAsync(user.Id, ProviderUserStatusType.Confirmed); - var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); + var twoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user); var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id); @@ -423,7 +427,7 @@ public class AccountsController : Controller await _userService.SaveUserAsync(model.ToUser(user)); - var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); + var twoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user); var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id); @@ -442,7 +446,7 @@ public class AccountsController : Controller } await _userService.SaveUserAsync(model.ToUser(user), true); - var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); + var userTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user); var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id); @@ -514,9 +518,8 @@ public class AccountsController : Controller } else { - // If Account Deprovisioning is enabled, we need to check if the user is claimed by any organization. - if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - && await _userService.IsClaimedByAnyOrganizationAsync(user.Id)) + // Check if the user is claimed by any organization. + if (await _userService.IsClaimedByAnyOrganizationAsync(user.Id)) { throw new BadRequestException("Cannot delete accounts owned by an organization. Contact your organization administrator for additional details."); } @@ -693,7 +696,6 @@ public class AccountsController : Controller } } - [RequireFeature(FeatureFlagKeys.NewDeviceVerification)] [AllowAnonymous] [HttpPost("resend-new-device-otp")] public async Task ResendNewDeviceOtpAsync([FromBody] UnauthenticatedSecretVerificationRequestModel request) diff --git a/src/Api/Billing/Controllers/AccountsBillingController.cs b/src/Api/Billing/Controllers/AccountsBillingController.cs index fcb89226e7..7abcf8c357 100644 --- a/src/Api/Billing/Controllers/AccountsBillingController.cs +++ b/src/Api/Billing/Controllers/AccountsBillingController.cs @@ -1,7 +1,7 @@ #nullable enable using Bit.Api.Billing.Models.Responses; -using Bit.Core.Billing.Models.Api.Requests.Accounts; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Tax.Requests; using Bit.Core.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; diff --git a/src/Api/Billing/Controllers/AccountsController.cs b/src/Api/Billing/Controllers/AccountsController.cs index bc263691a8..49ff679bb8 100644 --- a/src/Api/Billing/Controllers/AccountsController.cs +++ b/src/Api/Billing/Controllers/AccountsController.cs @@ -3,6 +3,7 @@ using Bit.Api.Models.Request; using Bit.Api.Models.Request.Accounts; using Bit.Api.Models.Response; using Bit.Api.Utilities; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Models; using Bit.Core.Billing.Services; using Bit.Core.Context; @@ -22,7 +23,8 @@ namespace Bit.Api.Billing.Controllers; [Route("accounts")] [Authorize("Application")] public class AccountsController( - IUserService userService) : Controller + IUserService userService, + ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery) : Controller { [HttpPost("premium")] public async Task PostPremiumAsync( @@ -56,7 +58,7 @@ public class AccountsController( model.PaymentMethodType!.Value, model.AdditionalStorageGb.GetValueOrDefault(0), license, new TaxInfo { BillingAddressCountry = model.Country, BillingAddressPostalCode = model.PostalCode }); - var userTwoFactorEnabled = await userService.TwoFactorIsEnabledAsync(user); + var userTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); var userHasPremiumFromOrganization = await userService.HasPremiumFromOrganization(user); var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id); diff --git a/src/Api/Billing/Controllers/InvoicesController.cs b/src/Api/Billing/Controllers/InvoicesController.cs index 686d9b9643..5a1d732f42 100644 --- a/src/Api/Billing/Controllers/InvoicesController.cs +++ b/src/Api/Billing/Controllers/InvoicesController.cs @@ -1,5 +1,5 @@ using Bit.Core.AdminConsole.Entities; -using Bit.Core.Billing.Models.Api.Requests.Organizations; +using Bit.Core.Billing.Tax.Requests; using Bit.Core.Context; using Bit.Core.Repositories; using Bit.Core.Services; diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index 1d4ebc1511..3ebae433d8 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -1,12 +1,15 @@ #nullable enable +using System.Diagnostics; using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; +using Bit.Api.Billing.Queries.Organizations; using Bit.Core; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Context; using Bit.Core.Repositories; using Bit.Core.Services; @@ -24,6 +27,7 @@ public class OrganizationBillingController( IFeatureService featureService, IOrganizationBillingService organizationBillingService, IOrganizationRepository organizationRepository, + IOrganizationWarningsQuery organizationWarningsQuery, IPaymentService paymentService, IPricingClient pricingClient, ISubscriberService subscriberService, @@ -290,6 +294,7 @@ public class OrganizationBillingController( sale.SubscriptionSetup.SkipTrial = true; await organizationBillingService.Finalize(sale); var org = await organizationRepository.GetByIdAsync(organizationId); + Debug.Assert(org is not null, "This organization has already been found via this same ID, this should be fine."); if (organizationSignup.PaymentMethodType != null) { var paymentSource = new TokenizedPaymentSource(organizationSignup.PaymentMethodType.Value, organizationSignup.PaymentToken); @@ -335,4 +340,28 @@ public class OrganizationBillingController( return TypedResults.Ok(providerId); } + + [HttpGet("warnings")] + public async Task GetWarningsAsync([FromRoute] Guid organizationId) + { + /* + * We'll keep these available at the User level, because we're hiding any pertinent information and + * we want to throw as few errors as possible since these are not core features. + */ + if (!await currentContext.OrganizationUser(organizationId)) + { + return Error.Unauthorized(); + } + + var organization = await organizationRepository.GetByIdAsync(organizationId); + + if (organization == null) + { + return Error.NotFound(); + } + + var response = await organizationWarningsQuery.Run(organization); + + return TypedResults.Ok(response); + } } diff --git a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs index 67cd691a34..0e04385dc9 100644 --- a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs +++ b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs @@ -1,4 +1,5 @@ using Bit.Api.Models.Request.Organizations; +using Bit.Api.Models.Response; using Bit.Api.Models.Response.Organizations; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces; @@ -8,6 +9,7 @@ using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Models.Api.Request.OrganizationSponsorships; using Bit.Core.Models.Api.Response.OrganizationSponsorships; +using Bit.Core.Models.Data.Organizations.OrganizationSponsorships; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; @@ -105,13 +107,16 @@ public class OrganizationSponsorshipsController : Controller model.FriendlyName, model.IsAdminInitiated.GetValueOrDefault(), model.Notes); - await _sendSponsorshipOfferCommand.SendSponsorshipOfferAsync(sponsorship, sponsoringOrg.Name); + if (sponsorship.OfferedToEmail != null) + { + await _sendSponsorshipOfferCommand.SendSponsorshipOfferAsync(sponsorship, sponsoringOrg.Name); + } } [Authorize("Application")] [HttpPost("{sponsoringOrgId}/families-for-enterprise/resend")] [SelfHosted(NotSelfHostedOnly = true)] - public async Task ResendSponsorshipOffer(Guid sponsoringOrgId) + public async Task ResendSponsorshipOffer(Guid sponsoringOrgId, [FromQuery] string sponsoredFriendlyName) { var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId, PolicyType.FreeFamiliesSponsorshipPolicy); @@ -124,11 +129,14 @@ public class OrganizationSponsorshipsController : Controller var sponsoringOrgUser = await _organizationUserRepository .GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default); - await _sendSponsorshipOfferCommand.SendSponsorshipOfferAsync( - await _organizationRepository.GetByIdAsync(sponsoringOrgId), - sponsoringOrgUser, - await _organizationSponsorshipRepository - .GetBySponsoringOrganizationUserIdAsync(sponsoringOrgUser.Id)); + var sponsorships = await _organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(sponsoringOrgId); + var filteredSponsorship = sponsorships.FirstOrDefault(s => s.FriendlyName != null && s.FriendlyName.Equals(sponsoredFriendlyName, StringComparison.OrdinalIgnoreCase)); + if (filteredSponsorship != null) + { + await _sendSponsorshipOfferCommand.SendSponsorshipOfferAsync( + await _organizationRepository.GetByIdAsync(sponsoringOrgId), + sponsoringOrgUser, filteredSponsorship); + } } [Authorize("Application")] @@ -199,7 +207,7 @@ public class OrganizationSponsorshipsController : Controller [HttpDelete("{sponsoringOrganizationId}")] [HttpPost("{sponsoringOrganizationId}/delete")] [SelfHosted(NotSelfHostedOnly = true)] - public async Task RevokeSponsorship(Guid sponsoringOrganizationId) + public async Task RevokeSponsorship(Guid sponsoringOrganizationId, [FromQuery] bool isAdminInitiated = false) { var orgUser = await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrganizationId, _currentContext.UserId ?? default); @@ -209,7 +217,7 @@ public class OrganizationSponsorshipsController : Controller } var existingOrgSponsorship = await _organizationSponsorshipRepository - .GetBySponsoringOrganizationUserIdAsync(orgUser.Id); + .GetBySponsoringOrganizationUserIdAsync(orgUser.Id, isAdminInitiated); await _revokeSponsorshipCommand.RevokeSponsorshipAsync(existingOrgSponsorship); } @@ -246,5 +254,30 @@ public class OrganizationSponsorshipsController : Controller return new OrganizationSponsorshipSyncStatusResponseModel(lastSyncDate); } + [Authorize("Application")] + [HttpGet("{sponsoringOrgId}/sponsored")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task> GetSponsoredOrganizations(Guid sponsoringOrgId) + { + var sponsoringOrg = await _organizationRepository.GetByIdAsync(sponsoringOrgId); + if (sponsoringOrg == null) + { + throw new NotFoundException(); + } + var organization = _currentContext.Organizations.First(x => x.Id == sponsoringOrg.Id); + if (!await _currentContext.OrganizationOwner(sponsoringOrg.Id) && !await _currentContext.OrganizationAdmin(sponsoringOrg.Id) && !organization.Permissions.ManageUsers) + { + throw new UnauthorizedAccessException(); + } + + var sponsorships = await _organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(sponsoringOrgId); + return new ListResponseModel( + sponsorships + .Where(s => s.IsAdminInitiated) + .Select(s => new OrganizationSponsorshipInvitesResponseModel(new OrganizationSponsorshipData(s))) + ); + + } + private Task CurrentUser => _userService.GetUserByIdAsync(_currentContext.UserId.Value); } diff --git a/src/Api/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index bb1fd7bb25..78e361e8b3 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -6,6 +6,7 @@ using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Context; using Bit.Core.Models.BitStripe; using Bit.Core.Services; diff --git a/src/Api/Billing/Controllers/StripeController.cs b/src/Api/Billing/Controllers/StripeController.cs index f5e8253bfa..15fccd16f4 100644 --- a/src/Api/Billing/Controllers/StripeController.cs +++ b/src/Api/Billing/Controllers/StripeController.cs @@ -1,4 +1,4 @@ -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Tax.Services; using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.HttpResults; diff --git a/src/Api/Billing/Controllers/TaxController.cs b/src/Api/Billing/Controllers/TaxController.cs new file mode 100644 index 0000000000..7b8b9d960f --- /dev/null +++ b/src/Api/Billing/Controllers/TaxController.cs @@ -0,0 +1,36 @@ +using Bit.Api.Billing.Models.Requests; +using Bit.Core.Billing.Tax.Commands; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Billing.Controllers; + +[Authorize("Application")] +[Route("tax")] +public class TaxController( + IPreviewTaxAmountCommand previewTaxAmountCommand) : BaseBillingController +{ + [HttpPost("preview-amount/organization-trial")] + public async Task PreviewTaxAmountForOrganizationTrialAsync( + [FromBody] PreviewTaxAmountForOrganizationTrialRequestBody requestBody) + { + var parameters = new OrganizationTrialParameters + { + PlanType = requestBody.PlanType, + ProductType = requestBody.ProductType, + TaxInformation = new OrganizationTrialParameters.TaxInformationDTO + { + Country = requestBody.TaxInformation.Country, + PostalCode = requestBody.TaxInformation.PostalCode, + TaxId = requestBody.TaxInformation.TaxId + } + }; + + var result = await previewTaxAmountCommand.Run(parameters); + + return result.Match( + taxAmount => TypedResults.Ok(new { TaxAmount = taxAmount }), + badRequest => Error.BadRequest(badRequest.TranslationKey), + unhandled => Error.ServerError(unhandled.TranslationKey)); + } +} diff --git a/src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs b/src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs new file mode 100644 index 0000000000..a3fda0fd6c --- /dev/null +++ b/src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs @@ -0,0 +1,27 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; +using Bit.Core.Billing.Enums; + +namespace Bit.Api.Billing.Models.Requests; + +public class PreviewTaxAmountForOrganizationTrialRequestBody +{ + [Required] + public PlanType PlanType { get; set; } + + [Required] + public ProductType ProductType { get; set; } + + [Required] public TaxInformationDTO TaxInformation { get; set; } = null!; + + public class TaxInformationDTO + { + [Required] + public string Country { get; set; } = null!; + + [Required] + public string PostalCode { get; set; } = null!; + + public string? TaxId { get; set; } + } +} diff --git a/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs b/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs index 32ba2effb2..edc45ce483 100644 --- a/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs +++ b/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs @@ -1,5 +1,5 @@ using System.ComponentModel.DataAnnotations; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Tax.Models; namespace Bit.Api.Billing.Models.Requests; diff --git a/src/Api/Billing/Models/Responses/Organizations/OrganizationWarningsResponse.cs b/src/Api/Billing/Models/Responses/Organizations/OrganizationWarningsResponse.cs new file mode 100644 index 0000000000..e124bdc318 --- /dev/null +++ b/src/Api/Billing/Models/Responses/Organizations/OrganizationWarningsResponse.cs @@ -0,0 +1,43 @@ +#nullable enable +namespace Bit.Api.Billing.Models.Responses.Organizations; + +public record OrganizationWarningsResponse +{ + public FreeTrialWarning? FreeTrial { get; set; } + public InactiveSubscriptionWarning? InactiveSubscription { get; set; } + public ResellerRenewalWarning? ResellerRenewal { get; set; } + + public record FreeTrialWarning + { + public int RemainingTrialDays { get; set; } + } + + public record InactiveSubscriptionWarning + { + public required string Resolution { get; set; } + } + + public record ResellerRenewalWarning + { + public required string Type { get; set; } + public UpcomingRenewal? Upcoming { get; set; } + public IssuedRenewal? Issued { get; set; } + public PastDueRenewal? PastDue { get; set; } + + public record UpcomingRenewal + { + public required DateTime RenewalDate { get; set; } + } + + public record IssuedRenewal + { + public required DateTime IssuedDate { get; set; } + public required DateTime DueDate { get; set; } + } + + public record PastDueRenewal + { + public required DateTime SuspensionDate { get; set; } + } + } +} diff --git a/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs b/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs index b89c1e9db9..fd248a0a00 100644 --- a/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs +++ b/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs @@ -1,4 +1,5 @@ using Bit.Core.Billing.Models; +using Bit.Core.Billing.Tax.Models; namespace Bit.Api.Billing.Models.Responses; diff --git a/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs b/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs index ea1479c9df..a2c6827314 100644 --- a/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs +++ b/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Tax.Models; using Stripe; namespace Bit.Api.Billing.Models.Responses; diff --git a/src/Api/Billing/Models/Responses/TaxInformationResponse.cs b/src/Api/Billing/Models/Responses/TaxInformationResponse.cs index 02349d74f7..59e4934751 100644 --- a/src/Api/Billing/Models/Responses/TaxInformationResponse.cs +++ b/src/Api/Billing/Models/Responses/TaxInformationResponse.cs @@ -1,4 +1,4 @@ -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Tax.Models; namespace Bit.Api.Billing.Models.Responses; diff --git a/src/Api/Billing/Queries/Organizations/OrganizationWarningsQuery.cs b/src/Api/Billing/Queries/Organizations/OrganizationWarningsQuery.cs new file mode 100644 index 0000000000..f6a0e5b1e6 --- /dev/null +++ b/src/Api/Billing/Queries/Organizations/OrganizationWarningsQuery.cs @@ -0,0 +1,214 @@ +// ReSharper disable InconsistentNaming + +#nullable enable + +using Bit.Api.Billing.Models.Responses.Organizations; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Services; +using Bit.Core.Context; +using Bit.Core.Services; +using Stripe; +using FreeTrialWarning = Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.FreeTrialWarning; +using InactiveSubscriptionWarning = + Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.InactiveSubscriptionWarning; +using ResellerRenewalWarning = + Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.ResellerRenewalWarning; + +namespace Bit.Api.Billing.Queries.Organizations; + +public interface IOrganizationWarningsQuery +{ + Task Run( + Organization organization); +} + +public class OrganizationWarningsQuery( + ICurrentContext currentContext, + IProviderRepository providerRepository, + IStripeAdapter stripeAdapter, + ISubscriberService subscriberService) : IOrganizationWarningsQuery +{ + public async Task Run( + Organization organization) + { + var response = new OrganizationWarningsResponse(); + + var subscription = + await subscriberService.GetSubscription(organization, + new SubscriptionGetOptions { Expand = ["customer", "latest_invoice", "test_clock"] }); + + if (subscription == null) + { + return response; + } + + response.FreeTrial = await GetFreeTrialWarning(organization, subscription); + + var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id); + + response.InactiveSubscription = await GetInactiveSubscriptionWarning(organization, provider, subscription); + + response.ResellerRenewal = await GetResellerRenewalWarning(provider, subscription); + + return response; + } + + private async Task GetFreeTrialWarning( + Organization organization, + Subscription subscription) + { + if (!await currentContext.EditSubscription(organization.Id)) + { + return null; + } + + if (subscription is not + { + Status: StripeConstants.SubscriptionStatus.Trialing, + TrialEnd: not null, + Customer: not null + }) + { + return null; + } + + var customer = subscription.Customer; + + var hasPaymentMethod = + !string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) || + !string.IsNullOrEmpty(customer.DefaultSourceId) || + customer.Metadata.ContainsKey(StripeConstants.MetadataKeys.BraintreeCustomerId); + + if (hasPaymentMethod) + { + return null; + } + + var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow; + + var remainingTrialDays = (int)Math.Ceiling((subscription.TrialEnd.Value - now).TotalDays); + + return new FreeTrialWarning { RemainingTrialDays = remainingTrialDays }; + } + + private async Task GetInactiveSubscriptionWarning( + Organization organization, + Provider? provider, + Subscription subscription) + { + if (organization.Enabled || + subscription.Status is not StripeConstants.SubscriptionStatus.Unpaid + and not StripeConstants.SubscriptionStatus.Canceled) + { + return null; + } + + if (provider != null) + { + return new InactiveSubscriptionWarning { Resolution = "contact_provider" }; + } + + if (await currentContext.OrganizationOwner(organization.Id)) + { + return subscription.Status switch + { + StripeConstants.SubscriptionStatus.Unpaid => new InactiveSubscriptionWarning + { + Resolution = "add_payment_method" + }, + StripeConstants.SubscriptionStatus.Canceled => new InactiveSubscriptionWarning + { + Resolution = "resubscribe" + }, + _ => null + }; + } + + return new InactiveSubscriptionWarning { Resolution = "contact_owner" }; + } + + private async Task GetResellerRenewalWarning( + Provider? provider, + Subscription subscription) + { + if (provider is not + { + Type: ProviderType.Reseller + }) + { + return null; + } + + if (subscription.CollectionMethod != StripeConstants.CollectionMethod.SendInvoice) + { + return null; + } + + var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow; + + // ReSharper disable once ConvertIfStatementToSwitchStatement + if (subscription is + { + Status: StripeConstants.SubscriptionStatus.Trialing or StripeConstants.SubscriptionStatus.Active, + LatestInvoice: null or { Status: StripeConstants.InvoiceStatus.Paid } + } && (subscription.CurrentPeriodEnd - now).TotalDays <= 14) + { + return new ResellerRenewalWarning + { + Type = "upcoming", + Upcoming = new ResellerRenewalWarning.UpcomingRenewal + { + RenewalDate = subscription.CurrentPeriodEnd + } + }; + } + + if (subscription is + { + Status: StripeConstants.SubscriptionStatus.Active, + LatestInvoice: { Status: StripeConstants.InvoiceStatus.Open, DueDate: not null } + } && subscription.LatestInvoice.DueDate > now) + { + return new ResellerRenewalWarning + { + Type = "issued", + Issued = new ResellerRenewalWarning.IssuedRenewal + { + IssuedDate = subscription.LatestInvoice.Created, + DueDate = subscription.LatestInvoice.DueDate.Value + } + }; + } + + // ReSharper disable once InvertIf + if (subscription.Status == StripeConstants.SubscriptionStatus.PastDue) + { + var openInvoices = await stripeAdapter.InvoiceSearchAsync(new InvoiceSearchOptions + { + Query = $"subscription:'{subscription.Id}' status:'open'" + }); + + var earliestOverdueInvoice = openInvoices + .Where(invoice => invoice.DueDate != null && invoice.DueDate < now) + .MinBy(invoice => invoice.Created); + + if (earliestOverdueInvoice != null) + { + return new ResellerRenewalWarning + { + Type = "past_due", + PastDue = new ResellerRenewalWarning.PastDueRenewal + { + SuspensionDate = earliestOverdueInvoice.DueDate!.Value.AddDays(30) + } + }; + } + } + + return null; + } +} diff --git a/src/Api/Billing/Registrations.cs b/src/Api/Billing/Registrations.cs new file mode 100644 index 0000000000..cb92098333 --- /dev/null +++ b/src/Api/Billing/Registrations.cs @@ -0,0 +1,11 @@ +using Bit.Api.Billing.Queries.Organizations; + +namespace Bit.Api.Billing; + +public static class Registrations +{ + public static void AddBillingQueries(this IServiceCollection services) + { + services.AddTransient(); + } +} diff --git a/src/Api/Controllers/PhishingDomainsController.cs b/src/Api/Controllers/PhishingDomainsController.cs new file mode 100644 index 0000000000..f0c1a65648 --- /dev/null +++ b/src/Api/Controllers/PhishingDomainsController.cs @@ -0,0 +1,34 @@ +using Bit.Core; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Controllers; + +[Route("phishing-domains")] +public class PhishingDomainsController(IPhishingDomainRepository phishingDomainRepository, IFeatureService featureService) : Controller +{ + [HttpGet] + public async Task>> GetPhishingDomainsAsync() + { + if (!featureService.IsEnabled(FeatureFlagKeys.PhishingDetection)) + { + return NotFound(); + } + + var domains = await phishingDomainRepository.GetActivePhishingDomainsAsync(); + return Ok(domains); + } + + [HttpGet("checksum")] + public async Task> GetChecksumAsync() + { + if (!featureService.IsEnabled(FeatureFlagKeys.PhishingDetection)) + { + return NotFound(); + } + + var checksum = await phishingDomainRepository.GetCurrentChecksumAsync(); + return Ok(checksum); + } +} diff --git a/src/Api/Tools/Controllers/HibpController.cs b/src/Api/Dirt/Controllers/HibpController.cs similarity index 100% rename from src/Api/Tools/Controllers/HibpController.cs rename to src/Api/Dirt/Controllers/HibpController.cs diff --git a/src/Api/Tools/Controllers/ReportsController.cs b/src/Api/Dirt/Controllers/ReportsController.cs similarity index 100% rename from src/Api/Tools/Controllers/ReportsController.cs rename to src/Api/Dirt/Controllers/ReportsController.cs diff --git a/src/Api/Tools/Models/PasswordHealthReportApplicationModel.cs b/src/Api/Dirt/Models/PasswordHealthReportApplicationModel.cs similarity index 100% rename from src/Api/Tools/Models/PasswordHealthReportApplicationModel.cs rename to src/Api/Dirt/Models/PasswordHealthReportApplicationModel.cs diff --git a/src/Api/Tools/Models/Response/MemberAccessReportModel.cs b/src/Api/Dirt/Models/Response/MemberAccessReportModel.cs similarity index 100% rename from src/Api/Tools/Models/Response/MemberAccessReportModel.cs rename to src/Api/Dirt/Models/Response/MemberAccessReportModel.cs diff --git a/src/Api/Tools/Models/Response/MemberCipherDetailsResponseModel.cs b/src/Api/Dirt/Models/Response/MemberCipherDetailsResponseModel.cs similarity index 82% rename from src/Api/Tools/Models/Response/MemberCipherDetailsResponseModel.cs rename to src/Api/Dirt/Models/Response/MemberCipherDetailsResponseModel.cs index 5c87264c51..d927da8123 100644 --- a/src/Api/Tools/Models/Response/MemberCipherDetailsResponseModel.cs +++ b/src/Api/Dirt/Models/Response/MemberCipherDetailsResponseModel.cs @@ -4,18 +4,20 @@ namespace Bit.Api.Tools.Models.Response; public class MemberCipherDetailsResponseModel { + public Guid? UserGuid { get; set; } public string UserName { get; set; } public string Email { get; set; } public bool UsesKeyConnector { get; set; } /// - /// A distinct list of the cipher ids associated with + /// A distinct list of the cipher ids associated with /// the organization member /// public IEnumerable CipherIds { get; set; } public MemberCipherDetailsResponseModel(MemberAccessCipherDetails memberAccessCipherDetails) { + this.UserGuid = memberAccessCipherDetails.UserGuid; this.UserName = memberAccessCipherDetails.UserName; this.Email = memberAccessCipherDetails.Email; this.UsesKeyConnector = memberAccessCipherDetails.UsesKeyConnector; diff --git a/src/Api/Jobs/JobsHostedService.cs b/src/Api/Jobs/JobsHostedService.cs index acd95a0213..57b827a8be 100644 --- a/src/Api/Jobs/JobsHostedService.cs +++ b/src/Api/Jobs/JobsHostedService.cs @@ -58,6 +58,13 @@ public class JobsHostedService : BaseJobsHostedService .StartNow() .WithCronSchedule("0 0 * * * ?") .Build(); + var updatePhishingDomainsTrigger = TriggerBuilder.Create() + .WithIdentity("UpdatePhishingDomainsTrigger") + .StartNow() + .WithSimpleSchedule(x => x + .WithIntervalInHours(24) + .RepeatForever()) + .Build(); var jobs = new List> @@ -68,6 +75,7 @@ public class JobsHostedService : BaseJobsHostedService new Tuple(typeof(ValidateUsersJob), everyTopOfTheSixthHourTrigger), new Tuple(typeof(ValidateOrganizationsJob), everyTwelfthHourAndThirtyMinutesTrigger), new Tuple(typeof(ValidateOrganizationDomainJob), validateOrganizationDomainTrigger), + new Tuple(typeof(UpdatePhishingDomainsJob), updatePhishingDomainsTrigger), }; if (_globalSettings.SelfHosted && _globalSettings.EnableCloudCommunication) @@ -96,6 +104,7 @@ public class JobsHostedService : BaseJobsHostedService services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); } public static void AddCommercialSecretsManagerJobServices(IServiceCollection services) diff --git a/src/Api/Jobs/UpdatePhishingDomainsJob.cs b/src/Api/Jobs/UpdatePhishingDomainsJob.cs new file mode 100644 index 0000000000..355f2af69b --- /dev/null +++ b/src/Api/Jobs/UpdatePhishingDomainsJob.cs @@ -0,0 +1,97 @@ +using Bit.Core; +using Bit.Core.Jobs; +using Bit.Core.PhishingDomainFeatures.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Quartz; + +namespace Bit.Api.Jobs; + +public class UpdatePhishingDomainsJob : BaseJob +{ + private readonly GlobalSettings _globalSettings; + private readonly IPhishingDomainRepository _phishingDomainRepository; + private readonly ICloudPhishingDomainQuery _cloudPhishingDomainQuery; + private readonly IFeatureService _featureService; + public UpdatePhishingDomainsJob( + GlobalSettings globalSettings, + IPhishingDomainRepository phishingDomainRepository, + ICloudPhishingDomainQuery cloudPhishingDomainQuery, + IFeatureService featureService, + ILogger logger) + : base(logger) + { + _globalSettings = globalSettings; + _phishingDomainRepository = phishingDomainRepository; + _cloudPhishingDomainQuery = cloudPhishingDomainQuery; + _featureService = featureService; + } + + protected override async Task ExecuteJobAsync(IJobExecutionContext context) + { + if (!_featureService.IsEnabled(FeatureFlagKeys.PhishingDetection)) + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. Feature flag is disabled."); + return; + } + + if (string.IsNullOrWhiteSpace(_globalSettings.PhishingDomain?.UpdateUrl)) + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. No URL configured."); + return; + } + + if (_globalSettings.SelfHosted && !_globalSettings.EnableCloudCommunication) + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. Cloud communication is disabled in global settings."); + return; + } + + var remoteChecksum = await _cloudPhishingDomainQuery.GetRemoteChecksumAsync(); + if (string.IsNullOrWhiteSpace(remoteChecksum)) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Could not retrieve remote checksum. Skipping update."); + return; + } + + var currentChecksum = await _phishingDomainRepository.GetCurrentChecksumAsync(); + + if (string.Equals(currentChecksum, remoteChecksum, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation(Constants.BypassFiltersEventId, + "Phishing domains list is up to date (checksum: {Checksum}). Skipping update.", + currentChecksum); + return; + } + + _logger.LogInformation(Constants.BypassFiltersEventId, + "Checksums differ (current: {CurrentChecksum}, remote: {RemoteChecksum}). Fetching updated domains from {Source}.", + currentChecksum, remoteChecksum, _globalSettings.SelfHosted ? "Bitwarden cloud API" : "external source"); + + try + { + var domains = await _cloudPhishingDomainQuery.GetPhishingDomainsAsync(); + if (!domains.Contains("phishing.testcategory.com", StringComparer.OrdinalIgnoreCase)) + { + domains.Add("phishing.testcategory.com"); + } + + if (domains.Count > 0) + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Updating {Count} phishing domains with checksum {Checksum}.", + domains.Count, remoteChecksum); + await _phishingDomainRepository.UpdatePhishingDomainsAsync(domains, remoteChecksum); + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated phishing domains."); + } + else + { + _logger.LogWarning(Constants.BypassFiltersEventId, "No valid domains found in the response. Skipping update."); + } + } + catch (Exception ex) + { + _logger.LogError(Constants.BypassFiltersEventId, ex, "Error updating phishing domains."); + } + } +} diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 2872a5b88b..1cc371ae1b 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -27,6 +27,7 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions; using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; using Bit.Api.Auth.Models.Request.WebAuthn; +using Bit.Api.Billing; using Bit.Core.AdminConsole.Services.NoopImplementations; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Identity.TokenProviders; @@ -182,6 +183,9 @@ public class Startup services.AddBillingOperations(); services.AddReportingServices(); services.AddImportServices(); + services.AddPhishingDomainServices(globalSettings); + + services.AddBillingQueries(); // Authorization Handlers services.AddAuthorizationHandlers(); diff --git a/src/Api/Utilities/CommandResultExtensions.cs b/src/Api/Utilities/CommandResultExtensions.cs deleted file mode 100644 index c7315a0fa0..0000000000 --- a/src/Api/Utilities/CommandResultExtensions.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Bit.Core.Models.Commands; -using Microsoft.AspNetCore.Mvc; - -namespace Bit.Api.Utilities; - -public static class CommandResultExtensions -{ - public static IActionResult MapToActionResult(this CommandResult commandResult) - { - return commandResult switch - { - NoRecordFoundFailure failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status404NotFound }, - BadRequestFailure failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest }, - Failure failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest }, - Success success => new ObjectResult(success.Value) { StatusCode = StatusCodes.Status200OK }, - _ => throw new InvalidOperationException($"Unhandled commandResult type: {commandResult.GetType().Name}") - }; - } - - public static IActionResult MapToActionResult(this CommandResult commandResult) - { - return commandResult switch - { - NoRecordFoundFailure failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status404NotFound }, - BadRequestFailure failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest }, - Failure failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest }, - Success => new ObjectResult(new { }) { StatusCode = StatusCodes.Status200OK }, - _ => throw new InvalidOperationException($"Unhandled commandResult type: {commandResult.GetType().Name}") - }; - } -} diff --git a/src/Api/Utilities/ServiceCollectionExtensions.cs b/src/Api/Utilities/ServiceCollectionExtensions.cs index 4c8589657e..e6a20fe364 100644 --- a/src/Api/Utilities/ServiceCollectionExtensions.cs +++ b/src/Api/Utilities/ServiceCollectionExtensions.cs @@ -3,6 +3,10 @@ using Bit.Api.Tools.Authorization; using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization; using Bit.Core.IdentityServer; +using Bit.Core.PhishingDomainFeatures; +using Bit.Core.PhishingDomainFeatures.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Repositories.Implementations; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Core.Vault.Authorization.SecurityTasks; @@ -109,4 +113,25 @@ public static class ServiceCollectionExtensions services.AddScoped(); } + + public static void AddPhishingDomainServices(this IServiceCollection services, GlobalSettings globalSettings) + { + services.AddHttpClient("PhishingDomains", client => + { + client.DefaultRequestHeaders.Add("User-Agent", globalSettings.SelfHosted ? "Bitwarden Self-Hosted" : "Bitwarden"); + client.Timeout = TimeSpan.FromSeconds(1000); // the source list is very slow + }); + + services.AddSingleton(); + services.AddSingleton(); + + if (globalSettings.SelfHosted) + { + services.AddScoped(); + } + else + { + services.AddScoped(); + } + } } diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 3bdb6c4bf0..02dace894d 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -1086,9 +1086,8 @@ public class CiphersController : Controller throw new BadRequestException(ModelState); } - // If Account Deprovisioning is enabled, we need to check if the user is claimed by any organization. - if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - && await _userService.IsClaimedByAnyOrganizationAsync(user.Id)) + // Check if the user is claimed by any organization. + if (await _userService.IsClaimedByAnyOrganizationAsync(user.Id)) { throw new BadRequestException("Cannot purge accounts owned by an organization. Contact your organization administrator for additional details."); } @@ -1241,6 +1240,20 @@ public class CiphersController : Controller return new CipherMiniResponseModel(cipher, _globalSettings, cipher.OrganizationUseTotp); } + [HttpGet("{id}/attachment/{attachmentId}/admin")] + public async Task GetAttachmentDataAdmin(Guid id, string attachmentId) + { + var cipher = await _cipherRepository.GetOrganizationDetailsByIdAsync(id); + if (cipher == null || !cipher.OrganizationId.HasValue || + !await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id })) + { + throw new NotFoundException(); + } + + var result = await _cipherService.GetAttachmentDownloadDataAsync(cipher, attachmentId); + return new AttachmentResponseModel(result); + } + [HttpGet("{id}/attachment/{attachmentId}")] public async Task GetAttachmentData(Guid id, string attachmentId) { @@ -1287,18 +1300,17 @@ public class CiphersController : Controller [HttpDelete("{id}/attachment/{attachmentId}/admin")] [HttpPost("{id}/attachment/{attachmentId}/delete-admin")] - public async Task DeleteAttachmentAdmin(string id, string attachmentId) + public async Task DeleteAttachmentAdmin(Guid id, string attachmentId) { - var idGuid = new Guid(id); var userId = _userService.GetProperUserId(User).Value; - var cipher = await _cipherRepository.GetByIdAsync(idGuid); + var cipher = await _cipherRepository.GetByIdAsync(id); if (cipher == null || !cipher.OrganizationId.HasValue || !await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id })) { throw new NotFoundException(); } - await _cipherService.DeleteAttachmentAsync(cipher, attachmentId, userId, true); + return await _cipherService.DeleteAttachmentAsync(cipher, attachmentId, userId, true); } [AllowAnonymous] diff --git a/src/Api/Vault/Controllers/SyncController.cs b/src/Api/Vault/Controllers/SyncController.cs index 4b66c7f2bd..568c05d651 100644 --- a/src/Api/Vault/Controllers/SyncController.cs +++ b/src/Api/Vault/Controllers/SyncController.cs @@ -3,6 +3,7 @@ using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -37,6 +38,7 @@ public class SyncController : Controller private readonly Version _sshKeyCipherMinimumVersion = new(Constants.SSHKeyCipherMinimumVersion); private readonly IFeatureService _featureService; private readonly IApplicationCacheService _applicationCacheService; + private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; public SyncController( IUserService userService, @@ -51,7 +53,8 @@ public class SyncController : Controller GlobalSettings globalSettings, ICurrentContext currentContext, IFeatureService featureService, - IApplicationCacheService applicationCacheService) + IApplicationCacheService applicationCacheService, + ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery) { _userService = userService; _folderRepository = folderRepository; @@ -66,6 +69,7 @@ public class SyncController : Controller _currentContext = currentContext; _featureService = featureService; _applicationCacheService = applicationCacheService; + _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; } [HttpGet("")] @@ -102,7 +106,7 @@ public class SyncController : Controller collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key); } - var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); + var userTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user); var organizationClaimingActiveUser = await _userService.GetOrganizationsClaimingUserAsync(user.Id); var organizationIdsClaimingActiveUser = organizationClaimingActiveUser.Select(o => o.Id); diff --git a/src/Api/appsettings.Development.json b/src/Api/appsettings.Development.json index 2f33d87ae8..82fb951261 100644 --- a/src/Api/appsettings.Development.json +++ b/src/Api/appsettings.Development.json @@ -37,6 +37,10 @@ }, "storage": { "connectionString": "UseDevelopmentStorage=true" + }, + "phishingDomain": { + "updateUrl": "https://phish.co.za/latest/phishing-domains-ACTIVE.txt", + "checksumUrl": "https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-domains-ACTIVE.txt.sha256" } } } diff --git a/src/Api/appsettings.json b/src/Api/appsettings.json index 98b210cb1e..f8a69dcfac 100644 --- a/src/Api/appsettings.json +++ b/src/Api/appsettings.json @@ -71,6 +71,9 @@ "accessKeySecret": "SECRET", "region": "SECRET" }, + "phishingDomain": { + "updateUrl": "SECRET" + }, "distributedIpRateLimiting": { "enabled": true, "maxRedisTimeoutsThreshold": 10, diff --git a/src/Billing/Controllers/FreshdeskController.cs b/src/Billing/Controllers/FreshdeskController.cs index 4bf6b7bad4..1fb0fb7ac7 100644 --- a/src/Billing/Controllers/FreshdeskController.cs +++ b/src/Billing/Controllers/FreshdeskController.cs @@ -63,6 +63,12 @@ public class FreshdeskController : Controller note += $"
  • Region: {_billingSettings.FreshDesk.Region}
  • "; var customFields = new Dictionary(); var user = await _userRepository.GetByEmailAsync(ticketContactEmail); + if (user == null) + { + note += $"
  • No user found: {ticketContactEmail}
  • "; + await CreateNote(ticketId, note); + } + if (user != null) { var userLink = $"{_globalSettings.BaseServiceUri.Admin}/users/edit/{user.Id}"; @@ -121,18 +127,7 @@ public class FreshdeskController : Controller Content = JsonContent.Create(updateBody), }; await CallFreshdeskApiAsync(updateRequest); - - var noteBody = new Dictionary - { - { "body", $"
      {note}
    " }, - { "private", true } - }; - var noteRequest = new HttpRequestMessage(HttpMethod.Post, - string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/notes", ticketId)) - { - Content = JsonContent.Create(noteBody), - }; - await CallFreshdeskApiAsync(noteRequest); + await CreateNote(ticketId, note); } return new OkResult(); @@ -208,6 +203,21 @@ public class FreshdeskController : Controller return true; } + private async Task CreateNote(string ticketId, string note) + { + var noteBody = new Dictionary + { + { "body", $"
      {note}
    " }, + { "private", true } + }; + var noteRequest = new HttpRequestMessage(HttpMethod.Post, + string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/notes", ticketId)) + { + Content = JsonContent.Create(noteBody), + }; + await CallFreshdeskApiAsync(noteRequest); + } + private async Task AddAnswerNoteToTicketAsync(string note, string ticketId) { // if there is no content, then we don't need to add a note diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index f75cbf8a8b..4c27098f38 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -4,8 +4,8 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Tax.Services; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; diff --git a/src/Core/AdminConsole/Models/Data/Integrations/IntegrationTemplateContext.cs b/src/Core/AdminConsole/Models/Data/Integrations/IntegrationTemplateContext.cs new file mode 100644 index 0000000000..18aa3b7681 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/Integrations/IntegrationTemplateContext.cs @@ -0,0 +1,37 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Enums; + +#nullable enable + +namespace Bit.Core.Models.Data.Integrations; + +public class IntegrationTemplateContext(EventMessage eventMessage) +{ + public EventMessage Event { get; } = eventMessage; + + public string DomainName => Event.DomainName; + public string IpAddress => Event.IpAddress; + public DeviceType? DeviceType => Event.DeviceType; + public Guid? ActingUserId => Event.ActingUserId; + public Guid? OrganizationUserId => Event.OrganizationUserId; + public DateTime Date => Event.Date; + public EventType Type => Event.Type; + public Guid? UserId => Event.UserId; + public Guid? OrganizationId => Event.OrganizationId; + public Guid? CipherId => Event.CipherId; + public Guid? CollectionId => Event.CollectionId; + public Guid? GroupId => Event.GroupId; + public Guid? PolicyId => Event.PolicyId; + + public User? User { get; set; } + public string? UserName => User?.Name; + public string? UserEmail => User?.Email; + + public User? ActingUser { get; set; } + public string? ActingUserName => ActingUser?.Name; + public string? ActingUserEmail => ActingUser?.Email; + + public Organization? Organization { get; set; } + public string? OrganizationName => Organization?.DisplayName(); +} diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs index 0771457d0a..a804dc0f6a 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs @@ -60,4 +60,5 @@ public class OrganizationUserOrganizationDetails public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool UseRiskInsights { get; set; } public bool UseAdminSponsoredFamilies { get; set; } + public bool? IsAdminInitiated { get; set; } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs index ec635282f7..43a3120ffd 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs @@ -20,7 +20,6 @@ public class VerifyOrganizationDomainCommand( IDnsResolverService dnsResolverService, IEventService eventService, IGlobalSettings globalSettings, - IFeatureService featureService, ICurrentContext currentContext, ISavePolicyCommand savePolicyCommand, IMailService mailService, @@ -125,11 +124,8 @@ public class VerifyOrganizationDomainCommand( private async Task DomainVerificationSideEffectsAsync(OrganizationDomain domain, IActingUser actingUser) { - if (featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) - { - await EnableSingleOrganizationPolicyAsync(domain.OrganizationId, actingUser); - await SendVerifiedDomainUserEmailAsync(domain); - } + await EnableSingleOrganizationPolicyAsync(domain.OrganizationId, actingUser); + await SendVerifiedDomainUserEmailAsync(domain); } private async Task EnableSingleOrganizationPolicyAsync(Guid organizationId, IActingUser actingUser) => diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs index 756bd2ae46..f3426efddc 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; @@ -24,6 +25,7 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand private readonly IPolicyService _policyService; private readonly IMailService _mailService; private readonly IUserRepository _userRepository; + private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; public AcceptOrgUserCommand( @@ -34,6 +36,7 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand IPolicyService policyService, IMailService mailService, IUserRepository userRepository, + ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IDataProtectorTokenFactory orgUserInviteTokenDataFactory) { @@ -45,6 +48,7 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand _policyService = policyService; _mailService = mailService; _userRepository = userRepository; + _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; } @@ -192,7 +196,7 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand } // Enforce Two Factor Authentication Policy of organization user is trying to join - if (!await userService.TwoFactorIsEnabledAsync(user)) + if (!await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user)) { var invitedTwoFactorPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Invited); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs index 302ee0901d..e574d29e48 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs @@ -1,4 +1,5 @@ using Bit.Core.Entities; +using Bit.Core.Exceptions; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IOrganizationUserUserDetailsQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IOrganizationUserUserDetailsQuery.cs index 8494a6d4ca..59162230da 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IOrganizationUserUserDetailsQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IOrganizationUserUserDetailsQuery.cs @@ -6,4 +6,8 @@ namespace Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; public interface IOrganizationUserUserDetailsQuery { Task> GetOrganizationUserUserDetails(OrganizationUserUserDetailsQueryRequest request); + + Task> Get(OrganizationUserUserDetailsQueryRequest request); + + Task> GetAccountRecoveryEnrolledUsers(OrganizationUserUserDetailsQueryRequest request); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRevokeNonCompliantOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRevokeNonCompliantOrganizationUserCommand.cs index c9768a8905..024d56e8c3 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRevokeNonCompliantOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRevokeNonCompliantOrganizationUserCommand.cs @@ -1,5 +1,5 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; -using Bit.Core.Models.Commands; +using Bit.Core.AdminConsole.Utilities.Commands; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/ErrorMapper.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/ErrorMapper.cs index c66d366de5..38fa35b29a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/ErrorMapper.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/ErrorMapper.cs @@ -1,4 +1,4 @@ -using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.Utilities.Errors; using Bit.Core.Exceptions; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/FailedToInviteUsersError.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/FailedToInviteUsersError.cs index 810ef744c9..48faf4cac0 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/FailedToInviteUsersError.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/FailedToInviteUsersError.cs @@ -1,5 +1,5 @@ -using Bit.Core.AdminConsole.Errors; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.Utilities.Errors; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/NoUsersToInviteError.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/NoUsersToInviteError.cs index 52697572e6..8cd70391a2 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/NoUsersToInviteError.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/NoUsersToInviteError.cs @@ -1,5 +1,5 @@ -using Bit.Core.AdminConsole.Errors; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.Utilities.Errors; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/UserAlreadyExistsError.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/UserAlreadyExistsError.cs index 475ad4a886..4fbb8f2bad 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/UserAlreadyExistsError.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/UserAlreadyExistsError.cs @@ -1,5 +1,5 @@ -using Bit.Core.AdminConsole.Errors; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.Utilities.Errors; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IInviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IInviteOrganizationUsersCommand.cs index 3e4c7652a5..7e0a8dc3cd 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IInviteOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IInviteOrganizationUsersCommand.cs @@ -1,5 +1,5 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; -using Bit.Core.Models.Commands; +using Bit.Core.AdminConsole.Utilities.Commands; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs index 662ed314ce..072bc5fc05 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs @@ -1,17 +1,17 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; -using Bit.Core.AdminConsole.Errors; using Bit.Core.AdminConsole.Interfaces; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.AdminConsole.Utilities.Commands; +using Bit.Core.AdminConsole.Utilities.Errors; +using Bit.Core.AdminConsole.Utilities.Validation; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models.Business; -using Bit.Core.Models.Commands; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; @@ -50,11 +50,11 @@ public class InviteOrganizationUsersCommand(IEventService eventService, { case Failure failure: return new Failure( - failure.Errors.Select(error => new Error(error.Message, + new Error(failure.Error.Message, new ScimInviteOrganizationUsersResponse { - InvitedUser = error.ErroredValue.InvitedUsers.FirstOrDefault() - }))); + InvitedUser = failure.Error.ErroredValue.InvitedUsers.FirstOrDefault() + })); case Success success when success.Value.InvitedUsers.Any(): var user = success.Value.InvitedUsers.First(); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/CannotAutoScaleOnSelfHostError.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/CannotAutoScaleOnSelfHostError.cs index 0624ffe027..e7e331686d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/CannotAutoScaleOnSelfHostError.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/CannotAutoScaleOnSelfHostError.cs @@ -1,4 +1,4 @@ -using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.Utilities.Errors; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/InviteUsersEnvironmentValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/InviteUsersEnvironmentValidator.cs index fd0441753a..fb50fd58dd 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/InviteUsersEnvironmentValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/InviteUsersEnvironmentValidator.cs @@ -1,4 +1,4 @@ -using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.AdminConsole.Utilities.Validation; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs index 79a3487d19..54f26cb46a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs @@ -1,7 +1,7 @@ -using Bit.Core.AdminConsole.Errors; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; -using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.AdminConsole.Utilities.Errors; +using Bit.Core.AdminConsole.Utilities.Validation; using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Organization/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Organization/Errors.cs index 5d072ca17d..f9e9f4eebf 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Organization/Errors.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Organization/Errors.cs @@ -1,5 +1,5 @@ -using Bit.Core.AdminConsole.Errors; -using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.Utilities.Errors; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Organization/InviteUsersOrganizationValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Organization/InviteUsersOrganizationValidator.cs index 9e2ca8d9a6..ce617a2db3 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Organization/InviteUsersOrganizationValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Organization/InviteUsersOrganizationValidator.cs @@ -1,5 +1,5 @@ using Bit.Core.AdminConsole.Models.Business; -using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.AdminConsole.Utilities.Validation; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/Errors.cs index 6ff7181456..40afa5e9d0 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/Errors.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/Errors.cs @@ -1,4 +1,4 @@ -using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.Utilities.Errors; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs index 6a8ec8e6d3..a1536ad439 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs @@ -4,7 +4,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.V using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.AdminConsole.Utilities.Validation; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/Errors.cs index c74d1048ad..865a3cb83a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/Errors.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/Errors.cs @@ -1,5 +1,5 @@ -using Bit.Core.AdminConsole.Errors; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; +using Bit.Core.AdminConsole.Utilities.Errors; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/InviteUserPaymentValidation.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/InviteUserPaymentValidation.cs index cc17a673f9..496dddc916 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/InviteUserPaymentValidation.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/InviteUserPaymentValidation.cs @@ -1,6 +1,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments; -using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.AdminConsole.Utilities.Validation; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/Errors.cs index 104ce5cc7e..759ac1b780 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/Errors.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/Errors.cs @@ -1,4 +1,4 @@ -using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.Utilities.Errors; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/InvitingUserOrganizationProviderValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/InvitingUserOrganizationProviderValidator.cs index f84b25f76f..eeb19eec98 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/InvitingUserOrganizationProviderValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/InvitingUserOrganizationProviderValidator.cs @@ -1,5 +1,5 @@ using Bit.Core.AdminConsole.Enums.Provider; -using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.AdminConsole.Utilities.Validation; using Bit.Core.Billing.Extensions; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs index 22fce08021..587e04826b 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs @@ -1,5 +1,9 @@ -using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Utilities; using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; @@ -9,12 +13,21 @@ namespace Core.AdminConsole.OrganizationFeatures.OrganizationUsers; public class OrganizationUserUserDetailsQuery : IOrganizationUserUserDetailsQuery { private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; + private readonly IFeatureService _featureService; + private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery; public OrganizationUserUserDetailsQuery( - IOrganizationUserRepository organizationUserRepository + IOrganizationUserRepository organizationUserRepository, + ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, + IFeatureService featureService, + IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery ) { _organizationUserRepository = organizationUserRepository; + _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; + _featureService = featureService; + _getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery; } /// @@ -37,4 +50,42 @@ public class OrganizationUserUserDetailsQuery : IOrganizationUserUserDetailsQuer return o; }); } + + /// + /// Get the organization user user details, two factor enabled status, and + /// claimed status for the provided request. + /// + /// Request details for the query + /// List of OrganizationUserUserDetails + public async Task> Get(OrganizationUserUserDetailsQueryRequest request) + { + var organizationUsers = await GetOrganizationUserUserDetails(request); + + var organizationUsersTwoFactorEnabled = (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers)).ToDictionary(u => u.user.Id); + var organizationUsersClaimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(request.OrganizationId, organizationUsers.Select(o => o.Id)); + var responses = organizationUsers.Select(o => (o, organizationUsersTwoFactorEnabled[o.Id].twoFactorIsEnabled, organizationUsersClaimedStatus[o.Id])); + + + return responses; + } + + /// + /// Get the organization users user details, two factor enabled status, and + /// claimed status for confirmed users that are enrolled in account recovery + /// + /// Request details for the query + /// List of OrganizationUserUserDetails + public async Task> GetAccountRecoveryEnrolledUsers(OrganizationUserUserDetailsQueryRequest request) + { + var organizationUsers = (await GetOrganizationUserUserDetails(request)) + .Where(o => o.Status.Equals(OrganizationUserStatusType.Confirmed) && o.UsesKeyConnector == false && !String.IsNullOrEmpty(o.ResetPasswordKey)); + + var organizationUsersTwoFactorEnabled = (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers)).ToDictionary(u => u.user.Id); + var organizationUsersClaimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(request.OrganizationId, organizationUsers.Select(o => o.Id)); + var responses = organizationUsers + .Select(o => (o, organizationUsersTwoFactorEnabled[o.Id].twoFactorIsEnabled, organizationUsersClaimedStatus[o.Id])); + + return responses; + } + } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs index 4de2cd0ea5..00d3ebb533 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs @@ -159,7 +159,7 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand throw new BadRequestException(RemoveAdminByCustomUserErrorMessage); } - if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null) + if (deletingUserId.HasValue && eventSystemUser == null) { var claimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(orgUser.OrganizationId, new[] { orgUser.Id }); if (claimedStatus.TryGetValue(orgUser.Id, out var isClaimed) && isClaimed) @@ -214,7 +214,7 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand deletingUserIsOwner = await _currentContext.OrganizationOwner(organizationId); } - var claimedStatus = _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null + var claimedStatus = deletingUserId.HasValue && eventSystemUser == null ? await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, filteredUsers.Select(u => u.Id)) : filteredUsers.ToDictionary(u => u.Id, u => false); var result = new List<(OrganizationUser OrganizationUser, string ErrorMessage)>(); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeNonCompliantOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeNonCompliantOrganizationUserCommand.cs index 971ed02b29..0773cf4f9c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeNonCompliantOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeNonCompliantOrganizationUserCommand.cs @@ -1,8 +1,8 @@ using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; +using Bit.Core.AdminConsole.Utilities.Commands; using Bit.Core.Enums; -using Bit.Core.Models.Commands; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs new file mode 100644 index 0000000000..3e060c66a5 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs @@ -0,0 +1,128 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Tokens; +using Microsoft.AspNetCore.DataProtection; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; + +public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand +{ + + private readonly IOrganizationService _organizationService; + private readonly ICollectionRepository _collectionRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; + private readonly IDataProtector _dataProtector; + private readonly IGlobalSettings _globalSettings; + private readonly IPolicyService _policyService; + private readonly IOrganizationUserRepository _organizationUserRepository; + + public InitPendingOrganizationCommand( + IOrganizationService organizationService, + ICollectionRepository collectionRepository, + IOrganizationRepository organizationRepository, + IDataProtectorTokenFactory orgUserInviteTokenDataFactory, + IDataProtectionProvider dataProtectionProvider, + IGlobalSettings globalSettings, + IPolicyService policyService, + IOrganizationUserRepository organizationUserRepository + ) + { + _organizationService = organizationService; + _collectionRepository = collectionRepository; + _organizationRepository = organizationRepository; + _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; + _dataProtector = dataProtectionProvider.CreateProtector(OrgUserInviteTokenable.DataProtectorPurpose); + _globalSettings = globalSettings; + _policyService = policyService; + _organizationUserRepository = organizationUserRepository; + } + + public async Task InitPendingOrganizationAsync(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken) + { + await ValidateSignUpPoliciesAsync(user.Id); + + var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); + if (orgUser == null) + { + throw new BadRequestException("User invalid."); + } + + var tokenValid = ValidateInviteToken(orgUser, user, emailToken); + + if (!tokenValid) + { + throw new BadRequestException("Invalid token"); + } + + var org = await _organizationRepository.GetByIdAsync(organizationId); + + if (org.Enabled) + { + throw new BadRequestException("Organization is already enabled."); + } + + if (org.Status != OrganizationStatusType.Pending) + { + throw new BadRequestException("Organization is not on a Pending status."); + } + + if (!string.IsNullOrEmpty(org.PublicKey)) + { + throw new BadRequestException("Organization already has a Public Key."); + } + + if (!string.IsNullOrEmpty(org.PrivateKey)) + { + throw new BadRequestException("Organization already has a Private Key."); + } + + org.Enabled = true; + org.Status = OrganizationStatusType.Created; + org.PublicKey = publicKey; + org.PrivateKey = privateKey; + + await _organizationService.UpdateAsync(org); + + if (!string.IsNullOrWhiteSpace(collectionName)) + { + // give the owner Can Manage access over the default collection + List defaultOwnerAccess = + [new CollectionAccessSelection { Id = orgUser.Id, HidePasswords = false, ReadOnly = false, Manage = true }]; + + var defaultCollection = new Collection + { + Name = collectionName, + OrganizationId = org.Id + }; + await _collectionRepository.CreateAsync(defaultCollection, null, defaultOwnerAccess); + } + } + + private async Task ValidateSignUpPoliciesAsync(Guid ownerId) + { + var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg); + if (anySingleOrgPolicies) + { + throw new BadRequestException("You may not create an organization. You belong to an organization " + + "which has a policy that prohibits you from being a member of any other organization."); + } + } + + private bool ValidateInviteToken(OrganizationUser orgUser, User user, string emailToken) + { + var tokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken( + _orgUserInviteTokenDataFactory, emailToken, orgUser); + + return tokenValid; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IInitPendingOrganizationCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IInitPendingOrganizationCommand.cs new file mode 100644 index 0000000000..273182664e --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IInitPendingOrganizationCommand.cs @@ -0,0 +1,13 @@ +using Bit.Core.Entities; +namespace Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; + +public interface IInitPendingOrganizationCommand +{ + /// + /// Update an Organization entry by setting the public/private keys, set it as 'Enabled' and move the Status from 'Pending' to 'Created'. + /// + /// + /// This method must target a disabled Organization that has null keys and status as 'Pending'. + /// + Task InitPendingOrganizationAsync(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs index a37deef3eb..49467eaae4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs @@ -61,16 +61,9 @@ public class SingleOrgPolicyValidator : IPolicyValidator { if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true }) { - if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) - { - var currentUser = _currentContext.UserId ?? Guid.Empty; - var isOwnerOrProvider = await _currentContext.OrganizationOwner(policyUpdate.OrganizationId); - await RevokeNonCompliantUsersAsync(policyUpdate.OrganizationId, policyUpdate.PerformedBy ?? new StandardUser(currentUser, isOwnerOrProvider)); - } - else - { - await RemoveNonCompliantUsersAsync(policyUpdate.OrganizationId); - } + var currentUser = _currentContext.UserId ?? Guid.Empty; + var isOwnerOrProvider = await _currentContext.OrganizationOwner(policyUpdate.OrganizationId); + await RevokeNonCompliantUsersAsync(policyUpdate.OrganizationId, policyUpdate.PerformedBy ?? new StandardUser(currentUser, isOwnerOrProvider)); } } @@ -116,42 +109,6 @@ public class SingleOrgPolicyValidator : IPolicyValidator _mailService.SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(), x.Email))); } - private async Task RemoveNonCompliantUsersAsync(Guid organizationId) - { - // Remove non-compliant users - var savingUserId = _currentContext.UserId; - // Note: must get OrganizationUserUserDetails so that Email is always populated from the User object - var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); - var org = await _organizationRepository.GetByIdAsync(organizationId); - if (org == null) - { - throw new NotFoundException(OrganizationNotFoundErrorMessage); - } - - var removableOrgUsers = orgUsers.Where(ou => - ou.Status != OrganizationUserStatusType.Invited && - ou.Status != OrganizationUserStatusType.Revoked && - ou.Type != OrganizationUserType.Owner && - ou.Type != OrganizationUserType.Admin && - ou.UserId != savingUserId - ).ToList(); - - var userOrgs = await _organizationUserRepository.GetManyByManyUsersAsync( - removableOrgUsers.Select(ou => ou.UserId!.Value)); - foreach (var orgUser in removableOrgUsers) - { - if (userOrgs.Any(ou => ou.UserId == orgUser.UserId - && ou.OrganizationId != org.Id - && ou.Status != OrganizationUserStatusType.Invited)) - { - await _removeOrganizationUserCommand.RemoveUserAsync(organizationId, orgUser.Id, savingUserId); - - await _mailService.SendOrganizationUserRemovedForPolicySingleOrgEmailAsync( - org.DisplayName(), orgUser.Email); - } - } - } - public async Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) { if (policyUpdate is not { Enabled: true }) @@ -165,8 +122,7 @@ public class SingleOrgPolicyValidator : IPolicyValidator return validateDecryptionErrorMessage; } - if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - && await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policyUpdate.OrganizationId)) + if (await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policyUpdate.OrganizationId)) { return ClaimedDomainSingleOrganizationRequiredErrorMessage; } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs index c757a65913..13cc935eb9 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs @@ -23,8 +23,6 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator private readonly IOrganizationRepository _organizationRepository; private readonly ICurrentContext _currentContext; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; - private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; - private readonly IFeatureService _featureService; private readonly IRevokeNonCompliantOrganizationUserCommand _revokeNonCompliantOrganizationUserCommand; public const string NonCompliantMembersWillLoseAccessMessage = "Policy could not be enabled. Non-compliant members will lose access to their accounts. Identify members without two-step login from the policies column in the members page."; @@ -38,8 +36,6 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator IOrganizationRepository organizationRepository, ICurrentContext currentContext, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IRemoveOrganizationUserCommand removeOrganizationUserCommand, - IFeatureService featureService, IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand) { _organizationUserRepository = organizationUserRepository; @@ -47,8 +43,6 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator _organizationRepository = organizationRepository; _currentContext = currentContext; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; - _removeOrganizationUserCommand = removeOrganizationUserCommand; - _featureService = featureService; _revokeNonCompliantOrganizationUserCommand = revokeNonCompliantOrganizationUserCommand; } @@ -56,16 +50,9 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator { if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true }) { - if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) - { - var currentUser = _currentContext.UserId ?? Guid.Empty; - var isOwnerOrProvider = await _currentContext.OrganizationOwner(policyUpdate.OrganizationId); - await RevokeNonCompliantUsersAsync(policyUpdate.OrganizationId, policyUpdate.PerformedBy ?? new StandardUser(currentUser, isOwnerOrProvider)); - } - else - { - await RemoveNonCompliantUsersAsync(policyUpdate.OrganizationId); - } + var currentUser = _currentContext.UserId ?? Guid.Empty; + var isOwnerOrProvider = await _currentContext.OrganizationOwner(policyUpdate.OrganizationId); + await RevokeNonCompliantUsersAsync(policyUpdate.OrganizationId, policyUpdate.PerformedBy ?? new StandardUser(currentUser, isOwnerOrProvider)); } } @@ -121,40 +108,6 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator _mailService.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(), x.Email))); } - private async Task RemoveNonCompliantUsersAsync(Guid organizationId) - { - var org = await _organizationRepository.GetByIdAsync(organizationId); - var savingUserId = _currentContext.UserId; - - var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); - var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers); - var removableOrgUsers = orgUsers.Where(ou => - ou.Status != OrganizationUserStatusType.Invited && ou.Status != OrganizationUserStatusType.Revoked && - ou.Type != OrganizationUserType.Owner && ou.Type != OrganizationUserType.Admin && - ou.UserId != savingUserId); - - // Reorder by HasMasterPassword to prioritize checking users without a master if they have 2FA enabled - foreach (var orgUser in removableOrgUsers.OrderBy(ou => ou.HasMasterPassword)) - { - var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == orgUser.Id) - .twoFactorIsEnabled; - if (!userTwoFactorEnabled) - { - if (!orgUser.HasMasterPassword) - { - throw new BadRequestException( - "Policy could not be enabled. Non-compliant members will lose access to their accounts. Identify members without two-step login from the policies column in the members page."); - } - - await _removeOrganizationUserCommand.RemoveUserAsync(organizationId, orgUser.Id, - savingUserId); - - await _mailService.SendOrganizationUserRemovedForPolicyTwoStepEmailAsync( - org!.DisplayName(), orgUser.Email); - } - } - } - private static bool MembersWithNoMasterPasswordWillLoseAccess( IEnumerable orgUserDetails, IEnumerable<(OrganizationUserUserDetails user, bool isTwoFactorEnabled)> organizationUsersTwoFactorEnabled) => diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index 228c2b522c..1e53be734e 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -48,14 +48,8 @@ public interface IOrganizationService Task>> RevokeUsersAsync(Guid organizationId, IEnumerable organizationUserIds, Guid? revokingUserId); Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted); - /// - /// Update an Organization entry by setting the public/private keys, set it as 'Enabled' and move the Status from 'Pending' to 'Created'. - /// - /// - /// This method must target a disabled Organization that has null keys and status as 'Pending'. - /// - Task InitPendingOrganization(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken); Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null); + Task<(bool canScale, string failureReason)> CanScaleAsync(Organization organization, int seatsToAdd); void ValidatePasswordManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade); void ValidateSecretsManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade); diff --git a/src/Core/AdminConsole/Services/IProviderService.cs b/src/Core/AdminConsole/Services/IProviderService.cs index 8999b3cb81..e4b6f3aabd 100644 --- a/src/Core/AdminConsole/Services/IProviderService.cs +++ b/src/Core/AdminConsole/Services/IProviderService.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; +using Bit.Core.Billing.Models; using Bit.Core.Entities; using Bit.Core.Models.Business; @@ -7,7 +8,8 @@ namespace Bit.Core.AdminConsole.Services; public interface IProviderService { - Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo = null); + Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, + TokenizedPaymentSource tokenizedPaymentSource = null); Task UpdateAsync(Provider provider, bool updateBilling = false); Task> InviteUserAsync(ProviderUserInvite invite); diff --git a/src/Core/AdminConsole/Services/Implementations/EventRouteService.cs b/src/Core/AdminConsole/Services/Implementations/EventRouteService.cs new file mode 100644 index 0000000000..a542e75a7b --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventRouteService.cs @@ -0,0 +1,34 @@ +using Bit.Core.Models.Data; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.Services; + +public class EventRouteService( + [FromKeyedServices("broadcast")] IEventWriteService broadcastEventWriteService, + [FromKeyedServices("storage")] IEventWriteService storageEventWriteService, + IFeatureService _featureService) : IEventWriteService +{ + public async Task CreateAsync(IEvent e) + { + if (_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations)) + { + await broadcastEventWriteService.CreateAsync(e); + } + else + { + await storageEventWriteService.CreateAsync(e); + } + } + + public async Task CreateManyAsync(IEnumerable e) + { + if (_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations)) + { + await broadcastEventWriteService.CreateManyAsync(e); + } + else + { + await storageEventWriteService.CreateManyAsync(e); + } + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/IntegrationEventHandlerBase.cs b/src/Core/AdminConsole/Services/Implementations/IntegrationEventHandlerBase.cs new file mode 100644 index 0000000000..d8e521de97 --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/IntegrationEventHandlerBase.cs @@ -0,0 +1,66 @@ +using System.Text.Json.Nodes; +using Bit.Core.AdminConsole.Utilities; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Integrations; +using Bit.Core.Repositories; + +namespace Bit.Core.Services; + +public abstract class IntegrationEventHandlerBase( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationIntegrationConfigurationRepository configurationRepository) + : IEventMessageHandler +{ + public async Task HandleEventAsync(EventMessage eventMessage) + { + var organizationId = eventMessage.OrganizationId ?? Guid.Empty; + var configurations = await configurationRepository.GetConfigurationDetailsAsync( + organizationId, + GetIntegrationType(), + eventMessage.Type); + + foreach (var configuration in configurations) + { + var context = await BuildContextAsync(eventMessage, configuration.Template); + var renderedTemplate = IntegrationTemplateProcessor.ReplaceTokens(configuration.Template, context); + + await ProcessEventIntegrationAsync(configuration.MergedConfiguration, renderedTemplate); + } + } + + public async Task HandleManyEventsAsync(IEnumerable eventMessages) + { + foreach (var eventMessage in eventMessages) + { + await HandleEventAsync(eventMessage); + } + } + + private async Task BuildContextAsync(EventMessage eventMessage, string template) + { + var context = new IntegrationTemplateContext(eventMessage); + + if (IntegrationTemplateProcessor.TemplateRequiresUser(template) && eventMessage.UserId.HasValue) + { + context.User = await userRepository.GetByIdAsync(eventMessage.UserId.Value); + } + + if (IntegrationTemplateProcessor.TemplateRequiresActingUser(template) && eventMessage.ActingUserId.HasValue) + { + context.ActingUser = await userRepository.GetByIdAsync(eventMessage.ActingUserId.Value); + } + + if (IntegrationTemplateProcessor.TemplateRequiresOrganization(template) && eventMessage.OrganizationId.HasValue) + { + context.Organization = await organizationRepository.GetByIdAsync(eventMessage.OrganizationId.Value); + } + + return context; + } + + protected abstract IntegrationType GetIntegrationType(); + + protected abstract Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, string renderedTemplate); +} diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationDomainService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationDomainService.cs index 9b99cf71f0..854e486b42 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationDomainService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationDomainService.cs @@ -93,16 +93,8 @@ public class OrganizationDomainService : IOrganizationDomainService //Send email to administrators if (adminEmails.Count > 0) { - if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) - { - await _mailService.SendUnclaimedOrganizationDomainEmailAsync(adminEmails, - domain.OrganizationId.ToString(), domain.DomainName); - } - else - { - await _mailService.SendUnverifiedOrganizationDomainEmailAsync(adminEmails, - domain.OrganizationId.ToString(), domain.DomainName); - } + await _mailService.SendUnclaimedOrganizationDomainEmailAsync(adminEmails, + domain.OrganizationId.ToString(), domain.DomainName); } _logger.LogInformation(Constants.BypassFiltersEventId, "Expired domain: {domainName}", domain.DomainName); diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index b31b43406e..5c7e5e29ed 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -13,7 +13,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; -using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Constants; @@ -31,12 +30,10 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Settings; -using Bit.Core.Tokens; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Services; using Bit.Core.Utilities; -using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Logging; using Stripe; using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite; @@ -77,8 +74,6 @@ public class OrganizationService : IOrganizationService private readonly IPricingClient _pricingClient; private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand; - private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; - private readonly IDataProtector _dataProtector; public OrganizationService( IOrganizationRepository organizationRepository, @@ -112,9 +107,7 @@ public class OrganizationService : IOrganizationService IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, IPricingClient pricingClient, IPolicyRequirementQuery policyRequirementQuery, - ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, - IDataProtectorTokenFactory orgUserInviteTokenDataFactory, - IDataProtectionProvider dataProtectionProvider + ISendOrganizationInvitesCommand sendOrganizationInvitesCommand ) { _organizationRepository = organizationRepository; @@ -149,8 +142,6 @@ public class OrganizationService : IOrganizationService _pricingClient = pricingClient; _policyRequirementQuery = policyRequirementQuery; _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand; - _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; - _dataProtector = dataProtectionProvider.CreateProtector(OrgUserInviteTokenable.DataProtectorPurpose); } public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken, @@ -1067,7 +1058,7 @@ public class OrganizationService : IOrganizationService organization: organization, initOrganization: initOrganization)); - internal async Task<(bool canScale, string failureReason)> CanScaleAsync( + public async Task<(bool canScale, string failureReason)> CanScaleAsync( Organization organization, int seatsToAdd) { @@ -1921,71 +1912,4 @@ public class OrganizationService : IOrganizationService SalesAssistedTrialStarted = salesAssistedTrialStarted, }); } - - public async Task InitPendingOrganization(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken) - { - await ValidateSignUpPoliciesAsync(user.Id); - - var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); - if (orgUser == null) - { - throw new BadRequestException("User invalid."); - } - - // TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete - var newTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken( - _orgUserInviteTokenDataFactory, emailToken, orgUser); - - var tokenValid = newTokenValid || - CoreHelpers.UserInviteTokenIsValid(_dataProtector, emailToken, user.Email, orgUser.Id, - _globalSettings); - - if (!tokenValid) - { - throw new BadRequestException("Invalid token."); - } - - var org = await GetOrgById(organizationId); - - if (org.Enabled) - { - throw new BadRequestException("Organization is already enabled."); - } - - if (org.Status != OrganizationStatusType.Pending) - { - throw new BadRequestException("Organization is not on a Pending status."); - } - - if (!string.IsNullOrEmpty(org.PublicKey)) - { - throw new BadRequestException("Organization already has a Public Key."); - } - - if (!string.IsNullOrEmpty(org.PrivateKey)) - { - throw new BadRequestException("Organization already has a Private Key."); - } - - org.Enabled = true; - org.Status = OrganizationStatusType.Created; - org.PublicKey = publicKey; - org.PrivateKey = privateKey; - - await UpdateAsync(org); - - if (!string.IsNullOrWhiteSpace(collectionName)) - { - // give the owner Can Manage access over the default collection - List defaultOwnerAccess = - [new CollectionAccessSelection { Id = organizationUserId, HidePasswords = false, ReadOnly = false, Manage = true }]; - - var defaultCollection = new Collection - { - Name = collectionName, - OrganizationId = org.Id - }; - await _collectionRepository.CreateAsync(defaultCollection, null, defaultOwnerAccess); - } - } } diff --git a/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs b/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs index c81914b708..3ddecc67f4 100644 --- a/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs @@ -1,46 +1,35 @@ using System.Text.Json; -using Bit.Core.AdminConsole.Utilities; +using System.Text.Json.Nodes; using Bit.Core.Enums; -using Bit.Core.Models.Data; using Bit.Core.Models.Data.Integrations; using Bit.Core.Repositories; +#nullable enable + namespace Bit.Core.Services; public class SlackEventHandler( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, IOrganizationIntegrationConfigurationRepository configurationRepository, ISlackService slackService) - : IEventMessageHandler + : IntegrationEventHandlerBase(userRepository, organizationRepository, configurationRepository) { - public async Task HandleEventAsync(EventMessage eventMessage) + protected override IntegrationType GetIntegrationType() => IntegrationType.Slack; + + protected override async Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, + string renderedTemplate) { - var organizationId = eventMessage.OrganizationId ?? Guid.Empty; - var configurations = await configurationRepository.GetConfigurationDetailsAsync( - organizationId, - IntegrationType.Slack, - eventMessage.Type); - - foreach (var configuration in configurations) + var config = mergedConfiguration.Deserialize(); + if (config is null) { - var config = configuration.MergedConfiguration.Deserialize(); - if (config is null) - { - continue; - } - - await slackService.SendSlackMessageByChannelIdAsync( - config.token, - IntegrationTemplateProcessor.ReplaceTokens(configuration.Template, eventMessage), - config.channelId - ); + return; } - } - public async Task HandleManyEventsAsync(IEnumerable eventMessages) - { - foreach (var eventMessage in eventMessages) - { - await HandleEventAsync(eventMessage); - } + await slackService.SendSlackMessageByChannelIdAsync( + config.token, + renderedTemplate, + config.channelId + ); } } diff --git a/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs b/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs index 1c3b279ee5..ec6924bb3e 100644 --- a/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs @@ -1,8 +1,7 @@ using System.Text; using System.Text.Json; -using Bit.Core.AdminConsole.Utilities; +using System.Text.Json.Nodes; using Bit.Core.Enums; -using Bit.Core.Models.Data; using Bit.Core.Models.Data.Integrations; using Bit.Core.Repositories; @@ -12,46 +11,28 @@ namespace Bit.Core.Services; public class WebhookEventHandler( IHttpClientFactory httpClientFactory, + IUserRepository userRepository, + IOrganizationRepository organizationRepository, IOrganizationIntegrationConfigurationRepository configurationRepository) - : IEventMessageHandler + : IntegrationEventHandlerBase(userRepository, organizationRepository, configurationRepository) { private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); public const string HttpClientName = "WebhookEventHandlerHttpClient"; - public async Task HandleEventAsync(EventMessage eventMessage) + protected override IntegrationType GetIntegrationType() => IntegrationType.Webhook; + + protected override async Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, + string renderedTemplate) { - var organizationId = eventMessage.OrganizationId ?? Guid.Empty; - var configurations = await configurationRepository.GetConfigurationDetailsAsync( - organizationId, - IntegrationType.Webhook, - eventMessage.Type); - - foreach (var configuration in configurations) + var config = mergedConfiguration.Deserialize(); + if (config is null || string.IsNullOrEmpty(config.url)) { - var config = configuration.MergedConfiguration.Deserialize(); - if (config is null || string.IsNullOrEmpty(config.url)) - { - continue; - } - - var content = new StringContent( - IntegrationTemplateProcessor.ReplaceTokens(configuration.Template, eventMessage), - Encoding.UTF8, - "application/json" - ); - var response = await _httpClient.PostAsync( - config.url, - content); - response.EnsureSuccessStatusCode(); + return; } - } - public async Task HandleManyEventsAsync(IEnumerable eventMessages) - { - foreach (var eventMessage in eventMessages) - { - await HandleEventAsync(eventMessage); - } + var content = new StringContent(renderedTemplate, Encoding.UTF8, "application/json"); + var response = await _httpClient.PostAsync(config.url, content); + response.EnsureSuccessStatusCode(); } } diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs b/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs index bd3a757663..94c1096b58 100644 --- a/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs +++ b/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; +using Bit.Core.Billing.Models; using Bit.Core.Entities; using Bit.Core.Models.Business; @@ -7,7 +8,7 @@ namespace Bit.Core.AdminConsole.Services.NoopImplementations; public class NoopProviderService : IProviderService { - public Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo = null) => throw new NotImplementedException(); + public Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null) => throw new NotImplementedException(); public Task UpdateAsync(Provider provider, bool updateBilling = false) => throw new NotImplementedException(); diff --git a/src/Core/AdminConsole/Shared/Validation/ValidationResult.cs b/src/Core/AdminConsole/Shared/Validation/ValidationResult.cs deleted file mode 100644 index ba78601637..0000000000 --- a/src/Core/AdminConsole/Shared/Validation/ValidationResult.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Bit.Core.AdminConsole.Errors; - -namespace Bit.Core.AdminConsole.Shared.Validation; - -public abstract record ValidationResult; - -public record Valid : ValidationResult -{ - public Valid() { } - - public Valid(T Value) - { - this.Value = Value; - } - - public T Value { get; init; } -} - -public record Invalid : ValidationResult -{ - public IEnumerable> Errors { get; init; } = []; - - public string ErrorMessageString => string.Join(" ", Errors.Select(e => e.Message)); - - public Invalid() { } - - public Invalid(Error error) : this([error]) { } - - public Invalid(IEnumerable> errors) - { - Errors = errors; - } -} - -public static class ValidationResultMappers -{ - public static ValidationResult Map(this ValidationResult validationResult, B invalidValue) => - validationResult switch - { - Valid => new Valid(invalidValue), - Invalid invalid => new Invalid(invalid.Errors.Select(x => x.ToError(invalidValue))), - _ => throw new ArgumentOutOfRangeException(nameof(validationResult), "Unhandled validation result type") - }; -} diff --git a/src/Core/AdminConsole/Utilities/Commands/CommandResult.cs b/src/Core/AdminConsole/Utilities/Commands/CommandResult.cs new file mode 100644 index 0000000000..274b1a8ba5 --- /dev/null +++ b/src/Core/AdminConsole/Utilities/Commands/CommandResult.cs @@ -0,0 +1,51 @@ +#nullable enable + +using Bit.Core.AdminConsole.Utilities.Errors; +using Bit.Core.AdminConsole.Utilities.Validation; + +namespace Bit.Core.AdminConsole.Utilities.Commands; + +public abstract class CommandResult; + +public class Success(T value) : CommandResult +{ + public T Value { get; } = value; +} + +public class Failure(Error error) : CommandResult +{ + public Error Error { get; } = error; +} + +public class Partial(IEnumerable successfulItems, IEnumerable> failedItems) + : CommandResult +{ + public IEnumerable Successes { get; } = successfulItems; + public IEnumerable> Failures { get; } = failedItems; +} + +public static class CommandResultExtensions +{ + /// + /// This is to help map between the InvalidT ValidationResult and the FailureT CommandResult types. + /// + /// + /// This is the invalid type from validating the object. + /// This function will map between the two types for the inner ErrorT + /// Invalid object's type + /// Failure object's type + /// + public static CommandResult MapToFailure(this Invalid invalidResult, Func mappingFunction) => + new Failure(invalidResult.Error.ToError(mappingFunction(invalidResult.Error.ErroredValue))); +} + +[Obsolete("Use CommandResult instead. This will be removed once old code is updated.")] +public class CommandResult(IEnumerable errors) +{ + public CommandResult(string error) : this([error]) { } + + public bool Success => ErrorMessages.Count == 0; + public bool HasErrors => ErrorMessages.Count > 0; + public List ErrorMessages { get; } = errors.ToList(); + public CommandResult() : this(Array.Empty()) { } +} diff --git a/src/Core/AdminConsole/Errors/Error.cs b/src/Core/AdminConsole/Utilities/Errors/Error.cs similarity index 80% rename from src/Core/AdminConsole/Errors/Error.cs rename to src/Core/AdminConsole/Utilities/Errors/Error.cs index 7ad057d6ed..949c6903a0 100644 --- a/src/Core/AdminConsole/Errors/Error.cs +++ b/src/Core/AdminConsole/Utilities/Errors/Error.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.AdminConsole.Errors; +namespace Bit.Core.AdminConsole.Utilities.Errors; public record Error(string Message, T ErroredValue); diff --git a/src/Core/AdminConsole/Errors/InsufficientPermissionsError.cs b/src/Core/AdminConsole/Utilities/Errors/InsufficientPermissionsError.cs similarity index 83% rename from src/Core/AdminConsole/Errors/InsufficientPermissionsError.cs rename to src/Core/AdminConsole/Utilities/Errors/InsufficientPermissionsError.cs index d04ceba7c9..c1a524fa0b 100644 --- a/src/Core/AdminConsole/Errors/InsufficientPermissionsError.cs +++ b/src/Core/AdminConsole/Utilities/Errors/InsufficientPermissionsError.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.AdminConsole.Errors; +namespace Bit.Core.AdminConsole.Utilities.Errors; public record InsufficientPermissionsError(string Message, T ErroredValue) : Error(Message, ErroredValue) { diff --git a/src/Core/AdminConsole/Errors/InvalidResultTypeError.cs b/src/Core/AdminConsole/Utilities/Errors/InvalidResultTypeError.cs similarity index 71% rename from src/Core/AdminConsole/Errors/InvalidResultTypeError.cs rename to src/Core/AdminConsole/Utilities/Errors/InvalidResultTypeError.cs index 67b5b634fb..f39aea68ce 100644 --- a/src/Core/AdminConsole/Errors/InvalidResultTypeError.cs +++ b/src/Core/AdminConsole/Utilities/Errors/InvalidResultTypeError.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.AdminConsole.Errors; +namespace Bit.Core.AdminConsole.Utilities.Errors; public record InvalidResultTypeError(T Value) : Error(Code, Value) { diff --git a/src/Core/AdminConsole/Errors/RecordNotFoundError.cs b/src/Core/AdminConsole/Utilities/Errors/RecordNotFoundError.cs similarity index 82% rename from src/Core/AdminConsole/Errors/RecordNotFoundError.cs rename to src/Core/AdminConsole/Utilities/Errors/RecordNotFoundError.cs index 25a169efe1..748bb57b5f 100644 --- a/src/Core/AdminConsole/Errors/RecordNotFoundError.cs +++ b/src/Core/AdminConsole/Utilities/Errors/RecordNotFoundError.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.AdminConsole.Errors; +namespace Bit.Core.AdminConsole.Utilities.Errors; public record RecordNotFoundError(string Message, T ErroredValue) : Error(Message, ErroredValue) { diff --git a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs index 178c0348d9..4fb5c15e63 100644 --- a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs +++ b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs @@ -10,8 +10,9 @@ public static partial class IntegrationTemplateProcessor public static string ReplaceTokens(string template, object values) { if (string.IsNullOrEmpty(template) || values == null) + { return template; - + } var type = values.GetType(); return TokenRegex().Replace(template, match => { @@ -20,4 +21,36 @@ public static partial class IntegrationTemplateProcessor return property?.GetValue(values)?.ToString() ?? match.Value; }); } + + public static bool TemplateRequiresUser(string template) + { + if (string.IsNullOrEmpty(template)) + { + return false; + } + + return template.Contains("#UserName#", StringComparison.Ordinal) + || template.Contains("#UserEmail#", StringComparison.Ordinal); + } + + public static bool TemplateRequiresActingUser(string template) + { + if (string.IsNullOrEmpty(template)) + { + return false; + } + + return template.Contains("#ActingUserName#", StringComparison.Ordinal) + || template.Contains("#ActingUserEmail#", StringComparison.Ordinal); + } + + public static bool TemplateRequiresOrganization(string template) + { + if (string.IsNullOrEmpty(template)) + { + return false; + } + + return template.Contains("#OrganizationName#", StringComparison.Ordinal); + } } diff --git a/src/Core/AdminConsole/Shared/Validation/IValidator.cs b/src/Core/AdminConsole/Utilities/Validation/IValidator.cs similarity index 62% rename from src/Core/AdminConsole/Shared/Validation/IValidator.cs rename to src/Core/AdminConsole/Utilities/Validation/IValidator.cs index d90386f00e..1598e4472f 100644 --- a/src/Core/AdminConsole/Shared/Validation/IValidator.cs +++ b/src/Core/AdminConsole/Utilities/Validation/IValidator.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.AdminConsole.Shared.Validation; +namespace Bit.Core.AdminConsole.Utilities.Validation; public interface IValidator { diff --git a/src/Core/AdminConsole/Utilities/Validation/ValidationResult.cs b/src/Core/AdminConsole/Utilities/Validation/ValidationResult.cs new file mode 100644 index 0000000000..c62aa880ec --- /dev/null +++ b/src/Core/AdminConsole/Utilities/Validation/ValidationResult.cs @@ -0,0 +1,20 @@ +using Bit.Core.AdminConsole.Utilities.Errors; + +namespace Bit.Core.AdminConsole.Utilities.Validation; + +public abstract record ValidationResult; + +public record Valid(T Value) : ValidationResult; + +public record Invalid(Error Error) : ValidationResult; + +public static class ValidationResultMappers +{ + public static ValidationResult Map(this ValidationResult validationResult, B invalidValue) => + validationResult switch + { + Valid => new Valid(invalidValue), + Invalid invalid => new Invalid(invalid.Error.ToError(invalidValue)), + _ => throw new ArgumentOutOfRangeException(nameof(validationResult), "Unhandled validation result type") + }; +} diff --git a/src/Core/Auth/Enums/TwoFactorProviderType.cs b/src/Core/Auth/Enums/TwoFactorProviderType.cs index 07a52dc429..c3613785bc 100644 --- a/src/Core/Auth/Enums/TwoFactorProviderType.cs +++ b/src/Core/Auth/Enums/TwoFactorProviderType.cs @@ -6,7 +6,8 @@ public enum TwoFactorProviderType : byte Email = 1, Duo = 2, YubiKey = 3, - U2f = 4, // Deprecated + [Obsolete("Deprecated in favor of WebAuthn.")] + U2f = 4, Remember = 5, OrganizationDuo = 6, WebAuthn = 7, diff --git a/src/Core/Auth/Identity/TokenProviders/AuthenticatorTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/AuthenticatorTokenProvider.cs index 9468e4d571..5a3d9522f3 100644 --- a/src/Core/Auth/Identity/TokenProviders/AuthenticatorTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/AuthenticatorTokenProvider.cs @@ -1,6 +1,5 @@ using Bit.Core.Auth.Enums; using Bit.Core.Entities; -using Bit.Core.Services; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; @@ -12,16 +11,13 @@ public class AuthenticatorTokenProvider : IUserTwoFactorTokenProvider { private const string CacheKeyFormat = "Authenticator_TOTP_{0}_{1}"; - private readonly IServiceProvider _serviceProvider; private readonly IDistributedCache _distributedCache; private readonly DistributedCacheEntryOptions _distributedCacheEntryOptions; public AuthenticatorTokenProvider( - IServiceProvider serviceProvider, [FromKeyedServices("persistent")] IDistributedCache distributedCache) { - _serviceProvider = serviceProvider; _distributedCache = distributedCache; _distributedCacheEntryOptions = new DistributedCacheEntryOptions { @@ -29,15 +25,14 @@ public class AuthenticatorTokenProvider : IUserTwoFactorTokenProvider }; } - public async Task CanGenerateTwoFactorTokenAsync(UserManager manager, User user) + public Task CanGenerateTwoFactorTokenAsync(UserManager manager, User user) { - var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Authenticator); - if (string.IsNullOrWhiteSpace((string)provider?.MetaData["Key"])) + var authenticatorProvider = user.GetTwoFactorProvider(TwoFactorProviderType.Authenticator); + if (string.IsNullOrWhiteSpace((string)authenticatorProvider?.MetaData["Key"])) { - return false; + return Task.FromResult(false); } - return await _serviceProvider.GetRequiredService() - .TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.Authenticator, user); + return Task.FromResult(authenticatorProvider.Enabled); } public Task GenerateAsync(string purpose, UserManager manager, User user) diff --git a/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenProvider.cs index 21311326c0..3f2a44915c 100644 --- a/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenProvider.cs @@ -16,10 +16,11 @@ public class DuoUniversalTokenProvider( IDuoUniversalTokenService duoUniversalTokenService) : IUserTwoFactorTokenProvider { /// - /// We need the IServiceProvider to resolve the IUserService. There is a complex dependency dance - /// occurring between IUserService, which extends the UserManager, and the usage of the - /// UserManager within this class. Trying to resolve the IUserService using the DI pipeline - /// will not allow the server to start and it will hang and give no helpful indication as to the problem. + /// We need the IServiceProvider to resolve the . There is a complex dependency dance + /// occurring between , which extends the , and the usage + /// of the within this class. Trying to resolve the using + /// the DI pipeline will not allow the server to start and it will hang and give no helpful indication as to the + /// problem. /// private readonly IServiceProvider _serviceProvider = serviceProvider; private readonly IDataProtectorTokenFactory _tokenDataFactory = tokenDataFactory; @@ -28,12 +29,13 @@ public class DuoUniversalTokenProvider( public async Task CanGenerateTwoFactorTokenAsync(UserManager manager, User user) { var userService = _serviceProvider.GetRequiredService(); - var provider = await GetDuoTwoFactorProvider(user, userService); - if (provider == null) + var duoUniversalTokenProvider = await GetDuoTwoFactorProvider(user, userService); + if (duoUniversalTokenProvider == null) { return false; } - return await userService.TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.Duo, user); + + return duoUniversalTokenProvider.Enabled; } public async Task GenerateAsync(string purpose, UserManager manager, User user) @@ -57,7 +59,7 @@ public class DuoUniversalTokenProvider( } /// - /// Get the Duo Two Factor Provider for the user if they have access to Duo + /// Get the Duo Two Factor Provider for the user if they have premium access to Duo /// /// Active User /// null or Duo TwoFactorProvider diff --git a/src/Core/Auth/Identity/TokenProviders/EmailTwoFactorTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/EmailTwoFactorTokenProvider.cs index b0ad9bd480..718e44ae5f 100644 --- a/src/Core/Auth/Identity/TokenProviders/EmailTwoFactorTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/EmailTwoFactorTokenProvider.cs @@ -1,7 +1,6 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Entities; -using Bit.Core.Services; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; @@ -10,31 +9,25 @@ namespace Bit.Core.Auth.Identity.TokenProviders; public class EmailTwoFactorTokenProvider : EmailTokenProvider { - private readonly IServiceProvider _serviceProvider; - public EmailTwoFactorTokenProvider( - IServiceProvider serviceProvider, [FromKeyedServices("persistent")] IDistributedCache distributedCache) : base(distributedCache) { - _serviceProvider = serviceProvider; - TokenAlpha = false; TokenNumeric = true; TokenLength = 6; } - public override async Task CanGenerateTwoFactorTokenAsync(UserManager manager, User user) + public override Task CanGenerateTwoFactorTokenAsync(UserManager manager, User user) { - var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Email); - if (!HasProperMetaData(provider)) + var emailTokenProvider = user.GetTwoFactorProvider(TwoFactorProviderType.Email); + if (!HasProperMetaData(emailTokenProvider)) { - return false; + return Task.FromResult(false); } - return await _serviceProvider.GetRequiredService(). - TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.Email, user); + return Task.FromResult(emailTokenProvider.Enabled); } public override Task GenerateAsync(string purpose, UserManager manager, User user) diff --git a/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs index 202ba3a38c..0bf75d0fc3 100644 --- a/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs @@ -25,17 +25,16 @@ public class WebAuthnTokenProvider : IUserTwoFactorTokenProvider _globalSettings = globalSettings; } - public async Task CanGenerateTwoFactorTokenAsync(UserManager manager, User user) + public Task CanGenerateTwoFactorTokenAsync(UserManager manager, User user) { - var userService = _serviceProvider.GetRequiredService(); - var webAuthnProvider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn); + // null check happens in this method if (!HasProperMetaData(webAuthnProvider)) { - return false; + return Task.FromResult(false); } - return await userService.TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.WebAuthn, user); + return Task.FromResult(webAuthnProvider.Enabled); } public async Task GenerateAsync(string purpose, UserManager manager, User user) @@ -81,7 +80,7 @@ public class WebAuthnTokenProvider : IUserTwoFactorTokenProvider var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn); var keys = LoadKeys(provider); - if (!provider.MetaData.ContainsKey("login")) + if (!provider.MetaData.TryGetValue("login", out var value)) { return false; } @@ -89,7 +88,7 @@ public class WebAuthnTokenProvider : IUserTwoFactorTokenProvider var clientResponse = JsonSerializer.Deserialize(token, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - var jsonOptions = provider.MetaData["login"].ToString(); + var jsonOptions = value.ToString(); var options = AssertionOptions.FromJson(jsonOptions); var webAuthCred = keys.Find(k => k.Item2.Descriptor.Id.SequenceEqual(clientResponse.Id)); @@ -126,6 +125,12 @@ public class WebAuthnTokenProvider : IUserTwoFactorTokenProvider } + /// + /// Checks if the provider has proper metadata. + /// This is used to determine if the provider has been properly configured. + /// + /// + /// true if metadata is present; false if empty or null private bool HasProperMetaData(TwoFactorProvider provider) { return provider?.MetaData?.Any() ?? false; diff --git a/src/Core/Auth/Identity/TokenProviders/YubicoOtpTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/YubicoOtpTokenProvider.cs index 9794a51ae9..b33d2fc0c9 100644 --- a/src/Core/Auth/Identity/TokenProviders/YubicoOtpTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/YubicoOtpTokenProvider.cs @@ -23,19 +23,21 @@ public class YubicoOtpTokenProvider : IUserTwoFactorTokenProvider public async Task CanGenerateTwoFactorTokenAsync(UserManager manager, User user) { + // Ensure the user has access to premium var userService = _serviceProvider.GetRequiredService(); if (!await userService.CanAccessPremium(user)) { return false; } - var provider = user.GetTwoFactorProvider(TwoFactorProviderType.YubiKey); - if (!provider?.MetaData.Values.Any(v => !string.IsNullOrWhiteSpace((string)v)) ?? true) + // Check if the user has a YubiKey provider configured + var yubicoProvider = user.GetTwoFactorProvider(TwoFactorProviderType.YubiKey); + if (!yubicoProvider?.MetaData.Values.Any(v => !string.IsNullOrWhiteSpace((string)v)) ?? true) { return false; } - return await userService.TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.YubiKey, user); + return yubicoProvider.Enabled; } public Task GenerateAsync(string purpose, UserManager manager, User user) diff --git a/src/Core/Auth/Identity/UserStore.cs b/src/Core/Auth/Identity/UserStore.cs index 3716d75b6a..41323f05b7 100644 --- a/src/Core/Auth/Identity/UserStore.cs +++ b/src/Core/Auth/Identity/UserStore.cs @@ -1,7 +1,7 @@ -using Bit.Core.Context; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Repositories; -using Bit.Core.Services; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; @@ -167,7 +167,7 @@ public class UserStore : public async Task GetTwoFactorEnabledAsync(User user, CancellationToken cancellationToken) { - return await _serviceProvider.GetRequiredService().TwoFactorIsEnabledAsync(user); + return await _serviceProvider.GetRequiredService().TwoFactorIsEnabledAsync(user); } public Task SetSecurityStampAsync(User user, string stamp, CancellationToken cancellationToken) diff --git a/src/Core/Auth/Models/Api/Request/ICaptchaProtectedModel.cs b/src/Core/Auth/Models/Api/Request/ICaptchaProtectedModel.cs deleted file mode 100644 index 6968a904b0..0000000000 --- a/src/Core/Auth/Models/Api/Request/ICaptchaProtectedModel.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Bit.Core.Auth.Models.Api; - -public interface ICaptchaProtectedModel -{ - string CaptchaResponse { get; set; } -} diff --git a/src/Core/Auth/Models/Business/CaptchaResponse.cs b/src/Core/Auth/Models/Business/CaptchaResponse.cs deleted file mode 100644 index 1a4b039ec0..0000000000 --- a/src/Core/Auth/Models/Business/CaptchaResponse.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Bit.Core.Auth.Models.Business; - -public class CaptchaResponse -{ - public bool Success { get; set; } - public bool MaybeBot { get; set; } - public bool IsBot { get; set; } - public double Score { get; set; } -} diff --git a/src/Core/Auth/Models/Business/Tokenables/HCaptchaTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/HCaptchaTokenable.cs deleted file mode 100644 index 72994563c1..0000000000 --- a/src/Core/Auth/Models/Business/Tokenables/HCaptchaTokenable.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Text.Json.Serialization; -using Bit.Core.Entities; -using Bit.Core.Tokens; - -namespace Bit.Core.Auth.Models.Business.Tokenables; - -public class HCaptchaTokenable : ExpiringTokenable -{ - private const double _tokenLifetimeInHours = (double)5 / 60; // 5 minutes - public const string ClearTextPrefix = "BWCaptchaBypass_"; - public const string DataProtectorPurpose = "CaptchaServiceDataProtector"; - public const string TokenIdentifier = "CaptchaBypassToken"; - - public string Identifier { get; set; } = TokenIdentifier; - public Guid Id { get; set; } - public string Email { get; set; } - - [JsonConstructor] - public HCaptchaTokenable() - { - ExpirationDate = DateTime.UtcNow.AddHours(_tokenLifetimeInHours); - } - - public HCaptchaTokenable(User user) : this() - { - Id = user?.Id ?? default; - Email = user?.Email; - } - - public bool TokenIsValid(User user) - { - if (Id == default || Email == default || user == null) - { - return false; - } - - return Id == user.Id && - Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase); - } - - // Validates deserialized - protected override bool TokenIsValid() => Identifier == TokenIdentifier && Id != default && !string.IsNullOrWhiteSpace(Email); -} diff --git a/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs index 24a74bde07..30687a6a4a 100644 --- a/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs @@ -4,9 +4,10 @@ using Bit.Core.Tokens; namespace Bit.Core.Auth.Models.Business.Tokenables; -// This token just provides a verifiable authN mechanism for the API service -// TwoFactorController.cs SendEmailLogin anonymous endpoint so it cannot be -// used maliciously. +/// +/// This token provides a verifiable authN mechanism for the TwoFactorController.SendEmailLoginAsync +/// anonymous endpoint so it cannot used maliciously. +/// public class SsoEmail2faSessionTokenable : ExpiringTokenable { // Just over 2 min expiration (client expires session after 2 min) diff --git a/src/Core/Auth/Models/ITwoFactorProvidersUser.cs b/src/Core/Auth/Models/ITwoFactorProvidersUser.cs index 5d9ae4b362..f953e4570e 100644 --- a/src/Core/Auth/Models/ITwoFactorProvidersUser.cs +++ b/src/Core/Auth/Models/ITwoFactorProvidersUser.cs @@ -1,10 +1,18 @@ using Bit.Core.Auth.Enums; +using Bit.Core.Services; namespace Bit.Core.Auth.Models; public interface ITwoFactorProvidersUser { string TwoFactorProviders { get; } + /// + /// Get the two factor providers for the user. Currently it can be assumed providers are enabled + /// if they exists in the dictionary. When two factor providers are disabled they are removed + /// from the dictionary. + /// + /// + /// Dictionary of providers with the type enum as the key Dictionary GetTwoFactorProviders(); Guid? GetUserId(); bool GetPremium(); diff --git a/src/Core/Auth/Services/ICaptchaValidationService.cs b/src/Core/Auth/Services/ICaptchaValidationService.cs deleted file mode 100644 index 8547c68f7a..0000000000 --- a/src/Core/Auth/Services/ICaptchaValidationService.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Bit.Core.Auth.Models.Business; -using Bit.Core.Context; -using Bit.Core.Entities; - -namespace Bit.Core.Auth.Services; - -public interface ICaptchaValidationService -{ - string SiteKey { get; } - string SiteKeyResponseKeyName { get; } - bool RequireCaptchaValidation(ICurrentContext currentContext, User user = null); - Task ValidateCaptchaResponseAsync(string captchResponse, string clientIpAddress, - User user = null); - string GenerateCaptchaBypassToken(User user); -} diff --git a/src/Core/Auth/Services/Implementations/HCaptchaValidationService.cs b/src/Core/Auth/Services/Implementations/HCaptchaValidationService.cs deleted file mode 100644 index cdd6c2017e..0000000000 --- a/src/Core/Auth/Services/Implementations/HCaptchaValidationService.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System.Net.Http.Json; -using System.Text.Json.Serialization; -using Bit.Core.Auth.Models.Business; -using Bit.Core.Auth.Models.Business.Tokenables; -using Bit.Core.Context; -using Bit.Core.Entities; -using Bit.Core.Settings; -using Bit.Core.Tokens; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.Auth.Services; - -public class HCaptchaValidationService : ICaptchaValidationService -{ - private readonly ILogger _logger; - private readonly IHttpClientFactory _httpClientFactory; - private readonly GlobalSettings _globalSettings; - private readonly IDataProtectorTokenFactory _tokenizer; - - public HCaptchaValidationService( - ILogger logger, - IHttpClientFactory httpClientFactory, - IDataProtectorTokenFactory tokenizer, - GlobalSettings globalSettings) - { - _logger = logger; - _httpClientFactory = httpClientFactory; - _globalSettings = globalSettings; - _tokenizer = tokenizer; - } - - public string SiteKeyResponseKeyName => "HCaptcha_SiteKey"; - public string SiteKey => _globalSettings.Captcha.HCaptchaSiteKey; - - public string GenerateCaptchaBypassToken(User user) => _tokenizer.Protect(new HCaptchaTokenable(user)); - - public async Task ValidateCaptchaResponseAsync(string captchaResponse, string clientIpAddress, - User user = null) - { - var response = new CaptchaResponse { Success = false }; - if (string.IsNullOrWhiteSpace(captchaResponse)) - { - return response; - } - - if (user != null && ValidateCaptchaBypassToken(captchaResponse, user)) - { - response.Success = true; - return response; - } - - var httpClient = _httpClientFactory.CreateClient("HCaptchaValidationService"); - - var requestMessage = new HttpRequestMessage - { - Method = HttpMethod.Post, - RequestUri = new Uri("https://hcaptcha.com/siteverify"), - Content = new FormUrlEncodedContent(new Dictionary - { - { "response", captchaResponse.TrimStart("hcaptcha|".ToCharArray()) }, - { "secret", _globalSettings.Captcha.HCaptchaSecretKey }, - { "sitekey", SiteKey }, - { "remoteip", clientIpAddress } - }) - }; - - HttpResponseMessage responseMessage; - try - { - responseMessage = await httpClient.SendAsync(requestMessage); - } - catch (Exception e) - { - _logger.LogError(11389, e, "Unable to verify with HCaptcha."); - return response; - } - - if (!responseMessage.IsSuccessStatusCode) - { - return response; - } - - using var hcaptchaResponse = await responseMessage.Content.ReadFromJsonAsync(); - response.Success = hcaptchaResponse.Success; - var score = hcaptchaResponse.Score.GetValueOrDefault(); - response.MaybeBot = score >= _globalSettings.Captcha.MaybeBotScoreThreshold; - response.IsBot = score >= _globalSettings.Captcha.IsBotScoreThreshold; - response.Score = score; - return response; - } - - public bool RequireCaptchaValidation(ICurrentContext currentContext, User user = null) - { - if (user == null) - { - return currentContext.IsBot || _globalSettings.Captcha.ForceCaptchaRequired; - } - - var failedLoginCeiling = _globalSettings.Captcha.MaximumFailedLoginAttempts; - var failedLoginCount = user?.FailedLoginCount ?? 0; - var requireOnCloud = !_globalSettings.SelfHosted && !user.EmailVerified && - user.CreationDate < DateTime.UtcNow.AddHours(-24); - return currentContext.IsBot || - _globalSettings.Captcha.ForceCaptchaRequired || - requireOnCloud || - failedLoginCeiling > 0 && failedLoginCount >= failedLoginCeiling; - } - - private static bool TokenIsValidApiKey(string bypassToken, User user) => - !string.IsNullOrWhiteSpace(bypassToken) && user != null && user.ApiKey == bypassToken; - - private bool TokenIsValidCaptchaBypassToken(string encryptedToken, User user) - { - return _tokenizer.TryUnprotect(encryptedToken, out var data) && - data.Valid && data.TokenIsValid(user); - } - - private bool ValidateCaptchaBypassToken(string bypassToken, User user) => - TokenIsValidApiKey(bypassToken, user) || TokenIsValidCaptchaBypassToken(bypassToken, user); - - public class HCaptchaResponse : IDisposable - { - [JsonPropertyName("success")] - public bool Success { get; set; } - [JsonPropertyName("score")] - public double? Score { get; set; } - [JsonPropertyName("score_reason")] - public List ScoreReason { get; set; } - - public void Dispose() { } - } -} diff --git a/src/Core/Auth/Services/NoopImplementations/NoopCaptchaValidationService.cs b/src/Core/Auth/Services/NoopImplementations/NoopCaptchaValidationService.cs deleted file mode 100644 index 47e1a38567..0000000000 --- a/src/Core/Auth/Services/NoopImplementations/NoopCaptchaValidationService.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Bit.Core.Auth.Models.Business; -using Bit.Core.Context; -using Bit.Core.Entities; - -namespace Bit.Core.Auth.Services; - -public class NoopCaptchaValidationService : ICaptchaValidationService -{ - public string SiteKeyResponseKeyName => null; - public string SiteKey => null; - public bool RequireCaptchaValidation(ICurrentContext currentContext, User user = null) => false; - public string GenerateCaptchaBypassToken(User user) => ""; - public Task ValidateCaptchaResponseAsync(string captchaResponse, string clientIpAddress, - User user = null) - { - return Task.FromResult(new CaptchaResponse { Success = true }); - } -} diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs index 203ef3accb..697c10690c 100644 --- a/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs @@ -2,6 +2,7 @@ namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; + public interface ITwoFactorIsEnabledQuery { /// @@ -16,7 +17,8 @@ public interface ITwoFactorIsEnabledQuery /// The type of user in the list. Must implement . Task> TwoFactorIsEnabledAsync(IEnumerable users) where T : ITwoFactorProvidersUser; /// - /// Returns whether two factor is enabled for the user. + /// Returns whether two factor is enabled for the user. A user is able to have a TwoFactorProvider that is enabled but requires Premium. + /// If the user does not have premium then the TwoFactorProvider is considered _not_ enabled. /// /// The user to check. Task TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user); diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs index bda2094f24..8d4bd49e42 100644 --- a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs @@ -1,17 +1,13 @@ -using Bit.Core.Auth.Models; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Repositories; namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth; -public class TwoFactorIsEnabledQuery : ITwoFactorIsEnabledQuery +public class TwoFactorIsEnabledQuery(IUserRepository userRepository) : ITwoFactorIsEnabledQuery { - private readonly IUserRepository _userRepository; - - public TwoFactorIsEnabledQuery(IUserRepository userRepository) - { - _userRepository = userRepository; - } + private readonly IUserRepository _userRepository = userRepository; public async Task> TwoFactorIsEnabledAsync(IEnumerable userIds) { @@ -21,26 +17,15 @@ public class TwoFactorIsEnabledQuery : ITwoFactorIsEnabledQuery return result; } - var userDetails = await _userRepository.GetManyWithCalculatedPremiumAsync(userIds.ToList()); - + var userDetails = await _userRepository.GetManyWithCalculatedPremiumAsync([.. userIds]); foreach (var userDetail in userDetails) { - var hasTwoFactor = false; - var providers = userDetail.GetTwoFactorProviders(); - if (providers != null) - { - // Get all enabled providers - var enabledProviderKeys = from provider in providers - where provider.Value?.Enabled ?? false - select provider.Key; - - // Find the first provider that is enabled and passes the premium check - hasTwoFactor = enabledProviderKeys - .Select(type => userDetail.HasPremiumAccess || !TwoFactorProvider.RequiresPremium(type)) - .FirstOrDefault(); - } - - result.Add((userDetail.Id, hasTwoFactor)); + result.Add( + (userDetail.Id, + await TwoFactorEnabledAsync(userDetail.GetTwoFactorProviders(), + () => Task.FromResult(userDetail.HasPremiumAccess)) + ) + ); } return result; @@ -83,41 +68,56 @@ public class TwoFactorIsEnabledQuery : ITwoFactorIsEnabledQuery return false; } - var providers = user.GetTwoFactorProviders(); - if (providers == null || !providers.Any()) + return await TwoFactorEnabledAsync( + user.GetTwoFactorProviders(), + async () => + { + var calcUser = await _userRepository.GetCalculatedPremiumAsync(userId.Value); + return calcUser?.HasPremiumAccess ?? false; + }); + } + + /// + /// Checks to see what kind of two-factor is enabled. + /// We use a delegate to check if the user has premium access, since there are multiple ways to + /// determine if a user has premium access. + /// + /// dictionary of two factor providers + /// function to check if the user has premium access + /// true if the user has two factor enabled; false otherwise; + private async static Task TwoFactorEnabledAsync( + Dictionary providers, + Func> hasPremiumAccessDelegate) + { + // If there are no providers, then two factor is not enabled + if (providers == null || providers.Count == 0) { return false; } // Get all enabled providers - var enabledProviderKeys = providers - .Where(provider => provider.Value?.Enabled ?? false) - .Select(provider => provider.Key); + // TODO: PM-21210: In practice we don't save disabled providers to the database, worth looking into. + var enabledProviderKeys = from provider in providers + where provider.Value?.Enabled ?? false + select provider.Key; + // If no providers are enabled then two factor is not enabled if (!enabledProviderKeys.Any()) { return false; } - // Determine if any enabled provider passes the premium check - var hasTwoFactor = enabledProviderKeys - .Select(type => user.GetPremium() || !TwoFactorProvider.RequiresPremium(type)) - .FirstOrDefault(); - - // If no enabled provider passes the check, check the repository for organization premium access - if (!hasTwoFactor) + // If there are only premium two factor options then standard two factor is not enabled + var onlyHasPremiumTwoFactor = enabledProviderKeys.All(TwoFactorProvider.RequiresPremium); + if (onlyHasPremiumTwoFactor) { - var userDetails = await _userRepository.GetManyWithCalculatedPremiumAsync(new List { userId.Value }); - var userDetail = userDetails.FirstOrDefault(); - - if (userDetail != null) - { - hasTwoFactor = enabledProviderKeys - .Select(type => userDetail.HasPremiumAccess || !TwoFactorProvider.RequiresPremium(type)) - .FirstOrDefault(); - } + // There are no Standard two factor options, check if the user has premium access + // If the user has premium access, then two factor is enabled + var premiumAccess = await hasPremiumAccessDelegate(); + return premiumAccess; } - return hasTwoFactor; + // The user has at least one non-premium two factor option + return true; } } diff --git a/src/Core/Auth/Utilities/CaptchaProtectedAttribute.cs b/src/Core/Auth/Utilities/CaptchaProtectedAttribute.cs deleted file mode 100644 index 052f178165..0000000000 --- a/src/Core/Auth/Utilities/CaptchaProtectedAttribute.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Bit.Core.Auth.Models.Api; -using Bit.Core.Auth.Services; -using Bit.Core.Context; -using Bit.Core.Exceptions; -using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.Extensions.DependencyInjection; - -namespace Bit.Core.Auth.Utilities; - -public class CaptchaProtectedAttribute : ActionFilterAttribute -{ - public string ModelParameterName { get; set; } = "model"; - - public override void OnActionExecuting(ActionExecutingContext context) - { - var currentContext = context.HttpContext.RequestServices.GetRequiredService(); - var captchaValidationService = context.HttpContext.RequestServices.GetRequiredService(); - - if (captchaValidationService.RequireCaptchaValidation(currentContext, null)) - { - var captchaResponse = (context.ActionArguments[ModelParameterName] as ICaptchaProtectedModel)?.CaptchaResponse; - - if (string.IsNullOrWhiteSpace(captchaResponse)) - { - throw new BadRequestException(captchaValidationService.SiteKeyResponseKeyName, captchaValidationService.SiteKey); - } - - var captchaValidationResponse = captchaValidationService.ValidateCaptchaResponseAsync(captchaResponse, - currentContext.IpAddress, null).GetAwaiter().GetResult(); - if (!captchaValidationResponse.Success || captchaValidationResponse.IsBot) - { - throw new BadRequestException("Captcha is invalid. Please refresh and try again"); - } - } - } -} diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 8a4303e378..c3e3ec6c30 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -2,6 +2,10 @@ public static class StripeConstants { + public static class Prices + { + public const string StoragePlanPersonal = "personal-storage-gb-annually"; + } public static class AutomaticTaxStatus { public const string Failed = "failed"; @@ -42,10 +46,12 @@ public static class StripeConstants { public const string Draft = "draft"; public const string Open = "open"; + public const string Paid = "paid"; } public static class MetadataKeys { + public const string BraintreeCustomerId = "btCustomerId"; public const string InvoiceApproved = "invoice_approved"; public const string OrganizationId = "organizationId"; public const string ProviderId = "providerId"; diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index 17285e0676..5c7a42e9b8 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -4,7 +4,9 @@ using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; -using Bit.Core.Billing.Services.Implementations.AutomaticTax; +using Bit.Core.Billing.Tax.Commands; +using Bit.Core.Billing.Tax.Services; +using Bit.Core.Billing.Tax.Services.Implementations; namespace Bit.Core.Billing.Extensions; @@ -24,5 +26,6 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddLicenseServices(); services.AddPricingClient(); + services.AddTransient(); } } diff --git a/src/Core/Billing/Models/Api/Requests/Accounts/TrialSendVerificationEmailRequestModel.cs b/src/Core/Billing/Models/Api/Requests/Accounts/TrialSendVerificationEmailRequestModel.cs index 2e8780e6a3..b31da9efbc 100644 --- a/src/Core/Billing/Models/Api/Requests/Accounts/TrialSendVerificationEmailRequestModel.cs +++ b/src/Core/Billing/Models/Api/Requests/Accounts/TrialSendVerificationEmailRequestModel.cs @@ -7,4 +7,5 @@ public class TrialSendVerificationEmailRequestModel : RegisterSendVerificationEm { public ProductTierType ProductTier { get; set; } public IEnumerable Products { get; set; } + public int? TrialLength { get; set; } } diff --git a/src/Core/Billing/Models/BillingCommandResult.cs b/src/Core/Billing/Models/BillingCommandResult.cs new file mode 100644 index 0000000000..1b8eefe8df --- /dev/null +++ b/src/Core/Billing/Models/BillingCommandResult.cs @@ -0,0 +1,36 @@ +using OneOf; + +namespace Bit.Core.Billing.Models; + +public record BadRequest(string TranslationKey) +{ + public static BadRequest TaxIdNumberInvalid => new(BillingErrorTranslationKeys.TaxIdInvalid); + public static BadRequest TaxLocationInvalid => new(BillingErrorTranslationKeys.CustomerTaxLocationInvalid); + public static BadRequest UnknownTaxIdType => new(BillingErrorTranslationKeys.UnknownTaxIdType); +} + +public record Unhandled(string TranslationKey = BillingErrorTranslationKeys.UnhandledError); + +public class BillingCommandResult : OneOfBase +{ + private BillingCommandResult(OneOf input) : base(input) { } + + public static implicit operator BillingCommandResult(T output) => new(output); + public static implicit operator BillingCommandResult(BadRequest badRequest) => new(badRequest); + public static implicit operator BillingCommandResult(Unhandled unhandled) => new(unhandled); +} + +public static class BillingErrorTranslationKeys +{ + // "The tax ID number you provided was invalid. Please try again or contact support." + public const string TaxIdInvalid = "taxIdInvalid"; + + // "Your location wasn't recognized. Please ensure your country and postal code are valid and try again." + public const string CustomerTaxLocationInvalid = "customerTaxLocationInvalid"; + + // "Something went wrong with your request. Please contact support." + public const string UnhandledError = "unhandledBillingError"; + + // "We couldn't find a corresponding tax ID type for the tax ID you provided. Please try again or contact support." + public const string UnknownTaxIdType = "unknownTaxIdType"; +} diff --git a/src/Core/Billing/Models/Mail/TrialInititaionVerifyEmail.cs b/src/Core/Billing/Models/Mail/TrialInititaionVerifyEmail.cs index 33b9578d0e..b97390dcc9 100644 --- a/src/Core/Billing/Models/Mail/TrialInititaionVerifyEmail.cs +++ b/src/Core/Billing/Models/Mail/TrialInititaionVerifyEmail.cs @@ -1,5 +1,6 @@ using Bit.Core.Auth.Models.Mail; using Bit.Core.Billing.Enums; +using Bit.Core.Enums; namespace Bit.Core.Billing.Models.Mail; @@ -16,13 +17,26 @@ public class TrialInitiationVerifyEmail : RegisterVerifyEmail $"&email={Email}" + $"&fromEmail=true" + $"&productTier={(int)ProductTier}" + - $"&product={string.Join(",", Product.Select(p => (int)p))}"; + $"&product={string.Join(",", Product.Select(p => (int)p))}" + + $"&trialLength={TrialLength}"; } + public string VerifyYourEmailHTMLCopy => + TrialLength == 7 + ? "Verify your email address below to finish signing up for your free trial." + : $"Verify your email address below to finish signing up for your {ProductTier.GetDisplayName()} plan."; + + public string VerifyYourEmailTextCopy => + TrialLength == 7 + ? "Verify your email address using the link below and start your free trial of Bitwarden." + : $"Verify your email address using the link below and start your {ProductTier.GetDisplayName()} Bitwarden plan."; + public ProductTierType ProductTier { get; set; } public IEnumerable Product { get; set; } + public int TrialLength { get; set; } + /// /// Currently we only support one product type at a time, despite Product being a collection. /// If we receive both PasswordManager and SecretsManager, we'll send the user to the PM trial route diff --git a/src/Core/Billing/Models/PaymentMethod.cs b/src/Core/Billing/Models/PaymentMethod.cs index b07fe82e46..2b8c59fa05 100644 --- a/src/Core/Billing/Models/PaymentMethod.cs +++ b/src/Core/Billing/Models/PaymentMethod.cs @@ -1,4 +1,6 @@ -namespace Bit.Core.Billing.Models; +using Bit.Core.Billing.Tax.Models; + +namespace Bit.Core.Billing.Models; public record PaymentMethod( long AccountCredit, diff --git a/src/Core/Billing/Models/Sales/CustomerSetup.cs b/src/Core/Billing/Models/Sales/CustomerSetup.cs index bb4f2352e3..aa67c712b5 100644 --- a/src/Core/Billing/Models/Sales/CustomerSetup.cs +++ b/src/Core/Billing/Models/Sales/CustomerSetup.cs @@ -1,4 +1,6 @@ -namespace Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Tax.Models; + +namespace Bit.Core.Billing.Models.Sales; #nullable enable diff --git a/src/Core/Billing/Models/Sales/OrganizationSale.cs b/src/Core/Billing/Models/Sales/OrganizationSale.cs index 0602cf1dd9..78ad26871b 100644 --- a/src/Core/Billing/Models/Sales/OrganizationSale.cs +++ b/src/Core/Billing/Models/Sales/OrganizationSale.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Models.Business; namespace Bit.Core.Billing.Models.Sales; @@ -26,12 +27,21 @@ public class OrganizationSale public static OrganizationSale From( Organization organization, - OrganizationSignup signup) => new() + OrganizationSignup signup) + { + var customerSetup = string.IsNullOrEmpty(organization.GatewayCustomerId) ? GetCustomerSetup(signup) : null; + + var subscriptionSetup = GetSubscriptionSetup(signup); + + subscriptionSetup.SkipTrial = signup.SkipTrial; + + return new OrganizationSale { Organization = organization, - CustomerSetup = string.IsNullOrEmpty(organization.GatewayCustomerId) ? GetCustomerSetup(signup) : null, - SubscriptionSetup = GetSubscriptionSetup(signup) + CustomerSetup = customerSetup, + SubscriptionSetup = subscriptionSetup }; + } public static OrganizationSale From( Organization organization, diff --git a/src/Core/Billing/Models/Sales/PremiumUserSale.cs b/src/Core/Billing/Models/Sales/PremiumUserSale.cs index 6bc054eac5..8c9b696aa3 100644 --- a/src/Core/Billing/Models/Sales/PremiumUserSale.cs +++ b/src/Core/Billing/Models/Sales/PremiumUserSale.cs @@ -1,4 +1,5 @@ -using Bit.Core.Entities; +using Bit.Core.Billing.Tax.Models; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; diff --git a/src/Core/Billing/Models/StaticStore/Plans/Families2019Plan.cs b/src/Core/Billing/Models/StaticStore/Plans/Families2019Plan.cs index b0ca8feeb0..93ab2c39a1 100644 --- a/src/Core/Billing/Models/StaticStore/Plans/Families2019Plan.cs +++ b/src/Core/Billing/Models/StaticStore/Plans/Families2019Plan.cs @@ -38,7 +38,7 @@ public record Families2019Plan : Plan HasPremiumAccessOption = true; StripePlanId = "personal-org-annually"; - StripeStoragePlanId = "storage-gb-annually"; + StripeStoragePlanId = "personal-storage-gb-annually"; StripePremiumAccessPlanId = "personal-org-premium-access-annually"; BasePrice = 12; AdditionalStoragePricePerGb = 4; diff --git a/src/Core/Billing/Models/StaticStore/Plans/FamiliesPlan.cs b/src/Core/Billing/Models/StaticStore/Plans/FamiliesPlan.cs index e2f51ec913..8c71e50fa4 100644 --- a/src/Core/Billing/Models/StaticStore/Plans/FamiliesPlan.cs +++ b/src/Core/Billing/Models/StaticStore/Plans/FamiliesPlan.cs @@ -37,7 +37,7 @@ public record FamiliesPlan : Plan HasAdditionalStorageOption = true; StripePlanId = "2020-families-org-annually"; - StripeStoragePlanId = "storage-gb-annually"; + StripeStoragePlanId = "personal-storage-gb-annually"; BasePrice = 40; AdditionalStoragePricePerGb = 4; diff --git a/src/Core/Billing/Services/IOrganizationBillingService.cs b/src/Core/Billing/Services/IOrganizationBillingService.cs index db62d545e3..5f7d33f118 100644 --- a/src/Core/Billing/Services/IOrganizationBillingService.cs +++ b/src/Core/Billing/Services/IOrganizationBillingService.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Tax.Models; namespace Bit.Core.Billing.Services; diff --git a/src/Core/Billing/Services/IPremiumUserBillingService.cs b/src/Core/Billing/Services/IPremiumUserBillingService.cs index b3bb580e2d..ed7a003599 100644 --- a/src/Core/Billing/Services/IPremiumUserBillingService.cs +++ b/src/Core/Billing/Services/IPremiumUserBillingService.cs @@ -1,5 +1,6 @@ using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Entities; namespace Bit.Core.Billing.Services; diff --git a/src/Core/Billing/Services/IProviderBillingService.cs b/src/Core/Billing/Services/IProviderBillingService.cs index 64585f3361..b6ddbdd642 100644 --- a/src/Core/Billing/Services/IProviderBillingService.cs +++ b/src/Core/Billing/Services/IProviderBillingService.cs @@ -4,6 +4,7 @@ using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Models.Business; using Stripe; @@ -59,7 +60,7 @@ public interface IProviderBillingService int seatAdjustment); /// - /// Determines whether the provided will result in a purchase for the 's . + /// Determines whether the provided will result in a purchase for the 's . /// Seat adjustments that result in purchases include: /// /// The going from below the seat minimum to above the seat minimum for the provided @@ -79,10 +80,12 @@ public interface IProviderBillingService /// /// The to create a Stripe customer for. /// The to use for calculating the customer's automatic tax. + /// The (ex. Credit Card) to attach to the customer. /// The newly created for the . Task SetupCustomer( Provider provider, - TaxInfo taxInfo); + TaxInfo taxInfo, + TokenizedPaymentSource tokenizedPaymentSource = null); /// /// For use during the provider setup process, this method starts a Stripe for the given . diff --git a/src/Core/Billing/Services/ISubscriberService.cs b/src/Core/Billing/Services/ISubscriberService.cs index bb0a23020c..6910948436 100644 --- a/src/Core/Billing/Services/ISubscriberService.cs +++ b/src/Core/Billing/Services/ISubscriberService.cs @@ -1,4 +1,5 @@ using Bit.Core.Billing.Models; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Stripe; diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs index 2e902ca028..20f6105c2a 100644 --- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs +++ b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs @@ -6,6 +6,8 @@ using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Tax.Models; +using Bit.Core.Billing.Tax.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/src/Core/Billing/Services/Implementations/PaymentHistoryService.cs b/src/Core/Billing/Services/Implementations/PaymentHistoryService.cs index 6e984f946e..5a8cf16f5a 100644 --- a/src/Core/Billing/Services/Implementations/PaymentHistoryService.cs +++ b/src/Core/Billing/Services/Implementations/PaymentHistoryService.cs @@ -5,14 +5,12 @@ using Bit.Core.Entities; using Bit.Core.Models.BitStripe; using Bit.Core.Repositories; using Bit.Core.Services; -using Microsoft.Extensions.Logging; namespace Bit.Core.Billing.Services.Implementations; public class PaymentHistoryService( IStripeAdapter stripeAdapter, - ITransactionRepository transactionRepository, - ILogger logger) : IPaymentHistoryService + ITransactionRepository transactionRepository) : IPaymentHistoryService { public async Task> GetInvoiceHistoryAsync( ISubscriber subscriber, diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs index 6746a8cc98..1b845e93f1 100644 --- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs +++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs @@ -2,7 +2,9 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; -using Bit.Core.Billing.Services.Implementations.AutomaticTax; +using Bit.Core.Billing.Tax.Models; +using Bit.Core.Billing.Tax.Services; +using Bit.Core.Billing.Tax.Services.Implementations; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -313,7 +315,7 @@ public class PremiumUserBillingService( { subscriptionItemOptionsList.Add(new SubscriptionItemOptions { - Price = "storage-gb-annually", + Price = StripeConstants.Prices.StoragePlanPersonal, Quantity = storage }); } diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 1b0e5b665b..10247cdf92 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -2,6 +2,8 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Tax.Models; +using Bit.Core.Billing.Tax.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; diff --git a/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs b/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs new file mode 100644 index 0000000000..304abbaae0 --- /dev/null +++ b/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs @@ -0,0 +1,147 @@ +#nullable enable +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Models; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Tax.Services; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using Stripe; + +namespace Bit.Core.Billing.Tax.Commands; + +public interface IPreviewTaxAmountCommand +{ + Task> Run(OrganizationTrialParameters parameters); +} + +public class PreviewTaxAmountCommand( + ILogger logger, + IPricingClient pricingClient, + IStripeAdapter stripeAdapter, + ITaxService taxService) : IPreviewTaxAmountCommand +{ + public async Task> Run(OrganizationTrialParameters parameters) + { + var (planType, productType, taxInformation) = parameters; + + var plan = await pricingClient.GetPlanOrThrow(planType); + + var options = new InvoiceCreatePreviewOptions + { + Currency = "usd", + CustomerDetails = new InvoiceCustomerDetailsOptions + { + Address = new AddressOptions + { + Country = taxInformation.Country, + PostalCode = taxInformation.PostalCode + } + }, + SubscriptionDetails = new InvoiceSubscriptionDetailsOptions + { + Items = [ + new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.HasNonSeatBasedPasswordManagerPlan() ? plan.PasswordManager.StripePlanId : plan.PasswordManager.StripeSeatPlanId, + Quantity = 1 + } + ] + } + }; + + if (productType == ProductType.SecretsManager) + { + options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.SecretsManager.StripeSeatPlanId, + Quantity = 1 + }); + + options.Coupon = StripeConstants.CouponIDs.SecretsManagerStandalone; + } + + if (!string.IsNullOrEmpty(taxInformation.TaxId)) + { + var taxIdType = taxService.GetStripeTaxCode( + taxInformation.Country, + taxInformation.TaxId); + + if (string.IsNullOrEmpty(taxIdType)) + { + return BadRequest.UnknownTaxIdType; + } + + options.CustomerDetails.TaxIds = [ + new InvoiceCustomerDetailsTaxIdOptions + { + Type = taxIdType, + Value = taxInformation.TaxId + } + ]; + } + + if (planType.GetProductTier() == ProductTierType.Families) + { + options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }; + } + else + { + options.AutomaticTax = new InvoiceAutomaticTaxOptions + { + Enabled = options.CustomerDetails.Address.Country == "US" || + options.CustomerDetails.TaxIds is [_, ..] + }; + } + + try + { + var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + return Convert.ToDecimal(invoice.Tax) / 100; + } + catch (StripeException stripeException) when (stripeException.StripeError.Code == + StripeConstants.ErrorCodes.CustomerTaxLocationInvalid) + { + return BadRequest.TaxLocationInvalid; + } + catch (StripeException stripeException) when (stripeException.StripeError.Code == + StripeConstants.ErrorCodes.TaxIdInvalid) + { + return BadRequest.TaxIdNumberInvalid; + } + catch (StripeException stripeException) + { + logger.LogError(stripeException, "Stripe responded with an error during {Operation}. Code: {Code}", nameof(PreviewTaxAmountCommand), stripeException.StripeError.Code); + return new Unhandled(); + } + } +} + +#region Command Parameters + +public record OrganizationTrialParameters +{ + public required PlanType PlanType { get; set; } + public required ProductType ProductType { get; set; } + public required TaxInformationDTO TaxInformation { get; set; } + + public void Deconstruct( + out PlanType planType, + out ProductType productType, + out TaxInformationDTO taxInformation) + { + planType = PlanType; + productType = ProductType; + taxInformation = TaxInformation; + } + + public record TaxInformationDTO + { + public required string Country { get; set; } + public required string PostalCode { get; set; } + public string? TaxId { get; set; } + } +} + +#endregion diff --git a/src/Core/Billing/Models/TaxIdType.cs b/src/Core/Billing/Tax/Models/TaxIdType.cs similarity index 92% rename from src/Core/Billing/Models/TaxIdType.cs rename to src/Core/Billing/Tax/Models/TaxIdType.cs index 3fc246d68b..6f8cfdde99 100644 --- a/src/Core/Billing/Models/TaxIdType.cs +++ b/src/Core/Billing/Tax/Models/TaxIdType.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; -namespace Bit.Core.Billing.Models; +namespace Bit.Core.Billing.Tax.Models; public class TaxIdType { diff --git a/src/Core/Billing/Models/TaxInformation.cs b/src/Core/Billing/Tax/Models/TaxInformation.cs similarity index 93% rename from src/Core/Billing/Models/TaxInformation.cs rename to src/Core/Billing/Tax/Models/TaxInformation.cs index 23ed3e5faa..2408ee0ecd 100644 --- a/src/Core/Billing/Models/TaxInformation.cs +++ b/src/Core/Billing/Tax/Models/TaxInformation.cs @@ -1,6 +1,6 @@ using Bit.Core.Models.Business; -namespace Bit.Core.Billing.Models; +namespace Bit.Core.Billing.Tax.Models; public record TaxInformation( string Country, diff --git a/src/Core/Billing/Models/Api/Requests/Accounts/PreviewIndividualInvoiceRequestModel.cs b/src/Core/Billing/Tax/Requests/PreviewIndividualInvoiceRequestModel.cs similarity index 87% rename from src/Core/Billing/Models/Api/Requests/Accounts/PreviewIndividualInvoiceRequestModel.cs rename to src/Core/Billing/Tax/Requests/PreviewIndividualInvoiceRequestModel.cs index 8597cea09b..340f07b56c 100644 --- a/src/Core/Billing/Models/Api/Requests/Accounts/PreviewIndividualInvoiceRequestModel.cs +++ b/src/Core/Billing/Tax/Requests/PreviewIndividualInvoiceRequestModel.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace Bit.Core.Billing.Models.Api.Requests.Accounts; +namespace Bit.Core.Billing.Tax.Requests; public class PreviewIndividualInvoiceRequestBody { diff --git a/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs b/src/Core/Billing/Tax/Requests/PreviewOrganizationInvoiceRequestModel.cs similarity index 93% rename from src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs rename to src/Core/Billing/Tax/Requests/PreviewOrganizationInvoiceRequestModel.cs index 461a6dca65..bfb47e7b2c 100644 --- a/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs +++ b/src/Core/Billing/Tax/Requests/PreviewOrganizationInvoiceRequestModel.cs @@ -2,7 +2,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Enums; -namespace Bit.Core.Billing.Models.Api.Requests.Organizations; +namespace Bit.Core.Billing.Tax.Requests; public class PreviewOrganizationInvoiceRequestBody { diff --git a/src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs b/src/Core/Billing/Tax/Requests/TaxInformationRequestModel.cs similarity index 84% rename from src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs rename to src/Core/Billing/Tax/Requests/TaxInformationRequestModel.cs index 9cb43645c6..13d4870ac5 100644 --- a/src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs +++ b/src/Core/Billing/Tax/Requests/TaxInformationRequestModel.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace Bit.Core.Billing.Models.Api.Requests; +namespace Bit.Core.Billing.Tax.Requests; public class TaxInformationRequestModel { diff --git a/src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs b/src/Core/Billing/Tax/Responses/PreviewInvoiceResponseModel.cs similarity index 74% rename from src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs rename to src/Core/Billing/Tax/Responses/PreviewInvoiceResponseModel.cs index fdde7dae1e..2753487e2f 100644 --- a/src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs +++ b/src/Core/Billing/Tax/Responses/PreviewInvoiceResponseModel.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Models.Api.Responses; +namespace Bit.Core.Billing.Tax.Responses; public record PreviewInvoiceResponseModel( decimal EffectiveTaxRate, diff --git a/src/Core/Billing/Services/IAutomaticTaxFactory.cs b/src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs similarity index 88% rename from src/Core/Billing/Services/IAutomaticTaxFactory.cs rename to src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs index c52a8f2671..90a3bc08ad 100644 --- a/src/Core/Billing/Services/IAutomaticTaxFactory.cs +++ b/src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs @@ -1,6 +1,6 @@ using Bit.Core.Billing.Services.Contracts; -namespace Bit.Core.Billing.Services; +namespace Bit.Core.Billing.Tax.Services; /// /// Responsible for defining the correct automatic tax strategy for either personal use of business use. diff --git a/src/Core/Billing/Services/IAutomaticTaxStrategy.cs b/src/Core/Billing/Tax/Services/IAutomaticTaxStrategy.cs similarity index 96% rename from src/Core/Billing/Services/IAutomaticTaxStrategy.cs rename to src/Core/Billing/Tax/Services/IAutomaticTaxStrategy.cs index 292f2d0939..557bb1d30c 100644 --- a/src/Core/Billing/Services/IAutomaticTaxStrategy.cs +++ b/src/Core/Billing/Tax/Services/IAutomaticTaxStrategy.cs @@ -1,7 +1,7 @@ #nullable enable using Stripe; -namespace Bit.Core.Billing.Services; +namespace Bit.Core.Billing.Tax.Services; public interface IAutomaticTaxStrategy { diff --git a/src/Core/Billing/Services/ITaxService.cs b/src/Core/Billing/Tax/Services/ITaxService.cs similarity index 94% rename from src/Core/Billing/Services/ITaxService.cs rename to src/Core/Billing/Tax/Services/ITaxService.cs index beee113d17..00cbf56a9b 100644 --- a/src/Core/Billing/Services/ITaxService.cs +++ b/src/Core/Billing/Tax/Services/ITaxService.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Services; +namespace Bit.Core.Billing.Tax.Services; public interface ITaxService { diff --git a/src/Core/Billing/Services/Implementations/AutomaticTax/AutomaticTaxFactory.cs b/src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs similarity index 96% rename from src/Core/Billing/Services/Implementations/AutomaticTax/AutomaticTaxFactory.cs rename to src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs index 133cd2c7a7..fa110f79d5 100644 --- a/src/Core/Billing/Services/Implementations/AutomaticTax/AutomaticTaxFactory.cs +++ b/src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs @@ -5,7 +5,7 @@ using Bit.Core.Billing.Services.Contracts; using Bit.Core.Entities; using Bit.Core.Services; -namespace Bit.Core.Billing.Services.Implementations.AutomaticTax; +namespace Bit.Core.Billing.Tax.Services.Implementations; public class AutomaticTaxFactory( IFeatureService featureService, diff --git a/src/Core/Billing/Services/Implementations/AutomaticTax/BusinessUseAutomaticTaxStrategy.cs b/src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs similarity index 97% rename from src/Core/Billing/Services/Implementations/AutomaticTax/BusinessUseAutomaticTaxStrategy.cs rename to src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs index 40eb6e4540..310aced130 100644 --- a/src/Core/Billing/Services/Implementations/AutomaticTax/BusinessUseAutomaticTaxStrategy.cs +++ b/src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs @@ -3,7 +3,7 @@ using Bit.Core.Billing.Extensions; using Bit.Core.Services; using Stripe; -namespace Bit.Core.Billing.Services.Implementations.AutomaticTax; +namespace Bit.Core.Billing.Tax.Services.Implementations; public class BusinessUseAutomaticTaxStrategy(IFeatureService featureService) : IAutomaticTaxStrategy { diff --git a/src/Core/Billing/Services/Implementations/AutomaticTax/PersonalUseAutomaticTaxStrategy.cs b/src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs similarity index 96% rename from src/Core/Billing/Services/Implementations/AutomaticTax/PersonalUseAutomaticTaxStrategy.cs rename to src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs index 15ee1adf8f..e89fc6a3b3 100644 --- a/src/Core/Billing/Services/Implementations/AutomaticTax/PersonalUseAutomaticTaxStrategy.cs +++ b/src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs @@ -3,7 +3,7 @@ using Bit.Core.Billing.Extensions; using Bit.Core.Services; using Stripe; -namespace Bit.Core.Billing.Services.Implementations.AutomaticTax; +namespace Bit.Core.Billing.Tax.Services.Implementations; public class PersonalUseAutomaticTaxStrategy(IFeatureService featureService) : IAutomaticTaxStrategy { diff --git a/src/Core/Billing/Services/TaxService.cs b/src/Core/Billing/Tax/Services/Implementations/TaxService.cs similarity index 99% rename from src/Core/Billing/Services/TaxService.cs rename to src/Core/Billing/Tax/Services/Implementations/TaxService.cs index 3066be92d1..204c997335 100644 --- a/src/Core/Billing/Services/TaxService.cs +++ b/src/Core/Billing/Tax/Services/Implementations/TaxService.cs @@ -1,7 +1,7 @@ using System.Text.RegularExpressions; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Tax.Models; -namespace Bit.Core.Billing.Services; +namespace Bit.Core.Billing.Tax.Services.Implementations; public class TaxService : ITaxService { diff --git a/src/Core/Billing/TrialInitiation/Registration/ISendTrialInitiationEmailForRegistrationCommand.cs b/src/Core/Billing/TrialInitiation/Registration/ISendTrialInitiationEmailForRegistrationCommand.cs index 01550228be..6ec31d7b8f 100644 --- a/src/Core/Billing/TrialInitiation/Registration/ISendTrialInitiationEmailForRegistrationCommand.cs +++ b/src/Core/Billing/TrialInitiation/Registration/ISendTrialInitiationEmailForRegistrationCommand.cs @@ -10,5 +10,6 @@ public interface ISendTrialInitiationEmailForRegistrationCommand string? name, bool receiveMarketingEmails, ProductTierType productTier, - IEnumerable products); + IEnumerable products, + int trialLength); } diff --git a/src/Core/Billing/TrialInitiation/Registration/Implementations/SendTrialInitiationEmailForRegistrationCommand.cs b/src/Core/Billing/TrialInitiation/Registration/Implementations/SendTrialInitiationEmailForRegistrationCommand.cs index 385d7ebbd6..3e5b056ec6 100644 --- a/src/Core/Billing/TrialInitiation/Registration/Implementations/SendTrialInitiationEmailForRegistrationCommand.cs +++ b/src/Core/Billing/TrialInitiation/Registration/Implementations/SendTrialInitiationEmailForRegistrationCommand.cs @@ -22,7 +22,8 @@ public class SendTrialInitiationEmailForRegistrationCommand( string? name, bool receiveMarketingEmails, ProductTierType productTier, - IEnumerable products) + IEnumerable products, + int trialLength) { ArgumentException.ThrowIfNullOrWhiteSpace(email, nameof(email)); @@ -43,7 +44,12 @@ public class SendTrialInitiationEmailForRegistrationCommand( await PerformConstantTimeOperationsAsync(); - await mailService.SendTrialInitiationSignupEmailAsync(userExists, email, token, productTier, products); + if (trialLength != 0 && trialLength != 7) + { + trialLength = 7; + } + + await mailService.SendTrialInitiationSignupEmailAsync(userExists, email, token, productTier, products, trialLength); return null; } diff --git a/src/Core/Billing/Utilities.cs b/src/Core/Billing/Utilities.cs index 695a3b1bb4..ebb7b0e525 100644 --- a/src/Core/Billing/Utilities.cs +++ b/src/Core/Billing/Utilities.cs @@ -1,4 +1,5 @@ using Bit.Core.Billing.Models; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Services; using Stripe; diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 5f0265df41..3399a729d1 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -114,10 +114,7 @@ public static class FeatureFlagKeys public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence"; public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; public const string EmailVerification = "email-verification"; - public const string DeviceTrustLogging = "pm-8285-device-trust-logging"; - public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token"; public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh"; - public const string NewDeviceVerification = "new-device-verification"; public const string SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor"; public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor"; public const string RecoveryCodeLogin = "pm-17128-recovery-code-login"; @@ -149,6 +146,9 @@ public static class FeatureFlagKeys public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion"; public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically"; + public const string PM19956_RequireProviderPaymentMethodDuringSetup = "pm-19956-require-provider-payment-method-during-setup"; + public const string UseOrganizationWarningsService = "use-organization-warnings-service"; + public const string PM20322_AllowTrialLength0 = "pm-20322-allow-trial-length-0"; /* Data Insights and Reporting Team */ public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application"; @@ -195,14 +195,14 @@ public static class FeatureFlagKeys /* Vault Team */ public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge"; public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form"; - public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss"; - public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss"; public const string RestrictProviderAccess = "restrict-provider-access"; public const string SecurityTasks = "security-tasks"; public const string CipherKeyEncryption = "cipher-key-encryption"; public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms"; public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk"; public const string EndUserNotifications = "pm-10609-end-user-notifications"; + public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions"; + public const string PhishingDetection = "phishing-detection"; public static List GetAllKeys() { diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index c7e812fd2c..6397e0b8ea 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -3,8 +3,6 @@ false bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml - - $(WarningsNotAsErrors);CS1570;CS1574;CS9113;CS1998 @@ -77,4 +75,8 @@ + + + + diff --git a/src/Core/Tools/Entities/PasswordHealthReportApplication.cs b/src/Core/Dirt/Reports/Entities/PasswordHealthReportApplication.cs similarity index 100% rename from src/Core/Tools/Entities/PasswordHealthReportApplication.cs rename to src/Core/Dirt/Reports/Entities/PasswordHealthReportApplication.cs diff --git a/src/Core/Tools/Models/Data/MemberAccessCipherDetails.cs b/src/Core/Dirt/Reports/Models/Data/MemberAccessCipherDetails.cs similarity index 100% rename from src/Core/Tools/Models/Data/MemberAccessCipherDetails.cs rename to src/Core/Dirt/Reports/Models/Data/MemberAccessCipherDetails.cs diff --git a/src/Core/Tools/ReportFeatures/AddPasswordHealthReportApplicationCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/AddPasswordHealthReportApplicationCommand.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/AddPasswordHealthReportApplicationCommand.cs rename to src/Core/Dirt/Reports/ReportFeatures/AddPasswordHealthReportApplicationCommand.cs diff --git a/src/Core/Tools/ReportFeatures/DropPasswordHealthReportApplicationCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/DropPasswordHealthReportApplicationCommand.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/DropPasswordHealthReportApplicationCommand.cs rename to src/Core/Dirt/Reports/ReportFeatures/DropPasswordHealthReportApplicationCommand.cs diff --git a/src/Core/Tools/ReportFeatures/GetPasswordHealthReportApplicationQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetPasswordHealthReportApplicationQuery.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/GetPasswordHealthReportApplicationQuery.cs rename to src/Core/Dirt/Reports/ReportFeatures/GetPasswordHealthReportApplicationQuery.cs diff --git a/src/Core/Tools/ReportFeatures/Interfaces/IAddPasswordHealthReportApplicationCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IAddPasswordHealthReportApplicationCommand.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/Interfaces/IAddPasswordHealthReportApplicationCommand.cs rename to src/Core/Dirt/Reports/ReportFeatures/Interfaces/IAddPasswordHealthReportApplicationCommand.cs diff --git a/src/Core/Tools/ReportFeatures/Interfaces/IDropPasswordHealthReportApplicationCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropPasswordHealthReportApplicationCommand.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/Interfaces/IDropPasswordHealthReportApplicationCommand.cs rename to src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropPasswordHealthReportApplicationCommand.cs diff --git a/src/Core/Tools/ReportFeatures/Interfaces/IGetPasswordHealthReportApplicationQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetPasswordHealthReportApplicationQuery.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/Interfaces/IGetPasswordHealthReportApplicationQuery.cs rename to src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetPasswordHealthReportApplicationQuery.cs diff --git a/src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/MemberAccessCipherDetailsQuery.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs rename to src/Core/Dirt/Reports/ReportFeatures/MemberAccessCipherDetailsQuery.cs diff --git a/src/Core/Tools/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs rename to src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs diff --git a/src/Core/Tools/ReportFeatures/ReportingServiceCollectionExtensions.cs b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/ReportingServiceCollectionExtensions.cs rename to src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs diff --git a/src/Core/Tools/ReportFeatures/Requests/AddPasswordHealthReportApplicationRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddPasswordHealthReportApplicationRequest.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/Requests/AddPasswordHealthReportApplicationRequest.cs rename to src/Core/Dirt/Reports/ReportFeatures/Requests/AddPasswordHealthReportApplicationRequest.cs diff --git a/src/Core/Tools/ReportFeatures/Requests/DropPasswordHealthReportApplicationRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/DropPasswordHealthReportApplicationRequest.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/Requests/DropPasswordHealthReportApplicationRequest.cs rename to src/Core/Dirt/Reports/ReportFeatures/Requests/DropPasswordHealthReportApplicationRequest.cs diff --git a/src/Core/Tools/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs rename to src/Core/Dirt/Reports/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs diff --git a/src/Core/Tools/Repositories/IPasswordHealthReportApplicationRepository.cs b/src/Core/Dirt/Reports/Repositories/IPasswordHealthReportApplicationRepository.cs similarity index 100% rename from src/Core/Tools/Repositories/IPasswordHealthReportApplicationRepository.cs rename to src/Core/Dirt/Reports/Repositories/IPasswordHealthReportApplicationRepository.cs diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index 9878c96c1c..b3a6a9592e 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -128,6 +128,10 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac public bool IsExpired() => PremiumExpirationDate.HasValue && PremiumExpirationDate.Value <= DateTime.UtcNow; + /// + /// Deserializes the User.TwoFactorProviders property from JSON to the appropriate C# dictionary. + /// + /// Dictionary of TwoFactor providers public Dictionary? GetTwoFactorProviders() { if (string.IsNullOrWhiteSpace(TwoFactorProviders)) @@ -137,19 +141,17 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac try { - if (_twoFactorProviders == null) - { - _twoFactorProviders = - JsonHelpers.LegacyDeserialize>( - TwoFactorProviders); - } + _twoFactorProviders ??= + JsonHelpers.LegacyDeserialize>( + TwoFactorProviders); - // U2F is no longer supported, and all users keys should have been migrated to WebAuthn. - // To prevent issues with accounts being prompted for unsupported U2F we remove them - if (_twoFactorProviders.ContainsKey(TwoFactorProviderType.U2f)) - { - _twoFactorProviders.Remove(TwoFactorProviderType.U2f); - } + /* + U2F is no longer supported, and all users keys should have been migrated to WebAuthn. + To prevent issues with accounts being prompted for unsupported U2F we remove them. + This will probably exist in perpetuity since there is no way to know for sure if any + given user does or doesn't have this enabled. It is a non-zero chance. + */ + _twoFactorProviders?.Remove(TwoFactorProviderType.U2f); return _twoFactorProviders; } @@ -169,6 +171,10 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac return Premium; } + /// + /// Serializes the C# object to the User.TwoFactorProviders property in JSON format. + /// + /// Dictionary of Two Factor providers public void SetTwoFactorProviders(Dictionary providers) { // When replacing with system.text remember to remove the extra serialization in WebAuthnTokenProvider. @@ -176,20 +182,21 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac _twoFactorProviders = providers; } - public void ClearTwoFactorProviders() - { - SetTwoFactorProviders(new Dictionary()); - } - + /// + /// Checks if the user has a specific TwoFactorProvider configured. If a user has a premium TwoFactor + /// configured it will still be found, even if the user's premium subscription has ended. + /// + /// TwoFactor provider being searched for + /// TwoFactorProvider if found; null otherwise. public TwoFactorProvider? GetTwoFactorProvider(TwoFactorProviderType provider) { var providers = GetTwoFactorProviders(); - if (providers == null || !providers.ContainsKey(provider)) + if (providers == null || !providers.TryGetValue(provider, out var value)) { return null; } - return providers[provider]; + return value; } public long StorageBytesRemaining() diff --git a/src/Core/Enums/EnumExtensions.cs b/src/Core/Enums/EnumExtensions.cs new file mode 100644 index 0000000000..d60b530ffb --- /dev/null +++ b/src/Core/Enums/EnumExtensions.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using System.Reflection; + +namespace Bit.Core.Enums; + +public static class EnumExtensions +{ + public static string GetDisplayName(this Enum value) + { + var field = value.GetType().GetField(value.ToString()); + if (field?.GetCustomAttribute() is { } attribute) + { + return attribute.Name ?? value.ToString(); + } + + return value.ToString(); + } +} diff --git a/src/Core/MailTemplates/Handlebars/Auth/FailedLoginAttempts.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/FailedLoginAttempts.html.hbs deleted file mode 100644 index 43531ef242..0000000000 --- a/src/Core/MailTemplates/Handlebars/Auth/FailedLoginAttempts.html.hbs +++ /dev/null @@ -1,31 +0,0 @@ -{{#>FullHtmlLayout}} - - - - - - - - - - - - - - - - -
    - Additional security has been placed on your Bitwarden account. -
    - We've detected several failed attempts to log into your Bitwarden account. Future login attempts for your account will be protected by a captcha. -
    - Account: {{AffectedEmail}}
    - Date: {{TheDate}} at {{TheTime}} {{TimeZone}}
    - IP Address: {{IpAddress}}
    -
    - If this was you, you can remove the captcha requirement by successfully logging in. -
    - If this was not you, don't worry. The login attempt was not successful and your account has been given additional protection. -
    -{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/Auth/FailedLoginAttempts.text.hbs b/src/Core/MailTemplates/Handlebars/Auth/FailedLoginAttempts.text.hbs deleted file mode 100644 index 3393210e4e..0000000000 --- a/src/Core/MailTemplates/Handlebars/Auth/FailedLoginAttempts.text.hbs +++ /dev/null @@ -1,13 +0,0 @@ -{{#>BasicTextLayout}} -Additional security has been placed on your Bitwarden account. - -We've detected several failed attempts to log into your Bitwarden account. Future login attempts for your account will be protected by a captcha. - -Account: {{AffectedEmail}} -Date: {{TheDate}} at {{TheTime}} {{TimeZone}} -IP Address: {{IpAddress}} - -If this was you, you can remove the captcha requirement by successfully logging in. - -If this was not you, don't worry. The login attempt was not successful and your account has been given additional protection. -{{/BasicTextLayout}} \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempts.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempts.html.hbs deleted file mode 100644 index d73775f8e8..0000000000 --- a/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempts.html.hbs +++ /dev/null @@ -1,31 +0,0 @@ -{{#>FullHtmlLayout}} - - - - - - - - - - - - - - - - -
    - Additional security has been placed on your Bitwarden account. -
    - We've detected several failed attempts to log into your Bitwarden account. Future login attempts for your account will be protected by a captcha. -
    - Account: {{AffectedEmail}}
    - Date: {{TheDate}} at {{TheTime}} {{TimeZone}}
    - IP Address: {{IpAddress}}
    -
    - If this was you, you can remove the captcha requirement by successfully logging in. If you're having trouble with two step login, you can login using a recovery code. -
    - If this was not you, you should change your master password immediately. You can view our tips for selecting a secure master password here. -
    -{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempts.text.hbs b/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempts.text.hbs deleted file mode 100644 index e742d35578..0000000000 --- a/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempts.text.hbs +++ /dev/null @@ -1,13 +0,0 @@ -{{#>BasicTextLayout}} -Additional security has been placed on your Bitwarden account. - -We've detected several failed attempts to log into your Bitwarden account. Future login attempts for your account will be protected by a captcha. - -Account: {{AffectedEmail}} -Date: {{TheDate}} at {{TheTime}} {{TimeZone}} -IP Address: {{IpAddress}} - -If this was you, you can remove the captcha requirement by successfully logging in. If you're having trouble with two step login, you can login using a recovery code (https://bitwarden.com/help/two-step-recovery-code/). - -If this was not you, you should change your master password (https://bitwarden.com/help/master-password/#change-master-password) immediately. You can view our tips for selecting a secure master password here (https://bitwarden.com/blog/picking-the-right-password-for-your-password-manager/). -{{/BasicTextLayout}} \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/Billing/TrialInitiationVerifyEmail.html.hbs b/src/Core/MailTemplates/Handlebars/Billing/TrialInitiationVerifyEmail.html.hbs index 6c1b9edec0..5d379288ef 100644 --- a/src/Core/MailTemplates/Handlebars/Billing/TrialInitiationVerifyEmail.html.hbs +++ b/src/Core/MailTemplates/Handlebars/Billing/TrialInitiationVerifyEmail.html.hbs @@ -2,7 +2,7 @@ diff --git a/src/Core/MailTemplates/Handlebars/Billing/TrialInitiationVerifyEmail.text.hbs b/src/Core/MailTemplates/Handlebars/Billing/TrialInitiationVerifyEmail.text.hbs index 690cf77734..4e0d064e36 100644 --- a/src/Core/MailTemplates/Handlebars/Billing/TrialInitiationVerifyEmail.text.hbs +++ b/src/Core/MailTemplates/Handlebars/Billing/TrialInitiationVerifyEmail.text.hbs @@ -1,5 +1,5 @@ {{#>BasicTextLayout}} -Verify your email address using the link below and start your free trial of Bitwarden. +{{VerifyYourEmailTextCopy}} If you did not request this email from Bitwarden, you can safely ignore it. diff --git a/src/Core/MailTemplates/Handlebars/OrganizationDomainUnverified.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationDomainUnverified.html.hbs deleted file mode 100644 index 11b482acda..0000000000 --- a/src/Core/MailTemplates/Handlebars/OrganizationDomainUnverified.html.hbs +++ /dev/null @@ -1,27 +0,0 @@ -{{#>FullHtmlLayout}} -
    - Verify your email address below to finish signing up for your free trial. + {{VerifyYourEmailHTMLCopy}}
    - - - - - - - - - - - - -
    - The domain {{DomainName}} in your Bitwarden organization could not be verified. -
    - Check the corresponding record in your domain host. Then reverify this domain in Bitwarden to use it for your organization. -
    - The domain will be removed from your organization in 7 days if it is not verified. -
    - - Manage Domains - -
    -
    -{{/FullHtmlLayout}} \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/OrganizationDomainUnverified.text.hbs b/src/Core/MailTemplates/Handlebars/OrganizationDomainUnverified.text.hbs deleted file mode 100644 index f056bf26c3..0000000000 --- a/src/Core/MailTemplates/Handlebars/OrganizationDomainUnverified.text.hbs +++ /dev/null @@ -1,10 +0,0 @@ -{{#>BasicTextLayout}} -The domain {{DomainName}} in your Bitwarden organization could not be verified. - -Check the corresponding record in your domain host. Then reverify this domain in Bitwarden to use it for your organization. - -The domain will be removed from your organization in 7 days if it is not verified. - -{{Url}} - -{{/BasicTextLayout}} \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/OrganizationUserRemovedForPolicySingleOrg.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationUserRemovedForPolicySingleOrg.html.hbs deleted file mode 100644 index bd2e4eb946..0000000000 --- a/src/Core/MailTemplates/Handlebars/OrganizationUserRemovedForPolicySingleOrg.html.hbs +++ /dev/null @@ -1,9 +0,0 @@ -{{#>FullHtmlLayout}} - - - - -
    - Your user account has been removed from the {{OrganizationName}} organization because you are a part of another organization. The {{OrganizationName}} organization has enabled a policy that prevents users from being a part of multiple organizations. Before you can re-join this organization you need to leave all other organizations or join with a different account. -
    -{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/OrganizationUserRemovedForPolicySingleOrg.text.hbs b/src/Core/MailTemplates/Handlebars/OrganizationUserRemovedForPolicySingleOrg.text.hbs deleted file mode 100644 index 44ef628a90..0000000000 --- a/src/Core/MailTemplates/Handlebars/OrganizationUserRemovedForPolicySingleOrg.text.hbs +++ /dev/null @@ -1,5 +0,0 @@ -{{#>BasicTextLayout}} -Your user account has been removed from the {{OrganizationName}} organization because you are a part of another -organization. The {{OrganizationName}} has enabled a policy that prevents users from being a part of multiple organizations. Before you can re-join this organization you need to leave all other organizations, or join with a -new account. -{{/BasicTextLayout}} diff --git a/src/Core/MailTemplates/Handlebars/OrganizationUserRemovedForPolicyTwoStep.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationUserRemovedForPolicyTwoStep.html.hbs deleted file mode 100644 index e82dfcef27..0000000000 --- a/src/Core/MailTemplates/Handlebars/OrganizationUserRemovedForPolicyTwoStep.html.hbs +++ /dev/null @@ -1,15 +0,0 @@ -{{#>FullHtmlLayout}} - - - - - - - -
    - Your user account has been removed from the {{OrganizationName}} organization because you do not have two-step login configured. Before you can re-join this organization you need to set up two-step login on your user account. -
    - Learn how to enable two-step login on your user account at - https://help.bitwarden.com/article/setup-two-step-login/ -
    -{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/OrganizationUserRemovedForPolicyTwoStep.text.hbs b/src/Core/MailTemplates/Handlebars/OrganizationUserRemovedForPolicyTwoStep.text.hbs deleted file mode 100644 index a79afb588a..0000000000 --- a/src/Core/MailTemplates/Handlebars/OrganizationUserRemovedForPolicyTwoStep.text.hbs +++ /dev/null @@ -1,7 +0,0 @@ -{{#>BasicTextLayout}} -Your user account has been removed from the {{OrganizationName}} organization because you do not have two-step login -configured. Before you can re-join this organization you need to set up two-step login on your user account. - -Learn how to enable two-step login on your user account at - -{{/BasicTextLayout}} \ No newline at end of file diff --git a/src/Core/Models/Api/Response/OrganizationSponsorships/OrganizationSponsorshipInvitesResponseModel.cs b/src/Core/Models/Api/Response/OrganizationSponsorships/OrganizationSponsorshipInvitesResponseModel.cs new file mode 100644 index 0000000000..b75144c81b --- /dev/null +++ b/src/Core/Models/Api/Response/OrganizationSponsorships/OrganizationSponsorshipInvitesResponseModel.cs @@ -0,0 +1,37 @@ +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations.OrganizationSponsorships; + +namespace Bit.Core.Models.Api.Response.OrganizationSponsorships; + +public class OrganizationSponsorshipInvitesResponseModel : ResponseModel +{ + public OrganizationSponsorshipInvitesResponseModel(OrganizationSponsorshipData sponsorshipData, string obj = "organizationSponsorship") : base(obj) + { + if (sponsorshipData == null) + { + throw new ArgumentNullException(nameof(sponsorshipData)); + } + + SponsoringOrganizationUserId = sponsorshipData.SponsoringOrganizationUserId; + FriendlyName = sponsorshipData.FriendlyName; + OfferedToEmail = sponsorshipData.OfferedToEmail; + PlanSponsorshipType = sponsorshipData.PlanSponsorshipType; + LastSyncDate = sponsorshipData.LastSyncDate; + ValidUntil = sponsorshipData.ValidUntil; + ToDelete = sponsorshipData.ToDelete; + IsAdminInitiated = sponsorshipData.IsAdminInitiated; + Notes = sponsorshipData.Notes; + CloudSponsorshipRemoved = sponsorshipData.CloudSponsorshipRemoved; + } + + public Guid SponsoringOrganizationUserId { get; set; } + public string FriendlyName { get; set; } + public string OfferedToEmail { get; set; } + public PlanSponsorshipType PlanSponsorshipType { get; set; } + public DateTime? LastSyncDate { get; set; } + public DateTime? ValidUntil { get; set; } + public bool ToDelete { get; set; } + public bool IsAdminInitiated { get; set; } + public string Notes { get; set; } + public bool CloudSponsorshipRemoved { get; set; } +} diff --git a/src/Core/Models/Api/Response/OrganizationSponsorships/OrganizationSponsorshipResponseModel.cs b/src/Core/Models/Api/Response/OrganizationSponsorships/OrganizationSponsorshipResponseModel.cs index 58c1b2cffb..e082d98de6 100644 --- a/src/Core/Models/Api/Response/OrganizationSponsorships/OrganizationSponsorshipResponseModel.cs +++ b/src/Core/Models/Api/Response/OrganizationSponsorships/OrganizationSponsorshipResponseModel.cs @@ -14,6 +14,7 @@ public class OrganizationSponsorshipResponseModel public bool ToDelete { get; set; } public bool CloudSponsorshipRemoved { get; set; } + public bool IsAdminInitiated { get; set; } public OrganizationSponsorshipResponseModel() { } @@ -27,6 +28,7 @@ public class OrganizationSponsorshipResponseModel ValidUntil = sponsorshipData.ValidUntil; ToDelete = sponsorshipData.ToDelete; CloudSponsorshipRemoved = sponsorshipData.CloudSponsorshipRemoved; + IsAdminInitiated = sponsorshipData.IsAdminInitiated; } public OrganizationSponsorshipData ToOrganizationSponsorship() @@ -40,7 +42,8 @@ public class OrganizationSponsorshipResponseModel LastSyncDate = LastSyncDate, ValidUntil = ValidUntil, ToDelete = ToDelete, - CloudSponsorshipRemoved = CloudSponsorshipRemoved + CloudSponsorshipRemoved = CloudSponsorshipRemoved, + IsAdminInitiated = IsAdminInitiated, }; } diff --git a/src/Core/Models/Business/OrganizationSignup.cs b/src/Core/Models/Business/OrganizationSignup.cs index b5ac69e73f..b8bd670d21 100644 --- a/src/Core/Models/Business/OrganizationSignup.cs +++ b/src/Core/Models/Business/OrganizationSignup.cs @@ -16,4 +16,5 @@ public class OrganizationSignup : OrganizationUpgrade public string InitiationPath { get; set; } public bool IsFromSecretsManagerTrial { get; set; } public bool IsFromProvider { get; set; } + public bool SkipTrial { get; set; } } diff --git a/src/Core/Models/Commands/BadRequestFailure.cs b/src/Core/Models/Commands/BadRequestFailure.cs deleted file mode 100644 index bd2753d4e4..0000000000 --- a/src/Core/Models/Commands/BadRequestFailure.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Bit.Core.Models.Commands; - -public class BadRequestFailure : Failure -{ - public BadRequestFailure(IEnumerable errorMessage) : base(errorMessage) - { - } - - public BadRequestFailure(string errorMessage) : base(errorMessage) - { - } -} - -public class BadRequestFailure : Failure -{ - public BadRequestFailure(IEnumerable errorMessage) : base(errorMessage) - { - } - - public BadRequestFailure(string errorMessage) : base(errorMessage) - { - } -} diff --git a/src/Core/Models/Commands/CommandResult.cs b/src/Core/Models/Commands/CommandResult.cs deleted file mode 100644 index 4a9477067e..0000000000 --- a/src/Core/Models/Commands/CommandResult.cs +++ /dev/null @@ -1,88 +0,0 @@ -#nullable enable - -using Bit.Core.AdminConsole.Errors; -using Bit.Core.AdminConsole.Shared.Validation; - -namespace Bit.Core.Models.Commands; - -public class CommandResult(IEnumerable errors) -{ - public CommandResult(string error) : this([error]) { } - - public bool Success => ErrorMessages.Count == 0; - public bool HasErrors => ErrorMessages.Count > 0; - public List ErrorMessages { get; } = errors.ToList(); - public CommandResult() : this(Array.Empty()) { } -} - -public class Failure : CommandResult -{ - protected Failure(IEnumerable errorMessages) : base(errorMessages) - { - - } - public Failure(string errorMessage) : base(errorMessage) - { - - } -} - -public class Success : CommandResult -{ -} - -public abstract class CommandResult; - -public class Success(T value) : CommandResult -{ - public T Value { get; } = value; -} - -public class Failure(IEnumerable errorMessages) : CommandResult -{ - public List ErrorMessages { get; } = errorMessages.ToList(); - public Error[] Errors { get; set; } = []; - - public string ErrorMessage => string.Join(" ", ErrorMessages); - - public Failure(string error) : this([error]) - { - } - - public Failure(IEnumerable> errors) : this(errors.Select(e => e.Message)) - { - Errors = errors.ToArray(); - } - - public Failure(Error error) : this([error.Message]) - { - Errors = [error]; - } -} - -public class Partial : CommandResult -{ - public T[] Successes { get; set; } = []; - public Error[] Failures { get; set; } = []; - - public Partial(IEnumerable successfulItems, IEnumerable> failedItems) - { - Successes = successfulItems.ToArray(); - Failures = failedItems.ToArray(); - } -} - -public static class CommandResultExtensions -{ - /// - /// This is to help map between the InvalidT ValidationResult and the FailureT CommandResult types. - /// - /// - /// This is the invalid type from validating the object. - /// This function will map between the two types for the inner ErrorT - /// Invalid object's type - /// Failure object's type - /// - public static CommandResult MapToFailure(this Invalid
    invalidResult, Func mappingFunction) => - new Failure(invalidResult.Errors.Select(errorA => errorA.ToError(mappingFunction(errorA.ErroredValue)))); -} diff --git a/src/Core/Models/Commands/NoRecordFoundFailure.cs b/src/Core/Models/Commands/NoRecordFoundFailure.cs deleted file mode 100644 index a8a322b928..0000000000 --- a/src/Core/Models/Commands/NoRecordFoundFailure.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace Bit.Core.Models.Commands; - -public class NoRecordFoundFailure : Failure -{ - public NoRecordFoundFailure(IEnumerable errorMessage) : base(errorMessage) - { - } - - public NoRecordFoundFailure(string errorMessage) : base(errorMessage) - { - } -} - -public class NoRecordFoundFailure : Failure -{ - public NoRecordFoundFailure(IEnumerable errorMessage) : base(errorMessage) - { - } - - public NoRecordFoundFailure(string errorMessage) : base(errorMessage) - { - } -} - diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 164710d522..b016e329bf 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -193,6 +193,7 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } // TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs index f81a1d9e84..b15cbea240 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs @@ -15,7 +15,8 @@ public class CreateSponsorshipCommand( ICurrentContext currentContext, IOrganizationSponsorshipRepository organizationSponsorshipRepository, IUserService userService, - IOrganizationService organizationService) : ICreateSponsorshipCommand + IOrganizationService organizationService, + IOrganizationUserRepository organizationUserRepository) : ICreateSponsorshipCommand { public async Task CreateSponsorshipAsync( Organization sponsoringOrganization, @@ -47,11 +48,12 @@ public class CreateSponsorshipCommand( throw new BadRequestException("Only confirmed users can sponsor other organizations."); } - var existingOrgSponsorship = await organizationSponsorshipRepository - .GetBySponsoringOrganizationUserIdAsync(sponsoringMember.Id); - if (existingOrgSponsorship?.SponsoredOrganizationId != null) + var sponsorships = + await organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(sponsoringOrganization.Id); + var existingSponsorship = sponsorships.FirstOrDefault(s => s.FriendlyName == friendlyName); + if (existingSponsorship != null) { - throw new BadRequestException("Can only sponsor one organization per Organization User."); + return existingSponsorship; } if (isAdminInitiated) @@ -70,15 +72,37 @@ public class CreateSponsorshipCommand( Notes = notes }; - if (existingOrgSponsorship != null) + if (!isAdminInitiated) { - // Replace existing invalid offer with our new sponsorship offer - sponsorship.Id = existingOrgSponsorship.Id; + var existingOrgSponsorship = await organizationSponsorshipRepository + .GetBySponsoringOrganizationUserIdAsync(sponsoringMember.Id); + if (existingOrgSponsorship?.SponsoredOrganizationId != null) + { + throw new BadRequestException("Can only sponsor one organization per Organization User."); + } + + if (existingOrgSponsorship != null) + { + sponsorship.Id = existingOrgSponsorship.Id; + } } if (isAdminInitiated && sponsoringOrganization.Seats.HasValue) { - await organizationService.AutoAddSeatsAsync(sponsoringOrganization, 1); + var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrganization.Id); + var availableSeats = sponsoringOrganization.Seats.Value - occupiedSeats; + + if (availableSeats <= 0) + { + var newSeatsRequired = 1; + var (canScale, failureReason) = await organizationService.CanScaleAsync(sponsoringOrganization, newSeatsRequired); + if (!canScale) + { + throw new BadRequestException(failureReason); + } + + await organizationService.AutoAddSeatsAsync(sponsoringOrganization, newSeatsRequired); + } } try diff --git a/src/Core/PhishingDomainFeatures/AzurePhishingDomainStorageService.cs b/src/Core/PhishingDomainFeatures/AzurePhishingDomainStorageService.cs new file mode 100644 index 0000000000..0d287a2229 --- /dev/null +++ b/src/Core/PhishingDomainFeatures/AzurePhishingDomainStorageService.cs @@ -0,0 +1,92 @@ +using System.Text; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using Bit.Core.Settings; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.PhishingDomainFeatures; + +public class AzurePhishingDomainStorageService +{ + private const string _containerName = "phishingdomains"; + private const string _domainsFileName = "domains.txt"; + private const string _checksumFileName = "checksum.txt"; + + private readonly BlobServiceClient _blobServiceClient; + private readonly ILogger _logger; + private BlobContainerClient _containerClient; + + public AzurePhishingDomainStorageService( + GlobalSettings globalSettings, + ILogger logger) + { + _blobServiceClient = new BlobServiceClient(globalSettings.Storage.ConnectionString); + _logger = logger; + } + + public async Task> GetDomainsAsync() + { + await InitAsync(); + + var blobClient = _containerClient.GetBlobClient(_domainsFileName); + if (!await blobClient.ExistsAsync()) + { + return []; + } + + var response = await blobClient.DownloadAsync(); + using var streamReader = new StreamReader(response.Value.Content); + var content = await streamReader.ReadToEndAsync(); + + return [.. content + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Trim()) + .Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith('#'))]; + } + + public async Task GetChecksumAsync() + { + await InitAsync(); + + var blobClient = _containerClient.GetBlobClient(_checksumFileName); + if (!await blobClient.ExistsAsync()) + { + return string.Empty; + } + + var response = await blobClient.DownloadAsync(); + using var streamReader = new StreamReader(response.Value.Content); + return (await streamReader.ReadToEndAsync()).Trim(); + } + + public async Task UpdateDomainsAsync(IEnumerable domains, string checksum) + { + await InitAsync(); + + var domainsContent = string.Join(Environment.NewLine, domains); + var domainsStream = new MemoryStream(Encoding.UTF8.GetBytes(domainsContent)); + var domainsBlobClient = _containerClient.GetBlobClient(_domainsFileName); + + await domainsBlobClient.UploadAsync(domainsStream, new BlobUploadOptions + { + HttpHeaders = new BlobHttpHeaders { ContentType = "text/plain" } + }, CancellationToken.None); + + var checksumStream = new MemoryStream(Encoding.UTF8.GetBytes(checksum)); + var checksumBlobClient = _containerClient.GetBlobClient(_checksumFileName); + + await checksumBlobClient.UploadAsync(checksumStream, new BlobUploadOptions + { + HttpHeaders = new BlobHttpHeaders { ContentType = "text/plain" } + }, CancellationToken.None); + } + + private async Task InitAsync() + { + if (_containerClient is null) + { + _containerClient = _blobServiceClient.GetBlobContainerClient(_containerName); + await _containerClient.CreateIfNotExistsAsync(); + } + } +} diff --git a/src/Core/PhishingDomainFeatures/CloudPhishingDomainDirectQuery.cs b/src/Core/PhishingDomainFeatures/CloudPhishingDomainDirectQuery.cs new file mode 100644 index 0000000000..420948e310 --- /dev/null +++ b/src/Core/PhishingDomainFeatures/CloudPhishingDomainDirectQuery.cs @@ -0,0 +1,100 @@ +using Bit.Core.PhishingDomainFeatures.Interfaces; +using Bit.Core.Settings; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.PhishingDomainFeatures; + +/// +/// Implementation of ICloudPhishingDomainQuery for cloud environments +/// that directly calls the external phishing domain source +/// +public class CloudPhishingDomainDirectQuery : ICloudPhishingDomainQuery +{ + private readonly IGlobalSettings _globalSettings; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public CloudPhishingDomainDirectQuery( + IGlobalSettings globalSettings, + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _globalSettings = globalSettings; + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public async Task> GetPhishingDomainsAsync() + { + if (string.IsNullOrWhiteSpace(_globalSettings.PhishingDomain?.UpdateUrl)) + { + throw new InvalidOperationException("Phishing domain update URL is not configured."); + } + + var httpClient = _httpClientFactory.CreateClient("PhishingDomains"); + var response = await httpClient.GetAsync(_globalSettings.PhishingDomain.UpdateUrl); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + return ParseDomains(content); + } + + /// + /// Gets the SHA256 checksum of the remote phishing domains list + /// + /// The SHA256 checksum as a lowercase hex string + public async Task GetRemoteChecksumAsync() + { + if (string.IsNullOrWhiteSpace(_globalSettings.PhishingDomain?.ChecksumUrl)) + { + _logger.LogWarning("Phishing domain checksum URL is not configured."); + return string.Empty; + } + + try + { + var httpClient = _httpClientFactory.CreateClient("PhishingDomains"); + var response = await httpClient.GetAsync(_globalSettings.PhishingDomain.ChecksumUrl); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + return ParseChecksumResponse(content); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving phishing domain checksum from {Url}", + _globalSettings.PhishingDomain.ChecksumUrl); + return string.Empty; + } + } + + /// + /// Parses a checksum response in the format "hash *filename" + /// + private static string ParseChecksumResponse(string checksumContent) + { + if (string.IsNullOrWhiteSpace(checksumContent)) + { + return string.Empty; + } + + // Format is typically "hash *filename" + var parts = checksumContent.Split(' ', 2); + + return parts.Length > 0 ? parts[0].Trim() : string.Empty; + } + + private static List ParseDomains(string content) + { + if (string.IsNullOrWhiteSpace(content)) + { + return []; + } + + return content + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Trim()) + .Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith("#")) + .ToList(); + } +} diff --git a/src/Core/PhishingDomainFeatures/CloudPhishingDomainRelayQuery.cs b/src/Core/PhishingDomainFeatures/CloudPhishingDomainRelayQuery.cs new file mode 100644 index 0000000000..2685d36a7f --- /dev/null +++ b/src/Core/PhishingDomainFeatures/CloudPhishingDomainRelayQuery.cs @@ -0,0 +1,66 @@ +using Bit.Core.PhishingDomainFeatures.Interfaces; +using Bit.Core.Services; +using Bit.Core.Settings; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.PhishingDomainFeatures; + +/// +/// Implementation of ICloudPhishingDomainQuery for self-hosted environments +/// that relays the request to the Bitwarden cloud API +/// +public class CloudPhishingDomainRelayQuery : BaseIdentityClientService, ICloudPhishingDomainQuery +{ + private readonly IGlobalSettings _globalSettings; + + public CloudPhishingDomainRelayQuery( + IHttpClientFactory httpFactory, + IGlobalSettings globalSettings, + ILogger logger) + : base( + httpFactory, + globalSettings.Installation.ApiUri, + globalSettings.Installation.IdentityUri, + "api.licensing", + $"installation.{globalSettings.Installation.Id}", + globalSettings.Installation.Key, + logger) + { + _globalSettings = globalSettings; + } + + public async Task> GetPhishingDomainsAsync() + { + if (!_globalSettings.SelfHosted || !_globalSettings.EnableCloudCommunication) + { + throw new InvalidOperationException("This query is only for self-hosted installations with cloud communication enabled."); + } + + var result = await SendAsync(HttpMethod.Get, "phishing-domains", null, true); + return result?.ToList() ?? new List(); + } + + /// + /// Gets the SHA256 checksum of the remote phishing domains list + /// + /// The SHA256 checksum as a lowercase hex string + public async Task GetRemoteChecksumAsync() + { + if (!_globalSettings.SelfHosted || !_globalSettings.EnableCloudCommunication) + { + throw new InvalidOperationException("This query is only for self-hosted installations with cloud communication enabled."); + } + + try + { + // For self-hosted environments, we get the checksum from the Bitwarden cloud API + var result = await SendAsync(HttpMethod.Get, "phishing-domains/checksum", null, true); + return result ?? string.Empty; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving phishing domain checksum from Bitwarden cloud API"); + return string.Empty; + } + } +} diff --git a/src/Core/PhishingDomainFeatures/Interfaces/ICloudPhishingDomainQuery.cs b/src/Core/PhishingDomainFeatures/Interfaces/ICloudPhishingDomainQuery.cs new file mode 100644 index 0000000000..dac91747f7 --- /dev/null +++ b/src/Core/PhishingDomainFeatures/Interfaces/ICloudPhishingDomainQuery.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.PhishingDomainFeatures.Interfaces; + +public interface ICloudPhishingDomainQuery +{ + Task> GetPhishingDomainsAsync(); + Task GetRemoteChecksumAsync(); +} diff --git a/src/Core/Repositories/IOrganizationSponsorshipRepository.cs b/src/Core/Repositories/IOrganizationSponsorshipRepository.cs index 30e6ee4a33..00cf6c8cce 100644 --- a/src/Core/Repositories/IOrganizationSponsorshipRepository.cs +++ b/src/Core/Repositories/IOrganizationSponsorshipRepository.cs @@ -11,7 +11,7 @@ public interface IOrganizationSponsorshipRepository : IRepository organizationSponsorships); Task DeleteManyAsync(IEnumerable organizationSponsorshipIds); Task> GetManyBySponsoringOrganizationAsync(Guid sponsoringOrganizationId); - Task GetBySponsoringOrganizationUserIdAsync(Guid sponsoringOrganizationUserId); + Task GetBySponsoringOrganizationUserIdAsync(Guid sponsoringOrganizationUserId, bool isAdminInitiated = false); Task GetBySponsoredOrganizationIdAsync(Guid sponsoredOrganizationId); Task GetLatestSyncDateBySponsoringOrganizationIdAsync(Guid sponsoringOrganizationId); } diff --git a/src/Core/Repositories/IPhishingDomainRepository.cs b/src/Core/Repositories/IPhishingDomainRepository.cs new file mode 100644 index 0000000000..2d653b0a43 --- /dev/null +++ b/src/Core/Repositories/IPhishingDomainRepository.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.Repositories; + +public interface IPhishingDomainRepository +{ + Task> GetActivePhishingDomainsAsync(); + Task UpdatePhishingDomainsAsync(IEnumerable domains, string checksum); + Task GetCurrentChecksumAsync(); +} diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index 0e59b9998f..22effb4329 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -25,6 +25,16 @@ public interface IUserRepository : IRepository ///
    Task> GetManyWithCalculatedPremiumAsync(IEnumerable ids); /// + /// Retrieves the data for the requested user ID and includes additional property indicating + /// whether the user has premium access directly or through an organization. + /// + /// Calls the same stored procedure as GetManyWithCalculatedPremiumAsync but handles the query + /// for a single user. + /// + /// The user ID to retrieve data for. + /// User data with calculated premium access; null if nothing is found + Task GetCalculatedPremiumAsync(Guid userId); + /// /// Sets a new user key and updates all encrypted data. /// Warning: Any user key encrypted data not included will be lost. /// diff --git a/src/Core/Repositories/Implementations/AzurePhishingDomainRepository.cs b/src/Core/Repositories/Implementations/AzurePhishingDomainRepository.cs new file mode 100644 index 0000000000..2d4ea15b7e --- /dev/null +++ b/src/Core/Repositories/Implementations/AzurePhishingDomainRepository.cs @@ -0,0 +1,126 @@ +using System.Text.Json; +using Bit.Core.PhishingDomainFeatures; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Repositories.Implementations; + +public class AzurePhishingDomainRepository : IPhishingDomainRepository +{ + private readonly AzurePhishingDomainStorageService _storageService; + private readonly IDistributedCache _cache; + private readonly ILogger _logger; + private const string _domainsCacheKey = "PhishingDomains_v1"; + private const string _checksumCacheKey = "PhishingDomains_Checksum_v1"; + private static readonly DistributedCacheEntryOptions _cacheOptions = new() + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24), + SlidingExpiration = TimeSpan.FromHours(1) + }; + + public AzurePhishingDomainRepository( + AzurePhishingDomainStorageService storageService, + IDistributedCache cache, + ILogger logger) + { + _storageService = storageService; + _cache = cache; + _logger = logger; + } + + public async Task> GetActivePhishingDomainsAsync() + { + try + { + var cachedDomains = await _cache.GetStringAsync(_domainsCacheKey); + if (!string.IsNullOrEmpty(cachedDomains)) + { + _logger.LogDebug("Retrieved phishing domains from cache"); + return JsonSerializer.Deserialize>(cachedDomains) ?? []; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to retrieve phishing domains from cache"); + } + + var domains = await _storageService.GetDomainsAsync(); + + try + { + await _cache.SetStringAsync( + _domainsCacheKey, + JsonSerializer.Serialize(domains), + _cacheOptions); + _logger.LogDebug("Stored {Count} phishing domains in cache", domains.Count); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to store phishing domains in cache"); + } + + return domains; + } + + public async Task GetCurrentChecksumAsync() + { + try + { + var cachedChecksum = await _cache.GetStringAsync(_checksumCacheKey); + if (!string.IsNullOrEmpty(cachedChecksum)) + { + _logger.LogDebug("Retrieved phishing domain checksum from cache"); + return cachedChecksum; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to retrieve phishing domain checksum from cache"); + } + + var checksum = await _storageService.GetChecksumAsync(); + + try + { + if (!string.IsNullOrEmpty(checksum)) + { + await _cache.SetStringAsync( + _checksumCacheKey, + checksum, + _cacheOptions); + _logger.LogDebug("Stored phishing domain checksum in cache"); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to store phishing domain checksum in cache"); + } + + return checksum; + } + + public async Task UpdatePhishingDomainsAsync(IEnumerable domains, string checksum) + { + var domainsList = domains.ToList(); + await _storageService.UpdateDomainsAsync(domainsList, checksum); + + try + { + await _cache.SetStringAsync( + _domainsCacheKey, + JsonSerializer.Serialize(domainsList), + _cacheOptions); + + await _cache.SetStringAsync( + _checksumCacheKey, + checksum, + _cacheOptions); + + _logger.LogDebug("Updated phishing domains cache after update operation"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update phishing domains in cache"); + } + } +} diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 9b05810eaa..aa1c0c8c25 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -21,7 +21,8 @@ public interface IMailService string email, string token, ProductTierType productTier, - IEnumerable products); + IEnumerable products, + int trialLength); Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token); Task SendCannotDeleteClaimedAccountEmailAsync(string email); Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail); @@ -39,7 +40,6 @@ public interface IMailService Task SendOrganizationAutoscaledEmailAsync(Organization organization, int initialSeatCount, IEnumerable ownerEmails); Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable adminEmails, bool hasAccessSecretsManager = false); Task SendOrganizationConfirmedEmailAsync(string organizationName, string email, bool hasAccessSecretsManager = false); - Task SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(string organizationName, string email); Task SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(string organizationName, string email); Task SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(string organizationName, string email); Task SendPasswordlessSignInAsync(string returnUrl, string token, string email); @@ -60,7 +60,6 @@ public interface IMailService Task SendLicenseExpiredAsync(IEnumerable emails, string? organizationName = null); Task SendNewDeviceLoggedInEmail(string email, string deviceType, DateTime timestamp, string ip); Task SendRecoverTwoFactorEmail(string email, DateTime timestamp, string ip); - Task SendOrganizationUserRemovedForPolicySingleOrgEmailAsync(string organizationName, string email); Task SendEmergencyAccessInviteEmailAsync(EmergencyAccess emergencyAccess, string name, string token); Task SendEmergencyAccessAcceptedEmailAsync(string granteeEmail, string email); Task SendEmergencyAccessConfirmedEmailAsync(string grantorName, string email); @@ -87,9 +86,6 @@ public interface IMailService Task SendFamiliesForEnterpriseRedeemedEmailsAsync(string familyUserEmail, string sponsorEmail); Task SendFamiliesForEnterpriseSponsorshipRevertingEmailAsync(string email, DateTime expirationDate); Task SendOTPEmailAsync(string email, string token); - Task SendFailedLoginAttemptsEmailAsync(string email, DateTime utcNow, string ip); - Task SendFailedTwoFactorAttemptsEmailAsync(string email, DateTime utcNow, string ip); - Task SendUnverifiedOrganizationDomainEmailAsync(IEnumerable adminEmails, string organizationId, string domainName); Task SendUnclaimedOrganizationDomainEmailAsync(IEnumerable adminEmails, string organizationId, string domainName); Task SendSecretsManagerMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable ownerEmails); Task SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable ownerEmails); diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index ded9f4cfd3..3fdb829cf4 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -1,9 +1,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.Billing.Models; -using Bit.Core.Billing.Models.Api.Requests.Accounts; -using Bit.Core.Billing.Models.Api.Requests.Organizations; -using Bit.Core.Billing.Models.Api.Responses; +using Bit.Core.Billing.Tax.Requests; +using Bit.Core.Billing.Tax.Responses; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 9b12713218..e63b4e3b87 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -71,11 +71,13 @@ public interface IUserService Task GenerateLicenseAsync(User user, SubscriptionInfo subscriptionInfo = null, int? version = null); Task CheckPasswordAsync(User user, string password); + /// + /// Checks if the user has access to premium features, either through a personal subscription or through an organization. + /// + /// user being acted on + /// true if they can access premium; false otherwise. Task CanAccessPremium(ITwoFactorProvidersUser user); Task HasPremiumFromOrganization(ITwoFactorProvidersUser user); - [Obsolete("Use ITwoFactorIsEnabledQuery instead.")] - Task TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user); - Task TwoFactorProviderIsEnabledAsync(TwoFactorProviderType provider, ITwoFactorProvidersUser user); Task GenerateSignInTokenAsync(User user, string purpose); Task UpdatePasswordHash(User user, string newPassword, @@ -131,16 +133,11 @@ public interface IUserService /// verified domains of that organization, and the user is a member of it. /// The organization must be enabled and able to have verified domains. /// - /// - /// False if the Account Deprovisioning feature flag is disabled. - /// Task IsClaimedByAnyOrganizationAsync(Guid userId); /// /// Verify whether the new email domain meets the requirements for managed users. /// - /// - /// /// /// IdentityResult /// @@ -149,9 +146,6 @@ public interface IUserService /// /// Gets the organizations that manage the user. /// - /// - /// An empty collection if the Account Deprovisioning feature flag is disabled. - /// /// Task> GetOrganizationsClaimingUserAsync(Guid userId); } diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 1fca85eff4..20f6e3a0ab 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -84,7 +84,8 @@ public class HandlebarsMailService : IMailService string email, string token, ProductTierType productTier, - IEnumerable products) + IEnumerable products, + int trialLength) { var message = CreateDefaultMessage("Verify your email", email); var model = new TrialInitiationVerifyEmail @@ -95,7 +96,8 @@ public class HandlebarsMailService : IMailService WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, SiteName = _globalSettings.SiteName, ProductTier = productTier, - Product = products + Product = products, + TrialLength = trialLength }; await AddMessageContentAsync(message, "Billing.TrialInitiationVerifyEmail", model); message.MetaData.Add("SendGridBypassListManagement", true); @@ -299,20 +301,6 @@ public class HandlebarsMailService : IMailService await EnqueueMailAsync(messageModels); } - public async Task SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(string organizationName, string email) - { - var message = CreateDefaultMessage($"You have been removed from {organizationName}", email); - var model = new OrganizationUserRemovedForPolicyTwoStepViewModel - { - OrganizationName = CoreHelpers.SanitizeForEmail(organizationName, false), - WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, - SiteName = _globalSettings.SiteName - }; - await AddMessageContentAsync(message, "OrganizationUserRemovedForPolicyTwoStep", model); - message.Category = "OrganizationUserRemovedForPolicyTwoStep"; - await _mailDeliveryService.SendEmailAsync(message); - } - public async Task SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(string organizationName, string email) { var message = CreateDefaultMessage($"You have been revoked from {organizationName}", email); @@ -530,20 +518,6 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } - public async Task SendOrganizationUserRemovedForPolicySingleOrgEmailAsync(string organizationName, string email) - { - var message = CreateDefaultMessage($"You have been removed from {organizationName}", email); - var model = new OrganizationUserRemovedForPolicySingleOrgViewModel - { - OrganizationName = CoreHelpers.SanitizeForEmail(organizationName, false), - WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, - SiteName = _globalSettings.SiteName - }; - await AddMessageContentAsync(message, "OrganizationUserRemovedForPolicySingleOrg", model); - message.Category = "OrganizationUserRemovedForPolicySingleOrg"; - await _mailDeliveryService.SendEmailAsync(message); - } - public async Task SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(string organizationName, string email) { var message = CreateDefaultMessage($"You have been revoked from {organizationName}", email); @@ -1135,53 +1109,6 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } - public async Task SendFailedLoginAttemptsEmailAsync(string email, DateTime utcNow, string ip) - { - var message = CreateDefaultMessage("Failed login attempts detected", email); - var model = new FailedAuthAttemptsModel() - { - TheDate = utcNow.ToLongDateString(), - TheTime = utcNow.ToShortTimeString(), - TimeZone = _utcTimeZoneDisplay, - IpAddress = ip, - AffectedEmail = email - - }; - await AddMessageContentAsync(message, "Auth.FailedLoginAttempts", model); - message.Category = "FailedLoginAttempts"; - await _mailDeliveryService.SendEmailAsync(message); - } - - public async Task SendFailedTwoFactorAttemptsEmailAsync(string email, DateTime utcNow, string ip) - { - var message = CreateDefaultMessage("Failed login attempts detected", email); - var model = new FailedAuthAttemptsModel() - { - TheDate = utcNow.ToLongDateString(), - TheTime = utcNow.ToShortTimeString(), - TimeZone = _utcTimeZoneDisplay, - IpAddress = ip, - AffectedEmail = email - - }; - await AddMessageContentAsync(message, "Auth.FailedTwoFactorAttempts", model); - message.Category = "FailedTwoFactorAttempts"; - await _mailDeliveryService.SendEmailAsync(message); - } - - public async Task SendUnverifiedOrganizationDomainEmailAsync(IEnumerable adminEmails, string organizationId, string domainName) - { - var message = CreateDefaultMessage("Domain not verified", adminEmails); - var model = new OrganizationDomainUnverifiedViewModel - { - Url = $"{_globalSettings.BaseServiceUri.VaultWithHash}/organizations/{organizationId}/settings/domain-verification", - DomainName = domainName - }; - await AddMessageContentAsync(message, "OrganizationDomainUnverified", model); - message.Category = "UnverifiedOrganizationDomain"; - await _mailDeliveryService.SendEmailAsync(message); - } - public async Task SendUnclaimedOrganizationDomainEmailAsync(IEnumerable adminEmails, string organizationId, string domainName) { var message = CreateDefaultMessage("Domain not claimed", adminEmails); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 51be369527..65c0525535 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -3,14 +3,15 @@ using Bit.Core.AdminConsole.Models.Business; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; -using Bit.Core.Billing.Models.Api.Requests.Accounts; -using Bit.Core.Billing.Models.Api.Requests.Organizations; -using Bit.Core.Billing.Models.Api.Responses; using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Contracts; -using Bit.Core.Billing.Services.Implementations.AutomaticTax; +using Bit.Core.Billing.Tax.Models; +using Bit.Core.Billing.Tax.Requests; +using Bit.Core.Billing.Tax.Responses; +using Bit.Core.Billing.Tax.Services; +using Bit.Core.Billing.Tax.Services.Implementations; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -112,6 +113,8 @@ public class StripePaymentService : IPaymentService throw new BadRequestException("You do not have an active subscription. Reinstate your subscription to make changes."); } + var existingCoupon = sub.Customer.Discount?.Coupon?.Id; + var collectionMethod = sub.CollectionMethod; var daysUntilDue = sub.DaysUntilDue; var chargeNow = collectionMethod == "charge_automatically"; @@ -216,6 +219,19 @@ public class StripePaymentService : IPaymentService DaysUntilDue = daysUntilDue, }); } + + var customer = await _stripeAdapter.CustomerGetAsync(sub.CustomerId); + + var newCoupon = customer.Discount?.Coupon?.Id; + + if (!string.IsNullOrEmpty(existingCoupon) && string.IsNullOrEmpty(newCoupon)) + { + // Re-add the lost coupon due to the update. + await _stripeAdapter.CustomerUpdateAsync(sub.CustomerId, new CustomerUpdateOptions + { + Coupon = existingCoupon + }); + } } return paymentIntentClientSecret; diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index de0fa427ba..151ff38aa5 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -11,9 +11,12 @@ using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -45,7 +48,6 @@ namespace Bit.Core.Services; public class UserService : UserManager, IUserService, IDisposable { private const string PremiumPlanId = "premium-annually"; - private const string StoragePlanId = "storage-gb-annually"; private readonly IUserRepository _userRepository; private readonly ICipherRepository _cipherRepository; @@ -77,6 +79,7 @@ public class UserService : UserManager, IUserService, IDisposable private readonly IPremiumUserBillingService _premiumUserBillingService; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IRevokeNonCompliantOrganizationUserCommand _revokeNonCompliantOrganizationUserCommand; + private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IDistributedCache _distributedCache; public UserService( @@ -115,6 +118,7 @@ public class UserService : UserManager, IUserService, IDisposable IPremiumUserBillingService premiumUserBillingService, IRemoveOrganizationUserCommand removeOrganizationUserCommand, IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand, + ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IDistributedCache distributedCache) : base( store, @@ -158,6 +162,7 @@ public class UserService : UserManager, IUserService, IDisposable _premiumUserBillingService = premiumUserBillingService; _removeOrganizationUserCommand = removeOrganizationUserCommand; _revokeNonCompliantOrganizationUserCommand = revokeNonCompliantOrganizationUserCommand; + _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _distributedCache = distributedCache; } @@ -918,7 +923,7 @@ public class UserService : UserManager, IUserService, IDisposable await SaveUserAsync(user); await _eventService.LogUserEventAsync(user.Id, EventType.User_Disabled2fa); - if (!await TwoFactorIsEnabledAsync(user)) + if (!await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user)) { await CheckPoliciesOnTwoFactorRemovalAsync(user); } @@ -1106,12 +1111,12 @@ public class UserService : UserManager, IUserService, IDisposable } var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, user, storageAdjustmentGb, - StoragePlanId); + StripeConstants.Prices.StoragePlanPersonal); await _referenceEventService.RaiseEventAsync( new ReferenceEvent(ReferenceEventType.AdjustStorage, user, _currentContext) { Storage = storageAdjustmentGb, - PlanName = StoragePlanId, + PlanName = StripeConstants.Prices.StoragePlanPersonal, }); await SaveUserAsync(user); return secret; @@ -1280,48 +1285,6 @@ public class UserService : UserManager, IUserService, IDisposable orgAbility.UsersGetPremium && orgAbility.Enabled); } - - public async Task TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user) - { - var providers = user.GetTwoFactorProviders(); - if (providers == null) - { - return false; - } - - foreach (var p in providers) - { - if (p.Value?.Enabled ?? false) - { - if (!TwoFactorProvider.RequiresPremium(p.Key)) - { - return true; - } - if (await CanAccessPremium(user)) - { - return true; - } - } - } - return false; - } - - public async Task TwoFactorProviderIsEnabledAsync(TwoFactorProviderType provider, ITwoFactorProvidersUser user) - { - var providers = user.GetTwoFactorProviders(); - if (providers == null || !providers.ContainsKey(provider) || !providers[provider].Enabled) - { - return false; - } - - if (!TwoFactorProvider.RequiresPremium(provider)) - { - return true; - } - - return await CanAccessPremium(user); - } - public async Task GenerateSignInTokenAsync(User user, string purpose) { var token = await GenerateUserTokenAsync(user, Options.Tokens.PasswordResetTokenProvider, @@ -1374,11 +1337,6 @@ public class UserService : UserManager, IUserService, IDisposable public async Task> GetOrganizationsClaimingUserAsync(Guid userId) { - if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) - { - return Enumerable.Empty(); - } - // Get all organizations that have verified the user's email domain. var organizationsWithVerifiedUserEmailDomain = await _organizationRepository.GetByVerifiedUserEmailDomainAsync(userId); @@ -1443,22 +1401,12 @@ public class UserService : UserManager, IUserService, IDisposable var removeOrgUserTasks = twoFactorPolicies.Select(async p => { var organization = await _organizationRepository.GetByIdAsync(p.OrganizationId); - if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) - { - await _revokeNonCompliantOrganizationUserCommand.RevokeNonCompliantOrganizationUsersAsync( - new RevokeOrganizationUsersRequest( - p.OrganizationId, - [new OrganizationUserUserDetails { Id = p.OrganizationUserId, OrganizationId = p.OrganizationId }], - new SystemUser(EventSystemUser.TwoFactorDisabled))); - await _mailService.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(), user.Email); - } - else - { - await _removeOrganizationUserCommand.RemoveUserAsync(p.OrganizationId, user.Id); - await _mailService.SendOrganizationUserRemovedForPolicyTwoStepEmailAsync( - organization.DisplayName(), user.Email); - } - + await _revokeNonCompliantOrganizationUserCommand.RevokeNonCompliantOrganizationUsersAsync( + new RevokeOrganizationUsersRequest( + p.OrganizationId, + [new OrganizationUserUserDetails { Id = p.OrganizationUserId, OrganizationId = p.OrganizationId }], + new SystemUser(EventSystemUser.TwoFactorDisabled))); + await _mailService.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(), user.Email); }).ToArray(); await Task.WhenAll(removeOrgUserTasks); diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index cd5c1af8a8..26858911a8 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -33,7 +33,8 @@ public class NoopMailService : IMailService string email, string token, ProductTierType productTier, - IEnumerable products) + IEnumerable products, + int trailLength) { return Task.FromResult(0); } @@ -79,11 +80,6 @@ public class NoopMailService : IMailService return Task.FromResult(0); } - public Task SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(string organizationName, string email) - { - return Task.FromResult(0); - } - public Task SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(string organizationName, string email) => Task.CompletedTask; @@ -154,11 +150,6 @@ public class NoopMailService : IMailService return Task.FromResult(0); } - public Task SendOrganizationUserRemovedForPolicySingleOrgEmailAsync(string organizationName, string email) - { - return Task.FromResult(0); - } - public Task SendEmergencyAccessInviteEmailAsync(EmergencyAccess emergencyAccess, string name, string token) { return Task.FromResult(0); @@ -267,21 +258,6 @@ public class NoopMailService : IMailService return Task.FromResult(0); } - public Task SendFailedLoginAttemptsEmailAsync(string email, DateTime utcNow, string ip) - { - return Task.FromResult(0); - } - - public Task SendFailedTwoFactorAttemptsEmailAsync(string email, DateTime utcNow, string ip) - { - return Task.FromResult(0); - } - - public Task SendUnverifiedOrganizationDomainEmailAsync(IEnumerable adminEmails, string organizationId, string domainName) - { - return Task.FromResult(0); - } - public Task SendUnclaimedOrganizationDomainEmailAsync(IEnumerable adminEmails, string organizationId, string domainName) { return Task.FromResult(0); diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 2b658c65b3..d31e18b955 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -45,7 +45,6 @@ public class GlobalSettings : IGlobalSettings public virtual bool EnableCloudCommunication { get; set; } = false; public virtual int OrganizationInviteExpirationHours { get; set; } = 120; // 5 days public virtual string EventGridKey { get; set; } - public virtual CaptchaSettings Captcha { get; set; } = new CaptchaSettings(); public virtual IInstallationSettings Installation { get; set; } = new InstallationSettings(); public virtual IBaseServiceUriSettings BaseServiceUri { get; set; } public virtual string DatabaseProvider { get; set; } @@ -85,6 +84,7 @@ public class GlobalSettings : IGlobalSettings public virtual ILaunchDarklySettings LaunchDarkly { get; set; } = new LaunchDarklySettings(); public virtual string DevelopmentDirectory { get; set; } public virtual IWebPushSettings WebPush { get; set; } = new WebPushSettings(); + public virtual IPhishingDomainSettings PhishingDomain { get; set; } = new PhishingDomainSettings(); public virtual bool EnableEmailVerification { get; set; } public virtual string KdfDefaultHashKey { get; set; } @@ -628,22 +628,18 @@ public class GlobalSettings : IGlobalSettings public bool EnforceSsoPolicyForAllUsers { get; set; } } - public class CaptchaSettings - { - public bool ForceCaptchaRequired { get; set; } = false; - public string HCaptchaSecretKey { get; set; } - public string HCaptchaSiteKey { get; set; } - public int MaximumFailedLoginAttempts { get; set; } - public double MaybeBotScoreThreshold { get; set; } = double.MaxValue; - public double IsBotScoreThreshold { get; set; } = double.MaxValue; - } - public class StripeSettings { public string ApiKey { get; set; } public int MaxNetworkRetries { get; set; } = 2; } + public class PhishingDomainSettings : IPhishingDomainSettings + { + public string UpdateUrl { get; set; } + public string ChecksumUrl { get; set; } + } + public class DistributedIpRateLimitingSettings { public string RedisConnectionString { get; set; } diff --git a/src/Core/Settings/IGlobalSettings.cs b/src/Core/Settings/IGlobalSettings.cs index 411014ea32..d77842373e 100644 --- a/src/Core/Settings/IGlobalSettings.cs +++ b/src/Core/Settings/IGlobalSettings.cs @@ -29,4 +29,5 @@ public interface IGlobalSettings string DevelopmentDirectory { get; set; } IWebPushSettings WebPush { get; set; } GlobalSettings.EventLoggingSettings EventLogging { get; set; } + IPhishingDomainSettings PhishingDomain { get; set; } } diff --git a/src/Core/Settings/IPhishingDomainSettings.cs b/src/Core/Settings/IPhishingDomainSettings.cs new file mode 100644 index 0000000000..2e4a901a5a --- /dev/null +++ b/src/Core/Settings/IPhishingDomainSettings.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Settings; + +public interface IPhishingDomainSettings +{ + string UpdateUrl { get; set; } + string ChecksumUrl { get; set; } +} diff --git a/src/Core/Vault/Queries/GetCipherPermissionsForUserQuery.cs b/src/Core/Vault/Queries/GetCipherPermissionsForUserQuery.cs index 5cce87e958..07e9d07299 100644 --- a/src/Core/Vault/Queries/GetCipherPermissionsForUserQuery.cs +++ b/src/Core/Vault/Queries/GetCipherPermissionsForUserQuery.cs @@ -45,7 +45,7 @@ public class GetCipherPermissionsForUserQuery : IGetCipherPermissionsForUserQuer cipher.Value.ViewPassword = true; } } - else if (await CanAccessUnassignedCiphersAsync(org)) + else if (CanAccessUnassignedCiphers(org)) { var unassignedCiphers = await _cipherRepository.GetManyUnassignedOrganizationDetailsByOrganizationIdAsync(organizationId); foreach (var unassignedCipher in unassignedCiphers) @@ -83,7 +83,7 @@ public class GetCipherPermissionsForUserQuery : IGetCipherPermissionsForUserQuer return false; } - private async Task CanAccessUnassignedCiphersAsync(CurrentContextOrganization org) + private bool CanAccessUnassignedCiphers(CurrentContextOrganization org) { if (org is { Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or diff --git a/src/Core/Vault/Repositories/ICipherRepository.cs b/src/Core/Vault/Repositories/ICipherRepository.cs index b094b42044..f6767fada2 100644 --- a/src/Core/Vault/Repositories/ICipherRepository.cs +++ b/src/Core/Vault/Repositories/ICipherRepository.cs @@ -3,6 +3,7 @@ using Bit.Core.KeyManagement.UserKey; using Bit.Core.Repositories; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Queries; namespace Bit.Core.Vault.Repositories; diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index 745d90b741..f81e404db8 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -379,7 +379,7 @@ public class CipherService : ICipherService if (!valid || realSize > MAX_FILE_SIZE) { // File reported differs in size from that promised. Must be a rogue client. Delete Send - await DeleteAttachmentAsync(cipher, attachmentData); + await DeleteAttachmentAsync(cipher, attachmentData, false); return false; } // Update Send data if necessary @@ -483,7 +483,7 @@ public class CipherService : ICipherService throw new NotFoundException(); } - return await DeleteAttachmentAsync(cipher, cipher.GetAttachments()[attachmentId]); + return await DeleteAttachmentAsync(cipher, cipher.GetAttachments()[attachmentId], orgAdmin); } public async Task PurgeAsync(Guid organizationId) @@ -877,7 +877,7 @@ public class CipherService : ICipherService } } - private async Task DeleteAttachmentAsync(Cipher cipher, CipherAttachment.MetaData attachmentData) + private async Task DeleteAttachmentAsync(Cipher cipher, CipherAttachment.MetaData attachmentData, bool orgAdmin) { if (attachmentData == null || string.IsNullOrWhiteSpace(attachmentData.AttachmentId)) { @@ -891,7 +891,14 @@ public class CipherService : ICipherService // Update the revision date when an attachment is deleted cipher.RevisionDate = DateTime.UtcNow; - await _cipherRepository.ReplaceAsync((CipherDetails)cipher); + if (orgAdmin) + { + await _cipherRepository.ReplaceAsync(cipher); + } + else + { + await _cipherRepository.ReplaceAsync((CipherDetails)cipher); + } // push await _pushService.PushSyncCipherUpdateAsync(cipher, null); diff --git a/src/Events/Startup.cs b/src/Events/Startup.cs index bb37e240c8..366b562485 100644 --- a/src/Events/Startup.cs +++ b/src/Events/Startup.cs @@ -1,5 +1,4 @@ using System.Globalization; -using Bit.Core; using Bit.Core.AdminConsole.Services.Implementations; using Bit.Core.AdminConsole.Services.NoopImplementations; using Bit.Core.Context; @@ -94,13 +93,7 @@ public class Startup services.AddKeyedSingleton("broadcast"); } } - services.AddScoped(sp => - { - var featureService = sp.GetRequiredService(); - var key = featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations) - ? "broadcast" : "storage"; - return sp.GetRequiredKeyedService(key); - }); + services.AddScoped(); services.AddScoped(); services.AddOptionality(); diff --git a/src/Identity/Billing/Controller/AccountsController.cs b/src/Identity/Billing/Controller/AccountsController.cs index 96ec1280cd..b83940d3aa 100644 --- a/src/Identity/Billing/Controller/AccountsController.cs +++ b/src/Identity/Billing/Controller/AccountsController.cs @@ -1,6 +1,8 @@ -using Bit.Core.Billing.Models.Api.Requests.Accounts; +using Bit.Core; +using Bit.Core.Billing.Models.Api.Requests.Accounts; using Bit.Core.Billing.TrialInitiation.Registration; using Bit.Core.Context; +using Bit.Core.Services; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Services; @@ -15,18 +17,24 @@ namespace Bit.Identity.Billing.Controller; public class AccountsController( ICurrentContext currentContext, ISendTrialInitiationEmailForRegistrationCommand sendTrialInitiationEmailForRegistrationCommand, - IReferenceEventService referenceEventService) : Microsoft.AspNetCore.Mvc.Controller + IReferenceEventService referenceEventService, + IFeatureService featureService) : Microsoft.AspNetCore.Mvc.Controller { [HttpPost("trial/send-verification-email")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostTrialInitiationSendVerificationEmailAsync([FromBody] TrialSendVerificationEmailRequestModel model) { + var allowTrialLength0 = featureService.IsEnabled(FeatureFlagKeys.PM20322_AllowTrialLength0); + + var trialLength = allowTrialLength0 ? model.TrialLength ?? 7 : 7; + var token = await sendTrialInitiationEmailForRegistrationCommand.Handle( model.Email, model.Name, model.ReceiveMarketingEmails, model.ProductTier, - model.Products); + model.Products, + trialLength); var refEvent = new ReferenceEvent { diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index fd42074359..80e9536ea3 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -5,7 +5,6 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Api.Response.Accounts; using Bit.Core.Auth.Models.Business.Tokenables; -using Bit.Core.Auth.Services; using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.WebAuthnLogin; using Bit.Core.Context; @@ -37,7 +36,6 @@ public class AccountsController : Controller private readonly ILogger _logger; private readonly IUserRepository _userRepository; private readonly IRegisterUserCommand _registerUserCommand; - private readonly ICaptchaValidationService _captchaValidationService; private readonly IDataProtectorTokenFactory _assertionOptionsDataProtector; private readonly IGetWebAuthnLoginCredentialAssertionOptionsCommand _getWebAuthnLoginCredentialAssertionOptionsCommand; private readonly ISendVerificationEmailForRegistrationCommand _sendVerificationEmailForRegistrationCommand; @@ -85,7 +83,6 @@ public class AccountsController : Controller ILogger logger, IUserRepository userRepository, IRegisterUserCommand registerUserCommand, - ICaptchaValidationService captchaValidationService, IDataProtectorTokenFactory assertionOptionsDataProtector, IGetWebAuthnLoginCredentialAssertionOptionsCommand getWebAuthnLoginCredentialAssertionOptionsCommand, ISendVerificationEmailForRegistrationCommand sendVerificationEmailForRegistrationCommand, @@ -99,7 +96,6 @@ public class AccountsController : Controller _logger = logger; _userRepository = userRepository; _registerUserCommand = registerUserCommand; - _captchaValidationService = captchaValidationService; _assertionOptionsDataProtector = assertionOptionsDataProtector; _getWebAuthnLoginCredentialAssertionOptionsCommand = getWebAuthnLoginCredentialAssertionOptionsCommand; _sendVerificationEmailForRegistrationCommand = sendVerificationEmailForRegistrationCommand; @@ -167,7 +163,7 @@ public class AccountsController : Controller } [HttpPost("register/finish")] - public async Task PostRegisterFinish([FromBody] RegisterFinishRequestModel model) + public async Task PostRegisterFinish([FromBody] RegisterFinishRequestModel model) { var user = model.ToUser(); @@ -208,12 +204,11 @@ public class AccountsController : Controller } } - private RegisterResponseModel ProcessRegistrationResult(IdentityResult result, User user) + private RegisterFinishResponseModel ProcessRegistrationResult(IdentityResult result, User user) { if (result.Succeeded) { - var captchaBypassToken = _captchaValidationService.GenerateCaptchaBypassToken(user); - return new RegisterResponseModel(captchaBypassToken); + return new RegisterFinishResponseModel(); } foreach (var error in result.Errors.Where(e => e.Code != "DuplicateUserName")) diff --git a/src/Identity/Identity.csproj b/src/Identity/Identity.csproj index e9e188b53f..cb506d86e9 100644 --- a/src/Identity/Identity.csproj +++ b/src/Identity/Identity.csproj @@ -3,8 +3,6 @@ bitwarden-Identity false - - $(WarningsNotAsErrors);CS0162 diff --git a/src/Identity/IdentityServer/CustomValidatorRequestContext.cs b/src/Identity/IdentityServer/CustomValidatorRequestContext.cs index bce460c5c4..eb441e7941 100644 --- a/src/Identity/IdentityServer/CustomValidatorRequestContext.cs +++ b/src/Identity/IdentityServer/CustomValidatorRequestContext.cs @@ -1,5 +1,4 @@ -using Bit.Core.Auth.Models.Business; -using Bit.Core.Entities; +using Bit.Core.Entities; using Duende.IdentityServer.Validation; namespace Bit.Identity.IdentityServer; @@ -9,7 +8,7 @@ public class CustomValidatorRequestContext public User User { get; set; } /// /// This is the device that the user is using to authenticate. It can be either known or unknown. - /// We set it here since the ResourceOwnerPasswordValidator needs the device to know if CAPTCHA is required. + /// We set it here since the ResourceOwnerPasswordValidator needs the device to do device validation. /// The option to set it here saves a trip to the database. /// public Device Device { get; set; } @@ -39,5 +38,4 @@ public class CustomValidatorRequestContext /// This will be null if the authentication request is successful. ///
    public Dictionary CustomResponse { get; set; } - public CaptchaResponse CaptchaResponse { get; set; } } diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs index 8b7034c9d7..9afdcacf14 100644 --- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs @@ -29,7 +29,6 @@ public abstract class BaseRequestValidator where T : class private readonly IDeviceValidator _deviceValidator; private readonly ITwoFactorAuthenticationValidator _twoFactorAuthenticationValidator; private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IMailService _mailService; private readonly ILogger _logger; private readonly GlobalSettings _globalSettings; private readonly IUserRepository _userRepository; @@ -49,7 +48,6 @@ public abstract class BaseRequestValidator where T : class IDeviceValidator deviceValidator, ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator, IOrganizationUserRepository organizationUserRepository, - IMailService mailService, ILogger logger, ICurrentContext currentContext, GlobalSettings globalSettings, @@ -66,7 +64,6 @@ public abstract class BaseRequestValidator where T : class _deviceValidator = deviceValidator; _twoFactorAuthenticationValidator = twoFactorAuthenticationValidator; _organizationUserRepository = organizationUserRepository; - _mailService = mailService; _logger = logger; CurrentContext = currentContext; _globalSettings = globalSettings; @@ -81,23 +78,12 @@ public abstract class BaseRequestValidator where T : class protected async Task ValidateAsync(T context, ValidatedTokenRequest request, CustomValidatorRequestContext validatorContext) { - // 1. We need to check if the user is a bot and if their master password hash is correct. - var isBot = validatorContext.CaptchaResponse?.IsBot ?? false; + // 1. We need to check if the user's master password hash is correct. var valid = await ValidateContextAsync(context, validatorContext); var user = validatorContext.User; - if (!valid || isBot) + if (!valid) { - if (isBot) - { - _logger.LogInformation(Constants.BypassFiltersEventId, - "Login attempt for {UserName} detected as a captcha bot with score {CaptchaScore}.", - request.UserName, validatorContext.CaptchaResponse.Score); - } - - if (!valid) - { - await UpdateFailedAuthDetailsAsync(user, false, !validatorContext.KnownDevice); - } + await UpdateFailedAuthDetailsAsync(user); await BuildErrorResultAsync("Username or password is incorrect. Try again.", false, context, user); return; @@ -167,7 +153,7 @@ public abstract class BaseRequestValidator where T : class } else { - await UpdateFailedAuthDetailsAsync(user, true, !validatorContext.KnownDevice); + await UpdateFailedAuthDetailsAsync(user); await BuildErrorResultAsync("Two-step token is invalid. Try again.", true, context, user); } return; @@ -379,7 +365,7 @@ public abstract class BaseRequestValidator where T : class await _userRepository.ReplaceAsync(user); } - private async Task UpdateFailedAuthDetailsAsync(User user, bool twoFactorInvalid, bool unknownDevice) + private async Task UpdateFailedAuthDetailsAsync(User user) { if (user == null) { @@ -390,32 +376,6 @@ public abstract class BaseRequestValidator where T : class user.FailedLoginCount = ++user.FailedLoginCount; user.LastFailedLoginDate = user.RevisionDate = utcNow; await _userRepository.ReplaceAsync(user); - - if (ValidateFailedAuthEmailConditions(unknownDevice, user)) - { - if (twoFactorInvalid) - { - await _mailService.SendFailedTwoFactorAttemptsEmailAsync(user.Email, utcNow, CurrentContext.IpAddress); - } - else - { - await _mailService.SendFailedLoginAttemptsEmailAsync(user.Email, utcNow, CurrentContext.IpAddress); - } - } - } - - /// - /// checks to see if a user is trying to log into a new device - /// and has reached the maximum number of failed login attempts. - /// - /// boolean - /// current user - /// - private bool ValidateFailedAuthEmailConditions(bool unknownDevice, User user) - { - var failedLoginCeiling = _globalSettings.Captcha.MaximumFailedLoginAttempts; - var failedLoginCount = user?.FailedLoginCount ?? 0; - return unknownDevice && failedLoginCeiling > 0 && failedLoginCount == failedLoginCeiling; } private async Task GetMasterPasswordPolicyAsync(User user) diff --git a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs index 841cd14137..6f2d81bd1b 100644 --- a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs @@ -35,7 +35,6 @@ public class CustomTokenRequestValidator : BaseRequestValidator logger, ICurrentContext currentContext, GlobalSettings globalSettings, @@ -53,7 +52,6 @@ public class CustomTokenRequestValidator : BaseRequestValidator logger, - IFeatureService featureService) : IDeviceValidator + ILogger logger) : IDeviceValidator { private readonly IDeviceService _deviceService = deviceService; private readonly IDeviceRepository _deviceRepository = deviceRepository; @@ -33,7 +32,6 @@ public class DeviceValidator( private readonly IUserService _userService = userService; private readonly IDistributedCache distributedCache = distributedCache; private readonly ILogger _logger = logger; - private readonly IFeatureService _featureService = featureService; public async Task ValidateRequestDeviceAsync(ValidatedTokenRequest request, CustomValidatorRequestContext context) { @@ -64,9 +62,7 @@ public class DeviceValidator( } // We have established that the device is unknown at this point; begin new device verification - // PM-13340: remove feature flag - if (_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification) && - request.GrantType == "password" && + if (request.GrantType == "password" && request.Raw["AuthRequest"] == null && !context.TwoFactorRequired && !context.SsoRequired && diff --git a/src/Identity/IdentityServer/RequestValidators/ITwoFactorAuthenticationValidator.cs b/src/Identity/IdentityServer/RequestValidators/ITwoFactorAuthenticationValidator.cs new file mode 100644 index 0000000000..cc45fcb3eb --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/ITwoFactorAuthenticationValidator.cs @@ -0,0 +1,38 @@ + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Entities; +using Duende.IdentityServer.Validation; + +namespace Bit.Identity.IdentityServer.RequestValidators; + +public interface ITwoFactorAuthenticationValidator +{ + /// + /// Check if the user is required to use two-factor authentication to login. This is based on the user's + /// enabled two-factor providers, the user's organizations enabled two-factor providers, and the grant type. + /// Client credentials and webauthn grant types do not require two-factor authentication. + /// + /// the active user for the request + /// the request that contains the grant types + /// boolean + Task> RequiresTwoFactorAsync(User user, ValidatedTokenRequest request); + /// + /// Builds the two-factor authentication result for the user based on the available two-factor providers + /// from either their user account or Organization. + /// + /// user trying to login + /// organization associated with the user; Can be null + /// Dictionary with the TwoFactorProviderType as the Key and the Provider Metadata as the Value + Task> BuildTwoFactorResultAsync(User user, Organization organization); + /// + /// Uses the built in userManager methods to verify the two-factor token for the user. If the organization uses + /// organization duo, it will use the organization duo token provider to verify the token. + /// + /// the active User + /// organization of user; can be null + /// Two Factor Provider to use to verify the token + /// secret passed from the user and consumed by the two-factor provider's verify method + /// boolean + Task VerifyTwoFactorAsync(User user, Organization organization, TwoFactorProviderType twoFactorProviderType, string token); +} diff --git a/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs b/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs index 7108831e84..68ae2ced4d 100644 --- a/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs @@ -3,7 +3,6 @@ using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Repositories; -using Bit.Core.Auth.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Repositories; @@ -21,7 +20,6 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator _userManager; private readonly ICurrentContext _currentContext; - private readonly ICaptchaValidationService _captchaValidationService; private readonly IAuthRequestRepository _authRequestRepository; private readonly IDeviceValidator _deviceValidator; public ResourceOwnerPasswordValidator( @@ -31,11 +29,9 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator logger, ICurrentContext currentContext, GlobalSettings globalSettings, - ICaptchaValidationService captchaValidationService, IAuthRequestRepository authRequestRepository, IUserRepository userRepository, IPolicyService policyService, @@ -50,7 +46,6 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator - { - { _captchaValidationService.SiteKeyResponseKeyName, _captchaValidationService.SiteKey }, - }); - return; - } - - validatorContext.CaptchaResponse = await _captchaValidationService.ValidateCaptchaResponseAsync( - captchaResponse, _currentContext.IpAddress, user); - if (!validatorContext.CaptchaResponse.Success) - { - await BuildErrorResultAsync("Captcha is invalid. Please refresh and try again", false, context, null); - return; - } - bypassToken = _captchaValidationService.GenerateCaptchaBypassToken(user); - } - await ValidateAsync(context, context.Request, validatorContext); - if (context.Result.CustomResponse != null && bypassToken != null) - { - context.Result.CustomResponse["CaptchaBypassToken"] = bypassToken; - } } protected async override Task ValidateContextAsync(ResourceOwnerPasswordValidationContext context, diff --git a/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs b/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs index e733d4f410..000f98c006 100644 --- a/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs @@ -4,6 +4,7 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Models.Data.Organizations; @@ -16,56 +17,25 @@ using Microsoft.AspNetCore.Identity; namespace Bit.Identity.IdentityServer.RequestValidators; -public interface ITwoFactorAuthenticationValidator -{ - /// - /// Check if the user is required to use two-factor authentication to login. This is based on the user's - /// enabled two-factor providers, the user's organizations enabled two-factor providers, and the grant type. - /// Client credentials and webauthn grant types do not require two-factor authentication. - /// - /// the active user for the request - /// the request that contains the grant types - /// boolean - Task> RequiresTwoFactorAsync(User user, ValidatedTokenRequest request); - /// - /// Builds the two-factor authentication result for the user based on the available two-factor providers - /// from either their user account or Organization. - /// - /// user trying to login - /// organization associated with the user; Can be null - /// Dictionary with the TwoFactorProviderType as the Key and the Provider Metadata as the Value - Task> BuildTwoFactorResultAsync(User user, Organization organization); - /// - /// Uses the built in userManager methods to verify the two-factor token for the user. If the organization uses - /// organization duo, it will use the organization duo token provider to verify the token. - /// - /// the active User - /// organization of user; can be null - /// Two Factor Provider to use to verify the token - /// secret passed from the user and consumed by the two-factor provider's verify method - /// boolean - Task VerifyTwoFactorAsync(User user, Organization organization, TwoFactorProviderType twoFactorProviderType, string token); -} - public class TwoFactorAuthenticationValidator( IUserService userService, UserManager userManager, IOrganizationDuoUniversalTokenProvider organizationDuoWebTokenProvider, - IFeatureService featureService, IApplicationCacheService applicationCacheService, IOrganizationUserRepository organizationUserRepository, IOrganizationRepository organizationRepository, IDataProtectorTokenFactory ssoEmail2faSessionTokeFactory, + ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, ICurrentContext currentContext) : ITwoFactorAuthenticationValidator { private readonly IUserService _userService = userService; private readonly UserManager _userManager = userManager; private readonly IOrganizationDuoUniversalTokenProvider _organizationDuoUniversalTokenProvider = organizationDuoWebTokenProvider; - private readonly IFeatureService _featureService = featureService; private readonly IApplicationCacheService _applicationCacheService = applicationCacheService; private readonly IOrganizationUserRepository _organizationUserRepository = organizationUserRepository; private readonly IOrganizationRepository _organizationRepository = organizationRepository; private readonly IDataProtectorTokenFactory _ssoEmail2faSessionTokeFactory = ssoEmail2faSessionTokeFactory; + private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; private readonly ICurrentContext _currentContext = currentContext; public async Task> RequiresTwoFactorAsync(User user, ValidatedTokenRequest request) @@ -121,7 +91,10 @@ public class TwoFactorAuthenticationValidator( { "TwoFactorProviders2", providers }, }; - // If we have email as a 2FA provider, we might need an SsoEmail2fa Session Token + // If we have an Email 2FA provider we need this session token so SSO users + // can re-request an email TOTP. The TwoFactorController.SendEmailLoginAsync + // endpoint requires a way to authenticate the user before sending another email with + // a TOTP, this token acts as the authentication mechanism. if (enabledProviders.Any(p => p.Key == TwoFactorProviderType.Email)) { twoFactorResultDict.Add("SsoEmail2faSessionToken", @@ -130,12 +103,6 @@ public class TwoFactorAuthenticationValidator( twoFactorResultDict.Add("Email", user.Email); } - if (enabledProviders.Count == 1 && enabledProviders.First().Key == TwoFactorProviderType.Email) - { - // Send email now if this is their only 2FA method - await _userService.SendTwoFactorEmailAsync(user); - } - return twoFactorResultDict; } @@ -161,7 +128,7 @@ public class TwoFactorAuthenticationValidator( // These cases we want to always return false, U2f is deprecated and OrganizationDuo // uses a different flow than the other two factor providers, it follows the same - // structure of a UserTokenProvider but has it's logic ran outside the usual token + // structure of a UserTokenProvider but has it's logic runs outside the usual token // provider flow. See IOrganizationDuoUniversalTokenProvider.cs if (type is TwoFactorProviderType.U2f or TwoFactorProviderType.OrganizationDuo) { @@ -171,12 +138,12 @@ public class TwoFactorAuthenticationValidator( // Now we are concerning the rest of the Two Factor Provider Types // The intent of this check is to make sure that the user is using a 2FA provider that - // is enabled and allowed by their premium status. The exception for Remember - // is because it is a "special" 2FA type that isn't ever explicitly + // is enabled and allowed by their premium status. + // The exception for Remember is because it is a "special" 2FA type that isn't ever explicitly // enabled by a user, so we can't check the user's 2FA providers to see if they're // enabled. We just have to check if the token is valid. if (type != TwoFactorProviderType.Remember && - !await _userService.TwoFactorProviderIsEnabledAsync(type, user)) + user.GetTwoFactorProvider(type) == null) { return false; } diff --git a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs index 654edeabe8..76949eb5f7 100644 --- a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs @@ -35,7 +35,6 @@ public class WebAuthnGrantValidator : BaseRequestValidator logger, ICurrentContext currentContext, GlobalSettings globalSettings, @@ -54,7 +53,6 @@ public class WebAuthnGrantValidator : BaseRequestValidator - - - $(WarningsNotAsErrors);CS8618 - - diff --git a/src/Infrastructure.Dapper/Repositories/DeviceRepository.cs b/src/Infrastructure.Dapper/Repositories/DeviceRepository.cs index 723200ff1c..33643eba88 100644 --- a/src/Infrastructure.Dapper/Repositories/DeviceRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/DeviceRepository.cs @@ -17,15 +17,11 @@ public class DeviceRepository : Repository, IDeviceRepository private readonly IGlobalSettings _globalSettings; public DeviceRepository(GlobalSettings globalSettings) - : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) + : base(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) { _globalSettings = globalSettings; } - public DeviceRepository(string connectionString, string readOnlyConnectionString) - : base(connectionString, readOnlyConnectionString) - { } - public async Task GetByIdAsync(Guid id, Guid userId) { var device = await GetByIdAsync(id); diff --git a/src/Infrastructure.Dapper/Repositories/OrganizationSponsorshipRepository.cs b/src/Infrastructure.Dapper/Repositories/OrganizationSponsorshipRepository.cs index cebf4b55c6..7033f2113b 100644 --- a/src/Infrastructure.Dapper/Repositories/OrganizationSponsorshipRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/OrganizationSponsorshipRepository.cs @@ -89,7 +89,7 @@ public class OrganizationSponsorshipRepository : Repository GetBySponsoringOrganizationUserIdAsync(Guid sponsoringOrganizationUserId) + public async Task GetBySponsoringOrganizationUserIdAsync(Guid sponsoringOrganizationUserId, bool isAdminInitiated) { using (var connection = new SqlConnection(ConnectionString)) { @@ -97,7 +97,8 @@ public class OrganizationSponsorshipRepository : Repository, IUserRepository } } - public async Task UpdateUserKeyAndEncryptedDataV2Async( User user, IEnumerable updateDataActions) @@ -289,7 +288,6 @@ public class UserRepository : Repository, IUserRepository UnprotectData(user); } - public async Task> GetManyAsync(IEnumerable ids) { using (var connection = new SqlConnection(ReadOnlyConnectionString)) @@ -318,6 +316,14 @@ public class UserRepository : Repository, IUserRepository } } + public async Task GetCalculatedPremiumAsync(Guid userId) + { + var result = await GetManyWithCalculatedPremiumAsync([userId]); + + UnprotectData(result); + return result.SingleOrDefault(); + } + private async Task ProtectDataAndSaveAsync(User user, Func saveTask) { if (user == null) diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs index 69f40bebb4..793abff8a2 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs @@ -7,8 +7,7 @@ public class OrganizationUserOrganizationDetailsViewQuery : IQuery Run(DatabaseContext dbContext) { var query = from ou in dbContext.OrganizationUsers - join o in dbContext.Organizations on ou.OrganizationId equals o.Id into outerOrganization - from o in outerOrganization.DefaultIfEmpty() + join o in dbContext.Organizations on ou.OrganizationId equals o.Id join su in dbContext.SsoUsers on new { ou.UserId, OrganizationId = (Guid?)ou.OrganizationId } equals new { UserId = (Guid?)su.UserId, su.OrganizationId } into su_g from su in su_g.DefaultIfEmpty() join po in dbContext.ProviderOrganizations on o.Id equals po.OrganizationId into po_g @@ -68,10 +67,11 @@ public class OrganizationUserOrganizationDetailsViewQuery : IQuery DateTime.UtcNow) + ) && + ( + // SENT status: When SponsoredOrganizationId is null + os.SponsoredOrganizationId == null || + // ACCEPTED status: When SponsoredOrganizationId is not null and ValidUntil is null or in the future + (os.SponsoredOrganizationId != null && + (!os.ValidUntil.HasValue || os.ValidUntil.Value > DateTime.UtcNow)) + ) select new OrganizationUser { Id = os.Id, diff --git a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs index ad6c7cf369..c9f0406a58 100644 --- a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs +++ b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs @@ -103,6 +103,7 @@ public static class EntityFrameworkServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); if (selfHosted) { diff --git a/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj b/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj index a11a209b39..9814eef2aa 100644 --- a/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj +++ b/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj @@ -1,10 +1,5 @@ - - - $(WarningsNotAsErrors);CS0108;CS8632 - - diff --git a/src/Infrastructure.EntityFramework/Platform/Installations/Models/Installation.cs b/src/Infrastructure.EntityFramework/Platform/Installations/Models/Installation.cs index 96b60a39ed..601ae993b3 100644 --- a/src/Infrastructure.EntityFramework/Platform/Installations/Models/Installation.cs +++ b/src/Infrastructure.EntityFramework/Platform/Installations/Models/Installation.cs @@ -3,22 +3,12 @@ using C = Bit.Core.Platform.Installations; namespace Bit.Infrastructure.EntityFramework.Platform; -public class Installation : C.Installation -{ - // Shadow property - to be introduced by https://bitwarden.atlassian.net/browse/PM-11129 - // This isn't a value or entity used by self hosted servers, but it's - // being added for synchronicity between database provider options. - public DateTime? LastActivityDate { get; set; } -} +public class Installation : C.Installation; public class InstallationMapperProfile : Profile { public InstallationMapperProfile() { - CreateMap() - // Shadow property - to be introduced by https://bitwarden.atlassian.net/browse/PM-11129 - .ForMember(i => i.LastActivityDate, opt => opt.Ignore()) - .ReverseMap(); CreateMap().ReverseMap(); } } diff --git a/src/Infrastructure.EntityFramework/Repositories/OrganizationSponsorshipRepository.cs b/src/Infrastructure.EntityFramework/Repositories/OrganizationSponsorshipRepository.cs index 0f76772c57..0481f9e13a 100644 --- a/src/Infrastructure.EntityFramework/Repositories/OrganizationSponsorshipRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/OrganizationSponsorshipRepository.cs @@ -104,12 +104,13 @@ public class OrganizationSponsorshipRepository : Repository GetBySponsoringOrganizationUserIdAsync(Guid sponsoringOrganizationUserId) + public async Task GetBySponsoringOrganizationUserIdAsync(Guid sponsoringOrganizationUserId, bool isAdminInitiated = false) { using (var scope = ServiceScopeFactory.CreateScope()) { var dbContext = GetDatabaseContext(scope); - var orgSponsorship = await GetDbSet(dbContext).Where(e => e.SponsoringOrganizationUserId == sponsoringOrganizationUserId) + var orgSponsorship = await GetDbSet(dbContext) + .Where(e => e.SponsoringOrganizationUserId == sponsoringOrganizationUserId && e.IsAdminInitiated == isAdminInitiated) .FirstOrDefaultAsync(); return orgSponsorship; } diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs index 127646ed59..bd70e27e78 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -1,10 +1,10 @@ using AutoMapper; using Bit.Core.KeyManagement.UserKey; +using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Infrastructure.EntityFramework.Models; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using DataModel = Bit.Core.Models.Data; #nullable enable @@ -38,13 +38,13 @@ public class UserRepository : Repository, IUserR } } - public async Task GetKdfInformationByEmailAsync(string email) + public async Task GetKdfInformationByEmailAsync(string email) { using (var scope = ServiceScopeFactory.CreateScope()) { var dbContext = GetDatabaseContext(scope); return await GetDbSet(dbContext).Where(e => e.Email == email) - .Select(e => new DataModel.UserKdfInformation + .Select(e => new UserKdfInformation { Kdf = e.Kdf, KdfIterations = e.KdfIterations, @@ -251,13 +251,13 @@ public class UserRepository : Repository, IUserR } } - public async Task> GetManyWithCalculatedPremiumAsync(IEnumerable ids) + public async Task> GetManyWithCalculatedPremiumAsync(IEnumerable ids) { using (var scope = ServiceScopeFactory.CreateScope()) { var dbContext = GetDatabaseContext(scope); var users = dbContext.Users.Where(x => ids.Contains(x.Id)); - return await users.Select(e => new DataModel.UserWithCalculatedPremium(e) + return await users.Select(e => new UserWithCalculatedPremium(e) { HasPremiumAccess = e.Premium || dbContext.OrganizationUsers .Any(ou => ou.UserId == e.Id && @@ -269,6 +269,12 @@ public class UserRepository : Repository, IUserR } } + public async Task GetCalculatedPremiumAsync(Guid id) + { + var result = await GetManyWithCalculatedPremiumAsync([id]); + return result.FirstOrDefault(); + } + public override async Task DeleteAsync(Core.Entities.User user) { using (var scope = ServiceScopeFactory.CreateScope()) diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 9883e6db47..9fbc14444f 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -4,7 +4,6 @@ using System.Security.Claims; using System.Security.Cryptography.X509Certificates; using AspNetCoreRateLimit; using Azure.Storage.Queues; -using Bit.Core; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Services; @@ -152,14 +151,6 @@ public static class ServiceCollectionExtensions serviceProvider.GetRequiredService>>()) ); - services.AddSingleton>(serviceProvider => - new DataProtectorTokenFactory( - HCaptchaTokenable.ClearTextPrefix, - HCaptchaTokenable.DataProtectorPurpose, - serviceProvider.GetDataProtectionProvider(), - serviceProvider.GetRequiredService>>()) - ); - services.AddSingleton>(serviceProvider => new DataProtectorTokenFactory( SsoTokenable.ClearTextPrefix, @@ -366,13 +357,7 @@ public static class ServiceCollectionExtensions services.AddKeyedSingleton("storage"); services.AddKeyedSingleton("broadcast"); } - services.AddScoped(sp => - { - var featureService = sp.GetRequiredService(); - var key = featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations) - ? "broadcast" : "storage"; - return sp.GetRequiredKeyedService(key); - }); + services.AddScoped(); if (CoreHelpers.SettingHasValue(globalSettings.Attachment.ConnectionString)) { @@ -408,16 +393,6 @@ public static class ServiceCollectionExtensions { services.AddSingleton(); } - - if (CoreHelpers.SettingHasValue(globalSettings.Captcha?.HCaptchaSecretKey) && - CoreHelpers.SettingHasValue(globalSettings.Captcha?.HCaptchaSiteKey)) - { - services.AddSingleton(); - } - else - { - services.AddSingleton(); - } } public static void AddOosServices(this IServiceCollection services) diff --git a/src/Sql/Sql.sqlproj b/src/Sql/Sql.sqlproj index 65524fca45..849fd3bdfd 100644 --- a/src/Sql/Sql.sqlproj +++ b/src/Sql/Sql.sqlproj @@ -1,6 +1,6 @@  - + Sql {58554e52-fdec-4832-aff9-302b01e08dca} diff --git a/src/Sql/Vault/dbo/Stored Procedures/Cipher/CipherDetails_Update.sql b/src/Sql/Vault/dbo/Stored Procedures/Cipher/CipherDetails_Update.sql index 8fc95eb302..214b74d092 100644 --- a/src/Sql/Vault/dbo/Stored Procedures/Cipher/CipherDetails_Update.sql +++ b/src/Sql/Vault/dbo/Stored Procedures/Cipher/CipherDetails_Update.sql @@ -6,7 +6,7 @@ @Data NVARCHAR(MAX), @Favorites NVARCHAR(MAX), -- not used @Folders NVARCHAR(MAX), -- not used - @Attachments NVARCHAR(MAX), + @Attachments NVARCHAR(MAX), -- not used @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7), @FolderId UNIQUEIDENTIFIER, @@ -50,7 +50,6 @@ BEGIN ELSE JSON_MODIFY([Favorites], @UserIdPath, NULL) END, - [Attachments] = @Attachments, [Reprompt] = @Reprompt, [CreationDate] = @CreationDate, [RevisionDate] = @RevisionDate, diff --git a/src/Sql/Vault/dbo/Stored Procedures/Cipher/Cipher_Create.sql b/src/Sql/Vault/dbo/Stored Procedures/Cipher/Cipher_Create.sql index c03c27ea78..676c013cc8 100644 --- a/src/Sql/Vault/dbo/Stored Procedures/Cipher/Cipher_Create.sql +++ b/src/Sql/Vault/dbo/Stored Procedures/Cipher/Cipher_Create.sql @@ -6,7 +6,7 @@ @Data NVARCHAR(MAX), @Favorites NVARCHAR(MAX), @Folders NVARCHAR(MAX), - @Attachments NVARCHAR(MAX), + @Attachments NVARCHAR(MAX), -- not used @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7), @DeletedDate DATETIME2(7), @@ -25,7 +25,6 @@ BEGIN [Data], [Favorites], [Folders], - [Attachments], [CreationDate], [RevisionDate], [DeletedDate], @@ -41,7 +40,6 @@ BEGIN @Data, @Favorites, @Folders, - @Attachments, @CreationDate, @RevisionDate, @DeletedDate, diff --git a/src/Sql/Vault/dbo/Stored Procedures/Cipher/Cipher_DeleteAttachment.sql b/src/Sql/Vault/dbo/Stored Procedures/Cipher/Cipher_DeleteAttachment.sql index 5983f557c2..75a0468b42 100644 --- a/src/Sql/Vault/dbo/Stored Procedures/Cipher/Cipher_DeleteAttachment.sql +++ b/src/Sql/Vault/dbo/Stored Procedures/Cipher/Cipher_DeleteAttachment.sql @@ -10,20 +10,59 @@ BEGIN DECLARE @UserId UNIQUEIDENTIFIER DECLARE @OrganizationId UNIQUEIDENTIFIER + DECLARE @CurrentAttachments NVARCHAR(MAX) + DECLARE @NewAttachments NVARCHAR(MAX) + -- Get current cipher data SELECT @UserId = [UserId], - @OrganizationId = [OrganizationId] - FROM + @OrganizationId = [OrganizationId], + @CurrentAttachments = [Attachments] + FROM [dbo].[Cipher] WHERE [Id] = @Id - UPDATE - [dbo].[Cipher] - SET - [Attachments] = JSON_MODIFY([Attachments], @AttachmentIdPath, NULL) - WHERE - [Id] = @Id + -- If there are no attachments, nothing to do + IF @CurrentAttachments IS NULL + BEGIN + RETURN; + END + + -- Validate the initial JSON + IF ISJSON(@CurrentAttachments) = 0 + BEGIN + THROW 50000, 'Current initial attachments data is not valid JSON', 1; + RETURN; + END + + -- Check if the attachment exists before trying to remove it + IF JSON_PATH_EXISTS(@CurrentAttachments, @AttachmentIdPath) = 0 + BEGIN + -- Attachment doesn't exist, nothing to do + RETURN; + END + + -- Create the new attachments JSON with the specified attachment removed + SET @NewAttachments = JSON_MODIFY(@CurrentAttachments, @AttachmentIdPath, NULL) + + -- Validate the resulting JSON + IF ISJSON(@NewAttachments) = 0 + BEGIN + THROW 50000, 'Failed to create valid JSON when removing attachment', 1; + RETURN; + END + + -- Check if we've removed all attachments and have an empty object + IF @NewAttachments = '{}' + BEGIN + -- If we have an empty JSON object, set to NULL instead + SET @NewAttachments = NULL; + END + + -- Update with validated JSON + UPDATE [dbo].[Cipher] + SET [Attachments] = @NewAttachments + WHERE [Id] = @Id IF @OrganizationId IS NOT NULL BEGIN diff --git a/src/Sql/Vault/dbo/Stored Procedures/Cipher/Cipher_Update.sql b/src/Sql/Vault/dbo/Stored Procedures/Cipher/Cipher_Update.sql index 7815aa3053..0aa73ae9b6 100644 --- a/src/Sql/Vault/dbo/Stored Procedures/Cipher/Cipher_Update.sql +++ b/src/Sql/Vault/dbo/Stored Procedures/Cipher/Cipher_Update.sql @@ -6,7 +6,7 @@ @Data NVARCHAR(MAX), @Favorites NVARCHAR(MAX), @Folders NVARCHAR(MAX), - @Attachments NVARCHAR(MAX), + @Attachments NVARCHAR(MAX), -- not used @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7), @DeletedDate DATETIME2(7), @@ -25,7 +25,6 @@ BEGIN [Data] = @Data, [Favorites] = @Favorites, [Folders] = @Folders, - [Attachments] = @Attachments, [CreationDate] = @CreationDate, [RevisionDate] = @RevisionDate, [DeletedDate] = @DeletedDate, @@ -42,4 +41,4 @@ BEGIN BEGIN EXEC [dbo].[User_BumpAccountRevisionDate] @UserId END -END \ No newline at end of file +END diff --git a/src/Sql/Vault/dbo/Stored Procedures/Cipher/Cipher_UpdateAttachment.sql b/src/Sql/Vault/dbo/Stored Procedures/Cipher/Cipher_UpdateAttachment.sql index 520df72505..4401a4a0c6 100644 --- a/src/Sql/Vault/dbo/Stored Procedures/Cipher/Cipher_UpdateAttachment.sql +++ b/src/Sql/Vault/dbo/Stored Procedures/Cipher/Cipher_UpdateAttachment.sql @@ -8,21 +8,75 @@ AS BEGIN SET NOCOUNT ON + -- Validate that AttachmentData is valid JSON + IF ISJSON(@AttachmentData) = 0 + BEGIN + THROW 50000, 'Invalid JSON format in AttachmentData parameter', 1; + RETURN; + END + + -- Validate that AttachmentData has the expected structure + -- Check for required fields + IF JSON_VALUE(@AttachmentData, '$.FileName') IS NULL OR + JSON_VALUE(@AttachmentData, '$.Size') IS NULL + BEGIN + THROW 50000, 'AttachmentData is missing required fields (FileName, Size)', 1; + RETURN; + END + + -- Validate data types for critical fields + DECLARE @Size BIGINT = TRY_CAST(JSON_VALUE(@AttachmentData, '$.Size') AS BIGINT) + IF @Size IS NULL OR @Size <= 0 + BEGIN + THROW 50000, 'AttachmentData has invalid Size value', 1; + RETURN; + END + DECLARE @AttachmentIdKey VARCHAR(50) = CONCAT('"', @AttachmentId, '"') DECLARE @AttachmentIdPath VARCHAR(50) = CONCAT('$.', @AttachmentIdKey) + DECLARE @NewAttachments NVARCHAR(MAX) - UPDATE - [dbo].[Cipher] - SET - [Attachments] = - CASE - WHEN [Attachments] IS NULL THEN - CONCAT('{', @AttachmentIdKey, ':', @AttachmentData, '}') - ELSE - JSON_MODIFY([Attachments], @AttachmentIdPath, JSON_QUERY(@AttachmentData, '$')) - END - WHERE - [Id] = @Id + -- Get current attachments + DECLARE @CurrentAttachments NVARCHAR(MAX) + SELECT @CurrentAttachments = [Attachments] FROM [dbo].[Cipher] WHERE [Id] = @Id + + -- Prepare the new attachments value based on current state + IF @CurrentAttachments IS NULL + BEGIN + -- Create new JSON object with the attachment + SET @NewAttachments = CONCAT('{', @AttachmentIdKey, ':', @AttachmentData, '}') + + -- Validate the constructed JSON + IF ISJSON(@NewAttachments) = 0 + BEGIN + THROW 50000, 'Failed to create valid JSON when adding new attachment', 1; + RETURN; + END + END + ELSE + BEGIN + -- Validate existing attachments + IF ISJSON(@CurrentAttachments) = 0 + BEGIN + THROW 50000, 'Current attachments data is not valid JSON', 1; + RETURN; + END + + -- Modify existing JSON + SET @NewAttachments = JSON_MODIFY(@CurrentAttachments, @AttachmentIdPath, JSON_QUERY(@AttachmentData, '$')) + + -- Validate the modified JSON + IF ISJSON(@NewAttachments) = 0 + BEGIN + THROW 50000, 'Failed to create valid JSON when updating existing attachments', 1; + RETURN; + END + END + + -- Update with validated JSON + UPDATE [dbo].[Cipher] + SET [Attachments] = @NewAttachments + WHERE [Id] = @Id IF @OrganizationId IS NOT NULL BEGIN @@ -34,4 +88,4 @@ BEGIN EXEC [dbo].[User_UpdateStorage] @UserId EXEC [dbo].[User_BumpAccountRevisionDate] @UserId END -END \ No newline at end of file +END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationDomainSsoDetails_ReadByEmail.sql b/src/Sql/dbo/Stored Procedures/OrganizationDomainSsoDetails_ReadByEmail.sql index 5cc47213d6..262d4bfd8d 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationDomainSsoDetails_ReadByEmail.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationDomainSsoDetails_ReadByEmail.sql @@ -3,9 +3,9 @@ CREATE PROCEDURE [dbo].[OrganizationDomainSsoDetails_ReadByEmail] AS BEGIN SET NOCOUNT ON - + DECLARE @Domain NVARCHAR(256) - + SELECT @Domain = SUBSTRING(@Email, CHARINDEX( '@', @Email) + 1, LEN(@Email)) SELECT @@ -19,8 +19,8 @@ BEGIN [dbo].[OrganizationView] O INNER JOIN [dbo].[OrganizationDomainView] OD ON O.Id = OD.OrganizationId - LEFT JOIN [dbo].[Ssoconfig] S + LEFT JOIN [dbo].[SsoConfig] S ON O.Id = S.OrganizationId WHERE OD.DomainName = @Domain AND O.Enabled = 1 -END \ No newline at end of file +END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationSponsorship_ReadBySponsoringOrganiationUserId.sql b/src/Sql/dbo/Stored Procedures/OrganizationSponsorship_ReadBySponsoringOrganiationUserId.sql deleted file mode 100644 index 817a95cbce..0000000000 --- a/src/Sql/dbo/Stored Procedures/OrganizationSponsorship_ReadBySponsoringOrganiationUserId.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE PROCEDURE [dbo].[OrganizationSponsorship_ReadBySponsoringOrganizationUserId] - @SponsoringOrganizationUserId UNIQUEIDENTIFIER -AS -BEGIN - SET NOCOUNT ON - - SELECT - * - FROM - [dbo].[OrganizationSponsorshipView] - WHERE - [SponsoringOrganizationUserId] = @SponsoringOrganizationUserId -END -GO diff --git a/src/Sql/dbo/Stored Procedures/OrganizationSponsorship_ReadBySponsoringOrganizationUserId.sql b/src/Sql/dbo/Stored Procedures/OrganizationSponsorship_ReadBySponsoringOrganizationUserId.sql new file mode 100644 index 0000000000..520a902601 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationSponsorship_ReadBySponsoringOrganizationUserId.sql @@ -0,0 +1,15 @@ +CREATE PROCEDURE [dbo].[OrganizationSponsorship_ReadBySponsoringOrganizationUserId] + @SponsoringOrganizationUserId UNIQUEIDENTIFIER, + @IsAdminInitiated BIT = 0 +AS +BEGIN + SET NOCOUNT ON; + + SELECT + * + FROM + [dbo].[OrganizationSponsorshipView] + WHERE + [SponsoringOrganizationUserID] = @SponsoringOrganizationUserId + and [IsAdminInitiated] = @IsAdminInitiated +END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadOccupiedSeatCountByOrganizationId.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadOccupiedSeatCountByOrganizationId.sql index 933441a210..3d861670a6 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadOccupiedSeatCountByOrganizationId.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadOccupiedSeatCountByOrganizationId.sql @@ -19,5 +19,19 @@ BEGIN FROM [dbo].[OrganizationSponsorship] WHERE SponsoringOrganizationId = @OrganizationId AND IsAdminInitiated = 1 + AND ( + -- Not marked for deletion - always count + (ToDelete = 0) + OR + -- Marked for deletion but has a valid until date in the future (RevokeWhenExpired status) + (ToDelete = 1 AND ValidUntil IS NOT NULL AND ValidUntil > GETUTCDATE()) + ) + AND ( + -- SENT status: When SponsoredOrganizationId is null + SponsoredOrganizationId IS NULL + OR + -- ACCEPTED status: When SponsoredOrganizationId is not null and ValidUntil is null or in the future + (SponsoredOrganizationId IS NOT NULL AND (ValidUntil IS NULL OR ValidUntil > GETUTCDATE())) + ) ) END diff --git a/src/Sql/dbo/Stored Procedures/VerfiedOrganaizationDomainSsoDetails_ReadByEmail.sql b/src/Sql/dbo/Stored Procedures/VerfiedOrganaizationDomainSsoDetails_ReadByEmail.sql index a32b42f6c1..2b1a594bfc 100644 --- a/src/Sql/dbo/Stored Procedures/VerfiedOrganaizationDomainSsoDetails_ReadByEmail.sql +++ b/src/Sql/dbo/Stored Procedures/VerfiedOrganaizationDomainSsoDetails_ReadByEmail.sql @@ -15,7 +15,7 @@ BEGIN OD.DomainName FROM [dbo].[OrganizationView] O INNER JOIN [dbo].[OrganizationDomainView] OD ON O.Id = OD.OrganizationId - LEFT JOIN [dbo].[Ssoconfig] S ON O.Id = S.OrganizationId + LEFT JOIN [dbo].[SsoConfig] S ON O.Id = S.OrganizationId WHERE OD.DomainName = @Domain AND O.Enabled = 1 AND OD.VerifiedDate IS NOT NULL diff --git a/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql b/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql index 1f749630a6..b2294ee21e 100644 --- a/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql +++ b/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql @@ -51,7 +51,8 @@ SELECT O.[AllowAdminAccessToAllCollectionItems], O.[UseRiskInsights], O.[UseAdminSponsoredFamilies], - O.[LimitItemDeletion] + O.[LimitItemDeletion], + OS.[IsAdminInitiated] FROM [dbo].[OrganizationUser] OU LEFT JOIN diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs new file mode 100644 index 0000000000..94432b05a0 --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs @@ -0,0 +1,39 @@ +using System.Net; +using System.Net.Http.Headers; +using Bit.Api.IntegrationTest.Factories; +using Bit.Seeder.Recipes; +using Xunit; +using Xunit.Abstractions; + +namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; + +public class OrganizationUsersControllerPerformanceTest(ITestOutputHelper testOutputHelper) +{ + [Theory(Skip = "Performance test")] + [InlineData(100)] + [InlineData(60000)] + public async Task GetAsync(int seats) + { + await using var factory = new ApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var seeder = new OrganizationWithUsersRecipe(db); + + var orgId = seeder.Seed("Org", seats, "large.test"); + + var tokens = await factory.LoginAsync("admin@large.test", "c55hlJ/cfdvTd4awTXUqow6X3cOQCfGwn11o3HblnPs="); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.GetAsync($"/organizations/{orgId}/users?includeCollections=true"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadAsStringAsync(); + Assert.NotEmpty(result); + + stopwatch.Stop(); + testOutputHelper.WriteLine($"Seed: {seats}; Request duration: {stopwatch.ElapsedMilliseconds} ms"); + } +} diff --git a/test/Api.IntegrationTest/Api.IntegrationTest.csproj b/test/Api.IntegrationTest/Api.IntegrationTest.csproj index 8fa74f98d4..a9d7fd502e 100644 --- a/test/Api.IntegrationTest/Api.IntegrationTest.csproj +++ b/test/Api.IntegrationTest/Api.IntegrationTest.csproj @@ -18,6 +18,7 @@ + diff --git a/test/Api.Test/AdminConsole/Authorization/HttpContextExtensionsTests.cs b/test/Api.Test/AdminConsole/Authorization/HttpContextExtensionsTests.cs index 1901742777..428726aaac 100644 --- a/test/Api.Test/AdminConsole/Authorization/HttpContextExtensionsTests.cs +++ b/test/Api.Test/AdminConsole/Authorization/HttpContextExtensionsTests.cs @@ -1,5 +1,7 @@ -using Bit.Api.AdminConsole.Authorization; +using AutoFixture.Xunit2; +using Bit.Api.AdminConsole.Authorization; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using NSubstitute; using Xunit; @@ -25,4 +27,42 @@ public class HttpContextExtensionsTests await callback.ReceivedWithAnyArgs(1).Invoke(); } + [Theory] + [InlineAutoData("orgId")] + [InlineAutoData("organizationId")] + public void GetOrganizationId_GivenValidParameter_ReturnsOrganizationId(string paramName, Guid orgId) + { + var httpContext = new DefaultHttpContext + { + Request = { RouteValues = new RouteValueDictionary + { + { "userId", "someGuid" }, + { paramName, orgId.ToString() } + } + } + }; + + var result = httpContext.GetOrganizationId(); + Assert.Equal(orgId, result); + } + + [Theory] + [InlineAutoData("orgId")] + [InlineAutoData("organizationId")] + [InlineAutoData("missingParameter")] + public void GetOrganizationId_GivenMissingOrInvalidGuid_Throws(string paramName) + { + var httpContext = new DefaultHttpContext + { + Request = { RouteValues = new RouteValueDictionary + { + { "userId", "someGuid" }, + { paramName, "invalidGuid" } + } + } + }; + + var exception = Assert.Throws(() => httpContext.GetOrganizationId()); + Assert.Equal(HttpContextExtensions.NoOrgIdError, exception.Message); + } } diff --git a/test/Api.Test/AdminConsole/Controllers/GroupsControllerPutTests.cs b/test/Api.Test/AdminConsole/Controllers/GroupsControllerPutTests.cs index 0e260e73e6..71dc5e5aea 100644 --- a/test/Api.Test/AdminConsole/Controllers/GroupsControllerPutTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/GroupsControllerPutTests.cs @@ -304,7 +304,7 @@ public class GroupsControllerPutTests // Arrange repositories sutProvider.GetDependency().GetManyUserIdsByIdAsync(group.Id).Returns(currentGroupUsers ?? []); sutProvider.GetDependency().GetByIdWithCollectionsAsync(group.Id) - .Returns(new Tuple>(group, currentCollectionAccess ?? [])); + .Returns(new Tuple>(group, currentCollectionAccess ?? [])); if (savingUser != null) { sutProvider.GetDependency().GetByOrganizationAsync(orgId, savingUser.UserId!.Value) diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index 107b9cdfb1..de54a44bca 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -238,20 +238,13 @@ public class OrganizationUsersControllerTests await Assert.ThrowsAsync(() => sutProvider.Sut.Invite(organizationAbility.Id, model)); } - [Theory] - [BitAutoData(true)] - [BitAutoData(false)] + [Theory, BitAutoData] public async Task Get_ReturnsUser( - bool accountDeprovisioningEnabled, OrganizationUserUserDetails organizationUser, ICollection collections, SutProvider sutProvider) { organizationUser.Permissions = null; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(accountDeprovisioningEnabled); - sutProvider.GetDependency() .ManageUsers(organizationUser.OrganizationId) .Returns(true); @@ -267,8 +260,8 @@ public class OrganizationUsersControllerTests var response = await sutProvider.Sut.Get(organizationUser.Id, false); Assert.Equal(organizationUser.Id, response.Id); - Assert.Equal(accountDeprovisioningEnabled, response.ManagedByOrganization); - Assert.Equal(accountDeprovisioningEnabled, response.ClaimedByOrganization); + Assert.True(response.ManagedByOrganization); + Assert.True(response.ClaimedByOrganization); } [Theory] diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 867f8f8ec6..7d0a57ea45 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -140,7 +140,6 @@ public class OrganizationsControllerTests : IDisposable _currentContext.OrganizationUser(orgId).Returns(true); _ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); - _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); _userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List { null }); var exception = await Assert.ThrowsAsync(() => _sut.Leave(orgId)); @@ -170,7 +169,6 @@ public class OrganizationsControllerTests : IDisposable _currentContext.OrganizationUser(orgId).Returns(true); _ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); - _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); _userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List { { foundOrg } }); var exception = await Assert.ThrowsAsync(() => _sut.Leave(orgId)); @@ -205,7 +203,6 @@ public class OrganizationsControllerTests : IDisposable _currentContext.OrganizationUser(orgId).Returns(true); _ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); - _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); _userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List()); await _sut.Leave(orgId); diff --git a/test/Api.Test/Api.Test.csproj b/test/Api.Test/Api.Test.csproj index ec22583caf..d6b31ce930 100644 --- a/test/Api.Test/Api.Test.csproj +++ b/test/Api.Test/Api.Test.csproj @@ -2,8 +2,6 @@ false - - $(WarningsNotAsErrors);CS8620;CS0169 diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index bd22fd9346..581a7e8f04 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -7,13 +7,13 @@ using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Api.KeyManagement.Validators; using Bit.Api.Tools.Models.Request; using Bit.Api.Vault.Models.Request; -using Bit.Core; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Exceptions; @@ -40,6 +40,7 @@ public class AccountsControllerTests : IDisposable private readonly IPolicyService _policyService; private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand; private readonly IRotateUserKeyCommand _rotateUserKeyCommand; + private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand; private readonly IFeatureService _featureService; @@ -64,6 +65,7 @@ public class AccountsControllerTests : IDisposable _policyService = Substitute.For(); _setInitialMasterPasswordCommand = Substitute.For(); _rotateUserKeyCommand = Substitute.For(); + _twoFactorIsEnabledQuery = Substitute.For(); _tdeOffboardingPasswordCommand = Substitute.For(); _featureService = Substitute.For(); _cipherValidator = @@ -87,6 +89,7 @@ public class AccountsControllerTests : IDisposable _setInitialMasterPasswordCommand, _tdeOffboardingPasswordCommand, _rotateUserKeyCommand, + _twoFactorIsEnabledQuery, _featureService, _cipherValidator, _folderValidator, @@ -189,21 +192,6 @@ public class AccountsControllerTests : IDisposable await _userService.Received(1).ChangeEmailAsync(user, default, default, default, default, default); } - [Fact] - public async Task PostEmail_WithAccountDeprovisioningEnabled_WhenUserIsNotManagedByAnOrganization_ShouldChangeUserEmail() - { - var user = GenerateExampleUser(); - ConfigureUserServiceToReturnValidPrincipalFor(user); - _userService.ChangeEmailAsync(user, default, default, default, default, default) - .Returns(Task.FromResult(IdentityResult.Success)); - _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); - _userService.IsClaimedByAnyOrganizationAsync(user.Id).Returns(false); - - await _sut.PostEmail(new EmailRequestModel()); - - await _userService.Received(1).ChangeEmailAsync(user, default, default, default, default, default); - } - [Fact] public async Task PostEmail_WhenNotAuthorized_ShouldThrownUnauthorizedAccessException() { @@ -533,12 +521,11 @@ public class AccountsControllerTests : IDisposable } [Fact] - public async Task Delete_WhenAccountDeprovisioningIsEnabled_WithUserManagedByAnOrganization_ThrowsBadRequestException() + public async Task Delete_WithUserManagedByAnOrganization_ThrowsBadRequestException() { var user = GenerateExampleUser(); ConfigureUserServiceToReturnValidPrincipalFor(user); ConfigureUserServiceToAcceptPasswordFor(user); - _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); _userService.IsClaimedByAnyOrganizationAsync(user.Id).Returns(true); var result = await Assert.ThrowsAsync(() => _sut.Delete(new SecretVerificationRequestModel())); @@ -547,12 +534,11 @@ public class AccountsControllerTests : IDisposable } [Fact] - public async Task Delete_WhenAccountDeprovisioningIsEnabled_WithUserNotManagedByAnOrganization_ShouldSucceed() + public async Task Delete_WithUserNotManagedByAnOrganization_ShouldSucceed() { var user = GenerateExampleUser(); ConfigureUserServiceToReturnValidPrincipalFor(user); ConfigureUserServiceToAcceptPasswordFor(user); - _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); _userService.IsClaimedByAnyOrganizationAsync(user.Id).Returns(false); _userService.DeleteAsync(user).Returns(IdentityResult.Success); diff --git a/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs b/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs index 81e100c58c..540d23f98b 100644 --- a/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs @@ -8,7 +8,6 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Settings; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -23,7 +22,6 @@ public class DevicesControllerTest private readonly IUntrustDevicesCommand _untrustDevicesCommand; private readonly IUserRepository _userRepositoryMock; private readonly ICurrentContext _currentContextMock; - private readonly IGlobalSettings _globalSettingsMock; private readonly ILogger _loggerMock; private readonly DevicesController _sut; diff --git a/test/Api.Test/Billing/Controllers/OrganizationSponsorshipsControllerTests.cs b/test/Api.Test/Billing/Controllers/OrganizationSponsorshipsControllerTests.cs index 377bc9c2c8..2ad7686c30 100644 --- a/test/Api.Test/Billing/Controllers/OrganizationSponsorshipsControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/OrganizationSponsorshipsControllerTests.cs @@ -6,6 +6,7 @@ using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Data; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; @@ -13,6 +14,7 @@ using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; +using NSubstitute.ReturnsExtensions; using Xunit; namespace Bit.Api.Test.Billing.Controllers; @@ -146,4 +148,86 @@ public class OrganizationSponsorshipsControllerTests .DidNotReceiveWithAnyArgs() .RemoveSponsorshipAsync(default); } + + [Theory] + [BitAutoData] + public async Task GetSponsoredOrganizations_OrganizationNotFound_ThrowsNotFound( + Guid sponsoringOrgId, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetByIdAsync(sponsoringOrgId).ReturnsNull(); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetSponsoredOrganizations(sponsoringOrgId)); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetManyBySponsoringOrganizationAsync(default); + } + + [Theory] + [BitAutoData] + public async Task GetSponsoredOrganizations_NotOrganizationOwner_ThrowsNotFound( + Organization sponsoringOrg, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().GetByIdAsync(sponsoringOrg.Id).Returns(sponsoringOrg); + sutProvider.GetDependency().OrganizationOwner(sponsoringOrg.Id).Returns(false); + sutProvider.GetDependency().OrganizationAdmin(sponsoringOrg.Id).Returns(false); + + // Create a CurrentContextOrganization with ManageUsers set to false + var currentContextOrg = new CurrentContextOrganization + { + Id = sponsoringOrg.Id, + Permissions = new Permissions { ManageUsers = false } + }; + sutProvider.GetDependency().Organizations.Returns(new List { currentContextOrg }); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetSponsoredOrganizations(sponsoringOrg.Id)); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetManyBySponsoringOrganizationAsync(default); + } + + [Theory] + [BitAutoData] + public async Task GetSponsoredOrganizations_Success_ReturnsSponsorships( + Organization sponsoringOrg, + List sponsorships, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().GetByIdAsync(sponsoringOrg.Id).Returns(sponsoringOrg); + sutProvider.GetDependency().OrganizationOwner(sponsoringOrg.Id).Returns(true); + sutProvider.GetDependency().OrganizationAdmin(sponsoringOrg.Id).Returns(false); + + // Create a CurrentContextOrganization from the sponsoringOrg + var currentContextOrg = new CurrentContextOrganization + { + Id = sponsoringOrg.Id, + Permissions = new Permissions { ManageUsers = true } + }; + sutProvider.GetDependency().Organizations.Returns(new List { currentContextOrg }); + + sutProvider.GetDependency() + .GetManyBySponsoringOrganizationAsync(sponsoringOrg.Id).Returns(sponsorships); + + // Set IsAdminInitiated to true for all test sponsorships + foreach (var sponsorship in sponsorships) + { + sponsorship.IsAdminInitiated = true; + } + + // Act + var result = await sutProvider.Sut.GetSponsoredOrganizations(sponsoringOrg.Id); + + // Assert + Assert.Equal(sponsorships.Count, result.Data.Count()); + await sutProvider.GetDependency().Received(1) + .GetManyBySponsoringOrganizationAsync(sponsoringOrg.Id); + } } diff --git a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs index df84f74d11..36990c7f9a 100644 --- a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs @@ -7,10 +7,10 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Context; using Bit.Core.Models.Api; using Bit.Core.Models.BitStripe; diff --git a/test/Api.Test/Billing/Queries/Organizations/OrganizationWarningsQueryTests.cs b/test/Api.Test/Billing/Queries/Organizations/OrganizationWarningsQueryTests.cs new file mode 100644 index 0000000000..67979f506e --- /dev/null +++ b/test/Api.Test/Billing/Queries/Organizations/OrganizationWarningsQueryTests.cs @@ -0,0 +1,315 @@ +using Bit.Api.Billing.Queries.Organizations; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Services; +using Bit.Core.Context; +using Bit.Core.Services; +using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Stripe; +using Stripe.TestHelpers; +using Xunit; + +namespace Bit.Api.Test.Billing.Queries.Organizations; + +[SutProviderCustomize] +public class OrganizationWarningsQueryTests +{ + private static readonly string[] _requiredExpansions = ["customer", "latest_invoice", "test_clock"]; + + [Theory, BitAutoData] + public async Task Run_NoSubscription_NoWarnings( + Organization organization, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .ReturnsNull(); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + FreeTrial: null, + InactiveSubscription: null, + ResellerRenewal: null + }); + } + + [Theory, BitAutoData] + public async Task Run_Has_FreeTrialWarning( + Organization organization, + SutProvider sutProvider) + { + var now = DateTime.UtcNow; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = StripeConstants.SubscriptionStatus.Trialing, + TrialEnd = now.AddDays(7), + Customer = new Customer + { + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + }, + TestClock = new TestClock + { + FrozenTime = now + } + }); + + sutProvider.GetDependency().EditSubscription(organization.Id).Returns(true); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + FreeTrial.RemainingTrialDays: 7 + }); + } + + [Theory, BitAutoData] + public async Task Run_Has_InactiveSubscriptionWarning_ContactProvider( + Organization organization, + SutProvider sutProvider) + { + organization.Enabled = false; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = StripeConstants.SubscriptionStatus.Unpaid + }); + + sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id) + .Returns(new Provider()); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + InactiveSubscription.Resolution: "contact_provider" + }); + } + + [Theory, BitAutoData] + public async Task Run_Has_InactiveSubscriptionWarning_AddPaymentMethod( + Organization organization, + SutProvider sutProvider) + { + organization.Enabled = false; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = StripeConstants.SubscriptionStatus.Unpaid + }); + + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + InactiveSubscription.Resolution: "add_payment_method" + }); + } + + [Theory, BitAutoData] + public async Task Run_Has_InactiveSubscriptionWarning_Resubscribe( + Organization organization, + SutProvider sutProvider) + { + organization.Enabled = false; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = StripeConstants.SubscriptionStatus.Canceled + }); + + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + InactiveSubscription.Resolution: "resubscribe" + }); + } + + [Theory, BitAutoData] + public async Task Run_Has_InactiveSubscriptionWarning_ContactOwner( + Organization organization, + SutProvider sutProvider) + { + organization.Enabled = false; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = StripeConstants.SubscriptionStatus.Unpaid + }); + + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(false); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + InactiveSubscription.Resolution: "contact_owner" + }); + } + + [Theory, BitAutoData] + public async Task Run_Has_ResellerRenewalWarning_Upcoming( + Organization organization, + SutProvider sutProvider) + { + var now = DateTime.UtcNow; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, + Status = StripeConstants.SubscriptionStatus.Active, + CurrentPeriodEnd = now.AddDays(10), + TestClock = new TestClock + { + FrozenTime = now + } + }); + + sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id) + .Returns(new Provider + { + Type = ProviderType.Reseller + }); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + ResellerRenewal.Type: "upcoming" + }); + + Assert.Equal(now.AddDays(10), response.ResellerRenewal.Upcoming!.RenewalDate); + } + + [Theory, BitAutoData] + public async Task Run_Has_ResellerRenewalWarning_Issued( + Organization organization, + SutProvider sutProvider) + { + var now = DateTime.UtcNow; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, + Status = StripeConstants.SubscriptionStatus.Active, + LatestInvoice = new Invoice + { + Status = StripeConstants.InvoiceStatus.Open, + DueDate = now.AddDays(30), + Created = now + }, + TestClock = new TestClock + { + FrozenTime = now + } + }); + + sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id) + .Returns(new Provider + { + Type = ProviderType.Reseller + }); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + ResellerRenewal.Type: "issued" + }); + + Assert.Equal(now, response.ResellerRenewal.Issued!.IssuedDate); + Assert.Equal(now.AddDays(30), response.ResellerRenewal.Issued!.DueDate); + } + + [Theory, BitAutoData] + public async Task Run_Has_ResellerRenewalWarning_PastDue( + Organization organization, + SutProvider sutProvider) + { + var now = DateTime.UtcNow; + + const string subscriptionId = "subscription_id"; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Id = subscriptionId, + CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, + Status = StripeConstants.SubscriptionStatus.PastDue, + TestClock = new TestClock + { + FrozenTime = now + } + }); + + sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id) + .Returns(new Provider + { + Type = ProviderType.Reseller + }); + + var dueDate = now.AddDays(-10); + + sutProvider.GetDependency().InvoiceSearchAsync(Arg.Is(options => + options.Query == $"subscription:'{subscriptionId}' status:'open'")).Returns([ + new Invoice { DueDate = dueDate, Created = dueDate.AddDays(-30) } + ]); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + ResellerRenewal.Type: "past_due" + }); + + Assert.Equal(dueDate.AddDays(30), response.ResellerRenewal.PastDue!.SuspensionDate); + } +} diff --git a/test/Api.Test/Tools/Controllers/ReportsControllerTests.cs b/test/Api.Test/Dirt/ReportsControllerTests.cs similarity index 100% rename from test/Api.Test/Tools/Controllers/ReportsControllerTests.cs rename to test/Api.Test/Dirt/ReportsControllerTests.cs diff --git a/test/Api.Test/Utilities/CommandResultExtensionTests.cs b/test/Api.Test/Utilities/CommandResultExtensionTests.cs deleted file mode 100644 index dafae10b5b..0000000000 --- a/test/Api.Test/Utilities/CommandResultExtensionTests.cs +++ /dev/null @@ -1,107 +0,0 @@ -using Bit.Api.Utilities; -using Bit.Core.Models.Commands; -using Bit.Core.Vault.Entities; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Xunit; - -namespace Bit.Api.Test.Utilities; - -public class CommandResultExtensionTests -{ - public static IEnumerable WithGenericTypeTestCases() - { - yield return new object[] - { - new NoRecordFoundFailure(new[] { "Error 1", "Error 2" }), - new ObjectResult(new[] { "Error 1", "Error 2" }) { StatusCode = StatusCodes.Status404NotFound } - }; - yield return new object[] - { - new BadRequestFailure("Error 3"), - new ObjectResult(new[] { "Error 3" }) { StatusCode = StatusCodes.Status400BadRequest } - }; - yield return new object[] - { - new Failure("Error 4"), - new ObjectResult(new[] { "Error 4" }) { StatusCode = StatusCodes.Status400BadRequest } - }; - var cipher = new Cipher() { Id = Guid.NewGuid() }; - - yield return new object[] - { - new Success(cipher), - new ObjectResult(cipher) { StatusCode = StatusCodes.Status200OK } - }; - } - - - [Theory] - [MemberData(nameof(WithGenericTypeTestCases))] - public void MapToActionResult_WithGenericType_ShouldMapToHttpResponse(CommandResult input, ObjectResult expected) - { - var result = input.MapToActionResult(); - - Assert.Equivalent(expected, result); - } - - - [Fact] - public void MapToActionResult_WithGenericType_ShouldThrowExceptionForUnhandledCommandResult() - { - var result = new NotImplementedCommandResult(); - - Assert.Throws(() => result.MapToActionResult()); - } - - public static IEnumerable TestCases() - { - yield return new object[] - { - new NoRecordFoundFailure(new[] { "Error 1", "Error 2" }), - new ObjectResult(new[] { "Error 1", "Error 2" }) { StatusCode = StatusCodes.Status404NotFound } - }; - yield return new object[] - { - new BadRequestFailure("Error 3"), - new ObjectResult(new[] { "Error 3" }) { StatusCode = StatusCodes.Status400BadRequest } - }; - yield return new object[] - { - new Failure("Error 4"), - new ObjectResult(new[] { "Error 4" }) { StatusCode = StatusCodes.Status400BadRequest } - }; - yield return new object[] - { - new Success(), - new ObjectResult(new { }) { StatusCode = StatusCodes.Status200OK } - }; - } - - [Theory] - [MemberData(nameof(TestCases))] - public void MapToActionResult_ShouldMapToHttpResponse(CommandResult input, ObjectResult expected) - { - var result = input.MapToActionResult(); - - Assert.Equivalent(expected, result); - } - - [Fact] - public void MapToActionResult_ShouldThrowExceptionForUnhandledCommandResult() - { - var result = new NotImplementedCommandResult(); - - Assert.Throws(() => result.MapToActionResult()); - } -} - -public class NotImplementedCommandResult : CommandResult -{ - -} - -public class NotImplementedCommandResult : CommandResult -{ - -} diff --git a/test/Api.Test/Vault/Controllers/SyncControllerTests.cs b/test/Api.Test/Vault/Controllers/SyncControllerTests.cs index 03c05ef0f4..ebbfc2a2ba 100644 --- a/test/Api.Test/Vault/Controllers/SyncControllerTests.cs +++ b/test/Api.Test/Vault/Controllers/SyncControllerTests.cs @@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Models; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -64,6 +65,7 @@ public class SyncControllerTests { // Get dependencies var userService = sutProvider.GetDependency(); + var twoFactorIsEnabledQuery = sutProvider.GetDependency(); var organizationUserRepository = sutProvider.GetDependency(); var providerUserRepository = sutProvider.GetDependency(); var folderRepository = sutProvider.GetDependency(); @@ -119,7 +121,7 @@ public class SyncControllerTests collectionRepository.GetManyByUserIdAsync(user.Id).Returns(collections); collectionCipherRepository.GetManyByUserIdAsync(user.Id).Returns(new List()); // Back to standard test setup - userService.TwoFactorIsEnabledAsync(user).Returns(false); + twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user).Returns(false); userService.HasPremiumFromOrganization(user).Returns(false); // Execute GET @@ -129,7 +131,7 @@ public class SyncControllerTests // Asserts // Assert that methods are called var hasEnabledOrgs = organizationUserDetails.Any(o => o.Enabled); - await this.AssertMethodsCalledAsync(userService, organizationUserRepository, providerUserRepository, folderRepository, + await this.AssertMethodsCalledAsync(userService, twoFactorIsEnabledQuery, organizationUserRepository, providerUserRepository, folderRepository, cipherRepository, sendRepository, collectionRepository, collectionCipherRepository, hasEnabledOrgs); Assert.IsType(result); @@ -155,6 +157,7 @@ public class SyncControllerTests { // Get dependencies var userService = sutProvider.GetDependency(); + var twoFactorIsEnabledQuery = sutProvider.GetDependency(); var organizationUserRepository = sutProvider.GetDependency(); var providerUserRepository = sutProvider.GetDependency(); var folderRepository = sutProvider.GetDependency(); @@ -205,7 +208,7 @@ public class SyncControllerTests policyRepository.GetManyByUserIdAsync(user.Id).Returns(policies); - userService.TwoFactorIsEnabledAsync(user).Returns(false); + twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user).Returns(false); userService.HasPremiumFromOrganization(user).Returns(false); // Execute GET @@ -216,7 +219,7 @@ public class SyncControllerTests // Assert that methods are called var hasEnabledOrgs = organizationUserDetails.Any(o => o.Enabled); - await this.AssertMethodsCalledAsync(userService, organizationUserRepository, providerUserRepository, folderRepository, + await this.AssertMethodsCalledAsync(userService, twoFactorIsEnabledQuery, organizationUserRepository, providerUserRepository, folderRepository, cipherRepository, sendRepository, collectionRepository, collectionCipherRepository, hasEnabledOrgs); Assert.IsType(result); @@ -244,6 +247,7 @@ public class SyncControllerTests { // Get dependencies var userService = sutProvider.GetDependency(); + var twoFactorIsEnabledQuery = sutProvider.GetDependency(); var organizationUserRepository = sutProvider.GetDependency(); var providerUserRepository = sutProvider.GetDependency(); var folderRepository = sutProvider.GetDependency(); @@ -283,7 +287,7 @@ public class SyncControllerTests collectionRepository.GetManyByUserIdAsync(user.Id).Returns(collections); collectionCipherRepository.GetManyByUserIdAsync(user.Id).Returns(new List()); // Back to standard test setup - userService.TwoFactorIsEnabledAsync(user).Returns(false); + twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user).Returns(false); userService.HasPremiumFromOrganization(user).Returns(false); // Execute GET @@ -293,7 +297,7 @@ public class SyncControllerTests // Assert that methods are called var hasEnabledOrgs = organizationUserDetails.Any(o => o.Enabled); - await this.AssertMethodsCalledAsync(userService, organizationUserRepository, providerUserRepository, folderRepository, + await this.AssertMethodsCalledAsync(userService, twoFactorIsEnabledQuery, organizationUserRepository, providerUserRepository, folderRepository, cipherRepository, sendRepository, collectionRepository, collectionCipherRepository, hasEnabledOrgs); Assert.IsType(result); @@ -315,6 +319,7 @@ public class SyncControllerTests private async Task AssertMethodsCalledAsync(IUserService userService, + ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IOrganizationUserRepository organizationUserRepository, IProviderUserRepository providerUserRepository, IFolderRepository folderRepository, ICipherRepository cipherRepository, ISendRepository sendRepository, @@ -356,7 +361,7 @@ public class SyncControllerTests .GetManyByUserIdAsync(default); } - await userService.ReceivedWithAnyArgs(1) + await twoFactorIsEnabledQuery.ReceivedWithAnyArgs(1) .TwoFactorIsEnabledAsync(default(ITwoFactorProvidersUser)); await userService.ReceivedWithAnyArgs(1) .HasPremiumFromOrganization(default); diff --git a/test/Billing.Test/Controllers/FreshdeskControllerTests.cs b/test/Billing.Test/Controllers/FreshdeskControllerTests.cs index 26ce310b9c..90f8a09ea0 100644 --- a/test/Billing.Test/Controllers/FreshdeskControllerTests.cs +++ b/test/Billing.Test/Controllers/FreshdeskControllerTests.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using NSubstitute; +using NSubstitute.ReceivedExtensions; using Xunit; namespace Bit.Billing.Test.Controllers; @@ -71,6 +72,41 @@ public class FreshdeskControllerTests _ = mockHttpMessageHandler.Received(1).Send(Arg.Is(m => m.Method == HttpMethod.Post && m.RequestUri.ToString().EndsWith($"{model.TicketId}/notes")), Arg.Any()); } + [Theory] + [BitAutoData(WebhookKey)] + public async Task PostWebhook_add_note_when_user_is_invalid( + string freshdeskWebhookKey, FreshdeskWebhookModel model, + SutProvider sutProvider) + { + // Arrange - for an invalid user + model.TicketContactEmail = "invalid@user"; + sutProvider.GetDependency().GetByEmailAsync(model.TicketContactEmail).Returns((User)null); + sutProvider.GetDependency>().Value.FreshDesk.WebhookKey.Returns(WebhookKey); + + var mockHttpMessageHandler = Substitute.ForPartsOf(); + var mockResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK); + mockHttpMessageHandler.Send(Arg.Any(), Arg.Any()) + .Returns(mockResponse); + var httpClient = new HttpClient(mockHttpMessageHandler); + sutProvider.GetDependency().CreateClient("FreshdeskApi").Returns(httpClient); + + // Act + var response = await sutProvider.Sut.PostWebhook(freshdeskWebhookKey, model); + + // Assert + var statusCodeResult = Assert.IsAssignableFrom(response); + Assert.Equal(StatusCodes.Status200OK, statusCodeResult.StatusCode); + + await mockHttpMessageHandler + .Received(1).Send( + Arg.Is( + m => m.Method == HttpMethod.Post + && m.RequestUri.ToString().EndsWith($"{model.TicketId}/notes") + && m.Content.ReadAsStringAsync().Result.Contains("No user found")), + Arg.Any()); + } + + [Theory] [BitAutoData((string)null, null)] [BitAutoData((string)null)] diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs index daa560f3bc..b0774927e3 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs @@ -166,7 +166,7 @@ public class VerifyOrganizationDomainCommandTests } [Theory, BitAutoData] - public async Task UserVerifyOrganizationDomainAsync_GivenOrganizationDomainWithAccountDeprovisioningEnabled_WhenDomainIsVerified_ThenSingleOrgPolicyShouldBeEnabled( + public async Task UserVerifyOrganizationDomainAsync_WhenDomainIsVerified_ThenSingleOrgPolicyShouldBeEnabled( OrganizationDomain domain, Guid userId, SutProvider sutProvider) { sutProvider.GetDependency() @@ -177,10 +177,6 @@ public class VerifyOrganizationDomainCommandTests .ResolveAsync(domain.DomainName, domain.Txt) .Returns(true); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(true); - sutProvider.GetDependency() .UserId.Returns(userId); @@ -196,33 +192,7 @@ public class VerifyOrganizationDomainCommandTests } [Theory, BitAutoData] - public async Task UserVerifyOrganizationDomainAsync_GivenOrganizationDomainWithAccountDeprovisioningDisabled_WhenDomainIsVerified_ThenSingleOrgPolicyShouldBeNotBeEnabled( - OrganizationDomain domain, SutProvider sutProvider) - { - sutProvider.GetDependency() - .GetClaimedDomainsByDomainNameAsync(domain.DomainName) - .Returns([]); - - sutProvider.GetDependency() - .ResolveAsync(domain.DomainName, domain.Txt) - .Returns(true); - - sutProvider.GetDependency() - .UserId.Returns(Guid.NewGuid()); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(false); - - _ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain); - - await sutProvider.GetDependency() - .DidNotReceive() - .SaveAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task UserVerifyOrganizationDomainAsync_GivenOrganizationDomainWithAccountDeprovisioningEnabled_WhenDomainIsNotVerified_ThenSingleOrgPolicyShouldNotBeEnabled( + public async Task UserVerifyOrganizationDomainAsync_WhenDomainIsNotVerified_ThenSingleOrgPolicyShouldNotBeEnabled( OrganizationDomain domain, SutProvider sutProvider) { sutProvider.GetDependency() @@ -236,10 +206,6 @@ public class VerifyOrganizationDomainCommandTests sutProvider.GetDependency() .UserId.Returns(Guid.NewGuid()); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(true); - _ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain); await sutProvider.GetDependency() @@ -248,33 +214,7 @@ public class VerifyOrganizationDomainCommandTests } [Theory, BitAutoData] - public async Task UserVerifyOrganizationDomainAsync_GivenOrganizationDomainWithAccountDeprovisioningDisabled_WhenDomainIsNotVerified_ThenSingleOrgPolicyShouldBeNotBeEnabled( - OrganizationDomain domain, SutProvider sutProvider) - { - sutProvider.GetDependency() - .GetClaimedDomainsByDomainNameAsync(domain.DomainName) - .Returns([]); - - sutProvider.GetDependency() - .ResolveAsync(domain.DomainName, domain.Txt) - .Returns(false); - - sutProvider.GetDependency() - .UserId.Returns(Guid.NewGuid()); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(true); - - _ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain); - - await sutProvider.GetDependency() - .DidNotReceive() - .SaveAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task UserVerifyOrganizationDomainAsync_GivenOrganizationDomainWithAccountDeprovisioningEnabled_WhenDomainIsVerified_ThenEmailShouldBeSentToUsersWhoBelongToTheDomain( + public async Task UserVerifyOrganizationDomainAsync_WhenDomainIsVerified_ThenEmailShouldBeSentToUsersWhoBelongToTheDomain( ICollection organizationUsers, OrganizationDomain domain, Organization organization, @@ -306,10 +246,6 @@ public class VerifyOrganizationDomainCommandTests sutProvider.GetDependency() .UserId.Returns(Guid.NewGuid()); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(true); - sutProvider.GetDependency() .GetManyDetailsByOrganizationAsync(domain.OrganizationId) .Returns(mockedUsers); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs index 2dda23481a..baf844acae 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; @@ -28,6 +29,7 @@ namespace Bit.Core.Test.OrganizationFeatures.OrganizationUsers; public class AcceptOrgUserCommandTests { private readonly IUserService _userService = Substitute.For(); + private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery = Substitute.For(); private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory = Substitute.For(); private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory = new FakeDataProtectorTokenFactory(); @@ -165,7 +167,7 @@ public class AcceptOrgUserCommandTests SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); // User doesn't have 2FA enabled - _userService.TwoFactorIsEnabledAsync(user).Returns(false); + _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user).Returns(false); // Organization they are trying to join requires 2FA var twoFactorPolicy = new OrganizationUserPolicyDetails { OrganizationId = orgUser.OrganizationId }; @@ -646,7 +648,7 @@ public class AcceptOrgUserCommandTests .Returns(false); // User doesn't have 2FA enabled - _userService.TwoFactorIsEnabledAsync(user).Returns(false); + _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user).Returns(false); // Org does not require 2FA sutProvider.GetDependency().GetPoliciesApplicableToUserAsync(user.Id, diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs index 0592b481d3..e54e4aa99b 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs @@ -2,7 +2,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; -using Bit.Core.AdminConsole.Errors; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; @@ -11,12 +10,13 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.M using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.AdminConsole.Utilities.Commands; +using Bit.Core.AdminConsole.Utilities.Errors; +using Bit.Core.AdminConsole.Utilities.Validation; using Bit.Core.Billing.Models.StaticStore.Plans; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; -using Bit.Core.Models.Commands; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.StaticStore; @@ -80,7 +80,7 @@ public class InviteOrganizationUserCommandTests // Assert Assert.IsType>(result); - Assert.Equal(NoUsersToInviteError.Code, (result as Failure).ErrorMessage); + Assert.Equal(NoUsersToInviteError.Code, (result as Failure)!.Error.Message); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -209,7 +209,7 @@ public class InviteOrganizationUserCommandTests Assert.IsType>(result); var failure = result as Failure; - Assert.Equal(errorMessage, failure!.ErrorMessage); + Assert.Equal(errorMessage, failure!.Error.Message); await sutProvider.GetDependency() .DidNotReceive() @@ -571,7 +571,7 @@ public class InviteOrganizationUserCommandTests // Assert Assert.IsType>(result); - Assert.Equal(FailedToInviteUsersError.Code, (result as Failure)!.ErrorMessage); + Assert.Equal(FailedToInviteUsersError.Code, (result as Failure)!.Error.Message); // org user revert await orgUserRepository.Received(1).DeleteManyAsync(Arg.Is>(x => x.Count() == 1)); @@ -677,7 +677,7 @@ public class InviteOrganizationUserCommandTests // Assert Assert.IsType>(result); - sutProvider.GetDependency().Received(1) + await sutProvider.GetDependency().Received(1) .SendOrganizationMaxSeatLimitReachedEmailAsync(organization, 2, Arg.Is>(emails => emails.Any(email => email == "provider@email.com"))); } @@ -768,7 +768,7 @@ public class InviteOrganizationUserCommandTests // Assert Assert.IsType>(result); - sutProvider.GetDependency().Received(1) + await sutProvider.GetDependency().Received(1) .SendOrganizationAutoscaledEmailAsync(organization, 1, Arg.Is>(emails => emails.Any(email => email == "provider@email.com"))); } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUsersValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUsersValidatorTests.cs index 191ef05603..7c06e04256 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUsersValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUsersValidatorTests.cs @@ -2,7 +2,7 @@ using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; -using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.AdminConsole.Utilities.Validation; using Bit.Core.Billing.Models.StaticStore.Plans; using Bit.Core.Exceptions; using Bit.Core.Models.Business; @@ -61,7 +61,7 @@ public class InviteOrganizationUsersValidatorTests _ = await sutProvider.Sut.ValidateAsync(request); - sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) .ValidateUpdateAsync(Arg.Is(x => x.SmSeatsChanged == true && x.SmSeats == 12)); @@ -156,6 +156,6 @@ public class InviteOrganizationUsersValidatorTests var result = await sutProvider.Sut.ValidateAsync(request); Assert.IsType>(result); - Assert.Equal("Some Secrets Manager Failure", (result as Invalid)!.ErrorMessageString); + Assert.Equal("Some Secrets Manager Failure", (result as Invalid)!.Error.Message); } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserOrganizationValidationTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserOrganizationValidationTests.cs index 508b9f3cb0..be5586f8a6 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserOrganizationValidationTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserOrganizationValidationTests.cs @@ -1,7 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization; -using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.AdminConsole.Utilities.Validation; using Bit.Core.Billing.Models.StaticStore.Plans; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -36,7 +36,7 @@ public class InviteUserOrganizationValidationTests var result = await sutProvider.Sut.ValidateAsync(inviteOrganization); Assert.IsType>(result); - Assert.Equal(OrganizationNoPaymentMethodFoundError.Code, (result as Invalid)!.ErrorMessageString); + Assert.Equal(OrganizationNoPaymentMethodFoundError.Code, (result as Invalid)!.Error.Message); } [Theory] @@ -53,6 +53,6 @@ public class InviteUserOrganizationValidationTests var result = await sutProvider.Sut.ValidateAsync(inviteOrganization); Assert.IsType>(result); - Assert.Equal(OrganizationNoSubscriptionFoundError.Code, (result as Invalid)!.ErrorMessageString); + Assert.Equal(OrganizationNoSubscriptionFoundError.Code, (result as Invalid)!.Error.Message); } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserPaymentValidationTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserPaymentValidationTests.cs index bcca89e1d2..d508f7cc5e 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserPaymentValidationTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserPaymentValidationTests.cs @@ -3,7 +3,7 @@ using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments; -using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.AdminConsole.Utilities.Validation; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.StaticStore.Plans; @@ -39,7 +39,7 @@ public class InviteUserPaymentValidationTests }); Assert.IsType>(result); - Assert.Equal(PaymentCancelledSubscriptionError.Code, (result as Invalid)!.ErrorMessageString); + Assert.Equal(PaymentCancelledSubscriptionError.Code, (result as Invalid)!.Error.Message); } [Fact] diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManagerInviteUserValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManagerInviteUserValidatorTests.cs index c320ada8cb..571832d675 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManagerInviteUserValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManagerInviteUserValidatorTests.cs @@ -1,7 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; -using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.AdminConsole.Utilities.Validation; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.StaticStore.Plans; using Bit.Test.Common.AutoFixture; @@ -67,7 +67,7 @@ public class InviteUsersPasswordManagerValidatorTests var result = await sutProvider.Sut.ValidateAsync(subscriptionUpdate); Assert.IsType>(result); - Assert.Equal(PasswordManagerSeatLimitHasBeenReachedError.Code, (result as Invalid)!.ErrorMessageString); + Assert.Equal(PasswordManagerSeatLimitHasBeenReachedError.Code, (result as Invalid)!.Error.Message); } [Theory] @@ -88,6 +88,6 @@ public class InviteUsersPasswordManagerValidatorTests var result = await sutProvider.Sut.ValidateAsync(subscriptionUpdate); Assert.IsType>(result); - Assert.Equal(PasswordManagerPlanDoesNotAllowAdditionalSeatsError.Code, (result as Invalid)!.ErrorMessageString); + Assert.Equal(PasswordManagerPlanDoesNotAllowAdditionalSeatsError.Code, (result as Invalid)!.Error.Message); } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommandTests.cs index 3578706e47..c105c7a9ee 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommandTests.cs @@ -40,43 +40,6 @@ public class RemoveOrganizationUserCommandTests // Act await sutProvider.Sut.RemoveUserAsync(deletingUser.OrganizationId, organizationUser.Id, deletingUser.UserId); - // Assert - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .GetUsersOrganizationClaimedStatusAsync(default, default); - await sutProvider.GetDependency() - .Received(1) - .DeleteAsync(organizationUser); - await sutProvider.GetDependency() - .Received(1) - .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Removed); - } - - [Theory, BitAutoData] - public async Task RemoveUser_WithDeletingUserId_WithAccountDeprovisioningEnabled_Success( - [OrganizationUser(type: OrganizationUserType.User)] OrganizationUser organizationUser, - [OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser deletingUser, - SutProvider sutProvider) - { - // Arrange - organizationUser.OrganizationId = deletingUser.OrganizationId; - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - sutProvider.GetDependency() - .GetByIdAsync(deletingUser.Id) - .Returns(deletingUser); - sutProvider.GetDependency() - .OrganizationOwner(deletingUser.OrganizationId) - .Returns(true); - - // Act - await sutProvider.Sut.RemoveUserAsync(deletingUser.OrganizationId, organizationUser.Id, deletingUser.UserId); - // Assert await sutProvider.GetDependency() .Received(1) @@ -235,15 +198,12 @@ public class RemoveOrganizationUserCommandTests } [Theory, BitAutoData] - public async Task RemoveUserAsync_WithDeletingUserId_WithAccountDeprovisioningEnabled_WhenUserIsManaged_ThrowsException( + public async Task RemoveUserAsync_WithDeletingUserId_WhenUserIsManaged_ThrowsException( [OrganizationUser(status: OrganizationUserStatusType.Confirmed)] OrganizationUser orgUser, Guid deletingUserId, SutProvider sutProvider) { // Arrange - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(true); sutProvider.GetDependency() .GetByIdAsync(orgUser.Id) .Returns(orgUser); @@ -285,34 +245,6 @@ public class RemoveOrganizationUserCommandTests .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Removed, eventSystemUser); } - [Theory, BitAutoData] - public async Task RemoveUser_WithEventSystemUser_WithAccountDeprovisioningEnabled_Success( - [OrganizationUser(type: OrganizationUserType.User)] OrganizationUser organizationUser, - EventSystemUser eventSystemUser, SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - - // Act - await sutProvider.Sut.RemoveUserAsync(organizationUser.OrganizationId, organizationUser.Id, eventSystemUser); - - // Assert - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .GetUsersOrganizationClaimedStatusAsync(default, default); - await sutProvider.GetDependency() - .Received(1) - .DeleteAsync(organizationUser); - await sutProvider.GetDependency() - .Received(1) - .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Removed, eventSystemUser); - } - [Theory] [BitAutoData] public async Task RemoveUser_WithEventSystemUser_NotFound_ThrowsException( @@ -474,7 +406,6 @@ public class RemoveOrganizationUserCommandTests var sutProvider = SutProviderFactory(); var eventDate = sutProvider.GetDependency().GetUtcNow().UtcDateTime; orgUser1.OrganizationId = orgUser2.OrganizationId = deletingUser.OrganizationId; - var organizationUsers = new[] { orgUser1, orgUser2 }; var organizationUserIds = organizationUsers.Select(u => u.Id); @@ -499,60 +430,6 @@ public class RemoveOrganizationUserCommandTests // Act var result = await sutProvider.Sut.RemoveUsersAsync(deletingUser.OrganizationId, organizationUserIds, deletingUser.UserId); - // Assert - Assert.Equal(2, result.Count()); - Assert.All(result, r => Assert.Empty(r.ErrorMessage)); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .GetUsersOrganizationClaimedStatusAsync(default, default); - await sutProvider.GetDependency() - .Received(1) - .DeleteManyAsync(Arg.Is>(i => i.Contains(orgUser1.Id) && i.Contains(orgUser2.Id))); - await sutProvider.GetDependency() - .Received(1) - .LogOrganizationUserEventsAsync( - Arg.Is>(i => - i.First().OrganizationUser.Id == orgUser1.Id - && i.Last().OrganizationUser.Id == orgUser2.Id - && i.All(u => u.DateTime == eventDate))); - } - - [Theory, BitAutoData] - public async Task RemoveUsers_WithDeletingUserId_WithAccountDeprovisioningEnabled_Success( - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser deletingUser, - [OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser orgUser1, OrganizationUser orgUser2) - { - // Arrange - var sutProvider = SutProviderFactory(); - var eventDate = sutProvider.GetDependency().GetUtcNow().UtcDateTime; - orgUser1.OrganizationId = orgUser2.OrganizationId = deletingUser.OrganizationId; - var organizationUsers = new[] { orgUser1, orgUser2 }; - var organizationUserIds = organizationUsers.Select(u => u.Id); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(true); - sutProvider.GetDependency() - .GetManyAsync(default) - .ReturnsForAnyArgs(organizationUsers); - sutProvider.GetDependency() - .GetByIdAsync(deletingUser.Id) - .Returns(deletingUser); - sutProvider.GetDependency() - .HasConfirmedOwnersExceptAsync(deletingUser.OrganizationId, Arg.Any>()) - .Returns(true); - sutProvider.GetDependency() - .OrganizationOwner(deletingUser.OrganizationId) - .Returns(true); - sutProvider.GetDependency() - .GetUsersOrganizationClaimedStatusAsync( - deletingUser.OrganizationId, - Arg.Is>(i => i.Contains(orgUser1.Id) && i.Contains(orgUser2.Id))) - .Returns(new Dictionary { { orgUser1.Id, false }, { orgUser2.Id, false } }); - - // Act - var result = await sutProvider.Sut.RemoveUsersAsync(deletingUser.OrganizationId, organizationUserIds, deletingUser.UserId); - // Assert Assert.Equal(2, result.Count()); Assert.All(result, r => Assert.Empty(r.ErrorMessage)); @@ -638,7 +515,7 @@ public class RemoveOrganizationUserCommandTests } [Theory, BitAutoData] - public async Task RemoveUsers_WithDeletingUserId_RemovingClaimedUser_WithAccountDeprovisioningEnabled_ThrowsException( + public async Task RemoveUsers_WithDeletingUserId_RemovingClaimedUser_ThrowsException( [OrganizationUser(status: OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser, OrganizationUser deletingUser, SutProvider sutProvider) @@ -646,10 +523,6 @@ public class RemoveOrganizationUserCommandTests // Arrange orgUser.OrganizationId = deletingUser.OrganizationId; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(true); - sutProvider.GetDependency() .GetManyAsync(Arg.Is>(i => i.Contains(orgUser.Id))) .Returns(new[] { orgUser }); @@ -739,51 +612,6 @@ public class RemoveOrganizationUserCommandTests && u.DateTime == eventDate))); } - [Theory, BitAutoData] - public async Task RemoveUsers_WithEventSystemUser_WithAccountDeprovisioningEnabled_Success( - EventSystemUser eventSystemUser, - [OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser orgUser1, - OrganizationUser orgUser2) - { - // Arrange - var sutProvider = SutProviderFactory(); - var eventDate = sutProvider.GetDependency().GetUtcNow().UtcDateTime; - orgUser1.OrganizationId = orgUser2.OrganizationId; - var organizationUsers = new[] { orgUser1, orgUser2 }; - var organizationUserIds = organizationUsers.Select(u => u.Id); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(true); - sutProvider.GetDependency() - .GetManyAsync(default) - .ReturnsForAnyArgs(organizationUsers); - sutProvider.GetDependency() - .HasConfirmedOwnersExceptAsync(orgUser1.OrganizationId, Arg.Any>()) - .Returns(true); - - // Act - var result = await sutProvider.Sut.RemoveUsersAsync(orgUser1.OrganizationId, organizationUserIds, eventSystemUser); - - // Assert - Assert.Equal(2, result.Count()); - Assert.All(result, r => Assert.Empty(r.ErrorMessage)); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .GetUsersOrganizationClaimedStatusAsync(default, default); - await sutProvider.GetDependency() - .Received(1) - .DeleteManyAsync(Arg.Is>(i => i.Contains(orgUser1.Id) && i.Contains(orgUser2.Id))); - await sutProvider.GetDependency() - .Received(1) - .LogOrganizationUserEventsAsync( - Arg.Is>( - i => i.First().OrganizationUser.Id == orgUser1.Id - && i.Last().OrganizationUser.Id == orgUser2.Id - && i.All(u => u.EventSystemUser == eventSystemUser - && u.DateTime == eventDate))); - } - [Theory, BitAutoData] public async Task RemoveUsers_WithEventSystemUser_WithMismatchingOrganizationId_ThrowsException( EventSystemUser eventSystemUser, diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommandTests.cs new file mode 100644 index 0000000000..83ea4798db --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommandTests.cs @@ -0,0 +1,169 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Tokens; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Fakes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations; + +[SutProviderCustomize] +public class InitPendingOrganizationCommandTests +{ + + private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory = Substitute.For(); + private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory = new FakeDataProtectorTokenFactory(); + + [Theory, BitAutoData] + public async Task Init_Organization_Success(User user, Guid orgId, Guid orgUserId, string publicKey, + string privateKey, SutProvider sutProvider, Organization org, OrganizationUser orgUser) + { + var token = CreateToken(orgUser, orgUserId, sutProvider); + + org.PrivateKey = null; + org.PublicKey = null; + + var organizationRepository = sutProvider.GetDependency(); + organizationRepository.GetByIdAsync(orgId).Returns(org); + + var organizationServcie = sutProvider.GetDependency(); + var collectionRepository = sutProvider.GetDependency(); + + await sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token); + + await organizationRepository.Received().GetByIdAsync(orgId); + await organizationServcie.Received().UpdateAsync(org); + await collectionRepository.DidNotReceiveWithAnyArgs().CreateAsync(default); + + } + + [Theory, BitAutoData] + public async Task Init_Organization_With_CollectionName_Success(User user, Guid orgId, Guid orgUserId, string publicKey, + string privateKey, SutProvider sutProvider, Organization org, string collectionName, OrganizationUser orgUser) + { + var token = CreateToken(orgUser, orgUserId, sutProvider); + + org.PrivateKey = null; + org.PublicKey = null; + org.Id = orgId; + + var organizationRepository = sutProvider.GetDependency(); + organizationRepository.GetByIdAsync(orgId).Returns(org); + + var organizationServcie = sutProvider.GetDependency(); + var collectionRepository = sutProvider.GetDependency(); + + await sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, collectionName, token); + + await organizationRepository.Received().GetByIdAsync(orgId); + await organizationServcie.Received().UpdateAsync(org); + + await collectionRepository.Received().CreateAsync( + Arg.Any(), + Arg.Is>(l => l == null), + Arg.Is>(l => l.Any(i => i.Manage == true))); + + } + + [Theory, BitAutoData] + public async Task Init_Organization_When_Organization_Is_Enabled(User user, Guid orgId, Guid orgUserId, string publicKey, + string privateKey, SutProvider sutProvider, Organization org, OrganizationUser orgUser) + + { + var token = CreateToken(orgUser, orgUserId, sutProvider); + + org.Enabled = true; + + var organizationRepository = sutProvider.GetDependency(); + organizationRepository.GetByIdAsync(orgId).Returns(org); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token)); + + Assert.Equal("Organization is already enabled.", exception.Message); + + } + + [Theory, BitAutoData] + public async Task Init_Organization_When_Organization_Is_Not_Pending(User user, Guid orgId, Guid orgUserId, string publicKey, + string privateKey, SutProvider sutProvider, Organization org, OrganizationUser orgUser) + + { + + + var token = CreateToken(orgUser, orgUserId, sutProvider); + + org.Status = Enums.OrganizationStatusType.Created; + + var organizationRepository = sutProvider.GetDependency(); + organizationRepository.GetByIdAsync(orgId).Returns(org); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token)); + + Assert.Equal("Organization is not on a Pending status.", exception.Message); + + } + + [Theory, BitAutoData] + public async Task Init_Organization_When_Organization_Has_Public_Key(User user, Guid orgId, Guid orgUserId, string publicKey, + string privateKey, SutProvider sutProvider, Organization org, OrganizationUser orgUser) + + { + var token = CreateToken(orgUser, orgUserId, sutProvider); + + org.PublicKey = publicKey; + + var organizationRepository = sutProvider.GetDependency(); + organizationRepository.GetByIdAsync(orgId).Returns(org); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token)); + + Assert.Equal("Organization already has a Public Key.", exception.Message); + + } + + [Theory, BitAutoData] + public async Task Init_Organization_When_Organization_Has_Private_Key(User user, Guid orgId, Guid orgUserId, string publicKey, + string privateKey, SutProvider sutProvider, Organization org, OrganizationUser orgUser) + + { + + var token = CreateToken(orgUser, orgUserId, sutProvider); + + org.PublicKey = null; + org.PrivateKey = privateKey; + org.Enabled = false; + + var organizationRepository = sutProvider.GetDependency(); + organizationRepository.GetByIdAsync(orgId).Returns(org); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token)); + + Assert.Equal("Organization already has a Private Key.", exception.Message); + + } + + public string CreateToken(OrganizationUser orgUser, Guid orgUserId, SutProvider sutProvider) + { + sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); + sutProvider.Create(); + + _orgUserInviteTokenableFactory.CreateToken(orgUser).Returns(new OrgUserInviteTokenable(orgUser) + { + ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) + }); + + var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser); + var protectedToken = _orgUserInviteTokenDataFactory.Protect(orgUserInviteTokenable); + sutProvider.GetDependency().GetByIdAsync(orgUserId).Returns(orgUser); + + return protectedToken; + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs index d2809102aa..e982a67e46 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.AdminConsole.Utilities.Commands; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; @@ -11,7 +12,6 @@ using Bit.Core.Auth.Repositories; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Models.Commands; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; @@ -122,9 +122,6 @@ public class SingleOrgPolicyValidatorTests sutProvider.GetDependency().UserId.Returns(savingUserId); sutProvider.GetDependency().GetByIdAsync(policyUpdate.OrganizationId).Returns(organization); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(true); - sutProvider.GetDependency() .RevokeNonCompliantOrganizationUsersAsync(Arg.Any()) .Returns(new CommandResult()); @@ -148,161 +145,4 @@ public class SingleOrgPolicyValidatorTests .Received(1) .SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(), nonCompliantUser.Email); } - - [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_RemovesNonCompliantUsers( - [PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg, false)] Policy policy, - Guid savingUserId, - Guid nonCompliantUserId, - Organization organization, SutProvider sutProvider) - { - policy.OrganizationId = organization.Id = policyUpdate.OrganizationId; - - var compliantUser1 = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - OrganizationId = organization.Id, - Type = OrganizationUserType.User, - Status = OrganizationUserStatusType.Confirmed, - UserId = new Guid(), - Email = "user1@example.com" - }; - - var compliantUser2 = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - OrganizationId = organization.Id, - Type = OrganizationUserType.User, - Status = OrganizationUserStatusType.Confirmed, - UserId = new Guid(), - Email = "user2@example.com" - }; - - var nonCompliantUser = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - OrganizationId = organization.Id, - Type = OrganizationUserType.User, - Status = OrganizationUserStatusType.Confirmed, - UserId = nonCompliantUserId, - Email = "user3@example.com" - }; - - sutProvider.GetDependency() - .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) - .Returns([compliantUser1, compliantUser2, nonCompliantUser]); - - var otherOrganizationUser = new OrganizationUser - { - Id = Guid.NewGuid(), - OrganizationId = new Guid(), - UserId = nonCompliantUserId, - Status = OrganizationUserStatusType.Confirmed - }; - - sutProvider.GetDependency() - .GetManyByManyUsersAsync(Arg.Is>(ids => ids.Contains(nonCompliantUserId))) - .Returns([otherOrganizationUser]); - - sutProvider.GetDependency().UserId.Returns(savingUserId); - sutProvider.GetDependency().GetByIdAsync(policyUpdate.OrganizationId).Returns(organization); - - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(false); - - sutProvider.GetDependency() - .RevokeNonCompliantOrganizationUsersAsync(Arg.Any()) - .Returns(new CommandResult()); - - await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy); - - await sutProvider.GetDependency() - .DidNotReceive() - .RemoveUserAsync(policyUpdate.OrganizationId, compliantUser1.Id, savingUserId); - await sutProvider.GetDependency() - .DidNotReceive() - .RemoveUserAsync(policyUpdate.OrganizationId, compliantUser2.Id, savingUserId); - await sutProvider.GetDependency() - .Received(1) - .RemoveUserAsync(policyUpdate.OrganizationId, nonCompliantUser.Id, savingUserId); - await sutProvider.GetDependency() - .DidNotReceive() - .SendOrganizationUserRemovedForPolicySingleOrgEmailAsync(organization.DisplayName(), compliantUser1.Email); - await sutProvider.GetDependency() - .DidNotReceive() - .SendOrganizationUserRemovedForPolicySingleOrgEmailAsync(organization.DisplayName(), compliantUser2.Email); - await sutProvider.GetDependency() - .Received(1) - .SendOrganizationUserRemovedForPolicySingleOrgEmailAsync(organization.DisplayName(), nonCompliantUser.Email); - } - - [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_WhenAccountDeprovisioningIsEnabled_ThenUsersAreRevoked( - [PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg, false)] Policy policy, - Guid savingUserId, - Guid nonCompliantUserId, - Organization organization, SutProvider sutProvider) - { - policy.OrganizationId = organization.Id = policyUpdate.OrganizationId; - - var compliantUser1 = new OrganizationUserUserDetails - { - OrganizationId = organization.Id, - Type = OrganizationUserType.User, - Status = OrganizationUserStatusType.Confirmed, - UserId = new Guid(), - Email = "user1@example.com" - }; - - var compliantUser2 = new OrganizationUserUserDetails - { - OrganizationId = organization.Id, - Type = OrganizationUserType.User, - Status = OrganizationUserStatusType.Confirmed, - UserId = new Guid(), - Email = "user2@example.com" - }; - - var nonCompliantUser = new OrganizationUserUserDetails - { - OrganizationId = organization.Id, - Type = OrganizationUserType.User, - Status = OrganizationUserStatusType.Confirmed, - UserId = nonCompliantUserId, - Email = "user3@example.com" - }; - - sutProvider.GetDependency() - .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) - .Returns([compliantUser1, compliantUser2, nonCompliantUser]); - - var otherOrganizationUser = new OrganizationUser - { - OrganizationId = new Guid(), - UserId = nonCompliantUserId, - Status = OrganizationUserStatusType.Confirmed - }; - - sutProvider.GetDependency() - .GetManyByManyUsersAsync(Arg.Is>(ids => ids.Contains(nonCompliantUserId))) - .Returns([otherOrganizationUser]); - - sutProvider.GetDependency().UserId.Returns(savingUserId); - sutProvider.GetDependency().GetByIdAsync(policyUpdate.OrganizationId) - .Returns(organization); - - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); - - sutProvider.GetDependency() - .RevokeNonCompliantOrganizationUsersAsync(Arg.Any()) - .Returns(new CommandResult()); - - await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy); - - await sutProvider.GetDependency() - .Received() - .RevokeNonCompliantOrganizationUsersAsync(Arg.Any()); - } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs index 0edc2b5973..6a97f6bc1e 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs @@ -4,11 +4,10 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.AdminConsole.Utilities.Commands; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; -using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Commands; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; @@ -24,7 +23,7 @@ namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidat public class TwoFactorAuthenticationPolicyValidatorTests { [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_RemovesNonCompliantUsers( + public async Task OnSaveSideEffectsAsync_GivenNonCompliantUsersWithoutMasterPassword_Throws( Organization organization, [PolicyUpdate(PolicyType.TwoFactorAuthentication)] PolicyUpdate policyUpdate, [Policy(PolicyType.TwoFactorAuthentication, false)] Policy policy, @@ -33,249 +32,6 @@ public class TwoFactorAuthenticationPolicyValidatorTests policy.OrganizationId = organization.Id = policyUpdate.OrganizationId; sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); - var orgUserDetailUserInvited = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - Status = OrganizationUserStatusType.Invited, - Type = OrganizationUserType.User, - // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync - Email = "user1@test.com", - Name = "TEST", - UserId = Guid.NewGuid(), - HasMasterPassword = false - }; - var orgUserDetailUserAcceptedWith2FA = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - Status = OrganizationUserStatusType.Accepted, - Type = OrganizationUserType.User, - // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync - Email = "user2@test.com", - Name = "TEST", - UserId = Guid.NewGuid(), - HasMasterPassword = true - }; - var orgUserDetailUserAcceptedWithout2FA = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - Status = OrganizationUserStatusType.Accepted, - Type = OrganizationUserType.User, - // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync - Email = "user3@test.com", - Name = "TEST", - UserId = Guid.NewGuid(), - HasMasterPassword = true - }; - var orgUserDetailAdmin = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - Status = OrganizationUserStatusType.Confirmed, - Type = OrganizationUserType.Admin, - // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync - Email = "admin@test.com", - Name = "ADMIN", - UserId = Guid.NewGuid(), - HasMasterPassword = false - }; - - sutProvider.GetDependency() - .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) - .Returns(new List - { - orgUserDetailUserInvited, - orgUserDetailUserAcceptedWith2FA, - orgUserDetailUserAcceptedWithout2FA, - orgUserDetailAdmin - }); - - sutProvider.GetDependency() - .TwoFactorIsEnabledAsync(Arg.Any>()) - .Returns(new List<(OrganizationUserUserDetails user, bool hasTwoFactor)>() - { - (orgUserDetailUserInvited, false), - (orgUserDetailUserAcceptedWith2FA, true), - (orgUserDetailUserAcceptedWithout2FA, false), - (orgUserDetailAdmin, false), - }); - - var savingUserId = Guid.NewGuid(); - sutProvider.GetDependency().UserId.Returns(savingUserId); - - await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy); - - var removeOrganizationUserCommand = sutProvider.GetDependency(); - - await removeOrganizationUserCommand.Received() - .RemoveUserAsync(policy.OrganizationId, orgUserDetailUserAcceptedWithout2FA.Id, savingUserId); - await sutProvider.GetDependency().Received() - .SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(organization.DisplayName(), orgUserDetailUserAcceptedWithout2FA.Email); - - await removeOrganizationUserCommand.DidNotReceive() - .RemoveUserAsync(policy.OrganizationId, orgUserDetailUserInvited.Id, savingUserId); - await sutProvider.GetDependency().DidNotReceive() - .SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(organization.DisplayName(), orgUserDetailUserInvited.Email); - await removeOrganizationUserCommand.DidNotReceive() - .RemoveUserAsync(policy.OrganizationId, orgUserDetailUserAcceptedWith2FA.Id, savingUserId); - await sutProvider.GetDependency().DidNotReceive() - .SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(organization.DisplayName(), orgUserDetailUserAcceptedWith2FA.Email); - await removeOrganizationUserCommand.DidNotReceive() - .RemoveUserAsync(policy.OrganizationId, orgUserDetailAdmin.Id, savingUserId); - await sutProvider.GetDependency().DidNotReceive() - .SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(organization.DisplayName(), orgUserDetailAdmin.Email); - } - - [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_UsersToBeRemovedDontHaveMasterPasswords_Throws( - Organization organization, - [PolicyUpdate(PolicyType.TwoFactorAuthentication)] PolicyUpdate policyUpdate, - [Policy(PolicyType.TwoFactorAuthentication, false)] Policy policy, - SutProvider sutProvider) - { - policy.OrganizationId = organization.Id = policyUpdate.OrganizationId; - - var orgUserDetailUserWith2FAAndMP = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - Status = OrganizationUserStatusType.Confirmed, - Type = OrganizationUserType.User, - // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync - Email = "user1@test.com", - Name = "TEST", - UserId = Guid.NewGuid(), - HasMasterPassword = true - }; - var orgUserDetailUserWith2FANoMP = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - Status = OrganizationUserStatusType.Confirmed, - Type = OrganizationUserType.User, - // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync - Email = "user2@test.com", - Name = "TEST", - UserId = Guid.NewGuid(), - HasMasterPassword = false - }; - var orgUserDetailUserWithout2FA = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - Status = OrganizationUserStatusType.Confirmed, - Type = OrganizationUserType.User, - // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync - Email = "user3@test.com", - Name = "TEST", - UserId = Guid.NewGuid(), - HasMasterPassword = false - }; - var orgUserDetailAdmin = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - Status = OrganizationUserStatusType.Confirmed, - Type = OrganizationUserType.Admin, - // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync - Email = "admin@test.com", - Name = "ADMIN", - UserId = Guid.NewGuid(), - HasMasterPassword = false - }; - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(false); - - sutProvider.GetDependency() - .GetManyDetailsByOrganizationAsync(policy.OrganizationId) - .Returns(new List - { - orgUserDetailUserWith2FAAndMP, - orgUserDetailUserWith2FANoMP, - orgUserDetailUserWithout2FA, - orgUserDetailAdmin - }); - - sutProvider.GetDependency() - .TwoFactorIsEnabledAsync(Arg.Is>(ids => - ids.Contains(orgUserDetailUserWith2FANoMP.UserId.Value) - && ids.Contains(orgUserDetailUserWithout2FA.UserId.Value) - && ids.Contains(orgUserDetailAdmin.UserId.Value))) - .Returns(new List<(Guid userId, bool hasTwoFactor)>() - { - (orgUserDetailUserWith2FANoMP.UserId.Value, true), - (orgUserDetailUserWithout2FA.UserId.Value, false), - (orgUserDetailAdmin.UserId.Value, false), - }); - - var badRequestException = await Assert.ThrowsAsync( - () => sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy)); - - Assert.Equal(TwoFactorAuthenticationPolicyValidator.NonCompliantMembersWillLoseAccessMessage, badRequestException.Message); - - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .RemoveUserAsync(organizationId: default, organizationUserId: default, deletingUserId: default); - } - - [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_GivenUpdateTo2faPolicy_WhenAccountProvisioningIsDisabled_ThenRevokeUserCommandShouldNotBeCalled( - Organization organization, - [PolicyUpdate(PolicyType.TwoFactorAuthentication)] - PolicyUpdate policyUpdate, - [Policy(PolicyType.TwoFactorAuthentication, false)] - Policy policy, - SutProvider sutProvider) - { - policy.OrganizationId = organization.Id = policyUpdate.OrganizationId; - sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(false); - - var orgUserDetailUserAcceptedWithout2Fa = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - Status = OrganizationUserStatusType.Accepted, - Type = OrganizationUserType.User, - Email = "user3@test.com", - Name = "TEST", - UserId = Guid.NewGuid(), - HasMasterPassword = true - }; - - sutProvider.GetDependency() - .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) - .Returns(new List - { - orgUserDetailUserAcceptedWithout2Fa - }); - - - sutProvider.GetDependency() - .TwoFactorIsEnabledAsync(Arg.Any>()) - .Returns(new List<(OrganizationUserUserDetails user, bool hasTwoFactor)>() - { - (orgUserDetailUserAcceptedWithout2Fa, false), - }); - - await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy); - - await sutProvider.GetDependency() - .DidNotReceive() - .RevokeNonCompliantOrganizationUsersAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_GivenUpdateTo2faPolicy_WhenAccountProvisioningIsEnabledAndUserDoesNotHaveMasterPassword_ThenNonCompliantMembersErrorMessageWillReturn( - Organization organization, - [PolicyUpdate(PolicyType.TwoFactorAuthentication)] PolicyUpdate policyUpdate, - [Policy(PolicyType.TwoFactorAuthentication, false)] Policy policy, - SutProvider sutProvider) - { - policy.OrganizationId = organization.Id = policyUpdate.OrganizationId; - sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(true); - var orgUserDetailUserWithout2Fa = new OrganizationUserUserDetails { Id = Guid.NewGuid(), @@ -304,7 +60,7 @@ public class TwoFactorAuthenticationPolicyValidatorTests } [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_WhenAccountProvisioningIsEnabledAndUserHasMasterPassword_ThenUserWillBeRevoked( + public async Task OnSaveSideEffectsAsync_RevokesNonCompliantUsers( Organization organization, [PolicyUpdate(PolicyType.TwoFactorAuthentication)] PolicyUpdate policyUpdate, [Policy(PolicyType.TwoFactorAuthentication, false)] Policy policy, @@ -313,10 +69,6 @@ public class TwoFactorAuthenticationPolicyValidatorTests policy.OrganizationId = organization.Id = policyUpdate.OrganizationId; sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(true); - var orgUserDetailUserWithout2Fa = new OrganizationUserUserDetails { Id = Guid.NewGuid(), diff --git a/test/Core.Test/AdminConsole/Services/EventRouteServiceTests.cs b/test/Core.Test/AdminConsole/Services/EventRouteServiceTests.cs new file mode 100644 index 0000000000..1a42d846f2 --- /dev/null +++ b/test/Core.Test/AdminConsole/Services/EventRouteServiceTests.cs @@ -0,0 +1,65 @@ +using Bit.Core.Models.Data; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class EventRouteServiceTests +{ + private readonly IEventWriteService _broadcastEventWriteService = Substitute.For(); + private readonly IEventWriteService _storageEventWriteService = Substitute.For(); + private readonly IFeatureService _featureService = Substitute.For(); + private readonly EventRouteService Subject; + + public EventRouteServiceTests() + { + Subject = new EventRouteService(_broadcastEventWriteService, _storageEventWriteService, _featureService); + } + + [Theory, BitAutoData] + public async Task CreateAsync_FlagDisabled_EventSentToStorageService(EventMessage eventMessage) + { + _featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(false); + + await Subject.CreateAsync(eventMessage); + + await _broadcastEventWriteService.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any()); + await _storageEventWriteService.Received(1).CreateAsync(eventMessage); + } + + [Theory, BitAutoData] + public async Task CreateAsync_FlagEnabled_EventSentToBroadcastService(EventMessage eventMessage) + { + _featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(true); + + await Subject.CreateAsync(eventMessage); + + await _broadcastEventWriteService.Received(1).CreateAsync(eventMessage); + await _storageEventWriteService.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task CreateManyAsync_FlagDisabled_EventsSentToStorageService(IEnumerable eventMessages) + { + _featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(false); + + await Subject.CreateManyAsync(eventMessages); + + await _broadcastEventWriteService.DidNotReceiveWithAnyArgs().CreateManyAsync(Arg.Any>()); + await _storageEventWriteService.Received(1).CreateManyAsync(eventMessages); + } + + [Theory, BitAutoData] + public async Task CreateManyAsync_FlagEnabled_EventsSentToBroadcastService(IEnumerable eventMessages) + { + _featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(true); + + await Subject.CreateManyAsync(eventMessages); + + await _broadcastEventWriteService.Received(1).CreateManyAsync(eventMessages); + await _storageEventWriteService.DidNotReceiveWithAnyArgs().CreateManyAsync(Arg.Any>()); + } +} diff --git a/test/Core.Test/AdminConsole/Services/IntegrationEventHandlerBaseTests.cs b/test/Core.Test/AdminConsole/Services/IntegrationEventHandlerBaseTests.cs new file mode 100644 index 0000000000..e1a2fbff68 --- /dev/null +++ b/test/Core.Test/AdminConsole/Services/IntegrationEventHandlerBaseTests.cs @@ -0,0 +1,219 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class IntegrationEventHandlerBaseEventHandlerTests +{ + private const string _templateBase = "Date: #Date#, Type: #Type#, UserId: #UserId#"; + private const string _templateWithOrganization = "Org: #OrganizationName#"; + private const string _templateWithUser = "#UserName#, #UserEmail#"; + private const string _templateWithActingUser = "#ActingUserName#, #ActingUserEmail#"; + private const string _url = "https://localhost"; + + private SutProvider GetSutProvider( + List configurations) + { + var configurationRepository = Substitute.For(); + configurationRepository.GetConfigurationDetailsAsync(Arg.Any(), + IntegrationType.Webhook, Arg.Any()).Returns(configurations); + + return new SutProvider() + .SetDependency(configurationRepository) + .Create(); + } + + private static List NoConfigurations() + { + return []; + } + + private static List OneConfiguration(string template) + { + var config = Substitute.For(); + config.Configuration = null; + config.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _url }); + config.Template = template; + + return [config]; + } + + private static List TwoConfigurations(string template) + { + var config = Substitute.For(); + config.Configuration = null; + config.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _url }); + config.Template = template; + var config2 = Substitute.For(); + config2.Configuration = null; + config2.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _url }); + config2.Template = template; + + return [config, config2]; + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_BaseTemplateNoConfigurations_DoesNothing(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(NoConfigurations()); + + await sutProvider.Sut.HandleEventAsync(eventMessage); + Assert.Empty(sutProvider.Sut.CapturedCalls); + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_BaseTemplateOneConfiguration_CallsProcessEventIntegrationAsync(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateBase)); + + await sutProvider.Sut.HandleEventAsync(eventMessage); + + Assert.Single(sutProvider.Sut.CapturedCalls); + + var expectedTemplate = $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}"; + + Assert.Equal(expectedTemplate, sutProvider.Sut.CapturedCalls.Single().RenderedTemplate); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_ActingUserTemplate_LoadsUserFromRepository(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithActingUser)); + var user = Substitute.For(); + user.Email = "test@example.com"; + user.Name = "Test"; + + sutProvider.GetDependency().GetByIdAsync(Arg.Any()).Returns(user); + await sutProvider.Sut.HandleEventAsync(eventMessage); + + Assert.Single(sutProvider.Sut.CapturedCalls); + + var expectedTemplate = $"{user.Name}, {user.Email}"; + Assert.Equal(expectedTemplate, sutProvider.Sut.CapturedCalls.Single().RenderedTemplate); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); + await sutProvider.GetDependency().Received(1).GetByIdAsync(eventMessage.ActingUserId ?? Guid.Empty); + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_OrganizationTemplate_LoadsOrganizationFromRepository(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithOrganization)); + var organization = Substitute.For(); + organization.Name = "Test"; + + sutProvider.GetDependency().GetByIdAsync(Arg.Any()).Returns(organization); + await sutProvider.Sut.HandleEventAsync(eventMessage); + + Assert.Single(sutProvider.Sut.CapturedCalls); + + var expectedTemplate = $"Org: {organization.Name}"; + Assert.Equal(expectedTemplate, sutProvider.Sut.CapturedCalls.Single().RenderedTemplate); + await sutProvider.GetDependency().Received(1).GetByIdAsync(eventMessage.OrganizationId ?? Guid.Empty); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_UserTemplate_LoadsUserFromRepository(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithUser)); + var user = Substitute.For(); + user.Email = "test@example.com"; + user.Name = "Test"; + + sutProvider.GetDependency().GetByIdAsync(Arg.Any()).Returns(user); + await sutProvider.Sut.HandleEventAsync(eventMessage); + + Assert.Single(sutProvider.Sut.CapturedCalls); + + var expectedTemplate = $"{user.Name}, {user.Email}"; + Assert.Equal(expectedTemplate, sutProvider.Sut.CapturedCalls.Single().RenderedTemplate); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); + await sutProvider.GetDependency().Received(1).GetByIdAsync(eventMessage.UserId ?? Guid.Empty); + } + + [Theory, BitAutoData] + public async Task HandleManyEventsAsync_BaseTemplateNoConfigurations_DoesNothing(List eventMessages) + { + var sutProvider = GetSutProvider(NoConfigurations()); + + await sutProvider.Sut.HandleManyEventsAsync(eventMessages); + Assert.Empty(sutProvider.Sut.CapturedCalls); + } + + [Theory, BitAutoData] + public async Task HandleManyEventsAsync_BaseTemplateOneConfiguration_CallsProcessEventIntegrationAsync(List eventMessages) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateBase)); + + await sutProvider.Sut.HandleManyEventsAsync(eventMessages); + + Assert.Equal(eventMessages.Count, sutProvider.Sut.CapturedCalls.Count); + var index = 0; + foreach (var call in sutProvider.Sut.CapturedCalls) + { + var expected = eventMessages[index]; + var expectedTemplate = $"Date: {expected.Date}, Type: {expected.Type}, UserId: {expected.UserId}"; + + Assert.Equal(expectedTemplate, call.RenderedTemplate); + index++; + } + } + + [Theory, BitAutoData] + public async Task HandleManyEventsAsync_BaseTemplateTwoConfigurations_CallsProcessEventIntegrationAsyncMultipleTimes( + List eventMessages) + { + var sutProvider = GetSutProvider(TwoConfigurations(_templateBase)); + + await sutProvider.Sut.HandleManyEventsAsync(eventMessages); + + Assert.Equal(eventMessages.Count * 2, sutProvider.Sut.CapturedCalls.Count); + + var capturedCalls = sutProvider.Sut.CapturedCalls.GetEnumerator(); + foreach (var eventMessage in eventMessages) + { + var expectedTemplate = $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}"; + + Assert.True(capturedCalls.MoveNext()); + var call = capturedCalls.Current; + Assert.Equal(expectedTemplate, call.RenderedTemplate); + + Assert.True(capturedCalls.MoveNext()); + call = capturedCalls.Current; + Assert.Equal(expectedTemplate, call.RenderedTemplate); + } + } + + private class TestIntegrationEventHandlerBase : IntegrationEventHandlerBase + { + public TestIntegrationEventHandlerBase(IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationIntegrationConfigurationRepository configurationRepository) + : base(userRepository, organizationRepository, configurationRepository) + { } + + public List<(JsonObject MergedConfiguration, string RenderedTemplate)> CapturedCalls { get; } = new(); + + protected override IntegrationType GetIntegrationType() => IntegrationType.Webhook; + + protected override Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, string renderedTemplate) + { + CapturedCalls.Add((mergedConfiguration, renderedTemplate)); + return Task.CompletedTask; + } + } +} diff --git a/test/Core.Test/AdminConsole/Services/SlackEventHandlerTests.cs b/test/Core.Test/AdminConsole/Services/SlackEventHandlerTests.cs index 798ba219eb..558bded8b3 100644 --- a/test/Core.Test/AdminConsole/Services/SlackEventHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/SlackEventHandlerTests.cs @@ -89,7 +89,7 @@ public class SlackEventHandlerTests var sutProvider = GetSutProvider(OneConfiguration()); await sutProvider.Sut.HandleEventAsync(eventMessage); - sutProvider.GetDependency().Received(1).SendSlackMessageByChannelIdAsync( + await sutProvider.GetDependency().Received(1).SendSlackMessageByChannelIdAsync( Arg.Is(AssertHelper.AssertPropertyEqual(_token)), Arg.Is(AssertHelper.AssertPropertyEqual( $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}")), @@ -103,13 +103,13 @@ public class SlackEventHandlerTests var sutProvider = GetSutProvider(TwoConfigurations()); await sutProvider.Sut.HandleEventAsync(eventMessage); - sutProvider.GetDependency().Received(1).SendSlackMessageByChannelIdAsync( + await sutProvider.GetDependency().Received(1).SendSlackMessageByChannelIdAsync( Arg.Is(AssertHelper.AssertPropertyEqual(_token)), Arg.Is(AssertHelper.AssertPropertyEqual( $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}")), Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)) ); - sutProvider.GetDependency().Received(1).SendSlackMessageByChannelIdAsync( + await sutProvider.GetDependency().Received(1).SendSlackMessageByChannelIdAsync( Arg.Is(AssertHelper.AssertPropertyEqual(_token2)), Arg.Is(AssertHelper.AssertPropertyEqual( $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}")), diff --git a/test/Core.Test/AdminConsole/Shared/IValidatorTests.cs b/test/Core.Test/AdminConsole/Shared/IValidatorTests.cs index abb49c25c6..1bc673426d 100644 --- a/test/Core.Test/AdminConsole/Shared/IValidatorTests.cs +++ b/test/Core.Test/AdminConsole/Shared/IValidatorTests.cs @@ -1,5 +1,5 @@ -using Bit.Core.AdminConsole.Errors; -using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.AdminConsole.Utilities.Errors; +using Bit.Core.AdminConsole.Utilities.Validation; using Xunit; namespace Bit.Core.Test.AdminConsole.Shared; @@ -22,13 +22,11 @@ public class IValidatorTests { if (string.IsNullOrWhiteSpace(value.Name)) { - return Task.FromResult>(new Invalid - { - Errors = [new InvalidRequestError(value)] - }); + return Task.FromResult>( + new Invalid(new InvalidRequestError(value))); } - return Task.FromResult>(new Valid { Value = value }); + return Task.FromResult>(new Valid(value)); } } @@ -41,7 +39,7 @@ public class IValidatorTests Assert.IsType>(result); var invalidResult = result as Invalid; - Assert.Equal(InvalidRequestError.Code, invalidResult.Errors.First().Message); + Assert.Equal(InvalidRequestError.Code, invalidResult!.Error.Message); } [Fact] diff --git a/test/Core.Test/Models/Commands/CommandResultTests.cs b/test/Core.Test/AdminConsole/Utilities/Commands/CommandResultTests.cs similarity index 92% rename from test/Core.Test/Models/Commands/CommandResultTests.cs rename to test/Core.Test/AdminConsole/Utilities/Commands/CommandResultTests.cs index c500fef4f5..67ff59c95b 100644 --- a/test/Core.Test/Models/Commands/CommandResultTests.cs +++ b/test/Core.Test/AdminConsole/Utilities/Commands/CommandResultTests.cs @@ -1,9 +1,9 @@ -using Bit.Core.AdminConsole.Errors; -using Bit.Core.Models.Commands; +using Bit.Core.AdminConsole.Utilities.Commands; +using Bit.Core.AdminConsole.Utilities.Errors; using Bit.Test.Common.AutoFixture.Attributes; using Xunit; -namespace Bit.Core.Test.Models.Commands; +namespace Bit.Core.Test.AdminConsole.Utilities.Commands; public class CommandResultTests { diff --git a/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs b/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs index 9ab3b592cb..d117b5e999 100644 --- a/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs +++ b/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs @@ -89,4 +89,61 @@ public class IntegrationTemplateProcessorTests Assert.Equal(expected, result); } + + [Theory] + [InlineData("User name is #UserName#")] + [InlineData("Email: #UserEmail#")] + public void TemplateRequiresUser_ContainingKeys_ReturnsTrue(string template) + { + var result = IntegrationTemplateProcessor.TemplateRequiresUser(template); + Assert.True(result); + } + + [Theory] + [InlineData("#UserId#")] // This is on the base class, not fetched, so should be false + [InlineData("No User Tokens")] + [InlineData("")] + public void TemplateRequiresUser_EmptyInputOrNoMatchingKeys_ReturnsFalse(string template) + { + var result = IntegrationTemplateProcessor.TemplateRequiresUser(template); + Assert.False(result); + } + + [Theory] + [InlineData("Acting user is #ActingUserName#")] + [InlineData("Acting user's email is #ActingUserEmail#")] + public void TemplateRequiresActingUser_ContainingKeys_ReturnsTrue(string template) + { + var result = IntegrationTemplateProcessor.TemplateRequiresActingUser(template); + Assert.True(result); + } + + [Theory] + [InlineData("No ActiveUser tokens")] + [InlineData("#ActiveUserId#")] // This is on the base class, not fetched, so should be false + [InlineData("")] + public void TemplateRequiresActingUser_EmptyInputOrNoMatchingKeys_ReturnsFalse(string template) + { + var result = IntegrationTemplateProcessor.TemplateRequiresActingUser(template); + Assert.False(result); + } + + [Theory] + [InlineData("Organization: #OrganizationName#")] + [InlineData("Welcome to #OrganizationName#")] + public void TemplateRequiresOrganization_ContainingKeys_ReturnsTrue(string template) + { + var result = IntegrationTemplateProcessor.TemplateRequiresOrganization(template); + Assert.True(result); + } + + [Theory] + [InlineData("No organization tokens")] + [InlineData("#OrganizationId#")] // This is on the base class, not fetched, so should be false + [InlineData("")] + public void TemplateRequiresOrganization_EmptyInputOrNoMatchingKeys_ReturnsFalse(string template) + { + var result = IntegrationTemplateProcessor.TemplateRequiresOrganization(template); + Assert.False(result); + } } diff --git a/test/Core.Test/Auth/Identity/BaseTokenProviderTests.cs b/test/Core.Test/Auth/Identity/BaseTokenProviderTests.cs index da2d4a282a..ff09e1f141 100644 --- a/test/Core.Test/Auth/Identity/BaseTokenProviderTests.cs +++ b/test/Core.Test/Auth/Identity/BaseTokenProviderTests.cs @@ -44,9 +44,6 @@ public abstract class BaseTokenProviderTests protected virtual void SetupUserService(IUserService userService, User user) { - userService - .TwoFactorProviderIsEnabledAsync(TwoFactorProviderType, user) - .Returns(true); userService .CanAccessPremium(user) .Returns(true); @@ -85,8 +82,6 @@ public abstract class BaseTokenProviderTests var userManager = SubstituteUserManager(); MockDatabase(user, metaData); - AdditionalSetup(sutProvider, user); - var response = await sutProvider.Sut.CanGenerateTwoFactorTokenAsync(userManager, user); Assert.Equal(expectedResponse, response); } diff --git a/test/Core.Test/Auth/Identity/DuoUniversalTwoFactorTokenProviderTests.cs b/test/Core.Test/Auth/Identity/DuoUniversalTwoFactorTokenProviderTests.cs index 85c687119b..5715403974 100644 --- a/test/Core.Test/Auth/Identity/DuoUniversalTwoFactorTokenProviderTests.cs +++ b/test/Core.Test/Auth/Identity/DuoUniversalTwoFactorTokenProviderTests.cs @@ -83,6 +83,7 @@ public class DuoUniversalTwoFactorTokenProviderTests : BaseTokenProviderTests sutProvider) { // Arrange + AdditionalSetup(sutProvider, user); user.Premium = true; user.PremiumExpirationDate = DateTime.UtcNow.AddDays(1); @@ -100,6 +101,8 @@ public class DuoUniversalTwoFactorTokenProviderTests : BaseTokenProviderTests sutProvider) { // Arrange + AdditionalSetup(sutProvider, user); + user.Premium = false; sutProvider.GetDependency() diff --git a/test/Core.Test/Auth/Models/Business/Tokenables/HCaptchaTokenableTests.cs b/test/Core.Test/Auth/Models/Business/Tokenables/HCaptchaTokenableTests.cs deleted file mode 100644 index 56533bab7a..0000000000 --- a/test/Core.Test/Auth/Models/Business/Tokenables/HCaptchaTokenableTests.cs +++ /dev/null @@ -1,87 +0,0 @@ -using AutoFixture.Xunit2; -using Bit.Core.Auth.Models.Business.Tokenables; -using Bit.Core.Entities; -using Bit.Core.Tokens; -using Bit.Test.Common.AutoFixture.Attributes; -using Xunit; - -namespace Bit.Core.Test.Auth.Models.Business.Tokenables; - -public class HCaptchaTokenableTests -{ - [Fact] - public void CanHandleNullUser() - { - var token = new HCaptchaTokenable(null); - - Assert.Equal(default, token.Id); - Assert.Equal(default, token.Email); - } - - [Fact] - public void TokenWithNullUserIsInvalid() - { - var token = new HCaptchaTokenable(null) - { - ExpirationDate = DateTime.UtcNow + TimeSpan.FromDays(1) - }; - - Assert.False(token.Valid); - } - - [Theory, BitAutoData] - public void TokenValidityCheckNullUserIdIsInvalid(User user) - { - var token = new HCaptchaTokenable(user) - { - ExpirationDate = DateTime.UtcNow + TimeSpan.FromDays(1) - }; - - Assert.False(token.TokenIsValid(null)); - } - - [Theory, AutoData] - public void CanUpdateExpirationToNonStandard(User user) - { - var token = new HCaptchaTokenable(user) - { - ExpirationDate = DateTime.MinValue - }; - - Assert.Equal(DateTime.MinValue, token.ExpirationDate, TimeSpan.FromMilliseconds(10)); - } - - [Theory, AutoData] - public void SetsDataFromUser(User user) - { - var token = new HCaptchaTokenable(user); - - Assert.Equal(user.Id, token.Id); - Assert.Equal(user.Email, token.Email); - } - - [Theory, AutoData] - public void SerializationSetsCorrectDateTime(User user) - { - var expectedDateTime = DateTime.UtcNow.AddHours(-5); - var token = new HCaptchaTokenable(user) - { - ExpirationDate = expectedDateTime - }; - - var result = Tokenable.FromToken(token.ToToken()); - - Assert.Equal(expectedDateTime, result.ExpirationDate, TimeSpan.FromMilliseconds(10)); - } - - [Theory, AutoData] - public void IsInvalidIfIdentifierIsWrong(User user) - { - var token = new HCaptchaTokenable(user) - { - Identifier = "not correct" - }; - - Assert.False(token.Valid); - } -} diff --git a/test/Core.Test/Auth/Models/Business/Tokenables/SsoTokenableTests.cs b/test/Core.Test/Auth/Models/Business/Tokenables/SsoTokenableTests.cs index 4d95a1c196..ab393203ab 100644 --- a/test/Core.Test/Auth/Models/Business/Tokenables/SsoTokenableTests.cs +++ b/test/Core.Test/Auth/Models/Business/Tokenables/SsoTokenableTests.cs @@ -67,7 +67,7 @@ public class SsoTokenableTests ExpirationDate = expectedDateTime }; - var result = Tokenable.FromToken(token.ToToken()); + var result = Tokenable.FromToken(token.ToToken()); Assert.Equal(expectedDateTime, result.ExpirationDate, TimeSpan.FromMilliseconds(10)); } diff --git a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs index 8011c52ead..adeac45d06 100644 --- a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs +++ b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs @@ -5,6 +5,7 @@ using Bit.Core.Entities; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; +using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -53,6 +54,39 @@ public class TwoFactorIsEnabledQueryTests } } + [Theory, BitAutoData] + public async Task TwoFactorIsEnabledQuery_DatabaseReturnsEmpty_ResultEmpty( + SutProvider sutProvider, + List usersWithCalculatedPremium) + { + // Arrange + var userIds = usersWithCalculatedPremium.Select(u => u.Id).ToList(); + + sutProvider.GetDependency() + .GetManyWithCalculatedPremiumAsync(Arg.Any>()) + .Returns([]); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(userIds); + + // Assert + Assert.Empty(result); + } + + [Theory] + [BitAutoData((IEnumerable)null)] + [BitAutoData([])] + public async Task TwoFactorIsEnabledQuery_UserIdsNullorEmpty_ResultEmpty( + IEnumerable userIds, + SutProvider sutProvider) + { + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(userIds); + + // Assert + Assert.Empty(result); + } + [Theory] [BitAutoData] public async Task TwoFactorIsEnabledQuery_WithNoTwoFactorEnabled_ReturnsAllTwoFactorDisabled( @@ -122,8 +156,11 @@ public class TwoFactorIsEnabledQueryTests } [Theory] - [BitAutoData] - public async Task TwoFactorIsEnabledQuery_WithNullTwoFactorProviders_ReturnsAllTwoFactorDisabled( + [BitAutoData("")] + [BitAutoData("{}")] + [BitAutoData((string)null)] + public async Task TwoFactorIsEnabledQuery_WithNullOrEmptyTwoFactorProviders_ReturnsAllTwoFactorDisabled( + string twoFactorProviders, SutProvider sutProvider, List usersWithCalculatedPremium) { @@ -132,7 +169,7 @@ public class TwoFactorIsEnabledQueryTests foreach (var user in usersWithCalculatedPremium) { - user.TwoFactorProviders = null; // No two-factor providers configured + user.TwoFactorProviders = twoFactorProviders; // No two-factor providers configured } sutProvider.GetDependency() @@ -176,6 +213,24 @@ public class TwoFactorIsEnabledQueryTests .GetManyWithCalculatedPremiumAsync(default); } + [Theory] + [BitAutoData] + public async Task TwoFactorIsEnabledQuery_UserIdNull_ReturnsFalse( + SutProvider sutProvider) + { + // Arrange + var user = new TestTwoFactorProviderUser + { + Id = null + }; + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(user); + + // Assert + Assert.False(result); + } + [Theory] [BitAutoData(TwoFactorProviderType.Authenticator)] [BitAutoData(TwoFactorProviderType.Email)] @@ -193,10 +248,8 @@ public class TwoFactorIsEnabledQueryTests { freeProviderType, new TwoFactorProvider { Enabled = true } } }; - user.Premium = false; user.SetTwoFactorProviders(twoFactorProviders); - // Act var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(user); @@ -205,7 +258,7 @@ public class TwoFactorIsEnabledQueryTests await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .GetManyWithCalculatedPremiumAsync(default); + .GetCalculatedPremiumAsync(default); } [Theory] @@ -230,7 +283,7 @@ public class TwoFactorIsEnabledQueryTests await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .GetManyWithCalculatedPremiumAsync(default); + .GetCalculatedPremiumAsync(default); } [Theory] @@ -252,14 +305,18 @@ public class TwoFactorIsEnabledQueryTests user.SetTwoFactorProviders(twoFactorProviders); sutProvider.GetDependency() - .GetManyWithCalculatedPremiumAsync(Arg.Is>(i => i.Contains(user.Id))) - .Returns(new List { user }); + .GetCalculatedPremiumAsync(user.Id) + .Returns(user); // Act var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(user); // Assert Assert.False(result); + + await sutProvider.GetDependency() + .ReceivedWithAnyArgs(1) + .GetCalculatedPremiumAsync(default); } [Theory] @@ -268,7 +325,7 @@ public class TwoFactorIsEnabledQueryTests public async Task TwoFactorIsEnabledQuery_WithProviderTypeRequiringPremium_WithUserPremium_ReturnsTrue( TwoFactorProviderType premiumProviderType, SutProvider sutProvider, - User user) + UserWithCalculatedPremium user) { // Arrange var twoFactorProviders = new Dictionary @@ -276,9 +333,14 @@ public class TwoFactorIsEnabledQueryTests { premiumProviderType, new TwoFactorProvider { Enabled = true } } }; - user.Premium = true; + user.Premium = false; + user.HasPremiumAccess = true; user.SetTwoFactorProviders(twoFactorProviders); + sutProvider.GetDependency() + .GetCalculatedPremiumAsync(user.Id) + .Returns(user); + // Act var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(user); @@ -286,8 +348,8 @@ public class TwoFactorIsEnabledQueryTests Assert.True(result); await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .GetManyWithCalculatedPremiumAsync(default); + .ReceivedWithAnyArgs(1) + .GetCalculatedPremiumAsync(default); } [Theory] @@ -309,14 +371,18 @@ public class TwoFactorIsEnabledQueryTests user.SetTwoFactorProviders(twoFactorProviders); sutProvider.GetDependency() - .GetManyWithCalculatedPremiumAsync(Arg.Is>(i => i.Contains(user.Id))) - .Returns(new List { user }); + .GetCalculatedPremiumAsync(user.Id) + .Returns(user); // Act var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(user); // Assert Assert.True(result); + + await sutProvider.GetDependency() + .ReceivedWithAnyArgs(1) + .GetCalculatedPremiumAsync(default); } [Theory] @@ -333,5 +399,29 @@ public class TwoFactorIsEnabledQueryTests // Assert Assert.False(result); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetCalculatedPremiumAsync(default); + } + + private class TestTwoFactorProviderUser : ITwoFactorProvidersUser + { + public Guid? Id { get; set; } + public string TwoFactorProviders { get; set; } + public bool Premium { get; set; } + public Dictionary GetTwoFactorProviders() + { + return JsonHelpers.LegacyDeserialize>(TwoFactorProviders); + } + + public Guid? GetUserId() + { + return Id; + } + + public bool GetPremium() + { + return Premium; + } } } diff --git a/test/Core.Test/Billing/Services/PaymentHistoryServiceTests.cs b/test/Core.Test/Billing/Services/PaymentHistoryServiceTests.cs index c9278e4488..06a408c5a8 100644 --- a/test/Core.Test/Billing/Services/PaymentHistoryServiceTests.cs +++ b/test/Core.Test/Billing/Services/PaymentHistoryServiceTests.cs @@ -4,7 +4,6 @@ using Bit.Core.Entities; using Bit.Core.Models.BitStripe; using Bit.Core.Repositories; using Bit.Core.Services; -using Microsoft.Extensions.Logging; using NSubstitute; using Stripe; using Xunit; @@ -22,8 +21,7 @@ public class PaymentHistoryServiceTests var stripeAdapter = Substitute.For(); stripeAdapter.InvoiceListAsync(Arg.Any()).Returns(invoices); var transactionRepository = Substitute.For(); - var logger = Substitute.For>(); - var paymentHistoryService = new PaymentHistoryService(stripeAdapter, transactionRepository, logger); + var paymentHistoryService = new PaymentHistoryService(stripeAdapter, transactionRepository); // Act var result = await paymentHistoryService.GetInvoiceHistoryAsync(subscriber); @@ -40,8 +38,7 @@ public class PaymentHistoryServiceTests // Arrange var paymentHistoryService = new PaymentHistoryService( Substitute.For(), - Substitute.For(), - Substitute.For>()); + Substitute.For()); // Act var result = await paymentHistoryService.GetInvoiceHistoryAsync(null); @@ -59,8 +56,7 @@ public class PaymentHistoryServiceTests var transactionRepository = Substitute.For(); transactionRepository.GetManyByOrganizationIdAsync(subscriber.Id, Arg.Any(), Arg.Any()).Returns(transactions); var stripeAdapter = Substitute.For(); - var logger = Substitute.For>(); - var paymentHistoryService = new PaymentHistoryService(stripeAdapter, transactionRepository, logger); + var paymentHistoryService = new PaymentHistoryService(stripeAdapter, transactionRepository); // Act var result = await paymentHistoryService.GetTransactionHistoryAsync(subscriber); @@ -77,8 +73,7 @@ public class PaymentHistoryServiceTests // Arrange var paymentHistoryService = new PaymentHistoryService( Substitute.For(), - Substitute.For(), - Substitute.For>()); + Substitute.For()); // Act var result = await paymentHistoryService.GetTransactionHistoryAsync(null); diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index 9e4be78787..b1f78ed987 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -3,13 +3,14 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; -using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Contracts; using Bit.Core.Billing.Services.Implementations; +using Bit.Core.Billing.Tax.Models; +using Bit.Core.Billing.Tax.Services; using Bit.Core.Enums; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Test.Billing.Stubs; +using Bit.Core.Test.Billing.Tax.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Braintree; diff --git a/test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs b/test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs new file mode 100644 index 0000000000..c35dc275e6 --- /dev/null +++ b/test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs @@ -0,0 +1,346 @@ +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Tax.Commands; +using Bit.Core.Billing.Tax.Services; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Stripe; +using Xunit; +using static Bit.Core.Billing.Tax.Commands.OrganizationTrialParameters; + +namespace Bit.Core.Test.Billing.Tax.Commands; + +public class PreviewTaxAmountCommandTests +{ + private readonly ILogger _logger = Substitute.For>(); + private readonly IPricingClient _pricingClient = Substitute.For(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly ITaxService _taxService = Substitute.For(); + + private readonly PreviewTaxAmountCommand _command; + + public PreviewTaxAmountCommandTests() + { + _command = new PreviewTaxAmountCommand(_logger, _pricingClient, _stripeAdapter, _taxService); + } + + [Fact] + public async Task Run_WithSeatBasedPasswordManagerPlan_GetsTaxAmount() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.EnterpriseAnnually, + ProductType = ProductType.PasswordManager, + TaxInformation = new TaxInformationDTO + { + Country = "US", + PostalCode = "12345" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is(options => + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId && + options.SubscriptionDetails.Items[0].Quantity == 1 && + options.AutomaticTax.Enabled == true + )) + .Returns(expectedInvoice); + + // Act + var result = await _command.Run(parameters); + + // Assert + Assert.True(result.IsT0); + var taxAmount = result.AsT0; + Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100); + } + + [Fact] + public async Task Run_WithNonSeatBasedPasswordManagerPlan_GetsTaxAmount() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.FamiliesAnnually, + ProductType = ProductType.PasswordManager, + TaxInformation = new TaxInformationDTO + { + Country = "US", + PostalCode = "12345" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is(options => + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripePlanId && + options.SubscriptionDetails.Items[0].Quantity == 1 && + options.AutomaticTax.Enabled == true + )) + .Returns(expectedInvoice); + + // Act + var result = await _command.Run(parameters); + + // Assert + Assert.True(result.IsT0); + var taxAmount = result.AsT0; + Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100); + } + + [Fact] + public async Task Run_WithSecretsManagerPlan_GetsTaxAmount() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.EnterpriseAnnually, + ProductType = ProductType.SecretsManager, + TaxInformation = new TaxInformationDTO + { + Country = "US", + PostalCode = "12345" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is(options => + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId && + options.SubscriptionDetails.Items[0].Quantity == 1 && + options.SubscriptionDetails.Items[1].Price == plan.SecretsManager.StripeSeatPlanId && + options.SubscriptionDetails.Items[1].Quantity == 1 && + options.Coupon == StripeConstants.CouponIDs.SecretsManagerStandalone && + options.AutomaticTax.Enabled == true + )) + .Returns(expectedInvoice); + + // Act + var result = await _command.Run(parameters); + + // Assert + Assert.True(result.IsT0); + var taxAmount = result.AsT0; + Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100); + } + + [Fact] + public async Task Run_NonUSWithoutTaxId_GetsTaxAmount() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.EnterpriseAnnually, + ProductType = ProductType.PasswordManager, + TaxInformation = new TaxInformationDTO + { + Country = "CA", + PostalCode = "12345" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is(options => + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "CA" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId && + options.SubscriptionDetails.Items[0].Quantity == 1 && + options.AutomaticTax.Enabled == false + )) + .Returns(expectedInvoice); + + // Act + var result = await _command.Run(parameters); + + // Assert + Assert.True(result.IsT0); + var taxAmount = result.AsT0; + Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100); + } + + [Fact] + public async Task Run_NonUSWithTaxId_GetsTaxAmount() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.EnterpriseAnnually, + ProductType = ProductType.PasswordManager, + TaxInformation = new TaxInformationDTO + { + Country = "CA", + PostalCode = "12345", + TaxId = "123456789" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + _taxService.GetStripeTaxCode(parameters.TaxInformation.Country, parameters.TaxInformation.TaxId) + .Returns("ca_st"); + + var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is(options => + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "CA" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxIds.Count == 1 && + options.CustomerDetails.TaxIds[0].Type == "ca_st" && + options.CustomerDetails.TaxIds[0].Value == "123456789" && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId && + options.SubscriptionDetails.Items[0].Quantity == 1 && + options.AutomaticTax.Enabled == true + )) + .Returns(expectedInvoice); + + // Act + var result = await _command.Run(parameters); + + // Assert + Assert.True(result.IsT0); + var taxAmount = result.AsT0; + Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100); + } + + [Fact] + public async Task Run_NonUSWithTaxId_UnknownTaxIdType_BadRequest() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.EnterpriseAnnually, + ProductType = ProductType.PasswordManager, + TaxInformation = new TaxInformationDTO + { + Country = "CA", + PostalCode = "12345", + TaxId = "123456789" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + _taxService.GetStripeTaxCode(parameters.TaxInformation.Country, parameters.TaxInformation.TaxId) + .Returns((string)null); + + // Act + var result = await _command.Run(parameters); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal(BillingErrorTranslationKeys.UnknownTaxIdType, badRequest.TranslationKey); + } + + [Fact] + public async Task Run_CustomerTaxLocationInvalid_BadRequest() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.EnterpriseAnnually, + ProductType = ProductType.PasswordManager, + TaxInformation = new TaxInformationDTO + { + Country = "US", + PostalCode = "12345" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) + .Throws(new StripeException + { + StripeError = new StripeError { Code = StripeConstants.ErrorCodes.CustomerTaxLocationInvalid } + }); + + // Act + var result = await _command.Run(parameters); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal(BillingErrorTranslationKeys.CustomerTaxLocationInvalid, badRequest.TranslationKey); + } + + [Fact] + public async Task Run_TaxIdInvalid_BadRequest() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.EnterpriseAnnually, + ProductType = ProductType.PasswordManager, + TaxInformation = new TaxInformationDTO + { + Country = "US", + PostalCode = "12345" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) + .Throws(new StripeException + { + StripeError = new StripeError { Code = StripeConstants.ErrorCodes.TaxIdInvalid } + }); + + // Act + var result = await _command.Run(parameters); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal(BillingErrorTranslationKeys.TaxIdInvalid, badRequest.TranslationKey); + } +} diff --git a/test/Core.Test/Billing/Services/Implementations/AutomaticTaxFactoryTests.cs b/test/Core.Test/Billing/Tax/Services/AutomaticTaxFactoryTests.cs similarity index 96% rename from test/Core.Test/Billing/Services/Implementations/AutomaticTaxFactoryTests.cs rename to test/Core.Test/Billing/Tax/Services/AutomaticTaxFactoryTests.cs index 7d5c9c3a26..8de51b1745 100644 --- a/test/Core.Test/Billing/Services/Implementations/AutomaticTaxFactoryTests.cs +++ b/test/Core.Test/Billing/Tax/Services/AutomaticTaxFactoryTests.cs @@ -3,14 +3,14 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.StaticStore.Plans; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services.Contracts; -using Bit.Core.Billing.Services.Implementations.AutomaticTax; +using Bit.Core.Billing.Tax.Services.Implementations; using Bit.Core.Entities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; -namespace Bit.Core.Test.Billing.Services.Implementations; +namespace Bit.Core.Test.Billing.Tax.Services; [SutProviderCustomize] public class AutomaticTaxFactoryTests diff --git a/test/Core.Test/Billing/Services/Implementations/AutomaticTax/BusinessUseAutomaticTaxStrategyTests.cs b/test/Core.Test/Billing/Tax/Services/BusinessUseAutomaticTaxStrategyTests.cs similarity index 99% rename from test/Core.Test/Billing/Services/Implementations/AutomaticTax/BusinessUseAutomaticTaxStrategyTests.cs rename to test/Core.Test/Billing/Tax/Services/BusinessUseAutomaticTaxStrategyTests.cs index dc40656275..dc10d222f1 100644 --- a/test/Core.Test/Billing/Services/Implementations/AutomaticTax/BusinessUseAutomaticTaxStrategyTests.cs +++ b/test/Core.Test/Billing/Tax/Services/BusinessUseAutomaticTaxStrategyTests.cs @@ -1,5 +1,5 @@ using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Services.Implementations.AutomaticTax; +using Bit.Core.Billing.Tax.Services.Implementations; using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -7,7 +7,7 @@ using NSubstitute; using Stripe; using Xunit; -namespace Bit.Core.Test.Billing.Services.Implementations.AutomaticTax; +namespace Bit.Core.Test.Billing.Tax.Services; [SutProviderCustomize] public class BusinessUseAutomaticTaxStrategyTests diff --git a/test/Core.Test/Billing/Stubs/FakeAutomaticTaxStrategy.cs b/test/Core.Test/Billing/Tax/Services/FakeAutomaticTaxStrategy.cs similarity index 92% rename from test/Core.Test/Billing/Stubs/FakeAutomaticTaxStrategy.cs rename to test/Core.Test/Billing/Tax/Services/FakeAutomaticTaxStrategy.cs index 253aead5c7..2f3cbc98ee 100644 --- a/test/Core.Test/Billing/Stubs/FakeAutomaticTaxStrategy.cs +++ b/test/Core.Test/Billing/Tax/Services/FakeAutomaticTaxStrategy.cs @@ -1,7 +1,7 @@ -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Tax.Services; using Stripe; -namespace Bit.Core.Test.Billing.Stubs; +namespace Bit.Core.Test.Billing.Tax.Services; /// /// Whether the subscription options will have automatic tax enabled or not. diff --git a/test/Core.Test/Billing/Services/Implementations/AutomaticTax/PersonalUseAutomaticTaxStrategyTests.cs b/test/Core.Test/Billing/Tax/Services/PersonalUseAutomaticTaxStrategyTests.cs similarity index 98% rename from test/Core.Test/Billing/Services/Implementations/AutomaticTax/PersonalUseAutomaticTaxStrategyTests.cs rename to test/Core.Test/Billing/Tax/Services/PersonalUseAutomaticTaxStrategyTests.cs index 2d50c9f75a..30614b94ba 100644 --- a/test/Core.Test/Billing/Services/Implementations/AutomaticTax/PersonalUseAutomaticTaxStrategyTests.cs +++ b/test/Core.Test/Billing/Tax/Services/PersonalUseAutomaticTaxStrategyTests.cs @@ -1,5 +1,5 @@ using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Services.Implementations.AutomaticTax; +using Bit.Core.Billing.Tax.Services.Implementations; using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -7,7 +7,7 @@ using NSubstitute; using Stripe; using Xunit; -namespace Bit.Core.Test.Billing.Services.Implementations.AutomaticTax; +namespace Bit.Core.Test.Billing.Tax.Services; [SutProviderCustomize] public class PersonalUseAutomaticTaxStrategyTests diff --git a/test/Core.Test/Core.Test.csproj b/test/Core.Test/Core.Test.csproj index cc19c50c35..c0f91a7bd3 100644 --- a/test/Core.Test/Core.Test.csproj +++ b/test/Core.Test/Core.Test.csproj @@ -2,8 +2,6 @@ false Bit.Core.Test - - $(WarningsNotAsErrors);CS4014 diff --git a/test/Core.Test/Tools/ReportFeatures/AddPasswordHealthReportApplicationCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/AddPasswordHealthReportApplicationCommandTests.cs similarity index 100% rename from test/Core.Test/Tools/ReportFeatures/AddPasswordHealthReportApplicationCommandTests.cs rename to test/Core.Test/Dirt/ReportFeatures/AddPasswordHealthReportApplicationCommandTests.cs diff --git a/test/Core.Test/Tools/ReportFeatures/DeletePasswordHealthReportApplicationCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/DeletePasswordHealthReportApplicationCommandTests.cs similarity index 100% rename from test/Core.Test/Tools/ReportFeatures/DeletePasswordHealthReportApplicationCommandTests.cs rename to test/Core.Test/Dirt/ReportFeatures/DeletePasswordHealthReportApplicationCommandTests.cs diff --git a/test/Core.Test/Tools/ReportFeatures/GetPasswordHealthReportApplicationQueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetPasswordHealthReportApplicationQueryTests.cs similarity index 100% rename from test/Core.Test/Tools/ReportFeatures/GetPasswordHealthReportApplicationQueryTests.cs rename to test/Core.Test/Dirt/ReportFeatures/GetPasswordHealthReportApplicationQueryTests.cs diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs index f6b6721bd2..7dc6b7360d 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs @@ -168,6 +168,11 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase }); sutProvider.GetDependency().UserId.Returns(sponsoringOrgUser.UserId.Value); + // Setup for checking available seats + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id) + .Returns(0); + await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, false, null); @@ -293,6 +298,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase { sponsoringOrg.PlanType = PlanType.EnterpriseAnnually; sponsoringOrg.UseAdminSponsoredFamilies = true; + sponsoringOrg.Seats = 10; sponsoringOrgUser.Status = OrganizationUserStatusType.Confirmed; sutProvider.GetDependency().GetUserByIdAsync(sponsoringOrgUser.UserId!.Value).Returns(user); @@ -311,6 +317,11 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase } ]); + // Setup for checking available seats - organization has plenty of seats + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id) + .Returns(5); + var actual = await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes); @@ -331,5 +342,121 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase await sutProvider.GetDependency().Received(1) .CreateAsync(Arg.Is(s => SponsorshipValidator(s, expectedSponsorship))); + + // Verify we didn't need to add seats + await sutProvider.GetDependency().DidNotReceive() + .AutoAddSeatsAsync(Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Owner)] + public async Task CreateSponsorship_CreatesAdminInitiatedSponsorship_AutoscalesWhenNeeded( + OrganizationUserType organizationUserType, + Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, User user, string sponsoredEmail, + string friendlyName, Guid sponsorshipId, Guid currentUserId, string notes, SutProvider sutProvider) + { + sponsoringOrg.PlanType = PlanType.EnterpriseAnnually; + sponsoringOrg.UseAdminSponsoredFamilies = true; + sponsoringOrg.Seats = 10; + sponsoringOrgUser.Status = OrganizationUserStatusType.Confirmed; + + sutProvider.GetDependency().GetUserByIdAsync(sponsoringOrgUser.UserId!.Value).Returns(user); + sutProvider.GetDependency().WhenForAnyArgs(x => x.UpsertAsync(null!)).Do(callInfo => + { + var sponsorship = callInfo.Arg(); + sponsorship.Id = sponsorshipId; + }); + sutProvider.GetDependency().UserId.Returns(currentUserId); + sutProvider.GetDependency().Organizations.Returns([ + new() + { + Id = sponsoringOrg.Id, + Permissions = new Permissions { ManageUsers = true }, + Type = organizationUserType + } + ]); + + // Setup for checking available seats - organization has no available seats + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id) + .Returns(10); + + // Setup for checking if can scale + sutProvider.GetDependency() + .CanScaleAsync(sponsoringOrg, 1) + .Returns((true, "")); + + var actual = await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, + PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes); + + + var expectedSponsorship = new OrganizationSponsorship + { + Id = sponsorshipId, + SponsoringOrganizationId = sponsoringOrg.Id, + SponsoringOrganizationUserId = sponsoringOrgUser.Id, + FriendlyName = friendlyName, + OfferedToEmail = sponsoredEmail, + PlanSponsorshipType = PlanSponsorshipType.FamiliesForEnterprise, + IsAdminInitiated = true, + Notes = notes + }; + + Assert.True(SponsorshipValidator(expectedSponsorship, actual)); + + await sutProvider.GetDependency().Received(1) + .CreateAsync(Arg.Is(s => SponsorshipValidator(s, expectedSponsorship))); + + // Verify we needed to add seats + await sutProvider.GetDependency().Received(1) + .AutoAddSeatsAsync(sponsoringOrg, 1); + } + + [Theory] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Owner)] + public async Task CreateSponsorship_CreatesAdminInitiatedSponsorship_ThrowsWhenCannotAutoscale( + OrganizationUserType organizationUserType, + Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, User user, string sponsoredEmail, + string friendlyName, Guid sponsorshipId, Guid currentUserId, string notes, SutProvider sutProvider) + { + sponsoringOrg.PlanType = PlanType.EnterpriseAnnually; + sponsoringOrg.UseAdminSponsoredFamilies = true; + sponsoringOrg.Seats = 10; + sponsoringOrgUser.Status = OrganizationUserStatusType.Confirmed; + + sutProvider.GetDependency().GetUserByIdAsync(sponsoringOrgUser.UserId!.Value).Returns(user); + sutProvider.GetDependency().WhenForAnyArgs(x => x.UpsertAsync(null!)).Do(callInfo => + { + var sponsorship = callInfo.Arg(); + sponsorship.Id = sponsorshipId; + }); + sutProvider.GetDependency().UserId.Returns(currentUserId); + sutProvider.GetDependency().Organizations.Returns([ + new() + { + Id = sponsoringOrg.Id, + Permissions = new Permissions { ManageUsers = true }, + Type = organizationUserType + } + ]); + + // Setup for checking available seats - organization has no available seats + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id) + .Returns(10); + + // Setup for checking if can scale - cannot scale + var failureReason = "Seat limit has been reached."; + sutProvider.GetDependency() + .CanScaleAsync(sponsoringOrg, 1) + .Returns((false, failureReason)); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, + PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes)); + + Assert.Equal(failureReason, exception.Message); } } diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs index 835f69b214..fa1dd60617 100644 --- a/test/Core.Test/Services/StripePaymentServiceTests.cs +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -1,13 +1,12 @@ using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models.Api.Requests; -using Bit.Core.Billing.Models.Api.Requests.Organizations; using Bit.Core.Billing.Models.StaticStore.Plans; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Tax.Requests; +using Bit.Core.Billing.Tax.Services; using Bit.Core.Enums; using Bit.Core.Services; -using Bit.Core.Test.Billing.Stubs; +using Bit.Core.Test.Billing.Tax.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index 02ff24d9bf..0458c7cdd9 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -9,6 +9,7 @@ using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Entities; @@ -324,6 +325,7 @@ public class UserServiceTests sutProvider.GetDependency(), sutProvider.GetDependency(), sutProvider.GetDependency(), + sutProvider.GetDependency(), sutProvider.GetDependency() ); @@ -341,28 +343,12 @@ public class UserServiceTests } [Theory, BitAutoData] - public async Task IsClaimedByAnyOrganizationAsync_WithAccountDeprovisioningDisabled_ReturnsFalse( - SutProvider sutProvider, Guid userId) - { - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(false); - - var result = await sutProvider.Sut.IsClaimedByAnyOrganizationAsync(userId); - Assert.False(result); - } - - [Theory, BitAutoData] - public async Task IsClaimedByAnyOrganizationAsync_WithAccountDeprovisioningEnabled_WithManagingEnabledOrganization_ReturnsTrue( + public async Task IsClaimedByAnyOrganizationAsync_WithManagingEnabledOrganization_ReturnsTrue( SutProvider sutProvider, Guid userId, Organization organization) { organization.Enabled = true; organization.UseSso = true; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(true); - sutProvider.GetDependency() .GetByVerifiedUserEmailDomainAsync(userId) .Returns(new[] { organization }); @@ -372,16 +358,12 @@ public class UserServiceTests } [Theory, BitAutoData] - public async Task IsClaimedByAnyOrganizationAsync_WithAccountDeprovisioningEnabled_WithManagingDisabledOrganization_ReturnsFalse( + public async Task IsClaimedByAnyOrganizationAsync_WithManagingDisabledOrganization_ReturnsFalse( SutProvider sutProvider, Guid userId, Organization organization) { organization.Enabled = false; organization.UseSso = true; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(true); - sutProvider.GetDependency() .GetByVerifiedUserEmailDomainAsync(userId) .Returns(new[] { organization }); @@ -391,16 +373,12 @@ public class UserServiceTests } [Theory, BitAutoData] - public async Task IsClaimedByAnyOrganizationAsync_WithAccountDeprovisioningEnabled_WithOrganizationUseSsoFalse_ReturnsFalse( + public async Task IsClaimedByAnyOrganizationAsync_WithOrganizationUseSsoFalse_ReturnsFalse( SutProvider sutProvider, Guid userId, Organization organization) { organization.Enabled = true; organization.UseSso = false; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(true); - sutProvider.GetDependency() .GetByVerifiedUserEmailDomainAsync(userId) .Returns(new[] { organization }); @@ -410,97 +388,7 @@ public class UserServiceTests } [Theory, BitAutoData] - public async Task DisableTwoFactorProviderAsync_WhenOrganizationHas2FAPolicyEnabled_DisablingAllProviders_RemovesUserFromOrganizationAndSendsEmail( - SutProvider sutProvider, User user, Organization organization) - { - // Arrange - user.SetTwoFactorProviders(new Dictionary - { - [TwoFactorProviderType.Email] = new() { Enabled = true } - }); - sutProvider.GetDependency() - .GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication) - .Returns( - [ - new OrganizationUserPolicyDetails - { - OrganizationId = organization.Id, - PolicyType = PolicyType.TwoFactorAuthentication, - PolicyEnabled = true - } - ]); - sutProvider.GetDependency() - .GetByIdAsync(organization.Id) - .Returns(organization); - var expectedSavedProviders = JsonHelpers.LegacySerialize(new Dictionary(), JsonHelpers.LegacyEnumKeyResolver); - - // Act - await sutProvider.Sut.DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Email); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .ReplaceAsync(Arg.Is(u => u.Id == user.Id && u.TwoFactorProviders == expectedSavedProviders)); - await sutProvider.GetDependency() - .Received(1) - .LogUserEventAsync(user.Id, EventType.User_Disabled2fa); - await sutProvider.GetDependency() - .Received(1) - .RemoveUserAsync(organization.Id, user.Id); - await sutProvider.GetDependency() - .Received(1) - .SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(organization.DisplayName(), user.Email); - } - - [Theory, BitAutoData] - public async Task DisableTwoFactorProviderAsync_WhenOrganizationHas2FAPolicyEnabled_UserHasOneProviderEnabled_DoesNotRemoveUserFromOrganization( - SutProvider sutProvider, User user, Organization organization) - { - // Arrange - user.SetTwoFactorProviders(new Dictionary - { - [TwoFactorProviderType.Email] = new() { Enabled = true }, - [TwoFactorProviderType.Remember] = new() { Enabled = true } - }); - sutProvider.GetDependency() - .GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication) - .Returns( - [ - new OrganizationUserPolicyDetails - { - OrganizationId = organization.Id, - PolicyType = PolicyType.TwoFactorAuthentication, - PolicyEnabled = true - } - ]); - sutProvider.GetDependency() - .GetByIdAsync(organization.Id) - .Returns(organization); - var expectedSavedProviders = JsonHelpers.LegacySerialize(new Dictionary - { - [TwoFactorProviderType.Remember] = new() { Enabled = true } - }, JsonHelpers.LegacyEnumKeyResolver); - - // Act - await sutProvider.Sut.DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Email); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .ReplaceAsync(Arg.Is(u => u.Id == user.Id && u.TwoFactorProviders == expectedSavedProviders)); - await sutProvider.GetDependency() - .Received(1) - .LogUserEventAsync(user.Id, EventType.User_Disabled2fa); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .RemoveUserAsync(default, default); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(default, default); - } - - [Theory, BitAutoData] - public async Task DisableTwoFactorProviderAsync_WithAccountDeprovisioningEnabled_WhenOrganizationHas2FAPolicyEnabled_DisablingAllProviders_RevokesUserAndSendsEmail( + public async Task DisableTwoFactorProviderAsync_WhenOrganizationHas2FAPolicyEnabled_DisablingAllProviders_RevokesUserAndSendsEmail( SutProvider sutProvider, User user, Organization organization1, Guid organizationUserId1, Organization organization2, Guid organizationUserId2) @@ -513,9 +401,6 @@ public class UserServiceTests organization1.Enabled = organization2.Enabled = true; organization1.UseSso = organization2.UseSso = true; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(true); sutProvider.GetDependency() .GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication) .Returns( @@ -578,7 +463,7 @@ public class UserServiceTests } [Theory, BitAutoData] - public async Task DisableTwoFactorProviderAsync_WithAccountDeprovisioningEnabled_UserHasOneProviderEnabled_DoesNotRemoveUserFromOrganization( + public async Task DisableTwoFactorProviderAsync_UserHasOneProviderEnabled_DoesNotRevokeUserFromOrganization( SutProvider sutProvider, User user, Organization organization) { // Arrange @@ -601,6 +486,9 @@ public class UserServiceTests sutProvider.GetDependency() .GetByIdAsync(organization.Id) .Returns(organization); + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(user) + .Returns(true); var expectedSavedProviders = JsonHelpers.LegacySerialize(new Dictionary { [TwoFactorProviderType.Remember] = new() { Enabled = true } @@ -911,6 +799,7 @@ public class UserServiceTests sutProvider.GetDependency(), sutProvider.GetDependency(), sutProvider.GetDependency(), + sutProvider.GetDependency(), sutProvider.GetDependency() ); } diff --git a/test/Core.Test/Vault/Queries/GetTasksForOrganizationQueryTests.cs b/test/Core.Test/Vault/Queries/GetTasksForOrganizationQueryTests.cs index 59ec7350da..f72a1f5f82 100644 --- a/test/Core.Test/Vault/Queries/GetTasksForOrganizationQueryTests.cs +++ b/test/Core.Test/Vault/Queries/GetTasksForOrganizationQueryTests.cs @@ -40,12 +40,12 @@ public class GetTasksForOrganizationQueryTests var result = await sutProvider.Sut.GetTasksAsync(org.Id, status); Assert.Equal(2, result.Count); - sutProvider.GetDependency().Received(1).AuthorizeAsync( + await sutProvider.GetDependency().Received(1).AuthorizeAsync( Arg.Any(), org, Arg.Is>( e => e.Contains(SecurityTaskOperations.ListAllForOrganization) ) ); - sutProvider.GetDependency().Received(1).GetManyByOrganizationIdStatusAsync(org.Id, SecurityTaskStatus.Pending); + await sutProvider.GetDependency().Received(1).GetManyByOrganizationIdStatusAsync(org.Id, SecurityTaskStatus.Pending); } [Theory, BitAutoData] @@ -82,11 +82,11 @@ public class GetTasksForOrganizationQueryTests await Assert.ThrowsAsync(() => sutProvider.Sut.GetTasksAsync(org.Id)); - sutProvider.GetDependency().Received(1).AuthorizeAsync( + await sutProvider.GetDependency().Received(1).AuthorizeAsync( Arg.Any(), org, Arg.Is>( e => e.Contains(SecurityTaskOperations.ListAllForOrganization) ) ); - sutProvider.GetDependency().Received(0).GetManyByOrganizationIdStatusAsync(org.Id, SecurityTaskStatus.Pending); + await sutProvider.GetDependency().Received(0).GetManyByOrganizationIdStatusAsync(org.Id, SecurityTaskStatus.Pending); } } diff --git a/test/Identity.Test/Controllers/AccountsControllerTests.cs b/test/Identity.Test/Controllers/AccountsControllerTests.cs index e36f7f37b6..a045490862 100644 --- a/test/Identity.Test/Controllers/AccountsControllerTests.cs +++ b/test/Identity.Test/Controllers/AccountsControllerTests.cs @@ -3,7 +3,6 @@ using System.Text; using Bit.Core; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Business.Tokenables; -using Bit.Core.Auth.Services; using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.WebAuthnLogin; using Bit.Core.Context; @@ -38,7 +37,6 @@ public class AccountsControllerTests : IDisposable private readonly ILogger _logger; private readonly IUserRepository _userRepository; private readonly IRegisterUserCommand _registerUserCommand; - private readonly ICaptchaValidationService _captchaValidationService; private readonly IDataProtectorTokenFactory _assertionOptionsDataProtector; private readonly IGetWebAuthnLoginCredentialAssertionOptionsCommand _getWebAuthnLoginCredentialAssertionOptionsCommand; private readonly ISendVerificationEmailForRegistrationCommand _sendVerificationEmailForRegistrationCommand; @@ -54,7 +52,6 @@ public class AccountsControllerTests : IDisposable _logger = Substitute.For>(); _userRepository = Substitute.For(); _registerUserCommand = Substitute.For(); - _captchaValidationService = Substitute.For(); _assertionOptionsDataProtector = Substitute.For>(); _getWebAuthnLoginCredentialAssertionOptionsCommand = Substitute.For(); _sendVerificationEmailForRegistrationCommand = Substitute.For(); @@ -68,7 +65,6 @@ public class AccountsControllerTests : IDisposable _logger, _userRepository, _registerUserCommand, - _captchaValidationService, _assertionOptionsDataProtector, _getWebAuthnLoginCredentialAssertionOptionsCommand, _sendVerificationEmailForRegistrationCommand, diff --git a/test/Identity.Test/Identity.Test.csproj b/test/Identity.Test/Identity.Test.csproj index 34010d811b..fc0cf07b63 100644 --- a/test/Identity.Test/Identity.Test.csproj +++ b/test/Identity.Test/Identity.Test.csproj @@ -2,8 +2,6 @@ false - - $(WarningsNotAsErrors);CS0672;CS1998 diff --git a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs index 1d58b62b02..9eb17da88a 100644 --- a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs @@ -33,7 +33,6 @@ public class BaseRequestValidatorTests private readonly IDeviceValidator _deviceValidator; private readonly ITwoFactorAuthenticationValidator _twoFactorAuthenticationValidator; private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IMailService _mailService; private readonly ILogger _logger; private readonly ICurrentContext _currentContext; private readonly GlobalSettings _globalSettings; @@ -54,7 +53,6 @@ public class BaseRequestValidatorTests _deviceValidator = Substitute.For(); _twoFactorAuthenticationValidator = Substitute.For(); _organizationUserRepository = Substitute.For(); - _mailService = Substitute.For(); _logger = Substitute.For>(); _currentContext = Substitute.For(); _globalSettings = Substitute.For(); @@ -72,7 +70,6 @@ public class BaseRequestValidatorTests _deviceValidator, _twoFactorAuthenticationValidator, _organizationUserRepository, - _mailService, _logger, _currentContext, _globalSettings, @@ -84,36 +81,6 @@ public class BaseRequestValidatorTests _policyRequirementQuery); } - /* Logic path - * ValidateAsync -> _Logger.LogInformation - * |-> BuildErrorResultAsync -> _eventService.LogUserEventAsync - * |-> SetErrorResult - */ - [Theory, BitAutoData] - public async Task ValidateAsync_IsBot_UserNotNull_ShouldBuildErrorResult_ShouldLogFailedLoginEvent( - [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - CustomValidatorRequestContext requestContext, - GrantValidationResult grantResult) - { - // Arrange - var context = CreateContext(tokenRequest, requestContext, grantResult); - - context.CustomValidatorRequestContext.CaptchaResponse.IsBot = true; - _sut.isValid = true; - - // Act - await _sut.ValidateAsync(context); - - var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"]; - - // Assert - await _eventService.Received(1) - .LogUserEventAsync(context.CustomValidatorRequestContext.User.Id, - EventType.User_FailedLogIn); - Assert.True(context.GrantResult.IsError); - Assert.Equal("Username or password is incorrect. Try again.", errorResponse.Message); - } - /* Logic path * ValidateAsync -> UpdateFailedAuthDetailsAsync -> _mailService.SendFailedLoginAttemptsEmailAsync * |-> BuildErrorResultAsync -> _eventService.LogUserEventAsync @@ -128,8 +95,6 @@ public class BaseRequestValidatorTests { // Arrange var context = CreateContext(tokenRequest, requestContext, grantResult); - context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false; - _globalSettings.Captcha.Returns(new GlobalSettings.CaptchaSettings()); _globalSettings.SelfHosted = true; _sut.isValid = false; @@ -142,44 +107,6 @@ public class BaseRequestValidatorTests Assert.Equal("Username or password is incorrect. Try again.", errorResponse.Message); } - /* Logic path - * ValidateAsync -> UpdateFailedAuthDetailsAsync -> _mailService.SendFailedLoginAttemptsEmailAsync - * |-> BuildErrorResultAsync -> _eventService.LogUserEventAsync - * |-> SetErrorResult - */ - [Theory, BitAutoData] - public async Task ValidateAsync_ContextNotValid_MaxAttemptLogin_ShouldSendEmail( - [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - CustomValidatorRequestContext requestContext, - GrantValidationResult grantResult) - { - // Arrange - var context = CreateContext(tokenRequest, requestContext, grantResult); - - context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false; - // This needs to be n-1 of the max failed login attempts - context.CustomValidatorRequestContext.User.FailedLoginCount = 2; - context.CustomValidatorRequestContext.KnownDevice = false; - - _globalSettings.Captcha.Returns( - new GlobalSettings.CaptchaSettings - { - MaximumFailedLoginAttempts = 3 - }); - _sut.isValid = false; - - // Act - await _sut.ValidateAsync(context); - - // Assert - await _mailService.Received(1) - .SendFailedLoginAttemptsEmailAsync( - Arg.Any(), Arg.Any(), Arg.Any()); - Assert.True(context.GrantResult.IsError); - var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"]; - Assert.Equal("Username or password is incorrect. Try again.", errorResponse.Message); - } - [Theory, BitAutoData] public async Task ValidateAsync_DeviceNotValidated_ShouldLogError( [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, @@ -189,7 +116,6 @@ public class BaseRequestValidatorTests // Arrange var context = CreateContext(tokenRequest, requestContext, grantResult); // 1 -> to pass - context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false; _sut.isValid = true; // 2 -> will result to false with no extra configuration @@ -226,7 +152,6 @@ public class BaseRequestValidatorTests // Arrange var context = CreateContext(tokenRequest, requestContext, grantResult); // 1 -> to pass - context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false; _sut.isValid = true; // 2 -> will result to false with no extra configuration @@ -263,7 +188,6 @@ public class BaseRequestValidatorTests { // Arrange var context = CreateContext(tokenRequest, requestContext, grantResult); - context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false; _sut.isValid = true; context.ValidatedTokenRequest.GrantType = grantType; @@ -294,7 +218,6 @@ public class BaseRequestValidatorTests // Arrange _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); var context = CreateContext(tokenRequest, requestContext, grantResult); - context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false; _sut.isValid = true; context.ValidatedTokenRequest.GrantType = grantType; @@ -326,7 +249,6 @@ public class BaseRequestValidatorTests // Arrange _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); var context = CreateContext(tokenRequest, requestContext, grantResult); - context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false; _sut.isValid = true; context.ValidatedTokenRequest.GrantType = grantType; @@ -363,7 +285,6 @@ public class BaseRequestValidatorTests { // Arrange var context = CreateContext(tokenRequest, requestContext, grantResult); - context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false; _sut.isValid = true; context.ValidatedTokenRequest.GrantType = grantType; @@ -401,7 +322,6 @@ public class BaseRequestValidatorTests { // Arrange var context = CreateContext(tokenRequest, requestContext, grantResult); - context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false; _sut.isValid = true; context.ValidatedTokenRequest.GrantType = grantType; @@ -439,7 +359,6 @@ public class BaseRequestValidatorTests var user = context.CustomValidatorRequestContext.User; user.Key = null; - context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false; context.ValidatedTokenRequest.ClientId = "Not Web"; _sut.isValid = true; _twoFactorAuthenticationValidator diff --git a/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs b/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs index b71dd6c230..9e20e630cd 100644 --- a/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs @@ -1,5 +1,4 @@ -using Bit.Core; -using Bit.Core.Context; +using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Api; @@ -28,7 +27,7 @@ public class DeviceValidatorTests private readonly IUserService _userService; private readonly IDistributedCache _distributedCache; private readonly Logger _logger; - private readonly IFeatureService _featureService; + private readonly DeviceValidator _sut; public DeviceValidatorTests() @@ -41,7 +40,6 @@ public class DeviceValidatorTests _userService = Substitute.For(); _distributedCache = Substitute.For(); _logger = new Logger(Substitute.For()); - _featureService = Substitute.For(); _sut = new DeviceValidator( _deviceService, _deviceRepository, @@ -50,8 +48,7 @@ public class DeviceValidatorTests _currentContext, _userService, _distributedCache, - _logger, - _featureService); + _logger); } [Theory, BitAutoData] @@ -312,8 +309,6 @@ public class DeviceValidatorTests AddValidDeviceToRequest(request); _deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id) .Returns(null as Device); - _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification) - .Returns(true); request.GrantType = grantType; @@ -336,8 +331,6 @@ public class DeviceValidatorTests AddValidDeviceToRequest(request); _deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id) .Returns(null as Device); - _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification) - .Returns(true); request.Raw.Add("AuthRequest", "authRequest"); @@ -360,8 +353,6 @@ public class DeviceValidatorTests AddValidDeviceToRequest(request); _deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id) .Returns(null as Device); - _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification) - .Returns(true); context.TwoFactorRequired = true; @@ -384,8 +375,6 @@ public class DeviceValidatorTests AddValidDeviceToRequest(request); _deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id) .Returns(null as Device); - _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification) - .Returns(true); context.SsoRequired = true; @@ -404,7 +393,6 @@ public class DeviceValidatorTests { // Arrange ArrangeForHandleNewDeviceVerificationTest(context, request); - _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true); _globalSettings.EnableNewDeviceVerification = true; context.User = null; @@ -430,7 +418,6 @@ public class DeviceValidatorTests { // Arrange ArrangeForHandleNewDeviceVerificationTest(context, request); - _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true); _globalSettings.EnableNewDeviceVerification = true; context.User.VerifyDevices = false; @@ -454,7 +441,6 @@ public class DeviceValidatorTests { // Arrange ArrangeForHandleNewDeviceVerificationTest(context, request); - _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true); _globalSettings.EnableNewDeviceVerification = true; _distributedCache.GetAsync(Arg.Any()).Returns(null as byte[]); context.User.CreationDate = DateTime.UtcNow - TimeSpan.FromHours(23); @@ -479,7 +465,6 @@ public class DeviceValidatorTests { // Arrange ArrangeForHandleNewDeviceVerificationTest(context, request); - _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true); _globalSettings.EnableNewDeviceVerification = true; _distributedCache.GetAsync(Arg.Any()).Returns([1]); @@ -503,7 +488,6 @@ public class DeviceValidatorTests { // Arrange ArrangeForHandleNewDeviceVerificationTest(context, request); - _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true); _globalSettings.EnableNewDeviceVerification = true; _distributedCache.GetAsync(Arg.Any()).Returns(null as byte[]); @@ -535,7 +519,6 @@ public class DeviceValidatorTests { // Arrange ArrangeForHandleNewDeviceVerificationTest(context, request); - _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true); _globalSettings.EnableNewDeviceVerification = true; _distributedCache.GetAsync(Arg.Any()).Returns(null as byte[]); @@ -564,7 +547,6 @@ public class DeviceValidatorTests { // Arrange ArrangeForHandleNewDeviceVerificationTest(context, request); - _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true); _globalSettings.EnableNewDeviceVerification = true; _distributedCache.GetAsync(Arg.Any()).Returns([1]); _deviceRepository.GetManyByUserIdAsync(context.User.Id).Returns([]); @@ -590,7 +572,6 @@ public class DeviceValidatorTests { // Arrange ArrangeForHandleNewDeviceVerificationTest(context, request); - _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true); _globalSettings.EnableNewDeviceVerification = true; _deviceRepository.GetManyByUserIdAsync(context.User.Id).Returns([new Device()]); _distributedCache.GetAsync(Arg.Any()).Returns(null as byte[]); diff --git a/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs b/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs index fb4d7c321a..53e9a00c9f 100644 --- a/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs @@ -2,6 +2,7 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Models.Data.Organizations; @@ -27,11 +28,11 @@ public class TwoFactorAuthenticationValidatorTests private readonly IUserService _userService; private readonly UserManagerTestWrapper _userManager; private readonly IOrganizationDuoUniversalTokenProvider _organizationDuoUniversalTokenProvider; - private readonly IFeatureService _featureService; private readonly IApplicationCacheService _applicationCacheService; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationRepository _organizationRepository; private readonly IDataProtectorTokenFactory _ssoEmail2faSessionTokenable; + private readonly ITwoFactorIsEnabledQuery _twoFactorenabledQuery; private readonly ICurrentContext _currentContext; private readonly TwoFactorAuthenticationValidator _sut; @@ -40,22 +41,22 @@ public class TwoFactorAuthenticationValidatorTests _userService = Substitute.For(); _userManager = SubstituteUserManager(); _organizationDuoUniversalTokenProvider = Substitute.For(); - _featureService = Substitute.For(); _applicationCacheService = Substitute.For(); _organizationUserRepository = Substitute.For(); _organizationRepository = Substitute.For(); _ssoEmail2faSessionTokenable = Substitute.For>(); + _twoFactorenabledQuery = Substitute.For(); _currentContext = Substitute.For(); _sut = new TwoFactorAuthenticationValidator( _userService, _userManager, _organizationDuoUniversalTokenProvider, - _featureService, _applicationCacheService, _organizationUserRepository, _organizationRepository, _ssoEmail2faSessionTokenable, + _twoFactorenabledQuery, _currentContext); } @@ -251,9 +252,9 @@ public class TwoFactorAuthenticationValidatorTests [Theory] [BitAutoData(TwoFactorProviderType.Email)] - public async void BuildTwoFactorResultAsync_IndividualEmailProvider_SendsEmail_SetsSsoToken_ReturnsNotNull( - TwoFactorProviderType providerType, - User user) + public async void BuildTwoFactorResultAsync_SetsSsoToken_ReturnsNotNull( + TwoFactorProviderType providerType, + User user) { // Arrange var providerTypeInt = (int)providerType; @@ -263,9 +264,6 @@ public class TwoFactorAuthenticationValidatorTests _userManager.SUPPORTS_TWO_FACTOR = true; _userManager.TWO_FACTOR_PROVIDERS = [providerType.ToString()]; - _userService.TwoFactorProviderIsEnabledAsync(Arg.Any(), user) - .Returns(true); - // Act var result = await _sut.BuildTwoFactorResultAsync(user, null); @@ -278,8 +276,6 @@ public class TwoFactorAuthenticationValidatorTests Assert.True(providers.ContainsKey(providerTypeInt.ToString())); Assert.True(result.ContainsKey("SsoEmail2faSessionToken")); Assert.True(result.ContainsKey("Email")); - - await _userService.Received(1).SendTwoFactorEmailAsync(Arg.Any()); } [Theory] @@ -322,9 +318,6 @@ public class TwoFactorAuthenticationValidatorTests string token) { // Arrange - _userService.TwoFactorProviderIsEnabledAsync( - TwoFactorProviderType.Email, user).Returns(true); - _userManager.TWO_FACTOR_PROVIDERS = ["email"]; // Act @@ -342,10 +335,8 @@ public class TwoFactorAuthenticationValidatorTests string token) { // Arrange - _userService.TwoFactorProviderIsEnabledAsync( - TwoFactorProviderType.Email, user).Returns(false); - _userManager.TWO_FACTOR_PROVIDERS = ["email"]; + user.TwoFactorProviders = ""; // Act var result = await _sut.VerifyTwoFactorAsync( @@ -362,9 +353,6 @@ public class TwoFactorAuthenticationValidatorTests string token) { // Arrange - _userService.TwoFactorProviderIsEnabledAsync( - TwoFactorProviderType.OrganizationDuo, user).Returns(false); - _userManager.TWO_FACTOR_PROVIDERS = ["OrganizationDuo"]; // Act @@ -387,11 +375,9 @@ public class TwoFactorAuthenticationValidatorTests string token) { // Arrange - _userService.TwoFactorProviderIsEnabledAsync( - providerType, user).Returns(true); - _userManager.TWO_FACTOR_ENABLED = true; _userManager.TWO_FACTOR_TOKEN_VERIFIED = true; + user.TwoFactorProviders = GetTwoFactorIndividualProviderJson(providerType); // Act var result = await _sut.VerifyTwoFactorAsync(user, null, providerType, token); @@ -412,11 +398,9 @@ public class TwoFactorAuthenticationValidatorTests string token) { // Arrange - _userService.TwoFactorProviderIsEnabledAsync( - providerType, user).Returns(true); - _userManager.TWO_FACTOR_ENABLED = true; _userManager.TWO_FACTOR_TOKEN_VERIFIED = false; + user.TwoFactorProviders = GetTwoFactorIndividualProviderJson(providerType); // Act var result = await _sut.VerifyTwoFactorAsync(user, null, providerType, token); diff --git a/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs index ed28f00ce7..4c14de2d73 100644 --- a/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs +++ b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs @@ -54,7 +54,6 @@ IBaseRequestValidatorTestWrapper IDeviceValidator deviceValidator, ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator, IOrganizationUserRepository organizationUserRepository, - IMailService mailService, ILogger logger, ICurrentContext currentContext, GlobalSettings globalSettings, @@ -71,7 +70,6 @@ IBaseRequestValidatorTestWrapper deviceValidator, twoFactorAuthenticationValidator, organizationUserRepository, - mailService, logger, currentContext, globalSettings, @@ -96,6 +94,7 @@ IBaseRequestValidatorTestWrapper return context.ValidatedTokenRequest.Subject ?? new ClaimsPrincipal(); } + [Obsolete] protected override void SetErrorResult( BaseRequestValidationContextFake context, Dictionary customResponse) @@ -103,6 +102,7 @@ IBaseRequestValidatorTestWrapper context.GrantResult = new GrantValidationResult(TokenRequestErrors.InvalidGrant, customResponse: customResponse); } + [Obsolete] protected override void SetSsoResult( BaseRequestValidationContextFake context, Dictionary customResponse) @@ -121,6 +121,7 @@ IBaseRequestValidatorTestWrapper return Task.CompletedTask; } + [Obsolete] protected override void SetTwoFactorResult( BaseRequestValidationContextFake context, Dictionary customResponse) diff --git a/test/Identity.Test/Wrappers/UserManagerTestWrapper.cs b/test/Identity.Test/Wrappers/UserManagerTestWrapper.cs index f1207a4b9a..3152f2327f 100644 --- a/test/Identity.Test/Wrappers/UserManagerTestWrapper.cs +++ b/test/Identity.Test/Wrappers/UserManagerTestWrapper.cs @@ -56,9 +56,9 @@ public class UserManagerTestWrapper : UserManager where TUser : cl ///
    /// /// - public override async Task GetTwoFactorEnabledAsync(TUser user) + public override Task GetTwoFactorEnabledAsync(TUser user) { - return TWO_FACTOR_ENABLED; + return Task.FromResult(TWO_FACTOR_ENABLED); } /// @@ -66,9 +66,9 @@ public class UserManagerTestWrapper : UserManager where TUser : cl /// /// /// - public override async Task> GetValidTwoFactorProvidersAsync(TUser user) + public override Task> GetValidTwoFactorProvidersAsync(TUser user) { - return TWO_FACTOR_PROVIDERS; + return Task.FromResult(TWO_FACTOR_PROVIDERS); } /// @@ -77,9 +77,9 @@ public class UserManagerTestWrapper : UserManager where TUser : cl /// /// /// - public override async Task GenerateTwoFactorTokenAsync(TUser user, string tokenProvider) + public override Task GenerateTwoFactorTokenAsync(TUser user, string tokenProvider) { - return TWO_FACTOR_TOKEN; + return Task.FromResult(TWO_FACTOR_TOKEN); } /// @@ -89,8 +89,8 @@ public class UserManagerTestWrapper : UserManager where TUser : cl /// /// /// - public override async Task VerifyTwoFactorTokenAsync(TUser user, string tokenProvider, string token) + public override Task VerifyTwoFactorTokenAsync(TUser user, string tokenProvider, string token) { - return TWO_FACTOR_TOKEN_VERIFIED; + return Task.FromResult(TWO_FACTOR_TOKEN_VERIFIED); } } diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs index 637e970f8f..fd759e4777 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs @@ -316,6 +316,29 @@ public class OrganizationUserRepositoryTests BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULl Plan = "Test", // TODO: EF does not enforce this being NOT NULl PrivateKey = "privatekey", + UsePolicies = false, + UseSso = false, + UseKeyConnector = false, + UseScim = false, + UseGroups = false, + UseDirectory = false, + UseEvents = false, + UseTotp = false, + Use2fa = false, + UseApi = false, + UseResetPassword = false, + UseSecretsManager = false, + SelfHost = false, + UsersGetPremium = false, + UseCustomPermissions = false, + Enabled = true, + UsePasswordManager = false, + LimitCollectionCreation = false, + LimitCollectionDeletion = false, + LimitItemDeletion = false, + AllowAdminAccessToAllCollectionItems = false, + UseRiskInsights = false, + UseAdminSponsoredFamilies = false }); var organizationDomain = new OrganizationDomain @@ -335,6 +358,7 @@ public class OrganizationUserRepositoryTests UserId = user1.Id, Status = OrganizationUserStatusType.Confirmed, ResetPasswordKey = "resetpasswordkey1", + AccessSecretsManager = false }); await organizationUserRepository.CreateAsync(new OrganizationUser diff --git a/test/Infrastructure.IntegrationTest/Platform/Installations/InstallationRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Platform/Installations/InstallationRepositoryTests.cs new file mode 100644 index 0000000000..2d212d4e39 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/Platform/Installations/InstallationRepositoryTests.cs @@ -0,0 +1,46 @@ +using Bit.Core.Platform.Installations; +using Bit.Infrastructure.IntegrationTest.Comparers; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.Platform.Installations; + +public class InstallationRepositoryTests +{ + [DatabaseTheory, DatabaseData] + public async Task GetByIdAsync_Works(IInstallationRepository installationRepository) + { + var installation = await installationRepository.CreateAsync(new Installation + { + Email = "test@email.com", + Key = "installation_key", + Enabled = true, + }); + + var retrievedInstallation = await installationRepository.GetByIdAsync(installation.Id); + + Assert.NotNull(retrievedInstallation); + Assert.Equal("installation_key", retrievedInstallation.Key); + } + + [DatabaseTheory, DatabaseData] + public async Task UpdateAsync_Works(IInstallationRepository installationRepository) + { + var installation = await installationRepository.CreateAsync(new Installation + { + Email = "test@email.com", + Key = "installation_key", + Enabled = true, + }); + + var now = DateTime.UtcNow; + + installation.LastActivityDate = now; + + await installationRepository.ReplaceAsync(installation); + + var retrievedInstallation = await installationRepository.GetByIdAsync(installation.Id); + + Assert.NotNull(retrievedInstallation.LastActivityDate); + Assert.Equal(now, retrievedInstallation.LastActivityDate.Value, LaxDateTimeComparer.Default); + } +} diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs index c1089608da..76fa0f03d1 100644 --- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs +++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs @@ -1,5 +1,4 @@ using AspNetCoreRateLimit; -using Bit.Core.Auth.Services; using Bit.Core.Billing.Services; using Bit.Core.Platform.Push; using Bit.Core.Platform.Push.Internal; @@ -207,8 +206,6 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory Replace(services); - Replace(services); - // TODO: Install and use azurite in CI pipeline Replace(services); diff --git a/util/DbSeederUtility/DbSeederUtility.csproj b/util/DbSeederUtility/DbSeederUtility.csproj new file mode 100644 index 0000000000..90ac7f22b4 --- /dev/null +++ b/util/DbSeederUtility/DbSeederUtility.csproj @@ -0,0 +1,22 @@ + + + + Exe + net8.0 + enable + enable + Bit.DbSeederUtility + DbSeeder + true + 2294c6ba-7cd0-4293-a797-3882e41c61cb + + + + + + + + + + + diff --git a/util/DbSeederUtility/GlobalSettingsFactory.cs b/util/DbSeederUtility/GlobalSettingsFactory.cs new file mode 100644 index 0000000000..e4ad275a0e --- /dev/null +++ b/util/DbSeederUtility/GlobalSettingsFactory.cs @@ -0,0 +1,34 @@ +using Bit.Core.Settings; +using Microsoft.Extensions.Configuration; + +namespace Bit.DbSeederUtility; + +public static class GlobalSettingsFactory +{ + private static GlobalSettings? _globalSettings; + + public static GlobalSettings GlobalSettings + { + get { return _globalSettings ??= LoadGlobalSettings(); } + } + + private static GlobalSettings LoadGlobalSettings() + { + Console.WriteLine("Loading global settings..."); + + var configBuilder = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) + .AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"}.json", optional: true, reloadOnChange: true) + .AddUserSecrets("bitwarden-Api") // Load user secrets from the API project + .AddEnvironmentVariables(); + + var configuration = configBuilder.Build(); + var globalSettingsSection = configuration.GetSection("globalSettings"); + + var settings = new GlobalSettings(); + globalSettingsSection.Bind(settings); + + return settings; + } +} diff --git a/util/DbSeederUtility/Program.cs b/util/DbSeederUtility/Program.cs new file mode 100644 index 0000000000..2d75b31934 --- /dev/null +++ b/util/DbSeederUtility/Program.cs @@ -0,0 +1,39 @@ +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.Seeder.Recipes; +using CommandDotNet; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.DbSeederUtility; + +public class Program +{ + private static int Main(string[] args) + { + return new AppRunner() + .Run(args); + } + + [Command("organization", Description = "Seed an organization and organization users")] + public void Organization( + [Option('n', "Name", Description = "Name of organization")] + string name, + [Option('u', "users", Description = "Number of users to generate")] + int users, + [Option('d', "domain", Description = "Email domain for users")] + string domain + ) + { + // Create service provider with necessary services + var services = new ServiceCollection(); + ServiceCollectionExtension.ConfigureServices(services); + var serviceProvider = services.BuildServiceProvider(); + + // Get a scoped DB context + using var scope = serviceProvider.CreateScope(); + var scopedServices = scope.ServiceProvider; + var db = scopedServices.GetRequiredService(); + + var recipe = new OrganizationWithUsersRecipe(db); + recipe.Seed(name, users, domain); + } +} diff --git a/util/DbSeederUtility/README.md b/util/DbSeederUtility/README.md new file mode 100644 index 0000000000..0eb21ae6c5 --- /dev/null +++ b/util/DbSeederUtility/README.md @@ -0,0 +1,40 @@ +# Bitwarden Database Seeder Utility + +A command-line utility for generating and managing test data for Bitwarden databases. + +## Overview + +DbSeederUtility is an executable wrapper around the Seeder class library that provides a convenient command-line +interface for executing seed-recipes in your local environment. + +## Installation + +The utility can be built and run as a .NET 8 application: + +``` +dotnet build +dotnet run -- [options] +``` + +Or directly using the compiled executable: + +``` +DbSeeder.exe [options] +``` + +## Examples + +### Generate and load test organization + +```bash +# Generate an organization called "seeded" with 10000 users using the @large.test email domain. +# Login using "admin@large.test" with password "asdfasdfasdf" +DbSeeder.exe organization -n seeded -u 10000 -d large.test +``` + +## Dependencies + +This utility depends on: +- The Seeder class library +- CommandDotNet for command-line parsing +- .NET 8.0 runtime diff --git a/util/DbSeederUtility/ServiceCollectionExtension.cs b/util/DbSeederUtility/ServiceCollectionExtension.cs new file mode 100644 index 0000000000..0653bb1801 --- /dev/null +++ b/util/DbSeederUtility/ServiceCollectionExtension.cs @@ -0,0 +1,25 @@ +using Bit.SharedWeb.Utilities; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Bit.DbSeederUtility; + +public static class ServiceCollectionExtension +{ + public static void ConfigureServices(ServiceCollection services) + { + // Load configuration using the GlobalSettingsFactory + var globalSettings = GlobalSettingsFactory.GlobalSettings; + + // Register services + services.AddLogging(builder => builder.AddConsole()); + services.AddSingleton(globalSettings); + + // Add Data Protection services + services.AddDataProtection() + .SetApplicationName("Bitwarden"); + + services.AddDatabaseRepositories(globalSettings); + } +} diff --git a/util/Migrator/DbScripts/2025-04-16_00_AttachmentJsonValidation.sql b/util/Migrator/DbScripts/2025-04-16_00_AttachmentJsonValidation.sql new file mode 100644 index 0000000000..a501b28574 --- /dev/null +++ b/util/Migrator/DbScripts/2025-04-16_00_AttachmentJsonValidation.sql @@ -0,0 +1,350 @@ +CREATE OR ALTER PROCEDURE [dbo].[Cipher_UpdateAttachment] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @AttachmentId VARCHAR(50), + @AttachmentData NVARCHAR(MAX) +AS +BEGIN + SET NOCOUNT ON + + -- Validate that AttachmentData is valid JSON + IF ISJSON(@AttachmentData) = 0 + BEGIN + THROW 50000, 'Invalid JSON format in AttachmentData parameter', 1; + RETURN; + END + + -- Validate that AttachmentData has the expected structure + -- Check for required fields + IF JSON_VALUE(@AttachmentData, '$.FileName') IS NULL OR + JSON_VALUE(@AttachmentData, '$.Size') IS NULL + BEGIN + THROW 50000, 'AttachmentData is missing required fields (FileName, Size)', 1; + RETURN; + END + + -- Validate data types for critical fields + DECLARE @Size BIGINT = TRY_CAST(JSON_VALUE(@AttachmentData, '$.Size') AS BIGINT) + IF @Size IS NULL OR @Size <= 0 + BEGIN + THROW 50000, 'AttachmentData has invalid Size value', 1; + RETURN; + END + + DECLARE @AttachmentIdKey VARCHAR(50) = CONCAT('"', @AttachmentId, '"') + DECLARE @AttachmentIdPath VARCHAR(50) = CONCAT('$.', @AttachmentIdKey) + DECLARE @NewAttachments NVARCHAR(MAX) + + -- Get current attachments + DECLARE @CurrentAttachments NVARCHAR(MAX) + SELECT @CurrentAttachments = [Attachments] FROM [dbo].[Cipher] WHERE [Id] = @Id + + -- Prepare the new attachments value based on current state + IF @CurrentAttachments IS NULL + BEGIN + -- Create new JSON object with the attachment + SET @NewAttachments = CONCAT('{', @AttachmentIdKey, ':', @AttachmentData, '}') + + -- Validate the constructed JSON + IF ISJSON(@NewAttachments) = 0 + BEGIN + THROW 50000, 'Failed to create valid JSON when adding new attachment', 1; + RETURN; + END + END + ELSE + BEGIN + -- Validate existing attachments + IF ISJSON(@CurrentAttachments) = 0 + BEGIN + THROW 50000, 'Current attachments data is not valid JSON', 1; + RETURN; + END + + -- Modify existing JSON + SET @NewAttachments = JSON_MODIFY(@CurrentAttachments, @AttachmentIdPath, JSON_QUERY(@AttachmentData, '$')) + + -- Validate the modified JSON + IF ISJSON(@NewAttachments) = 0 + BEGIN + THROW 50000, 'Failed to create valid JSON when updating existing attachments', 1; + RETURN; + END + END + + -- Update with validated JSON + UPDATE [dbo].[Cipher] + SET [Attachments] = @NewAttachments + WHERE [Id] = @Id + + IF @OrganizationId IS NOT NULL + BEGIN + EXEC [dbo].[Organization_UpdateStorage] @OrganizationId + EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId + END + ELSE IF @UserId IS NOT NULL + BEGIN + EXEC [dbo].[User_UpdateStorage] @UserId + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + END +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Cipher_DeleteAttachment] + @Id UNIQUEIDENTIFIER, + @AttachmentId VARCHAR(50) +AS +BEGIN + SET NOCOUNT ON + + DECLARE @AttachmentIdKey VARCHAR(50) = CONCAT('"', @AttachmentId, '"') + DECLARE @AttachmentIdPath VARCHAR(50) = CONCAT('$.', @AttachmentIdKey) + + DECLARE @UserId UNIQUEIDENTIFIER + DECLARE @OrganizationId UNIQUEIDENTIFIER + DECLARE @CurrentAttachments NVARCHAR(MAX) + DECLARE @NewAttachments NVARCHAR(MAX) + + -- Get current cipher data + SELECT + @UserId = [UserId], + @OrganizationId = [OrganizationId], + @CurrentAttachments = [Attachments] + FROM + [dbo].[Cipher] + WHERE [Id] = @Id + + -- If there are no attachments, nothing to do + IF @CurrentAttachments IS NULL + BEGIN + RETURN; + END + + -- Validate the initial JSON + IF ISJSON(@CurrentAttachments) = 0 + BEGIN + THROW 50000, 'Current initial attachments data is not valid JSON', 1; + RETURN; + END + + -- Check if the attachment exists before trying to remove it + IF JSON_PATH_EXISTS(@CurrentAttachments, @AttachmentIdPath) = 0 + BEGIN + -- Attachment doesn't exist, nothing to do + RETURN; + END + + -- Create the new attachments JSON with the specified attachment removed + SET @NewAttachments = JSON_MODIFY(@CurrentAttachments, @AttachmentIdPath, NULL) + + -- Validate the resulting JSON + IF ISJSON(@NewAttachments) = 0 + BEGIN + THROW 50000, 'Failed to create valid JSON when removing attachment', 1; + RETURN; + END + + -- Check if we've removed all attachments and have an empty object + IF @NewAttachments = '{}' + BEGIN + -- If we have an empty JSON object, set to NULL instead + SET @NewAttachments = NULL; + END + + -- Update with validated JSON + UPDATE [dbo].[Cipher] + SET [Attachments] = @NewAttachments + WHERE [Id] = @Id + + IF @OrganizationId IS NOT NULL + BEGIN + EXEC [dbo].[Organization_UpdateStorage] @OrganizationId + EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId + END + ELSE IF @UserId IS NOT NULL + BEGIN + EXEC [dbo].[User_UpdateStorage] @UserId + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + END +END +GO + +-- Remove [Attachments] assignment from Cipher_Create, Cipher_Update, and CipherDetails_Update procedures + +CREATE OR ALTER PROCEDURE [dbo].[Cipher_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Data NVARCHAR(MAX), + @Favorites NVARCHAR(MAX), + @Folders NVARCHAR(MAX), + @Attachments NVARCHAR(MAX), -- not used + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @DeletedDate DATETIME2(7), + @Reprompt TINYINT, + @Key VARCHAR(MAX) = NULL +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[Cipher] + ( + [Id], + [UserId], + [OrganizationId], + [Type], + [Data], + [Favorites], + [Folders], + [CreationDate], + [RevisionDate], + [DeletedDate], + [Reprompt], + [Key] + ) + VALUES + ( + @Id, + CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END, + @OrganizationId, + @Type, + @Data, + @Favorites, + @Folders, + @CreationDate, + @RevisionDate, + @DeletedDate, + @Reprompt, + @Key + ) + + IF @OrganizationId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId + END + ELSE IF @UserId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + END +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_Update] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Data NVARCHAR(MAX), + @Favorites NVARCHAR(MAX), -- not used + @Folders NVARCHAR(MAX), -- not used + @Attachments NVARCHAR(MAX), -- not used + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @FolderId UNIQUEIDENTIFIER, + @Favorite BIT, + @Edit BIT, -- not used + @ViewPassword BIT, -- not used + @Manage BIT, -- not used + @OrganizationUseTotp BIT, -- not used + @DeletedDate DATETIME2(2), + @Reprompt TINYINT, + @Key VARCHAR(MAX) = NULL +AS +BEGIN + SET NOCOUNT ON + + DECLARE @UserIdKey VARCHAR(50) = CONCAT('"', @UserId, '"') + DECLARE @UserIdPath VARCHAR(50) = CONCAT('$.', @UserIdKey) + + UPDATE + [dbo].[Cipher] + SET + [UserId] = CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END, + [OrganizationId] = @OrganizationId, + [Type] = @Type, + [Data] = @Data, + [Folders] = + CASE + WHEN @FolderId IS NOT NULL AND [Folders] IS NULL THEN + CONCAT('{', @UserIdKey, ':"', @FolderId, '"', '}') + WHEN @FolderId IS NOT NULL THEN + JSON_MODIFY([Folders], @UserIdPath, CAST(@FolderId AS VARCHAR(50))) + ELSE + JSON_MODIFY([Folders], @UserIdPath, NULL) + END, + [Favorites] = + CASE + WHEN @Favorite = 1 AND [Favorites] IS NULL THEN + CONCAT('{', @UserIdKey, ':true}') + WHEN @Favorite = 1 THEN + JSON_MODIFY([Favorites], @UserIdPath, CAST(1 AS BIT)) + ELSE + JSON_MODIFY([Favorites], @UserIdPath, NULL) + END, + [Reprompt] = @Reprompt, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate, + [DeletedDate] = @DeletedDate, + [Key] = @Key + WHERE + [Id] = @Id + + IF @OrganizationId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId + END + ELSE IF @UserId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + END +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Cipher_Update] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Data NVARCHAR(MAX), + @Favorites NVARCHAR(MAX), + @Folders NVARCHAR(MAX), + @Attachments NVARCHAR(MAX), -- not used + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @DeletedDate DATETIME2(7), + @Reprompt TINYINT, + @Key VARCHAR(MAX) = NULL +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[Cipher] + SET + [UserId] = CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END, + [OrganizationId] = @OrganizationId, + [Type] = @Type, + [Data] = @Data, + [Favorites] = @Favorites, + [Folders] = @Folders, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate, + [DeletedDate] = @DeletedDate, + [Reprompt] = @Reprompt, + [Key] = @Key + WHERE + [Id] = @Id + + IF @OrganizationId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId + END + ELSE IF @UserId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + END +END +GO diff --git a/util/Migrator/DbScripts/2025-04-24_00_UpdateOrgUserReadOccupiedSeatCountForSponsorships.sql b/util/Migrator/DbScripts/2025-04-24_00_UpdateOrgUserReadOccupiedSeatCountForSponsorships.sql new file mode 100644 index 0000000000..7260c9c6d4 --- /dev/null +++ b/util/Migrator/DbScripts/2025-04-24_00_UpdateOrgUserReadOccupiedSeatCountForSponsorships.sql @@ -0,0 +1,41 @@ +IF OBJECT_ID('[dbo].[OrganizationUser_ReadOccupiedSeatCountByOrganizationId]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[OrganizationUser_ReadOccupiedSeatCountByOrganizationId] +END +GO + +CREATE PROCEDURE [dbo].[OrganizationUser_ReadOccupiedSeatCountByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + ( + SELECT COUNT(1) + FROM [dbo].[OrganizationUserView] + WHERE OrganizationId = @OrganizationId + AND Status >= 0 --Invited + ) + + ( + SELECT COUNT(1) + FROM [dbo].[OrganizationSponsorship] + WHERE SponsoringOrganizationId = @OrganizationId + AND IsAdminInitiated = 1 + AND ( + -- Not marked for deletion - always count + (ToDelete = 0) + OR + -- Marked for deletion but has a valid until date in the future (RevokeWhenExpired status) + (ToDelete = 1 AND ValidUntil IS NOT NULL AND ValidUntil > GETUTCDATE()) + ) + AND ( + -- SENT status: When SponsoredOrganizationId is null + SponsoredOrganizationId IS NULL + OR + -- ACCEPTED status: When SponsoredOrganizationId is not null and ValidUntil is null or in the future + (SponsoredOrganizationId IS NOT NULL AND (ValidUntil IS NULL OR ValidUntil > GETUTCDATE())) + ) + ) +END +GO diff --git a/util/Migrator/DbScripts/2025-05-05_00_AddIsAdminInitiated_RefreshView.sql b/util/Migrator/DbScripts/2025-05-05_00_AddIsAdminInitiated_RefreshView.sql new file mode 100644 index 0000000000..8fd465025c --- /dev/null +++ b/util/Migrator/DbScripts/2025-05-05_00_AddIsAdminInitiated_RefreshView.sql @@ -0,0 +1,221 @@ +CREATE OR ALTER VIEW [dbo].[OrganizationUserOrganizationDetailsView] +AS +SELECT + OU.[UserId], + OU.[OrganizationId], + OU.[Id] OrganizationUserId, + O.[Name], + O.[Enabled], + O.[PlanType], + O.[UsePolicies], + O.[UseSso], + O.[UseKeyConnector], + O.[UseScim], + O.[UseGroups], + O.[UseDirectory], + O.[UseEvents], + O.[UseTotp], + O.[Use2fa], + O.[UseApi], + O.[UseResetPassword], + O.[SelfHost], + O.[UsersGetPremium], + O.[UseCustomPermissions], + O.[UseSecretsManager], + O.[Seats], + O.[MaxCollections], + O.[MaxStorageGb], + O.[Identifier], + OU.[Key], + OU.[ResetPasswordKey], + O.[PublicKey], + O.[PrivateKey], + OU.[Status], + OU.[Type], + SU.[ExternalId] SsoExternalId, + OU.[Permissions], + PO.[ProviderId], + P.[Name] ProviderName, + P.[Type] ProviderType, + SS.[Data] SsoConfig, + OS.[FriendlyName] FamilySponsorshipFriendlyName, + OS.[LastSyncDate] FamilySponsorshipLastSyncDate, + OS.[ToDelete] FamilySponsorshipToDelete, + OS.[ValidUntil] FamilySponsorshipValidUntil, + OU.[AccessSecretsManager], + O.[UsePasswordManager], + O.[SmSeats], + O.[SmServiceAccounts], + O.[LimitCollectionCreation], + O.[LimitCollectionDeletion], + O.[AllowAdminAccessToAllCollectionItems], + O.[UseRiskInsights], + O.[UseAdminSponsoredFamilies], + O.[LimitItemDeletion], + OS.[IsAdminInitiated] +FROM + [dbo].[OrganizationUser] OU + LEFT JOIN + [dbo].[Organization] O ON O.[Id] = OU.[OrganizationId] + LEFT JOIN + [dbo].[SsoUser] SU ON SU.[UserId] = OU.[UserId] AND SU.[OrganizationId] = OU.[OrganizationId] + LEFT JOIN + [dbo].[ProviderOrganization] PO ON PO.[OrganizationId] = O.[Id] + LEFT JOIN + [dbo].[Provider] P ON P.[Id] = PO.[ProviderId] + LEFT JOIN + [dbo].[SsoConfig] SS ON SS.[OrganizationId] = OU.[OrganizationId] + LEFT JOIN + [dbo].[OrganizationSponsorship] OS ON OS.[SponsoringOrganizationUserID] = OU.[Id] +GO + +--Manually refresh [dbo].[OrganizationUserOrganizationDetailsView] +IF OBJECT_ID('[dbo].[OrganizationUserOrganizationDetailsView]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationUserOrganizationDetailsView]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationView]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationView]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorshipView]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorshipView]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationUserOrganizationDetailsView]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationUserOrganizationDetailsView]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_OrganizationUserDeleted]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_OrganizationUserDeleted]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_CreateMany]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_CreateMany]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_OrganizationUsersDeleted]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_OrganizationUsersDeleted]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_DeleteExpired]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_DeleteExpired]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_Update]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_Update]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_DeleteById]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_DeleteById]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_Create]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_Create]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_OrganizationDeleted]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_OrganizationDeleted]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_UpdateMany]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_UpdateMany]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_DeleteByIds]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_DeleteByIds]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_ReadLatestBySponsoringOrganizationId]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_ReadLatestBySponsoringOrganizationId]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_ReadByOfferedToEmail]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_ReadByOfferedToEmail]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_ReadBySponsoredOrganizationId]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_ReadBySponsoredOrganizationId]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_ReadBySponsoringOrganizationId]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_ReadBySponsoringOrganizationId]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_ReadById]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_ReadById]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_ReadBySponsoringOrganizationUserId]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_ReadBySponsoringOrganizationUserId]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationUserOrganizationDetails_ReadByUserIdStatus]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationUserOrganizationDetails_ReadByUserIdStatus]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationUserOrganizationDetails_ReadByUserIdStatusOrganizationId]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationUserOrganizationDetails_ReadByUserIdStatusOrganizationId]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationUser_DeleteById]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationUser_DeleteById]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationUser_DeleteByIds]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationUser_DeleteByIds]'; +END +GO + +IF OBJECT_ID('[dbo].[Organization_DeleteById]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[Organization_DeleteById]'; +END +GO diff --git a/util/Migrator/DbScripts/2025-05-05_01_AddIsAdminInitiated_OrganizationSponsorship_ReadBySponsoringOrganizationUserId.sql b/util/Migrator/DbScripts/2025-05-05_01_AddIsAdminInitiated_OrganizationSponsorship_ReadBySponsoringOrganizationUserId.sql new file mode 100644 index 0000000000..bb3bdee9b9 --- /dev/null +++ b/util/Migrator/DbScripts/2025-05-05_01_AddIsAdminInitiated_OrganizationSponsorship_ReadBySponsoringOrganizationUserId.sql @@ -0,0 +1,20 @@ +SET ANSI_NULLS ON +GO +SET QUOTED_IDENTIFIER ON +GO +ALTER PROCEDURE [dbo].[OrganizationSponsorship_ReadBySponsoringOrganizationUserId] + @SponsoringOrganizationUserId UNIQUEIDENTIFIER, + @IsAdminInitiated BIT = 0 +AS +BEGIN + SET NOCOUNT ON; + + SELECT + * + FROM + [dbo].[OrganizationSponsorshipView] + WHERE + [SponsoringOrganizationUserId] = @SponsoringOrganizationUserId + and [IsAdminInitiated] = @IsAdminInitiated +END +GO diff --git a/util/Seeder/Factories/OrganizationSeeder.cs b/util/Seeder/Factories/OrganizationSeeder.cs new file mode 100644 index 0000000000..5e5cb17419 --- /dev/null +++ b/util/Seeder/Factories/OrganizationSeeder.cs @@ -0,0 +1,44 @@ +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Infrastructure.EntityFramework.AdminConsole.Models; +using Bit.Infrastructure.EntityFramework.Models; + +namespace Bit.Seeder.Factories; + +public class OrganizationSeeder +{ + public static Organization CreateEnterprise(string name, string domain, int seats) + { + return new Organization + { + Id = Guid.NewGuid(), + Name = name, + BillingEmail = $"billing@{domain}", + Plan = "Enterprise (Annually)", + PlanType = PlanType.EnterpriseAnnually, + Seats = seats, + + // Currently hardcoded to the values from https://github.com/bitwarden/sdk-internal/blob/main/crates/bitwarden-core/src/client/test_accounts.rs. + // TODO: These should be dynamically generated by the SDK. + PublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmIJbGMk6eZqVE7UxhZ46Weu2jKciqOiOkSVYtGvs61rfe9AXxtLaaZEKN4d4DmkZcF6dna2eXNxZmb7U4pwlttye8ksqISe6IUAZQox7auBpjopdCEPhKRg3BD/u8ks9UxSxgWe+fpebjt6gd5hsl1/5HOObn7SeU6EEU04cp3/eH7a4OTdXxB8oN62HGV9kM/ubM1goILgjoSJDbihMK0eb7b8hPHwcA/YOgKKiu/N3FighccdSMD5Pk+HfjacsFNZQa2EsqW09IvvSZ+iL6HQeZ1vwc/6TO1J7EOfJZFQcjoEL9LVI693efYoMZSmrPEWziZ4PvwpOOGo6OObyMQIDAQAB", + PrivateKey = "2.6FggyKVyaKQsfohi5yqgbg==|UU2JeafOB41L5UscGmf4kq15JGDf3Bkf67KECiehTODzbWctVLTgyDk0Qco8/6CMN6nZGXjxR2A4r5ExhmwRNsNxd77G+MprkmiJz+7w33ROZ1ouQO5XjD3wbQ3ssqNiTKId6yAUPBvuAZRixVApauTuADc8QWGixqCQcqZzmU7YSBBIPf652/AEYr4Tk64YihoE39pHiK8MRbTLdRt3EF4LSMugPAPM24vCgUv3w1TD3Fj6sDg/6oi3flOV9SJZX4vCiUXbDNEuD/p2aQrEXVbaxweFOHjTe7F4iawjXw3nG3SO8rUBHcxbhDDVx5rjYactbW5QvHWiyla6uLb6o8WHBneg2EjTEwAHOZE/rBjcqmAJb2sVp1E0Kwq8ycGmL69vmqJPC1GqVTohAQvmEkaxIPpfq24Yb9ZPrADA7iEXBKuAQ1FphFUVgJBJGJbd60sOV1Rz1T+gUwS4wCNQ4l3LG1S22+wzUVlEku5DXFnT932tatqTyWEthqPqLCt6dL1+qa94XLpeHagXAx2VGe8n8IlcADtxqS+l8xQ4heT12WO9kC316vqvg1mnsI56faup9hb3eT9ZpKyxSBGYOphlTWfV1Y/v64f5PYvTo4aL0IYHyLY/9Qi72vFmOpPeHBYgD5t3j+H2CsiU1PkYsBggOmD7xW8FDuT6HWVvwhEJqeibVPK0Lhyj6tgvlSIAvFUaSMFPlmwFNmwfj/AHUhr9KuTfsBFTZ10yy9TZVgf+EofwnrxHBaWUgdD40aHoY1VjfG33iEuajb6buxG3pYFyPNhJNzeLZisUKIDRMQpUHrsE22EyrFFran3tZGdtcyIEK4Q1F0ULYzJ6T9iY25/ZgPy3pEAAMZCtqo3s+GjX295fWIHfMcnjMgNUHPjExjWBHa+ggK9iQXkFpBVyYB1ga/+0eiIhiek3PlgtvpDrqF7TsLK+ROiBw2GJ7uaO3EEXOj2GpNBuEJ5CdodhZkwzhwMcSatgDHkUuNVu0iVbF6/MxVdOxWXKO+jCYM6PZk/vAhLYqpPzu2T2Uyz4nkDs2Tiq61ez6FoCrzdHIiyIxVTzUQH8G9FgSmtaZ7GCbqlhnurYgcMciwPzxg0hpAQT+NZw1tVEii9vFSpJJbGJqNhORKfKh/Mu1P/9LOQq7Y0P2FIR3x/eUVEQ7CGv2jVtO5ryGSmKeq/P9Fr54wTPaNiqN2K+leACUznCdUWw8kZo/AsBcrOe4OkRX6k8LC3oeJXy06DEToatxEvPYemUauhxiXRw8nfNMqc4LyJq2bbT0zCgJHoqpozPdNg6AYWcoIobgAGu7ZQGq+oE1MT3GZxotMPe/NUJiAc5YE9Thb5Yf3gyno71pyqPTVl/6IQuh4SUz7rkgwF/aVHEnr4aUYNoc0PEzd2Me0jElsA3GAneq1I/wngutOWgTViTK4Nptr5uIzMVQs9H1rOMJNorP8b02t1NDu010rSsib9GaaJJq4r4iy46laQOxWoU0ex26arYnk+jw4833WSCTVBIprTgizZ+fKjoY0xwXvI2oOvGNEUCtGFvKFORTaQrlaXZIg1toa2BBVNicyONbwnI3KIu3MgGJ2SlCVXJn8oHFppVHFCdwgN1uDzGiKAhjvr0sZTUtXin2f2CszPTbbo=|fUhbVKrr8CSKE7TZJneXpDGraj5YhRrq9ESo206S+BY=", + }; + } +} + +public static class OrgnaizationExtensions +{ + public static OrganizationUser CreateOrganizationUser(this Organization organization, User user) + { + return new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = organization.Id, + UserId = user.Id, + + Key = "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==", + Type = OrganizationUserType.Admin, + Status = OrganizationUserStatusType.Confirmed + }; + } +} diff --git a/util/Seeder/Factories/UserSeeder.cs b/util/Seeder/Factories/UserSeeder.cs new file mode 100644 index 0000000000..90cadf0b78 --- /dev/null +++ b/util/Seeder/Factories/UserSeeder.cs @@ -0,0 +1,25 @@ +using Bit.Core.Enums; +using Bit.Infrastructure.EntityFramework.Models; + +namespace Bit.Seeder.Factories; + +public class UserSeeder +{ + public static User CreateUser(string email) + { + return new User + { + Id = Guid.NewGuid(), + Email = email, + MasterPassword = "AQAAAAIAAYagAAAAEBATmF66OHMpHuHKc1CsGZQ1ltHUHyhYK+7e4re3bVFi16SOpLpDfzdFswnvFQs2Rg==", + SecurityStamp = "4830e359-e150-4eae-be2a-996c81c5e609", + Key = "2.z/eLKFhd62qy9RzXu3UHgA==|fF6yNupiCIguFKSDTB3DoqcGR0Xu4j+9VlnMyT5F3PaWIcGhzQKIzxdB95nhslaCQv3c63M7LBnvzVo1J9SUN85RMbP/57bP1HvhhU1nvL8=|IQPtf8v7k83MFZEhazSYXSdu98BBU5rqtvC4keVWyHM=", + PublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Ww2chogqCpaAR7Uw448am4b7vDFXiM5kXjFlGfXBlrAdAqTTggEvTDlMNYqPlCo+mBM6iFmTTUY9rpZBvFskMnKvsvpJ47/fehAH2o2e3Ulv/5NFevaVCMCmpkBDtbMbO1A4a3btdRtCP8DsKWMefHauEpaoLxNTLWnOIZVfCMjsSgx2EvULHAZPTtbFwm4+UVKniM4ds4jvOsD85h4jn2aLs/jWJXFfxN8iVSqEqpC2TBvsPdyHb49xQoWWfF0Z6BiNqeNGKEU9Uos1pjL+kzhEzzSpH31PZT/ufJ/oo4+93wrUt57hb6f0jxiXhwd5yQ+9F6wVwpbfkq0IwhjOwIDAQAB", + PrivateKey = "2.yN7l00BOlUE0Sb0M//Q53w==|EwKG/BduQRQ33Izqc/ogoBROIoI5dmgrxSo82sgzgAMIBt3A2FZ9vPRMY+GWT85JiqytDitGR3TqwnFUBhKUpRRAq4x7rA6A1arHrFp5Tp1p21O3SfjtvB3quiOKbqWk6ZaU1Np9HwqwAecddFcB0YyBEiRX3VwF2pgpAdiPbSMuvo2qIgyob0CUoC/h4Bz1be7Qa7B0Xw9/fMKkB1LpOm925lzqosyMQM62YpMGkjMsbZz0uPopu32fxzDWSPr+kekNNyLt9InGhTpxLmq1go/pXR2uw5dfpXc5yuta7DB0EGBwnQ8Vl5HPdDooqOTD9I1jE0mRyuBpWTTI3FRnu3JUh3rIyGBJhUmHqGZvw2CKdqHCIrQeQkkEYqOeJRJVdBjhv5KGJifqT3BFRwX/YFJIChAQpebNQKXe/0kPivWokHWwXlDB7S7mBZzhaAPidZvnuIhalE2qmTypDwHy22FyqV58T8MGGMchcASDi/QXI6kcdpJzPXSeU9o+NC68QDlOIrMVxKFeE7w7PvVmAaxEo0YwmuAzzKy9QpdlK0aab/xEi8V4iXj4hGepqAvHkXIQd+r3FNeiLfllkb61p6WTjr5urcmDQMR94/wYoilpG5OlybHdbhsYHvIzYoLrC7fzl630gcO6t4nM24vdB6Ymg9BVpEgKRAxSbE62Tqacxqnz9AcmgItb48NiR/He3n3ydGjPYuKk/ihZMgEwAEZvSlNxYONSbYrIGDtOY+8Nbt6KiH3l06wjZW8tcmFeVlWv+tWotnTY9IqlAfvNVTjtsobqtQnvsiDjdEVtNy/s2ci5TH+NdZluca2OVEr91Wayxh70kpM6ib4UGbfdmGgCo74gtKvKSJU0rTHakQ5L9JlaSDD5FamBRyI0qfL43Ad9qOUZ8DaffDCyuaVyuqk7cz9HwmEmvWU3VQ+5t06n/5kRDXttcw8w+3qClEEdGo1KeENcnXCB32dQe3tDTFpuAIMLqwXs6FhpawfZ5kPYvLPczGWaqftIs/RXJ/EltGc0ugw2dmTLpoQhCqrcKEBDoYVk0LDZKsnzitOGdi9mOWse7Se8798ib1UsHFUjGzISEt6upestxOeupSTOh0v4+AjXbDzRUyogHww3V+Bqg71bkcMxtB+WM+pn1XNbVTyl9NR040nhP7KEf6e9ruXAtmrBC2ah5cFEpLIot77VFZ9ilLuitSz+7T8n1yAh1IEG6xxXxninAZIzi2qGbH69O5RSpOJuJTv17zTLJQIIc781JwQ2TTwTGnx5wZLbffhCasowJKd2EVcyMJyhz6ru0PvXWJ4hUdkARJs3Xu8dus9a86N8Xk6aAPzBDqzYb1vyFIfBxP0oO8xFHgd30Cgmz8UrSE3qeWRrF8ftrI6xQnFjHBGWD/JWSvd6YMcQED0aVuQkuNW9ST/DzQThPzRfPUoiL10yAmV7Ytu4fR3x2sF0Yfi87YhHFuCMpV/DsqxmUizyiJuD938eRcH8hzR/VO53Qo3UIsqOLcyXtTv6THjSlTopQ+JOLOnHm1w8dzYbLN44OG44rRsbihMUQp+wUZ6bsI8rrOnm9WErzkbQFbrfAINdoCiNa6cimYIjvvnMTaFWNymqY1vZxGztQiMiHiHYwTfwHTXrb9j0uPM=|09J28iXv9oWzYtzK2LBT6Yht4IT4MijEkk0fwFdrVQ4=", + ApiKey = "7gp59kKHt9kMlks0BuNC4IjNXYkljR", + + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 600_000, + }; + } +} diff --git a/util/Seeder/README.md b/util/Seeder/README.md new file mode 100644 index 0000000000..8597ad6e39 --- /dev/null +++ b/util/Seeder/README.md @@ -0,0 +1,18 @@ +# Bitwarden Database Seeder + +A class library for generating and inserting test data. + +## Project Structure + +The project is organized into these main components: + +### Factories + +Factories are helper classes for creating domain entities and populating them with realistic data. This assist in +decreasing the amount of boilerplate code needed to create test data in recipes. + +### Recipes + +Recipes are pre-defined data sets which can be run to generate and load data into the database. They often allow a allow +for a few arguments to customize the data slightly. Recipes should be kept simple and focused on a single task. Default +to creating more recipes rather than adding complexity to existing ones. diff --git a/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs b/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs new file mode 100644 index 0000000000..fb06c091ae --- /dev/null +++ b/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs @@ -0,0 +1,37 @@ +using Bit.Infrastructure.EntityFramework.Models; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.Seeder.Factories; +using LinqToDB.EntityFrameworkCore; + +namespace Bit.Seeder.Recipes; + +public class OrganizationWithUsersRecipe(DatabaseContext db) +{ + public Guid Seed(string name, int users, string domain) + { + var organization = OrganizationSeeder.CreateEnterprise(name, domain, users); + var user = UserSeeder.CreateUser($"admin@{domain}"); + var orgUser = organization.CreateOrganizationUser(user); + + var additionalUsers = new List(); + var additionalOrgUsers = new List(); + for (var i = 0; i < users; i++) + { + var additionalUser = UserSeeder.CreateUser($"user{i}@{domain}"); + additionalUsers.Add(additionalUser); + additionalOrgUsers.Add(organization.CreateOrganizationUser(additionalUser)); + } + + db.Add(organization); + db.Add(user); + db.Add(orgUser); + + db.SaveChanges(); + + // Use LinqToDB's BulkCopy for significant better performance + db.BulkCopy(additionalUsers); + db.BulkCopy(additionalOrgUsers); + + return organization.Id; + } +} diff --git a/util/Seeder/Seeder.csproj b/util/Seeder/Seeder.csproj new file mode 100644 index 0000000000..392f6434cc --- /dev/null +++ b/util/Seeder/Seeder.csproj @@ -0,0 +1,29 @@ + + + + + net8.0 + enable + enable + Bit.Seeder + Bit.Seeder + Core library for generating and managing test data for Bitwarden + library + false + + + + + + + + + + + + + + + + + diff --git a/util/Setup/Configuration.cs b/util/Setup/Configuration.cs index 264eef05b2..3372652d03 100644 --- a/util/Setup/Configuration.cs +++ b/util/Setup/Configuration.cs @@ -31,9 +31,6 @@ public class Configuration "Learn more: https://docs.docker.com/compose/compose-file/#ports")] public string HttpsPort { get; set; } = "443"; - [Description("Configure Nginx for Captcha.")] - public bool Captcha { get; set; } = false; - [Description("Configure Nginx for SSL.")] public bool Ssl { get; set; } = true; diff --git a/util/Setup/NginxConfigBuilder.cs b/util/Setup/NginxConfigBuilder.cs index 865b8bdd69..1315ffaba7 100644 --- a/util/Setup/NginxConfigBuilder.cs +++ b/util/Setup/NginxConfigBuilder.cs @@ -73,7 +73,6 @@ public class NginxConfigBuilder public TemplateModel(Context context) { - Captcha = context.Config.Captcha; Ssl = context.Config.Ssl; EnableKeyConnector = context.Config.EnableKeyConnector; EnableScim = context.Config.EnableScim; @@ -127,7 +126,6 @@ public class NginxConfigBuilder } } - public bool Captcha { get; set; } public bool Ssl { get; set; } public bool EnableKeyConnector { get; set; } public bool EnableScim { get; set; } diff --git a/util/Setup/Templates/NginxConfig.hbs b/util/Setup/Templates/NginxConfig.hbs index 115c79c72a..f37987ca70 100644 --- a/util/Setup/Templates/NginxConfig.hbs +++ b/util/Setup/Templates/NginxConfig.hbs @@ -100,16 +100,6 @@ server { proxy_pass http://web:5000/sso-connector.html; } -{{#if Captcha}} - location = /captcha-connector.html { - proxy_pass http://web:5000/captcha-connector.html; - } - - location = /captcha-mobile-connector.html { - proxy_pass http://web:5000/captcha-mobile-connector.html; - } -{{/if}} - location /attachments/ { proxy_pass http://attachments:5000/; }