1
0
mirror of https://github.com/bitwarden/server synced 2026-01-14 06:23:46 +00:00

Merge branch 'main' into tools/pm-21918/send-authentication-commands

This commit is contained in:
✨ Audrey ✨
2025-08-25 14:55:45 -04:00
176 changed files with 14667 additions and 1455 deletions

View File

@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"swashbuckle.aspnetcore.cli": {
"version": "7.3.2",
"version": "9.0.2",
"commands": ["swagger"]
},
"dotnet-ef": {

View File

@@ -153,7 +153,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Import GPG keys
uses: crazy-max/ghaction-import-gpg@cb9bde2e2525e640591a934b1fd28eef1dcaf5e5 # v6.2.0
uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6.3.0
with:
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}

View File

@@ -30,7 +30,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
- name: Verify format
run: dotnet format --verify-no-changes
@@ -117,10 +117,10 @@ jobs:
fi
- name: Set up .NET
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
cache: "npm"
cache-dependency-path: "**/package-lock.json"
@@ -166,10 +166,10 @@ jobs:
########## Set up Docker ##########
- name: Set up QEMU emulators
uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
########## ACRs ##########
- name: Log in to Azure
@@ -237,7 +237,7 @@ jobs:
- name: Build Docker image
id: build-artifacts
uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile
@@ -252,7 +252,7 @@ jobs:
- name: Install Cosign
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
- name: Sign image with Cosign
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
@@ -269,18 +269,18 @@ jobs:
- name: Scan Docker image
id: container-scan
uses: anchore/scan-action@abae793926ec39a78ab18002bc7fc45bbbd94342 # v6.0.0
uses: anchore/scan-action@2c901ab7378897c01b8efaa2d0c9bf519cc64b9e # v6.2.0
with:
image: ${{ steps.image-tags.outputs.primary_tag }}
fail-build: false
output-format: sarif
# - name: Upload Grype results to GitHub
# uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
# with:
# sarif_file: ${{ steps.container-scan.outputs.sarif }}
# sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
# ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
- name: Upload Grype results to GitHub
uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with:
sarif_file: ${{ steps.container-scan.outputs.sarif }}
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
@@ -299,7 +299,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
@@ -376,62 +376,23 @@ jobs:
path: docker-stub-EU.zip
if-no-files-found: error
- name: Build Public API Swagger
- name: Build Swagger files
run: |
cd ./src/Api
echo "Restore tools"
dotnet tool restore
echo "Publish"
dotnet publish -c "Release" -o obj/build-output/publish
dotnet swagger tofile --output ../../swagger.json --host https://api.bitwarden.com \
./obj/build-output/publish/Api.dll public
cd ../..
env:
ASPNETCORE_ENVIRONMENT: Production
swaggerGen: "True"
DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2
GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
cd ./dev
pwsh ./generate_openapi_files.ps1
- name: Upload Public API Swagger artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: swagger.json
path: swagger.json
path: api.public.json
if-no-files-found: error
- name: Build Internal API Swagger
run: |
cd ./src/Api
echo "Restore API tools"
dotnet tool restore
echo "Publish API"
dotnet publish -c "Release" -o obj/build-output/publish
dotnet swagger tofile --output ../../internal.json --host https://api.bitwarden.com \
./obj/build-output/publish/Api.dll internal
cd ../Identity
echo "Restore Identity tools"
dotnet tool restore
echo "Publish Identity"
dotnet publish -c "Release" -o obj/build-output/publish
dotnet swagger tofile --output ../../identity.json --host https://identity.bitwarden.com \
./obj/build-output/publish/Identity.dll v1
cd ../..
env:
ASPNETCORE_ENVIRONMENT: Development
swaggerGen: "True"
DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2
GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
- name: Upload Internal API Swagger artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: internal.json
path: internal.json
path: api.json
if-no-files-found: error
- name: Upload Identity Swagger artifact
@@ -464,7 +425,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
- name: Print environment
run: |

View File

@@ -86,7 +86,7 @@ jobs:
- name: Create release
if: ${{ inputs.release_type != 'Dry Run' }}
uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0
uses: ncipollo/release-action@440c8c1cb0ed28b9f43e4d1d670870f059653174 # v1.16.0
with:
artifacts: "docker-stub-US.zip,
docker-stub-EU.zip,

View File

@@ -82,7 +82,7 @@ jobs:
version: ${{ inputs.version_number_override }}
- name: Generate GH App token
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
@@ -200,7 +200,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}

View File

@@ -38,8 +38,6 @@ jobs:
pull-requests: write
security-events: write
id-token: write
with:
upload-sarif: false
quality:
name: Sonar

View File

@@ -47,7 +47,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
- name: Restore tools
run: dotnet tool restore
@@ -154,7 +154,7 @@ jobs:
run: 'docker logs $(docker ps --quiet --filter "name=mssql")'
- name: Report test results
uses: dorny/test-reporter@6e6a65b7a0bd2c9197df7d0ae36ac5cee784230c # v2.0.0
uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with:
name: Test Results
@@ -163,7 +163,7 @@ jobs:
fail-on-error: true
- name: Upload to codecov.io
uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
- name: Docker Compose down
if: always()
@@ -179,7 +179,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
- name: Print environment
run: |

View File

@@ -30,7 +30,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
- name: Print environment
run: |
@@ -49,7 +49,7 @@ jobs:
run: dotnet test ./bitwarden_license/test --configuration Debug --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
- name: Report test results
uses: dorny/test-reporter@6e6a65b7a0bd2c9197df7d0ae36ac5cee784230c # v2.0.0
uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with:
name: Test Results
@@ -58,4 +58,4 @@ jobs:
fail-on-error: true
- name: Upload to codecov.io
uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3

7
.gitignore vendored
View File

@@ -129,7 +129,7 @@ publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# TODO: Comment the next line if you want to checkin your web deploy settings
# TODO: Comment the next line if you want to checkin your web deploy settings
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
@@ -226,3 +226,8 @@ src/Notifications/Notifications.zip
bitwarden_license/src/Portal/Portal.zip
bitwarden_license/src/Sso/Sso.zip
**/src/**/flags.json
# Generated swagger specs
/identity.json
/api.json
/api.public.json

View File

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

View File

@@ -133,6 +133,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Seeder", "util\Seeder\Seede
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbSeederUtility", "util\DbSeederUtility\DbSeederUtility.csproj", "{17A89266-260A-4A03-81AE-C0468C6EE06E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -337,6 +339,10 @@ Global
{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
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -391,6 +397,7 @@ Global
{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}
{AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}

View File

@@ -1,7 +1,6 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
@@ -137,20 +136,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }]
};
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()
};
}
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);

View File

@@ -120,10 +120,7 @@ public class ProviderService : IProviderService
throw new BadRequestException("Both address and postal code are required to set up your provider.");
}
var requireProviderPaymentMethodDuringSetup =
_featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
if (requireProviderPaymentMethodDuringSetup && tokenizedPaymentSource is not
if (tokenizedPaymentSource is not
{
Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
Token: not null and not ""

View File

@@ -0,0 +1,101 @@
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Providers.Models;
using Bit.Core.Billing.Providers.Queries;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Services;
using Stripe;
using Stripe.Tax;
namespace Bit.Commercial.Core.Billing.Providers.Queries;
using static StripeConstants;
using SuspensionWarning = ProviderWarnings.SuspensionWarning;
using TaxIdWarning = ProviderWarnings.TaxIdWarning;
public class GetProviderWarningsQuery(
ICurrentContext currentContext,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : IGetProviderWarningsQuery
{
public async Task<ProviderWarnings?> Run(Provider provider)
{
var warnings = new ProviderWarnings();
var subscription =
await subscriberService.GetSubscription(provider,
new SubscriptionGetOptions { Expand = ["customer.tax_ids"] });
if (subscription == null)
{
return warnings;
}
warnings.Suspension = GetSuspensionWarning(provider, subscription);
warnings.TaxId = await GetTaxIdWarningAsync(provider, subscription.Customer);
return warnings;
}
private SuspensionWarning? GetSuspensionWarning(
Provider provider,
Subscription subscription)
{
if (provider.Enabled)
{
return null;
}
return subscription.Status switch
{
SubscriptionStatus.Unpaid => currentContext.ProviderProviderAdmin(provider.Id)
? new SuspensionWarning { Resolution = "add_payment_method", SubscriptionCancelsAt = subscription.CancelAt }
: new SuspensionWarning { Resolution = "contact_administrator" },
_ => new SuspensionWarning { Resolution = "contact_support" }
};
}
private async Task<TaxIdWarning?> GetTaxIdWarningAsync(
Provider provider,
Customer customer)
{
if (!currentContext.ProviderProviderAdmin(provider.Id))
{
return null;
}
// TODO: Potentially DRY this out with the GetOrganizationWarningsQuery
// Get active and scheduled registrations
var registrations = (await Task.WhenAll(
stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Active }),
stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Scheduled })))
.SelectMany(registrations => registrations.Data);
// Find the matching registration for the customer
var registration = registrations.FirstOrDefault(registration => registration.Country == customer.Address.Country);
// If we're not registered in their country, we don't need a warning
if (registration == null)
{
return null;
}
var taxId = customer.TaxIds.FirstOrDefault();
return taxId switch
{
// Customer's tax ID is missing
null => new TaxIdWarning { Type = "tax_id_missing" },
// Not sure if this case is valid, but Stripe says this property is nullable
not null when taxId.Verification == null => null,
// Customer's tax ID is pending verification
not null when taxId.Verification.Status == TaxIdVerificationStatus.Pending => new TaxIdWarning { Type = "tax_id_pending_verification" },
// Customer's tax ID failed verification
not null when taxId.Verification.Status == TaxIdVerificationStatus.Unverified => new TaxIdWarning { Type = "tax_id_failed_verification" },
_ => null
};
}
}

View File

@@ -3,7 +3,6 @@
using System.Globalization;
using Bit.Commercial.Core.Billing.Providers.Models;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
@@ -41,7 +40,6 @@ namespace Bit.Commercial.Core.Billing.Providers.Services;
public class ProviderBillingService(
IBraintreeGateway braintreeGateway,
IEventService eventService,
IFeatureService featureService,
IGlobalSettings globalSettings,
ILogger<ProviderBillingService> logger,
IOrganizationRepository organizationRepository,
@@ -284,9 +282,7 @@ public class ProviderBillingService(
]
};
var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge && providerCustomer.Address is not { Country: "US" })
if (providerCustomer.Address is not { Country: "US" })
{
customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse;
}
@@ -483,8 +479,10 @@ public class ProviderBillingService(
public async Task<Customer> SetupCustomer(
Provider provider,
TaxInfo taxInfo,
TokenizedPaymentSource tokenizedPaymentSource = null)
TokenizedPaymentSource tokenizedPaymentSource)
{
ArgumentNullException.ThrowIfNull(tokenizedPaymentSource);
if (taxInfo is not
{
BillingAddressCountry: not null and not "",
@@ -527,9 +525,7 @@ public class ProviderBillingService(
}
};
var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge && taxInfo.BillingAddressCountry != "US")
if (taxInfo.BillingAddressCountry is not "US")
{
options.TaxExempt = StripeConstants.TaxExempt.Reverse;
}
@@ -569,56 +565,50 @@ 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 ""
})
{
if (tokenizedPaymentSource is not
logger.LogError("Cannot create customer for provider ({ProviderID}) with invalid payment method", provider.Id);
throw new BillingException();
}
var (type, token) = tokenizedPaymentSource;
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (type)
{
case PaymentMethodType.BankAccount:
{
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 setupIntent =
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token }))
.FirstOrDefault();
var (type, token) = tokenizedPaymentSource;
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (type)
{
case PaymentMethodType.BankAccount:
if (setupIntent == null)
{
var setupIntent =
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token }))
.FirstOrDefault();
logger.LogError("Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account", provider.Id);
throw new BillingException();
}
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;
}
}
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
@@ -640,25 +630,22 @@ public class ProviderBillingService(
async Task Revert()
{
if (requireProviderPaymentMethodDuringSetup && tokenizedPaymentSource != null)
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (tokenizedPaymentSource.Type)
{
// 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;
}
}
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;
}
}
}
}
@@ -701,9 +688,6 @@ public class ProviderBillingService(
});
}
var requireProviderPaymentMethodDuringSetup =
featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
var setupIntentId = await setupIntentCache.Get(provider.Id);
var setupIntent = !string.IsNullOrEmpty(setupIntentId)
@@ -714,10 +698,9 @@ public class ProviderBillingService(
: null;
var usePaymentMethod =
requireProviderPaymentMethodDuringSetup &&
(!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) ||
customer.Metadata.ContainsKey(BraintreeCustomerIdKey) ||
setupIntent.IsUnverifiedBankAccount());
!string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) ||
(customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) == true) ||
(setupIntent?.IsUnverifiedBankAccount() == true);
int? trialPeriodDays = provider.Type switch
{
@@ -742,21 +725,8 @@ public class ProviderBillingService(
TrialPeriodDays = trialPeriodDays
};
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()
};
}
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
try
{

View File

@@ -1,8 +1,10 @@
using Bit.Commercial.Core.AdminConsole.Providers;
using Bit.Commercial.Core.AdminConsole.Services;
using Bit.Commercial.Core.Billing.Providers.Queries;
using Bit.Commercial.Core.Billing.Providers.Services;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Providers.Queries;
using Bit.Core.Billing.Providers.Services;
using Microsoft.Extensions.DependencyInjection;
@@ -17,5 +19,6 @@ public static class ServiceCollectionExtensions
services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>();
services.AddTransient<IProviderBillingService, ProviderBillingService>();
services.AddTransient<IBusinessUnitConverter, BusinessUnitConverter>();
services.AddTransient<IGetProviderWarningsQuery, GetProviderWarningsQuery>();
}
}

View File

