1
0
mirror of https://github.com/bitwarden/server.git synced 2025-05-27 14:24:56 -05:00

Merge branch 'main' into pm-20416-downgraded-paid-org-cannot-upgrade-from-free

This commit is contained in:
Cy Okeke 2025-05-20 13:47:30 +01:00
commit 86923fe1b8
No known key found for this signature in database
GPG Key ID: 88B341B55C84B45C
482 changed files with 21504 additions and 5690 deletions

11
.github/CODEOWNERS vendored
View File

@ -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
@ -82,6 +90,9 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev
.github/workflows/test-database.yml @bitwarden/team-platform-dev
.github/workflows/test.yml @bitwarden/team-platform-dev
**/*Platform* @bitwarden/team-platform-dev
**/.dockerignore @bitwarden/team-platform-dev
**/Dockerfile @bitwarden/team-platform-dev
**/entrypoint.sh @bitwarden/team-platform-dev
# Multiple owners - DO NOT REMOVE (BRE)
**/packages.lock.json

View File

@ -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",

View File

@ -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:
@ -234,12 +235,18 @@ jobs:
- name: Generate Docker 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

View File

@ -2,7 +2,9 @@ name: Build on PR Target
on:
pull_request_target:
types: [opened, synchronize]
types: [opened, synchronize, reopened]
branches:
- "main"
defaults:
run:

View File

@ -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'

View File

@ -7,8 +7,14 @@ on:
- "main"
- "rc"
- "hotfix-rc"
pull_request:
types: [opened, synchronize, reopened]
branches-ignore:
- main
pull_request_target:
types: [opened, synchronize]
types: [opened, synchronize, reopened]
branches:
- "main"
jobs:
check-run:

View File

@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Version>2025.4.3</Version>
<Version>2025.5.1</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>

View File

@ -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}

View File

@ -8,12 +8,10 @@ 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.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.Extensions.DependencyInjection;
using Stripe;
namespace Bit.Commercial.Core.AdminConsole.Providers;
@ -23,7 +21,6 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
private readonly IEventService _eventService;
private readonly IMailService _mailService;
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationService _organizationService;
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IStripeAdapter _stripeAdapter;
private readonly IFeatureService _featureService;
@ -31,26 +28,22 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
private readonly ISubscriberService _subscriberService;
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
private readonly IPricingClient _pricingClient;
private readonly IAutomaticTaxStrategy _automaticTaxStrategy;
public RemoveOrganizationFromProviderCommand(
IEventService eventService,
IMailService mailService,
IOrganizationRepository organizationRepository,
IOrganizationService organizationService,
IProviderOrganizationRepository providerOrganizationRepository,
IStripeAdapter stripeAdapter,
IFeatureService featureService,
IProviderBillingService providerBillingService,
ISubscriberService subscriberService,
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
IPricingClient pricingClient,
[FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy)
IPricingClient pricingClient)
{
_eventService = eventService;
_mailService = mailService;
_organizationRepository = organizationRepository;
_organizationService = organizationService;
_providerOrganizationRepository = providerOrganizationRepository;
_stripeAdapter = stripeAdapter;
_featureService = featureService;
@ -58,7 +51,6 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
_subscriberService = subscriberService;
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
_pricingClient = pricingClient;
_automaticTaxStrategy = automaticTaxStrategy;
}
public async Task RemoveOrganizationFromProvider(
@ -76,7 +68,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
if (!await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId,
Array.Empty<Guid>(),
[],
includeProvider: false))
{
throw new BadRequestException("Organization must have at least one confirmed owner.");
@ -101,7 +93,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
/// <summary>
/// When a client organization is unlinked from a provider, we have to check if they're Stripe-enabled
/// and, if they are, we remove their MSP discount and set their Subscription to `send_invoice`. This is because
/// the provider's payment method will be removed from their Stripe customer causing ensuing charges to fail. Lastly,
/// the provider's payment method will be removed from their Stripe customer, causing ensuing charges to fail. Lastly,
/// we email the organization owners letting them know they need to add a new payment method.
/// </summary>
private async Task ResetOrganizationBillingAsync(
@ -141,15 +133,18 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }]
};
if (_featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
var setNonUSBusinessUseToReverseCharge = _featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge)
{
_automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer);
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
}
else
else if (customer.HasRecognizedTaxLocation())
{
subscriptionCreateOptions.AutomaticTax ??= new SubscriptionAutomaticTaxOptions
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
Enabled = customer.Address.Country == "US" ||
customer.TaxIds.Any()
};
}
@ -186,7 +181,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
await _mailService.SendProviderUpdatePaymentMethod(
organization.Id,
organization.Name,
provider.Name,
provider.Name!,
organizationOwnerEmails);
}
}

View File

@ -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<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo = null)
public async Task<Provider> 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;

View File

@ -67,6 +67,7 @@ public class BusinessUnitConverter(
organization.MaxStorageGb = updatedPlan.PasswordManager.BaseStorageGb;
organization.UsePolicies = updatedPlan.HasPolicies;
organization.UseSso = updatedPlan.HasSso;
organization.UseOrganizationDomains = updatedPlan.HasOrganizationDomains;
organization.UseGroups = updatedPlan.HasGroups;
organization.UseEvents = updatedPlan.HasEvents;
organization.UseDirectory = updatedPlan.HasDirectory;

View File

@ -6,30 +6,37 @@ 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.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
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,
@ -40,13 +47,12 @@ public class ProviderBillingService(
IProviderOrganizationRepository providerOrganizationRepository,
IProviderPlanRepository providerPlanRepository,
IProviderUserRepository providerUserRepository,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService,
ITaxService taxService,
[FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy)
ITaxService taxService)
: IProviderBillingService
{
[RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
public async Task AddExistingOrganization(
Provider provider,
Organization organization,
@ -90,6 +96,7 @@ public class ProviderBillingService(
organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb;
organization.UsePolicies = plan.HasPolicies;
organization.UseSso = plan.HasSso;
organization.UseOrganizationDomains = plan.HasOrganizationDomains;
organization.UseGroups = plan.HasGroups;
organization.UseEvents = plan.HasEvents;
organization.UseDirectory = plan.HasDirectory;
@ -118,7 +125,7 @@ public class ProviderBillingService(
/*
* We have to scale the provider's seats before the ProviderOrganization
* row is inserted so the added organization's seats don't get double counted.
* row is inserted so the added organization's seats don't get double-counted.
*/
await ScaleSeats(provider, organization.PlanType, organization.Seats!.Value);
@ -226,7 +233,7 @@ public class ProviderBillingService(
var providerCustomer = await subscriberService.GetCustomerOrThrow(provider, new CustomerGetOptions
{
Expand = ["tax_ids"]
Expand = ["tax", "tax_ids"]
});
var providerTaxId = providerCustomer.TaxIds.FirstOrDefault();
@ -274,6 +281,13 @@ public class ProviderBillingService(
]
};
var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge && providerCustomer.Address is not { Country: "US" })
{
customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse;
}
var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions);
organization.GatewayCustomerId = customer.Id;
@ -312,7 +326,6 @@ public class ProviderBillingService(
return memoryStream.ToArray();
}
[RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
public async Task<IEnumerable<AddableOrganization>> GetAddableOrganizations(
Provider provider,
Guid userId)
@ -466,7 +479,8 @@ public class ProviderBillingService(
public async Task<Customer> SetupCustomer(
Provider provider,
TaxInfo taxInfo)
TaxInfo taxInfo,
TokenizedPaymentSource tokenizedPaymentSource = null)
{
if (taxInfo is not
{
@ -510,6 +524,13 @@ public class ProviderBillingService(
}
};
var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge && taxInfo.BillingAddressCountry != "US")
{
options.TaxExempt = StripeConstants.TaxExempt.Reverse;
}
if (!string.IsNullOrEmpty(taxInfo.TaxIdNumber))
{
var taxIdType = taxService.GetStripeTaxCode(
@ -521,6 +542,7 @@ public class ProviderBillingService(
logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.",
taxInfo.BillingAddressCountry,
taxInfo.TaxIdNumber);
throw new BadRequestException("billingTaxIdTypeInferenceError");
}
@ -535,13 +557,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;
}
}
}
}
}
@ -583,34 +689,71 @@ 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());
int? trialPeriodDays = provider.Type switch
{
ProviderType.Msp when usePaymentMethod => 14,
ProviderType.BusinessUnit when usePaymentMethod => 4,
_ => null
};
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<string, string>
{
{ "providerId", provider.Id.ToString() }
},
OffSession = true,
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
TrialPeriodDays = trialPeriodDays
};
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
{
automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer);
}
else
var setNonUSBusinessUseToReverseCharge =
featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge)
{
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
}
else if (customer.HasRecognizedTaxLocation())
{
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = customer.Address.Country == "US" ||
customer.TaxIds.Any()
};
}
try
{
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;
}

View File

@ -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));
}
}

View File

@ -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<ScimInviteOrganizationUsersResponse> success => success.Value.InvitedUser.Id,
Failure<ScimInviteOrganizationUsersResponse> failure when failure.Errors
.Any(x => x.Message == NoUsersToInviteError.Code) => (Guid?)null,
Failure<ScimInviteOrganizationUsersResponse> failure when failure.Errors.Length != 0 => throw MapToBitException(failure.Errors),
Failure<ScimInviteOrganizationUsersResponse> { Error.Message: NoUsersToInviteError.Code } => (Guid?)null,
Failure<ScimInviteOrganizationUsersResponse> failure => throw MapToBitException(failure.Error),
_ => throw new InvalidOperationException()
};

View File

