1
0
mirror of https://github.com/bitwarden/server synced 2026-01-28 15:23:38 +00:00

Merge branch 'main' into auth/pm-27084/register-accepts-new-data-types

This commit is contained in:
Patrick-Pimentel-Bitwarden
2025-12-22 21:52:02 -05:00
committed by GitHub
357 changed files with 22977 additions and 5580 deletions

View File

@@ -1,25 +0,0 @@
Please review this pull request with a focus on:
- Code quality and best practices
- Potential bugs or issues
- Security implications
- Performance considerations
Note: The PR branch is already checked out in the current working directory.
Provide a comprehensive review including:
- Summary of changes since last review
- Critical issues found (be thorough)
- Suggested improvements (be thorough)
- Good practices observed (be concise - list only the most notable items without elaboration)
- Action items for the author
- Leverage collapsible <details> sections where appropriate for lengthy explanations or code snippets to enhance human readability
When reviewing subsequent commits:
- Track status of previously identified issues (fixed/unfixed/reopened)
- Identify NEW problems introduced since last review
- Note if fixes introduced new issues
IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively.

7
.github/CODEOWNERS vendored
View File

@@ -53,6 +53,11 @@ src/Core/IdentityServer @bitwarden/team-auth-dev
# Dirt (Data Insights & Reporting) team
**/Dirt @bitwarden/team-data-insights-and-reporting-dev
src/Events @bitwarden/team-data-insights-and-reporting-dev
src/EventsProcessor @bitwarden/team-data-insights-and-reporting-dev
test/Events.IntegrationTest @bitwarden/team-data-insights-and-reporting-dev
test/Events.Test @bitwarden/team-data-insights-and-reporting-dev
test/EventsProcessor.Test @bitwarden/team-data-insights-and-reporting-dev
# Vault team
**/Vault @bitwarden/team-vault-dev
@@ -63,8 +68,6 @@ src/Core/IdentityServer @bitwarden/team-auth-dev
bitwarden_license/src/Scim @bitwarden/team-admin-console-dev
bitwarden_license/src/test/Scim.IntegrationTest @bitwarden/team-admin-console-dev
bitwarden_license/src/test/Scim.ScimTest @bitwarden/team-admin-console-dev
src/Events @bitwarden/team-admin-console-dev
src/EventsProcessor @bitwarden/team-admin-console-dev
# Billing team
**/*billing* @bitwarden/team-billing-dev

View File

@@ -38,7 +38,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Check out branch
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
persist-credentials: false
@@ -68,7 +68,7 @@ jobs:
if: ${{ needs.setup.outputs.copy_edd_scripts == 'true' }}
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
persist-credentials: true

View File

@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
@@ -102,7 +102,7 @@ jobs:
echo "has_secrets=$has_secrets" >> "$GITHUB_OUTPUT"
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
@@ -289,7 +289,7 @@ jobs:
actions: read
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
@@ -416,7 +416,7 @@ jobs:
- win-x64
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false

View File

@@ -31,7 +31,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Checkout main
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: main
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}

View File

@@ -36,7 +36,7 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false

View File

@@ -87,7 +87,7 @@ jobs:
datadog/agent:7-full@sha256:7ea933dec3b8baa8c19683b1c3f6f801dbf3291f748d9ed59234accdaac4e479
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false

View File

@@ -31,7 +31,7 @@ jobs:
label: "DB-migrations-changed"
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 2
persist-credentials: false

View File

@@ -106,7 +106,7 @@ jobs:
echo "Github Release Option: $RELEASE_OPTION"
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
persist-credentials: false

View File

@@ -39,7 +39,7 @@ jobs:
fi
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
persist-credentials: false

View File

@@ -91,7 +91,7 @@ jobs:
permission-contents: write
- name: Check out branch
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: main
token: ${{ steps.app-token.outputs.token }}
@@ -215,7 +215,7 @@ jobs:
permission-contents: write
- name: Check out target ref
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ inputs.target_ref }}
token: ${{ steps.app-token.outputs.token }}

View File

@@ -15,7 +15,7 @@ jobs:
pull-requests: write
steps:
- name: Check
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
with:
stale-issue-label: "needs-reply"
stale-pr-label: "needs-changes"

View File

@@ -44,7 +44,7 @@ jobs:
checks: write
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
@@ -178,7 +178,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
@@ -269,7 +269,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
persist-credentials: false

View File

@@ -27,7 +27,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false

1
.gitignore vendored
View File

@@ -234,6 +234,7 @@ bitwarden_license/src/Sso/Sso.zip
/identity.json
/api.json
/api.public.json
.serena/
# Serena
.serena/

View File

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

View File

@@ -113,7 +113,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
await _providerBillingService.CreateCustomerForClientOrganization(provider, organization);
}
var customer = await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
var customer = await _stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
{
Description = string.Empty,
Email = organization.BillingEmail,
@@ -138,7 +138,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
var subscription = await _stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions);
organization.GatewaySubscriptionId = subscription.Id;
organization.Status = OrganizationStatusType.Created;
@@ -148,27 +148,26 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
}
else if (organization.IsStripeEnabled())
{
var subscription = await _stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId, new SubscriptionGetOptions
var subscription = await _stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId, new SubscriptionGetOptions
{
Expand = ["customer"]
});
if (subscription.Status is StripeConstants.SubscriptionStatus.Canceled or StripeConstants.SubscriptionStatus.IncompleteExpired)
{
return;
}
await _stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions
await _stripeAdapter.UpdateCustomerAsync(subscription.CustomerId, new CustomerUpdateOptions
{
Email = organization.BillingEmail
});
if (subscription.Customer.Discount?.Coupon != null)
{
await _stripeAdapter.CustomerDeleteDiscountAsync(subscription.CustomerId);
await _stripeAdapter.DeleteCustomerDiscountAsync(subscription.CustomerId);
}
await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, new SubscriptionUpdateOptions
await _stripeAdapter.UpdateSubscriptionAsync(organization.GatewaySubscriptionId, new SubscriptionUpdateOptions
{
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
DaysUntilDue = 30,

View File

@@ -9,12 +9,16 @@ using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Business.Provider;
using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
@@ -59,6 +63,7 @@ public class ProviderService : IProviderService
private readonly IProviderBillingService _providerBillingService;
private readonly IPricingClient _pricingClient;
private readonly IProviderClientOrganizationSignUpCommand _providerClientOrganizationSignUpCommand;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository,
IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository,
@@ -68,7 +73,8 @@ public class ProviderService : IProviderService
ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService,
IDataProtectorTokenFactory<ProviderDeleteTokenable> providerDeleteTokenDataFactory,
IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService, IPricingClient pricingClient,
IProviderClientOrganizationSignUpCommand providerClientOrganizationSignUpCommand)
IProviderClientOrganizationSignUpCommand providerClientOrganizationSignUpCommand,
IPolicyRequirementQuery policyRequirementQuery)
{
_providerRepository = providerRepository;
_providerUserRepository = providerUserRepository;
@@ -89,6 +95,7 @@ public class ProviderService : IProviderService
_providerBillingService = providerBillingService;
_pricingClient = pricingClient;
_providerClientOrganizationSignUpCommand = providerClientOrganizationSignUpCommand;
_policyRequirementQuery = policyRequirementQuery;
}
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress)
@@ -116,6 +123,18 @@ public class ProviderService : IProviderService
throw new BadRequestException("Invalid owner.");
}
if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
{
var organizationAutoConfirmPolicyRequirement = await _policyRequirementQuery
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(ownerUserId);
if (organizationAutoConfirmPolicyRequirement
.CannotCreateProvider())
{
throw new BadRequestException(new UserCannotJoinProvider().Message);
}
}
var customer = await _providerBillingService.SetupCustomer(provider, paymentMethod, billingAddress);
provider.GatewayCustomerId = customer.Id;
var subscription = await _providerBillingService.SetupSubscription(provider);
@@ -248,6 +267,18 @@ public class ProviderService : IProviderService
throw new BadRequestException("User email does not match invite.");
}
if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
{
var organizationAutoConfirmPolicyRequirement = await _policyRequirementQuery
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(user.Id);
if (organizationAutoConfirmPolicyRequirement
.CannotJoinProvider())
{
throw new BadRequestException(new UserCannotJoinProvider().Message);
}
}
providerUser.Status = ProviderUserStatusType.Accepted;
providerUser.UserId = user.Id;
providerUser.Email = null;
@@ -293,6 +324,19 @@ public class ProviderService : IProviderService
throw new BadRequestException("Invalid user.");
}
if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
{
var organizationAutoConfirmPolicyRequirement = await _policyRequirementQuery
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(user.Id);
if (organizationAutoConfirmPolicyRequirement
.CannotJoinProvider())
{
result.Add(Tuple.Create(providerUser, new UserCannotJoinProvider().Message));
continue;
}
}
providerUser.Status = ProviderUserStatusType.Confirmed;
providerUser.Key = keys[providerUser.Id];
providerUser.Email = null;
@@ -427,7 +471,7 @@ public class ProviderService : IProviderService
if (!string.IsNullOrEmpty(organization.GatewayCustomerId))
{
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
await _stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
{
Email = provider.BillingEmail
});
@@ -487,7 +531,7 @@ public class ProviderService : IProviderService
private async Task<SubscriptionItem> GetSubscriptionItemAsync(string subscriptionId, string oldPlanId)
{
var subscriptionDetails = await _stripeAdapter.SubscriptionGetAsync(subscriptionId);
var subscriptionDetails = await _stripeAdapter.GetSubscriptionAsync(subscriptionId);
return subscriptionDetails.Items.Data.FirstOrDefault(item => item.Price.Id == oldPlanId);
}
@@ -497,7 +541,7 @@ public class ProviderService : IProviderService
{
if (subscriptionItem.Price.Id != extractedPlanType)
{
await _stripeAdapter.SubscriptionUpdateAsync(subscriptionItem.Subscription,
await _stripeAdapter.UpdateSubscriptionAsync(subscriptionItem.Subscription,
new Stripe.SubscriptionUpdateOptions
{
Items = new List<Stripe.SubscriptionItemOptions>

View File

@@ -4,7 +4,6 @@ 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;
@@ -76,8 +75,8 @@ public class GetProviderWarningsQuery(
// Get active and scheduled registrations
var registrations = (await Task.WhenAll(
stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Active }),
stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Scheduled })))
stripeAdapter.ListTaxRegistrationsAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Active }),
stripeAdapter.ListTaxRegistrationsAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Scheduled })))
.SelectMany(registrations => registrations.Data);
// Find the matching registration for the customer

View File

@@ -101,7 +101,7 @@ public class BusinessUnitConverter(
providerUser.Status = ProviderUserStatusType.Confirmed;
// Stripe requires that we clear all the custom fields from the invoice settings if we want to replace them.
await stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions
await stripeAdapter.UpdateCustomerAsync(subscription.CustomerId, new CustomerUpdateOptions
{
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
@@ -116,7 +116,7 @@ public class BusinessUnitConverter(
["convertedFrom"] = organization.Id.ToString()
};
var updateCustomer = stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions
var updateCustomer = stripeAdapter.UpdateCustomerAsync(subscription.CustomerId, new CustomerUpdateOptions
{
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
@@ -148,7 +148,7 @@ public class BusinessUnitConverter(
// Replace the existing password manager price with the new business unit price.
var updateSubscription =
stripeAdapter.SubscriptionUpdateAsync(subscription.Id,
stripeAdapter.UpdateSubscriptionAsync(subscription.Id,
new SubscriptionUpdateOptions
{
Items = [

View File

@@ -61,11 +61,11 @@ public class ProviderBillingService(
Organization organization,
string key)
{
await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
await stripeAdapter.UpdateSubscriptionAsync(organization.GatewaySubscriptionId,
new SubscriptionUpdateOptions { CancelAtPeriodEnd = false });
var subscription =
await stripeAdapter.SubscriptionCancelAsync(organization.GatewaySubscriptionId,
await stripeAdapter.CancelSubscriptionAsync(organization.GatewaySubscriptionId,
new SubscriptionCancelOptions
{
CancellationDetails = new SubscriptionCancellationDetailsOptions
@@ -83,7 +83,7 @@ public class ProviderBillingService(
if (!wasTrialing && subscription.LatestInvoice.Status == InvoiceStatus.Draft)
{
await stripeAdapter.InvoiceFinalizeInvoiceAsync(subscription.LatestInvoiceId,
await stripeAdapter.FinalizeInvoiceAsync(subscription.LatestInvoiceId,
new InvoiceFinalizeOptions { AutoAdvance = true });
}
@@ -138,7 +138,7 @@ public class ProviderBillingService(
if (clientCustomer.Balance != 0)
{
await stripeAdapter.CustomerBalanceTransactionCreate(provider.GatewayCustomerId,
await stripeAdapter.CreateCustomerBalanceTransactionAsync(provider.GatewayCustomerId,
new CustomerBalanceTransactionCreateOptions
{
Amount = clientCustomer.Balance,
@@ -187,7 +187,7 @@ public class ProviderBillingService(
]
};
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, updateOptions);
await stripeAdapter.UpdateSubscriptionAsync(provider.GatewaySubscriptionId, updateOptions);
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
// 1. Retrieve PlanType and PlanName for ProviderPlan
@@ -275,7 +275,7 @@ public class ProviderBillingService(
customerCreateOptions.TaxExempt = TaxExempt.Reverse;
}
var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions);
var customer = await stripeAdapter.CreateCustomerAsync(customerCreateOptions);
organization.GatewayCustomerId = customer.Id;
@@ -525,7 +525,7 @@ public class ProviderBillingService(
case TokenizablePaymentMethodType.BankAccount:
{
var setupIntent =
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions
(await stripeAdapter.ListSetupIntentsAsync(new SetupIntentListOptions
{
PaymentMethod = paymentMethod.Token
}))
@@ -558,7 +558,7 @@ public class ProviderBillingService(
try
{
return await stripeAdapter.CustomerCreateAsync(options);
return await stripeAdapter.CreateCustomerAsync(options);
}
catch (StripeException stripeException) when (stripeException.StripeError?.Code == ErrorCodes.TaxIdInvalid)
{
@@ -580,7 +580,7 @@ public class ProviderBillingService(
case TokenizablePaymentMethodType.BankAccount:
{
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id);
await stripeAdapter.SetupIntentCancel(setupIntentId,
await stripeAdapter.CancelSetupIntentAsync(setupIntentId,
new SetupIntentCancelOptions { CancellationReason = "abandoned" });
await setupIntentCache.RemoveSetupIntentForSubscriber(provider.Id);
break;
@@ -638,7 +638,7 @@ public class ProviderBillingService(
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id);
var setupIntent = !string.IsNullOrEmpty(setupIntentId)
? await stripeAdapter.SetupIntentGet(setupIntentId,
? await stripeAdapter.GetSetupIntentAsync(setupIntentId,
new SetupIntentGetOptions { Expand = ["payment_method"] })
: null;
@@ -673,7 +673,7 @@ public class ProviderBillingService(
try
{
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions);
if (subscription is
{
@@ -708,7 +708,7 @@ public class ProviderBillingService(
subscriberService.UpdatePaymentSource(provider, tokenizedPaymentSource),
subscriberService.UpdateTaxInformation(provider, taxInformation));
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
await stripeAdapter.UpdateSubscriptionAsync(provider.GatewaySubscriptionId,
new SubscriptionUpdateOptions { CollectionMethod = CollectionMethod.ChargeAutomatically });
}
@@ -791,11 +791,49 @@ public class ProviderBillingService(
if (subscriptionItemOptionsList.Count > 0)
{
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
await stripeAdapter.UpdateSubscriptionAsync(provider.GatewaySubscriptionId,
new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList });
}
}
public async Task UpdateProviderNameAndEmail(Provider provider)
{
if (string.IsNullOrWhiteSpace(provider.GatewayCustomerId))
{
logger.LogWarning(
"Provider ({ProviderId}) has no Stripe customer to update",
provider.Id);
return;
}
var newDisplayName = provider.DisplayName();
// Provider.DisplayName() can return null - handle gracefully
if (string.IsNullOrWhiteSpace(newDisplayName))
{
logger.LogWarning(
"Provider ({ProviderId}) has no name to update in Stripe",
provider.Id);
return;
}
await stripeAdapter.UpdateCustomerAsync(provider.GatewayCustomerId,
new CustomerUpdateOptions
{
Email = provider.BillingEmail,
Description = newDisplayName,
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
CustomFields = [
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = provider.SubscriberType(),
Value = newDisplayName
}]
},
});
}
private Func<int, Task> CurrySeatScalingUpdate(
Provider provider,
ProviderPlan providerPlan,
@@ -807,7 +845,7 @@ public class ProviderBillingService(
var item = subscription.Items.First(item => item.Price.Id == priceId);
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions
await stripeAdapter.UpdateSubscriptionAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions
{
Items =
[

View File

@@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.E
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.AdminConsole.Utilities.Commands;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
@@ -24,7 +25,7 @@ public class PostUserCommand(
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService,
IPaymentService paymentService,
IStripePaymentService paymentService,
IScimContext scimContext,
IFeatureService featureService,
IInviteOrganizationUsersCommand inviteOrganizationUsersCommand,

View File

@@ -201,12 +201,15 @@ public class AccountController : Controller
returnUrl,
state = context.Parameters["state"],
userIdentifier = context.Parameters["session_state"],
ssoToken
});
}
[HttpGet]
public IActionResult ExternalChallenge(string scheme, string returnUrl, string state, string userIdentifier)
public IActionResult ExternalChallenge(string scheme, string returnUrl, string state, string userIdentifier, string ssoToken)
{
ValidateSchemeAgainstSsoToken(scheme, ssoToken);
if (string.IsNullOrEmpty(returnUrl))
{
returnUrl = "~/";
@@ -235,6 +238,31 @@ public class AccountController : Controller
return Challenge(props, scheme);
}
/// <summary>
/// Validates the scheme (organization ID) against the organization ID found in the ssoToken.
/// </summary>
/// <param name="scheme">The authentication scheme (organization ID) to validate.</param>
/// <param name="ssoToken">The SSO token to validate against.</param>
/// <exception cref="Exception">Thrown if the scheme (organization ID) does not match the organization ID found in the ssoToken.</exception>
private void ValidateSchemeAgainstSsoToken(string scheme, string ssoToken)
{
SsoTokenable tokenable;
try
{
tokenable = _dataProtector.Unprotect(ssoToken);
}
catch
{
throw new Exception(_i18nService.T("InvalidSsoToken"));
}
if (!Guid.TryParse(scheme, out var schemeOrgId) || tokenable.OrganizationId != schemeOrgId)
{
throw new Exception(_i18nService.T("SsoOrganizationIdMismatch"));
}
}
[HttpGet]
public async Task<IActionResult> ExternalCallback()
{

View File

@@ -131,7 +131,7 @@ public class RemoveOrganizationFromProviderCommandTests
Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com"));
await sutProvider.GetDependency<IStripeAdapter>().DidNotReceiveWithAnyArgs()
.CustomerUpdateAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
}
[Theory, BitAutoData]
@@ -156,7 +156,7 @@ public class RemoveOrganizationFromProviderCommandTests
"b@example.com"
]);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId, Arg.Is<SubscriptionGetOptions>(
sutProvider.GetDependency<IStripeAdapter>().GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Is<SubscriptionGetOptions>(
options => options.Expand.Contains("customer")))
.Returns(GetSubscription(organization.GatewaySubscriptionId, organization.GatewayCustomerId));
@@ -164,12 +164,14 @@ public class RemoveOrganizationFromProviderCommandTests
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
await stripeAdapter.Received(1).CustomerUpdateAsync(organization.GatewayCustomerId,
await stripeAdapter.Received(1).UpdateCustomerAsync(organization.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(options => options.Email == "a@example.com"));
await stripeAdapter.Received(1).CustomerDeleteDiscountAsync(organization.GatewayCustomerId);
await stripeAdapter.Received(1).DeleteCustomerDiscountAsync(organization.GatewayCustomerId);
await stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
await stripeAdapter.Received(1).DeleteCustomerDiscountAsync(organization.GatewayCustomerId);
await stripeAdapter.Received(1).UpdateSubscriptionAsync(organization.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options =>
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
options.DaysUntilDue == 30));
@@ -226,7 +228,7 @@ public class RemoveOrganizationFromProviderCommandTests
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Description == string.Empty &&
options.Email == organization.BillingEmail &&
options.Expand[0] == "tax" &&
@@ -239,14 +241,14 @@ public class RemoveOrganizationFromProviderCommandTests
}
});
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
{
Id = "subscription_id"
});
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>
await stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == organization.GatewayCustomerId &&
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
options.DaysUntilDue == 30 &&
@@ -315,7 +317,7 @@ public class RemoveOrganizationFromProviderCommandTests
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Description == string.Empty &&
options.Email == organization.BillingEmail &&
options.Expand[0] == "tax" &&
@@ -328,14 +330,14 @@ public class RemoveOrganizationFromProviderCommandTests
}
});
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
{
Id = "subscription_id"
});
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>
await stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == organization.GatewayCustomerId &&
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
options.DaysUntilDue == 30 &&
@@ -434,7 +436,7 @@ public class RemoveOrganizationFromProviderCommandTests
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Any<CustomerUpdateOptions>())
stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Any<CustomerUpdateOptions>())
.Returns(new Customer
{
Id = "customer_id",
@@ -444,7 +446,7 @@ public class RemoveOrganizationFromProviderCommandTests
}
});
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
{
Id = "new_subscription_id"
});

View File

@@ -1,17 +1,23 @@
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;
using Bit.Core.AdminConsole.Models.Business.Provider;
using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
@@ -100,6 +106,57 @@ public class ProviderServiceTests
.ReplaceAsync(Arg.Is<ProviderUser>(pu => pu.UserId == user.Id && pu.ProviderId == provider.Id && pu.Key == key));
}
[Theory, BitAutoData]
public async Task CompleteSetupAsync_WithAutoConfirmEnabled_ThrowsUserCannotJoinProviderError(User user, Provider provider,
string key,
TokenizedPaymentMethod tokenizedPaymentMethod, BillingAddress billingAddress,
[ProviderUser] ProviderUser providerUser,
SutProvider<ProviderService> sutProvider)
{
providerUser.ProviderId = provider.Id;
providerUser.UserId = user.Id;
var userService = sutProvider.GetDependency<IUserService>();
userService.GetUserByIdAsync(user.Id).Returns(user);
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
.Returns(protector);
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
var customer = new Customer { Id = "customer_id" };
providerBillingService.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress).Returns(customer);
var subscription = new Subscription { Id = "subscription_id" };
providerBillingService.SetupSubscription(provider).Returns(subscription);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(true);
var policyDetails = new List<PolicyDetails> { new() { OrganizationId = Guid.NewGuid(), IsProvider = false } };
var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(policyDetails);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(user.Id)
.Returns(policyRequirement);
sutProvider.Create();
var token = protector.Protect(
$"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, tokenizedPaymentMethod,
billingAddress));
Assert.Equal(new UserCannotJoinProvider().Message, exception.Message);
}
[Theory, BitAutoData]
public async Task UpdateAsync_ProviderIdIsInvalid_Throws(Provider provider, SutProvider<ProviderService> sutProvider)
{
@@ -579,6 +636,132 @@ public class ProviderServiceTests
Assert.Equal(user.Id, pu.UserId);
}
[Theory, BitAutoData]
public async Task AcceptUserAsync_WithAutoConfirmEnabledAndPolicyExists_Throws(
[ProviderUser(ProviderUserStatusType.Invited)] ProviderUser providerUser,
User user,
SutProvider<ProviderService> sutProvider)
{
// Arrange
sutProvider.GetDependency<IProviderUserRepository>()
.GetByIdAsync(providerUser.Id)
.Returns(providerUser);
var protector = DataProtectionProvider
.Create("ApplicationName")
.CreateProtector("ProviderServiceDataProtector");
sutProvider.GetDependency<IDataProtectionProvider>()
.CreateProtector("ProviderServiceDataProtector")
.Returns(protector);
sutProvider.Create();
providerUser.Email = user.Email;
var token = protector.Protect($"ProviderUserInvite {providerUser.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(true);
var policyDetails = new List<PolicyDetails>
{
new() { OrganizationId = Guid.NewGuid(), IsProvider = false }
};
var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(policyDetails);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(user.Id)
.Returns(policyRequirement);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.AcceptUserAsync(providerUser.Id, user, token));
Assert.Equal(new UserCannotJoinProvider().Message, exception.Message);
}
[Theory, BitAutoData]
public async Task AcceptUserAsync_WithAutoConfirmEnabledButNoPolicyExists_Success(
[ProviderUser(ProviderUserStatusType.Invited)] ProviderUser providerUser,
User user,
SutProvider<ProviderService> sutProvider)
{
// Arrange
sutProvider.GetDependency<IProviderUserRepository>()
.GetByIdAsync(providerUser.Id)
.Returns(providerUser);
var protector = DataProtectionProvider
.Create("ApplicationName")
.CreateProtector("ProviderServiceDataProtector");
sutProvider.GetDependency<IDataProtectionProvider>()
.CreateProtector("ProviderServiceDataProtector")
.Returns(protector);
sutProvider.Create();
providerUser.Email = user.Email;
var token = protector.Protect($"ProviderUserInvite {providerUser.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(true);
var policyRequirement = new AutomaticUserConfirmationPolicyRequirement([]);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(user.Id)
.Returns(policyRequirement);
// Act
var pu = await sutProvider.Sut.AcceptUserAsync(providerUser.Id, user, token);
// Assert
Assert.Null(pu.Email);
Assert.Equal(ProviderUserStatusType.Accepted, pu.Status);
Assert.Equal(user.Id, pu.UserId);
}
[Theory, BitAutoData]
public async Task AcceptUserAsync_WithAutoConfirmDisabled_Success(
[ProviderUser(ProviderUserStatusType.Invited)] ProviderUser providerUser,
User user,
SutProvider<ProviderService> sutProvider)
{
// Arrange
sutProvider.GetDependency<IProviderUserRepository>()
.GetByIdAsync(providerUser.Id)
.Returns(providerUser);
var protector = DataProtectionProvider
.Create("ApplicationName")
.CreateProtector("ProviderServiceDataProtector");
sutProvider.GetDependency<IDataProtectionProvider>()
.CreateProtector("ProviderServiceDataProtector")
.Returns(protector);
sutProvider.Create();
providerUser.Email = user.Email;
var token = protector.Protect($"ProviderUserInvite {providerUser.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(false);
// Act
var pu = await sutProvider.Sut.AcceptUserAsync(providerUser.Id, user, token);
// Assert
Assert.Null(pu.Email);
Assert.Equal(ProviderUserStatusType.Accepted, pu.Status);
Assert.Equal(user.Id, pu.UserId);
// Verify that policy check was never called when feature flag is disabled
await sutProvider.GetDependency<IPolicyRequirementQuery>()
.DidNotReceive()
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(user.Id);
}
[Theory, BitAutoData]
public async Task ConfirmUsersAsync_NoValid(
[ProviderUser(ProviderUserStatusType.Invited)] ProviderUser pu1,
@@ -625,13 +808,131 @@ public class ProviderServiceTests
Assert.Equal("Invalid user.", result[2].Item2);
}
[Theory, BitAutoData]
public async Task ConfirmUsersAsync_WithAutoConfirmEnabledAndPolicyExists_ReturnsError(
[ProviderUser(ProviderUserStatusType.Accepted)] ProviderUser pu1, User u1,
Provider provider, User confirmingUser, SutProvider<ProviderService> sutProvider)
{
// Arrange
pu1.ProviderId = provider.Id;
pu1.UserId = u1.Id;
var providerUsers = new[] { pu1 };
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
providerUserRepository.GetManyAsync([]).ReturnsForAnyArgs(providerUsers);
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
sutProvider.GetDependency<IUserRepository>().GetManyAsync([]).ReturnsForAnyArgs([u1]);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(true);
var policyDetails = new List<PolicyDetails>
{
new() { OrganizationId = Guid.NewGuid(), IsProvider = false }
};
var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(policyDetails);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(u1.Id)
.Returns(policyRequirement);
var dict = providerUsers.ToDictionary(pu => pu.Id, _ => "key");
// Act
var result = await sutProvider.Sut.ConfirmUsersAsync(pu1.ProviderId, dict, confirmingUser.Id);
// Assert
Assert.Single(result);
Assert.Equal(new UserCannotJoinProvider().Message, result[0].Item2);
// Verify user was not confirmed
await providerUserRepository.DidNotReceive().ReplaceAsync(Arg.Any<ProviderUser>());
}
[Theory, BitAutoData]
public async Task ConfirmUsersAsync_WithAutoConfirmEnabledButNoPolicyExists_Success(
[ProviderUser(ProviderUserStatusType.Accepted)] ProviderUser pu1, User u1,
Provider provider, User confirmingUser, SutProvider<ProviderService> sutProvider)
{
// Arrange
pu1.ProviderId = provider.Id;
pu1.UserId = u1.Id;
var providerUsers = new[] { pu1 };
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
providerUserRepository.GetManyAsync([]).ReturnsForAnyArgs(providerUsers);
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
sutProvider.GetDependency<IUserRepository>().GetManyAsync([]).ReturnsForAnyArgs([u1]);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(true);
var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(new List<PolicyDetails>());
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(u1.Id)
.Returns(policyRequirement);
var dict = providerUsers.ToDictionary(pu => pu.Id, _ => "key");
// Act
var result = await sutProvider.Sut.ConfirmUsersAsync(pu1.ProviderId, dict, confirmingUser.Id);
// Assert
Assert.Single(result);
Assert.Equal("", result[0].Item2);
// Verify user was confirmed
await providerUserRepository.Received(1).ReplaceAsync(Arg.Is<ProviderUser>(pu =>
pu.Status == ProviderUserStatusType.Confirmed));
}
[Theory, BitAutoData]
public async Task ConfirmUsersAsync_WithAutoConfirmDisabled_Success(
[ProviderUser(ProviderUserStatusType.Accepted)] ProviderUser pu1, User u1,
Provider provider, User confirmingUser, SutProvider<ProviderService> sutProvider)
{
// Arrange
pu1.ProviderId = provider.Id;
pu1.UserId = u1.Id;
var providerUsers = new[] { pu1 };
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
providerUserRepository.GetManyAsync([]).ReturnsForAnyArgs(providerUsers);
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
sutProvider.GetDependency<IUserRepository>().GetManyAsync([]).ReturnsForAnyArgs([u1]);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(false);
var dict = providerUsers.ToDictionary(pu => pu.Id, _ => "key");
// Act
var result = await sutProvider.Sut.ConfirmUsersAsync(pu1.ProviderId, dict, confirmingUser.Id);
// Assert
Assert.Single(result);
Assert.Equal("", result[0].Item2);
// Verify user was confirmed
await providerUserRepository.Received(1).ReplaceAsync(Arg.Is<ProviderUser>(pu =>
pu.Status == ProviderUserStatusType.Confirmed));
// Verify that policy check was never called when feature flag is disabled
await sutProvider.GetDependency<IPolicyRequirementQuery>()
.DidNotReceive()
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(Arg.Any<Guid>());
}
[Theory, BitAutoData]
public async Task SaveUserAsync_UserIdIsInvalid_Throws(ProviderUser providerUser,
SutProvider<ProviderService> sutProvider)
{
providerUser.Id = default;
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SaveUserAsync(providerUser, default));
providerUser.Id = Guid.Empty;
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.SaveUserAsync(providerUser, Guid.Empty));
Assert.Equal("Invite the user first.", exception.Message);
}
@@ -757,7 +1058,7 @@ public class ProviderServiceTests
await organizationRepository.Received(1)
.ReplaceAsync(Arg.Is<Organization>(org => org.BillingEmail == provider.BillingEmail));
await sutProvider.GetDependency<IStripeAdapter>().Received(1).CustomerUpdateAsync(
await sutProvider.GetDependency<IStripeAdapter>().Received(1).UpdateCustomerAsync(
organization.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(options => options.Email == provider.BillingEmail));
@@ -828,9 +1129,9 @@ public class ProviderServiceTests
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
var subscriptionItem = GetSubscription(organization.GatewaySubscriptionId);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId)
sutProvider.GetDependency<IStripeAdapter>().GetSubscriptionAsync(organization.GatewaySubscriptionId)
.Returns(GetSubscription(organization.GatewaySubscriptionId));
await sutProvider.GetDependency<IStripeAdapter>().SubscriptionUpdateAsync(
await sutProvider.GetDependency<IStripeAdapter>().UpdateSubscriptionAsync(
organization.GatewaySubscriptionId, SubscriptionUpdateRequest(expectedPlanId, subscriptionItem));
await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key);

View File

@@ -3,7 +3,6 @@ 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;
@@ -63,7 +62,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration> { Data = [] });
var response = await sutProvider.Sut.Run(provider);
@@ -95,7 +94,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration> { Data = [] });
var response = await sutProvider.Sut.Run(provider);
@@ -129,7 +128,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(false);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration> { Data = [] });
var response = await sutProvider.Sut.Run(provider);
@@ -163,7 +162,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration> { Data = [] });
var response = await sutProvider.Sut.Run(provider);
@@ -224,7 +223,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "GB" }]
@@ -257,7 +256,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "CA" }]
@@ -296,7 +295,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "CA" }]
@@ -338,7 +337,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "CA" }]
@@ -383,7 +382,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "CA" }]
@@ -428,7 +427,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "CA" }]
@@ -461,7 +460,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Is<RegistrationListOptions>(opt => opt.Status == TaxRegistrationStatus.Active))
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Is<RegistrationListOptions>(opt => opt.Status == TaxRegistrationStatus.Active))
.Returns(new StripeList<Registration>
{
Data = [
@@ -470,7 +469,7 @@ public class GetProviderWarningsQueryTests
new Registration { Country = "FR" }
]
});
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Is<RegistrationListOptions>(opt => opt.Status == TaxRegistrationStatus.Scheduled))
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Is<RegistrationListOptions>(opt => opt.Status == TaxRegistrationStatus.Scheduled))
.Returns(new StripeList<Registration> { Data = [] });
var response = await sutProvider.Sut.Run(provider);
@@ -505,7 +504,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "CA" }]
@@ -543,7 +542,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "US" }]

View File

@@ -144,11 +144,11 @@ public class BusinessUnitConverterTests
await businessUnitConverter.FinalizeConversion(organization, userId, token, providerKey, organizationKey);
await _stripeAdapter.Received(2).CustomerUpdateAsync(subscription.CustomerId, Arg.Any<CustomerUpdateOptions>());
await _stripeAdapter.Received(2).UpdateCustomerAsync(subscription.CustomerId, Arg.Any<CustomerUpdateOptions>());
var updatedPriceId = ProviderPriceAdapter.GetActivePriceId(provider, enterpriseAnnually.Type);
await _stripeAdapter.Received(1).SubscriptionUpdateAsync(subscription.Id, Arg.Is<SubscriptionUpdateOptions>(
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscription.Id, Arg.Is<SubscriptionUpdateOptions>(
arguments =>
arguments.Items.Count == 2 &&
arguments.Items[0].Id == "subscription_item_id" &&

View File

@@ -20,7 +20,6 @@ using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Test.Billing.Mocks;
using Bit.Test.Common.AutoFixture;
@@ -85,7 +84,7 @@ public class ProviderBillingServiceTests
// Assert
await providerPlanRepository.Received(0).ReplaceAsync(Arg.Any<ProviderPlan>());
await stripeAdapter.Received(0).SubscriptionUpdateAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
await stripeAdapter.Received(0).UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
}
[Theory, BitAutoData]
@@ -113,7 +112,7 @@ public class ProviderBillingServiceTests
// Assert
await providerPlanRepository.Received(0).ReplaceAsync(Arg.Any<ProviderPlan>());
await stripeAdapter.Received(0).SubscriptionUpdateAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
await stripeAdapter.Received(0).UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
}
[Theory, BitAutoData]
@@ -180,14 +179,14 @@ public class ProviderBillingServiceTests
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
await stripeAdapter.Received(1)
.SubscriptionUpdateAsync(
.UpdateSubscriptionAsync(
Arg.Is(provider.GatewaySubscriptionId),
Arg.Is<SubscriptionUpdateOptions>(p =>
p.Items.Count(si => si.Id == "si_ent_annual" && si.Deleted == true) == 1));
var newPlanCfg = MockPlans.Get(command.NewPlan);
await stripeAdapter.Received(1)
.SubscriptionUpdateAsync(
.UpdateSubscriptionAsync(
Arg.Is(provider.GatewaySubscriptionId),
Arg.Is<SubscriptionUpdateOptions>(p =>
p.Items.Count(si =>
@@ -268,7 +267,7 @@ public class ProviderBillingServiceTests
CloudRegion = "US"
});
sutProvider.GetDependency<IStripeAdapter>().CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(
sutProvider.GetDependency<IStripeAdapter>().CreateCustomerAsync(Arg.Is<CustomerCreateOptions>(
options =>
options.Address.Country == providerCustomer.Address.Country &&
options.Address.PostalCode == providerCustomer.Address.PostalCode &&
@@ -288,7 +287,7 @@ public class ProviderBillingServiceTests
await sutProvider.Sut.CreateCustomerForClientOrganization(provider, organization);
await sutProvider.GetDependency<IStripeAdapter>().Received(1).CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(
await sutProvider.GetDependency<IStripeAdapter>().Received(1).CreateCustomerAsync(Arg.Is<CustomerCreateOptions>(
options =>
options.Address.Country == providerCustomer.Address.Country &&
options.Address.PostalCode == providerCustomer.Address.PostalCode &&
@@ -349,7 +348,7 @@ public class ProviderBillingServiceTests
CloudRegion = "US"
});
sutProvider.GetDependency<IStripeAdapter>().CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(
sutProvider.GetDependency<IStripeAdapter>().CreateCustomerAsync(Arg.Is<CustomerCreateOptions>(
options =>
options.Address.Country == providerCustomer.Address.Country &&
options.Address.PostalCode == providerCustomer.Address.PostalCode &&
@@ -370,7 +369,7 @@ public class ProviderBillingServiceTests
await sutProvider.Sut.CreateCustomerForClientOrganization(provider, organization);
await sutProvider.GetDependency<IStripeAdapter>().Received(1).CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(
await sutProvider.GetDependency<IStripeAdapter>().Received(1).CreateCustomerAsync(Arg.Is<CustomerCreateOptions>(
options =>
options.Address.Country == providerCustomer.Address.Country &&
options.Address.PostalCode == providerCustomer.Address.PostalCode &&
@@ -535,7 +534,7 @@ public class ProviderBillingServiceTests
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
// 50 assigned seats + 10 seat scale up = 60 seats, well below the 100 minimum
await sutProvider.GetDependency<IStripeAdapter>().DidNotReceiveWithAnyArgs().SubscriptionUpdateAsync(
await sutProvider.GetDependency<IStripeAdapter>().DidNotReceiveWithAnyArgs().UpdateSubscriptionAsync(
Arg.Any<string>(),
Arg.Any<SubscriptionUpdateOptions>());
@@ -619,7 +618,7 @@ public class ProviderBillingServiceTests
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
// 95 current + 10 seat scale = 105 seats, 5 above the minimum
await sutProvider.GetDependency<IStripeAdapter>().Received(1).SubscriptionUpdateAsync(
await sutProvider.GetDependency<IStripeAdapter>().Received(1).UpdateSubscriptionAsync(
provider.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(
options =>
@@ -707,7 +706,7 @@ public class ProviderBillingServiceTests
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
// 110 current + 10 seat scale up = 120 seats
await sutProvider.GetDependency<IStripeAdapter>().Received(1).SubscriptionUpdateAsync(
await sutProvider.GetDependency<IStripeAdapter>().Received(1).UpdateSubscriptionAsync(
provider.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(
options =>
@@ -795,7 +794,7 @@ public class ProviderBillingServiceTests
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, -30);
// 110 seats - 30 scale down seats = 80 seats, below the 100 seat minimum.
await sutProvider.GetDependency<IStripeAdapter>().Received(1).SubscriptionUpdateAsync(
await sutProvider.GetDependency<IStripeAdapter>().Received(1).UpdateSubscriptionAsync(
provider.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(
options =>
@@ -914,12 +913,12 @@ public class ProviderBillingServiceTests
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = "token" };
stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options =>
stripeAdapter.ListSetupIntentsAsync(Arg.Is<SetupIntentListOptions>(options =>
options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([
new SetupIntent { Id = "setup_intent_id" }
]);
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
stripeAdapter.CreateCustomerAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == billingAddress.Line1 &&
@@ -942,7 +941,7 @@ public class ProviderBillingServiceTests
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Set(provider.Id, "setup_intent_id");
await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_id", Arg.Is<SetupIntentCancelOptions>(options =>
await stripeAdapter.Received(1).CancelSetupIntentAsync("setup_intent_id", Arg.Is<SetupIntentCancelOptions>(options =>
options.CancellationReason == "abandoned"));
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).RemoveSetupIntentForSubscriber(provider.Id);
@@ -964,7 +963,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token)
.Returns("braintree_customer_id");
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
stripeAdapter.CreateCustomerAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == billingAddress.Line1 &&
@@ -1007,12 +1006,12 @@ public class ProviderBillingServiceTests
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = "token" };
stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options =>
stripeAdapter.ListSetupIntentsAsync(Arg.Is<SetupIntentListOptions>(options =>
options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([
new SetupIntent { Id = "setup_intent_id" }
]);
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
stripeAdapter.CreateCustomerAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == billingAddress.Line1 &&
@@ -1058,7 +1057,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token)
.Returns("braintree_customer_id");
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
stripeAdapter.CreateCustomerAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == billingAddress.Line1 &&
@@ -1100,7 +1099,7 @@ public class ProviderBillingServiceTests
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" };
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
stripeAdapter.CreateCustomerAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == billingAddress.Line1 &&
@@ -1142,7 +1141,7 @@ public class ProviderBillingServiceTests
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" };
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
stripeAdapter.CreateCustomerAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == billingAddress.Line1 &&
@@ -1178,7 +1177,7 @@ public class ProviderBillingServiceTests
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" };
stripeAdapter.CustomerCreateAsync(Arg.Any<CustomerCreateOptions>())
stripeAdapter.CreateCustomerAsync(Arg.Any<CustomerCreateOptions>())
.Throws(new StripeException("Invalid tax ID") { StripeError = new StripeError { Code = "tax_id_invalid" } });
var actual = await Assert.ThrowsAsync<BadRequestException>(async () =>
@@ -1216,7 +1215,7 @@ public class ProviderBillingServiceTests
await sutProvider.GetDependency<IStripeAdapter>()
.DidNotReceiveWithAnyArgs()
.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
}
[Theory, BitAutoData]
@@ -1244,7 +1243,7 @@ public class ProviderBillingServiceTests
await sutProvider.GetDependency<IStripeAdapter>()
.DidNotReceiveWithAnyArgs()
.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
}
[Theory, BitAutoData]
@@ -1272,7 +1271,7 @@ public class ProviderBillingServiceTests
await sutProvider.GetDependency<IStripeAdapter>()
.DidNotReceiveWithAnyArgs()
.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
}
[Theory, BitAutoData]
@@ -1323,7 +1322,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
.Returns(providerPlans);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>())
sutProvider.GetDependency<IStripeAdapter>().CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>())
.Returns(
new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Incomplete });
@@ -1381,7 +1380,7 @@ public class ProviderBillingServiceTests
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
sutProvider.GetDependency<IStripeAdapter>().CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(
sub =>
sub.AutomaticTax.Enabled == true &&
sub.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
@@ -1458,7 +1457,7 @@ public class ProviderBillingServiceTests
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
sutProvider.GetDependency<IStripeAdapter>().CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(
sub =>
sub.AutomaticTax.Enabled == true &&
sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically &&
@@ -1538,7 +1537,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntentId);
sutProvider.GetDependency<IStripeAdapter>().SetupIntentGet(setupIntentId, Arg.Is<SetupIntentGetOptions>(options =>
sutProvider.GetDependency<IStripeAdapter>().GetSetupIntentAsync(setupIntentId, Arg.Is<SetupIntentGetOptions>(options =>
options.Expand.Contains("payment_method"))).Returns(new SetupIntent
{
Id = setupIntentId,
@@ -1553,7 +1552,7 @@ public class ProviderBillingServiceTests
}
});
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
sutProvider.GetDependency<IStripeAdapter>().CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(
sub =>
sub.AutomaticTax.Enabled == true &&
sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically &&
@@ -1635,7 +1634,7 @@ public class ProviderBillingServiceTests
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
sutProvider.GetDependency<IStripeAdapter>().CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(
sub =>
sub.AutomaticTax.Enabled == true &&
sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically &&
@@ -1713,7 +1712,7 @@ public class ProviderBillingServiceTests
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
sutProvider.GetDependency<IStripeAdapter>().CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(
sub =>
sub.AutomaticTax.Enabled == true &&
sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically &&
@@ -1828,7 +1827,7 @@ public class ProviderBillingServiceTests
await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 20 && providerPlan.PurchasedSeats == 5));
await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
await stripeAdapter.Received(1).UpdateSubscriptionAsync(provider.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(
options =>
options.Items.Count == 2 &&
@@ -1908,7 +1907,7 @@ public class ProviderBillingServiceTests
await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 50));
await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
await stripeAdapter.Received(1).UpdateSubscriptionAsync(provider.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(
options =>
options.Items.Count == 2 &&
@@ -1989,7 +1988,7 @@ public class ProviderBillingServiceTests
providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 60 && providerPlan.PurchasedSeats == 10));
await stripeAdapter.DidNotReceiveWithAnyArgs()
.SubscriptionUpdateAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
}
[Theory, BitAutoData]
@@ -2062,7 +2061,7 @@ public class ProviderBillingServiceTests
await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 80 && providerPlan.PurchasedSeats == 0));
await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
await stripeAdapter.Received(1).UpdateSubscriptionAsync(provider.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(
options =>
options.Items.Count == 2 &&
@@ -2142,7 +2141,7 @@ public class ProviderBillingServiceTests
await providerPlanRepository.DidNotReceive().ReplaceAsync(Arg.Is<ProviderPlan>(
providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly));
await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
await stripeAdapter.Received(1).UpdateSubscriptionAsync(provider.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(
options =>
options.Items.Count == 1 &&
@@ -2151,4 +2150,151 @@ public class ProviderBillingServiceTests
}
#endregion
#region UpdateProviderNameAndEmail
[Theory, BitAutoData]
public async Task UpdateProviderNameAndEmail_NullGatewayCustomerId_LogsWarningAndReturns(
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.GatewayCustomerId = null;
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
// Assert
await stripeAdapter.DidNotReceive().UpdateCustomerAsync(
Arg.Any<string>(),
Arg.Any<CustomerUpdateOptions>());
}
[Theory, BitAutoData]
public async Task UpdateProviderNameAndEmail_EmptyGatewayCustomerId_LogsWarningAndReturns(
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.GatewayCustomerId = "";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
// Assert
await stripeAdapter.DidNotReceive().UpdateCustomerAsync(
Arg.Any<string>(),
Arg.Any<CustomerUpdateOptions>());
}
[Theory, BitAutoData]
public async Task UpdateProviderNameAndEmail_NullProviderName_LogsWarningAndReturns(
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.Name = null;
provider.GatewayCustomerId = "cus_test123";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
// Assert
await stripeAdapter.DidNotReceive().UpdateCustomerAsync(
Arg.Any<string>(),
Arg.Any<CustomerUpdateOptions>());
}
[Theory, BitAutoData]
public async Task UpdateProviderNameAndEmail_EmptyProviderName_LogsWarningAndReturns(
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.Name = "";
provider.GatewayCustomerId = "cus_test123";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
// Assert
await stripeAdapter.DidNotReceive().UpdateCustomerAsync(
Arg.Any<string>(),
Arg.Any<CustomerUpdateOptions>());
}
[Theory, BitAutoData]
public async Task UpdateProviderNameAndEmail_ValidProvider_CallsStripeWithCorrectParameters(
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.Name = "Test Provider";
provider.BillingEmail = "billing@test.com";
provider.GatewayCustomerId = "cus_test123";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
// Assert
await stripeAdapter.Received(1).UpdateCustomerAsync(
provider.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(options =>
options.Email == provider.BillingEmail &&
options.Description == provider.Name &&
options.InvoiceSettings.CustomFields.Count == 1 &&
options.InvoiceSettings.CustomFields[0].Name == "Provider" &&
options.InvoiceSettings.CustomFields[0].Value == provider.Name));
}
[Theory, BitAutoData]
public async Task UpdateProviderNameAndEmail_LongProviderName_UsesFullName(
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
var longName = new string('A', 50); // 50 characters
provider.Name = longName;
provider.BillingEmail = "billing@test.com";
provider.GatewayCustomerId = "cus_test123";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
// Assert
await stripeAdapter.Received(1).UpdateCustomerAsync(
provider.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(options =>
options.InvoiceSettings.CustomFields[0].Value == longName));
}
[Theory, BitAutoData]
public async Task UpdateProviderNameAndEmail_NullBillingEmail_UpdatesWithNull(
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.Name = "Test Provider";
provider.BillingEmail = null;
provider.GatewayCustomerId = "cus_test123";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
// Assert
await stripeAdapter.Received(1).UpdateCustomerAsync(
provider.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(options =>
options.Email == null &&
options.Description == provider.Name));
}
#endregion
}

View File

@@ -3,6 +3,7 @@ using System.Security.Claims;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.Registration;
@@ -10,6 +11,7 @@ using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Tokens;
using Bit.Sso.Controllers;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@@ -1137,4 +1139,129 @@ public class AccountControllerTest
Assert.NotNull(result.user);
Assert.Equal(email, result.user.Email);
}
[Theory, BitAutoData]
public void ExternalChallenge_WithMatchingOrgId_Succeeds(
SutProvider<AccountController> sutProvider,
Organization organization)
{
// Arrange
var orgId = organization.Id;
var scheme = orgId.ToString();
var returnUrl = "~/vault";
var state = "test-state";
var userIdentifier = "user-123";
var ssoToken = "valid-sso-token";
// Mock the data protector to return a tokenable with matching org ID
var dataProtector = sutProvider.GetDependency<IDataProtectorTokenFactory<SsoTokenable>>();
var tokenable = new SsoTokenable(organization, 3600);
dataProtector.Unprotect(ssoToken).Returns(tokenable);
// Mock URL helper for IsLocalUrl check
var urlHelper = Substitute.For<IUrlHelper>();
urlHelper.IsLocalUrl(returnUrl).Returns(true);
sutProvider.Sut.Url = urlHelper;
// Mock interaction service for IsValidReturnUrl check
var interactionService = sutProvider.GetDependency<IIdentityServerInteractionService>();
interactionService.IsValidReturnUrl(returnUrl).Returns(true);
// Act
var result = sutProvider.Sut.ExternalChallenge(scheme, returnUrl, state, userIdentifier, ssoToken);
// Assert
var challengeResult = Assert.IsType<ChallengeResult>(result);
Assert.Contains(scheme, challengeResult.AuthenticationSchemes);
Assert.NotNull(challengeResult.Properties);
Assert.Equal(scheme, challengeResult.Properties.Items["scheme"]);
Assert.Equal(returnUrl, challengeResult.Properties.Items["return_url"]);
Assert.Equal(state, challengeResult.Properties.Items["state"]);
Assert.Equal(userIdentifier, challengeResult.Properties.Items["user_identifier"]);
}
[Theory, BitAutoData]
public void ExternalChallenge_WithMismatchedOrgId_ThrowsSsoOrganizationIdMismatch(
SutProvider<AccountController> sutProvider,
Organization organization)
{
// Arrange
var correctOrgId = organization.Id;
var wrongOrgId = Guid.NewGuid();
var scheme = wrongOrgId.ToString(); // Different from tokenable's org ID
var returnUrl = "~/vault";
var state = "test-state";
var userIdentifier = "user-123";
var ssoToken = "valid-sso-token";
// Mock the data protector to return a tokenable with different org ID
var dataProtector = sutProvider.GetDependency<IDataProtectorTokenFactory<SsoTokenable>>();
var tokenable = new SsoTokenable(organization, 3600); // Contains correctOrgId
dataProtector.Unprotect(ssoToken).Returns(tokenable);
// Mock i18n service to return the key
sutProvider.GetDependency<II18nService>()
.T(Arg.Any<string>())
.Returns(ci => (string)ci[0]!);
// Act & Assert
var ex = Assert.Throws<Exception>(() =>
sutProvider.Sut.ExternalChallenge(scheme, returnUrl, state, userIdentifier, ssoToken));
Assert.Equal("SsoOrganizationIdMismatch", ex.Message);
}
[Theory, BitAutoData]
public void ExternalChallenge_WithInvalidSchemeFormat_ThrowsSsoOrganizationIdMismatch(
SutProvider<AccountController> sutProvider,
Organization organization)
{
// Arrange
var scheme = "not-a-valid-guid";
var returnUrl = "~/vault";
var state = "test-state";
var userIdentifier = "user-123";
var ssoToken = "valid-sso-token";
// Mock the data protector to return a valid tokenable
var dataProtector = sutProvider.GetDependency<IDataProtectorTokenFactory<SsoTokenable>>();
var tokenable = new SsoTokenable(organization, 3600);
dataProtector.Unprotect(ssoToken).Returns(tokenable);
// Mock i18n service to return the key
sutProvider.GetDependency<II18nService>()
.T(Arg.Any<string>())
.Returns(ci => (string)ci[0]!);
// Act & Assert
var ex = Assert.Throws<Exception>(() =>
sutProvider.Sut.ExternalChallenge(scheme, returnUrl, state, userIdentifier, ssoToken));
Assert.Equal("SsoOrganizationIdMismatch", ex.Message);
}
[Theory, BitAutoData]
public void ExternalChallenge_WithInvalidSsoToken_ThrowsInvalidSsoToken(
SutProvider<AccountController> sutProvider)
{
// Arrange
var orgId = Guid.NewGuid();
var scheme = orgId.ToString();
var returnUrl = "~/vault";
var state = "test-state";
var userIdentifier = "user-123";
var ssoToken = "invalid-corrupted-token";
// Mock the data protector to throw when trying to unprotect
var dataProtector = sutProvider.GetDependency<IDataProtectorTokenFactory<SsoTokenable>>();
dataProtector.Unprotect(ssoToken).Returns(_ => throw new Exception("Token validation failed"));
// Mock i18n service to return the key
sutProvider.GetDependency<II18nService>()
.T(Arg.Any<string>())
.Returns(ci => (string)ci[0]!);
// Act & Assert
var ex = Assert.Throws<Exception>(() =>
sutProvider.Sut.ExternalChallenge(scheme, returnUrl, state, userIdentifier, ssoToken));
Assert.Equal("InvalidSsoToken", ex.Message);
}
}

View File

@@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
@@ -36,7 +37,7 @@ public class PostUserCommandTests
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
sutProvider.GetDependency<IPaymentService>().HasSecretsManagerStandalone(organization).Returns(true);
sutProvider.GetDependency<IStripePaymentService>().HasSecretsManagerStandalone(organization).Returns(true);
sutProvider.GetDependency<IOrganizationService>()
.InviteUserAsync(organizationId,

View File

@@ -99,7 +99,7 @@ services:
- idp
rabbitmq:
image: rabbitmq:4.1.3-management
image: rabbitmq:4.2.0-management
ports:
- "5672:5672"
- "15672:15672"

View File

@@ -33,6 +33,10 @@
"id": "<your Installation Id>",
"key": "<your Installation Key>"
},
"events": {
"connectionString": "",
"queueName": "event"
},
"licenseDirectory": "<full path to license directory>",
"enableNewDeviceVerification": true,
"enableEmailVerification": true

View File

@@ -5,6 +5,7 @@
},
"msbuild-sdks": {
"Microsoft.Build.Traversal": "4.1.0",
"Microsoft.Build.Sql": "1.0.0"
"Microsoft.Build.Sql": "1.0.0",
"Bitwarden.Server.Sdk": "1.2.0"
}
}

View File

@@ -14,8 +14,10 @@ using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Organizations.Services;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services;
using Bit.Core.Enums;
using Bit.Core.Models.OrganizationConnectionConfigs;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
@@ -41,7 +43,7 @@ public class OrganizationsController : Controller
private readonly ICollectionRepository _collectionRepository;
private readonly IGroupRepository _groupRepository;
private readonly IPolicyRepository _policyRepository;
private readonly IPaymentService _paymentService;
private readonly IStripePaymentService _paymentService;
private readonly IApplicationCacheService _applicationCacheService;
private readonly GlobalSettings _globalSettings;
private readonly IProviderRepository _providerRepository;
@@ -56,6 +58,7 @@ public class OrganizationsController : Controller
private readonly IOrganizationInitiateDeleteCommand _organizationInitiateDeleteCommand;
private readonly IPricingClient _pricingClient;
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
private readonly IOrganizationBillingService _organizationBillingService;
public OrganizationsController(
IOrganizationRepository organizationRepository,
@@ -66,7 +69,7 @@ public class OrganizationsController : Controller
ICollectionRepository collectionRepository,
IGroupRepository groupRepository,
IPolicyRepository policyRepository,
IPaymentService paymentService,
IStripePaymentService paymentService,
IApplicationCacheService applicationCacheService,
GlobalSettings globalSettings,
IProviderRepository providerRepository,
@@ -80,7 +83,8 @@ public class OrganizationsController : Controller
IProviderBillingService providerBillingService,
IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand,
IPricingClient pricingClient,
IResendOrganizationInviteCommand resendOrganizationInviteCommand)
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
IOrganizationBillingService organizationBillingService)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@@ -105,6 +109,7 @@ public class OrganizationsController : Controller
_organizationInitiateDeleteCommand = organizationInitiateDeleteCommand;
_pricingClient = pricingClient;
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
_organizationBillingService = organizationBillingService;
}
[RequirePermission(Permission.Org_List_View)]
@@ -241,6 +246,8 @@ public class OrganizationsController : Controller
var existingOrganizationData = new Organization
{
Id = organization.Id,
Name = organization.Name,
BillingEmail = organization.BillingEmail,
Status = organization.Status,
PlanType = organization.PlanType,
Seats = organization.Seats
@@ -286,6 +293,22 @@ public class OrganizationsController : Controller
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
// Sync name/email changes to Stripe
if (existingOrganizationData.Name != organization.Name || existingOrganizationData.BillingEmail != organization.BillingEmail)
{
try
{
await _organizationBillingService.UpdateOrganizationNameAndEmail(organization);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to update Stripe customer for organization {OrganizationId}. Database was updated successfully.",
organization.Id);
TempData["Warning"] = "Organization updated successfully, but Stripe customer name/email synchronization failed.";
}
}
return RedirectToAction("Edit", new { id });
}

View File

@@ -56,6 +56,7 @@ public class ProvidersController : Controller
private readonly IStripeAdapter _stripeAdapter;
private readonly IAccessControlService _accessControlService;
private readonly ISubscriberService _subscriberService;
private readonly ILogger<ProvidersController> _logger;
public ProvidersController(IOrganizationRepository organizationRepository,
IResellerClientOrganizationSignUpCommand resellerClientOrganizationSignUpCommand,
@@ -72,7 +73,8 @@ public class ProvidersController : Controller
IPricingClient pricingClient,
IStripeAdapter stripeAdapter,
IAccessControlService accessControlService,
ISubscriberService subscriberService)
ISubscriberService subscriberService,
ILogger<ProvidersController> logger)
{
_organizationRepository = organizationRepository;
_resellerClientOrganizationSignUpCommand = resellerClientOrganizationSignUpCommand;
@@ -92,6 +94,7 @@ public class ProvidersController : Controller
_braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl();
_braintreeMerchantId = globalSettings.Braintree.MerchantId;
_subscriberService = subscriberService;
_logger = logger;
}
[RequirePermission(Permission.Provider_List_View)]
@@ -296,6 +299,9 @@ public class ProvidersController : Controller
var originalProviderStatus = provider.Enabled;
// Capture original billing email before modifications for Stripe sync
var originalBillingEmail = provider.BillingEmail;
model.ToProvider(provider);
// validate the stripe ids to prevent saving a bad one
@@ -321,6 +327,22 @@ public class ProvidersController : Controller
await _providerService.UpdateAsync(provider);
await _applicationCacheService.UpsertProviderAbilityAsync(provider);
// Sync billing email changes to Stripe
if (!string.IsNullOrEmpty(provider.GatewayCustomerId) && originalBillingEmail != provider.BillingEmail)
{
try
{
await _providerBillingService.UpdateProviderNameAndEmail(provider);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to update Stripe customer for provider {ProviderId}. Database was updated successfully.",
provider.Id);
TempData["Warning"] = "Provider updated successfully, but Stripe customer email synchronization failed.";
}
}
if (!provider.IsBillable())
{
return RedirectToAction("Edit", new { id });
@@ -339,11 +361,11 @@ public class ProvidersController : Controller
]);
await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand);
var customer = await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId);
var customer = await _stripeAdapter.GetCustomerAsync(provider.GatewayCustomerId);
if (model.PayByInvoice != customer.ApprovedToPayByInvoice())
{
var approvedToPayByInvoice = model.PayByInvoice ? "1" : "0";
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
await _stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions
{
Metadata = new Dictionary<string, string>
{

View File

@@ -8,6 +8,7 @@ using Bit.Admin.Utilities;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Organizations.Queries;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Platform.Installations;
using Bit.Core.Repositories;

View File

@@ -5,6 +5,7 @@ using Bit.Admin.Models;
using Bit.Admin.Services;
using Bit.Admin.Utilities;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Services;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
@@ -20,7 +21,7 @@ public class UsersController : Controller
{
private readonly IUserRepository _userRepository;
private readonly ICipherRepository _cipherRepository;
private readonly IPaymentService _paymentService;
private readonly IStripePaymentService _paymentService;
private readonly GlobalSettings _globalSettings;
private readonly IAccessControlService _accessControlService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
@@ -30,7 +31,7 @@ public class UsersController : Controller
public UsersController(
IUserRepository userRepository,
ICipherRepository cipherRepository,
IPaymentService paymentService,
IStripePaymentService paymentService,
GlobalSettings globalSettings,
IAccessControlService accessControlService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,

View File

@@ -1,7 +1,7 @@
###############################################
# Node.js build stage #
###############################################
FROM node:20-alpine3.21 AS node-build
FROM --platform=$BUILDPLATFORM node:20-alpine3.21 AS node-build
WORKDIR /app
COPY src/Admin/package*.json ./

View File

@@ -1,8 +1,8 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -12,8 +12,10 @@ namespace Bit.Api.AdminConsole.Controllers;
[Authorize("Application")]
public class OrganizationIntegrationConfigurationController(
ICurrentContext currentContext,
IOrganizationIntegrationRepository integrationRepository,
IOrganizationIntegrationConfigurationRepository integrationConfigurationRepository) : Controller
ICreateOrganizationIntegrationConfigurationCommand createCommand,
IUpdateOrganizationIntegrationConfigurationCommand updateCommand,
IDeleteOrganizationIntegrationConfigurationCommand deleteCommand,
IGetOrganizationIntegrationConfigurationsQuery getQuery) : Controller
{
[HttpGet("")]
public async Task<List<OrganizationIntegrationConfigurationResponseModel>> GetAsync(
@@ -24,13 +26,8 @@ public class OrganizationIntegrationConfigurationController(
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
var configurations = await integrationConfigurationRepository.GetManyByIntegrationAsync(integrationId);
var configurations = await getQuery.GetManyByIntegrationAsync(organizationId, integrationId);
return configurations
.Select(configuration => new OrganizationIntegrationConfigurationResponseModel(configuration))
.ToList();
@@ -46,19 +43,11 @@ public class OrganizationIntegrationConfigurationController(
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
if (!model.IsValidForType(integration.Type))
{
throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}");
}
var organizationIntegrationConfiguration = model.ToOrganizationIntegrationConfiguration(integrationId);
var configuration = await integrationConfigurationRepository.CreateAsync(organizationIntegrationConfiguration);
return new OrganizationIntegrationConfigurationResponseModel(configuration);
var configuration = model.ToOrganizationIntegrationConfiguration(integrationId);
var created = await createCommand.CreateAsync(organizationId, integrationId, configuration);
return new OrganizationIntegrationConfigurationResponseModel(created);
}
[HttpPut("{configurationId:guid}")]
@@ -72,26 +61,11 @@ public class OrganizationIntegrationConfigurationController(
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
if (!model.IsValidForType(integration.Type))
{
throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}");
}
var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId);
if (configuration is null || configuration.OrganizationIntegrationId != integrationId)
{
throw new NotFoundException();
}
var configuration = model.ToOrganizationIntegrationConfiguration(integrationId);
var updated = await updateCommand.UpdateAsync(organizationId, integrationId, configurationId, configuration);
var newConfiguration = model.ToOrganizationIntegrationConfiguration(configuration);
await integrationConfigurationRepository.ReplaceAsync(newConfiguration);
return new OrganizationIntegrationConfigurationResponseModel(newConfiguration);
return new OrganizationIntegrationConfigurationResponseModel(updated);
}
[HttpDelete("{configurationId:guid}")]
@@ -101,19 +75,8 @@ public class OrganizationIntegrationConfigurationController(
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId);
if (configuration is null || configuration.OrganizationIntegrationId != integrationId)
{
throw new NotFoundException();
}
await integrationConfigurationRepository.DeleteAsync(configuration);
await deleteCommand.DeleteAsync(organizationId, integrationId, configurationId);
}
[HttpPost("{configurationId:guid}/delete")]

View File

@@ -5,6 +5,7 @@ using Bit.Api.AdminConsole.Models.Request.Providers;
using Bit.Api.AdminConsole.Models.Response.Providers;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Services;
@@ -23,15 +24,20 @@ public class ProvidersController : Controller
private readonly IProviderService _providerService;
private readonly ICurrentContext _currentContext;
private readonly GlobalSettings _globalSettings;
private readonly IProviderBillingService _providerBillingService;
private readonly ILogger<ProvidersController> _logger;
public ProvidersController(IUserService userService, IProviderRepository providerRepository,
IProviderService providerService, ICurrentContext currentContext, GlobalSettings globalSettings)
IProviderService providerService, ICurrentContext currentContext, GlobalSettings globalSettings,
IProviderBillingService providerBillingService, ILogger<ProvidersController> logger)
{
_userService = userService;
_providerRepository = providerRepository;
_providerService = providerService;
_currentContext = currentContext;
_globalSettings = globalSettings;
_providerBillingService = providerBillingService;
_logger = logger;
}
[HttpGet("{id:guid}")]
@@ -65,7 +71,27 @@ public class ProvidersController : Controller
throw new NotFoundException();
}
// Capture original values before modifications for Stripe sync
var originalName = provider.Name;
var originalBillingEmail = provider.BillingEmail;
await _providerService.UpdateAsync(model.ToProvider(provider, _globalSettings));
// Sync name/email changes to Stripe
if (originalName != provider.Name || originalBillingEmail != provider.BillingEmail)
{
try
{
await _providerBillingService.UpdateProviderNameAndEmail(provider);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to update Stripe customer for provider {ProviderId}. Database was updated successfully.",
provider.Id);
}
}
return new ProviderResponseModel(provider);
}

View File

@@ -1,6 +1,4 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
@@ -16,38 +14,6 @@ public class OrganizationIntegrationConfigurationRequestModel
public string? Template { get; set; }
public bool IsValidForType(IntegrationType integrationType)
{
switch (integrationType)
{
case IntegrationType.CloudBillingSync or IntegrationType.Scim:
return false;
case IntegrationType.Slack:
return !string.IsNullOrWhiteSpace(Template) &&
IsConfigurationValid<SlackIntegrationConfiguration>() &&
IsFiltersValid();
case IntegrationType.Webhook:
return !string.IsNullOrWhiteSpace(Template) &&
IsConfigurationValid<WebhookIntegrationConfiguration>() &&
IsFiltersValid();
case IntegrationType.Hec:
return !string.IsNullOrWhiteSpace(Template) &&
Configuration is null &&
IsFiltersValid();
case IntegrationType.Datadog:
return !string.IsNullOrWhiteSpace(Template) &&
Configuration is null &&
IsFiltersValid();
case IntegrationType.Teams:
return !string.IsNullOrWhiteSpace(Template) &&
Configuration is null &&
IsFiltersValid();
default:
return false;
}
}
public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(Guid organizationIntegrationId)
{
return new OrganizationIntegrationConfiguration()
@@ -59,50 +25,4 @@ public class OrganizationIntegrationConfigurationRequestModel
Template = Template
};
}
public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(OrganizationIntegrationConfiguration currentConfiguration)
{
currentConfiguration.Configuration = Configuration;
currentConfiguration.EventType = EventType;
currentConfiguration.Filters = Filters;
currentConfiguration.Template = Template;
return currentConfiguration;
}
private bool IsConfigurationValid<T>()
{
if (string.IsNullOrWhiteSpace(Configuration))
{
return false;
}
try
{
var config = JsonSerializer.Deserialize<T>(Configuration);
return config is not null;
}
catch
{
return false;
}
}
private bool IsFiltersValid()
{
if (Filters is null)
{
return true;
}
try
{
var filters = JsonSerializer.Deserialize<IntegrationFilterGroup>(Filters);
return filters is not null;
}
catch
{
return false;
}
}
}

View File

@@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -24,7 +25,7 @@ public class MembersController : Controller
private readonly ICurrentContext _currentContext;
private readonly IUpdateOrganizationUserCommand _updateOrganizationUserCommand;
private readonly IUpdateOrganizationUserGroupsCommand _updateOrganizationUserGroupsCommand;
private readonly IPaymentService _paymentService;
private readonly IStripePaymentService _paymentService;
private readonly IOrganizationRepository _organizationRepository;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
@@ -37,7 +38,7 @@ public class MembersController : Controller
ICurrentContext currentContext,
IUpdateOrganizationUserCommand updateOrganizationUserCommand,
IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand,
IPaymentService paymentService,
IStripePaymentService paymentService,
IOrganizationRepository organizationRepository,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IRemoveOrganizationUserCommand removeOrganizationUserCommand,

View File

@@ -2,6 +2,7 @@
#nullable disable
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Bit.Api.Models.Public.Response;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Models.Data;
@@ -13,6 +14,12 @@ namespace Bit.Api.AdminConsole.Public.Models.Response;
/// </summary>
public class GroupResponseModel : GroupBaseModel, IResponseModel
{
[JsonConstructor]
public GroupResponseModel()
{
}
public GroupResponseModel(Group group, IEnumerable<CollectionAccessSelection> collections)
{
if (group == null)

View File

@@ -18,6 +18,7 @@ using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Kdf;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Core.Models.Api.Response;
using Bit.Core.Repositories;
@@ -44,6 +45,7 @@ public class AccountsController : Controller
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
private readonly ITwoFactorEmailService _twoFactorEmailService;
private readonly IChangeKdfCommand _changeKdfCommand;
private readonly IUserRepository _userRepository;
public AccountsController(
IOrganizationService organizationService,
@@ -57,7 +59,8 @@ public class AccountsController : Controller
IFeatureService featureService,
IUserAccountKeysQuery userAccountKeysQuery,
ITwoFactorEmailService twoFactorEmailService,
IChangeKdfCommand changeKdfCommand
IChangeKdfCommand changeKdfCommand,
IUserRepository userRepository
)
{
_organizationService = organizationService;
@@ -72,6 +75,7 @@ public class AccountsController : Controller
_userAccountKeysQuery = userAccountKeysQuery;
_twoFactorEmailService = twoFactorEmailService;
_changeKdfCommand = changeKdfCommand;
_userRepository = userRepository;
}
@@ -432,16 +436,36 @@ public class AccountsController : Controller
throw new UnauthorizedAccessException();
}
if (_featureService.IsEnabled(FeatureFlagKeys.ReturnErrorOnExistingKeypair))
if (!string.IsNullOrWhiteSpace(user.PrivateKey) || !string.IsNullOrWhiteSpace(user.PublicKey))
{
if (!string.IsNullOrWhiteSpace(user.PrivateKey) || !string.IsNullOrWhiteSpace(user.PublicKey))
{
throw new BadRequestException("User has existing keypair");
}
throw new BadRequestException("User has existing keypair");
}
if (model.AccountKeys != null)
{
var accountKeysData = model.AccountKeys.ToAccountKeysData();
if (!accountKeysData.IsV2Encryption())
{
throw new BadRequestException("AccountKeys are only supported for V2 encryption.");
}
await _userRepository.SetV2AccountCryptographicStateAsync(user.Id, accountKeysData);
return new KeysResponseModel(accountKeysData, user.Key);
}
else
{
// Todo: Drop this after a transition period. This will drop no-account-keys requests.
// The V1 check in the other branch should persist
// https://bitwarden.atlassian.net/browse/PM-27329
await _userService.SaveUserAsync(model.ToUser(user));
return new KeysResponseModel(new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
user.PrivateKey,
user.PublicKey
)
}, user.Key);
}
await _userService.SaveUserAsync(model.ToUser(user));
return new KeysResponseModel(user);
}
[HttpGet("keys")]
@@ -453,7 +477,8 @@ public class AccountsController : Controller
throw new UnauthorizedAccessException();
}
return new KeysResponseModel(user);
var accountKeys = await _userAccountKeysQuery.Run(user);
return new KeysResponseModel(accountKeys, user.Key);
}
[HttpDelete]

View File

@@ -10,7 +10,7 @@ namespace Bit.Api.Billing.Controllers;
[Route("accounts/billing")]
[Authorize("Application")]
public class AccountsBillingController(
IPaymentService paymentService,
IStripePaymentService paymentService,
IUserService userService,
IPaymentHistoryService paymentHistoryService) : Controller
{

View File

@@ -79,7 +79,7 @@ public class AccountsController(
[HttpGet("subscription")]
public async Task<SubscriptionResponseModel> GetSubscriptionAsync(
[FromServices] GlobalSettings globalSettings,
[FromServices] IPaymentService paymentService)
[FromServices] IStripePaymentService paymentService)
{
var user = await userService.GetUserByPrincipalAsync(User);
if (user == null)

View File

@@ -0,0 +1,91 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Organizations.Queries;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Models.Api.OrganizationLicenses;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Billing.Controllers;
[Route("licenses")]
[Authorize("Licensing")]
[SelfHosted(NotSelfHostedOnly = true)]
public class LicensesController : Controller
{
private readonly IUserRepository _userRepository;
private readonly IUserService _userService;
private readonly IOrganizationRepository _organizationRepository;
private readonly IGetCloudOrganizationLicenseQuery _getCloudOrganizationLicenseQuery;
private readonly IValidateBillingSyncKeyCommand _validateBillingSyncKeyCommand;
private readonly ICurrentContext _currentContext;
public LicensesController(
IUserRepository userRepository,
IUserService userService,
IOrganizationRepository organizationRepository,
IGetCloudOrganizationLicenseQuery getCloudOrganizationLicenseQuery,
IValidateBillingSyncKeyCommand validateBillingSyncKeyCommand,
ICurrentContext currentContext)
{
_userRepository = userRepository;
_userService = userService;
_organizationRepository = organizationRepository;
_getCloudOrganizationLicenseQuery = getCloudOrganizationLicenseQuery;
_validateBillingSyncKeyCommand = validateBillingSyncKeyCommand;
_currentContext = currentContext;
}
[HttpGet("user/{id}")]
public async Task<UserLicense> GetUser(string id, [FromQuery] string key)
{
var user = await _userRepository.GetByIdAsync(new Guid(id));
if (user == null)
{
return null;
}
else if (!user.LicenseKey.Equals(key))
{
await Task.Delay(2000);
throw new BadRequestException("Invalid license key.");
}
var license = await _userService.GenerateLicenseAsync(user, null);
return license;
}
/// <summary>
/// Used by self-hosted installations to get an updated license file
/// </summary>
[HttpGet("organization/{id}")]
public async Task<OrganizationLicense> OrganizationSync(string id, [FromBody] SelfHostedOrganizationLicenseRequestModel model)
{
var organization = await _organizationRepository.GetByIdAsync(new Guid(id));
if (organization == null)
{
throw new NotFoundException("Organization not found.");
}
if (!organization.LicenseKey.Equals(model.LicenseKey))
{
await Task.Delay(2000);
throw new BadRequestException("Invalid license key.");
}
if (!await _validateBillingSyncKeyCommand.ValidateBillingSyncKeyAsync(organization, model.BillingSyncKey))
{
throw new BadRequestException("Invalid Billing Sync Key");
}
var license = await _getCloudOrganizationLicenseQuery.GetLicenseAsync(organization, _currentContext.InstallationId.Value);
return license;
}
}

View File

@@ -5,7 +5,6 @@ using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -19,7 +18,7 @@ public class OrganizationBillingController(
ICurrentContext currentContext,
IOrganizationBillingService organizationBillingService,
IOrganizationRepository organizationRepository,
IPaymentService paymentService,
IStripePaymentService paymentService,
IPaymentHistoryService paymentHistoryService) : BaseBillingController
{
// TODO: Remove when pm-25379-use-new-organization-metadata-structure is removed.

View File

@@ -36,7 +36,7 @@ public class OrganizationsController(
IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService,
IUserService userService,
IPaymentService paymentService,
IStripePaymentService paymentService,
ICurrentContext currentContext,
IGetCloudOrganizationLicenseQuery getCloudOrganizationLicenseQuery,
GlobalSettings globalSettings,

View File

@@ -43,7 +43,7 @@ public class ProviderBillingController(
return result;
}
var invoices = await stripeAdapter.InvoiceListAsync(new StripeInvoiceListOptions
var invoices = await stripeAdapter.ListInvoicesAsync(new StripeInvoiceListOptions
{
Customer = provider.GatewayCustomerId
});
@@ -87,7 +87,7 @@ public class ProviderBillingController(
return result;
}
var subscription = await stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId,
var subscription = await stripeAdapter.GetSubscriptionAsync(provider.GatewaySubscriptionId,
new SubscriptionGetOptions { Expand = ["customer.tax_ids", "discounts", "test_clock"] });
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
@@ -96,7 +96,7 @@ public class ProviderBillingController(
{
var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType);
var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, plan.Type);
var price = await stripeAdapter.PriceGetAsync(priceId);
var price = await stripeAdapter.GetPriceAsync(priceId);
var unitAmount = price.UnitAmountDecimal.HasValue
? price.UnitAmountDecimal.Value / 100M

View File

@@ -1,5 +1,5 @@
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Services;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
@@ -28,7 +28,7 @@ public class StripeController(
Usage = "off_session"
};
var setupIntent = await stripeAdapter.SetupIntentCreate(options);
var setupIntent = await stripeAdapter.CreateSetupIntentAsync(options);
return TypedResults.Ok(setupIntent.ClientSecret);
}
@@ -43,7 +43,7 @@ public class StripeController(
Usage = "off_session"
};
var setupIntent = await stripeAdapter.SetupIntentCreate(options);
var setupIntent = await stripeAdapter.CreateSetupIntentAsync(options);
return TypedResults.Ok(setupIntent.ClientSecret);
}

View File

@@ -1,34 +0,0 @@
using Bit.Core;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Controllers;
[Route("phishing-domains")]
public class PhishingDomainsController(IPhishingDomainRepository phishingDomainRepository, IFeatureService featureService) : Controller
{
[HttpGet]
public async Task<ActionResult<ICollection<string>>> GetPhishingDomainsAsync()
{
if (!featureService.IsEnabled(FeatureFlagKeys.PhishingDetection))
{
return NotFound();
}
var domains = await phishingDomainRepository.GetActivePhishingDomainsAsync();
return Ok(domains);
}
[HttpGet("checksum")]
public async Task<ActionResult<string>> GetChecksumAsync()
{
if (!featureService.IsEnabled(FeatureFlagKeys.PhishingDetection))
{
return NotFound();
}
var checksum = await phishingDomainRepository.GetCurrentChecksumAsync();
return Ok(checksum);
}
}

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Api.Dirt.Models.Response;
using Bit.Api.Models.Response;
using Bit.Api.Utilities;
using Bit.Api.Utilities.DiagnosticTools;
@@ -17,7 +18,7 @@ using Bit.Core.Vault.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Controllers;
namespace Bit.Api.Dirt.Controllers;
[Route("events")]
[Authorize("Application")]

View File

@@ -2,7 +2,7 @@
using Bit.Core.Models.Api;
using Bit.Core.Models.Data;
namespace Bit.Api.Models.Response;
namespace Bit.Api.Dirt.Models.Response;
public class EventResponseModel : ResponseModel
{

View File

@@ -1,6 +1,5 @@

using System.Net;
using Bit.Api.Models.Public.Request;
using System.Net;
using Bit.Api.Dirt.Public.Models;
using Bit.Api.Models.Public.Response;
using Bit.Api.Utilities.DiagnosticTools;
using Bit.Core.Context;
@@ -12,7 +11,7 @@ using Bit.Core.Vault.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Public.Controllers;
namespace Bit.Api.Dirt.Public.Controllers;
[Route("public/events")]
[Authorize("Organization")]

View File

@@ -3,7 +3,7 @@
using Bit.Core.Exceptions;
namespace Bit.Api.Models.Public.Request;
namespace Bit.Api.Dirt.Public.Models;
public class EventFilterRequestModel
{

View File

@@ -1,8 +1,9 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.Models.Public.Response;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
namespace Bit.Api.Models.Public.Response;
namespace Bit.Api.Dirt.Public.Models;
/// <summary>
/// An event log.

View File

@@ -59,13 +59,6 @@ public class JobsHostedService : BaseJobsHostedService
.StartNow()
.WithCronSchedule("0 0 * * * ?")
.Build();
var updatePhishingDomainsTrigger = TriggerBuilder.Create()
.WithIdentity("UpdatePhishingDomainsTrigger")
.StartNow()
.WithSimpleSchedule(x => x
.WithIntervalInHours(24)
.RepeatForever())
.Build();
var updateOrgSubscriptionsTrigger = TriggerBuilder.Create()
.WithIdentity("UpdateOrgSubscriptionsTrigger")
.StartNow()
@@ -81,7 +74,6 @@ public class JobsHostedService : BaseJobsHostedService
new Tuple<Type, ITrigger>(typeof(ValidateUsersJob), everyTopOfTheSixthHourTrigger),
new Tuple<Type, ITrigger>(typeof(ValidateOrganizationsJob), everyTwelfthHourAndThirtyMinutesTrigger),
new Tuple<Type, ITrigger>(typeof(ValidateOrganizationDomainJob), validateOrganizationDomainTrigger),
new Tuple<Type, ITrigger>(typeof(UpdatePhishingDomainsJob), updatePhishingDomainsTrigger),
new (typeof(OrganizationSubscriptionUpdateJob), updateOrgSubscriptionsTrigger),
};
@@ -111,7 +103,6 @@ public class JobsHostedService : BaseJobsHostedService
services.AddTransient<ValidateUsersJob>();
services.AddTransient<ValidateOrganizationsJob>();
services.AddTransient<ValidateOrganizationDomainJob>();
services.AddTransient<UpdatePhishingDomainsJob>();
services.AddTransient<OrganizationSubscriptionUpdateJob>();
}

View File

@@ -1,97 +0,0 @@
using Bit.Core;
using Bit.Core.Jobs;
using Bit.Core.PhishingDomainFeatures.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Quartz;
namespace Bit.Api.Jobs;
public class UpdatePhishingDomainsJob : BaseJob
{
private readonly GlobalSettings _globalSettings;
private readonly IPhishingDomainRepository _phishingDomainRepository;
private readonly ICloudPhishingDomainQuery _cloudPhishingDomainQuery;
private readonly IFeatureService _featureService;
public UpdatePhishingDomainsJob(
GlobalSettings globalSettings,
IPhishingDomainRepository phishingDomainRepository,
ICloudPhishingDomainQuery cloudPhishingDomainQuery,
IFeatureService featureService,
ILogger<UpdatePhishingDomainsJob> logger)
: base(logger)
{
_globalSettings = globalSettings;
_phishingDomainRepository = phishingDomainRepository;
_cloudPhishingDomainQuery = cloudPhishingDomainQuery;
_featureService = featureService;
}
protected override async Task ExecuteJobAsync(IJobExecutionContext context)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.PhishingDetection))
{
_logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. Feature flag is disabled.");
return;
}
if (string.IsNullOrWhiteSpace(_globalSettings.PhishingDomain?.UpdateUrl))
{
_logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. No URL configured.");
return;
}
if (_globalSettings.SelfHosted && !_globalSettings.EnableCloudCommunication)
{
_logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. Cloud communication is disabled in global settings.");
return;
}
var remoteChecksum = await _cloudPhishingDomainQuery.GetRemoteChecksumAsync();
if (string.IsNullOrWhiteSpace(remoteChecksum))
{
_logger.LogWarning(Constants.BypassFiltersEventId, "Could not retrieve remote checksum. Skipping update.");
return;
}
var currentChecksum = await _phishingDomainRepository.GetCurrentChecksumAsync();
if (string.Equals(currentChecksum, remoteChecksum, StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation(Constants.BypassFiltersEventId,
"Phishing domains list is up to date (checksum: {Checksum}). Skipping update.",
currentChecksum);
return;
}
_logger.LogInformation(Constants.BypassFiltersEventId,
"Checksums differ (current: {CurrentChecksum}, remote: {RemoteChecksum}). Fetching updated domains from {Source}.",
currentChecksum, remoteChecksum, _globalSettings.SelfHosted ? "Bitwarden cloud API" : "external source");
try
{
var domains = await _cloudPhishingDomainQuery.GetPhishingDomainsAsync();
if (!domains.Contains("phishing.testcategory.com", StringComparer.OrdinalIgnoreCase))
{
domains.Add("phishing.testcategory.com");
}
if (domains.Count > 0)
{
_logger.LogInformation(Constants.BypassFiltersEventId, "Updating {Count} phishing domains with checksum {Checksum}.",
domains.Count, remoteChecksum);
await _phishingDomainRepository.UpdatePhishingDomainsAsync(domains, remoteChecksum);
_logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated phishing domains.");
}
else
{
_logger.LogWarning(Constants.BypassFiltersEventId, "No valid domains found in the response. Skipping update.");
}
}
catch (Exception ex)
{
_logger.LogError(Constants.BypassFiltersEventId, ex, "Error updating phishing domains.");
}
}
}

View File

@@ -47,6 +47,7 @@ public class AccountsKeyManagementController : Controller
_webauthnKeyValidator;
private readonly IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> _deviceValidator;
private readonly IKeyConnectorConfirmationDetailsQuery _keyConnectorConfirmationDetailsQuery;
private readonly ISetKeyConnectorKeyCommand _setKeyConnectorKeyCommand;
public AccountsKeyManagementController(IUserService userService,
IFeatureService featureService,
@@ -62,8 +63,10 @@ public class AccountsKeyManagementController : Controller
emergencyAccessValidator,
IRotationValidator<IEnumerable<ResetPasswordWithOrgIdRequestModel>, IReadOnlyList<OrganizationUser>>
organizationUserValidator,
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>> webAuthnKeyValidator,
IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> deviceValidator)
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>
webAuthnKeyValidator,
IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> deviceValidator,
ISetKeyConnectorKeyCommand setKeyConnectorKeyCommand)
{
_userService = userService;
_featureService = featureService;
@@ -79,6 +82,7 @@ public class AccountsKeyManagementController : Controller
_webauthnKeyValidator = webAuthnKeyValidator;
_deviceValidator = deviceValidator;
_keyConnectorConfirmationDetailsQuery = keyConnectorConfirmationDetailsQuery;
_setKeyConnectorKeyCommand = setKeyConnectorKeyCommand;
}
[HttpPost("key-management/regenerate-keys")]
@@ -146,18 +150,28 @@ public class AccountsKeyManagementController : Controller
throw new UnauthorizedAccessException();
}
var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier);
if (result.Succeeded)
if (model.IsV2Request())
{
return;
// V2 account registration
await _setKeyConnectorKeyCommand.SetKeyConnectorKeyForUserAsync(user, model.ToKeyConnectorKeysData());
}
foreach (var error in result.Errors)
else
{
ModelState.AddModelError(string.Empty, error.Description);
}
// V1 account registration
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier);
if (result.Succeeded)
{
return;
}
throw new BadRequestException(ModelState);
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
throw new BadRequestException(ModelState);
}
}
[HttpPost("convert-to-key-connector")]

View File

@@ -1,36 +1,112 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Models.Api.Request;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Utilities;
namespace Bit.Api.KeyManagement.Models.Requests;
public class SetKeyConnectorKeyRequestModel
public class SetKeyConnectorKeyRequestModel : IValidatableObject
{
[Required]
public string Key { get; set; }
[Required]
public KeysRequestModel Keys { get; set; }
[Required]
public KdfType Kdf { get; set; }
[Required]
public int KdfIterations { get; set; }
public int? KdfMemory { get; set; }
public int? KdfParallelism { get; set; }
[Required]
public string OrgIdentifier { get; set; }
// TODO will be removed with https://bitwarden.atlassian.net/browse/PM-27328
[Obsolete("Use KeyConnectorKeyWrappedUserKey instead")]
public string? Key { get; set; }
[Obsolete("Use AccountKeys instead")]
public KeysRequestModel? Keys { get; set; }
[Obsolete("Not used anymore")]
public KdfType? Kdf { get; set; }
[Obsolete("Not used anymore")]
public int? KdfIterations { get; set; }
[Obsolete("Not used anymore")]
public int? KdfMemory { get; set; }
[Obsolete("Not used anymore")]
public int? KdfParallelism { get; set; }
[EncryptedString]
public string? KeyConnectorKeyWrappedUserKey { get; set; }
public AccountKeysRequestModel? AccountKeys { get; set; }
[Required]
public required string OrgIdentifier { get; init; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (IsV2Request())
{
// V2 registration
yield break;
}
// V1 registration
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
if (string.IsNullOrEmpty(Key))
{
yield return new ValidationResult("Key must be supplied.");
}
if (Keys == null)
{
yield return new ValidationResult("Keys must be supplied.");
}
if (Kdf == null)
{
yield return new ValidationResult("Kdf must be supplied.");
}
if (KdfIterations == null)
{
yield return new ValidationResult("KdfIterations must be supplied.");
}
if (Kdf == KdfType.Argon2id)
{
if (KdfMemory == null)
{
yield return new ValidationResult("KdfMemory must be supplied when Kdf is Argon2id.");
}
if (KdfParallelism == null)
{
yield return new ValidationResult("KdfParallelism must be supplied when Kdf is Argon2id.");
}
}
}
public bool IsV2Request()
{
return !string.IsNullOrEmpty(KeyConnectorKeyWrappedUserKey) && AccountKeys != null;
}
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
public User ToUser(User existingUser)
{
existingUser.Kdf = Kdf;
existingUser.KdfIterations = KdfIterations;
existingUser.Kdf = Kdf!.Value;
existingUser.KdfIterations = KdfIterations!.Value;
existingUser.KdfMemory = KdfMemory;
existingUser.KdfParallelism = KdfParallelism;
existingUser.Key = Key;
Keys.ToUser(existingUser);
Keys!.ToUser(existingUser);
return existingUser;
}
public KeyConnectorKeysData ToKeyConnectorKeysData()
{
// TODO remove validation with https://bitwarden.atlassian.net/browse/PM-27328
if (string.IsNullOrEmpty(KeyConnectorKeyWrappedUserKey) || AccountKeys == null)
{
throw new BadRequestException("KeyConnectorKeyWrappedUserKey and AccountKeys must be supplied.");
}
return new KeyConnectorKeysData
{
KeyConnectorKeyWrappedUserKey = KeyConnectorKeyWrappedUserKey,
AccountKeys = AccountKeys,
OrgIdentifier = OrgIdentifier
};
}
}

View File

@@ -2,6 +2,7 @@
#nullable disable
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Bit.Api.AdminConsole.Public.Models.Response;
using Bit.Core.Entities;
using Bit.Core.Models.Data;
@@ -13,6 +14,12 @@ namespace Bit.Api.Models.Public.Response;
/// </summary>
public class CollectionResponseModel : CollectionBaseModel, IResponseModel
{
[JsonConstructor]
public CollectionResponseModel()
{
}
public CollectionResponseModel(Collection collection, IEnumerable<CollectionAccessSelection> groups)
{
if (collection == null)

View File

@@ -1,27 +1,32 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Entities;
using Bit.Core.KeyManagement.Models.Api.Response;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Models.Api;
namespace Bit.Api.Models.Response;
public class KeysResponseModel : ResponseModel
{
public KeysResponseModel(User user)
public KeysResponseModel(UserAccountKeysData accountKeys, string? masterKeyWrappedUserKey)
: base("keys")
{
if (user == null)
if (masterKeyWrappedUserKey != null)
{
throw new ArgumentNullException(nameof(user));
Key = masterKeyWrappedUserKey;
}
Key = user.Key;
PublicKey = user.PublicKey;
PrivateKey = user.PrivateKey;
PublicKey = accountKeys.PublicKeyEncryptionKeyPairData.PublicKey;
PrivateKey = accountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey;
AccountKeys = new PrivateKeysResponseModel(accountKeys);
}
public string Key { get; set; }
/// <summary>
/// The master key wrapped user key. The master key can either be a master-password master key or a
/// key-connector master key.
/// </summary>
public string? Key { get; set; }
[Obsolete("Use AccountKeys.PublicKeyEncryptionKeyPair.PublicKey instead")]
public string PublicKey { get; set; }
[Obsolete("Use AccountKeys.PublicKeyEncryptionKeyPair.WrappedPrivateKey instead")]
public string PrivateKey { get; set; }
public PrivateKeysResponseModel AccountKeys { get; set; }
}

View File

@@ -65,10 +65,11 @@ public class CollectionsController : Controller
[ProducesResponseType(typeof(ListResponseModel<CollectionResponseModel>), (int)HttpStatusCode.OK)]
public async Task<IActionResult> List()
{
var collections = await _collectionRepository.GetManySharedCollectionsByOrganizationIdAsync(
_currentContext.OrganizationId.Value);
// TODO: Get all CollectionGroup associations for the organization and marry them up here for the response.
var collectionResponses = collections.Select(c => new CollectionResponseModel(c, null));
var collections = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(_currentContext.OrganizationId.Value);
var collectionResponses = collections.Select(c =>
new CollectionResponseModel(c.Item1, c.Item2.Groups));
var response = new ListResponseModel<CollectionResponseModel>(collectionResponses);
return new JsonResult(response);
}

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Api.Dirt.Models.Response;
using Bit.Api.Models.Response;
using Bit.Api.Utilities;
using Bit.Core.Exceptions;

View File

@@ -187,7 +187,6 @@ public class Startup
services.AddBillingOperations();
services.AddReportingServices();
services.AddImportServices();
services.AddPhishingDomainServices(globalSettings);
services.AddSendServices();

View File

@@ -1,4 +1,4 @@
using Bit.Api.Models.Public.Request;
using Bit.Api.Dirt.Public.Models;
using Bit.Api.Models.Public.Response;
using Bit.Core;
using Bit.Core.Services;
@@ -49,7 +49,7 @@ public static class EventDiagnosticLogger
this ILogger logger,
IFeatureService featureService,
Guid organizationId,
IEnumerable<Bit.Api.Models.Response.EventResponseModel> data,
IEnumerable<Dirt.Models.Response.EventResponseModel> data,
string? continuationToken,
DateTime? queryStart = null,
DateTime? queryEnd = null)

View File

@@ -1,9 +1,5 @@
using Bit.Api.AdminConsole.Authorization;
using Bit.Api.Tools.Authorization;
using Bit.Core.PhishingDomainFeatures;
using Bit.Core.PhishingDomainFeatures.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Repositories.Implementations;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Core.Vault.Authorization.SecurityTasks;
@@ -103,25 +99,4 @@ public static class ServiceCollectionExtensions
// Admin Console authorization handlers
services.AddAdminConsoleAuthorizationHandlers();
}
public static void AddPhishingDomainServices(this IServiceCollection services, GlobalSettings globalSettings)
{
services.AddHttpClient("PhishingDomains", client =>
{
client.DefaultRequestHeaders.Add("User-Agent", globalSettings.SelfHosted ? "Bitwarden Self-Hosted" : "Bitwarden");
client.Timeout = TimeSpan.FromSeconds(1000); // the source list is very slow
});
services.AddSingleton<AzurePhishingDomainStorageService>();
services.AddSingleton<IPhishingDomainRepository, AzurePhishingDomainRepository>();
if (globalSettings.SelfHosted)
{
services.AddScoped<ICloudPhishingDomainQuery, CloudPhishingDomainRelayQuery>();
}
else
{
services.AddScoped<ICloudPhishingDomainQuery, CloudPhishingDomainDirectQuery>();
}
}
}

View File

@@ -38,10 +38,6 @@
"storage": {
"connectionString": "UseDevelopmentStorage=true"
},
"phishingDomain": {
"updateUrl": "https://phish.co.za/latest/phishing-domains-ACTIVE.txt",
"checksumUrl": "https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-domains-ACTIVE.txt.sha256"
},
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
}
}

View File

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

View File

@@ -1,9 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Sdk Name="Bitwarden.Server.Sdk" />
<PropertyGroup>
<UserSecretsId>bitwarden-Billing</UserSecretsId>
</PropertyGroup>
<PropertyGroup Label="Server SDK settings">
<!-- These features will be gradually turned on -->
<BitIncludeFeatures>false</BitIncludeFeatures>
<BitIncludeTelemetry>false</BitIncludeTelemetry>
<BitIncludeAuthentication>false</BitIncludeAuthentication>
</PropertyGroup>
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Billing' " />
<ItemGroup>
<ProjectReference Include="..\..\bitwarden_license\src\Commercial.Core\Commercial.Core.csproj" />

View File

@@ -9,10 +9,7 @@ public class BillingSettings
public virtual string StripeWebhookKey { get; set; }
public virtual string StripeWebhookSecret20250827Basil { get; set; }
public virtual string AppleWebhookKey { get; set; }
public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings();
public virtual string FreshsalesApiKey { get; set; }
public virtual PayPalSettings PayPal { get; set; } = new PayPalSettings();
public virtual OnyxSettings Onyx { get; set; } = new OnyxSettings();
public class PayPalSettings
{
@@ -21,35 +18,4 @@ public class BillingSettings
public virtual string WebhookKey { get; set; }
}
public class FreshDeskSettings
{
public virtual string ApiKey { get; set; }
public virtual string WebhookKey { get; set; }
/// <summary>
/// Indicates the data center region. Valid values are "US" and "EU"
/// </summary>
public virtual string Region { get; set; }
public virtual string UserFieldName { get; set; }
public virtual string OrgFieldName { get; set; }
public virtual bool RemoveNewlinesInReplies { get; set; } = false;
public virtual string AutoReplyGreeting { get; set; } = string.Empty;
public virtual string AutoReplySalutation { get; set; } = string.Empty;
}
public class OnyxSettings
{
public virtual string ApiKey { get; set; }
public virtual string BaseUrl { get; set; }
public virtual string Path { get; set; }
public virtual int PersonaId { get; set; }
public virtual bool UseAnswerWithCitationModels { get; set; } = true;
public virtual SearchSettings SearchSettings { get; set; } = new SearchSettings();
}
public class SearchSettings
{
public virtual string RunSearch { get; set; } = "auto"; // "always", "never", "auto"
public virtual bool RealTime { get; set; } = true;
}
}

View File

@@ -29,7 +29,7 @@ public class BitPayController(
IUserRepository userRepository,
IProviderRepository providerRepository,
IMailService mailService,
IPaymentService paymentService,
IStripePaymentService paymentService,
ILogger<BitPayController> logger,
IPremiumUserBillingService premiumUserBillingService)
: Controller

View File

@@ -1,395 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.Net.Http.Headers;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Web;
using Bit.Billing.Models;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Markdig;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace Bit.Billing.Controllers;
[Route("freshdesk")]
public class FreshdeskController : Controller
{
private readonly BillingSettings _billingSettings;
private readonly IUserRepository _userRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly ILogger<FreshdeskController> _logger;
private readonly GlobalSettings _globalSettings;
private readonly IHttpClientFactory _httpClientFactory;
public FreshdeskController(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOptions<BillingSettings> billingSettings,
ILogger<FreshdeskController> logger,
GlobalSettings globalSettings,
IHttpClientFactory httpClientFactory)
{
_billingSettings = billingSettings?.Value ?? throw new ArgumentNullException(nameof(billingSettings));
_userRepository = userRepository;
_organizationRepository = organizationRepository;
_logger = logger;
_globalSettings = globalSettings;
_httpClientFactory = httpClientFactory;
}
[HttpPost("webhook")]
public async Task<IActionResult> PostWebhook([FromQuery, Required] string key,
[FromBody, Required] FreshdeskWebhookModel model)
{
if (string.IsNullOrWhiteSpace(key) || !CoreHelpers.FixedTimeEquals(key, _billingSettings.FreshDesk.WebhookKey))
{
return new BadRequestResult();
}
try
{
var ticketId = model.TicketId;
var ticketContactEmail = model.TicketContactEmail;
var ticketTags = model.TicketTags;
if (string.IsNullOrWhiteSpace(ticketId) || string.IsNullOrWhiteSpace(ticketContactEmail))
{
return new BadRequestResult();
}
var updateBody = new Dictionary<string, object>();
var note = string.Empty;
note += $"<li>Region: {_billingSettings.FreshDesk.Region}</li>";
var customFields = new Dictionary<string, object>();
var user = await _userRepository.GetByEmailAsync(ticketContactEmail);
if (user == null)
{
note += $"<li>No user found: {ticketContactEmail}</li>";
await CreateNote(ticketId, note);
}
if (user != null)
{
var userLink = $"{_globalSettings.BaseServiceUri.Admin}/users/edit/{user.Id}";
note += $"<li>User, {user.Email}: {userLink}</li>";
customFields.Add(_billingSettings.FreshDesk.UserFieldName, userLink);
var tags = new HashSet<string>();
if (user.Premium)
{
tags.Add("Premium");
}
var orgs = await _organizationRepository.GetManyByUserIdAsync(user.Id);
foreach (var org in orgs)
{
// Prevent org names from injecting any additional HTML
var orgName = HttpUtility.HtmlEncode(org.Name);
var orgNote = $"{orgName} ({org.Seats.GetValueOrDefault()}): " +
$"{_globalSettings.BaseServiceUri.Admin}/organizations/edit/{org.Id}";
note += $"<li>Org, {orgNote}</li>";
if (!customFields.Any(kvp => kvp.Key == _billingSettings.FreshDesk.OrgFieldName))
{
customFields.Add(_billingSettings.FreshDesk.OrgFieldName, orgNote);
}
else
{
customFields[_billingSettings.FreshDesk.OrgFieldName] += $"\n{orgNote}";
}
var displayAttribute = GetAttribute<DisplayAttribute>(org.PlanType);
var planName = displayAttribute?.Name?.Split(" ").FirstOrDefault();
if (!string.IsNullOrWhiteSpace(planName))
{
tags.Add(string.Format("Org: {0}", planName));
}
}
if (tags.Any())
{
var tagsToUpdate = tags.ToList();
if (!string.IsNullOrWhiteSpace(ticketTags))
{
var splitTicketTags = ticketTags.Split(',');
for (var i = 0; i < splitTicketTags.Length; i++)
{
tagsToUpdate.Insert(i, splitTicketTags[i]);
}
}
updateBody.Add("tags", tagsToUpdate);
}
if (customFields.Any())
{
updateBody.Add("custom_fields", customFields);
}
var updateRequest = new HttpRequestMessage(HttpMethod.Put,
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}", ticketId))
{
Content = JsonContent.Create(updateBody),
};
await CallFreshdeskApiAsync(updateRequest);
await CreateNote(ticketId, note);
}
return new OkResult();
}
catch (Exception e)
{
_logger.LogError(e, "Error processing freshdesk webhook.");
return new BadRequestResult();
}
}
[HttpPost("webhook-onyx-ai")]
public async Task<IActionResult> PostWebhookOnyxAi([FromQuery, Required] string key,
[FromBody, Required] FreshdeskOnyxAiWebhookModel model)
{
// ensure that the key is from Freshdesk
if (!IsValidRequestFromFreshdesk(key))
{
return new BadRequestResult();
}
// if there is no description, then we don't send anything to onyx
if (string.IsNullOrEmpty(model.TicketDescriptionText.Trim()))
{
return Ok();
}
// Get response from Onyx AI
var (onyxRequest, onyxResponse) = await GetAnswerFromOnyx(model);
// the CallOnyxApi will return a null if we have an error response
if (onyxResponse?.Answer == null || !string.IsNullOrEmpty(onyxResponse?.ErrorMsg))
{
_logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ",
JsonSerializer.Serialize(model),
JsonSerializer.Serialize(onyxRequest),
JsonSerializer.Serialize(onyxResponse));
return Ok(); // return ok so we don't retry
}
// add the answer as a note to the ticket
await AddAnswerNoteToTicketAsync(onyxResponse?.Answer ?? string.Empty, model.TicketId);
return Ok();
}
[HttpPost("webhook-onyx-ai-reply")]
public async Task<IActionResult> PostWebhookOnyxAiReply([FromQuery, Required] string key,
[FromBody, Required] FreshdeskOnyxAiWebhookModel model)
{
// NOTE:
// at this time, this endpoint is a duplicate of `webhook-onyx-ai`
// eventually, we will merge both endpoints into one webhook for Freshdesk
// ensure that the key is from Freshdesk
if (!IsValidRequestFromFreshdesk(key) || !ModelState.IsValid)
{
return new BadRequestResult();
}
// if there is no description, then we don't send anything to onyx
if (string.IsNullOrEmpty(model.TicketDescriptionText.Trim()))
{
return Ok();
}
// create the onyx `answer-with-citation` request
var (onyxRequest, onyxResponse) = await GetAnswerFromOnyx(model);
// the CallOnyxApi will return a null if we have an error response
if (onyxResponse?.Answer == null || !string.IsNullOrEmpty(onyxResponse?.ErrorMsg))
{
_logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ",
JsonSerializer.Serialize(model),
JsonSerializer.Serialize(onyxRequest),
JsonSerializer.Serialize(onyxResponse));
return Ok(); // return ok so we don't retry
}
// add the reply to the ticket
await AddReplyToTicketAsync(onyxResponse?.Answer ?? string.Empty, model.TicketId);
return Ok();
}
private bool IsValidRequestFromFreshdesk(string key)
{
if (string.IsNullOrWhiteSpace(key)
|| !CoreHelpers.FixedTimeEquals(key, _billingSettings.FreshDesk.WebhookKey))
{
return false;
}
return true;
}
private async Task CreateNote(string ticketId, string note)
{
var noteBody = new Dictionary<string, object>
{
{ "body", $"<ul>{note}</ul>" },
{ "private", true }
};
var noteRequest = new HttpRequestMessage(HttpMethod.Post,
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/notes", ticketId))
{
Content = JsonContent.Create(noteBody),
};
await CallFreshdeskApiAsync(noteRequest);
}
private async Task AddAnswerNoteToTicketAsync(string note, string ticketId)
{
// if there is no content, then we don't need to add a note
if (string.IsNullOrWhiteSpace(note))
{
return;
}
var noteBody = new Dictionary<string, object>
{
{ "body", $"<b>Onyx AI:</b><ul>{note}</ul>" },
{ "private", true }
};
var noteRequest = new HttpRequestMessage(HttpMethod.Post,
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/notes", ticketId))
{
Content = JsonContent.Create(noteBody),
};
var addNoteResponse = await CallFreshdeskApiAsync(noteRequest);
if (addNoteResponse.StatusCode != System.Net.HttpStatusCode.Created)
{
_logger.LogError("Error adding note to Freshdesk ticket. Ticket Id: {0}. Status: {1}",
ticketId, addNoteResponse.ToString());
}
}
private async Task AddReplyToTicketAsync(string note, string ticketId)
{
// if there is no content, then we don't need to add a note
if (string.IsNullOrWhiteSpace(note))
{
return;
}
// convert note from markdown to html
var htmlNote = note;
try
{
var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
htmlNote = Markdig.Markdown.ToHtml(note, pipeline);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error converting markdown to HTML for Freshdesk reply. Ticket Id: {0}. Note: {1}",
ticketId, note);
htmlNote = note; // fallback to the original note
}
// clear out any new lines that Freshdesk doesn't like
if (_billingSettings.FreshDesk.RemoveNewlinesInReplies)
{
htmlNote = htmlNote.Replace(Environment.NewLine, string.Empty);
}
var replyBody = new FreshdeskReplyRequestModel
{
Body = $"{_billingSettings.FreshDesk.AutoReplyGreeting}{htmlNote}{_billingSettings.FreshDesk.AutoReplySalutation}",
};
var replyRequest = new HttpRequestMessage(HttpMethod.Post,
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/reply", ticketId))
{
Content = JsonContent.Create(replyBody),
};
var addReplyResponse = await CallFreshdeskApiAsync(replyRequest);
if (addReplyResponse.StatusCode != System.Net.HttpStatusCode.Created)
{
_logger.LogError("Error adding reply to Freshdesk ticket. Ticket Id: {0}. Status: {1}",
ticketId, addReplyResponse.ToString());
}
}
private async Task<HttpResponseMessage> CallFreshdeskApiAsync(HttpRequestMessage request, int retriedCount = 0)
{
try
{
var freshdeskAuthkey = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_billingSettings.FreshDesk.ApiKey}:X"));
var httpClient = _httpClientFactory.CreateClient("FreshdeskApi");
request.Headers.Add("Authorization", $"Basic {freshdeskAuthkey}");
var response = await httpClient.SendAsync(request);
if (response.StatusCode != System.Net.HttpStatusCode.TooManyRequests || retriedCount > 3)
{
return response;
}
}
catch
{
if (retriedCount > 3)
{
throw;
}
}
await Task.Delay(30000 * (retriedCount + 1));
return await CallFreshdeskApiAsync(request, retriedCount++);
}
async Task<(OnyxRequestModel onyxRequest, OnyxResponseModel onyxResponse)> GetAnswerFromOnyx(FreshdeskOnyxAiWebhookModel model)
{
// TODO: remove the use of the deprecated answer-with-citation models after we are sure
if (_billingSettings.Onyx.UseAnswerWithCitationModels)
{
var onyxRequest = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText, _billingSettings.Onyx);
var onyxAnswerWithCitationRequest = new HttpRequestMessage(HttpMethod.Post,
string.Format("{0}/query/answer-with-citation", _billingSettings.Onyx.BaseUrl))
{
Content = JsonContent.Create(onyxRequest, mediaType: new MediaTypeHeaderValue("application/json")),
};
var onyxResponse = await CallOnyxApi<OnyxResponseModel>(onyxAnswerWithCitationRequest);
return (onyxRequest, onyxResponse);
}
var request = new OnyxSendMessageSimpleApiRequestModel(model.TicketDescriptionText, _billingSettings.Onyx);
var onyxSimpleRequest = new HttpRequestMessage(HttpMethod.Post,
string.Format("{0}{1}", _billingSettings.Onyx.BaseUrl, _billingSettings.Onyx.Path))
{
Content = JsonContent.Create(request, mediaType: new MediaTypeHeaderValue("application/json")),
};
var onyxSimpleResponse = await CallOnyxApi<OnyxResponseModel>(onyxSimpleRequest);
return (request, onyxSimpleResponse);
}
private async Task<T> CallOnyxApi<T>(HttpRequestMessage request) where T : class, new()
{
var httpClient = _httpClientFactory.CreateClient("OnyxApi");
var response = await httpClient.SendAsync(request);
if (response.StatusCode != System.Net.HttpStatusCode.OK)
{
_logger.LogError("Error calling Onyx AI API. Status code: {0}. Response {1}",
response.StatusCode, JsonSerializer.Serialize(response));
return new T();
}
var responseStr = await response.Content.ReadAsStringAsync();
var responseJson = JsonSerializer.Deserialize<T>(responseStr, options: new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
});
return responseJson ?? new T();
}
private TAttribute? GetAttribute<TAttribute>(Enum enumValue) where TAttribute : Attribute
{
var memberInfo = enumValue.GetType().GetMember(enumValue.ToString()).FirstOrDefault();
return memberInfo != null ? memberInfo.GetCustomAttribute<TAttribute>() : null;
}
}

View File

@@ -1,248 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Net.Http.Headers;
using System.Text.Json.Serialization;
using Bit.Core.Billing.Enums;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace Bit.Billing.Controllers;
[Route("freshsales")]
public class FreshsalesController : Controller
{
private readonly IUserRepository _userRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly ILogger _logger;
private readonly GlobalSettings _globalSettings;
private readonly string _freshsalesApiKey;
private readonly HttpClient _httpClient;
public FreshsalesController(IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOptions<BillingSettings> billingSettings,
ILogger<FreshsalesController> logger,
GlobalSettings globalSettings)
{
_userRepository = userRepository;
_organizationRepository = organizationRepository;
_logger = logger;
_globalSettings = globalSettings;
_httpClient = new HttpClient
{
BaseAddress = new Uri("https://bitwarden.freshsales.io/api/")
};
_freshsalesApiKey = billingSettings.Value.FreshsalesApiKey;
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Token",
$"token={_freshsalesApiKey}");
}
[HttpPost("webhook")]
public async Task<IActionResult> PostWebhook([FromHeader(Name = "Authorization")] string key,
[FromBody] CustomWebhookRequestModel request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(key) || !CoreHelpers.FixedTimeEquals(_freshsalesApiKey, key))
{
return Unauthorized();
}
try
{
var leadResponse = await _httpClient.GetFromJsonAsync<LeadWrapper<FreshsalesLeadModel>>(
$"leads/{request.LeadId}",
cancellationToken);
var lead = leadResponse.Lead;
var primaryEmail = lead.Emails
.Where(e => e.IsPrimary)
.FirstOrDefault();
if (primaryEmail == null)
{
return BadRequest(new { Message = "Lead has not primary email." });
}
var user = await _userRepository.GetByEmailAsync(primaryEmail.Value);
if (user == null)
{
return NoContent();
}
var newTags = new HashSet<string>();
if (user.Premium)
{
newTags.Add("Premium");
}
var noteItems = new List<string>
{
$"User, {user.Email}: {_globalSettings.BaseServiceUri.Admin}/users/edit/{user.Id}"
};
var orgs = await _organizationRepository.GetManyByUserIdAsync(user.Id);
foreach (var org in orgs)
{
noteItems.Add($"Org, {org.DisplayName()}: {_globalSettings.BaseServiceUri.Admin}/organizations/edit/{org.Id}");
if (TryGetPlanName(org.PlanType, out var planName))
{
newTags.Add($"Org: {planName}");
}
}
if (newTags.Any())
{
var allTags = newTags.Concat(lead.Tags);
var updateLeadResponse = await _httpClient.PutAsJsonAsync(
$"leads/{request.LeadId}",
CreateWrapper(new { tags = allTags }),
cancellationToken);
updateLeadResponse.EnsureSuccessStatusCode();
}
var createNoteResponse = await _httpClient.PostAsJsonAsync(
"notes",
CreateNoteRequestModel(request.LeadId, string.Join('\n', noteItems)), cancellationToken);
createNoteResponse.EnsureSuccessStatusCode();
return NoContent();
}
catch (Exception ex)
{
Console.WriteLine(ex);
_logger.LogError(ex, "Error processing freshsales webhook");
return BadRequest(new { ex.Message });
}
}
private static LeadWrapper<T> CreateWrapper<T>(T lead)
{
return new LeadWrapper<T>
{
Lead = lead,
};
}
private static CreateNoteRequestModel CreateNoteRequestModel(long leadId, string content)
{
return new CreateNoteRequestModel
{
Note = new EditNoteModel
{
Description = content,
TargetableType = "Lead",
TargetableId = leadId,
},
};
}
private static bool TryGetPlanName(PlanType planType, out string planName)
{
switch (planType)
{
case PlanType.Free:
planName = "Free";
return true;
case PlanType.FamiliesAnnually:
case PlanType.FamiliesAnnually2025:
case PlanType.FamiliesAnnually2019:
planName = "Families";
return true;
case PlanType.TeamsAnnually:
case PlanType.TeamsAnnually2023:
case PlanType.TeamsAnnually2020:
case PlanType.TeamsAnnually2019:
case PlanType.TeamsMonthly:
case PlanType.TeamsMonthly2023:
case PlanType.TeamsMonthly2020:
case PlanType.TeamsMonthly2019:
case PlanType.TeamsStarter:
case PlanType.TeamsStarter2023:
planName = "Teams";
return true;
case PlanType.EnterpriseAnnually:
case PlanType.EnterpriseAnnually2023:
case PlanType.EnterpriseAnnually2020:
case PlanType.EnterpriseAnnually2019:
case PlanType.EnterpriseMonthly:
case PlanType.EnterpriseMonthly2023:
case PlanType.EnterpriseMonthly2020:
case PlanType.EnterpriseMonthly2019:
planName = "Enterprise";
return true;
case PlanType.Custom:
planName = "Custom";
return true;
default:
planName = null;
return false;
}
}
}
public class CustomWebhookRequestModel
{
[JsonPropertyName("leadId")]
public long LeadId { get; set; }
}
public class LeadWrapper<T>
{
[JsonPropertyName("lead")]
public T Lead { get; set; }
public static LeadWrapper<TItem> Create<TItem>(TItem lead)
{
return new LeadWrapper<TItem>
{
Lead = lead,
};
}
}
public class FreshsalesLeadModel
{
public string[] Tags { get; set; }
public FreshsalesEmailModel[] Emails { get; set; }
}
public class FreshsalesEmailModel
{
[JsonPropertyName("value")]
public string Value { get; set; }
[JsonPropertyName("is_primary")]
public bool IsPrimary { get; set; }
}
public class CreateNoteRequestModel
{
[JsonPropertyName("note")]
public EditNoteModel Note { get; set; }
}
public class EditNoteModel
{
[JsonPropertyName("description")]
public string Description { get; set; }
[JsonPropertyName("targetable_type")]
public string TargetableType { get; set; }
[JsonPropertyName("targetable_id")]
public long TargetableId { get; set; }
}

View File

@@ -23,7 +23,7 @@ public class PayPalController : Controller
private readonly ILogger<PayPalController> _logger;
private readonly IMailService _mailService;
private readonly IOrganizationRepository _organizationRepository;
private readonly IPaymentService _paymentService;
private readonly IStripePaymentService _paymentService;
private readonly ITransactionRepository _transactionRepository;
private readonly IUserRepository _userRepository;
private readonly IProviderRepository _providerRepository;
@@ -34,7 +34,7 @@ public class PayPalController : Controller
ILogger<PayPalController> logger,
IMailService mailService,
IOrganizationRepository organizationRepository,
IPaymentService paymentService,
IStripePaymentService paymentService,
ITransactionRepository transactionRepository,
IUserRepository userRepository,
IProviderRepository providerRepository,

View File

@@ -39,15 +39,11 @@ public class ReconcileAdditionalStorageJob(
logger.LogInformation("Starting ReconcileAdditionalStorageJob (live mode: {LiveMode})", liveMode);
var priceIds = new[] { _storageGbMonthlyPriceId, _storageGbAnnuallyPriceId, _personalStorageGbAnnuallyPriceId };
var stripeStatusesToProcess = new[] { StripeConstants.SubscriptionStatus.Active, StripeConstants.SubscriptionStatus.Trialing, StripeConstants.SubscriptionStatus.PastDue };
foreach (var priceId in priceIds)
{
var options = new SubscriptionListOptions
{
Limit = 100,
Status = StripeConstants.SubscriptionStatus.Active,
Price = priceId
};
var options = new SubscriptionListOptions { Limit = 100, Price = priceId };
await foreach (var subscription in stripeFacade.ListSubscriptionsAutoPagingAsync(options))
{
@@ -64,7 +60,7 @@ public class ReconcileAdditionalStorageJob(
failures.Count > 0
? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}"
: string.Empty
);
);
return;
}
@@ -73,6 +69,12 @@ public class ReconcileAdditionalStorageJob(
continue;
}
if (!stripeStatusesToProcess.Contains(subscription.Status))
{
logger.LogInformation("Skipping subscription with unsupported status: {SubscriptionId} - {Status}", subscription.Id, subscription.Status);
continue;
}
logger.LogInformation("Processing subscription: {SubscriptionId}", subscription.Id);
subscriptionsFound++;
@@ -133,7 +135,7 @@ public class ReconcileAdditionalStorageJob(
failures.Count > 0
? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}"
: string.Empty
);
);
}
private SubscriptionUpdateOptions? BuildSubscriptionUpdateOptions(
@@ -145,15 +147,7 @@ public class ReconcileAdditionalStorageJob(
return null;
}
var updateOptions = new SubscriptionUpdateOptions
{
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o")
},
Items = []
};
var updateOptions = new SubscriptionUpdateOptions { ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, Metadata = new Dictionary<string, string> { [StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o") }, Items = [] };
var hasUpdates = false;
@@ -172,11 +166,7 @@ public class ReconcileAdditionalStorageJob(
newQuantity,
item.Price.Id);
updateOptions.Items.Add(new SubscriptionItemOptions
{
Id = item.Id,
Quantity = newQuantity
});
updateOptions.Items.Add(new SubscriptionItemOptions { Id = item.Id, Quantity = newQuantity });
}
else
{
@@ -185,11 +175,7 @@ public class ReconcileAdditionalStorageJob(
currentQuantity,
item.Price.Id);
updateOptions.Items.Add(new SubscriptionItemOptions
{
Id = item.Id,
Deleted = true
});
updateOptions.Items.Add(new SubscriptionItemOptions { Id = item.Id, Deleted = true });
}
}

View File

@@ -1,9 +0,0 @@
using System.Text.Json.Serialization;
namespace Bit.Billing.Models;
public class FreshdeskReplyRequestModel
{
[JsonPropertyName("body")]
public required string Body { get; set; }
}

View File

@@ -1,24 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Text.Json.Serialization;
namespace Bit.Billing.Models;
public class FreshdeskWebhookModel
{
[JsonPropertyName("ticket_id")]
public string TicketId { get; set; }
[JsonPropertyName("ticket_contact_email")]
public string TicketContactEmail { get; set; }
[JsonPropertyName("ticket_tags")]
public string TicketTags { get; set; }
}
public class FreshdeskOnyxAiWebhookModel : FreshdeskWebhookModel
{
[JsonPropertyName("ticket_description_text")]
public string TicketDescriptionText { get; set; }
}

View File

@@ -1,75 +0,0 @@
using System.Text.Json.Serialization;
using static Bit.Billing.BillingSettings;
namespace Bit.Billing.Models;
public class OnyxRequestModel
{
[JsonPropertyName("persona_id")]
public int PersonaId { get; set; } = 1;
[JsonPropertyName("retrieval_options")]
public RetrievalOptions RetrievalOptions { get; set; } = new RetrievalOptions();
public OnyxRequestModel(OnyxSettings onyxSettings)
{
PersonaId = onyxSettings.PersonaId;
RetrievalOptions.RunSearch = onyxSettings.SearchSettings.RunSearch;
RetrievalOptions.RealTime = onyxSettings.SearchSettings.RealTime;
}
}
/// <summary>
/// This is used with the onyx endpoint /query/answer-with-citation
/// which has been deprecated. This can be removed once later
/// </summary>
public class OnyxAnswerWithCitationRequestModel : OnyxRequestModel
{
[JsonPropertyName("messages")]
public List<Message> Messages { get; set; } = new List<Message>();
public OnyxAnswerWithCitationRequestModel(string message, OnyxSettings onyxSettings) : base(onyxSettings)
{
message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' ');
Messages = new List<Message>() { new Message() { MessageText = message } };
}
}
/// <summary>
/// This is used with the onyx endpoint /chat/send-message-simple-api
/// </summary>
public class OnyxSendMessageSimpleApiRequestModel : OnyxRequestModel
{
[JsonPropertyName("message")]
public string Message { get; set; } = string.Empty;
public OnyxSendMessageSimpleApiRequestModel(string message, OnyxSettings onyxSettings) : base(onyxSettings)
{
Message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' ');
}
}
public class Message
{
[JsonPropertyName("message")]
public string MessageText { get; set; } = string.Empty;
[JsonPropertyName("sender")]
public string Sender { get; set; } = "user";
}
public class RetrievalOptions
{
[JsonPropertyName("run_search")]
public string RunSearch { get; set; } = RetrievalOptionsRunSearch.Auto;
[JsonPropertyName("real_time")]
public bool RealTime { get; set; } = true;
}
public class RetrievalOptionsRunSearch
{
public const string Always = "always";
public const string Never = "never";
public const string Auto = "auto";
}

View File

@@ -1,15 +0,0 @@
using System.Text.Json.Serialization;
namespace Bit.Billing.Models;
public class OnyxResponseModel
{
[JsonPropertyName("answer")]
public string Answer { get; set; } = string.Empty;
[JsonPropertyName("answer_citationless")]
public string AnswerCitationless { get; set; } = string.Empty;
[JsonPropertyName("error_msg")]
public string ErrorMsg { get; set; } = string.Empty;
}

View File

@@ -8,6 +8,7 @@ public class Program
{
Host
.CreateDefaultBuilder(args)
.UseBitwardenSdk()
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();

View File

@@ -36,7 +36,7 @@ public interface IStripeEventUtilityService
/// <param name="userId"></param>
/// /// <param name="providerId"></param>
/// <returns></returns>
Transaction FromChargeToTransaction(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId);
Task<Transaction> FromChargeToTransactionAsync(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId);
/// <summary>
/// Attempts to pay the specified invoice. If a customer is eligible, the invoice is paid using Braintree or Stripe.

View File

@@ -20,6 +20,12 @@ public interface IStripeFacade
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
IAsyncEnumerable<CustomerCashBalanceTransaction> GetCustomerCashBalanceTransactions(
string customerId,
CustomerCashBalanceTransactionListOptions customerCashBalanceTransactionListOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<Customer> UpdateCustomer(
string customerId,
CustomerUpdateOptions customerUpdateOptions = null,

View File

@@ -38,7 +38,7 @@ public class ChargeRefundedHandler : IChargeRefundedHandler
{
// Attempt to create a transaction for the charge if it doesn't exist
var (organizationId, userId, providerId) = await _stripeEventUtilityService.GetEntityIdsFromChargeAsync(charge);
var tx = _stripeEventUtilityService.FromChargeToTransaction(charge, organizationId, userId, providerId);
var tx = await _stripeEventUtilityService.FromChargeToTransactionAsync(charge, organizationId, userId, providerId);
try
{
parentTransaction = await _transactionRepository.CreateAsync(tx);

View File

@@ -46,7 +46,7 @@ public class ChargeSucceededHandler : IChargeSucceededHandler
return;
}
var transaction = _stripeEventUtilityService.FromChargeToTransaction(charge, organizationId, userId, providerId);
var transaction = await _stripeEventUtilityService.FromChargeToTransactionAsync(charge, organizationId, userId, providerId);
if (!transaction.PaymentMethodType.HasValue)
{
_logger.LogWarning("Charge success from unsupported source/method. {ChargeId}", charge.Id);

View File

@@ -2,8 +2,8 @@
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Services;
using Bit.Core.Repositories;
using Bit.Core.Services;
using OneOf;
using Stripe;
using Event = Stripe.Event;
@@ -59,10 +59,10 @@ public class SetupIntentSucceededHandler(
return;
}
await stripeAdapter.PaymentMethodAttachAsync(paymentMethod.Id,
await stripeAdapter.AttachPaymentMethodAsync(paymentMethod.Id,
new PaymentMethodAttachOptions { Customer = customerId });
await stripeAdapter.CustomerUpdateAsync(customerId, new CustomerUpdateOptions
await stripeAdapter.UpdateCustomerAsync(customerId, new CustomerUpdateOptions
{
InvoiceSettings = new CustomerInvoiceSettingsOptions
{

View File

@@ -124,7 +124,7 @@ public class StripeEventUtilityService : IStripeEventUtilityService
/// <param name="userId"></param>
/// /// <param name="providerId"></param>
/// <returns></returns>
public Transaction FromChargeToTransaction(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId)
public async Task<Transaction> FromChargeToTransactionAsync(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId)
{
var transaction = new Transaction
{
@@ -209,6 +209,24 @@ public class StripeEventUtilityService : IStripeEventUtilityService
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
transaction.Details = $"ACH => {achCreditTransfer.BankName}, {achCreditTransfer.AccountNumber}";
}
else if (charge.PaymentMethodDetails.CustomerBalance != null)
{
var bankTransferType = await GetFundingBankTransferTypeAsync(charge);
if (!string.IsNullOrEmpty(bankTransferType))
{
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
transaction.Details = bankTransferType switch
{
"eu_bank_transfer" => "EU Bank Transfer",
"gb_bank_transfer" => "GB Bank Transfer",
"jp_bank_transfer" => "JP Bank Transfer",
"mx_bank_transfer" => "MX Bank Transfer",
"us_bank_transfer" => "US Bank Transfer",
_ => "Bank Transfer"
};
}
}
break;
}
@@ -289,20 +307,13 @@ public class StripeEventUtilityService : IStripeEventUtilityService
}
var btInvoiceAmount = Math.Round(invoice.AmountDue / 100M, 2);
var existingTransactions = organizationId.HasValue
? await _transactionRepository.GetManyByOrganizationIdAsync(organizationId.Value)
: userId.HasValue
? await _transactionRepository.GetManyByUserIdAsync(userId.Value)
: await _transactionRepository.GetManyByProviderIdAsync(providerId.Value);
var duplicateTimeSpan = TimeSpan.FromHours(24);
var now = DateTime.UtcNow;
var duplicateTransaction = existingTransactions?
.FirstOrDefault(t => (now - t.CreationDate) < duplicateTimeSpan);
if (duplicateTransaction != null)
// Check if this invoice already has a Braintree transaction ID to prevent duplicate charges
if (invoice.Metadata?.ContainsKey("btTransactionId") ?? false)
{
_logger.LogWarning("There is already a recent PayPal transaction ({0}). " +
"Do not charge again to prevent possible duplicate.", duplicateTransaction.GatewayId);
_logger.LogWarning("Invoice {InvoiceId} already has a Braintree transaction ({TransactionId}). " +
"Do not charge again to prevent duplicate.",
invoice.Id,
invoice.Metadata["btTransactionId"]);
return false;
}
@@ -413,4 +424,55 @@ public class StripeEventUtilityService : IStripeEventUtilityService
throw;
}
}
/// <summary>
/// Retrieves the bank transfer type that funded a charge paid via customer balance.
/// </summary>
/// <param name="charge">The charge to analyze.</param>
/// <returns>
/// The bank transfer type (e.g., "us_bank_transfer", "eu_bank_transfer") if the charge was funded
/// by a bank transfer via customer balance, otherwise null.
/// </returns>
private async Task<string> GetFundingBankTransferTypeAsync(Charge charge)
{
if (charge is not
{
CustomerId: not null,
PaymentIntentId: not null,
PaymentMethodDetails: { Type: "customer_balance" }
})
{
return null;
}
var cashBalanceTransactions = _stripeFacade.GetCustomerCashBalanceTransactions(charge.CustomerId);
string bankTransferType = null;
var matchingPaymentIntentFound = false;
await foreach (var cashBalanceTransaction in cashBalanceTransactions)
{
switch (cashBalanceTransaction)
{
case { Type: "funded", Funded: not null }:
{
bankTransferType = cashBalanceTransaction.Funded.BankTransfer.Type;
break;
}
case { Type: "applied_to_payment", AppliedToPayment: not null }
when cashBalanceTransaction.AppliedToPayment.PaymentIntentId == charge.PaymentIntentId:
{
matchingPaymentIntentFound = true;
break;
}
}
if (matchingPaymentIntentFound && !string.IsNullOrEmpty(bankTransferType))
{
return bankTransferType;
}
}
return null;
}
}

View File

@@ -11,6 +11,7 @@ public class StripeFacade : IStripeFacade
{
private readonly ChargeService _chargeService = new();
private readonly CustomerService _customerService = new();
private readonly CustomerCashBalanceTransactionService _customerCashBalanceTransactionService = new();
private readonly EventService _eventService = new();
private readonly InvoiceService _invoiceService = new();
private readonly PaymentMethodService _paymentMethodService = new();
@@ -41,6 +42,13 @@ public class StripeFacade : IStripeFacade
CancellationToken cancellationToken = default) =>
await _customerService.GetAsync(customerId, customerGetOptions, requestOptions, cancellationToken);
public IAsyncEnumerable<CustomerCashBalanceTransaction> GetCustomerCashBalanceTransactions(
string customerId,
CustomerCashBalanceTransactionListOptions customerCashBalanceTransactionListOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default)
=> _customerCashBalanceTransactionService.ListAutoPagingAsync(customerId, customerCashBalanceTransactionListOptions, requestOptions, cancellationToken);
public async Task<Customer> UpdateCustomer(
string customerId,
CustomerUpdateOptions customerUpdateOptions = null,

View File

@@ -109,8 +109,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
break;
}
if (subscription.Status is StripeSubscriptionStatus.Unpaid &&
subscription.Items.Any(i => i.Price.Id is IStripeEventUtilityService.PremiumPlanId or IStripeEventUtilityService.PremiumPlanIdAppStore))
if (await IsPremiumSubscriptionAsync(subscription))
{
await CancelSubscription(subscription.Id);
await VoidOpenInvoices(subscription.Id);
@@ -118,6 +117,20 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
await _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd);
break;
}
case StripeSubscriptionStatus.Incomplete when userId.HasValue:
{
// Handle Incomplete subscriptions for Premium users that have open invoices from failed payments
// This prevents duplicate subscriptions when users retry the subscription flow
if (await IsPremiumSubscriptionAsync(subscription) &&
subscription.LatestInvoice is { Status: StripeInvoiceStatus.Open })
{
await CancelSubscription(subscription.Id);
await VoidOpenInvoices(subscription.Id);
await _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd);
}
break;
}
case StripeSubscriptionStatus.Active when organizationId.HasValue:
@@ -190,6 +203,13 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
}
}
private async Task<bool> IsPremiumSubscriptionAsync(Subscription subscription)
{
var premiumPlans = await _pricingClient.ListPremiumPlans();
var premiumPriceIds = premiumPlans.SelectMany(p => new[] { p.Seat.StripePriceId, p.Storage.StripePriceId }).ToHashSet();
return subscription.Items.Any(i => premiumPriceIds.Contains(i.Price.Id));
}
/// <summary>
/// Checks if the provider subscription status has changed from a non-active to an active status type
/// If the previous status is already active(active,past-due,trialing),canceled,or null, then this will return false.

View File

@@ -2,7 +2,6 @@
#nullable disable
using System.Globalization;
using System.Net.Http.Headers;
using Bit.Billing.Services;
using Bit.Billing.Services.Implementations;
using Bit.Commercial.Core.Utilities;
@@ -98,13 +97,6 @@ public class Startup
// Authentication
services.AddAuthentication();
// Set up HttpClients
services.AddHttpClient("FreshdeskApi");
services.AddHttpClient("OnyxApi", client =>
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", billingSettings.Onyx.ApiKey);
});
services.AddScoped<IStripeFacade, StripeFacade>();
services.AddScoped<IStripeEventService, StripeEventService>();
services.AddScoped<IProviderEventService, ProviderEventService>();

View File

@@ -32,10 +32,5 @@
"connectionString": "UseDevelopmentStorage=true"
}
},
"billingSettings": {
"onyx": {
"personaId": 68
}
},
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
}

View File

@@ -26,10 +26,7 @@
"payPal": {
"production": true,
"businessId": "4ZDA7DLUUJGMN"
},
"onyx": {
"personaId": 7
}
}
},
"Logging": {
"IncludeScopes": false,

View File

@@ -61,27 +61,6 @@
"production": false,
"businessId": "AD3LAUZSNVPJY",
"webhookKey": "SECRET"
},
"freshdesk": {
"apiKey": "SECRET",
"webhookKey": "SECRET",
"region": "US",
"userFieldName": "cf_user",
"orgFieldName": "cf_org",
"removeNewlinesInReplies": true,
"autoReplyGreeting": "<b>Greetings,</b><br /><br />Thank you for contacting Bitwarden. The reply below was generated by our AI agent based on your message:<br /><br />",
"autoReplySalutation": "<br /><br />If this response doesnt fully address your question, simply reply to this email and a member of our Customer Success team will be happy to assist you further.<br /><p><b>Best Regards,</b><br />The Bitwarden Customer Success Team</p>"
},
"onyx": {
"apiKey": "SECRET",
"baseUrl": "https://cloud.onyx.app/api",
"path": "/chat/send-message-simple-api",
"useAnswerWithCitationModels": true,
"personaId": 7,
"searchSettings": {
"runSearch": "always",
"realTime": true
}
}
}
}

View File

@@ -1,8 +1,24 @@
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
using Azure.Messaging.ServiceBus;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.AdminConsole.Models.Teams;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.AdminConsole.Services.NoopImplementations;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Integration.AspNet.Core;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ZiggyCreatures.Caching.Fusion;
using TableStorageRepos = Bit.Core.Repositories.TableStorage;
namespace Microsoft.Extensions.DependencyInjection;
@@ -20,8 +36,467 @@ public static class EventIntegrationsServiceCollectionExtensions
// This is idempotent for the same named cache, so it's safe to call.
services.AddExtendedCache(EventIntegrationsCacheConstants.CacheName, globalSettings);
// Add Validator
services.TryAddSingleton<IOrganizationIntegrationConfigurationValidator, OrganizationIntegrationConfigurationValidator>();
// Add all commands/queries
services.AddOrganizationIntegrationCommandsQueries();
services.AddOrganizationIntegrationConfigurationCommandsQueries();
return services;
}
/// <summary>
/// Registers event write services based on available configuration.
/// </summary>
/// <param name="services">The service collection to add services to.</param>
/// <param name="globalSettings">The global settings containing event logging configuration.</param>
/// <returns>The service collection for chaining.</returns>
/// <remarks>
/// <para>
/// This method registers the appropriate IEventWriteService implementation based on the available
/// configuration, checking in the following priority order:
/// </para>
/// <para>
/// 1. Azure Service Bus - If all Azure Service Bus settings are present, registers
/// EventIntegrationEventWriteService with AzureServiceBusService as the publisher
/// </para>
/// <para>
/// 2. RabbitMQ - If all RabbitMQ settings are present, registers EventIntegrationEventWriteService with
/// RabbitMqService as the publisher
/// </para>
/// <para>
/// 3. Azure Queue Storage - If Events.ConnectionString is present, registers AzureQueueEventWriteService
/// </para>
/// <para>
/// 4. Repository (Self-Hosted) - If SelfHosted is true, registers RepositoryEventWriteService
/// </para>
/// <para>
/// 5. Noop - If none of the above are configured, registers NoopEventWriteService (no-op implementation)
/// </para>
/// </remarks>
public static IServiceCollection AddEventWriteServices(this IServiceCollection services, GlobalSettings globalSettings)
{
if (IsAzureServiceBusEnabled(globalSettings))
{
services.TryAddSingleton<IEventIntegrationPublisher, AzureServiceBusService>();
services.TryAddSingleton<IEventWriteService, EventIntegrationEventWriteService>();
return services;
}
if (IsRabbitMqEnabled(globalSettings))
{
services.TryAddSingleton<IEventIntegrationPublisher, RabbitMqService>();
services.TryAddSingleton<IEventWriteService, EventIntegrationEventWriteService>();
return services;
}
if (CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString) &&
CoreHelpers.SettingHasValue(globalSettings.Events.QueueName))
{
services.TryAddSingleton<IEventWriteService, AzureQueueEventWriteService>();
return services;
}
if (globalSettings.SelfHosted)
{
services.TryAddSingleton<IEventWriteService, RepositoryEventWriteService>();
return services;
}
services.TryAddSingleton<IEventWriteService, NoopEventWriteService>();
return services;
}
/// <summary>
/// Registers Azure Service Bus-based event integration listeners and supporting infrastructure.
/// </summary>
/// <param name="services">The service collection to add services to.</param>
/// <param name="globalSettings">The global settings containing Azure Service Bus configuration.</param>
/// <returns>The service collection for chaining.</returns>
/// <remarks>
/// <para>
/// If Azure Service Bus is not enabled (missing required settings), this method returns immediately
/// without registering any services.
/// </para>
/// <para>
/// When Azure Service Bus is enabled, this method registers:
/// - IAzureServiceBusService and IEventIntegrationPublisher implementations
/// - Table Storage event repository
/// - Azure Table Storage event handler
/// - All event integration services via AddEventIntegrationServices
/// </para>
/// <para>
/// PREREQUISITE: Callers must ensure AddDistributedCache has been called before this method,
/// as it is required to create the event integrations extended cache.
/// </para>
/// </remarks>
public static IServiceCollection AddAzureServiceBusListeners(this IServiceCollection services, GlobalSettings globalSettings)
{
if (!IsAzureServiceBusEnabled(globalSettings))
{
return services;
}
services.TryAddSingleton<IAzureServiceBusService, AzureServiceBusService>();
services.TryAddSingleton<IEventIntegrationPublisher, AzureServiceBusService>();
services.TryAddSingleton<IEventRepository, TableStorageRepos.EventRepository>();
services.TryAddKeyedSingleton<IEventWriteService, RepositoryEventWriteService>("persistent");
services.TryAddSingleton<AzureTableStorageEventHandler>();
services.AddEventIntegrationServices(globalSettings);
return services;
}
/// <summary>
/// Registers RabbitMQ-based event integration listeners and supporting infrastructure.
/// </summary>
/// <param name="services">The service collection to add services to.</param>
/// <param name="globalSettings">The global settings containing RabbitMQ configuration.</param>
/// <returns>The service collection for chaining.</returns>
/// <remarks>
/// <para>
/// If RabbitMQ is not enabled (missing required settings), this method returns immediately
/// without registering any services.
/// </para>
/// <para>
/// When RabbitMQ is enabled, this method registers:
/// - IRabbitMqService and IEventIntegrationPublisher implementations
/// - Event repository handler
/// - All event integration services via AddEventIntegrationServices
/// </para>
/// <para>
/// PREREQUISITE: Callers must ensure AddDistributedCache has been called before this method,
/// as it is required to create the event integrations extended cache.
/// </para>
/// </remarks>
public static IServiceCollection AddRabbitMqListeners(this IServiceCollection services, GlobalSettings globalSettings)
{
if (!IsRabbitMqEnabled(globalSettings))
{
return services;
}
services.TryAddSingleton<IRabbitMqService, RabbitMqService>();
services.TryAddSingleton<IEventIntegrationPublisher, RabbitMqService>();
services.TryAddSingleton<EventRepositoryHandler>();
services.AddEventIntegrationServices(globalSettings);
return services;
}
/// <summary>
/// Registers Slack integration services based on configuration settings.
/// </summary>
/// <param name="services">The service collection to add services to.</param>
/// <param name="globalSettings">The global settings containing Slack configuration.</param>
/// <returns>The service collection for chaining.</returns>
/// <remarks>
/// If all required Slack settings are configured (ClientId, ClientSecret, Scopes), registers the full SlackService,
/// including an HttpClient for Slack API calls. Otherwise, registers a NoopSlackService that performs no operations.
/// </remarks>
public static IServiceCollection AddSlackService(this IServiceCollection services, GlobalSettings globalSettings)
{
if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes))
{
services.AddHttpClient(SlackService.HttpClientName);
services.TryAddSingleton<ISlackService, SlackService>();
}
else
{
services.TryAddSingleton<ISlackService, NoopSlackService>();
}
return services;
}
/// <summary>
/// Registers Microsoft Teams integration services based on configuration settings.
/// </summary>
/// <param name="services">The service collection to add services to.</param>
/// <param name="globalSettings">The global settings containing Teams configuration.</param>
/// <returns>The service collection for chaining.</returns>
/// <remarks>
/// If all required Teams settings are configured (ClientId, ClientSecret, Scopes), registers:
/// - TeamsService and its interfaces (IBot, ITeamsService)
/// - IBotFrameworkHttpAdapter with Teams credentials
/// - HttpClient for Teams API calls
/// Otherwise, registers a NoopTeamsService that performs no operations.
/// </remarks>
public static IServiceCollection AddTeamsService(this IServiceCollection services, GlobalSettings globalSettings)
{
if (CoreHelpers.SettingHasValue(globalSettings.Teams.ClientId) &&
CoreHelpers.SettingHasValue(globalSettings.Teams.ClientSecret) &&
CoreHelpers.SettingHasValue(globalSettings.Teams.Scopes))
{
services.AddHttpClient(TeamsService.HttpClientName);
services.TryAddSingleton<TeamsService>();
services.TryAddSingleton<IBot>(sp => sp.GetRequiredService<TeamsService>());
services.TryAddSingleton<ITeamsService>(sp => sp.GetRequiredService<TeamsService>());
services.TryAddSingleton<IBotFrameworkHttpAdapter>(_ =>
new BotFrameworkHttpAdapter(
new TeamsBotCredentialProvider(
clientId: globalSettings.Teams.ClientId,
clientSecret: globalSettings.Teams.ClientSecret
)
)
);
}
else
{
services.TryAddSingleton<ITeamsService, NoopTeamsService>();
}
return services;
}
/// <summary>
/// Registers event integration services including handlers, listeners, and supporting infrastructure.
/// </summary>
/// <param name="services">The service collection to add services to.</param>
/// <param name="globalSettings">The global settings containing integration configuration.</param>
/// <returns>The service collection for chaining.</returns>
/// <remarks>
/// <para>
/// This method orchestrates the registration of all event integration components based on the enabled
/// message broker (Azure Service Bus or RabbitMQ). It is an internal method called by the public
/// entry points AddAzureServiceBusListeners and AddRabbitMqListeners.
/// </para>
/// <para>
/// NOTE: If both Azure Service Bus and RabbitMQ are configured, Azure Service Bus takes precedence. This means that
/// Azure Service Bus listeners will be registered (and RabbitMQ listeners will NOT) even if this event is called
/// from AddRabbitMqListeners when Azure Service Bus settings are configured.
/// </para>
/// <para>
/// PREREQUISITE: Callers must ensure AddDistributedCache has been called before invoking this method.
/// This method depends on distributed cache infrastructure being available for the keyed extended
/// cache registration.
/// </para>
/// <para>
/// Registered Services:
/// - Keyed ExtendedCache for event integrations
/// - Integration filter service
/// - Integration handlers for Slack, Webhook, Hec, Datadog, and Teams
/// - Hosted services for event and integration listeners (based on enabled message broker)
/// </para>
/// </remarks>
internal static IServiceCollection AddEventIntegrationServices(this IServiceCollection services,
GlobalSettings globalSettings)
{
// Add common services
// NOTE: AddDistributedCache must be called by the caller before this method
services.AddExtendedCache(EventIntegrationsCacheConstants.CacheName, globalSettings);
services.TryAddSingleton<IIntegrationFilterService, IntegrationFilterService>();
services.TryAddKeyedSingleton<IEventWriteService, RepositoryEventWriteService>("persistent");
// Add services in support of handlers
services.AddSlackService(globalSettings);
services.AddTeamsService(globalSettings);
services.TryAddSingleton(TimeProvider.System);
services.AddHttpClient(WebhookIntegrationHandler.HttpClientName);
services.AddHttpClient(DatadogIntegrationHandler.HttpClientName);
// Add integration handlers
services.TryAddSingleton<IIntegrationHandler<SlackIntegrationConfigurationDetails>, SlackIntegrationHandler>();
services.TryAddSingleton<IIntegrationHandler<WebhookIntegrationConfigurationDetails>, WebhookIntegrationHandler>();
services.TryAddSingleton<IIntegrationHandler<DatadogIntegrationConfigurationDetails>, DatadogIntegrationHandler>();
services.TryAddSingleton<IIntegrationHandler<TeamsIntegrationConfigurationDetails>, TeamsIntegrationHandler>();
var repositoryConfiguration = new RepositoryListenerConfiguration(globalSettings);
var slackConfiguration = new SlackListenerConfiguration(globalSettings);
var webhookConfiguration = new WebhookListenerConfiguration(globalSettings);
var hecConfiguration = new HecListenerConfiguration(globalSettings);
var datadogConfiguration = new DatadogListenerConfiguration(globalSettings);
var teamsConfiguration = new TeamsListenerConfiguration(globalSettings);
if (IsAzureServiceBusEnabled(globalSettings))
{
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService,
AzureServiceBusEventListenerService<RepositoryListenerConfiguration>>(provider =>
new AzureServiceBusEventListenerService<RepositoryListenerConfiguration>(
configuration: repositoryConfiguration,
handler: provider.GetRequiredService<AzureTableStorageEventHandler>(),
serviceBusService: provider.GetRequiredService<IAzureServiceBusService>(),
serviceBusOptions: new ServiceBusProcessorOptions()
{
PrefetchCount = repositoryConfiguration.EventPrefetchCount,
MaxConcurrentCalls = repositoryConfiguration.EventMaxConcurrentCalls
},
loggerFactory: provider.GetRequiredService<ILoggerFactory>()
)
)
);
services.AddAzureServiceBusIntegration<SlackIntegrationConfigurationDetails, SlackListenerConfiguration>(slackConfiguration);
services.AddAzureServiceBusIntegration<WebhookIntegrationConfigurationDetails, WebhookListenerConfiguration>(webhookConfiguration);
services.AddAzureServiceBusIntegration<WebhookIntegrationConfigurationDetails, HecListenerConfiguration>(hecConfiguration);
services.AddAzureServiceBusIntegration<DatadogIntegrationConfigurationDetails, DatadogListenerConfiguration>(datadogConfiguration);
services.AddAzureServiceBusIntegration<TeamsIntegrationConfigurationDetails, TeamsListenerConfiguration>(teamsConfiguration);
return services;
}
if (IsRabbitMqEnabled(globalSettings))
{
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService,
RabbitMqEventListenerService<RepositoryListenerConfiguration>>(provider =>
new RabbitMqEventListenerService<RepositoryListenerConfiguration>(
handler: provider.GetRequiredService<EventRepositoryHandler>(),
configuration: repositoryConfiguration,
rabbitMqService: provider.GetRequiredService<IRabbitMqService>(),
loggerFactory: provider.GetRequiredService<ILoggerFactory>()
)
)
);
services.AddRabbitMqIntegration<SlackIntegrationConfigurationDetails, SlackListenerConfiguration>(slackConfiguration);
services.AddRabbitMqIntegration<WebhookIntegrationConfigurationDetails, WebhookListenerConfiguration>(webhookConfiguration);
services.AddRabbitMqIntegration<WebhookIntegrationConfigurationDetails, HecListenerConfiguration>(hecConfiguration);
services.AddRabbitMqIntegration<DatadogIntegrationConfigurationDetails, DatadogListenerConfiguration>(datadogConfiguration);
services.AddRabbitMqIntegration<TeamsIntegrationConfigurationDetails, TeamsListenerConfiguration>(teamsConfiguration);
}
return services;
}
/// <summary>
/// Registers Azure Service Bus-based event integration listeners for a specific integration type.
/// </summary>
/// <typeparam name="TConfig">The integration configuration details type (e.g., SlackIntegrationConfigurationDetails).</typeparam>
/// <typeparam name="TListenerConfig">The listener configuration type implementing IIntegrationListenerConfiguration.</typeparam>
/// <param name="services">The service collection to add services to.</param>
/// <param name="listenerConfiguration">The listener configuration containing routing keys and message processing settings.</param>
/// <returns>The service collection for chaining.</returns>
/// <remarks>
/// <para>
/// This method registers three key components:
/// 1. EventIntegrationHandler - Keyed singleton for processing integration events
/// 2. AzureServiceBusEventListenerService - Hosted service for listening to event messages from Azure Service Bus
/// for this integration type
/// 3. AzureServiceBusIntegrationListenerService - Hosted service for listening to integration messages from
/// Azure Service Bus for this integration type
/// </para>
/// <para>
/// The handler uses the listener configuration's routing key as its service key, allowing multiple
/// handlers to be registered for different integration types.
/// </para>
/// <para>
/// Service Bus processor options (PrefetchCount and MaxConcurrentCalls) are configured from the listener
/// configuration to optimize message throughput and concurrency.
/// </para>
/// </remarks>
internal static IServiceCollection AddAzureServiceBusIntegration<TConfig, TListenerConfig>(this IServiceCollection services,
TListenerConfig listenerConfiguration)
where TConfig : class
where TListenerConfig : IIntegrationListenerConfiguration
{
services.TryAddKeyedSingleton<IEventMessageHandler>(serviceKey: listenerConfiguration.RoutingKey, implementationFactory: (provider, _) =>
new EventIntegrationHandler<TConfig>(
integrationType: listenerConfiguration.IntegrationType,
eventIntegrationPublisher: provider.GetRequiredService<IEventIntegrationPublisher>(),
integrationFilterService: provider.GetRequiredService<IIntegrationFilterService>(),
cache: provider.GetRequiredKeyedService<IFusionCache>(EventIntegrationsCacheConstants.CacheName),
configurationRepository: provider.GetRequiredService<IOrganizationIntegrationConfigurationRepository>(),
groupRepository: provider.GetRequiredService<IGroupRepository>(),
organizationRepository: provider.GetRequiredService<IOrganizationRepository>(),
organizationUserRepository: provider.GetRequiredService<IOrganizationUserRepository>(), logger: provider.GetRequiredService<ILogger<EventIntegrationHandler<TConfig>>>())
);
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService,
AzureServiceBusEventListenerService<TListenerConfig>>(provider =>
new AzureServiceBusEventListenerService<TListenerConfig>(
configuration: listenerConfiguration,
handler: provider.GetRequiredKeyedService<IEventMessageHandler>(serviceKey: listenerConfiguration.RoutingKey),
serviceBusService: provider.GetRequiredService<IAzureServiceBusService>(),
serviceBusOptions: new ServiceBusProcessorOptions()
{
PrefetchCount = listenerConfiguration.EventPrefetchCount,
MaxConcurrentCalls = listenerConfiguration.EventMaxConcurrentCalls
},
loggerFactory: provider.GetRequiredService<ILoggerFactory>()
)
)
);
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService,
AzureServiceBusIntegrationListenerService<TListenerConfig>>(provider =>
new AzureServiceBusIntegrationListenerService<TListenerConfig>(
configuration: listenerConfiguration,
handler: provider.GetRequiredService<IIntegrationHandler<TConfig>>(),
serviceBusService: provider.GetRequiredService<IAzureServiceBusService>(),
serviceBusOptions: new ServiceBusProcessorOptions()
{
PrefetchCount = listenerConfiguration.IntegrationPrefetchCount,
MaxConcurrentCalls = listenerConfiguration.IntegrationMaxConcurrentCalls
},
loggerFactory: provider.GetRequiredService<ILoggerFactory>()
)
)
);
return services;
}
/// <summary>
/// Registers RabbitMQ-based event integration listeners for a specific integration type.
/// </summary>
/// <typeparam name="TConfig">The integration configuration details type (e.g., SlackIntegrationConfigurationDetails).</typeparam>
/// <typeparam name="TListenerConfig">The listener configuration type implementing IIntegrationListenerConfiguration.</typeparam>
/// <param name="services">The service collection to add services to.</param>
/// <param name="listenerConfiguration">The listener configuration containing routing keys and message processing settings.</param>
/// <returns>The service collection for chaining.</returns>
/// <remarks>
/// <para>
/// This method registers three key components:
/// 1. EventIntegrationHandler - Keyed singleton for processing integration events
/// 2. RabbitMqEventListenerService - Hosted service for listening to event messages from RabbitMQ for
/// this integration type
/// 3. RabbitMqIntegrationListenerService - Hosted service for listening to integration messages from RabbitMQ for
/// this integration type
/// </para>
///
/// <para>
/// The handler uses the listener configuration's routing key as its service key, allowing multiple
/// handlers to be registered for different integration types.
/// </para>
/// </remarks>
internal static IServiceCollection AddRabbitMqIntegration<TConfig, TListenerConfig>(this IServiceCollection services,
TListenerConfig listenerConfiguration)
where TConfig : class
where TListenerConfig : IIntegrationListenerConfiguration
{
services.TryAddKeyedSingleton<IEventMessageHandler>(serviceKey: listenerConfiguration.RoutingKey, implementationFactory: (provider, _) =>
new EventIntegrationHandler<TConfig>(
integrationType: listenerConfiguration.IntegrationType,
eventIntegrationPublisher: provider.GetRequiredService<IEventIntegrationPublisher>(),
integrationFilterService: provider.GetRequiredService<IIntegrationFilterService>(),
cache: provider.GetRequiredKeyedService<IFusionCache>(EventIntegrationsCacheConstants.CacheName),
configurationRepository: provider.GetRequiredService<IOrganizationIntegrationConfigurationRepository>(),
groupRepository: provider.GetRequiredService<IGroupRepository>(),
organizationRepository: provider.GetRequiredService<IOrganizationRepository>(),
organizationUserRepository: provider.GetRequiredService<IOrganizationUserRepository>(), logger: provider.GetRequiredService<ILogger<EventIntegrationHandler<TConfig>>>())
);
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService,
RabbitMqEventListenerService<TListenerConfig>>(provider =>
new RabbitMqEventListenerService<TListenerConfig>(
handler: provider.GetRequiredKeyedService<IEventMessageHandler>(serviceKey: listenerConfiguration.RoutingKey),
configuration: listenerConfiguration,
rabbitMqService: provider.GetRequiredService<IRabbitMqService>(),
loggerFactory: provider.GetRequiredService<ILoggerFactory>()
)
)
);
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService,
RabbitMqIntegrationListenerService<TListenerConfig>>(provider =>
new RabbitMqIntegrationListenerService<TListenerConfig>(
handler: provider.GetRequiredService<IIntegrationHandler<TConfig>>(),
configuration: listenerConfiguration,
rabbitMqService: provider.GetRequiredService<IRabbitMqService>(),
loggerFactory: provider.GetRequiredService<ILoggerFactory>(),
timeProvider: provider.GetRequiredService<TimeProvider>()
)
)
);
return services;
}
@@ -35,4 +510,58 @@ public static class EventIntegrationsServiceCollectionExtensions
return services;
}
internal static IServiceCollection AddOrganizationIntegrationConfigurationCommandsQueries(this IServiceCollection services)
{
services.TryAddScoped<ICreateOrganizationIntegrationConfigurationCommand, CreateOrganizationIntegrationConfigurationCommand>();
services.TryAddScoped<IUpdateOrganizationIntegrationConfigurationCommand, UpdateOrganizationIntegrationConfigurationCommand>();
services.TryAddScoped<IDeleteOrganizationIntegrationConfigurationCommand, DeleteOrganizationIntegrationConfigurationCommand>();
services.TryAddScoped<IGetOrganizationIntegrationConfigurationsQuery, GetOrganizationIntegrationConfigurationsQuery>();
return services;
}
/// <summary>
/// Determines if RabbitMQ is enabled for event integrations based on configuration settings.
/// </summary>
/// <param name="settings">The global settings containing RabbitMQ configuration.</param>
/// <returns>True if all required RabbitMQ settings are present; otherwise, false.</returns>
/// <remarks>
/// Requires all the following settings to be configured:
/// <list type="bullet">
/// <item><description>EventLogging.RabbitMq.HostName</description></item>
/// <item><description>EventLogging.RabbitMq.Username</description></item>
/// <item><description>EventLogging.RabbitMq.Password</description></item>
/// <item><description>EventLogging.RabbitMq.EventExchangeName</description></item>
/// <item><description>EventLogging.RabbitMq.IntegrationExchangeName</description></item>
/// </list>
/// </remarks>
internal static bool IsRabbitMqEnabled(GlobalSettings settings)
{
return CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.HostName) &&
CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.Username) &&
CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.Password) &&
CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.EventExchangeName) &&
CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.IntegrationExchangeName);
}
/// <summary>
/// Determines if Azure Service Bus is enabled for event integrations based on configuration settings.
/// </summary>
/// <param name="settings">The global settings containing Azure Service Bus configuration.</param>
/// <returns>True if all required Azure Service Bus settings are present; otherwise, false.</returns>
/// <remarks>
/// Requires all of the following settings to be configured:
/// <list type="bullet">
/// <item><description>EventLogging.AzureServiceBus.ConnectionString</description></item>
/// <item><description>EventLogging.AzureServiceBus.EventTopicName</description></item>
/// <item><description>EventLogging.AzureServiceBus.IntegrationTopicName</description></item>
/// </list>
/// </remarks>
internal static bool IsAzureServiceBusEnabled(GlobalSettings settings)
{
return CoreHelpers.SettingHasValue(settings.EventLogging.AzureServiceBus.ConnectionString) &&
CoreHelpers.SettingHasValue(settings.EventLogging.AzureServiceBus.EventTopicName) &&
CoreHelpers.SettingHasValue(settings.EventLogging.AzureServiceBus.IntegrationTopicName);
}
}

View File

@@ -0,0 +1,64 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.Extensions.DependencyInjection;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations;
/// <summary>
/// Command implementation for creating organization integration configurations with validation and cache invalidation support.
/// </summary>
public class CreateOrganizationIntegrationConfigurationCommand(
IOrganizationIntegrationRepository integrationRepository,
IOrganizationIntegrationConfigurationRepository configurationRepository,
[FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache,
IOrganizationIntegrationConfigurationValidator validator)
: ICreateOrganizationIntegrationConfigurationCommand
{
public async Task<OrganizationIntegrationConfiguration> CreateAsync(
Guid organizationId,
Guid integrationId,
OrganizationIntegrationConfiguration configuration)
{
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
if (!validator.ValidateConfiguration(integration.Type, configuration))
{
throw new BadRequestException(
$"Invalid Configuration and/or Filters for integration type {integration.Type}");
}
var created = await configurationRepository.CreateAsync(configuration);
// Invalidate the cached configuration details
// Even though this is a new record, the cache could hold a stale empty list for this
if (created.EventType == null)
{
// Wildcard configuration - invalidate all cached results for this org/integration
await cache.RemoveByTagAsync(
EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
organizationId: organizationId,
integrationType: integration.Type
));
}
else
{
// Specific event type - only invalidate that specific cache entry
await cache.RemoveAsync(
EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
organizationId: organizationId,
integrationType: integration.Type,
eventType: created.EventType.Value
));
}
return created;
}
}

View File

@@ -0,0 +1,54 @@
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.Extensions.DependencyInjection;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations;
/// <summary>
/// Command implementation for deleting organization integration configurations with cache invalidation support.
/// </summary>
public class DeleteOrganizationIntegrationConfigurationCommand(
IOrganizationIntegrationRepository integrationRepository,
IOrganizationIntegrationConfigurationRepository configurationRepository,
[FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache)
: IDeleteOrganizationIntegrationConfigurationCommand
{
public async Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId)
{
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
var configuration = await configurationRepository.GetByIdAsync(configurationId);
if (configuration is null || configuration.OrganizationIntegrationId != integrationId)
{
throw new NotFoundException();
}
await configurationRepository.DeleteAsync(configuration);
if (configuration.EventType == null)
{
// Wildcard configuration - invalidate all cached results for this org/integration
await cache.RemoveByTagAsync(
EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
organizationId: organizationId,
integrationType: integration.Type
));
}
else
{
// Specific event type - only invalidate that specific cache entry
await cache.RemoveAsync(
EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
organizationId: organizationId,
integrationType: integration.Type,
eventType: configuration.EventType.Value
));
}
}
}

View File

@@ -0,0 +1,29 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations;
/// <summary>
/// Query implementation for retrieving organization integration configurations.
/// </summary>
public class GetOrganizationIntegrationConfigurationsQuery(
IOrganizationIntegrationRepository integrationRepository,
IOrganizationIntegrationConfigurationRepository configurationRepository)
: IGetOrganizationIntegrationConfigurationsQuery
{
public async Task<List<OrganizationIntegrationConfiguration>> GetManyByIntegrationAsync(
Guid organizationId,
Guid integrationId)
{
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
var configurations = await configurationRepository.GetManyByIntegrationAsync(integrationId);
return configurations.ToList();
}
}

View File

@@ -0,0 +1,22 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
/// <summary>
/// Command interface for creating organization integration configurations.
/// </summary>
public interface ICreateOrganizationIntegrationConfigurationCommand
{
/// <summary>
/// Creates a new configuration for an organization integration.
/// </summary>
/// <param name="organizationId">The unique identifier of the organization.</param>
/// <param name="integrationId">The unique identifier of the integration.</param>
/// <param name="configuration">The configuration to create.</param>
/// <returns>The created configuration.</returns>
/// <exception cref="Exceptions.NotFoundException">Thrown when the integration does not exist
/// or does not belong to the specified organization.</exception>
/// <exception cref="Exceptions.BadRequestException">Thrown when the configuration or filters
/// are invalid for the integration type.</exception>
Task<OrganizationIntegrationConfiguration> CreateAsync(Guid organizationId, Guid integrationId, OrganizationIntegrationConfiguration configuration);
}

View File

@@ -0,0 +1,19 @@
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
/// <summary>
/// Command interface for deleting organization integration configurations.
/// </summary>
public interface IDeleteOrganizationIntegrationConfigurationCommand
{
/// <summary>
/// Deletes a configuration from an organization integration.
/// </summary>
/// <param name="organizationId">The unique identifier of the organization.</param>
/// <param name="integrationId">The unique identifier of the integration.</param>
/// <param name="configurationId">The unique identifier of the configuration to delete.</param>
/// <exception cref="Exceptions.NotFoundException">
/// Thrown when the integration or configuration does not exist,
/// or the integration does not belong to the specified organization,
/// or the configuration does not belong to the specified integration.</exception>
Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId);
}

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