@@ -28,7 +28,10 @@ public class ProjectRepository : Repository<Core.SecretsManager.Entities.Project
}
}
public async Task<IEnumerable<ProjectPermissionDetails>> GetManyByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType)
public async Task<IEnumerable<ProjectPermissionDetails>> GetManyByOrganizationIdAsync(
Guid organizationId,
Guid userId,
AccessClientType accessType)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);

View File

@@ -45,6 +45,19 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
}
}
public async Task<IEnumerable<Core.SecretsManager.Entities.Secret>> GetManyTrashedSecretsByIds(IEnumerable<Guid> ids)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var secrets = await dbContext.Secret
.Where(c => ids.Contains(c.Id) && c.DeletedDate != null)
.Include(c => c.Projects)
.ToListAsync();
return Mapper.Map<List<Core.SecretsManager.Entities.Secret>>(secrets);
}
}
public async Task<IEnumerable<Core.SecretsManager.Entities.Secret>> GetManyByOrganizationIdAsync(
Guid organizationId, Guid userId, AccessClientType accessType)
{
@@ -66,10 +79,14 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
return Mapper.Map<List<Core.SecretsManager.Entities.Secret>>(secrets);
}
public async Task<IEnumerable<SecretPermissionDetails>> GetManyDetailsByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType)
public async Task<IEnumerable<SecretPermissionDetails>> GetManyDetailsByOrganizationIdAsync(
Guid organizationId,
Guid userId,
AccessClientType accessType)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var query = dbContext.Secret
.Include(c => c.Projects)
.Where(c => c.OrganizationId == organizationId && c.DeletedDate == null)

View File

@@ -8,7 +8,7 @@ using Bit.Core.Utilities;
using Bit.Scim.Context;
using Bit.Scim.Utilities;
using Bit.SharedWeb.Utilities;
using IdentityModel;
using Duende.IdentityModel;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Stripe;

View File

@@ -3,7 +3,7 @@ using System.Text.Encodings.Web;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Scim.Context;
using IdentityModel;
using Duende.IdentityModel;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;

View File

@@ -23,10 +23,10 @@ using Bit.Core.Tokens;
using Bit.Core.Utilities;
using Bit.Sso.Models;
using Bit.Sso.Utilities;
using Duende.IdentityModel;
using Duende.IdentityServer;
using Duende.IdentityServer.Services;
using Duende.IdentityServer.Stores;
using IdentityModel;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;

View File

@@ -10,9 +10,9 @@ using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Sso.Models;
using Bit.Sso.Utilities;
using Duende.IdentityModel;
using Duende.IdentityServer;
using Duende.IdentityServer.Infrastructure;
using IdentityModel;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.Options;

View File

@@ -1,5 +1,4 @@
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;
@@ -332,9 +331,6 @@ public class RemoveOrganizationFromProviderCommandTests
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,6 +1,5 @@
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;
@@ -120,8 +119,6 @@ public class ProviderServiceTests
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 };
@@ -1194,7 +1191,7 @@ public class ProviderServiceTests
private static SubscriptionUpdateOptions SubscriptionUpdateRequest(string expectedPlanId, Subscription subscriptionItem) =>
new()
{
Items = new List<Stripe.SubscriptionItemOptions>
Items = new List<SubscriptionItemOptions>
{
new() { Id = subscriptionItem.Id, Price = expectedPlanId },
}

View File

@@ -0,0 +1,523 @@
using Bit.Commercial.Core.Billing.Providers.Queries;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Stripe;
using Stripe.Tax;
using Xunit;
namespace Bit.Commercial.Core.Test.Billing.Providers.Queries;
using static StripeConstants;
[SutProviderCustomize]
public class GetProviderWarningsQueryTests
{
private static readonly string[] _requiredExpansions = ["customer.tax_ids"];
[Theory, BitAutoData]
public async Task Run_NoSubscription_NoWarnings(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.ReturnsNull();
var response = await sutProvider.Sut.Run(provider);
Assert.True(response is
{
Suspension: null,
TaxId: null
});
}
[Theory, BitAutoData]
public async Task Run_ProviderEnabled_NoSuspensionWarning(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = true;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Unpaid,
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration> { Data = [] });
var response = await sutProvider.Sut.Run(provider);
Assert.Null(response!.Suspension);
}
[Theory, BitAutoData]
public async Task Run_Has_SuspensionWarning_AddPaymentMethod(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = false;
var cancelAt = DateTime.UtcNow.AddDays(7);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Unpaid,
CancelAt = cancelAt,
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration> { Data = [] });
var response = await sutProvider.Sut.Run(provider);
Assert.True(response is
{
Suspension.Resolution: "add_payment_method"
});
Assert.Equal(cancelAt, response.Suspension.SubscriptionCancelsAt);
}
[Theory, BitAutoData]
public async Task Run_Has_SuspensionWarning_ContactAdministrator(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = false;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Unpaid,
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(false);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration> { Data = [] });
var response = await sutProvider.Sut.Run(provider);
Assert.True(response is
{
Suspension.Resolution: "contact_administrator"
});
Assert.Null(response.Suspension.SubscriptionCancelsAt);
}
[Theory, BitAutoData]
public async Task Run_Has_SuspensionWarning_ContactSupport(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = false;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Canceled,
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration> { Data = [] });
var response = await sutProvider.Sut.Run(provider);
Assert.True(response is
{
Suspension.Resolution: "contact_support"
});
}
[Theory, BitAutoData]
public async Task Run_NotProviderAdmin_NoTaxIdWarning(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = true;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Active,
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(false);
var response = await sutProvider.Sut.Run(provider);
Assert.Null(response!.TaxId);
}
[Theory, BitAutoData]
public async Task Run_NoTaxRegistrationForCountry_NoTaxIdWarning(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = true;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Active,
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "CA" }]
});
var response = await sutProvider.Sut.Run(provider);
Assert.Null(response!.TaxId);
}
[Theory, BitAutoData]
public async Task Run_Has_TaxIdMissingWarning(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = true;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Active,
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "US" }]
});
var response = await sutProvider.Sut.Run(provider);
Assert.True(response is
{
TaxId.Type: "tax_id_missing"
});
}
[Theory, BitAutoData]
public async Task Run_TaxIdVerificationIsNull_NoTaxIdWarning(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = true;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Active,
Customer = new Customer
{
TaxIds = new StripeList<TaxId>
{
Data = [new TaxId { Verification = null }]
},
Address = new Address { Country = "US" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "US" }]
});
var response = await sutProvider.Sut.Run(provider);
Assert.Null(response!.TaxId);
}
[Theory, BitAutoData]
public async Task Run_Has_TaxIdPendingVerificationWarning(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = true;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Active,
Customer = new Customer
{
TaxIds = new StripeList<TaxId>
{
Data = [new TaxId
{
Verification = new TaxIdVerification
{
Status = TaxIdVerificationStatus.Pending
}
}]
},
Address = new Address { Country = "US" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "US" }]
});
var response = await sutProvider.Sut.Run(provider);
Assert.True(response is
{
TaxId.Type: "tax_id_pending_verification"
});
}
[Theory, BitAutoData]
public async Task Run_Has_TaxIdFailedVerificationWarning(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = true;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Active,
Customer = new Customer
{
TaxIds = new StripeList<TaxId>
{
Data = [new TaxId
{
Verification = new TaxIdVerification
{
Status = TaxIdVerificationStatus.Unverified
}
}]
},
Address = new Address { Country = "US" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "US" }]
});
var response = await sutProvider.Sut.Run(provider);
Assert.True(response is
{
TaxId.Type: "tax_id_failed_verification"
});
}
[Theory, BitAutoData]
public async Task Run_TaxIdVerified_NoTaxIdWarning(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = true;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Active,
Customer = new Customer
{
TaxIds = new StripeList<TaxId>
{
Data = [new TaxId
{
Verification = new TaxIdVerification
{
Status = TaxIdVerificationStatus.Verified
}
}]
},
Address = new Address { Country = "US" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "US" }]
});
var response = await sutProvider.Sut.Run(provider);
Assert.Null(response!.TaxId);
}
[Theory, BitAutoData]
public async Task Run_MultipleRegistrations_MatchesCorrectCountry(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = true;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Active,
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "DE" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Is<RegistrationListOptions>(opt => opt.Status == TaxRegistrationStatus.Active))
.Returns(new StripeList<Registration>
{
Data = [
new Registration { Country = "US" },
new Registration { Country = "DE" },
new Registration { Country = "FR" }
]
});
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Is<RegistrationListOptions>(opt => opt.Status == TaxRegistrationStatus.Scheduled))
.Returns(new StripeList<Registration> { Data = [] });
var response = await sutProvider.Sut.Run(provider);
Assert.True(response is
{
TaxId.Type: "tax_id_missing"
});
}
[Theory, BitAutoData]
public async Task Run_CombinesBothWarningTypes(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = false;
var cancelAt = DateTime.UtcNow.AddDays(5);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Unpaid,
CancelAt = cancelAt,
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "US" }]
});
var response = await sutProvider.Sut.Run(provider);
Assert.True(response is
{
Suspension.Resolution: "add_payment_method",
TaxId.Type: "tax_id_missing"
});
Assert.Equal(cancelAt, response.Suspension.SubscriptionCancelsAt);
}
}

View File

@@ -2,7 +2,6 @@
using System.Net;
using Bit.Commercial.Core.Billing.Providers.Models;
using Bit.Commercial.Core.Billing.Providers.Services;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
@@ -352,9 +351,6 @@ public class ProviderBillingServiceTests
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 &&
@@ -901,11 +897,12 @@ public class ProviderBillingServiceTests
public async Task SetupCustomer_MissingCountry_ContactSupport(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo)
TaxInfo taxInfo,
TokenizedPaymentSource tokenizedPaymentSource)
{
taxInfo.BillingAddressCountry = null;
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo));
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
await sutProvider.GetDependency<IStripeAdapter>()
.DidNotReceiveWithAnyArgs()
@@ -916,60 +913,27 @@ public class ProviderBillingServiceTests
public async Task SetupCustomer_MissingPostalCode_ContactSupport(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo)
TaxInfo taxInfo,
TokenizedPaymentSource tokenizedPaymentSource)
{
taxInfo.BillingAddressCountry = null;
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo));
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
await sutProvider.GetDependency<IStripeAdapter>()
.DidNotReceiveWithAnyArgs()
.CustomerGetAsync(Arg.Any<string>(), Arg.Any<CustomerGetOptions>());
}
[Theory, BitAutoData]
public async Task SetupCustomer_NoPaymentMethod_Success(
public async Task SetupCustomer_NullPaymentSource_ThrowsArgumentNullException(
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 }
};
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);
Assert.Equivalent(expected, actual);
await Assert.ThrowsAsync<ArgumentNullException>(() =>
sutProvider.Sut.SetupCustomer(provider, taxInfo, null));
}
[Theory, BitAutoData]
@@ -989,8 +953,6 @@ public class ProviderBillingServiceTests
taxInfo.BillingAddressCountry = "AD";
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay };
@@ -1018,8 +980,6 @@ public class ProviderBillingServiceTests
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([
@@ -1075,8 +1035,6 @@ public class ProviderBillingServiceTests
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");
@@ -1130,8 +1088,6 @@ public class ProviderBillingServiceTests
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([
@@ -1187,8 +1143,6 @@ public class ProviderBillingServiceTests
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");
@@ -1241,8 +1195,6 @@ public class ProviderBillingServiceTests
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 &&
@@ -1293,11 +1245,6 @@ public class ProviderBillingServiceTests
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 &&
@@ -1327,7 +1274,8 @@ public class ProviderBillingServiceTests
public async Task SetupCustomer_Throws_BadRequestException_WhenTaxIdIsInvalid(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo)
TaxInfo taxInfo,
TokenizedPaymentSource tokenizedPaymentSource)
{
provider.Name = "MSP";
@@ -1340,7 +1288,7 @@ public class ProviderBillingServiceTests
.Returns((string)null);
var actual = await Assert.ThrowsAsync<BadRequestException>(async () =>
await sutProvider.Sut.SetupCustomer(provider, taxInfo));
await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
Assert.IsType<BadRequestException>(actual);
Assert.Equal("billingTaxIdTypeInferenceError", actual.Message);
@@ -1616,8 +1564,6 @@ public class ProviderBillingServiceTests
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 =>
@@ -1694,8 +1640,6 @@ public class ProviderBillingServiceTests
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";
@@ -1797,8 +1741,6 @@ public class ProviderBillingServiceTests
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 =>
@@ -1877,11 +1819,6 @@ public class ProviderBillingServiceTests
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 =>

View File

@@ -0,0 +1,19 @@
Set-Location "$PSScriptRoot/.."
$env:ASPNETCORE_ENVIRONMENT = "Development"
$env:swaggerGen = "True"
$env:DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX = "2"
$env:GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING = "placeholder"
dotnet tool restore
# Identity
Set-Location "./src/Identity"
dotnet build
dotnet swagger tofile --output "../../identity.json" --host "https://identity.bitwarden.com" "./bin/Debug/net8.0/Identity.dll" "v1"
# Api internal & public
Set-Location "../../src/Api"
dotnet build
dotnet swagger tofile --output "../../api.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "internal"
dotnet swagger tofile --output "../../api.public.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "public"

View File

@@ -22,6 +22,7 @@ using Bit.Core.Billing.Providers.Entities;
using Bit.Core.Billing.Providers.Models;
using Bit.Core.Billing.Providers.Repositories;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
@@ -53,6 +54,7 @@ public class ProvidersController : Controller
private readonly IPricingClient _pricingClient;
private readonly IStripeAdapter _stripeAdapter;
private readonly IAccessControlService _accessControlService;
private readonly ISubscriberService _subscriberService;
private readonly string _stripeUrl;
private readonly string _braintreeMerchantUrl;
private readonly string _braintreeMerchantId;
@@ -73,7 +75,8 @@ public class ProvidersController : Controller
IWebHostEnvironment webHostEnvironment,
IPricingClient pricingClient,
IStripeAdapter stripeAdapter,
IAccessControlService accessControlService)
IAccessControlService accessControlService,
ISubscriberService subscriberService)
{
_organizationRepository = organizationRepository;
_resellerClientOrganizationSignUpCommand = resellerClientOrganizationSignUpCommand;
@@ -93,6 +96,7 @@ public class ProvidersController : Controller
_braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl();
_braintreeMerchantId = globalSettings.Braintree.MerchantId;
_accessControlService = accessControlService;
_subscriberService = subscriberService;
}
[RequirePermission(Permission.Provider_List_View)]
@@ -299,6 +303,23 @@ public class ProvidersController : Controller
model.ToProvider(provider);
// validate the stripe ids to prevent saving a bad one
if (provider.IsBillable())
{
if (!await _subscriberService.IsValidGatewayCustomerIdAsync(provider))
{
var oldModel = await GetEditModel(id);
ModelState.AddModelError(nameof(model.GatewayCustomerId), $"Invalid Gateway Customer Id: {model.GatewayCustomerId}");
return View(oldModel);
}
if (!await _subscriberService.IsValidGatewaySubscriptionIdAsync(provider))
{
var oldModel = await GetEditModel(id);
ModelState.AddModelError(nameof(model.GatewaySubscriptionId), $"Invalid Gateway Subscription Id: {model.GatewaySubscriptionId}");
return View(oldModel);
}
}
provider.Enabled = _accessControlService.UserHasPermission(Permission.Provider_CheckEnabledBox)
? model.Enabled : originalProviderStatus;
@@ -382,10 +403,8 @@ public class ProvidersController : Controller
}
var providerPlans = await _providerPlanRepository.GetByProviderId(id);
var payByInvoice =
_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) &&
(await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId)).ApprovedToPayByInvoice();
var payByInvoice = _featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) &&
((await _subscriberService.GetCustomer(provider))?.ApprovedToPayByInvoice() ?? false);
return new ProviderEditModel(
provider, users, providerOrganizations,

View File

@@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Providers.Entities;
using Bit.Core.Enums;
using Bit.SharedWeb.Utilities;
@@ -87,14 +88,13 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant().Trim();
existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant().Trim();
existingProvider.Enabled = Enabled;
switch (Type)
if (Type.IsStripeSupported())
{
case ProviderType.Msp:
existingProvider.Gateway = Gateway;
existingProvider.GatewayCustomerId = GatewayCustomerId;
existingProvider.GatewaySubscriptionId = GatewaySubscriptionId;
break;
existingProvider.Gateway = Gateway;
existingProvider.GatewayCustomerId = GatewayCustomerId;
existingProvider.GatewaySubscriptionId = GatewaySubscriptionId;
}
return existingProvider;
}