@ -15,7 +15,7 @@
},
"devDependencies": {
"css-loader": "7.1.2",
"expose-loader": "5.0.0",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.85.0",
"sass-loader": "16.0.4",
@ -1083,9 +1083,9 @@
}
},
"node_modules/expose-loader": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-5.0.0.tgz",
"integrity": "sha512-BtUqYRmvx1bEY5HN6eK2I9URUZgNmN0x5UANuocaNjXSgfoDlkXt+wyEMe7i5DzDNh2BKJHPc5F4rBwEdSQX6w==",
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-5.0.1.tgz",
"integrity": "sha512-5YPZuszN/eWND/B+xuq5nIpb/l5TV1HYmdO6SubYtHv+HenVw9/6bn33Mm5reY8DNid7AVtbARvyUD34edfCtg==",
"dev": true,
"license": "MIT",
"engines": {

View File

@ -14,7 +14,7 @@
},
"devDependencies": {
"css-loader": "7.1.2",
"expose-loader": "5.0.0",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.85.0",
"sass-loader": "16.0.4",

View File

@ -1,4 +1,5 @@
using Bit.Commercial.Core.AdminConsole.Providers;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
@ -223,31 +224,115 @@ public class RemoveOrganizationFromProviderCommandTests
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Description == string.Empty &&
options.Email == organization.BillingEmail &&
options.Expand[0] == "tax" &&
options.Expand[1] == "tax_ids")).Returns(new Customer
{
Id = "customer_id",
Address = new Address
{
Country = "US"
}
});
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
{
Id = "subscription_id"
});
sutProvider.GetDependency<IAutomaticTaxStrategy>()
.When(x => x.SetCreateOptions(
Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == organization.GatewayCustomerId &&
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
options.DaysUntilDue == 30 &&
options.Metadata["organizationId"] == organization.Id.ToString() &&
options.OffSession == true &&
options.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations &&
options.Items.First().Price == teamsMonthlyPlan.PasswordManager.StripeSeatPlanId &&
options.Items.First().Quantity == organization.Seats)
, Arg.Any<Customer>()))
.Do(x =>
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == organization.GatewayCustomerId &&
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
options.DaysUntilDue == 30 &&
options.AutomaticTax.Enabled == true &&
options.Metadata["organizationId"] == organization.Id.ToString() &&
options.OffSession == true &&
options.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations &&
options.Items.First().Price == teamsMonthlyPlan.PasswordManager.StripeSeatPlanId &&
options.Items.First().Quantity == organization.Seats));
await sutProvider.GetDependency<IProviderBillingService>().Received(1)
.ScaleSeats(provider, organization.PlanType, -organization.Seats ?? 0);
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(
org =>
org.BillingEmail == "a@example.com" &&
org.GatewaySubscriptionId == "subscription_id" &&
org.Status == OrganizationStatusType.Created));
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
.DeleteAsync(providerOrganization);
await sutProvider.GetDependency<IEventService>().Received(1)
.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
await sutProvider.GetDependency<IMailService>().Received(1)
.SendProviderUpdatePaymentMethod(
organization.Id,
organization.Name,
provider.Name,
Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com"));
}
[Theory, BitAutoData]
public async Task RemoveOrganizationFromProvider_OrganizationStripeEnabled_ConsolidatedBilling_ReverseCharge_MakesCorrectInvocations(
Provider provider,
ProviderOrganization providerOrganization,
Organization organization,
SutProvider<RemoveOrganizationFromProviderCommand> sutProvider)
{
provider.Status = ProviderStatusType.Billable;
providerOrganization.ProviderId = provider.Id;
organization.Status = OrganizationStatusType.Managed;
organization.PlanType = PlanType.TeamsMonthly;
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>().HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId,
[],
includeProvider: false)
.Returns(true);
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns([
"a@example.com",
"b@example.com"
]);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Description == string.Empty &&
options.Email == organization.BillingEmail &&
options.Expand[0] == "tax" &&
options.Expand[1] == "tax_ids")).Returns(new Customer
{
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions
Id = "customer_id",
Address = new Address
{
Enabled = true
};
Country = "US"
}
});
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
{
Id = "subscription_id"
});
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true);
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>

View File

@ -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<ProviderService> sutProvider)
{
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => 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<BadRequestException>(
() => 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<ProviderService> sutProvider)
{
providerUser.ProviderId = provider.Id;
providerUser.UserId = user.Id;
var userService = sutProvider.GetDependency<IUserService>();
userService.GetUserByIdAsync(user.Id).Returns(user);
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
sutProvider.GetDependency<IDataProtectionProvider>().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<BadRequestException>(() =>
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<ProviderService> sutProvider)
{
providerUser.ProviderId = provider.Id;
providerUser.UserId = user.Id;
var userService = sutProvider.GetDependency<IUserService>();
userService.GetUserByIdAsync(user.Id).Returns(user);
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
.Returns(protector);
sutProvider.Create();
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay };
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
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<ProviderService> sutProvider)
{
@ -75,7 +150,7 @@ public class ProviderServiceTests
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
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<IProviderRepository>().Received().UpsertAsync(Arg.Is<Provider>(
p =>

View File

@ -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;
@ -252,7 +262,7 @@ public class ProviderBillingServiceTests
};
sutProvider.GetDependency<ISubscriberService>().GetCustomerOrThrow(provider, Arg.Is<CustomerGetOptions>(
options => options.Expand.FirstOrDefault() == "tax_ids"))
options => options.Expand.Contains("tax") && options.Expand.Contains("tax_ids")))
.Returns(providerCustomer);
sutProvider.GetDependency<IGlobalSettings>().BaseServiceUri
@ -302,6 +312,91 @@ public class ProviderBillingServiceTests
org => org.GatewayCustomerId == "customer_id"));
}
[Theory, BitAutoData]
public async Task CreateCustomer_ForClientOrg_ReverseCharge_Succeeds(
Provider provider,
Organization organization,
SutProvider<ProviderBillingService> sutProvider)
{
organization.GatewayCustomerId = null;
organization.Name = "Name";
organization.BusinessName = "BusinessName";
var providerCustomer = new Customer
{
Address = new Address
{
Country = "CA",
PostalCode = "12345",
Line1 = "123 Main St.",
Line2 = "Unit 4",
City = "Fake Town",
State = "Fake State"
},
TaxIds = new StripeList<TaxId>
{
Data =
[
new TaxId { Type = "TYPE", Value = "VALUE" }
]
}
};
sutProvider.GetDependency<ISubscriberService>().GetCustomerOrThrow(provider, Arg.Is<CustomerGetOptions>(
options => options.Expand.Contains("tax") && options.Expand.Contains("tax_ids")))
.Returns(providerCustomer);
sutProvider.GetDependency<IGlobalSettings>().BaseServiceUri
.Returns(new Bit.Core.Settings.GlobalSettings.BaseServiceUriSettings(new Bit.Core.Settings.GlobalSettings())
{
CloudRegion = "US"
});
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(
options =>
options.Address.Country == providerCustomer.Address.Country &&
options.Address.PostalCode == providerCustomer.Address.PostalCode &&
options.Address.Line1 == providerCustomer.Address.Line1 &&
options.Address.Line2 == providerCustomer.Address.Line2 &&
options.Address.City == providerCustomer.Address.City &&
options.Address.State == providerCustomer.Address.State &&
options.Name == organization.DisplayName() &&
options.Description == $"{provider.Name} Client Organization" &&
options.Email == provider.BillingEmail &&
options.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Organization" &&
options.InvoiceSettings.CustomFields.FirstOrDefault().Value == "Name" &&
options.Metadata["region"] == "US" &&
options.TaxIdData.FirstOrDefault().Type == providerCustomer.TaxIds.FirstOrDefault().Type &&
options.TaxIdData.FirstOrDefault().Value == providerCustomer.TaxIds.FirstOrDefault().Value &&
options.TaxExempt == StripeConstants.TaxExempt.Reverse))
.Returns(new Customer { Id = "customer_id" });
await sutProvider.Sut.CreateCustomerForClientOrganization(provider, organization);
await sutProvider.GetDependency<IStripeAdapter>().Received(1).CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(
options =>
options.Address.Country == providerCustomer.Address.Country &&
options.Address.PostalCode == providerCustomer.Address.PostalCode &&
options.Address.Line1 == providerCustomer.Address.Line1 &&
options.Address.Line2 == providerCustomer.Address.Line2 &&
options.Address.City == providerCustomer.Address.City &&
options.Address.State == providerCustomer.Address.State &&
options.Name == organization.DisplayName() &&
options.Description == $"{provider.Name} Client Organization" &&
options.Email == provider.BillingEmail &&
options.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Organization" &&
options.InvoiceSettings.CustomFields.FirstOrDefault().Value == "Name" &&
options.Metadata["region"] == "US" &&
options.TaxIdData.FirstOrDefault().Type == providerCustomer.TaxIds.FirstOrDefault().Type &&
options.TaxIdData.FirstOrDefault().Value == providerCustomer.TaxIds.FirstOrDefault().Value));
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Is<Organization>(
org => org.GatewayCustomerId == "customer_id"));
}
#endregion
#region GenerateClientInvoiceReport
@ -833,7 +928,7 @@ public class ProviderBillingServiceTests
}
[Theory, BitAutoData]
public async Task SetupCustomer_Success(
public async Task SetupCustomer_NoPaymentMethod_Success(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo)
@ -877,6 +972,357 @@ public class ProviderBillingServiceTests
Assert.Equivalent(expected, actual);
}
[Theory, BitAutoData]
public async Task SetupCustomer_InvalidRequiredPaymentMethod_ThrowsBillingException(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo,
TokenizedPaymentSource tokenizedPaymentSource)
{
provider.Name = "MSP";
sutProvider.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
sutProvider.GetDependency<IFeatureService>()
.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<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo)
{
provider.Name = "MSP";
sutProvider.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options =>
options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([
new SetupIntent { Id = "setup_intent_id" }
]);
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(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<StripeException>();
sutProvider.GetDependency<ISetupIntentCache>().Get(provider.Id).Returns("setup_intent_id");
await Assert.ThrowsAsync<StripeException>(() =>
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Set(provider.Id, "setup_intent_id");
await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_id", Arg.Is<SetupIntentCancelOptions>(options =>
options.CancellationReason == "abandoned"));
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Remove(provider.Id);
}
[Theory, BitAutoData]
public async Task SetupCustomer_WithPayPal_Error_Reverts(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo)
{
provider.Name = "MSP";
sutProvider.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token)
.Returns("braintree_customer_id");
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(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<StripeException>();
await Assert.ThrowsAsync<StripeException>(() =>
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
await sutProvider.GetDependency<IBraintreeGateway>().Customer.Received(1).DeleteAsync("braintree_customer_id");
}
[Theory, BitAutoData]
public async Task SetupCustomer_WithBankAccount_Success(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo)
{
provider.Name = "MSP";
sutProvider.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var expected = new Customer
{
Id = "customer_id",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
};
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options =>
options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([
new SetupIntent { Id = "setup_intent_id" }
]);
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(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<ISetupIntentCache>().Received(1).Set(provider.Id, "setup_intent_id");
}
[Theory, BitAutoData]
public async Task SetupCustomer_WithPayPal_Success(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo)
{
provider.Name = "MSP";
sutProvider.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var expected = new Customer
{
Id = "customer_id",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
};
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token)
.Returns("braintree_customer_id");
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(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<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo)
{
provider.Name = "MSP";
sutProvider.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var expected = new Customer
{
Id = "customer_id",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
};
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(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_WithCard_ReverseCharge_Success(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo)
{
provider.Name = "MSP";
sutProvider.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var expected = new Customer
{
Id = "customer_id",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
};
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true);
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(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 &&
o.TaxExempt == StripeConstants.TaxExempt.Reverse))
.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<ProviderBillingService> sutProvider,
@ -1002,7 +1448,7 @@ public class ProviderBillingServiceTests
.Returns(new Customer
{
Id = "customer_id",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
Address = new Address { Country = "US" }
});
var providerPlans = new List<ProviderPlan>
@ -1044,7 +1490,7 @@ public class ProviderBillingServiceTests
}
[Theory, BitAutoData]
public async Task SetupSubscription_Succeeds(
public async Task SetupSubscription_SendInvoice_Succeeds(
SutProvider<ProviderBillingService> sutProvider,
Provider provider)
{
@ -1054,7 +1500,7 @@ public class ProviderBillingServiceTests
var customer = new Customer
{
Id = "customer_id",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
Address = new Address { Country = "US" }
};
sutProvider.GetDependency<ISubscriberService>()
.GetCustomerOrThrow(
@ -1094,19 +1540,6 @@ public class ProviderBillingServiceTests
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IAutomaticTaxStrategy>()
.When(x => x.SetCreateOptions(
Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == "customer_id")
, Arg.Is<Customer>(p => p == customer)))
.Do(x =>
{
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
};
});
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
sub =>
sub.AutomaticTax.Enabled == true &&
@ -1127,6 +1560,350 @@ public class ProviderBillingServiceTests
Assert.Equivalent(expected, actual);
}
[Theory, BitAutoData]
public async Task SetupSubscription_ChargeAutomatically_HasCard_Succeeds(
SutProvider<ProviderBillingService> sutProvider,
Provider provider)
{
provider.Type = ProviderType.Msp;
provider.GatewaySubscriptionId = null;
var customer = new Customer
{
Id = "customer_id",
Address = new Address { Country = "US" },
InvoiceSettings = new CustomerInvoiceSettings
{
DefaultPaymentMethodId = "pm_123"
}
};
sutProvider.GetDependency<ISubscriberService>()
.GetCustomerOrThrow(
provider,
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))).Returns(customer);
var providerPlans = new List<ProviderPlan>
{
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<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
}
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
.Returns(providerPlans);
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
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<ProviderBillingService> sutProvider,
Provider provider)
{
provider.Type = ProviderType.Msp;
provider.GatewaySubscriptionId = null;
var customer = new Customer
{
Id = "customer_id",
Address = new Address { Country = "US" },
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
sutProvider.GetDependency<ISubscriberService>()
.GetCustomerOrThrow(
provider,
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))).Returns(customer);
var providerPlans = new List<ProviderPlan>
{
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<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
}
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
.Returns(providerPlans);
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
const string setupIntentId = "seti_123";
sutProvider.GetDependency<ISetupIntentCache>().Get(provider.Id).Returns(setupIntentId);
sutProvider.GetDependency<IStripeAdapter>().SetupIntentGet(setupIntentId, Arg.Is<SetupIntentGetOptions>(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<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
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<ProviderBillingService> sutProvider,
Provider provider)
{
provider.Type = ProviderType.Msp;
provider.GatewaySubscriptionId = null;
var customer = new Customer
{
Id = "customer_id",
Address = new Address
{
Country = "US"
},
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>
{
["btCustomerId"] = "braintree_customer_id"
}
};
sutProvider.GetDependency<ISubscriberService>()
.GetCustomerOrThrow(
provider,
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))).Returns(customer);
var providerPlans = new List<ProviderPlan>
{
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<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
}
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
.Returns(providerPlans);
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
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_ReverseCharge_Succeeds(
SutProvider<ProviderBillingService> sutProvider,
Provider provider)
{
provider.Type = ProviderType.Msp;
provider.GatewaySubscriptionId = null;
var customer = new Customer
{
Id = "customer_id",
Address = new Address { Country = "CA" },
InvoiceSettings = new CustomerInvoiceSettings
{
DefaultPaymentMethodId = "pm_123"
}
};
sutProvider.GetDependency<ISubscriberService>()
.GetCustomerOrThrow(
provider,
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))).Returns(customer);
var providerPlans = new List<ProviderPlan>
{
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<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
}
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
.Returns(providerPlans);
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
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