View File

@@ -5,9 +5,12 @@ using Bit.Api.Models.Response;
using Bit.Api.Utilities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
using Bit.Core.Vault.Repositories;
using Microsoft.AspNetCore.Authorization;
@@ -25,6 +28,8 @@ public class EventsController : Controller
private readonly IProviderUserRepository _providerUserRepository;
private readonly IEventRepository _eventRepository;
private readonly ICurrentContext _currentContext;
private readonly ISecretRepository _secretRepository;
private readonly IProjectRepository _projectRepository;
public EventsController(
IUserService userService,
@@ -32,7 +37,9 @@ public class EventsController : Controller
IOrganizationUserRepository organizationUserRepository,
IProviderUserRepository providerUserRepository,
IEventRepository eventRepository,
ICurrentContext currentContext)
ICurrentContext currentContext,
ISecretRepository secretRepository,
IProjectRepository projectRepository)
{
_userService = userService;
_cipherRepository = cipherRepository;
@@ -40,6 +47,8 @@ public class EventsController : Controller
_providerUserRepository = providerUserRepository;
_eventRepository = eventRepository;
_currentContext = currentContext;
_secretRepository = secretRepository;
_projectRepository = projectRepository;
}
[HttpGet("")]
@@ -104,6 +113,77 @@ public class EventsController : Controller
return new ListResponseModel<EventResponseModel>(responses, result.ContinuationToken);
}
[HttpGet("~/organization/{orgId}/secrets/{id}/events")]
public async Task<ListResponseModel<EventResponseModel>> GetSecrets(
Guid id, Guid orgId,
[FromQuery] DateTime? start = null,
[FromQuery] DateTime? end = null,
[FromQuery] string continuationToken = null)
{
if (id == Guid.Empty || orgId == Guid.Empty)
{
throw new NotFoundException();
}
var secret = await _secretRepository.GetByIdAsync(id);
var orgIdForVerification = secret?.OrganizationId ?? orgId;
var secretOrg = _currentContext.GetOrganization(orgIdForVerification);
if (secretOrg == null || !await _currentContext.AccessEventLogs(secretOrg.Id))
{
throw new NotFoundException();
}
bool canViewLogs = false;
if (secret == null)
{
secret = new Core.SecretsManager.Entities.Secret { Id = id, OrganizationId = orgId };
canViewLogs = secretOrg.Type is Core.Enums.OrganizationUserType.Admin or Core.Enums.OrganizationUserType.Owner;
}
else
{
canViewLogs = await CanViewSecretsLogs(secret);
}
if (!canViewLogs)
{
throw new NotFoundException();
}
var (fromDate, toDate) = ApiHelpers.GetDateRange(start, end);
var result = await _eventRepository.GetManyBySecretAsync(secret, fromDate, toDate, new PageOptions { ContinuationToken = continuationToken });
var responses = result.Data.Select(e => new EventResponseModel(e));
return new ListResponseModel<EventResponseModel>(responses, result.ContinuationToken);
}
[HttpGet("~/organization/{orgId}/projects/{id}/events")]
public async Task<ListResponseModel<EventResponseModel>> GetProjects(
Guid id,
Guid orgId,
[FromQuery] DateTime? start = null,
[FromQuery] DateTime? end = null,
[FromQuery] string continuationToken = null)
{
if (id == Guid.Empty || orgId == Guid.Empty)
{
throw new NotFoundException();
}
var project = await GetProject(id, orgId);
await ValidateOrganization(project);
var (fromDate, toDate) = ApiHelpers.GetDateRange(start, end);
var result = await _eventRepository.GetManyByProjectAsync(
project,
fromDate,
toDate,
new PageOptions { ContinuationToken = continuationToken });
var responses = result.Data.Select(e => new EventResponseModel(e));
return new ListResponseModel<EventResponseModel>(responses, result.ContinuationToken);
}
[HttpGet("~/organizations/{orgId}/users/{id}/events")]
public async Task<ListResponseModel<EventResponseModel>> GetOrganizationUser(string orgId, string id,
[FromQuery] DateTime? start = null, [FromQuery] DateTime? end = null, [FromQuery] string continuationToken = null)
@@ -157,4 +237,48 @@ public class EventsController : Controller
var responses = result.Data.Select(e => new EventResponseModel(e));
return new ListResponseModel<EventResponseModel>(responses, result.ContinuationToken);
}
[ApiExplorerSettings(IgnoreApi = true)]
private async Task ValidateOrganization(Project project)
{
var org = _currentContext.GetOrganization(project.OrganizationId);
if (org == null || !await _currentContext.AccessEventLogs(org.Id))
{
throw new NotFoundException();
}
}
[ApiExplorerSettings(IgnoreApi = true)]
private async Task<Project> GetProject(Guid projectGuid, Guid orgGuid)
{
var project = await _projectRepository.GetByIdAsync(projectGuid);
if (project != null)
{
return project;
}
var fallbackProject = new Project
{
Id = projectGuid,
OrganizationId = orgGuid
};
return fallbackProject;
}
[ApiExplorerSettings(IgnoreApi = true)]
private async Task<bool> CanViewSecretsLogs(Secret secret)
{
if (!_currentContext.AccessSecretsManager(secret.OrganizationId))
{
throw new NotFoundException();
}
var userId = _userService.GetProperUserId(User)!.Value;
var isAdmin = await _currentContext.OrganizationAdmin(secret.OrganizationId);
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, isAdmin);
var access = await _secretRepository.AccessToSecretAsync(secret.Id, userId, accessClient);
return access.Read;
}
}

View File

@@ -12,6 +12,7 @@ using Bit.Api.Models.Request.Accounts;
using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
@@ -235,8 +236,7 @@ public class OrganizationsController : Controller
throw new NotFoundException();
}
var updateBilling = !_globalSettings.SelfHosted && (model.BusinessName != organization.DisplayBusinessName() ||
model.BillingEmail != organization.BillingEmail);
var updateBilling = ShouldUpdateBilling(model, organization);
var hasRequiredPermissions = updateBilling
? await _currentContext.EditSubscription(orgIdGuid)
@@ -582,4 +582,11 @@ public class OrganizationsController : Controller
return organization.PlanType;
}
private bool ShouldUpdateBilling(OrganizationUpdateRequestModel model, Organization organization)
{
var organizationNameChanged = model.Name != organization.Name;
var billingEmailChanged = model.BillingEmail != organization.BillingEmail;
return !_globalSettings.SelfHosted && (organizationNameChanged || billingEmailChanged);
}
}

View File

@@ -33,6 +33,7 @@ public class EventResponseModel : ResponseModel
SystemUser = ev.SystemUser;
DomainName = ev.DomainName;
SecretId = ev.SecretId;
ProjectId = ev.ProjectId;
ServiceAccountId = ev.ServiceAccountId;
}
@@ -55,5 +56,6 @@ public class EventResponseModel : ResponseModel
public EventSystemUser? SystemUser { get; set; }
public string DomainName { get; set; }
public Guid? SecretId { get; set; }
public Guid? ProjectId { get; set; }
public Guid? ServiceAccountId { get; set; }
}

View File

@@ -4,7 +4,6 @@
using System.Net;
using Bit.Api.AdminConsole.Public.Models.Request;
using Bit.Api.Models.Public.Response;
using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.Context;
using Bit.Core.Exceptions;
@@ -57,25 +56,13 @@ public class OrganizationController : Controller
throw new BadRequestException("You cannot import this much data at once.");
}
if (_featureService.IsEnabled(FeatureFlagKeys.ImportAsyncRefactor))
{
await _importOrganizationUsersAndGroupsCommand.ImportAsync(
await _importOrganizationUsersAndGroupsCommand.ImportAsync(
_currentContext.OrganizationId.Value,
model.Groups.Select(g => g.ToImportedGroup(_currentContext.OrganizationId.Value)),
model.Members.Where(u => !u.Deleted).Select(u => u.ToImportedOrganizationUser()),
model.Members.Where(u => u.Deleted).Select(u => u.ExternalId),
model.OverwriteExisting.GetValueOrDefault());
}
else
{
await _organizationService.ImportAsync(
_currentContext.OrganizationId.Value,
model.Groups.Select(g => g.ToImportedGroup(_currentContext.OrganizationId.Value)),
model.Members.Where(u => !u.Deleted).Select(u => u.ToImportedOrganizationUser()),
model.Members.Where(u => u.Deleted).Select(u => u.ExternalId),
model.OverwriteExisting.GetValueOrDefault(),
Core.Enums.EventSystemUser.PublicApi);
}
model.OverwriteExisting.GetValueOrDefault()
);
return new OkResult();
}

View File

@@ -28,6 +28,7 @@ public class EventResponseModel : IResponseModel
IpAddress = ev.IpAddress;
InstallationId = ev.InstallationId;
SecretId = ev.SecretId;
ProjectId = ev.ProjectId;
ServiceAccountId = ev.ServiceAccountId;
}
@@ -97,6 +98,11 @@ public class EventResponseModel : IResponseModel
/// <example>e68b8629-85eb-4929-92c0-b84464976ba4</example>
public Guid? SecretId { get; set; }
/// <summary>
/// The unique identifier of the related project that the event describes.
/// </summary>
/// <example>e68b8629-85eb-4929-92c0-b84464976ba4</example>
public Guid? ProjectId { get; set; }
/// <summary>
/// The unique identifier of the related service account that the event describes.
/// </summary>
/// <example>e68b8629-85eb-4929-92c0-b84464976ba4</example>

View File

@@ -34,7 +34,7 @@
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" />
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" />
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.25.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.2" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.2" />
</ItemGroup>
</Project>

View File

@@ -6,7 +6,6 @@ using Bit.Api.Billing.Models.Responses;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Organizations.Queries;
using Bit.Core.Billing.Organizations.Services;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Services;
@@ -28,7 +27,6 @@ public class OrganizationBillingController(
ICurrentContext currentContext,
IOrganizationBillingService organizationBillingService,
IOrganizationRepository organizationRepository,
IGetOrganizationWarningsQuery getOrganizationWarningsQuery,
IPaymentService paymentService,
IPricingClient pricingClient,
ISubscriberService subscriberService,
@@ -359,31 +357,6 @@ 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 warnings = await getOrganizationWarningsQuery.Run(organization);
return TypedResults.Ok(warnings);
}
[HttpPost("change-frequency")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IResult> ChangePlanSubscriptionFrequencyAsync(

View File

@@ -4,7 +4,6 @@
using Bit.Api.Billing.Models.Requests;
using Bit.Api.Billing.Models.Responses;
using Bit.Commercial.Core.Billing.Providers.Services;
using Bit.Core;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Models;
@@ -27,7 +26,6 @@ namespace Bit.Api.Billing.Controllers;
[Authorize("Application")]
public class ProviderBillingController(
ICurrentContext currentContext,
IFeatureService featureService,
ILogger<BaseProviderController> logger,
IPricingClient pricingClient,
IProviderBillingService providerBillingService,
@@ -139,27 +137,15 @@ public class ProviderBillingController(
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
var getProviderPriceFromStripe = featureService.IsEnabled(FeatureFlagKeys.PM21383_GetProviderPriceFromStripe);
var configuredProviderPlans = await Task.WhenAll(providerPlans.Select(async providerPlan =>
{
var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType);
var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, plan.Type);
var price = await stripeAdapter.PriceGetAsync(priceId);
decimal unitAmount;
if (getProviderPriceFromStripe)
{
var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, plan.Type);
var price = await stripeAdapter.PriceGetAsync(priceId);
unitAmount = price.UnitAmountDecimal.HasValue
? price.UnitAmountDecimal.Value / 100M
: plan.PasswordManager.ProviderPortalSeatPrice;
}
else
{
unitAmount = plan.PasswordManager.ProviderPortalSeatPrice;
}
var unitAmount = price.UnitAmountDecimal.HasValue
? price.UnitAmountDecimal.Value / 100M
: plan.PasswordManager.ProviderPortalSeatPrice;
return new ConfiguredProviderPlan(
providerPlan.Id,

View File

@@ -1,9 +1,10 @@
#nullable enable
using Bit.Api.AdminConsole.Authorization;
using Bit.Api.AdminConsole.Authorization;
using Bit.Api.AdminConsole.Authorization.Requirements;
using Bit.Api.Billing.Attributes;
using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Api.Billing.Models.Requirements;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Organizations.Queries;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Utilities;
@@ -21,6 +22,7 @@ public class OrganizationBillingVNextController(
ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand,
IGetBillingAddressQuery getBillingAddressQuery,
IGetCreditQuery getCreditQuery,
IGetOrganizationWarningsQuery getOrganizationWarningsQuery,
IGetPaymentMethodQuery getPaymentMethodQuery,
IUpdateBillingAddressCommand updateBillingAddressCommand,
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
@@ -104,4 +106,14 @@ public class OrganizationBillingVNextController(
var result = await verifyBankAccountCommand.Run(organization, request.DescriptorCode);
return Handle(result);
}
[Authorize<MemberOrProviderRequirement>]
[HttpGet("warnings")]
[InjectOrganization]
public async Task<IResult> GetWarningsAsync(
[BindNever] Organization organization)
{
var warnings = await getOrganizationWarningsQuery.Run(organization);
return TypedResults.Ok(warnings);
}
}

View File

@@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Providers.Queries;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
@@ -19,6 +20,7 @@ public class ProviderBillingVNextController(
IGetBillingAddressQuery getBillingAddressQuery,
IGetCreditQuery getCreditQuery,
IGetPaymentMethodQuery getPaymentMethodQuery,
IGetProviderWarningsQuery getProviderWarningsQuery,
IProviderService providerService,
IUpdateBillingAddressCommand updateBillingAddressCommand,
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
@@ -104,4 +106,13 @@ public class ProviderBillingVNextController(
var result = await verifyBankAccountCommand.Run(provider, request.DescriptorCode);
return Handle(result);
}
[HttpGet("warnings")]
[InjectProvider(ProviderUserType.ServiceUser)]
public async Task<IResult> GetWarningsAsync(
[BindNever] Provider provider)
{
var warnings = await getProviderWarningsQuery.Run(provider);
return TypedResults.Ok(warnings);
}
}

View File

@@ -1,5 +1,4 @@
#nullable enable
using Bit.Api.AdminConsole.Authorization;
using Bit.Api.AdminConsole.Authorization;
using Bit.Core.Context;
using Bit.Core.Enums;

View File

@@ -146,7 +146,7 @@ public class CollectionsController : Controller
}
[HttpPost("")]
public async Task<CollectionResponseModel> Post(Guid orgId, [FromBody] CollectionRequestModel model)
public async Task<CollectionResponseModel> Post(Guid orgId, [FromBody] CreateCollectionRequestModel model)
{
var collection = model.ToCollection(orgId);
@@ -174,7 +174,7 @@ public class CollectionsController : Controller
[HttpPut("{id}")]
[HttpPost("{id}")]
public async Task<CollectionResponseModel> Put(Guid orgId, Guid id, [FromBody] CollectionRequestModel model)
public async Task<CollectionResponseModel> Put(Guid orgId, Guid id, [FromBody] UpdateCollectionRequestModel model)
{
var collection = await _collectionRepository.GetByIdAsync(id);
var authorized = (await _authorizationService.AuthorizeAsync(User, collection, BulkCollectionOperations.Update)).Succeeded;

View File

@@ -7,7 +7,7 @@ using Bit.Core.Utilities;
namespace Bit.Api.Models.Request;
public class CollectionRequestModel
public class CreateCollectionRequestModel
{
[Required]
[EncryptedString]
@@ -40,7 +40,7 @@ public class CollectionBulkDeleteRequestModel
public IEnumerable<Guid> Ids { get; set; }
}
public class CollectionWithIdRequestModel : CollectionRequestModel
public class CollectionWithIdRequestModel : CreateCollectionRequestModel
{
public Guid? Id { get; set; }
@@ -50,3 +50,21 @@ public class CollectionWithIdRequestModel : CollectionRequestModel
return base.ToCollection(existingCollection);
}
}
public class UpdateCollectionRequestModel : CreateCollectionRequestModel
{
[EncryptedString]
[EncryptedStringLength(1000)]
public new string Name { get; set; }
public override Collection ToCollection(Collection existingCollection)
{
if (string.IsNullOrEmpty(existingCollection.DefaultUserCollectionEmail) && !string.IsNullOrWhiteSpace(Name))
{
existingCollection.Name = Name;
}
existingCollection.ExternalId = ExternalId;
return existingCollection;
}
}

View File

@@ -49,6 +49,7 @@ public class CollectionDetailsResponseModel : CollectionResponseModel
ReadOnly = collectionDetails.ReadOnly;
HidePasswords = collectionDetails.HidePasswords;
Manage = collectionDetails.Manage;
DefaultUserCollectionEmail = collectionDetails.DefaultUserCollectionEmail;
}
public bool ReadOnly { get; set; }

View File

@@ -7,6 +7,7 @@ using Bit.Api.SecretsManager.Models.Response;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Identity;
using Bit.Core.SecretsManager.AuthorizationRequirements;
using Bit.Core.SecretsManager.Commands.Projects.Interfaces;
using Bit.Core.SecretsManager.Entities;
@@ -29,6 +30,7 @@ public class ProjectsController : Controller
private readonly IUpdateProjectCommand _updateProjectCommand;
private readonly IDeleteProjectCommand _deleteProjectCommand;
private readonly IAuthorizationService _authorizationService;
private readonly IEventService _eventService;
public ProjectsController(
ICurrentContext currentContext,
@@ -38,7 +40,8 @@ public class ProjectsController : Controller
ICreateProjectCommand createProjectCommand,
IUpdateProjectCommand updateProjectCommand,
IDeleteProjectCommand deleteProjectCommand,
IAuthorizationService authorizationService)
IAuthorizationService authorizationService,
IEventService eventService)
{
_currentContext = currentContext;
_userService = userService;
@@ -48,6 +51,7 @@ public class ProjectsController : Controller
_updateProjectCommand = updateProjectCommand;
_deleteProjectCommand = deleteProjectCommand;
_authorizationService = authorizationService;
_eventService = eventService;
}
[HttpGet("organizations/{organizationId}/projects")]
@@ -89,6 +93,11 @@ public class ProjectsController : Controller
var userId = _userService.GetProperUserId(User).Value;
var result = await _createProjectCommand.CreateAsync(project, userId, _currentContext.IdentityClientType);
if (result != null)
{
await LogProjectEventAsync(project, EventType.Project_Created);
}
// Creating a project means you have read & write permission.
return new ProjectResponseModel(result, true, true);
}
@@ -106,6 +115,10 @@ public class ProjectsController : Controller
}
var result = await _updateProjectCommand.UpdateAsync(updateRequest.ToProject(id));
if (result != null)
{
await LogProjectEventAsync(project, EventType.Project_Edited);
}
// Updating a project means you have read & write permission.
return new ProjectResponseModel(result, true, true);
@@ -136,6 +149,8 @@ public class ProjectsController : Controller
throw new NotFoundException();
}
await LogProjectEventAsync(project, EventType.Project_Retrieved);
return new ProjectResponseModel(project, access.Read, access.Write);
}
@@ -175,9 +190,32 @@ public class ProjectsController : Controller
}
}
await _deleteProjectCommand.DeleteProjects(projectsToDelete);
if (projectsToDelete.Count > 0)
{
await _deleteProjectCommand.DeleteProjects(projectsToDelete);
await LogProjectsEventAsync(projectsToDelete, EventType.Project_Deleted);
}
var responses = results.Select(r => new BulkDeleteResponseModel(r.Project.Id, r.Error));
return new ListResponseModel<BulkDeleteResponseModel>(responses);
}
private async Task LogProjectsEventAsync(IEnumerable<Project> projects, EventType eventType)
{
var userId = _userService.GetProperUserId(User)!.Value;
switch (_currentContext.IdentityClientType)
{
case IdentityClientType.ServiceAccount:
await _eventService.LogServiceAccountProjectsEventAsync(userId, projects, eventType);
break;
case IdentityClientType.User:
await _eventService.LogUserProjectsEventAsync(userId, projects, eventType);
break;
}
}
private Task LogProjectEventAsync(Project project, EventType eventType) =>
LogProjectsEventAsync(new[] { project }, eventType);
}

View File

@@ -1,8 +1,12 @@
using Bit.Api.SecretsManager.Models.Response;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Identity;
using Bit.Core.SecretsManager.Commands.Trash.Interfaces;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -15,17 +19,23 @@ public class TrashController : Controller
private readonly ISecretRepository _secretRepository;
private readonly IEmptyTrashCommand _emptyTrashCommand;
private readonly IRestoreTrashCommand _restoreTrashCommand;
private readonly IUserService _userService;
private readonly IEventService _eventService;
public TrashController(
ICurrentContext currentContext,
ISecretRepository secretRepository,
IEmptyTrashCommand emptyTrashCommand,
IRestoreTrashCommand restoreTrashCommand)
IRestoreTrashCommand restoreTrashCommand,
IUserService userService,
IEventService eventService)
{
_currentContext = currentContext;
_secretRepository = secretRepository;
_emptyTrashCommand = emptyTrashCommand;
_restoreTrashCommand = restoreTrashCommand;
_userService = userService;
_eventService = eventService;
}
[HttpGet("secrets/{organizationId}/trash")]
@@ -58,7 +68,9 @@ public class TrashController : Controller
throw new UnauthorizedAccessException();
}
var deletedSecrets = await _secretRepository.GetManyTrashedSecretsByIds(ids);
await _emptyTrashCommand.EmptyTrash(organizationId, ids);
await LogSecretsTrashEventAsync(deletedSecrets, EventType.Secret_Permanently_Deleted);
}
[HttpPost("secrets/{organizationId}/trash/restore")]
@@ -75,5 +87,27 @@ public class TrashController : Controller
}
await _restoreTrashCommand.RestoreTrash(organizationId, ids);
await LogSecretsTrashEventAsync(ids, EventType.Secret_Restored);
}
private async Task LogSecretsTrashEventAsync(IEnumerable<Guid> secretIds, EventType eventType)
{
var secrets = await _secretRepository.GetManyByIds(secretIds);
await LogSecretsTrashEventAsync(secrets, eventType);
}
private async Task LogSecretsTrashEventAsync(IEnumerable<Secret> secrets, EventType eventType)
{
var userId = _userService.GetProperUserId(User)!.Value;
switch (_currentContext.IdentityClientType)
{
case IdentityClientType.ServiceAccount:
await _eventService.LogServiceAccountSecretsEventAsync(userId, secrets, eventType);
break;
case IdentityClientType.User:
await _eventService.LogUserSecretsEventAsync(userId, secrets, eventType);
break;
}
}
}

View File

@@ -5,7 +5,7 @@ using Bit.Core.Settings;
using AspNetCoreRateLimit;
using Stripe;
using Bit.Core.Utilities;
using IdentityModel;
using Duende.IdentityModel;
using System.Globalization;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Auth.Models.Request;
@@ -210,7 +210,7 @@ public class Startup
config.Conventions.Add(new PublicApiControllersModelConvention());
});
services.AddSwagger(globalSettings);
services.AddSwagger(globalSettings, Environment);
Jobs.JobsHostedService.AddJobsServices(services, globalSettings.SelfHosted);
services.AddHostedService<Jobs.JobsHostedService>();

View File