View File

@ -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;

View File

@ -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<MaxProjectsQuery> sutProvider,
Guid organizationId)
{
sutProvider.GetDependency<IGlobalSettings>().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<MaxProjectsQuery> sutProvider,
Guid organizationId)
{
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(default).ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(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<MaxProjectsQuery> sutProvider, Organization organization)
{
organization.PlanType = planType;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
await Assert.ThrowsAsync<BadRequestException>(
async () => await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1));
await sutProvider.GetDependency<IProjectRepository>()
.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<MaxProjectsQuery> sutProvider, Organization organization)
{
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
organization.PlanType = planType;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IPricingClient>().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<IProjectRepository>().GetProjectCountByOrganizationIdAsync(organization.Id)
.Returns(projects);
sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType));
var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, projectsToAdd);
Assert.NotNull(max);

View File

@ -11,6 +11,7 @@ MAILCATCHER_PORT=1080
# Alternative databases
POSTGRES_PASSWORD=SET_A_PASSWORD_HERE_123
MYSQL_ROOT_PASSWORD=SET_A_PASSWORD_HERE_123
MARIADB_ROOT_PASSWORD=SET_A_PASSWORD_HERE_123
# IdP configuration
# Complete using the values from the Manage SSO page in the web vault

View File

@ -70,6 +70,20 @@ services:
profiles:
- mysql
mariadb:
image: mariadb:10
ports:
- 4306:3306
environment:
MARIADB_USER: maria
MARIADB_PASSWORD: ${MARIADB_ROOT_PASSWORD}
MARIADB_DATABASE: vault_dev
MARIADB_RANDOM_ROOT_PASSWORD: "true"
volumes:
- mariadb_dev_data:/var/lib/mysql
profiles:
- mariadb
idp:
image: kenchan0130/simplesamlphp:1.19.8
container_name: idp
@ -124,8 +138,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:

View File