@@ -19,7 +19,7 @@ namespace Bit.Api.Utilities;
public static class ServiceCollectionExtensions
{
public static void AddSwagger(this IServiceCollection services, GlobalSettings globalSettings)
public static void AddSwagger(this IServiceCollection services, GlobalSettings globalSettings, IWebHostEnvironment environment)
{
services.AddSwaggerGen(config =>
{
@@ -83,6 +83,14 @@ public static class ServiceCollectionExtensions
// config.UseReferencedDefinitionsForEnums();
config.SchemaFilter<EnumSchemaFilter>();
config.SchemaFilter<EncryptedStringSchemaFilter>();
// These two filters require debug symbols/git, so only add them in development mode
if (environment.IsDevelopment())
{
config.DocumentFilter<GitCommitDocumentFilter>();
config.OperationFilter<SourceFileLineOperationFilter>();
}
var apiFilePath = Path.Combine(AppContext.BaseDirectory, "Api.xml");
config.IncludeXmlComments(apiFilePath, true);

View File

@@ -11,7 +11,7 @@
<ProjectReference Include="..\Core\Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.2" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.2" />
</ItemGroup>
</Project>

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
@@ -18,7 +18,6 @@ using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
public class UpcomingInvoiceHandler(
IFeatureService featureService,
ILogger<StripeEventProcessor> logger,
IMailService mailService,
IOrganizationRepository organizationRepository,
@@ -48,8 +47,6 @@ 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);
@@ -59,7 +56,7 @@ public class UpcomingInvoiceHandler(
return;
}
await AlignOrganizationTaxConcernsAsync(organization, subscription, parsedEvent.Id, setNonUSBusinessUseToReverseCharge);
await AlignOrganizationTaxConcernsAsync(organization, subscription, parsedEvent.Id);
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
@@ -138,7 +135,7 @@ public class UpcomingInvoiceHandler(
return;
}
await AlignProviderTaxConcernsAsync(provider, subscription, parsedEvent.Id, setNonUSBusinessUseToReverseCharge);
await AlignProviderTaxConcernsAsync(provider, subscription, parsedEvent.Id);
await SendUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice);
}
@@ -164,45 +161,30 @@ public class UpcomingInvoiceHandler(
private async Task AlignOrganizationTaxConcernsAsync(
Organization organization,
Subscription subscription,
string eventId,
bool setNonUSBusinessUseToReverseCharge)
string eventId)
{
var nonUSBusinessUse =
organization.PlanType.GetProductTier() != ProductTierType.Families &&
subscription.Customer.Address.Country != "US";
bool setAutomaticTaxToEnabled;
if (setNonUSBusinessUseToReverseCharge)
if (nonUSBusinessUse && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
{
if (nonUSBusinessUse && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
try
{
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.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);
}
setAutomaticTaxToEnabled = true;
}
else
{
setAutomaticTaxToEnabled =
subscription.Customer.HasRecognizedTaxLocation() &&
(subscription.Customer.Address.Country == "US" ||
(nonUSBusinessUse && subscription.Customer.TaxIds.Any()));
}
if (!subscription.AutomaticTax.Enabled && setAutomaticTaxToEnabled)
if (!subscription.AutomaticTax.Enabled)
{
try
{
@@ -226,41 +208,27 @@ public class UpcomingInvoiceHandler(
private async Task AlignProviderTaxConcernsAsync(
Provider provider,
Subscription subscription,
string eventId,
bool setNonUSBusinessUseToReverseCharge)
string eventId)
{
bool setAutomaticTaxToEnabled;
if (setNonUSBusinessUseToReverseCharge)
if (subscription.Customer.Address.Country != "US" &&
subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
{
if (subscription.Customer.Address.Country != "US" && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
try
{
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);
}
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);
}
setAutomaticTaxToEnabled = true;
}
else
{
setAutomaticTaxToEnabled =
subscription.Customer.HasRecognizedTaxLocation() &&
(subscription.Customer.Address.Country == "US" ||
subscription.Customer.TaxIds.Any());
}
if (!subscription.AutomaticTax.Enabled && setAutomaticTaxToEnabled)
if (!subscription.AutomaticTax.Enabled)
{
try
{

View File

@@ -32,6 +32,7 @@ public class Event : ITableObject<Guid>, IEvent
SystemUser = e.SystemUser;
DomainName = e.DomainName;
SecretId = e.SecretId;
ProjectId = e.ProjectId;
ServiceAccountId = e.ServiceAccountId;
}
@@ -56,6 +57,7 @@ public class Event : ITableObject<Guid>, IEvent
public EventSystemUser? SystemUser { get; set; }
public string? DomainName { get; set; }
public Guid? SecretId { get; set; }
public Guid? ProjectId { get; set; }
public Guid? ServiceAccountId { get; set; }
public void SetNewId()

View File

@@ -30,6 +30,7 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable
/// This value is HTML encoded. For display purposes use the method DisplayBusinessName() instead.
/// </summary>
[MaxLength(50)]
[Obsolete("This property has been deprecated. Use the 'Name' property instead.")]
public string? BusinessName { get; set; }
[MaxLength(50)]
public string? BusinessAddress1 { get; set; }
@@ -147,6 +148,8 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable
/// <summary>
/// Returns the business name of the organization, HTML decoded ready for display.
/// </summary>
///
[Obsolete("This method has been deprecated. Use the 'DisplayName()' method instead.")]
public string? DisplayBusinessName()
{
return WebUtility.HtmlDecode(BusinessName);

View File

@@ -93,4 +93,11 @@ public enum EventType : int
Secret_Created = 2101,
Secret_Edited = 2102,
Secret_Deleted = 2103,
Secret_Permanently_Deleted = 2104,
Secret_Restored = 2105,
Project_Retrieved = 2200,
Project_Created = 2201,
Project_Edited = 2202,
Project_Deleted = 2203,
}

View File

@@ -37,5 +37,6 @@ public class EventMessage : IEvent
public EventSystemUser? SystemUser { get; set; }
public string DomainName { get; set; }
public Guid? SecretId { get; set; }
public Guid? ProjectId { get; set; }
public Guid? ServiceAccountId { get; set; }
}

View File

@@ -35,6 +35,7 @@ public class AzureEvent : ITableEntity
public int? SystemUser { get; set; }
public string DomainName { get; set; }
public Guid? SecretId { get; set; }
public Guid? ProjectId { get; set; }
public Guid? ServiceAccountId { get; set; }
public EventTableEntity ToEventTableEntity()
@@ -65,7 +66,8 @@ public class AzureEvent : ITableEntity
SystemUser = SystemUser.HasValue ? (EventSystemUser)SystemUser.Value : null,
DomainName = DomainName,
SecretId = SecretId,
ServiceAccountId = ServiceAccountId
ServiceAccountId = ServiceAccountId,
ProjectId = ProjectId,
};
}
}
@@ -95,6 +97,7 @@ public class EventTableEntity : IEvent
SystemUser = e.SystemUser;
DomainName = e.DomainName;
SecretId = e.SecretId;
ProjectId = e.ProjectId;
ServiceAccountId = e.ServiceAccountId;
}
@@ -122,6 +125,7 @@ public class EventTableEntity : IEvent
public EventSystemUser? SystemUser { get; set; }
public string DomainName { get; set; }
public Guid? SecretId { get; set; }
public Guid? ProjectId { get; set; }
public Guid? ServiceAccountId { get; set; }
public AzureEvent ToAzureEvent()
@@ -152,6 +156,7 @@ public class EventTableEntity : IEvent
SystemUser = SystemUser.HasValue ? (int)SystemUser.Value : null,
DomainName = DomainName,
SecretId = SecretId,
ProjectId = ProjectId,
ServiceAccountId = ServiceAccountId
};
}
@@ -218,6 +223,15 @@ public class EventTableEntity : IEvent
});
}
if (e.ProjectId.HasValue)
{
entities.Add(new EventTableEntity(e)
{
PartitionKey = pKey,
RowKey = $"ProjectId={e.ProjectId}__Date={dateKey}__Uniquifier={uniquifier}"
});
}
return entities;
}

View File

@@ -26,5 +26,6 @@ public interface IEvent
EventSystemUser? SystemUser { get; set; }
string DomainName { get; set; }
Guid? SecretId { get; set; }
Guid? ProjectId { get; set; }
Guid? ServiceAccountId { get; set; }
}

View File

@@ -1,4 +1,5 @@
using Bit.Core.Models.Data;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.Vault.Entities;
#nullable enable
@@ -11,6 +12,13 @@ public interface IEventRepository
PageOptions pageOptions);
Task<PagedResult<IEvent>> GetManyByOrganizationAsync(Guid organizationId, DateTime startDate, DateTime endDate,
PageOptions pageOptions);
Task<PagedResult<IEvent>> GetManyBySecretAsync(Secret secret, DateTime startDate, DateTime endDate,
PageOptions pageOptions);
Task<PagedResult<IEvent>> GetManyByProjectAsync(Project project, DateTime startDate, DateTime endDate,
PageOptions pageOptions);
Task<PagedResult<IEvent>> GetManyByOrganizationActingUserAsync(Guid organizationId, Guid actingUserId,
DateTime startDate, DateTime endDate, PageOptions pageOptions);
Task<PagedResult<IEvent>> GetManyByProviderAsync(Guid providerId, DateTime startDate, DateTime endDate,

View File

@@ -1,5 +1,6 @@
using Azure.Data.Tables;
using Bit.Core.Models.Data;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Core.Vault.Entities;
@@ -34,6 +35,20 @@ public class EventRepository : IEventRepository
return await GetManyAsync($"OrganizationId={organizationId}", "Date={0}", startDate, endDate, pageOptions);
}
public async Task<PagedResult<IEvent>> GetManyBySecretAsync(Secret secret,
DateTime startDate, DateTime endDate, PageOptions pageOptions)
{
return await GetManyAsync($"OrganizationId={secret.OrganizationId}",
$"SecretId={secret.Id}__Date={{0}}", startDate, endDate, pageOptions); ;
}
public async Task<PagedResult<IEvent>> GetManyByProjectAsync(Project project,
DateTime startDate, DateTime endDate, PageOptions pageOptions)
{
return await GetManyAsync($"OrganizationId={project.OrganizationId}",
$"ProjectId={project.Id}__Date={{0}}", startDate, endDate, pageOptions);
}
public async Task<PagedResult<IEvent>> GetManyByOrganizationActingUserAsync(Guid organizationId, Guid actingUserId,
DateTime startDate, DateTime endDate, PageOptions pageOptions)
{

View File

@@ -35,4 +35,6 @@ public interface IEventService
Task LogOrganizationDomainEventAsync(OrganizationDomain organizationDomain, EventType type, EventSystemUser systemUser, DateTime? date = null);
Task LogUserSecretsEventAsync(Guid userId, IEnumerable<Secret> secrets, EventType type, DateTime? date = null);
Task LogServiceAccountSecretsEventAsync(Guid serviceAccountId, IEnumerable<Secret> secrets, EventType type, DateTime? date = null);
Task LogUserProjectsEventAsync(Guid userId, IEnumerable<Project> projects, EventType type, DateTime? date = null);
Task LogServiceAccountProjectsEventAsync(Guid serviceAccountId, IEnumerable<Project> projects, EventType type, DateTime? date = null);
}

View File

@@ -2,7 +2,6 @@
#nullable disable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.Auth.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
@@ -13,7 +12,6 @@ namespace Bit.Core.Services;
public interface IOrganizationService
{
Task CancelSubscriptionAsync(Guid organizationId, bool? endOfPeriod = null);
Task ReinstateSubscriptionAsync(Guid organizationId);
Task<string> AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb);
Task UpdateSubscription(Guid organizationId, int seatAdjustment, int? maxAutoscaleSeats);
@@ -30,9 +28,6 @@ public interface IOrganizationService
IEnumerable<(OrganizationUserInvite invite, string externalId)> invites);
Task<IEnumerable<Tuple<OrganizationUser, string>>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable<Guid> organizationUsersId);
Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid userId, string resetPasswordKey, Guid? callingUserId);
Task ImportAsync(Guid organizationId, IEnumerable<ImportedGroup> groups,
IEnumerable<ImportedOrganizationUser> newUsers, IEnumerable<string> removeUserExternalIds,
bool overwriteExisting, EventSystemUser eventSystemUser);
Task DeleteSsoUserAsync(Guid userId, Guid? organizationId);
Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null);
Task<(bool canScale, string failureReason)> CanScaleAsync(Organization organization, int seatsToAdd);

View File

@@ -464,6 +464,58 @@ public class EventService : IEventService
await _eventWriteService.CreateManyAsync(eventMessages);
}
public async Task LogUserProjectsEventAsync(Guid userId, IEnumerable<Project> projects, EventType type, DateTime? date = null)
{
var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
var eventMessages = new List<IEvent>();
foreach (var project in projects)
{
if (!CanUseEvents(orgAbilities, project.OrganizationId))
{
continue;
}
var e = new EventMessage(_currentContext)
{
OrganizationId = project.OrganizationId,
Type = type,
ProjectId = project.Id,
UserId = userId,
Date = date.GetValueOrDefault(DateTime.UtcNow)
};
eventMessages.Add(e);
}
await _eventWriteService.CreateManyAsync(eventMessages);
}
public async Task LogServiceAccountProjectsEventAsync(Guid serviceAccountId, IEnumerable<Project> projects, EventType type, DateTime? date = null)
{
var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
var eventMessages = new List<IEvent>();
foreach (var project in projects)
{
if (!CanUseEvents(orgAbilities, project.OrganizationId))
{
continue;
}
var e = new EventMessage(_currentContext)
{
OrganizationId = project.OrganizationId,
Type = type,
ProjectId = project.Id,
ServiceAccountId = serviceAccountId,
Date = date.GetValueOrDefault(DateTime.UtcNow)
};
eventMessages.Add(e);
}
await _eventWriteService.CreateManyAsync(eventMessages);
}
private async Task<Guid?> GetProviderIdAsync(Guid? orgId)
{
if (_currentContext == null || !orgId.HasValue)

View File

@@ -5,7 +5,6 @@ using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
@@ -27,7 +26,6 @@ using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
@@ -65,6 +63,7 @@ public class OrganizationService : IOrganizationService
private readonly IPricingClient _pricingClient;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand;
private readonly IStripeAdapter _stripeAdapter;
public OrganizationService(
IOrganizationRepository organizationRepository,
@@ -90,7 +89,8 @@ public class OrganizationService : IOrganizationService
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
IPricingClient pricingClient,
IPolicyRequirementQuery policyRequirementQuery,
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand,
IStripeAdapter stripeAdapter
)
{
_organizationRepository = organizationRepository;
@@ -117,24 +117,7 @@ public class OrganizationService : IOrganizationService
_pricingClient = pricingClient;
_policyRequirementQuery = policyRequirementQuery;
_sendOrganizationInvitesCommand = sendOrganizationInvitesCommand;
}
public async Task CancelSubscriptionAsync(Guid organizationId, bool? endOfPeriod = null)
{
var organization = await GetOrgById(organizationId);
if (organization == null)
{
throw new NotFoundException();
}
var eop = endOfPeriod.GetValueOrDefault(true);
if (!endOfPeriod.HasValue && organization.ExpirationDate.HasValue &&
organization.ExpirationDate.Value < DateTime.UtcNow)
{
eop = false;
}
await _paymentService.CancelSubscriptionAsync(organization, eop);
_stripeAdapter = stripeAdapter;
}
public async Task ReinstateSubscriptionAsync(Guid organizationId)
@@ -355,8 +338,7 @@ public class OrganizationService : IOrganizationService
}
var bankService = new BankAccountService();
var customerService = new CustomerService();
var customer = await customerService.GetAsync(organization.GatewayCustomerId,
var customer = await _stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId,
new CustomerGetOptions { Expand = new List<string> { "sources" } });
if (customer == null)
{
@@ -417,12 +399,25 @@ public class OrganizationService : IOrganizationService
if (updateBilling && !string.IsNullOrWhiteSpace(organization.GatewayCustomerId))
{
var customerService = new CustomerService();
await customerService.UpdateAsync(organization.GatewayCustomerId,
var newDisplayName = organization.DisplayName();
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId,
new CustomerUpdateOptions
{
Email = organization.BillingEmail,
Description = organization.DisplayBusinessName()
Description = organization.DisplayBusinessName(),
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
// This overwrites the existing custom fields for this organization
CustomFields = [
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = organization.SubscriberType(),
Value = newDisplayName.Length <= 30
? newDisplayName
: newDisplayName[..30]
}]
},
});
}
@@ -926,214 +921,6 @@ public class OrganizationService : IOrganizationService
: EventType.OrganizationUser_ResetPassword_Withdraw);
}
public async Task ImportAsync(Guid organizationId,
IEnumerable<ImportedGroup> groups,
IEnumerable<ImportedOrganizationUser> newUsers,
IEnumerable<string> removeUserExternalIds,
bool overwriteExisting,
EventSystemUser eventSystemUser
)
{
var organization = await GetOrgById(organizationId);
if (organization == null)
{
throw new NotFoundException();
}
if (!organization.UseDirectory)
{
throw new BadRequestException("Organization cannot use directory syncing.");
}
var newUsersSet = new HashSet<string>(newUsers?.Select(u => u.ExternalId) ?? new List<string>());
var existingUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
var existingExternalUsers = existingUsers.Where(u => !string.IsNullOrWhiteSpace(u.ExternalId)).ToList();
var existingExternalUsersIdDict = existingExternalUsers.ToDictionary(u => u.ExternalId, u => u.Id);
// Users
var events = new List<(OrganizationUserUserDetails ou, EventType e, DateTime? d)>();
// Remove Users
if (removeUserExternalIds?.Any() ?? false)
{
var existingUsersDict = existingExternalUsers.ToDictionary(u => u.ExternalId);
var removeUsersSet = new HashSet<string>(removeUserExternalIds)
.Except(newUsersSet)
.Where(u => existingUsersDict.TryGetValue(u, out var existingUser) &&
existingUser.Type != OrganizationUserType.Owner)
.Select(u => existingUsersDict[u]);
await _organizationUserRepository.DeleteManyAsync(removeUsersSet.Select(u => u.Id));
events.AddRange(removeUsersSet.Select(u => (
u,
EventType.OrganizationUser_Removed,
(DateTime?)DateTime.UtcNow
))
);
}
if (overwriteExisting)
{
// Remove existing external users that are not in new user set
var usersToDelete = existingExternalUsers.Where(u =>
u.Type != OrganizationUserType.Owner &&
!newUsersSet.Contains(u.ExternalId) &&
existingExternalUsersIdDict.ContainsKey(u.ExternalId));
await _organizationUserRepository.DeleteManyAsync(usersToDelete.Select(u => u.Id));
events.AddRange(usersToDelete.Select(u => (
u,
EventType.OrganizationUser_Removed,
(DateTime?)DateTime.UtcNow
))
);
foreach (var deletedUser in usersToDelete)
{
existingExternalUsersIdDict.Remove(deletedUser.ExternalId);
}
}
if (newUsers?.Any() ?? false)
{
// Marry existing users
var existingUsersEmailsDict = existingUsers
.Where(u => string.IsNullOrWhiteSpace(u.ExternalId))
.ToDictionary(u => u.Email);
var newUsersEmailsDict = newUsers.ToDictionary(u => u.Email);
var usersToAttach = existingUsersEmailsDict.Keys.Intersect(newUsersEmailsDict.Keys).ToList();
var usersToUpsert = new List<OrganizationUser>();
foreach (var user in usersToAttach)
{
var orgUserDetails = existingUsersEmailsDict[user];
var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserDetails.Id);
if (orgUser != null)
{
orgUser.ExternalId = newUsersEmailsDict[user].ExternalId;
usersToUpsert.Add(orgUser);
existingExternalUsersIdDict.Add(orgUser.ExternalId, orgUser.Id);
}
}
await _organizationUserRepository.UpsertManyAsync(usersToUpsert);
// Add new users
var existingUsersSet = new HashSet<string>(existingExternalUsersIdDict.Keys);
var usersToAdd = newUsersSet.Except(existingUsersSet).ToList();
var seatsAvailable = int.MaxValue;
var enoughSeatsAvailable = true;
if (organization.Seats.HasValue)
{
var seatCounts =
await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
seatsAvailable = organization.Seats.Value - seatCounts.Total;
enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count;
}
var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization);
var userInvites = new List<(OrganizationUserInvite, string)>();
foreach (var user in newUsers)
{
if (!usersToAdd.Contains(user.ExternalId) || string.IsNullOrWhiteSpace(user.Email))
{
continue;
}
try
{
var invite = new OrganizationUserInvite
{
Emails = new List<string> { user.Email },
Type = OrganizationUserType.User,
Collections = new List<CollectionAccessSelection>(),
AccessSecretsManager = hasStandaloneSecretsManager
};
userInvites.Add((invite, user.ExternalId));
}
catch (BadRequestException)
{
// Thrown when the user is already invited to the organization
continue;
}
}
var invitedUsers = await InviteUsersAsync(organizationId, invitingUserId: null, systemUser: eventSystemUser,
userInvites);
foreach (var invitedUser in invitedUsers)
{
existingExternalUsersIdDict.Add(invitedUser.ExternalId, invitedUser.Id);
}
}
// Groups
if (groups?.Any() ?? false)
{
if (!organization.UseGroups)
{
throw new BadRequestException("Organization cannot use groups.");
}
var groupsDict = groups.ToDictionary(g => g.Group.ExternalId);
var existingGroups = await _groupRepository.GetManyByOrganizationIdAsync(organizationId);
var existingExternalGroups = existingGroups
.Where(u => !string.IsNullOrWhiteSpace(u.ExternalId)).ToList();
var existingExternalGroupsDict = existingExternalGroups.ToDictionary(g => g.ExternalId);
var newGroups = groups
.Where(g => !existingExternalGroupsDict.ContainsKey(g.Group.ExternalId))
.Select(g => g.Group).ToList();
var savedGroups = new List<Group>();
foreach (var group in newGroups)
{
group.CreationDate = group.RevisionDate = DateTime.UtcNow;
savedGroups.Add(await _groupRepository.CreateAsync(group));
await UpdateUsersAsync(group, groupsDict[group.ExternalId].ExternalUserIds,
existingExternalUsersIdDict);
}
await _eventService.LogGroupEventsAsync(
savedGroups.Select(g => (g, EventType.Group_Created, (EventSystemUser?)eventSystemUser,
(DateTime?)DateTime.UtcNow)));
var updateGroups = existingExternalGroups
.Where(g => groupsDict.ContainsKey(g.ExternalId))
.ToList();
if (updateGroups.Any())
{
var groupUsers = await _groupRepository.GetManyGroupUsersByOrganizationIdAsync(organizationId);
var existingGroupUsers = groupUsers
.GroupBy(gu => gu.GroupId)
.ToDictionary(g => g.Key, g => new HashSet<Guid>(g.Select(gr => gr.OrganizationUserId)));
foreach (var group in updateGroups)
{
var updatedGroup = groupsDict[group.ExternalId].Group;
if (group.Name != updatedGroup.Name)
{
group.RevisionDate = DateTime.UtcNow;
group.Name = updatedGroup.Name;
await _groupRepository.ReplaceAsync(group);
}
await UpdateUsersAsync(group, groupsDict[group.ExternalId].ExternalUserIds,
existingExternalUsersIdDict,
existingGroupUsers.ContainsKey(group.Id) ? existingGroupUsers[group.Id] : null);
}
await _eventService.LogGroupEventsAsync(
updateGroups.Select(g => (g, EventType.Group_Updated, (EventSystemUser?)eventSystemUser,
(DateTime?)DateTime.UtcNow)));
}
}
await _eventService.LogOrganizationUserEventsAsync(events.Select(e => (e.ou, e.e, eventSystemUser, e.d)));
}
public async Task DeleteSsoUserAsync(Guid userId, Guid? organizationId)
{
@@ -1150,18 +937,6 @@ public class OrganizationService : IOrganizationService
}
}
private async Task UpdateUsersAsync(Group group, HashSet<string> groupUsers,
Dictionary<string, Guid> existingUsersIdDict, HashSet<Guid> existingUsers = null)
{
var availableUsers = groupUsers.Intersect(existingUsersIdDict.Keys);
var users = new HashSet<Guid>(availableUsers.Select(u => existingUsersIdDict[u]));
if (existingUsers != null && existingUsers.Count == users.Count && users.SetEquals(existingUsers))
{
return;
}
await _groupRepository.UpdateUsersAsync(group.Id, users);
}
public async Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null)
{

View File

@@ -127,4 +127,16 @@ public class NoopEventService : IEventService
{
return Task.FromResult(0);
}
public Task LogUserProjectsEventAsync(Guid userId, IEnumerable<Project> projects, EventType type,
DateTime? date = null)
{
return Task.FromResult(0);
}
public Task LogServiceAccountProjectsEventAsync(Guid serviceAccountId, IEnumerable<Project> projects, EventType type,
DateTime? date = null)
{
return Task.FromResult(0);
}
}

View File

@@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Core.Test")]
[assembly: InternalsVisibleTo("Identity.IntegrationTest")]

View File

@@ -113,6 +113,21 @@ public static class StripeConstants
public const string SpanishNIF = "es_cif";
}
public static class TaxIdVerificationStatus
{
public const string Pending = "pending";
public const string Unavailable = "unavailable";
public const string Unverified = "unverified";
public const string Verified = "verified";
}
public static class TaxRegistrationStatus
{
public const string Active = "active";
public const string Expired = "expired";
public const string Scheduled = "scheduled";
}
public static class ValidateTaxLocationTiming
{
public const string Deferred = "deferred";

View File

@@ -36,6 +36,10 @@ public static class BillingExtensions
Status: ProviderStatusType.Billable
};
// Reseller types do not have Stripe entities
public static bool IsStripeSupported(this ProviderType providerType) =>
providerType is ProviderType.Msp or ProviderType.BusinessUnit;
public static bool SupportsConsolidatedBilling(this ProviderType providerType)
=> providerType is ProviderType.Msp or ProviderType.BusinessUnit;

View File