@ -5,6 +5,7 @@ param(
[switch]$all,
[switch]$postgres,
[switch]$mysql,
[switch]$mariadb,
[switch]$mssql,
[switch]$sqlite,
[switch]$selfhost,
@ -15,11 +16,15 @@ param(
$ErrorActionPreference = "Stop"
$currentDir = Get-Location
if (!$all -and !$postgres -and !$mysql -and !$sqlite) {
function Get-IsEFDatabase {
return $postgres -or $mysql -or $mariadb -or $sqlite;
}
if (!$all -and !$(Get-IsEFDatabase)) {
$mssql = $true;
}
if ($all -or $postgres -or $mysql -or $sqlite) {
if ($all -or $(Get-IsEFDatabase)) {
dotnet ef *> $null
if ($LASTEXITCODE -ne 0) {
Write-Host "Entity Framework Core tools were not found in the dotnet global tools. Attempting to install"
@ -60,9 +65,12 @@ if ($all -or $mssql) {
}
Foreach ($item in @(
@($mysql, "MySQL", "MySqlMigrations", "mySql", 2),
@($postgres, "PostgreSQL", "PostgresMigrations", "postgreSql", 0),
@($sqlite, "SQLite", "SqliteMigrations", "sqlite", 1)
@($sqlite, "SQLite", "SqliteMigrations", "sqlite", 1),
@($mysql, "MySQL", "MySqlMigrations", "mySql", 2),
# MariaDB shares the MySQL connection string in the server config so they are mutually exclusive in that context.
# However they can still be run independently for integration tests.
@($mariadb, "MariaDB", "MySqlMigrations", "mySql", 3)
)) {
if (!$item[0] -and !$all) {
continue

View File

@ -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"
}
}

View File

@ -40,8 +40,6 @@ export function authenticate(
payload["deviceName"] = "chrome";
payload["username"] = username;
payload["password"] = password;
params.headers["Auth-Email"] = encoding.b64encode(username);
} else {
payload["scope"] = "api.organization";
payload["grant_type"] = "client_credentials";

View File

@ -481,6 +481,7 @@ public class OrganizationsController : Controller
organization.UsersGetPremium = model.UsersGetPremium;
organization.UseSecretsManager = model.UseSecretsManager;
organization.UseRiskInsights = model.UseRiskInsights;
organization.UseOrganizationDomains = model.UseOrganizationDomains;
organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies;
//secrets

View File

@ -470,6 +470,19 @@ public class ProvidersController : Controller
[RequirePermission(Permission.Provider_Edit)]
public async Task<IActionResult> Delete(Guid id, string providerName)
{
var provider = await _providerRepository.GetByIdAsync(id);
if (provider is null)
{
return BadRequest("Provider does not exist");
}
if (provider.Status == ProviderStatusType.Pending)
{
await _providerService.DeleteAsync(provider);
return NoContent();
}
if (string.IsNullOrWhiteSpace(providerName))
{
return BadRequest("Invalid provider name");
@ -482,13 +495,6 @@ public class ProvidersController : Controller
return BadRequest("You must unlink all clients before you can delete a provider");
}
var provider = await _providerRepository.GetByIdAsync(id);
if (provider is null)
{
return BadRequest("Provider does not exist");
}
if (!string.Equals(providerName.Trim(), provider.DisplayName(), StringComparison.OrdinalIgnoreCase))
{
return BadRequest("Invalid provider name");

View File

@ -102,7 +102,7 @@ public class OrganizationEditModel : OrganizationViewModel
MaxAutoscaleSmSeats = org.MaxAutoscaleSmSeats;
SmServiceAccounts = org.SmServiceAccounts;
MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts;
UseOrganizationDomains = org.UseOrganizationDomains;
_plans = plans;
}
@ -186,6 +186,8 @@ public class OrganizationEditModel : OrganizationViewModel
public int? SmServiceAccounts { get; set; }
[Display(Name = "Max Autoscale Machine Accounts")]
public int? MaxAutoscaleSmServiceAccounts { get; set; }
[Display(Name = "Use Organization Domains")]
public bool UseOrganizationDomains { get; set; }
/**
* Creates a Plan[] object for use in Javascript
@ -215,6 +217,7 @@ public class OrganizationEditModel : OrganizationViewModel
Has2fa = p.Has2fa,
HasApi = p.HasApi,
HasSso = p.HasSso,
HasOrganizationDomains = p.HasOrganizationDomains,
HasKeyConnector = p.HasKeyConnector,
HasScim = p.HasScim,
HasResetPassword = p.HasResetPassword,
@ -315,6 +318,7 @@ public class OrganizationEditModel : OrganizationViewModel
existingOrganization.MaxAutoscaleSmSeats = MaxAutoscaleSmSeats;
existingOrganization.SmServiceAccounts = SmServiceAccounts;
existingOrganization.MaxAutoscaleSmServiceAccounts = MaxAutoscaleSmServiceAccounts;
existingOrganization.UseOrganizationDomains = UseOrganizationDomains;
return existingOrganization;
}
}

View File

@ -183,17 +183,29 @@
<div class="p-3">
<h4 class="fw-bolder" id="exampleModalLabel">Delete provider</h4>
</div>
<div class="modal-body">
<span class="fw-light">
This action is permanent and irreversible. Enter the provider name to complete deletion of the provider and associated data.
</span>
<form>
<div class="mb-3">
<label for="provider-name" class="col-form-label">Provider name</label>
<input type="text" class="form-control" id="provider-name">
</div>
</form>
</div>
@if (Model.Provider.Status == ProviderStatusType.Pending)
{
<div class="modal-body">
<span class="fw-light">
This action is permanent and irreversible.
</span>
</div>
}
else
{
<div class="modal-body">
<span class="fw-light">
This action is permanent and irreversible. Enter the provider name to complete deletion of the provider and associated data.
</span>
<form>
<div class="mb-3">
<label for="provider-name" class="col-form-label">Provider name</label>
<input type="text" class="form-control" id="provider-name">
</div>
</form>
</div>
}
<div class="modal-footer">
<button type="button" class="btn btn-outline-primary btn-pill" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger btn-pill" onclick="deleteProvider('@Model.Provider.Id');">Delete provider</button>

View File

@ -124,6 +124,10 @@
<input type="checkbox" class="form-check-input" asp-for="UseSso" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseSso"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseOrganizationDomains" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseOrganizationDomains"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseKeyConnector" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseKeyConnector"></label>

View File

@ -69,6 +69,7 @@
document.getElementById('@(nameof(Model.UseGroups))').checked = plan.hasGroups;
document.getElementById('@(nameof(Model.UsePolicies))').checked = plan.hasPolicies;
document.getElementById('@(nameof(Model.UseSso))').checked = plan.hasSso;
document.getElementById('@(nameof(Model.UseOrganizationDomains))').checked = plan.hasOrganizationDomains;
document.getElementById('@(nameof(Model.UseScim))').checked = plan.hasScim;
document.getElementById('@(nameof(Model.UseDirectory))').checked = plan.hasDirectory;
document.getElementById('@(nameof(Model.UseEvents))').checked = plan.hasEvents;

View File

@ -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<IActionResult> 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<bool?> AccountDeprovisioningEnabled(Guid userId)
{
return _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
? await _userService.IsClaimedByAnyOrganizationAsync(userId)
: null;
}
}

View File

@ -17,7 +17,7 @@ public class ChargeBraintreeModel : IValidatableObject
{
if (Id != null)
{
if (Id.Length != 36 || (Id[0] != 'o' && Id[0] != 'u') ||
if (Id.Length != 36 || (Id[0] != 'o' && Id[0] != 'u' && Id[0] != 'p') ||
!Guid.TryParse(Id.Substring(1, 32), out var guid))
{
yield return new ValidationResult("Customer Id is not a valid format.");

View File

@ -2,7 +2,7 @@
using Bit.Core;
using Bit.Core.Jobs;
using Bit.Core.Tools.Repositories;
using Bit.Core.Tools.Services;
using Bit.Core.Tools.SendFeatures.Commands.Interfaces;
using Quartz;
namespace Bit.Admin.Tools.Jobs;
@ -32,10 +32,10 @@ public class DeleteSendsJob : BaseJob
}
using (var scope = _serviceProvider.CreateScope())
{
var sendService = scope.ServiceProvider.GetRequiredService<ISendService>();
var nonAnonymousSendCommand = scope.ServiceProvider.GetRequiredService<INonAnonymousSendCommand>();
foreach (var send in sends)
{
await sendService.DeleteSendAsync(send);
await nonAnonymousSendCommand.DeleteSendAsync(send);
}
}
}

View File

@ -16,7 +16,7 @@
},
"devDependencies": {
"css-loader": "7.1.2",
"expose-loader": "5.0.0",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.85.0",
"sass-loader": "16.0.4",
@ -1084,9 +1084,9 @@
}
},
"node_modules/expose-loader": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-5.0.0.tgz",
"integrity": "sha512-BtUqYRmvx1bEY5HN6eK2I9URUZgNmN0x5UANuocaNjXSgfoDlkXt+wyEMe7i5DzDNh2BKJHPc5F4rBwEdSQX6w==",
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-5.0.1.tgz",
"integrity": "sha512-5YPZuszN/eWND/B+xuq5nIpb/l5TV1HYmdO6SubYtHv+HenVw9/6bn33Mm5reY8DNid7AVtbARvyUD34edfCtg==",
"dev": true,
"license": "MIT",
"engines": {

View File

@ -15,7 +15,7 @@
},
"devDependencies": {
"css-loader": "7.1.2",
"expose-loader": "5.0.0",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.85.0",
"sass-loader": "16.0.4",

View File

@ -9,7 +9,7 @@ namespace Bit.Api.AdminConsole.Authorization;
public static class HttpContextExtensions
{
public const string NoOrgIdError =
"A route decorated with with '[Authorize<Requirement>]' must include a route value named 'orgId' either through the [Controller] attribute or through a '[Http*]' attribute.";
"A route decorated with with '[Authorize<Requirement>]' must include a route value named 'orgId' or 'organizationId' either through the [Controller] attribute or through a '[Http*]' attribute.";
/// <summary>
/// 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
/// <summary>
/// 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.
/// </summary>
/// <param name="httpContext"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
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);
}
}

View File

@ -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<bool> AuthorizeAsync(
CurrentContextOrganization? organizationClaims,
Func<Task<bool>> isProviderUserForOrg)
=> organizationClaims switch
{
{ Type: OrganizationUserType.Owner } => true,
{ Type: OrganizationUserType.Admin } => true,
{ Permissions.ManageResetPassword: true } => true,
_ => await isProviderUserForOrg()
};
}

View File

@ -1,13 +1,16 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")]
[Authorize("Application")]
public class OrganizationIntegrationConfigurationController(

View File

@ -1,8 +1,10 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -10,6 +12,7 @@ using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations/{organizationId:guid}/integrations")]
[Authorize("Application")]
public class OrganizationIntegrationController(

View File

@ -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<ListResponseModel<OrganizationUserUserDetailsResponseModel>> 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<OrganizationUserUserDetailsResponseModel>(responses);
}
private async Task<ListResponseModel<OrganizationUserUserDetailsResponseModel>> 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<OrganizationUserUserDetailsResponseModel> GetResultListResponseModel(IEnumerable<(OrganizationUserUserDetails OrgUser,
bool TwoFactorEnabled, bool ClaimedByOrganization)> results)
{
return new ListResponseModel<OrganizationUserUserDetailsResponseModel>(results
.Select(result => new OrganizationUserUserDetailsResponseModel(result))
.ToList());
}
[HttpGet("{id}/groups")]
public async Task<IEnumerable<string>> 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<ListResponseModel<OrganizationUserBulkResponseModel>> BulkDeleteAccount(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
@ -719,11 +758,6 @@ public class OrganizationUsersController : Controller
private async Task<IDictionary<Guid, bool>> GetClaimedByOrganizationStatusAsync(Guid orgId, IEnumerable<Guid> userIds)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
{
return userIds.ToDictionary(kvp => kvp, kvp => false);
}
var usersOrganizationClaimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(orgId, userIds);
return usersOrganizationClaimedStatus;
}

View File

@ -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.");
}

View File

@ -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);
}

View File

@ -1,6 +1,5 @@
using Bit.Api.Billing.Controllers;
using Bit.Api.Billing.Models.Requests;
using Bit.Core;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Services;
@ -17,7 +16,6 @@ namespace Bit.Api.AdminConsole.Controllers;
[Route("providers/{providerId:guid}/clients")]
public class ProviderClientsController(
ICurrentContext currentContext,
IFeatureService featureService,
ILogger<BaseProviderController> logger,
IOrganizationRepository organizationRepository,
IProviderBillingService providerBillingService,
@ -140,11 +138,6 @@ public class ProviderClientsController(
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IResult> GetAddableOrganizationsAsync([FromRoute] Guid providerId)
{
if (!featureService.IsEnabled(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal))
{
return Error.NotFound();
}
var (provider, result) = await TryGetBillableProviderForServiceUserOperation(providerId);
if (provider == null)

View File

@ -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);
}

View File

@ -1,5 +1,6 @@
using System.Text.Json;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Context;
using Bit.Core.Enums;
@ -7,11 +8,13 @@ using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Integrations;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations/{organizationId:guid}/integrations/slack")]
[Authorize("Application")]
public class SlackIntegrationController(

View File

@ -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);

View File

@ -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)
{

View File

@ -64,6 +64,7 @@ public class OrganizationResponseModel : ResponseModel
LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UseRiskInsights = organization.UseRiskInsights;
UseOrganizationDomains = organization.UseOrganizationDomains;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
}
@ -111,6 +112,7 @@ public class OrganizationResponseModel : ResponseModel
public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; }
public bool UseRiskInsights { get; set; }
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
}

View File

@ -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)

View File

@ -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();
@ -72,6 +73,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UserIsClaimedByOrganization = organizationIdsClaimingUser.Contains(organization.OrganizationId);
UseRiskInsights = organization.UseRiskInsights;
UseOrganizationDomains = organization.UseOrganizationDomains;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
if (organization.SsoConfig != null)
@ -135,7 +137,6 @@ public class ProfileOrganizationResponseModel : ResponseModel
public bool AllowAdminAccessToAllCollectionItems { get; set; }
/// <summary>
/// Obsolete.
///
/// See <see cref="UserIsClaimedByOrganization"/>
/// </summary>
[Obsolete("Please use UserIsClaimedByOrganization instead. This property will be removed in a future version.")]
@ -145,16 +146,15 @@ public class ProfileOrganizationResponseModel : ResponseModel
set => UserIsClaimedByOrganization = value;
}
/// <summary>
/// Indicates if the organization claims the user.
/// Indicates if the user is claimed by the organization.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <returns>
/// False if the Account Deprovisioning feature flag is disabled.
/// </returns>
public bool UserIsClaimedByOrganization { get; set; }
public bool UseRiskInsights { get; set; }
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
public bool IsAdminInitiated { get; set; }
}

View File

@ -50,6 +50,7 @@ public class ProfileProviderOrganizationResponseModel : ProfileOrganizationRespo
LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UseRiskInsights = organization.UseRiskInsights;
UseOrganizationDomains = organization.UseOrganizationDomains;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
}
}

View File

@ -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
{

View File

@ -4,8 +4,6 @@
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
<ANCMPreConfiguredForIIS>true</ANCMPreConfiguredForIIS>
<!-- Temp exclusions until warnings are fixed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CS8604</WarningsNotAsErrors>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">

View File

@ -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<CipherWithIdRequestModel>, IEnumerable<Cipher>> _cipherValidator;
@ -68,6 +70,7 @@ public class AccountsController : Controller
ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand,
ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand,
IRotateUserKeyCommand rotateUserKeyCommand,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IFeatureService featureService,
IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> cipherValidator,
IRotationValidator<IEnumerable<FolderWithIdRequestModel>, IEnumerable<Folder>> 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)

View File

@ -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;

View File

@ -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<PaymentResponseModel> 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);

View File

@ -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;

View File

@ -1,13 +1,16 @@
#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.Enums;
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;
@ -25,6 +28,7 @@ public class OrganizationBillingController(
IFeatureService featureService,
IOrganizationBillingService organizationBillingService,
IOrganizationRepository organizationRepository,
IOrganizationWarningsQuery organizationWarningsQuery,
IPaymentService paymentService,
IPricingClient pricingClient,
ISubscriberService subscriberService,
@ -306,14 +310,17 @@ public class OrganizationBillingController(
sale.Organization.UseScim = plan.HasScim;
sale.Organization.UseCustomPermissions = plan.HasCustomPermissions;
}
await organizationBillingService.Finalize(sale);
var org = await organizationRepository.GetByIdAsync(organizationId);
if (organizationSignup.PaymentMethodType != null)
if (organizationSignup.PaymentMethodType == null || string.IsNullOrEmpty(organizationSignup.PaymentToken))
{
var paymentSource = new TokenizedPaymentSource(organizationSignup.PaymentMethodType.Value, organizationSignup.PaymentToken);
var taxInformation = TaxInformation.From(organizationSignup.TaxInfo);
await organizationBillingService.UpdatePaymentMethod(org, paymentSource, taxInformation);
return Error.BadRequest("A payment method is required to restart the subscription.");
}
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.");
var paymentSource = new TokenizedPaymentSource(organizationSignup.PaymentMethodType.Value, organizationSignup.PaymentToken);
var taxInformation = TaxInformation.From(organizationSignup.TaxInfo);
await organizationBillingService.UpdatePaymentMethod(org, paymentSource, taxInformation);
await organizationBillingService.Finalize(sale);
return TypedResults.Ok();
}
@ -353,4 +360,28 @@ public class OrganizationBillingController(
return TypedResults.Ok(providerId);
}
[HttpGet("warnings")]
public async Task<IResult> 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);
}
}

View File

@ -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")]
@ -214,6 +222,20 @@ public class OrganizationSponsorshipsController : Controller
await _revokeSponsorshipCommand.RevokeSponsorshipAsync(existingOrgSponsorship);
}
[Authorize("Application")]
[HttpDelete("{sponsoringOrgId}/{sponsoredFriendlyName}/revoke")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task AdminInitiatedRevokeSponsorshipAsync(Guid sponsoringOrgId, string sponsoredFriendlyName)
{
var sponsorships = await _organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(sponsoringOrgId);
var existingOrgSponsorship = sponsorships.FirstOrDefault(s => s.FriendlyName != null && s.FriendlyName.Equals(sponsoredFriendlyName, StringComparison.OrdinalIgnoreCase));
if (existingOrgSponsorship == null)
{
throw new BadRequestException("The specified sponsored organization could not be found under the given sponsoring organization.");
}
await _revokeSponsorshipCommand.RevokeSponsorshipAsync(existingOrgSponsorship);
}
[Authorize("Application")]
[HttpDelete("sponsored/{sponsoredOrgId}")]
[HttpPost("sponsored/{sponsoredOrgId}/remove")]
@ -246,5 +268,30 @@ public class OrganizationSponsorshipsController : Controller
return new OrganizationSponsorshipSyncStatusResponseModel(lastSyncDate);
}
[Authorize("Application")]
[HttpGet("{sponsoringOrgId}/sponsored")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<ListResponseModel<OrganizationSponsorshipInvitesResponseModel>> 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<OrganizationSponsorshipInvitesResponseModel>(
sponsorships
.Where(s => s.IsAdminInitiated)
.Select(s => new OrganizationSponsorshipInvitesResponseModel(new OrganizationSponsorshipData(s)))
);
}
private Task<User> CurrentUser => _userService.GetUserByIdAsync(_currentContext.UserId.Value);
}

View File

@ -109,28 +109,6 @@ public class OrganizationsController(
return license;
}
[HttpPost("{id:guid}/payment")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostPayment(Guid id, [FromBody] PaymentRequestModel model)
{
if (!await currentContext.EditPaymentMethods(id))
{
throw new NotFoundException();
}
await organizationService.ReplacePaymentMethodAsync(id, model.PaymentToken,
model.PaymentMethodType.Value, new TaxInfo
{
BillingAddressLine1 = model.Line1,
BillingAddressLine2 = model.Line2,
BillingAddressState = model.State,
BillingAddressCity = model.City,
BillingAddressPostalCode = model.PostalCode,
BillingAddressCountry = model.Country,
TaxIdNumber = model.TaxId,
});
}
[HttpPost("{id:guid}/upgrade")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<PaymentResponseModel> PostUpgrade(Guid id, [FromBody] OrganizationUpgradeRequestModel model)

View File

@ -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;

View File

@ -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;

View File

@ -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<IResult> 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<IResult>(
taxAmount => TypedResults.Ok(new { TaxAmount = taxAmount }),
badRequest => Error.BadRequest(badRequest.TranslationKey),
unhandled => Error.ServerError(unhandled.TranslationKey));
}
}

View File

@ -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; }
}
}

View File

@ -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;

View File

@ -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; }
}
}
}

View File

@ -1,4 +1,5 @@
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Tax.Models;
namespace Bit.Api.Billing.Models.Responses;

View File

@ -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;

View File

@ -1,4 +1,4 @@
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Tax.Models;
namespace Bit.Api.Billing.Models.Responses;

View File

@ -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<OrganizationWarningsResponse> Run(
Organization organization);
}
public class OrganizationWarningsQuery(
ICurrentContext currentContext,
IProviderRepository providerRepository,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : IOrganizationWarningsQuery
{
public async Task<OrganizationWarningsResponse> 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<FreeTrialWarning?> 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<InactiveSubscriptionWarning?> 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<ResellerRenewalWarning?> 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;
}
}

View File

@ -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<IOrganizationWarningsQuery, OrganizationWarningsQuery>();
}
}

View File

@ -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<ActionResult<ICollection<string>>> GetPhishingDomainsAsync()
{
if (!featureService.IsEnabled(FeatureFlagKeys.PhishingDetection))
{
return NotFound();
}
var domains = await phishingDomainRepository.GetActivePhishingDomainsAsync();
return Ok(domains);
}
[HttpGet("checksum")]
public async Task<ActionResult<string>> GetChecksumAsync()
{
if (!featureService.IsEnabled(FeatureFlagKeys.PhishingDetection))
{
return NotFound();
}
var checksum = await phishingDomainRepository.GetCurrentChecksumAsync();
return Ok(checksum);
}
}

View File

@ -1,6 +1,10 @@
using Bit.Api.Models.Request.Organizations;
using Bit.Api.AdminConsole.Authorization.Requirements;
using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response;
using Bit.Core.Context;
using Bit.Core.Exceptions;
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;
@ -22,6 +26,7 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
private readonly IRevokeSponsorshipCommand _revokeSponsorshipCommand;
private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService;
private readonly IAuthorizationService _authorizationService;
public SelfHostedOrganizationSponsorshipsController(
ICreateSponsorshipCommand offerSponsorshipCommand,
@ -30,7 +35,8 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
IOrganizationSponsorshipRepository organizationSponsorshipRepository,
IOrganizationUserRepository organizationUserRepository,
ICurrentContext currentContext,
IFeatureService featureService
IFeatureService featureService,
IAuthorizationService authorizationService
)
{
_offerSponsorshipCommand = offerSponsorshipCommand;
@ -40,6 +46,7 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
_organizationUserRepository = organizationUserRepository;
_currentContext = currentContext;
_featureService = featureService;
_authorizationService = authorizationService;
}
[HttpPost("{sponsoringOrgId}/families-for-enterprise")]
@ -84,4 +91,41 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
await _revokeSponsorshipCommand.RevokeSponsorshipAsync(existingOrgSponsorship);
}
[HttpDelete("{sponsoringOrgId}/{sponsoredFriendlyName}/revoke")]
public async Task AdminInitiatedRevokeSponsorshipAsync(Guid sponsoringOrgId, string sponsoredFriendlyName)
{
var sponsorships = await _organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(sponsoringOrgId);
var existingOrgSponsorship = sponsorships.FirstOrDefault(s => s.FriendlyName != null && s.FriendlyName.Equals(sponsoredFriendlyName, StringComparison.OrdinalIgnoreCase));
if (existingOrgSponsorship == null)
{
throw new BadRequestException("The specified sponsored organization could not be found under the given sponsoring organization.");
}
await _revokeSponsorshipCommand.RevokeSponsorshipAsync(existingOrgSponsorship);
}
[Authorize("Application")]
[HttpGet("{orgId}/sponsored")]
public async Task<ListResponseModel<OrganizationSponsorshipInvitesResponseModel>> GetSponsoredOrganizations(Guid orgId)
{
var sponsoringOrg = await _organizationRepository.GetByIdAsync(orgId);
if (sponsoringOrg == null)
{
throw new NotFoundException();
}
var authorizationResult = await _authorizationService.AuthorizeAsync(User, orgId, new ManageUsersRequirement());
if (!authorizationResult.Succeeded)
{
throw new UnauthorizedAccessException();
}
var sponsorships = await _organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(orgId);
return new ListResponseModel<OrganizationSponsorshipInvitesResponseModel>(
sponsorships
.Where(s => s.IsAdminInitiated)
.Select(s => new OrganizationSponsorshipInvitesResponseModel(new OrganizationSponsorshipData(s)))
);
}
}

View File

@ -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; }
/// <summary>
/// A distinct list of the cipher ids associated with
/// A distinct list of the cipher ids associated with
/// the organization member
/// </summary>
public IEnumerable<string> CipherIds { get; set; }
public MemberCipherDetailsResponseModel(MemberAccessCipherDetails memberAccessCipherDetails)
{
this.UserGuid = memberAccessCipherDetails.UserGuid;
this.UserName = memberAccessCipherDetails.UserName;
this.Email = memberAccessCipherDetails.Email;
this.UsesKeyConnector = memberAccessCipherDetails.UsesKeyConnector;

View File

@ -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<Tuple<Type, ITrigger>>
@ -68,6 +75,7 @@ public class JobsHostedService : BaseJobsHostedService
new Tuple<Type, ITrigger>(typeof(ValidateUsersJob), everyTopOfTheSixthHourTrigger),
new Tuple<Type, ITrigger>(typeof(ValidateOrganizationsJob), everyTwelfthHourAndThirtyMinutesTrigger),
new Tuple<Type, ITrigger>(typeof(ValidateOrganizationDomainJob), validateOrganizationDomainTrigger),
new Tuple<Type, ITrigger>(typeof(UpdatePhishingDomainsJob), updatePhishingDomainsTrigger),
};
if (_globalSettings.SelfHosted && _globalSettings.EnableCloudCommunication)
@ -96,6 +104,7 @@ public class JobsHostedService : BaseJobsHostedService
services.AddTransient<ValidateUsersJob>();
services.AddTransient<ValidateOrganizationsJob>();
services.AddTransient<ValidateOrganizationDomainJob>();
services.AddTransient<UpdatePhishingDomainsJob>();
}
public static void AddCommercialSecretsManagerJobServices(IServiceCollection services)

View File

@ -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<UpdatePhishingDomainsJob> 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.");
}
}
}

View File

@ -12,17 +12,17 @@ namespace Bit.Api.KeyManagement.Validators;
/// </summary>
public class SendRotationValidator : IRotationValidator<IEnumerable<SendWithIdRequestModel>, IReadOnlyList<Send>>
{
private readonly ISendService _sendService;
private readonly ISendAuthorizationService _sendAuthorizationService;
private readonly ISendRepository _sendRepository;
/// <summary>
/// Instantiates a new <see cref="SendRotationValidator"/>
/// </summary>
/// <param name="sendService">Enables conversion of <see cref="SendWithIdRequestModel"/> to <see cref="Send"/></param>
/// <param name="sendAuthorizationService">Enables conversion of <see cref="SendWithIdRequestModel"/> to <see cref="Send"/></param>
/// <param name="sendRepository">Retrieves all user <see cref="Send"/>s</param>
public SendRotationValidator(ISendService sendService, ISendRepository sendRepository)
public SendRotationValidator(ISendAuthorizationService sendAuthorizationService, ISendRepository sendRepository)
{
_sendService = sendService;
_sendAuthorizationService = sendAuthorizationService;
_sendRepository = sendRepository;
}
@ -44,7 +44,7 @@ public class SendRotationValidator : IRotationValidator<IEnumerable<SendWithIdRe
throw new BadRequestException("All existing sends must be included in the rotation.");
}
result.Add(send.ToSend(existing, _sendService));
result.Add(send.ToSend(existing, _sendAuthorizationService));
}
return result;

View File

@ -32,6 +32,7 @@ public class PlanResponseModel : ResponseModel
HasTotp = plan.HasTotp;
Has2fa = plan.Has2fa;
HasSso = plan.HasSso;
HasOrganizationDomains = plan.HasOrganizationDomains;
HasResetPassword = plan.HasResetPassword;
UsersGetPremium = plan.UsersGetPremium;
UpgradeSortOrder = plan.UpgradeSortOrder;
@ -71,6 +72,7 @@ public class PlanResponseModel : ResponseModel
public bool Has2fa { get; set; }
public bool HasApi { get; set; }
public bool HasSso { get; set; }
public bool HasOrganizationDomains { get; set; }
public bool HasResetPassword { get; set; }
public bool UsersGetPremium { get; set; }

View File

@ -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;
@ -34,6 +35,7 @@ using Bit.Core.Services;
using Bit.Core.Tools.ImportFeatures;
using Bit.Core.Tools.ReportFeatures;
using Bit.Core.Auth.Models.Api.Request;
using Bit.Core.Tools.SendFeatures;
#if !OSS
using Bit.Commercial.Core.SecretsManager;
@ -182,6 +184,10 @@ public class Startup
services.AddBillingOperations();
services.AddReportingServices();
services.AddImportServices();
services.AddPhishingDomainServices(globalSettings);
services.AddBillingQueries();
services.AddSendServices();
// Authorization Handlers
services.AddAuthorizationHandlers();

View File

@ -12,6 +12,8 @@ using Bit.Core.Settings;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Repositories;
using Bit.Core.Tools.SendFeatures;
using Bit.Core.Tools.SendFeatures.Commands.Interfaces;
using Bit.Core.Tools.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
@ -25,8 +27,10 @@ public class SendsController : Controller
{
private readonly ISendRepository _sendRepository;
private readonly IUserService _userService;
private readonly ISendService _sendService;
private readonly ISendAuthorizationService _sendAuthorizationService;
private readonly ISendFileStorageService _sendFileStorageService;
private readonly IAnonymousSendCommand _anonymousSendCommand;
private readonly INonAnonymousSendCommand _nonAnonymousSendCommand;
private readonly ILogger<SendsController> _logger;
private readonly GlobalSettings _globalSettings;
private readonly ICurrentContext _currentContext;
@ -34,7 +38,9 @@ public class SendsController : Controller
public SendsController(
ISendRepository sendRepository,
IUserService userService,
ISendService sendService,
ISendAuthorizationService sendAuthorizationService,
IAnonymousSendCommand anonymousSendCommand,
INonAnonymousSendCommand nonAnonymousSendCommand,
ISendFileStorageService sendFileStorageService,
ILogger<SendsController> logger,
GlobalSettings globalSettings,
@ -42,13 +48,16 @@ public class SendsController : Controller
{
_sendRepository = sendRepository;
_userService = userService;
_sendService = sendService;
_sendAuthorizationService = sendAuthorizationService;
_anonymousSendCommand = anonymousSendCommand;
_nonAnonymousSendCommand = nonAnonymousSendCommand;
_sendFileStorageService = sendFileStorageService;
_logger = logger;
_globalSettings = globalSettings;
_currentContext = currentContext;
}
#region Anonymous endpoints
[AllowAnonymous]
[HttpPost("access/{id}")]
public async Task<IActionResult> Access(string id, [FromBody] SendAccessRequestModel model)
@ -61,18 +70,19 @@ public class SendsController : Controller
//}
var guid = new Guid(CoreHelpers.Base64UrlDecode(id));
var (send, passwordRequired, passwordInvalid) =
await _sendService.AccessAsync(guid, model.Password);
if (passwordRequired)
var send = await _sendRepository.GetByIdAsync(guid);
SendAccessResult sendAuthResult =
await _sendAuthorizationService.AccessAsync(send, model.Password);
if (sendAuthResult.Equals(SendAccessResult.PasswordRequired))
{
return new UnauthorizedResult();
}
if (passwordInvalid)
if (sendAuthResult.Equals(SendAccessResult.PasswordInvalid))
{
await Task.Delay(2000);
throw new BadRequestException("Invalid password.");
}
if (send == null)
if (sendAuthResult.Equals(SendAccessResult.Denied))
{
throw new NotFoundException();
}
@ -106,19 +116,19 @@ public class SendsController : Controller
throw new BadRequestException("Could not locate send");
}
var (url, passwordRequired, passwordInvalid) = await _sendService.GetSendFileDownloadUrlAsync(send, fileId,
var (url, result) = await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId,
model.Password);
if (passwordRequired)
if (result.Equals(SendAccessResult.PasswordRequired))
{
return new UnauthorizedResult();
}
if (passwordInvalid)
if (result.Equals(SendAccessResult.PasswordInvalid))
{
await Task.Delay(2000);
throw new BadRequestException("Invalid password.");
}
if (send == null)
if (result.Equals(SendAccessResult.Denied))
{
throw new NotFoundException();
}
@ -130,6 +140,45 @@ public class SendsController : Controller
});
}
[AllowAnonymous]
[HttpPost("file/validate/azure")]
public async Task<ObjectResult> AzureValidateFile()
{
return await ApiHelpers.HandleAzureEvents(Request, new Dictionary<string, Func<EventGridEvent, Task>>
{
{
"Microsoft.Storage.BlobCreated", async (eventGridEvent) =>
{
try
{
var blobName = eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1];
var sendId = AzureSendFileStorageService.SendIdFromBlobName(blobName);
var send = await _sendRepository.GetByIdAsync(new Guid(sendId));
if (send == null)
{
if (_sendFileStorageService is AzureSendFileStorageService azureSendFileStorageService)
{
await azureSendFileStorageService.DeleteBlobAsync(blobName);
}
return;
}
await _nonAnonymousSendCommand.ConfirmFileSize(send);
}
catch (Exception e)
{
_logger.LogError(e, $"Uncaught exception occurred while handling event grid event: {JsonSerializer.Serialize(eventGridEvent)}");
return;
}
}
}
});
}
#endregion
#region Non-anonymous endpoints
[HttpGet("{id}")]
public async Task<SendResponseModel> Get(string id)
{
@ -157,8 +206,8 @@ public class SendsController : Controller
{
model.ValidateCreation();
var userId = _userService.GetProperUserId(User).Value;
var send = model.ToSend(userId, _sendService);
await _sendService.SaveSendAsync(send);
var send = model.ToSend(userId, _sendAuthorizationService);
await _nonAnonymousSendCommand.SaveSendAsync(send);
return new SendResponseModel(send, _globalSettings);
}
@ -175,15 +224,15 @@ public class SendsController : Controller
throw new BadRequestException("Invalid content. File size hint is required.");
}
if (model.FileLength.Value > SendService.MAX_FILE_SIZE)
if (model.FileLength.Value > Constants.FileSize501mb)
{
throw new BadRequestException($"Max file size is {SendService.MAX_FILE_SIZE_READABLE}.");
throw new BadRequestException($"Max file size is {SendFileSettingHelper.MAX_FILE_SIZE_READABLE}.");
}
model.ValidateCreation();
var userId = _userService.GetProperUserId(User).Value;
var (send, data) = model.ToSend(userId, model.File.FileName, _sendService);
var uploadUrl = await _sendService.SaveFileSendAsync(send, data, model.FileLength.Value);
var (send, data) = model.ToSend(userId, model.File.FileName, _sendAuthorizationService);
var uploadUrl = await _nonAnonymousSendCommand.SaveFileSendAsync(send, data, model.FileLength.Value);
return new SendFileUploadDataResponseModel
{
Url = uploadUrl,
@ -230,41 +279,7 @@ public class SendsController : Controller
var send = await _sendRepository.GetByIdAsync(new Guid(id));
await Request.GetFileAsync(async (stream) =>
{
await _sendService.UploadFileToExistingSendAsync(stream, send);
});
}
[AllowAnonymous]
[HttpPost("file/validate/azure")]
public async Task<ObjectResult> AzureValidateFile()
{
return await ApiHelpers.HandleAzureEvents(Request, new Dictionary<string, Func<EventGridEvent, Task>>
{
{
"Microsoft.Storage.BlobCreated", async (eventGridEvent) =>
{
try
{
var blobName = eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1];
var sendId = AzureSendFileStorageService.SendIdFromBlobName(blobName);
var send = await _sendRepository.GetByIdAsync(new Guid(sendId));
if (send == null)
{
if (_sendFileStorageService is AzureSendFileStorageService azureSendFileStorageService)
{
await azureSendFileStorageService.DeleteBlobAsync(blobName);
}
return;
}
await _sendService.ValidateSendFile(send);
}
catch (Exception e)
{
_logger.LogError(e, $"Uncaught exception occurred while handling event grid event: {JsonSerializer.Serialize(eventGridEvent)}");
return;
}
}
}
await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send);
});
}
@ -279,7 +294,7 @@ public class SendsController : Controller
throw new NotFoundException();
}
await _sendService.SaveSendAsync(model.ToSend(send, _sendService));
await _nonAnonymousSendCommand.SaveSendAsync(model.ToSend(send, _sendAuthorizationService));
return new SendResponseModel(send, _globalSettings);
}
@ -294,7 +309,7 @@ public class SendsController : Controller
}
send.Password = null;
await _sendService.SaveSendAsync(send);
await _nonAnonymousSendCommand.SaveSendAsync(send);
return new SendResponseModel(send, _globalSettings);
}
@ -308,6 +323,8 @@ public class SendsController : Controller
throw new NotFoundException();
}
await _sendService.DeleteSendAsync(send);
await _nonAnonymousSendCommand.DeleteSendAsync(send);
}
#endregion
}

View File

@ -36,31 +36,31 @@ public class SendRequestModel
public bool? Disabled { get; set; }
public bool? HideEmail { get; set; }
public Send ToSend(Guid userId, ISendService sendService)
public Send ToSend(Guid userId, ISendAuthorizationService sendAuthorizationService)
{
var send = new Send
{
Type = Type,
UserId = (Guid?)userId
};
ToSend(send, sendService);
ToSend(send, sendAuthorizationService);
return send;
}
public (Send, SendFileData) ToSend(Guid userId, string fileName, ISendService sendService)
public (Send, SendFileData) ToSend(Guid userId, string fileName, ISendAuthorizationService sendAuthorizationService)
{
var send = ToSendBase(new Send
{
Type = Type,
UserId = (Guid?)userId
}, sendService);
}, sendAuthorizationService);
var data = new SendFileData(Name, Notes, fileName);
return (send, data);
}
public Send ToSend(Send existingSend, ISendService sendService)
public Send ToSend(Send existingSend, ISendAuthorizationService sendAuthorizationService)
{
existingSend = ToSendBase(existingSend, sendService);
existingSend = ToSendBase(existingSend, sendAuthorizationService);
switch (existingSend.Type)
{
case SendType.File:
@ -125,7 +125,7 @@ public class SendRequestModel
}
}
private Send ToSendBase(Send existingSend, ISendService sendService)
private Send ToSendBase(Send existingSend, ISendAuthorizationService authorizationService)
{
existingSend.Key = Key;
existingSend.ExpirationDate = ExpirationDate;
@ -133,7 +133,7 @@ public class SendRequestModel
existingSend.MaxAccessCount = MaxAccessCount;
if (!string.IsNullOrWhiteSpace(Password))
{
existingSend.Password = sendService.HashPassword(Password);
existingSend.Password = authorizationService.HashPassword(Password);
}
existingSend.Disabled = Disabled.GetValueOrDefault();
existingSend.HideEmail = HideEmail.GetValueOrDefault();

View File

@ -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<T>(this CommandResult<T> commandResult)
{
return commandResult switch
{
NoRecordFoundFailure<T> failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status404NotFound },
BadRequestFailure<T> failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest },
Failure<T> failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest },
Success<T> 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}")
};
}
}

View File

@ -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<IAuthorizationHandler, OrganizationRequirementHandler>();
}
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<AzurePhishingDomainStorageService>();
services.AddSingleton<IPhishingDomainRepository, AzurePhishingDomainRepository>();
if (globalSettings.SelfHosted)
{
services.AddScoped<ICloudPhishingDomainQuery, CloudPhishingDomainRelayQuery>();
}
else
{
services.AddScoped<ICloudPhishingDomainQuery, CloudPhishingDomainDirectQuery>();
}
}
}

View File

@ -315,26 +315,10 @@ public class CiphersController : Controller
{
var org = _currentContext.GetOrganization(organizationId);
// If we're not an "admin", we don't need to check the ciphers
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }))
// If we're not an "admin" or if we're not a provider user we don't need to check the ciphers
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }) || await _currentContext.ProviderUserForOrgAsync(organizationId))
{
// Are we a provider user? If so, we need to be sure we're not restricted
// Once the feature flag is removed, this check can be combined with the above
if (await _currentContext.ProviderUserForOrgAsync(organizationId))
{
// Provider is restricted from editing ciphers, so we're not an "admin"
if (_featureService.IsEnabled(FeatureFlagKeys.RestrictProviderAccess))
{
return false;
}
// Provider is unrestricted, so we're an "admin", don't return early
}
else
{
// Not a provider or admin
return false;
}
return false;
}
// We know we're an "admin", now check the ciphers explicitly (in case admins are restricted)
@ -350,26 +334,10 @@ public class CiphersController : Controller
var org = _currentContext.GetOrganization(organizationId);
// If we're not an "admin", we don't need to check the ciphers
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }))
// If we're not an "admin" or if we're a provider user we don't need to check the ciphers
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }) || await _currentContext.ProviderUserForOrgAsync(organizationId))
{
// Are we a provider user? If so, we need to be sure we're not restricted
// Once the feature flag is removed, this check can be combined with the above
if (await _currentContext.ProviderUserForOrgAsync(organizationId))
{
// Provider is restricted from editing ciphers, so we're not an "admin"
if (_featureService.IsEnabled(FeatureFlagKeys.RestrictProviderAccess))
{
return false;
}
// Provider is unrestricted, so we're an "admin", don't return early
}
else
{
// Not a provider or admin
return false;
}
return false;
}
// If the user can edit all ciphers for the organization, just check they all belong to the org
@ -462,10 +430,10 @@ public class CiphersController : Controller
return true;
}
// Provider users can edit all ciphers if RestrictProviderAccess is disabled
// Provider users cannot edit ciphers
if (await _currentContext.ProviderUserForOrgAsync(organizationId))
{
return !_featureService.IsEnabled(FeatureFlagKeys.RestrictProviderAccess);
return false;
}
return false;
@ -485,10 +453,10 @@ public class CiphersController : Controller
return true;
}
// Provider users can only access organization ciphers if RestrictProviderAccess is disabled
// Provider users cannot access organization ciphers
if (await _currentContext.ProviderUserForOrgAsync(organizationId))
{
return !_featureService.IsEnabled(FeatureFlagKeys.RestrictProviderAccess);
return false;
}
return false;
@ -508,10 +476,10 @@ public class CiphersController : Controller
return true;
}
// Provider users can only access all ciphers if RestrictProviderAccess is disabled
// Provider users cannot access ciphers
if (await _currentContext.ProviderUserForOrgAsync(organizationId))
{
return !_featureService.IsEnabled(FeatureFlagKeys.RestrictProviderAccess);
return false;
}
return false;
@ -1086,9 +1054,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 +1208,20 @@ public class CiphersController : Controller
return new CipherMiniResponseModel(cipher, _globalSettings, cipher.OrganizationUseTotp);
}
[HttpGet("{id}/attachment/{attachmentId}/admin")]
public async Task<AttachmentResponseModel> 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<AttachmentResponseModel> GetAttachmentData(Guid id, string attachmentId)
{
@ -1287,18 +1268,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<DeleteAttachmentResponseData> 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]

View File

@ -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);

View File

@ -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"
}
}
}

View File

@ -71,6 +71,9 @@
"accessKeySecret": "SECRET",
"region": "SECRET"
},
"phishingDomain": {
"updateUrl": "SECRET"
},
"distributedIpRateLimiting": {
"enabled": true,
"maxRedisTimeoutsThreshold": 10,

View File

@ -63,6 +63,12 @@ public class FreshdeskController : Controller
note += $"<li>Region: {_billingSettings.FreshDesk.Region}</li>";
var customFields = new Dictionary<string, object>();
var user = await _userRepository.GetByEmailAsync(ticketContactEmail);
if (user == null)
{
note += $"<li>No user found: {ticketContactEmail}</li>";
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<string, object>
{
{ "body", $"<ul>{note}</ul>" },
{ "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<string, object>
{
{ "body", $"<ul>{note}</ul>" },
{ "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

View File

@ -1,11 +1,11 @@
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
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.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
@ -25,8 +25,7 @@ public class UpcomingInvoiceHandler(
IStripeEventService stripeEventService,
IStripeEventUtilityService stripeEventUtilityService,
IUserRepository userRepository,
IValidateSponsorshipCommand validateSponsorshipCommand,
IAutomaticTaxFactory automaticTaxFactory)
IValidateSponsorshipCommand validateSponsorshipCommand)
: IUpcomingInvoiceHandler
{
public async Task HandleAsync(Event parsedEvent)
@ -46,6 +45,8 @@ public class UpcomingInvoiceHandler(
var (organizationId, userId, providerId) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (organizationId.HasValue)
{
var organization = await organizationRepository.GetByIdAsync(organizationId.Value);
@ -55,7 +56,7 @@ public class UpcomingInvoiceHandler(
return;
}
await TryEnableAutomaticTaxAsync(subscription);
await AlignOrganizationTaxConcernsAsync(organization, subscription, parsedEvent.Id, setNonUSBusinessUseToReverseCharge);
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
@ -100,7 +101,25 @@ public class UpcomingInvoiceHandler(
return;
}
await TryEnableAutomaticTaxAsync(subscription);
if (!subscription.AutomaticTax.Enabled && subscription.Customer.HasRecognizedTaxLocation())
{
try
{
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}",
user.Id,
parsedEvent.Id);
}
}
if (user.Premium)
{
@ -116,7 +135,7 @@ public class UpcomingInvoiceHandler(
return;
}
await TryEnableAutomaticTaxAsync(subscription);
await AlignProviderTaxConcernsAsync(provider, subscription, parsedEvent.Id, setNonUSBusinessUseToReverseCharge);
await SendUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice);
}
@ -139,50 +158,123 @@ public class UpcomingInvoiceHandler(
}
}
private async Task TryEnableAutomaticTaxAsync(Subscription subscription)
private async Task AlignOrganizationTaxConcernsAsync(
Organization organization,
Subscription subscription,
string eventId,
bool setNonUSBusinessUseToReverseCharge)
{
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
{
var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscription.Items.Select(x => x.Price.Id));
var automaticTaxStrategy = await automaticTaxFactory.CreateAsync(automaticTaxParameters);
var updateOptions = automaticTaxStrategy.GetUpdateOptions(subscription);
var nonUSBusinessUse =
organization.PlanType.GetProductTier() != ProductTierType.Families &&
subscription.Customer.Address.Country != "US";
if (updateOptions == null)
bool setAutomaticTaxToEnabled;
if (setNonUSBusinessUseToReverseCharge)
{
if (nonUSBusinessUse && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
{
return;
try
{
await stripeFacade.UpdateCustomer(subscription.CustomerId,
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set organization's ({OrganizationID}) to reverse tax exemption while processing event with ID {EventID}",
organization.Id,
eventId);
}
}
await stripeFacade.UpdateSubscription(subscription.Id, updateOptions);
return;
setAutomaticTaxToEnabled = true;
}
if (subscription.AutomaticTax.Enabled ||
!subscription.Customer.HasBillingLocation() ||
await IsNonTaxableNonUSBusinessUseSubscription(subscription))
else
{
return;
setAutomaticTaxToEnabled =
subscription.Customer.HasRecognizedTaxLocation() &&
(subscription.Customer.Address.Country == "US" ||
(nonUSBusinessUse && subscription.Customer.TaxIds.Any()));
}
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
if (!subscription.AutomaticTax.Enabled && setAutomaticTaxToEnabled)
{
try
{
DefaultTaxRates = [],
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set organization's ({OrganizationID}) subscription to automatic tax while processing event with ID {EventID}",
organization.Id,
eventId);
}
}
}
return;
private async Task AlignProviderTaxConcernsAsync(
Provider provider,
Subscription subscription,
string eventId,
bool setNonUSBusinessUseToReverseCharge)
{
bool setAutomaticTaxToEnabled;
async Task<bool> IsNonTaxableNonUSBusinessUseSubscription(Subscription localSubscription)
if (setNonUSBusinessUseToReverseCharge)
{
var familyPriceIds = (await Task.WhenAll(
pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019),
pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually)))
.Select(plan => plan.PasswordManager.StripePlanId);
if (subscription.Customer.Address.Country != "US" && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
{
try
{
await stripeFacade.UpdateCustomer(subscription.CustomerId,
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set provider's ({ProviderID}) to reverse tax exemption while processing event with ID {EventID}",
provider.Id,
eventId);
}
}
return localSubscription.Customer.Address.Country != "US" &&
localSubscription.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId) &&
!localSubscription.Items.Select(item => item.Price.Id).Intersect(familyPriceIds).Any() &&
!localSubscription.Customer.TaxIds.Any();
setAutomaticTaxToEnabled = true;
}
else
{
setAutomaticTaxToEnabled =
subscription.Customer.HasRecognizedTaxLocation() &&
(subscription.Customer.Address.Country == "US" ||
subscription.Customer.TaxIds.Any());
}
if (!subscription.AutomaticTax.Enabled && setAutomaticTaxToEnabled)
{
try
{
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set provider's ({ProviderID}) subscription to automatic tax while processing event with ID {EventID}",
provider.Id,
eventId);
}
}
}
}

View File

@ -114,6 +114,11 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
/// </summary>
public bool UseRiskInsights { get; set; }
/// <summary>
/// If true, the organization can claim domains, which unlocks additional enterprise features
/// </summary>
public bool UseOrganizationDomains { get; set; }
/// <summary>
/// If set to true, admins can initiate organization-issued sponsorships.
/// </summary>
@ -319,5 +324,7 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
SmSeats = license.SmSeats;
SmServiceAccounts = license.SmServiceAccounts;
UseRiskInsights = license.UseRiskInsights;
UseOrganizationDomains = license.UseOrganizationDomains;
UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies;
}
}

View File

@ -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();
}

View File

@ -26,6 +26,7 @@ public class OrganizationAbility
LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UseRiskInsights = organization.UseRiskInsights;
UseOrganizationDomains = organization.UseOrganizationDomains;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
}
@ -46,5 +47,6 @@ public class OrganizationAbility
public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; }
public bool UseRiskInsights { get; set; }
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
}

View File

@ -59,5 +59,7 @@ public class OrganizationUserOrganizationDetails
public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; }
public bool UseRiskInsights { get; set; }
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
public bool? IsAdminInitiated { get; set; }
}

View File

@ -150,6 +150,7 @@ public class SelfHostedOrganizationDetails : Organization
AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems,
Status = Status,
UseRiskInsights = UseRiskInsights,
UseAdminSponsoredFamilies = UseAdminSponsoredFamilies,
};
}
}

Some files were not shown because too many files have changed in this diff Show More