@@ -3,7 +3,6 @@
using System.Security.Claims;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Models.Business;
namespace Bit.Core.Billing.Licenses.Extensions;
@@ -14,7 +13,7 @@ public static class LicenseExtensions
{
if (subscriptionInfo?.Subscription == null)
{
if (org.PlanType == PlanType.Custom && org.ExpirationDate.HasValue)
if (org.ExpirationDate.HasValue)
{
return org.ExpirationDate.Value;
}

View File

@@ -4,7 +4,6 @@
using System.Globalization;
using System.Security.Claims;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Licenses.Models;
using Bit.Core.Enums;
@@ -121,6 +120,6 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory<Organizati
private static bool IsTrialing(Organization org, SubscriptionInfo subscriptionInfo) =>
subscriptionInfo?.Subscription is null
? org.PlanType != PlanType.Custom || !org.ExpirationDate.HasValue
? !org.ExpirationDate.HasValue
: subscriptionInfo.Subscription.TrialEndDate > DateTime.UtcNow;
}

View File

@@ -98,7 +98,7 @@ public class OrganizationLicense : ILicense
if (subscriptionInfo?.Subscription == null)
{
if (org.PlanType == PlanType.Custom && org.ExpirationDate.HasValue)
if (org.ExpirationDate.HasValue)
{
Expires = Refresh = org.ExpirationDate.Value;
Trial = false;

View File

@@ -5,6 +5,7 @@ public record OrganizationWarnings
public FreeTrialWarning? FreeTrial { get; set; }
public InactiveSubscriptionWarning? InactiveSubscription { get; set; }
public ResellerRenewalWarning? ResellerRenewal { get; set; }
public TaxIdWarning? TaxId { get; set; }
public record FreeTrialWarning
{
@@ -39,4 +40,9 @@ public record OrganizationWarnings
public required DateTime SuspensionDate { get; set; }
}
}
public record TaxIdWarning
{
public required string Type { get; set; }
}
}

View File

@@ -1,26 +1,25 @@
// ReSharper disable InconsistentNaming
using Bit.Core.AdminConsole.Entities;
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.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Services;
using Stripe;
using FreeTrialWarning = Bit.Core.Billing.Organizations.Models.OrganizationWarnings.FreeTrialWarning;
using InactiveSubscriptionWarning =
Bit.Core.Billing.Organizations.Models.OrganizationWarnings.InactiveSubscriptionWarning;
using ResellerRenewalWarning =
Bit.Core.Billing.Organizations.Models.OrganizationWarnings.ResellerRenewalWarning;
using Stripe.Tax;
namespace Bit.Core.Billing.Organizations.Queries;
using static StripeConstants;
using FreeTrialWarning = OrganizationWarnings.FreeTrialWarning;
using InactiveSubscriptionWarning = OrganizationWarnings.InactiveSubscriptionWarning;
using ResellerRenewalWarning = OrganizationWarnings.ResellerRenewalWarning;
using TaxIdWarning = OrganizationWarnings.TaxIdWarning;
public interface IGetOrganizationWarningsQuery
{
@@ -38,29 +37,31 @@ public class GetOrganizationWarningsQuery(
public async Task<OrganizationWarnings> Run(
Organization organization)
{
var response = new OrganizationWarnings();
var warnings = new OrganizationWarnings();
var subscription =
await subscriberService.GetSubscription(organization,
new SubscriptionGetOptions { Expand = ["customer", "latest_invoice", "test_clock"] });
new SubscriptionGetOptions { Expand = ["customer.tax_ids", "latest_invoice", "test_clock"] });
if (subscription == null)
{
return response;
return warnings;
}
response.FreeTrial = await GetFreeTrialWarning(organization, subscription);
warnings.FreeTrial = await GetFreeTrialWarningAsync(organization, subscription);
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id);
response.InactiveSubscription = await GetInactiveSubscriptionWarning(organization, provider, subscription);
warnings.InactiveSubscription = await GetInactiveSubscriptionWarningAsync(organization, provider, subscription);
response.ResellerRenewal = await GetResellerRenewalWarning(provider, subscription);
warnings.ResellerRenewal = await GetResellerRenewalWarningAsync(provider, subscription);
return response;
warnings.TaxId = await GetTaxIdWarningAsync(organization, subscription.Customer, provider);
return warnings;
}
private async Task<FreeTrialWarning?> GetFreeTrialWarning(
private async Task<FreeTrialWarning?> GetFreeTrialWarningAsync(
Organization organization,
Subscription subscription)
{
@@ -81,7 +82,7 @@ public class GetOrganizationWarningsQuery(
var customer = subscription.Customer;
var hasUnverifiedBankAccount = await HasUnverifiedBankAccount(organization);
var hasUnverifiedBankAccount = await HasUnverifiedBankAccountAsync(organization);
var hasPaymentMethod =
!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) ||
@@ -101,66 +102,51 @@ public class GetOrganizationWarningsQuery(
return new FreeTrialWarning { RemainingTrialDays = remainingTrialDays };
}
private async Task<InactiveSubscriptionWarning?> GetInactiveSubscriptionWarning(
private async Task<InactiveSubscriptionWarning?> GetInactiveSubscriptionWarningAsync(
Organization organization,
Provider? provider,
Subscription subscription)
{
// If the organization is enabled or the subscription is active, don't return a warning.
if (organization.Enabled || subscription is not
{
Status: SubscriptionStatus.Unpaid or SubscriptionStatus.Canceled
})
{
return null;
}
// If the organization is managed by a provider, return a warning asking them to contact the provider.
if (provider != null)
{
return new InactiveSubscriptionWarning { Resolution = "contact_provider" };
}
var isOrganizationOwner = await currentContext.OrganizationOwner(organization.Id);
switch (organization.Enabled)
/* If the organization is not managed by a provider and this user is the owner, return a warning based
on the subscription status. */
if (isOrganizationOwner)
{
// Member of an enabled, trialing organization.
case true when subscription.Status is SubscriptionStatus.Trialing:
return subscription.Status switch
{
SubscriptionStatus.Unpaid => new InactiveSubscriptionWarning
{
var hasUnverifiedBankAccount = await HasUnverifiedBankAccount(organization);
var hasPaymentMethod =
!string.IsNullOrEmpty(subscription.Customer.InvoiceSettings.DefaultPaymentMethodId) ||
!string.IsNullOrEmpty(subscription.Customer.DefaultSourceId) ||
hasUnverifiedBankAccount ||
subscription.Customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId);
// If this member is the owner and there's no payment method on file, ask them to add one.
return isOrganizationOwner && !hasPaymentMethod
? new InactiveSubscriptionWarning { Resolution = "add_payment_method_optional_trial" }
: null;
}
// Member of disabled and unpaid or canceled organization.
case false when subscription.Status is SubscriptionStatus.Unpaid or SubscriptionStatus.Canceled:
Resolution = "add_payment_method"
},
SubscriptionStatus.Canceled => new InactiveSubscriptionWarning
{
// If the organization is managed by a provider, return a warning asking them to contact the provider.
if (provider != null)
{
return new InactiveSubscriptionWarning { Resolution = "contact_provider" };
}
/* If the organization is not managed by a provider and this user is the owner, return an action warning based
on the subscription status. */
if (isOrganizationOwner)
{
return subscription.Status switch
{
SubscriptionStatus.Unpaid => new InactiveSubscriptionWarning
{
Resolution = "add_payment_method"
},
SubscriptionStatus.Canceled => new InactiveSubscriptionWarning
{
Resolution = "resubscribe"
},
_ => null
};
}
// Otherwise, this member is not the owner, and we need to ask them to contact the owner.
return new InactiveSubscriptionWarning { Resolution = "contact_owner" };
}
default: return null;
Resolution = "resubscribe"
},
_ => null
};
}
// Otherwise, return a warning asking them to contact the owner.
return new InactiveSubscriptionWarning { Resolution = "contact_owner" };
}
private async Task<ResellerRenewalWarning?> GetResellerRenewalWarning(
private async Task<ResellerRenewalWarning?> GetResellerRenewalWarningAsync(
Provider? provider,
Subscription subscription)
{
@@ -241,7 +227,62 @@ public class GetOrganizationWarningsQuery(
return null;
}
private async Task<bool> HasUnverifiedBankAccount(
private async Task<TaxIdWarning?> GetTaxIdWarningAsync(
Organization organization,
Customer customer,
Provider? provider)
{
var productTier = organization.PlanType.GetProductTier();
// Only business tier customers can have tax IDs
if (productTier is not ProductTierType.Teams and not ProductTierType.Enterprise)
{
return null;
}
// Only an organization owner can update a tax ID
if (!await currentContext.OrganizationOwner(organization.Id))
{
return null;
}
if (provider != null)
{
return null;
}
// Get active and scheduled registrations
var registrations = (await Task.WhenAll(
stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Active }),
stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Scheduled })))
.SelectMany(registrations => registrations.Data);
// Find the matching registration for the customer
var registration = registrations.FirstOrDefault(registration => registration.Country == customer.Address?.Country);
// If we're not registered in their country, we don't need a warning
if (registration == null)
{
return null;
}
var taxId = customer.TaxIds.FirstOrDefault();
return taxId switch
{
// Customer's tax ID is missing
null => new TaxIdWarning { Type = "tax_id_missing" },
// Not sure if this case is valid, but Stripe says this property is nullable
not null when taxId.Verification == null => null,
// Customer's tax ID is pending verification
not null when taxId.Verification.Status == TaxIdVerificationStatus.Pending => new TaxIdWarning { Type = "tax_id_pending_verification" },
// Customer's tax ID failed verification
not null when taxId.Verification.Status == TaxIdVerificationStatus.Unverified => new TaxIdWarning { Type = "tax_id_failed_verification" },
_ => null
};
}
private async Task<bool> HasUnverifiedBankAccountAsync(
Organization organization)
{
var setupIntentId = await setupIntentCache.Get(organization.Id);

View File

@@ -26,7 +26,6 @@ namespace Bit.Core.Billing.Organizations.Services;
public class OrganizationBillingService(
IBraintreeGateway braintreeGateway,
IFeatureService featureService,
IGlobalSettings globalSettings,
ILogger<OrganizationBillingService> logger,
IOrganizationRepository organizationRepository,
@@ -273,11 +272,9 @@ public class OrganizationBillingService(
ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately
};
var setNonUSBusinessUseToReverseCharge =
featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge &&
planType.GetProductTier() is not ProductTierType.Free and not ProductTierType.Families &&
if (planType.GetProductTier() is not ProductTierType.Free and not ProductTierType.Families &&
customerSetup.TaxInformation.Country != "US")
{
customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse;
@@ -491,24 +488,10 @@ public class OrganizationBillingService(
};
}
var setNonUSBusinessUseToReverseCharge =
featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge && customer.HasBillingLocation())
if (customer.HasBillingLocation())
{
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
}
else if (customer.HasRecognizedTaxLocation())
{
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled =
subscriptionSetup.PlanType.GetProductTier() == ProductTierType.Families ||
customer.Address.Country == "US" ||
customer.TaxIds.Any()
};
}
return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
}
@@ -519,9 +502,7 @@ public class OrganizationBillingService(
var customer = await subscriberService.GetCustomerOrThrow(organization,
new CustomerGetOptions { Expand = ["tax", "tax_ids"] });
var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (!setNonUSBusinessUseToReverseCharge || subscriptionSetup.PlanType.GetProductTier() is
if (subscriptionSetup.PlanType.GetProductTier() is
not (ProductTierType.Teams or
ProductTierType.TeamsStarter or
ProductTierType.Enterprise))

View File

@@ -7,11 +7,13 @@ using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Providers.Entities;
using Bit.Core.Billing.Providers.Migration.Models;
using Bit.Core.Billing.Providers.Models;
using Bit.Core.Billing.Providers.Repositories;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
@@ -253,7 +255,10 @@ public class ProviderMigrator(
var taxInfo = await paymentService.GetTaxInfoAsync(sampleOrganization);
var customer = await providerBillingService.SetupCustomer(provider, taxInfo);
// Create dummy payment source for legacy migration - this migrator is deprecated and will be removed
var dummyPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "migration_dummy_token");
var customer = await providerBillingService.SetupCustomer(provider, taxInfo, dummyPaymentSource);
await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
{

View File

@@ -0,0 +1,18 @@
namespace Bit.Core.Billing.Providers.Models;
public class ProviderWarnings
{
public SuspensionWarning? Suspension { get; set; }
public TaxIdWarning? TaxId { get; set; }
public record SuspensionWarning
{
public required string Resolution { get; set; }
public DateTime? SubscriptionCancelsAt { get; set; }
}
public record TaxIdWarning
{
public required string Type { get; set; }
}
}

View File

@@ -0,0 +1,9 @@
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Providers.Models;
namespace Bit.Core.Billing.Providers.Queries;
public interface IGetProviderWarningsQuery
{
Task<ProviderWarnings?> Run(Provider provider);
}

View File

@@ -88,7 +88,7 @@ public interface IProviderBillingService
Task<Customer> SetupCustomer(
Provider provider,
TaxInfo taxInfo,
TokenizedPaymentSource tokenizedPaymentSource = null);
TokenizedPaymentSource tokenizedPaymentSource);
/// <summary>
/// For use during the provider setup process, this method starts a Stripe <see cref="Stripe.Subscription"/> for the given <paramref name="provider"/>.

View File

@@ -157,4 +157,22 @@ public interface ISubscriberService
Task VerifyBankAccount(
ISubscriber subscriber,
string descriptorCode);
/// <summary>
/// Validates whether the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewayCustomerId"/> exists in the gateway.
/// If the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewayCustomerId"/> is <see langword="null"/> or empty, returns <see langword="true"/>.
/// </summary>
/// <param name="subscriber">The subscriber whose gateway customer ID should be validated.</param>
/// <returns><see langword="true"/> if the gateway customer ID is valid or empty; <see langword="false"/> if the customer doesn't exist in the gateway.</returns>
/// <exception cref="ArgumentNullException">Thrown when the <paramref name="subscriber"/> is <see langword="null"/>.</exception>
Task<bool> IsValidGatewayCustomerIdAsync(ISubscriber subscriber);
/// <summary>
/// Validates whether the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewaySubscriptionId"/> exists in the gateway.
/// If the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewaySubscriptionId"/> is <see langword="null"/> or empty, returns <see langword="true"/>.
/// </summary>
/// <param name="subscriber">The subscriber whose gateway subscription ID should be validated.</param>
/// <returns><see langword="true"/> if the gateway subscription ID is valid or empty; <see langword="false"/> if the subscription doesn't exist in the gateway.</returns>
/// <exception cref="ArgumentNullException">Thrown when the <paramref name="subscriber"/> is <see langword="null"/>.</exception>
Task<bool> IsValidGatewaySubscriptionIdAsync(ISubscriber subscriber);
}

View File

@@ -18,7 +18,7 @@ using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using IdentityModel;
using Duende.IdentityModel;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

View File

@@ -33,7 +33,6 @@ using static StripeConstants;
public class SubscriberService(
IBraintreeGateway braintreeGateway,
IFeatureService featureService,
IGlobalSettings globalSettings,
ILogger<SubscriberService> logger,
IOrganizationRepository organizationRepository,
@@ -802,28 +801,27 @@ public class SubscriberService(
_ => false
};
var setNonUSBusinessUseToReverseCharge =
featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge && isBusinessUseSubscriber)
if (isBusinessUseSubscriber)
{
switch (customer)
{
case
{
Address.Country: not "US",
TaxExempt: not StripeConstants.TaxExempt.Reverse
TaxExempt: not TaxExempt.Reverse
}:
await stripeAdapter.CustomerUpdateAsync(customer.Id,
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
new CustomerUpdateOptions { TaxExempt = TaxExempt.Reverse });
break;
case
{
Address.Country: "US",
TaxExempt: StripeConstants.TaxExempt.Reverse
TaxExempt: TaxExempt.Reverse
}:
await stripeAdapter.CustomerUpdateAsync(customer.Id,
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.None });
new CustomerUpdateOptions { TaxExempt = TaxExempt.None });
break;
}
@@ -909,6 +907,44 @@ public class SubscriberService(
}
}
public async Task<bool> IsValidGatewayCustomerIdAsync(ISubscriber subscriber)
{
ArgumentNullException.ThrowIfNull(subscriber);
if (string.IsNullOrEmpty(subscriber.GatewayCustomerId))
{
// subscribers are allowed to have no customer id as a business rule
return true;
}
try
{
await stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId);
return true;
}
catch (StripeException e) when (e.StripeError.Code == "resource_missing")
{
return false;
}
}
public async Task<bool> IsValidGatewaySubscriptionIdAsync(ISubscriber subscriber)
{
ArgumentNullException.ThrowIfNull(subscriber);
if (string.IsNullOrEmpty(subscriber.GatewaySubscriptionId))
{
// subscribers are allowed to have no subscription id as a business rule
return true;
}
try
{
await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId);
return true;
}
catch (StripeException e) when (e.StripeError.Code == "resource_missing")
{
return false;
}
}
#region Shared Utilities
private async Task AddBraintreeCustomerIdAsync(

View File

@@ -113,7 +113,6 @@ public static class FeatureFlagKeys
public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast";
public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations";
public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions";
public const string ImportAsyncRefactor = "pm-22583-refactor-import-async";
public const string CreateDefaultLocation = "pm-19467-create-default-location";
public const string DirectoryConnectorPreventUserRemoval = "pm-24592-directory-connector-prevent-user-removal";
@@ -154,13 +153,10 @@ public static class FeatureFlagKeys
public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features";
public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates";
public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically";
public const string PM19956_RequireProviderPaymentMethodDuringSetup = "pm-19956-require-provider-payment-method-during-setup";
public const string UseOrganizationWarningsService = "use-organization-warnings-service";
public const string PM20322_AllowTrialLength0 = "pm-20322-allow-trial-length-0";
public const string PM21092_SetNonUSBusinessUseToReverseCharge = "pm-21092-set-non-us-business-use-to-reverse-charge";
public const string PM21383_GetProviderPriceFromStripe = "pm-21383-get-provider-price-from-stripe";
public const string PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout";
public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover";
public const string PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings";
/* Key Management Team */
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";

View File

@@ -50,7 +50,7 @@
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" />
<PackageReference Include="Sentry.Serilog" Version="5.0.0" />
<PackageReference Include="Duende.IdentityServer" Version="7.0.8" />
<PackageReference Include="Duende.IdentityServer" Version="7.2.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog.Sinks.SyslogMessages" Version="4.0.0" />
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />

View File

@@ -3,8 +3,8 @@
<tr>
<td
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-style: normal; font-weight: 400; font-size: 16px; line-height: 24px;">
Keep you and your organization's data safe by changing passwords that are weak, reused, or have been exposed in a
data breach.
Keep yourself and your organization's data safe by changing passwords that are weak, reused, or have been exposed
in a data breach.
</td>
</tr>
<tr>

View File

@@ -1,6 +1,6 @@
{{#>SecurityTasksHtmlLayout}}
Keep you and your organization's data safe by changing passwords that are weak, reused, or have been exposed in a data
breach.
Keep yourself and your organization's data safe by changing passwords that are weak, reused, or have been exposed in a
data breach.
Launch the Bitwarden extension to review your at-risk passwords.

View File

@@ -16,6 +16,7 @@ public interface ISecretRepository
Task<IEnumerable<Secret>> GetManyByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType);
Task<IEnumerable<Secret>> GetManyByOrganizationIdInTrashByIdsAsync(Guid organizationId, IEnumerable<Guid> ids);
Task<IEnumerable<Secret>> GetManyByIds(IEnumerable<Guid> ids);
Task<IEnumerable<Secret>> GetManyTrashedSecretsByIds(IEnumerable<Guid> ids);
Task<Secret> GetByIdAsync(Guid id);
Task<Secret> CreateAsync(Secret secret, SecretAccessPoliciesUpdates accessPoliciesUpdates = null);
Task<Secret> UpdateAsync(Secret secret, SecretAccessPoliciesUpdates accessPoliciesUpdates = null);

View File

@@ -105,4 +105,6 @@ public class NoopSecretRepository : ISecretRepository
{
return Task.FromResult(0);
}
public Task<IEnumerable<Secret>> GetManyTrashedSecretsByIds(IEnumerable<Guid> ids) => Task.FromResult<IEnumerable<Secret>>([]);
}

View File

@@ -48,6 +48,7 @@ public interface IStripeAdapter
Task<Stripe.PaymentMethod> PaymentMethodDetachAsync(string id, Stripe.PaymentMethodDetachOptions options = null);
Task<Stripe.TaxId> TaxIdCreateAsync(string id, Stripe.TaxIdCreateOptions options);
Task<Stripe.TaxId> TaxIdDeleteAsync(string customerId, string taxIdId, Stripe.TaxIdDeleteOptions options = null);
Task<Stripe.StripeList<Stripe.Tax.Registration>> TaxRegistrationsListAsync(Stripe.Tax.RegistrationListOptions options = null);
Task<Stripe.StripeList<Stripe.Charge>> ChargeListAsync(Stripe.ChargeListOptions options);
Task<Stripe.Refund> RefundCreateAsync(Stripe.RefundCreateOptions options);
Task<Stripe.Card> CardDeleteAsync(string customerId, string cardId, Stripe.CardDeleteOptions options = null);

View File

@@ -24,6 +24,7 @@ using Bit.Core.Utilities;
using Bit.Core.Vault.Models.Data;
using Core.Auth.Enums;
using HandlebarsDotNet;
using Microsoft.Extensions.Caching.Distributed;
namespace Bit.Core.Services;
@@ -31,10 +32,12 @@ public class HandlebarsMailService : IMailService
{
private const string Namespace = "Bit.Core.MailTemplates.Handlebars";
private const string _utcTimeZoneDisplay = "UTC";
private const string FailedTwoFactorAttemptCacheKeyFormat = "FailedTwoFactorAttemptEmail_{0}";
private readonly GlobalSettings _globalSettings;
private readonly IMailDeliveryService _mailDeliveryService;
private readonly IMailEnqueuingService _mailEnqueuingService;
private readonly IDistributedCache _distributedCache;
private readonly Dictionary<string, HandlebarsTemplate<object, object>> _templateCache = new();
private bool _registeredHelpersAndPartials = false;
@@ -42,11 +45,13 @@ public class HandlebarsMailService : IMailService
public HandlebarsMailService(
GlobalSettings globalSettings,
IMailDeliveryService mailDeliveryService,
IMailEnqueuingService mailEnqueuingService)
IMailEnqueuingService mailEnqueuingService,
IDistributedCache distributedCache)
{
_globalSettings = globalSettings;
_mailDeliveryService = mailDeliveryService;
_mailEnqueuingService = mailEnqueuingService;
_distributedCache = distributedCache;
}
public async Task SendVerifyEmailEmailAsync(string email, Guid userId, string token)
@@ -196,6 +201,16 @@ public class HandlebarsMailService : IMailService
public async Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip)
{
// Check if we've sent this email within the last hour
var cacheKey = string.Format(FailedTwoFactorAttemptCacheKeyFormat, email);
var cachedValue = await _distributedCache.GetAsync(cacheKey);
if (cachedValue != null)
{
// Email was already sent within the last hour, skip sending
return;
}
var message = CreateDefaultMessage("Failed two-step login attempt detected", email);
var model = new FailedAuthAttemptModel()
{
@@ -211,6 +226,13 @@ public class HandlebarsMailService : IMailService
await AddMessageContentAsync(message, "Auth.FailedTwoFactorAttempt", model);
message.Category = "FailedTwoFactorAttempt";
await _mailDeliveryService.SendEmailAsync(message);
// Set cache entry with 1 hour expiration to prevent sending again
var cacheOptions = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
};
await _distributedCache.SetAsync(cacheKey, [1], cacheOptions);
}
public async Task SendMasterPasswordHintEmailAsync(string email, string hint)

View File

@@ -22,6 +22,7 @@ public class StripeAdapter : IStripeAdapter
private readonly Stripe.SetupIntentService _setupIntentService;
private readonly Stripe.TestHelpers.TestClockService _testClockService;
private readonly CustomerBalanceTransactionService _customerBalanceTransactionService;
private readonly Stripe.Tax.RegistrationService _taxRegistrationService;
public StripeAdapter()
{
@@ -39,6 +40,7 @@ public class StripeAdapter : IStripeAdapter
_setupIntentService = new SetupIntentService();
_testClockService = new Stripe.TestHelpers.TestClockService();
_customerBalanceTransactionService = new CustomerBalanceTransactionService();
_taxRegistrationService = new Stripe.Tax.RegistrationService();
}
public Task<Stripe.Customer> CustomerCreateAsync(Stripe.CustomerCreateOptions options)
@@ -208,6 +210,11 @@ public class StripeAdapter : IStripeAdapter
return _taxIdService.DeleteAsync(customerId, taxIdId);
}
public Task<Stripe.StripeList<Stripe.Tax.Registration>> TaxRegistrationsListAsync(Stripe.Tax.RegistrationListOptions options = null)
{
return _taxRegistrationService.ListAsync(options);
}
public Task<Stripe.StripeList<Stripe.Charge>> ChargeListAsync(Stripe.ChargeListOptions options)
{
return _chargeService.ListAsync(options);

View File

@@ -1,11 +1,10 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Organizations.Models;
@@ -136,69 +135,17 @@ public class StripePaymentService : IPaymentService
if (subscriptionUpdate is CompleteSubscriptionUpdate)
{
var setNonUSBusinessUseToReverseCharge =
_featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge)
{
if (sub.Customer is
{
Address.Country: not "US",
TaxExempt: not StripeConstants.TaxExempt.Reverse
})
if (sub.Customer is
{
await _stripeAdapter.CustomerUpdateAsync(sub.CustomerId,
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
}
subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
}
else if (sub.Customer.HasRecognizedTaxLocation())
Address.Country: not "US",
TaxExempt: not StripeConstants.TaxExempt.Reverse
})
{
switch (subscriber)
{
case User:
{
subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
break;
}
case Organization:
{
if (sub.Customer.Address.Country == "US")
{
subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
}
else
{
var familyPriceIds = (await Task.WhenAll(
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019),
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually)))
.Select(plan => plan.PasswordManager.StripePlanId);
var updateIsForPersonalUse = updatedItemOptions
.Select(option => option.Price)
.Intersect(familyPriceIds)
.Any();
subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = updateIsForPersonalUse || sub.Customer.TaxIds.Any()
};
}
break;
}
case Provider:
{
subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = sub.Customer.Address.Country == "US" ||
sub.Customer.TaxIds.Any()
};
break;
}
}
await _stripeAdapter.CustomerUpdateAsync(sub.CustomerId,
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
}
subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
}
if (!subscriptionUpdate.UpdateNeeded(sub))

View File

@@ -21,7 +21,7 @@ using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Identity;
using Bit.Core.Settings;
using IdentityModel;
using Duende.IdentityModel;
using Microsoft.AspNetCore.DataProtection;
using MimeKit;

View File

@@ -5,7 +5,7 @@ using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.SharedWeb.Utilities;
using IdentityModel;
using Duende.IdentityModel;
namespace Bit.Events;

View File

@@ -1,7 +1,5 @@
using Bit.Core;
using Bit.Core.Billing.Models.Api.Requests.Accounts;
using Bit.Core.Billing.Models.Api.Requests.Accounts;
using Bit.Core.Billing.TrialInitiation.Registration;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.SharedWeb.Utilities;
using Microsoft.AspNetCore.Mvc;
@@ -11,16 +9,13 @@ namespace Bit.Identity.Billing.Controller;
[Route("accounts")]
[ExceptionHandlerFilter]
public class AccountsController(
ISendTrialInitiationEmailForRegistrationCommand sendTrialInitiationEmailForRegistrationCommand,
IFeatureService featureService) : Microsoft.AspNetCore.Mvc.Controller
ISendTrialInitiationEmailForRegistrationCommand sendTrialInitiationEmailForRegistrationCommand) : Microsoft.AspNetCore.Mvc.Controller
{
[HttpPost("trial/send-verification-email")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IActionResult> PostTrialInitiationSendVerificationEmailAsync([FromBody] TrialSendVerificationEmailRequestModel model)
{
var allowTrialLength0 = featureService.IsEnabled(FeatureFlagKeys.PM20322_AllowTrialLength0);
var trialLength = allowTrialLength0 ? model.TrialLength ?? 7 : 7;
var trialLength = model.TrialLength ?? 7;
var token = await sendTrialInitiationEmailForRegistrationCommand.Handle(
model.Email,

View File

@@ -8,9 +8,9 @@ using Bit.Core.Entities;
using Bit.Core.Models.Api;
using Bit.Core.Repositories;
using Bit.Identity.Models;
using Duende.IdentityModel;
using Duende.IdentityServer;
using Duende.IdentityServer.Services;
using IdentityModel;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc;

View File

@@ -1,7 +1,7 @@
using Bit.Core.Identity;
using Bit.Core.IdentityServer;
using Duende.IdentityModel;
using Duende.IdentityServer.Models;
using IdentityModel;
namespace Bit.Identity.IdentityServer;

View File

@@ -3,8 +3,8 @@
using Bit.Core.IdentityServer;
using Bit.Core.Platform.Installations;
using Duende.IdentityModel;
using Duende.IdentityServer.Models;
using IdentityModel;
namespace Bit.Identity.IdentityServer.ClientProviders;

View File

@@ -3,8 +3,8 @@
using System.Diagnostics;
using Bit.Core.IdentityServer;
using Bit.Core.Settings;
using Duende.IdentityModel;
using Duende.IdentityServer.Models;
using IdentityModel;
namespace Bit.Identity.IdentityServer.ClientProviders;

View File

@@ -5,8 +5,8 @@ using Bit.Core.Enums;
using Bit.Core.Identity;
using Bit.Core.IdentityServer;
using Bit.Core.Repositories;
using Duende.IdentityModel;
using Duende.IdentityServer.Models;
using IdentityModel;
namespace Bit.Identity.IdentityServer.ClientProviders;

View File

@@ -5,8 +5,8 @@ using Bit.Core.Identity;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Models.Data;
using Bit.Core.SecretsManager.Repositories;
using Duende.IdentityModel;
using Duende.IdentityServer.Models;
using IdentityModel;
namespace Bit.Identity.IdentityServer.ClientProviders;

View File

@@ -8,8 +8,8 @@ using Bit.Core.Context;
using Bit.Core.Identity;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Duende.IdentityModel;
using Duende.IdentityServer.Models;
using IdentityModel;
namespace Bit.Identity.IdentityServer.ClientProviders;

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