1
0
mirror of https://github.com/bitwarden/server synced 2025-12-27 21:53:24 +00:00

Merge branch 'main' of github.com:bitwarden/server into arch/seeder-api

# Conflicts:
#	util/Seeder/Recipes/OrganizationWithUsersRecipe.cs
This commit is contained in:
Hinton
2025-12-16 16:48:29 +01:00
484 changed files with 43837 additions and 8360 deletions

View File

@@ -1,4 +1,4 @@
name: Bitwarden Lite Deployment Bug Report
name: Bitwarden lite Deployment Bug Report
description: File a bug report
labels: [bug, bw-lite-deploy]
body:
@@ -70,15 +70,6 @@ body:
mariadb:10
# Postgres Example
postgres:14
- type: textarea
id: epic-label
attributes:
label: Issue-Link
description: Link to our pinned issue, tracking all Bitwarden Lite
value: |
https://github.com/bitwarden/server/issues/2480
validations:
required: true
- type: checkboxes
id: issue-tracking-info
attributes:

View File

@@ -44,6 +44,7 @@
{
matchPackageNames: ["https://github.com/bitwarden/sdk-internal.git"],
groupName: "sdk-internal",
dependencyDashboardApproval: true
},
{
matchManagers: ["dockerfile", "docker-compose"],
@@ -63,7 +64,6 @@
},
{
matchPackageNames: [
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
"DuoUniversal",
"Fido2.AspNet",
"Duende.IdentityServer",
@@ -137,6 +137,7 @@
"AspNetCoreRateLimit",
"AspNetCoreRateLimit.Redis",
"Azure.Data.Tables",
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
"Azure.Messaging.EventGrid",
"Azure.Messaging.ServiceBus",
"Azure.Storage.Blobs",

View File

@@ -185,13 +185,6 @@ jobs:
- name: Log in to ACR - production subscription
run: az acr login -n bitwardenprod
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
########## Generate image tag and build Docker image ##########
- name: Generate Docker image tag
id: tag
@@ -250,8 +243,6 @@ jobs:
linux/arm64
push: true
tags: ${{ steps.image-tags.outputs.tags }}
secrets: |
"GH_PAT=${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}"
- name: Install Cosign
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
@@ -479,20 +470,29 @@ jobs:
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
keyvault: gh-org-bitwarden
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Trigger Bitwarden Lite build
- name: Generate GH App token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
owner: ${{ github.repository_owner }}
repositories: self-host
- name: Trigger Bitwarden lite build
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
github-token: ${{ steps.app-token.outputs.token }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'bitwarden',
@@ -520,20 +520,29 @@ jobs:
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
keyvault: gh-org-bitwarden
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
owner: ${{ github.repository_owner }}
repositories: devops
- name: Trigger k8s deploy
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
github-token: ${{ steps.app-token.outputs.token }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'bitwarden',

View File

@@ -22,9 +22,7 @@ on:
required: false
type: string
permissions:
pull-requests: write
contents: write
permissions: {}
jobs:
setup:
@@ -32,6 +30,7 @@ jobs:
runs-on: ubuntu-24.04
outputs:
branch: ${{ steps.set-branch.outputs.branch }}
permissions: {}
steps:
- name: Set branch
id: set-branch
@@ -89,6 +88,7 @@ jobs:
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
permission-contents: write
- name: Check out branch
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -212,6 +212,7 @@ jobs:
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
permission-contents: write
- name: Check out target ref
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -240,10 +241,5 @@ jobs:
move_edd_db_scripts:
name: Move EDD database scripts
needs: cut_branch
permissions:
actions: read
contents: write
id-token: write
pull-requests: write
permissions: {}
uses: ./.github/workflows/_move_edd_db_scripts.yml
secrets: inherit

View File

@@ -62,7 +62,7 @@ jobs:
docker compose --profile mssql --profile postgres --profile mysql up -d
shell: pwsh
- name: Add MariaDB for Bitwarden Lite
- name: Add MariaDB for Bitwarden lite
# Use a different port than MySQL
run: |
docker run --detach --name mariadb --env MARIADB_ROOT_PASSWORD=mariadb-password -p 4306:3306 mariadb:10
@@ -133,7 +133,7 @@ jobs:
# Default Sqlite
BW_TEST_DATABASES__3__TYPE: "Sqlite"
BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db"
# Bitwarden Lite MariaDB
# Bitwarden lite MariaDB
BW_TEST_DATABASES__4__TYPE: "MySql"
BW_TEST_DATABASES__4__CONNECTIONSTRING: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true"
run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
@@ -262,3 +262,26 @@ jobs:
working-directory: "dev"
run: docker compose down
shell: pwsh
validate-migration-naming:
name: Validate new migration naming and order
runs-on: ubuntu-22.04
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
persist-credentials: false
- name: Validate new migrations for pull request
if: github.event_name == 'pull_request'
run: |
git fetch origin main:main
pwsh dev/verify_migrations.ps1 -BaseRef main
shell: pwsh
- name: Validate new migrations for push
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
run: pwsh dev/verify_migrations.ps1 -BaseRef HEAD~1
shell: pwsh

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.11.1</Version>
<Version>2025.12.2</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
@@ -16,7 +16,7 @@
<PropertyGroup>
<MicrosoftNetTestSdkVersion>17.8.0</MicrosoftNetTestSdkVersion>
<MicrosoftNetTestSdkVersion>18.0.1</MicrosoftNetTestSdkVersion>
<XUnitVersion>2.6.6</XUnitVersion>

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

@@ -0,0 +1,94 @@
using AutoMapper;
using Bit.Core.SecretsManager.Repositories;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Infrastructure.EntityFramework.SecretsManager.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Commercial.Infrastructure.EntityFramework.SecretsManager.Repositories;
public class SecretVersionRepository : Repository<Core.SecretsManager.Entities.SecretVersion, SecretVersion, Guid>, ISecretVersionRepository
{
public SecretVersionRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
: base(serviceScopeFactory, mapper, db => db.SecretVersion)
{ }
public override async Task<Core.SecretsManager.Entities.SecretVersion?> GetByIdAsync(Guid id)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var secretVersion = await dbContext.SecretVersion
.Where(sv => sv.Id == id)
.FirstOrDefaultAsync();
return Mapper.Map<Core.SecretsManager.Entities.SecretVersion>(secretVersion);
}
public async Task<IEnumerable<Core.SecretsManager.Entities.SecretVersion>> GetManyBySecretIdAsync(Guid secretId)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var secretVersions = await dbContext.SecretVersion
.Where(sv => sv.SecretId == secretId)
.OrderByDescending(sv => sv.VersionDate)
.ToListAsync();
return Mapper.Map<List<Core.SecretsManager.Entities.SecretVersion>>(secretVersions);
}
public async Task<IEnumerable<Core.SecretsManager.Entities.SecretVersion>> GetManyByIdsAsync(IEnumerable<Guid> ids)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var versionIds = ids.ToList();
var secretVersions = await dbContext.SecretVersion
.Where(sv => versionIds.Contains(sv.Id))
.OrderByDescending(sv => sv.VersionDate)
.ToListAsync();
return Mapper.Map<List<Core.SecretsManager.Entities.SecretVersion>>(secretVersions);
}
public override async Task<Core.SecretsManager.Entities.SecretVersion> CreateAsync(Core.SecretsManager.Entities.SecretVersion secretVersion)
{
const int maxVersionsToKeep = 10;
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
await using var transaction = await dbContext.Database.BeginTransactionAsync();
// Get the IDs of the most recent (maxVersionsToKeep - 1) versions to keep
var versionsToKeepIds = await dbContext.SecretVersion
.Where(sv => sv.SecretId == secretVersion.SecretId)
.OrderByDescending(sv => sv.VersionDate)
.Take(maxVersionsToKeep - 1)
.Select(sv => sv.Id)
.ToListAsync();
// Delete all versions for this secret that are not in the "keep" list
if (versionsToKeepIds.Any())
{
await dbContext.SecretVersion
.Where(sv => sv.SecretId == secretVersion.SecretId && !versionsToKeepIds.Contains(sv.Id))
.ExecuteDeleteAsync();
}
secretVersion.SetNewId();
var entity = Mapper.Map<SecretVersion>(secretVersion);
await dbContext.AddAsync(entity);
await dbContext.SaveChangesAsync();
await transaction.CommitAsync();
return secretVersion;
}
public async Task DeleteManyByIdAsync(IEnumerable<Guid> ids)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
var secretVersionIds = ids.ToList();
await dbContext.SecretVersion
.Where(sv => secretVersionIds.Contains(sv.Id))
.ExecuteDeleteAsync();
}
}

View File

@@ -10,6 +10,7 @@ public static class SecretsManagerEfServiceCollectionExtensions
{
services.AddSingleton<IAccessPolicyRepository, AccessPolicyRepository>();
services.AddSingleton<ISecretRepository, SecretRepository>();
services.AddSingleton<ISecretVersionRepository, SecretVersionRepository>();
services.AddSingleton<IProjectRepository, ProjectRepository>();
services.AddSingleton<IServiceAccountRepository, ServiceAccountRepository>();
}

View File

@@ -61,17 +61,15 @@ public class GroupsController : Controller
[HttpGet("")]
public async Task<IActionResult> Get(
Guid organizationId,
[FromQuery] string filter,
[FromQuery] int? count,
[FromQuery] int? startIndex)
[FromQuery] GetGroupsQueryParamModel model)
{
var groupsListQueryResult = await _getGroupsListQuery.GetGroupsListAsync(organizationId, filter, count, startIndex);
var groupsListQueryResult = await _getGroupsListQuery.GetGroupsListAsync(organizationId, model);
var scimListResponseModel = new ScimListResponseModel<ScimGroupResponseModel>
{
Resources = groupsListQueryResult.groupList.Select(g => new ScimGroupResponseModel(g)).ToList(),
ItemsPerPage = count.GetValueOrDefault(groupsListQueryResult.groupList.Count()),
ItemsPerPage = model.Count,
TotalResults = groupsListQueryResult.totalResults,
StartIndex = startIndex.GetValueOrDefault(1),
StartIndex = model.StartIndex,
};
return Ok(scimListResponseModel);
}

View File

@@ -3,6 +3,7 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;

View File

@@ -4,6 +4,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models;
namespace Bit.Scim.Groups;
@@ -16,10 +17,16 @@ public class GetGroupsListQuery : IGetGroupsListQuery
_groupRepository = groupRepository;
}
public async Task<(IEnumerable<Group> groupList, int totalResults)> GetGroupsListAsync(Guid organizationId, string filter, int? count, int? startIndex)
public async Task<(IEnumerable<Group> groupList, int totalResults)> GetGroupsListAsync(
Guid organizationId, GetGroupsQueryParamModel groupQueryParams)
{
string nameFilter = null;
string externalIdFilter = null;
int count = groupQueryParams.Count;
int startIndex = groupQueryParams.StartIndex;
string filter = groupQueryParams.Filter;
if (!string.IsNullOrWhiteSpace(filter))
{
if (filter.StartsWith("displayName eq "))
@@ -53,11 +60,11 @@ public class GetGroupsListQuery : IGetGroupsListQuery
}
totalResults = groupList.Count;
}
else if (string.IsNullOrWhiteSpace(filter) && startIndex.HasValue && count.HasValue)
else if (string.IsNullOrWhiteSpace(filter))
{
groupList = groups.OrderBy(g => g.Name)
.Skip(startIndex.Value - 1)
.Take(count.Value)
.Skip(startIndex - 1)
.Take(count)
.ToList();
totalResults = groups.Count;
}

View File

@@ -1,8 +1,9 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Scim.Models;
namespace Bit.Scim.Groups.Interfaces;
public interface IGetGroupsListQuery
{
Task<(IEnumerable<Group> groupList, int totalResults)> GetGroupsListAsync(Guid organizationId, string filter, int? count, int? startIndex);
Task<(IEnumerable<Group> groupList, int totalResults)> GetGroupsListAsync(Guid organizationId, GetGroupsQueryParamModel model);
}

View File

@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Scim.Models;
public class GetGroupsQueryParamModel
{
public string Filter { get; init; } = string.Empty;
[Range(1, int.MaxValue)]
public int Count { get; init; } = 50;
[Range(1, int.MaxValue)]
public int StartIndex { get; init; } = 1;
}

View File

@@ -1,5 +1,7 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Scim.Models;
public class GetUsersQueryParamModel
{
public string Filter { get; init; } = string.Empty;

View File

@@ -3,6 +3,7 @@
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Scim.Models;
using Bit.Scim.Users.Interfaces;
namespace Bit.Scim.Users;

View File

@@ -1,4 +1,5 @@
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Scim.Models;
namespace Bit.Scim.Users.Interfaces;

View File

@@ -1,5 +1,5 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;

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

@@ -25,6 +25,12 @@
"connectionString": "UseDevelopmentStorage=true"
},
"developmentDirectory": "../../../dev",
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
"pricingUri": "https://billingpricing.qa.bitwarden.pw",
"mail": {
"smtp": {
"host": "localhost",
"port": 10250
}
}
}
}

View File

@@ -13,7 +13,11 @@
"mail": {
"sendGridApiKey": "SECRET",
"amazonConfigSetName": "Email",
"replyToEmail": "no-reply@bitwarden.com"
"replyToEmail": "no-reply@bitwarden.com",
"smtp": {
"host": "localhost",
"port": 10250
}
},
"identityServer": {
"certificateThumbprint": "SECRET"

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

@@ -0,0 +1,130 @@
using Bit.Core.SecretsManager.Entities;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Commercial.Core.Test.SecretsManager.Repositories;
public class SecretVersionRepositoryTests
{
[Theory]
[BitAutoData]
public void SecretVersion_EntityCreation_Success(SecretVersion secretVersion)
{
// Arrange & Act
secretVersion.SetNewId();
// Assert
Assert.NotEqual(Guid.Empty, secretVersion.Id);
Assert.NotEqual(Guid.Empty, secretVersion.SecretId);
Assert.NotNull(secretVersion.Value);
Assert.NotEqual(default, secretVersion.VersionDate);
}
[Theory]
[BitAutoData]
public void SecretVersion_WithServiceAccountEditor_Success(SecretVersion secretVersion, Guid serviceAccountId)
{
// Arrange & Act
secretVersion.EditorServiceAccountId = serviceAccountId;
secretVersion.EditorOrganizationUserId = null;
// Assert
Assert.Equal(serviceAccountId, secretVersion.EditorServiceAccountId);
Assert.Null(secretVersion.EditorOrganizationUserId);
}
[Theory]
[BitAutoData]
public void SecretVersion_WithOrganizationUserEditor_Success(SecretVersion secretVersion, Guid organizationUserId)
{
// Arrange & Act
secretVersion.EditorOrganizationUserId = organizationUserId;
secretVersion.EditorServiceAccountId = null;
// Assert
Assert.Equal(organizationUserId, secretVersion.EditorOrganizationUserId);
Assert.Null(secretVersion.EditorServiceAccountId);
}
[Theory]
[BitAutoData]
public void SecretVersion_NullableEditors_Success(SecretVersion secretVersion)
{
// Arrange & Act
secretVersion.EditorServiceAccountId = null;
secretVersion.EditorOrganizationUserId = null;
// Assert
Assert.Null(secretVersion.EditorServiceAccountId);
Assert.Null(secretVersion.EditorOrganizationUserId);
}
[Theory]
[BitAutoData]
public void SecretVersion_VersionDateSet_Success(SecretVersion secretVersion)
{
// Arrange
var versionDate = DateTime.UtcNow;
// Act
secretVersion.VersionDate = versionDate;
// Assert
Assert.Equal(versionDate, secretVersion.VersionDate);
}
[Theory]
[BitAutoData]
public void SecretVersion_ValueEncrypted_Success(SecretVersion secretVersion, string encryptedValue)
{
// Arrange & Act
secretVersion.Value = encryptedValue;
// Assert
Assert.Equal(encryptedValue, secretVersion.Value);
Assert.NotEmpty(secretVersion.Value);
}
[Theory]
[BitAutoData]
public void SecretVersion_MultipleVersions_DifferentIds(List<SecretVersion> secretVersions, Guid secretId)
{
// Arrange & Act
foreach (var version in secretVersions)
{
version.SecretId = secretId;
version.SetNewId();
}
// Assert
var distinctIds = secretVersions.Select(v => v.Id).Distinct();
Assert.Equal(secretVersions.Count, distinctIds.Count());
Assert.All(secretVersions, v => Assert.Equal(secretId, v.SecretId));
}
[Theory]
[BitAutoData]
public void SecretVersion_VersionDateOrdering_Success(SecretVersion version1, SecretVersion version2, SecretVersion version3, Guid secretId)
{
// Arrange
var now = DateTime.UtcNow;
version1.SecretId = secretId;
version1.VersionDate = now.AddDays(-2);
version2.SecretId = secretId;
version2.VersionDate = now.AddDays(-1);
version3.SecretId = secretId;
version3.VersionDate = now;
var versions = new List<SecretVersion> { version2, version3, version1 };
// Act
var orderedVersions = versions.OrderByDescending(v => v.VersionDate).ToList();
// Assert
Assert.Equal(version3.Id, orderedVersions[0].Id); // Most recent
Assert.Equal(version2.Id, orderedVersions[1].Id);
Assert.Equal(version1.Id, orderedVersions[2].Id); // Oldest
}
}

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

@@ -200,6 +200,38 @@ public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsy
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
}
[Fact]
public async Task GetList_SearchDisplayNameWithoutOptionalParameters_Success()
{
string filter = "displayName eq Test Group 2";
int? itemsPerPage = null;
int? startIndex = null;
var expectedResponse = new ScimListResponseModel<ScimGroupResponseModel>
{
ItemsPerPage = 50, //default value
TotalResults = 1,
StartIndex = 1, //default value
Resources = new List<ScimGroupResponseModel>
{
new ScimGroupResponseModel
{
Id = ScimApplicationFactory.TestGroupId2,
DisplayName = "Test Group 2",
ExternalId = "B",
Schemas = new List<string> { ScimConstants.Scim2SchemaGroup }
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaListResponse }
};
var context = await _factory.GroupsGetListAsync(ScimApplicationFactory.TestOrganizationId1, filter, itemsPerPage, startIndex);
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
var responseModel = JsonSerializer.Deserialize<ScimListResponseModel<ScimGroupResponseModel>>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
}
[Fact]
public async Task Post_Success()
{

View File

@@ -1,6 +1,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Scim.Groups;
using Bit.Scim.Models;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
@@ -24,7 +25,7 @@ public class GetGroupsListCommandTests
.GetManyByOrganizationIdAsync(organizationId)
.Returns(groups);
var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, null, count, startIndex);
var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Count = count, StartIndex = startIndex });
AssertHelper.AssertPropertyEqual(groups.Skip(startIndex - 1).Take(count).ToList(), result.groupList);
AssertHelper.AssertPropertyEqual(groups.Count, result.totalResults);
@@ -47,7 +48,7 @@ public class GetGroupsListCommandTests
.GetManyByOrganizationIdAsync(organizationId)
.Returns(groups);
var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null);
var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Filter = filter });
AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList);
AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults);
@@ -67,7 +68,7 @@ public class GetGroupsListCommandTests
.GetManyByOrganizationIdAsync(organizationId)
.Returns(groups);
var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null);
var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Filter = filter });
AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList);
AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults);
@@ -90,7 +91,7 @@ public class GetGroupsListCommandTests
.GetManyByOrganizationIdAsync(organizationId)
.Returns(groups);
var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null);
var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Filter = filter });
AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList);
AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults);
@@ -112,7 +113,7 @@ public class GetGroupsListCommandTests
.GetManyByOrganizationIdAsync(organizationId)
.Returns(groups);
var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null);
var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Filter = filter });
AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList);
AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults);

View File

@@ -1,5 +1,6 @@
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Scim.Models;
using Bit.Scim.Users;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;

View File

@@ -1,6 +1,6 @@
using System.Text.Json;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;

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

@@ -18,11 +18,11 @@ if ($LASTEXITCODE -ne 0) {
# Api internal & public
Set-Location "../../src/Api"
dotnet build
dotnet swagger tofile --output "../../api.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "internal"
dotnet swagger tofile --output "../../api.json" "./bin/Debug/net8.0/Api.dll" "internal"
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
dotnet swagger tofile --output "../../api.public.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "public"
dotnet swagger tofile --output "../../api.public.json" "./bin/Debug/net8.0/Api.dll" "public"
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}

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

132
dev/verify_migrations.ps1 Normal file
View File

@@ -0,0 +1,132 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Validates that new database migration files follow naming conventions and chronological order.
.DESCRIPTION
This script validates migration files in util/Migrator/DbScripts/ to ensure:
1. New migrations follow the naming format: YYYY-MM-DD_NN_Description.sql
2. New migrations are chronologically ordered (filename sorts after existing migrations)
3. Dates use leading zeros (e.g., 2025-01-05, not 2025-1-5)
4. A 2-digit sequence number is included (e.g., _00, _01)
.PARAMETER BaseRef
The base git reference to compare against (e.g., 'main', 'HEAD~1')
.PARAMETER CurrentRef
The current git reference (defaults to 'HEAD')
.EXAMPLE
# For pull requests - compare against main branch
.\verify_migrations.ps1 -BaseRef main
.EXAMPLE
# For pushes - compare against previous commit
.\verify_migrations.ps1 -BaseRef HEAD~1
#>
param(
[Parameter(Mandatory = $true)]
[string]$BaseRef,
[Parameter(Mandatory = $false)]
[string]$CurrentRef = "HEAD"
)
# Use invariant culture for consistent string comparison
[System.Threading.Thread]::CurrentThread.CurrentCulture = [System.Globalization.CultureInfo]::InvariantCulture
$migrationPath = "util/Migrator/DbScripts"
# Get list of migrations from base reference
try {
$baseMigrations = git ls-tree -r --name-only $BaseRef -- "$migrationPath/*.sql" 2>$null | Sort-Object
if ($LASTEXITCODE -ne 0) {
Write-Host "Warning: Could not retrieve migrations from base reference '$BaseRef'"
$baseMigrations = @()
}
}
catch {
Write-Host "Warning: Could not retrieve migrations from base reference '$BaseRef'"
$baseMigrations = @()
}
# Get list of migrations from current reference
$currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$migrationPath/*.sql" | Sort-Object
# Find added migrations
$addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations }
if ($addedMigrations.Count -eq 0) {
Write-Host "No new migration files added."
exit 0
}
Write-Host "New migration files detected:"
$addedMigrations | ForEach-Object { Write-Host " $_" }
Write-Host ""
# Get the last migration from base reference
if ($baseMigrations.Count -eq 0) {
Write-Host "No previous migrations found (initial commit?). Skipping validation."
exit 0
}
$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1)
Write-Host "Last migration in base reference: $lastBaseMigration"
Write-Host ""
# Required format regex: YYYY-MM-DD_NN_Description.sql
$formatRegex = '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}_.+\.sql$'
$validationFailed = $false
foreach ($migration in $addedMigrations) {
$migrationName = Split-Path -Leaf $migration
# Validate NEW migration filename format
if ($migrationName -notmatch $formatRegex) {
Write-Host "ERROR: Migration '$migrationName' does not match required format"
Write-Host "Required format: YYYY-MM-DD_NN_Description.sql"
Write-Host " - YYYY: 4-digit year"
Write-Host " - MM: 2-digit month with leading zero (01-12)"
Write-Host " - DD: 2-digit day with leading zero (01-31)"
Write-Host " - NN: 2-digit sequence number (00, 01, 02, etc.)"
Write-Host "Example: 2025-01-15_00_MyMigration.sql"
$validationFailed = $true
continue
}
# Compare migration name with last base migration (using ordinal string comparison)
if ([string]::CompareOrdinal($migrationName, $lastBaseMigration) -lt 0) {
Write-Host "ERROR: New migration '$migrationName' is not chronologically after '$lastBaseMigration'"
$validationFailed = $true
}
else {
Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'"
}
}
Write-Host ""
if ($validationFailed) {
Write-Host "FAILED: One or more migrations are incorrectly named or not in chronological order"
Write-Host ""
Write-Host "All new migration files must:"
Write-Host " 1. Follow the naming format: YYYY-MM-DD_NN_Description.sql"
Write-Host " 2. Use leading zeros in dates (e.g., 2025-01-05, not 2025-1-5)"
Write-Host " 3. Include a 2-digit sequence number (e.g., _00, _01)"
Write-Host " 4. Have a filename that sorts after the last migration in base"
Write-Host ""
Write-Host "To fix this issue:"
Write-Host " 1. Locate your migration file(s) in util/Migrator/DbScripts/"
Write-Host " 2. Rename to follow format: YYYY-MM-DD_NN_Description.sql"
Write-Host " 3. Ensure the date is after $lastBaseMigration"
Write-Host ""
Write-Host "Example: 2025-01-15_00_AddNewFeature.sql"
exit 1
}
Write-Host "SUCCESS: All new migrations are correctly named and in chronological order"
exit 0

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 });
}
@@ -473,6 +496,7 @@ public class OrganizationsController : Controller
organization.UseOrganizationDomains = model.UseOrganizationDomains;
organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies;
organization.UseAutomaticUserConfirmation = model.UseAutomaticUserConfirmation;
organization.UsePhishingBlocker = model.UsePhishingBlocker;
//secrets
organization.SmSeats = model.SmSeats;

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

@@ -107,6 +107,7 @@ public class OrganizationEditModel : OrganizationViewModel
MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts;
UseOrganizationDomains = org.UseOrganizationDomains;
UseAutomaticUserConfirmation = org.UseAutomaticUserConfirmation;
UsePhishingBlocker = org.UsePhishingBlocker;
_plans = plans;
}
@@ -160,6 +161,8 @@ public class OrganizationEditModel : OrganizationViewModel
public new bool UseSecretsManager { get; set; }
[Display(Name = "Risk Insights")]
public new bool UseRiskInsights { get; set; }
[Display(Name = "Phishing Blocker")]
public new bool UsePhishingBlocker { get; set; }
[Display(Name = "Admin Sponsored Families")]
public bool UseAdminSponsoredFamilies { get; set; }
[Display(Name = "Self Host")]
@@ -327,6 +330,7 @@ public class OrganizationEditModel : OrganizationViewModel
existingOrganization.SmServiceAccounts = SmServiceAccounts;
existingOrganization.MaxAutoscaleSmServiceAccounts = MaxAutoscaleSmServiceAccounts;
existingOrganization.UseOrganizationDomains = UseOrganizationDomains;
existingOrganization.UsePhishingBlocker = UsePhishingBlocker;
return existingOrganization;
}
}

View File

@@ -75,6 +75,7 @@ public class OrganizationViewModel
public int OccupiedSmSeatsCount { get; set; }
public bool UseSecretsManager => Organization.UseSecretsManager;
public bool UseRiskInsights => Organization.UseRiskInsights;
public bool UsePhishingBlocker => Organization.UsePhishingBlocker;
public IEnumerable<OrganizationUserUserDetails> OwnersDetails { get; set; }
public IEnumerable<OrganizationUserUserDetails> AdminsDetails { get; set; }
}

View File

@@ -156,6 +156,10 @@
<input type="checkbox" class="form-check-input" asp-for="UseAdminSponsoredFamilies" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UsePhishingBlocker" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UsePhishingBlocker"></label>
</div>
@if(FeatureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
{
<div class="form-check">

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

@@ -19,7 +19,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
public virtual async Task StartAsync(CancellationToken cancellationToken)
{
// Wait 20 seconds to allow database to come online
await Task.Delay(20000);
await Task.Delay(20000, cancellationToken);
var maxMigrationAttempts = 10;
for (var i = 1; i <= maxMigrationAttempts; i++)
@@ -41,7 +41,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
{
_logger.LogError(e,
"Database unavailable for migration. Trying again (attempt #{0})...", i + 1);
await Task.Delay(20000);
await Task.Delay(20000, cancellationToken);
}
}
}

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

@@ -1,8 +1,8 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -12,7 +12,10 @@ namespace Bit.Api.AdminConsole.Controllers;
[Authorize("Application")]
public class OrganizationIntegrationController(
ICurrentContext currentContext,
IOrganizationIntegrationRepository integrationRepository) : Controller
ICreateOrganizationIntegrationCommand createCommand,
IUpdateOrganizationIntegrationCommand updateCommand,
IDeleteOrganizationIntegrationCommand deleteCommand,
IGetOrganizationIntegrationsQuery getQuery) : Controller
{
[HttpGet("")]
public async Task<List<OrganizationIntegrationResponseModel>> GetAsync(Guid organizationId)
@@ -22,7 +25,7 @@ public class OrganizationIntegrationController(
throw new NotFoundException();
}
var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId);
var integrations = await getQuery.GetManyByOrganizationAsync(organizationId);
return integrations
.Select(integration => new OrganizationIntegrationResponseModel(integration))
.ToList();
@@ -36,8 +39,10 @@ public class OrganizationIntegrationController(
throw new NotFoundException();
}
var integration = await integrationRepository.CreateAsync(model.ToOrganizationIntegration(organizationId));
return new OrganizationIntegrationResponseModel(integration);
var integration = model.ToOrganizationIntegration(organizationId);
var created = await createCommand.CreateAsync(integration);
return new OrganizationIntegrationResponseModel(created);
}
[HttpPut("{integrationId:guid}")]
@@ -48,14 +53,10 @@ public class OrganizationIntegrationController(
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration is null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
var integration = model.ToOrganizationIntegration(organizationId);
var updated = await updateCommand.UpdateAsync(organizationId, integrationId, integration);
await integrationRepository.ReplaceAsync(model.ToOrganizationIntegration(integration));
return new OrganizationIntegrationResponseModel(integration);
return new OrganizationIntegrationResponseModel(updated);
}
[HttpDelete("{integrationId:guid}")]
@@ -66,13 +67,7 @@ public class OrganizationIntegrationController(
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration is null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
await integrationRepository.DeleteAsync(integration);
await deleteCommand.DeleteAsync(organizationId, integrationId);
}
[HttpPost("{integrationId:guid}/delete")]

View File

@@ -41,6 +41,8 @@ using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using V1_RevokeOrganizationUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1.IRevokeOrganizationUserCommand;
using V2_RevokeOrganizationUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
namespace Bit.Api.AdminConsole.Controllers;
@@ -71,11 +73,13 @@ public class OrganizationUsersController : BaseAdminConsoleController
private readonly IFeatureService _featureService;
private readonly IPricingClient _pricingClient;
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
private readonly IBulkResendOrganizationInvitesCommand _bulkResendOrganizationInvitesCommand;
private readonly IAutomaticallyConfirmOrganizationUserCommand _automaticallyConfirmOrganizationUserCommand;
private readonly V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand _revokeOrganizationUserCommandVNext;
private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand;
private readonly V1_RevokeOrganizationUserCommand _revokeOrganizationUserCommand;
private readonly IAdminRecoverAccountCommand _adminRecoverAccountCommand;
public OrganizationUsersController(IOrganizationRepository organizationRepository,
@@ -103,10 +107,12 @@ public class OrganizationUsersController : BaseAdminConsoleController
IConfirmOrganizationUserCommand confirmOrganizationUserCommand,
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
IInitPendingOrganizationCommand initPendingOrganizationCommand,
IRevokeOrganizationUserCommand revokeOrganizationUserCommand,
V1_RevokeOrganizationUserCommand revokeOrganizationUserCommand,
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
IBulkResendOrganizationInvitesCommand bulkResendOrganizationInvitesCommand,
IAdminRecoverAccountCommand adminRecoverAccountCommand,
IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand)
IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand,
V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand revokeOrganizationUserCommandVNext)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@@ -131,7 +137,9 @@ public class OrganizationUsersController : BaseAdminConsoleController
_featureService = featureService;
_pricingClient = pricingClient;
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
_bulkResendOrganizationInvitesCommand = bulkResendOrganizationInvitesCommand;
_automaticallyConfirmOrganizationUserCommand = automaticallyConfirmOrganizationUserCommand;
_revokeOrganizationUserCommandVNext = revokeOrganizationUserCommandVNext;
_confirmOrganizationUserCommand = confirmOrganizationUserCommand;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
_initPendingOrganizationCommand = initPendingOrganizationCommand;
@@ -273,7 +281,17 @@ public class OrganizationUsersController : BaseAdminConsoleController
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkReinvite(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{
var userId = _userService.GetProperUserId(User);
var result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids);
IEnumerable<Tuple<Core.Entities.OrganizationUser, string>> result;
if (_featureService.IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud))
{
result = await _bulkResendOrganizationInvitesCommand.BulkResendInvitesAsync(orgId, userId.Value, model.Ids);
}
else
{
result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids);
}
return new ListResponseModel<OrganizationUserBulkResponseModel>(
result.Select(t => new OrganizationUserBulkResponseModel(t.Item1.Id, t.Item2)));
}
@@ -483,43 +501,10 @@ public class OrganizationUsersController : BaseAdminConsoleController
}
}
#nullable enable
[HttpPut("{id}/reset-password")]
[Authorize<ManageAccountRecoveryRequirement>]
public async Task<IResult> PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
{
if (_featureService.IsEnabled(FeatureFlagKeys.AccountRecoveryCommand))
{
// TODO: remove legacy implementation after feature flag is enabled.
return await PutResetPasswordNew(orgId, id, model);
}
// Get the users role, since provider users aren't a member of the organization we use the owner check
var orgUserType = await _currentContext.OrganizationOwner(orgId)
? OrganizationUserType.Owner
: _currentContext.Organizations?.FirstOrDefault(o => o.Id == orgId)?.Type;
if (orgUserType == null)
{
return TypedResults.NotFound();
}
var result = await _userService.AdminResetPasswordAsync(orgUserType.Value, orgId, id, model.NewMasterPasswordHash, model.Key);
if (result.Succeeded)
{
return TypedResults.Ok();
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
await Task.Delay(2000);
return TypedResults.BadRequest(ModelState);
}
#nullable enable
// TODO: make sure the route and authorize attributes are maintained when the legacy implementation is removed.
private async Task<IResult> PutResetPasswordNew(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
{
var targetOrganizationUser = await _organizationUserRepository.GetByIdAsync(id);
if (targetOrganizationUser == null || targetOrganizationUser.OrganizationId != orgId)
@@ -662,7 +647,29 @@ public class OrganizationUsersController : BaseAdminConsoleController
[Authorize<ManageUsersRequirement>]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{
return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync);
if (!_featureService.IsEnabled(FeatureFlagKeys.BulkRevokeUsersV2))
{
return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync);
}
var currentUserId = _userService.GetProperUserId(User);
if (currentUserId == null)
{
throw new UnauthorizedAccessException();
}
var results = await _revokeOrganizationUserCommandVNext.RevokeUsersAsync(
new V2_RevokeOrganizationUserCommand.RevokeOrganizationUsersRequest(
orgId,
model.Ids.ToArray(),
new StandardUser(currentUserId.Value, await _currentContext.OrganizationOwner(orgId))));
return new ListResponseModel<OrganizationUserBulkResponseModel>(results
.Select(result => new OrganizationUserBulkResponseModel(result.Id,
result.Result.Match(
error => error.Message,
_ => string.Empty
))));
}
[HttpPatch("revoke")]

View File

@@ -42,7 +42,6 @@ public class PoliciesController : Controller
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
private readonly IPolicyRepository _policyRepository;
private readonly IUserService _userService;
private readonly IFeatureService _featureService;
private readonly ISavePolicyCommand _savePolicyCommand;
private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand;
@@ -55,7 +54,6 @@ public class PoliciesController : Controller
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery,
IOrganizationRepository organizationRepository,
IFeatureService featureService,
ISavePolicyCommand savePolicyCommand,
IVNextSavePolicyCommand vNextSavePolicyCommand)
{
@@ -69,7 +67,6 @@ public class PoliciesController : Controller
_organizationRepository = organizationRepository;
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
_featureService = featureService;
_savePolicyCommand = savePolicyCommand;
_vNextSavePolicyCommand = vNextSavePolicyCommand;
}
@@ -221,9 +218,7 @@ public class PoliciesController : Controller
{
var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, type, _currentContext);
var policy = _featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) ?
await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest) :
await _savePolicyCommand.VNextSaveAsync(savePolicyRequest);
var policy = await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest);
return new PolicyResponseModel(policy);
}

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

@@ -119,7 +119,7 @@ public class OrganizationUserResetPasswordEnrollmentRequestModel
public class OrganizationUserBulkRequestModel
{
[Required]
[Required, MinLength(1)]
public IEnumerable<Guid> Ids { get; set; }
}

View File

@@ -47,6 +47,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel
UseAdminSponsoredFamilies = organizationDetails.UseAdminSponsoredFamilies;
UseAutomaticUserConfirmation = organizationDetails.UseAutomaticUserConfirmation;
UseSecretsManager = organizationDetails.UseSecretsManager;
UsePhishingBlocker = organizationDetails.UsePhishingBlocker;
UsePasswordManager = organizationDetails.UsePasswordManager;
SelfHost = organizationDetails.SelfHost;
Seats = organizationDetails.Seats;
@@ -99,6 +100,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
public bool UsePhishingBlocker { get; set; }
public bool SelfHost { get; set; }
public int? Seats { get; set; }
public short? MaxCollections { get; set; }

View File

@@ -1,10 +1,13 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Security.Claims;
using System.Text.Json.Serialization;
using Bit.Api.Models.Response;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Licenses;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Models.Api;
using Bit.Core.Models.Business;
@@ -71,6 +74,7 @@ public class OrganizationResponseModel : ResponseModel
UseOrganizationDomains = organization.UseOrganizationDomains;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
UsePhishingBlocker = organization.UsePhishingBlocker;
}
public Guid Id { get; set; }
@@ -120,6 +124,7 @@ public class OrganizationResponseModel : ResponseModel
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
public bool UsePhishingBlocker { get; set; }
}
public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
@@ -175,6 +180,30 @@ public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
}
}
public OrganizationSubscriptionResponseModel(Organization organization, OrganizationLicense license, ClaimsPrincipal claimsPrincipal) :
this(organization, (Plan)null)
{
if (license != null)
{
// CRITICAL: When a license has a Token (JWT), ALWAYS use the expiration from the token claim
// The token's expiration is cryptographically secured and cannot be tampered with
// The file's Expires property can be manually edited and should NOT be trusted for display
if (claimsPrincipal != null)
{
Expiration = claimsPrincipal.GetValue<DateTime>(OrganizationLicenseConstants.Expires);
ExpirationWithoutGracePeriod = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.ExpirationWithoutGracePeriod);
}
else
{
// No token - use the license file expiration (for older licenses without tokens)
Expiration = license.Expires;
ExpirationWithoutGracePeriod = license.ExpirationWithoutGracePeriod ?? (license.Trial
? license.Expires
: license.Expires?.AddDays(-Constants.OrganizationSelfHostSubscriptionGracePeriodDays));
}
}
}
public string StorageName { get; set; }
public double? StorageGb { get; set; }
public BillingCustomerDiscount CustomerDiscount { get; set; }

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

@@ -5,15 +5,10 @@ using System.Net;
using Bit.Api.AdminConsole.Public.Models.Request;
using Bit.Api.AdminConsole.Public.Models.Response;
using Bit.Api.Models.Public.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Context;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -24,25 +19,16 @@ namespace Bit.Api.AdminConsole.Public.Controllers;
public class PoliciesController : Controller
{
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyService _policyService;
private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService;
private readonly ISavePolicyCommand _savePolicyCommand;
private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand;
public PoliciesController(
IPolicyRepository policyRepository,
IPolicyService policyService,
ICurrentContext currentContext,
IFeatureService featureService,
ISavePolicyCommand savePolicyCommand,
IVNextSavePolicyCommand vNextSavePolicyCommand)
{
_policyRepository = policyRepository;
_policyService = policyService;
_currentContext = currentContext;
_featureService = featureService;
_savePolicyCommand = savePolicyCommand;
_vNextSavePolicyCommand = vNextSavePolicyCommand;
}
@@ -97,17 +83,8 @@ public class PoliciesController : Controller
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> Put(PolicyType type, [FromBody] PolicyUpdateRequestModel model)
{
Policy policy;
if (_featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor))
{
var savePolicyModel = model.ToSavePolicyModel(_currentContext.OrganizationId!.Value, type);
policy = await _vNextSavePolicyCommand.SaveAsync(savePolicyModel);
}
else
{
var policyUpdate = model.ToPolicyUpdate(_currentContext.OrganizationId!.Value, type);
policy = await _savePolicyCommand.SaveAsync(policyUpdate);
}
var savePolicyModel = model.ToSavePolicyModel(_currentContext.OrganizationId!.Value, type);
var policy = await _vNextSavePolicyCommand.SaveAsync(savePolicyModel);
var response = new PolicyResponseModel(policy);
return new JsonResult(response);

View File

@@ -33,7 +33,7 @@
<ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" />
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" />
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.25.0" />
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.31.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
</ItemGroup>

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

@@ -9,7 +9,6 @@ using Bit.Api.Models.Response;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Services;
using Bit.Core.Context;
@@ -35,7 +34,7 @@ public class TwoFactorController : Controller
private readonly IOrganizationService _organizationService;
private readonly UserManager<User> _userManager;
private readonly ICurrentContext _currentContext;
private readonly IVerifyAuthRequestCommand _verifyAuthRequestCommand;
private readonly IAuthRequestRepository _authRequestRepository;
private readonly IDuoUniversalTokenService _duoUniversalTokenService;
private readonly IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> _twoFactorAuthenticatorDataProtector;
private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _ssoEmailTwoFactorSessionDataProtector;
@@ -47,7 +46,7 @@ public class TwoFactorController : Controller
IOrganizationService organizationService,
UserManager<User> userManager,
ICurrentContext currentContext,
IVerifyAuthRequestCommand verifyAuthRequestCommand,
IAuthRequestRepository authRequestRepository,
IDuoUniversalTokenService duoUniversalConfigService,
IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> twoFactorAuthenticatorDataProtector,
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> ssoEmailTwoFactorSessionDataProtector,
@@ -58,7 +57,7 @@ public class TwoFactorController : Controller
_organizationService = organizationService;
_userManager = userManager;
_currentContext = currentContext;
_verifyAuthRequestCommand = verifyAuthRequestCommand;
_authRequestRepository = authRequestRepository;
_duoUniversalTokenService = duoUniversalConfigService;
_twoFactorAuthenticatorDataProtector = twoFactorAuthenticatorDataProtector;
_ssoEmailTwoFactorSessionDataProtector = ssoEmailTwoFactorSessionDataProtector;
@@ -350,14 +349,15 @@ public class TwoFactorController : Controller
if (user != null)
{
// Check if 2FA email is from Passwordless.
// Check if 2FA email is from a device approval ("Log in with device") scenario.
if (!string.IsNullOrEmpty(requestModel.AuthRequestAccessCode))
{
if (await _verifyAuthRequestCommand
.VerifyAuthRequestAsync(new Guid(requestModel.AuthRequestId),
requestModel.AuthRequestAccessCode))
var authRequest = await _authRequestRepository.GetByIdAsync(new Guid(requestModel.AuthRequestId));
if (authRequest != null &&
authRequest.IsValidForAuthentication(user.Id, requestModel.AuthRequestAccessCode))
{
await _twoFactorEmailService.SendTwoFactorEmailAsync(user);
return;
}
}
else if (!string.IsNullOrEmpty(requestModel.SsoEmail2FaSessionToken))

View File

@@ -1,7 +1,5 @@
#nullable enable
using Bit.Api.Billing.Models.Responses;
using Bit.Api.Billing.Models.Responses;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Requests;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
@@ -12,10 +10,11 @@ namespace Bit.Api.Billing.Controllers;
[Route("accounts/billing")]
[Authorize("Application")]
public class AccountsBillingController(
IPaymentService paymentService,
IStripePaymentService paymentService,
IUserService userService,
IPaymentHistoryService paymentHistoryService) : Controller
{
// TODO: Migrate to Query / AccountBillingVNextController
[HttpGet("history")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<BillingHistoryResponseModel> GetBillingHistoryAsync()
@@ -30,20 +29,7 @@ public class AccountsBillingController(
return new BillingHistoryResponseModel(billingInfo);
}
[HttpGet("payment-method")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<BillingPaymentResponseModel> GetPaymentMethodAsync()
{
var user = await userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var billingInfo = await paymentService.GetBillingAsync(user);
return new BillingPaymentResponseModel(billingInfo);
}
// TODO: Migrate to Query / AccountBillingVNextController
[HttpGet("invoices")]
public async Task<IResult> GetInvoicesAsync([FromQuery] string? status = null, [FromQuery] string? startAfter = null)
{
@@ -62,6 +48,7 @@ public class AccountsBillingController(
return TypedResults.Ok(invoices);
}
// TODO: Migrate to Query / AccountBillingVNextController
[HttpGet("transactions")]
public async Task<IResult> GetTransactionsAsync([FromQuery] DateTime? startAfter = null)
{
@@ -78,18 +65,4 @@ public class AccountsBillingController(
return TypedResults.Ok(transactions);
}
[HttpPost("preview-invoice")]
public async Task<IResult> PreviewInvoiceAsync([FromBody] PreviewIndividualInvoiceRequestBody model)
{
var user = await userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var invoice = await paymentService.PreviewInvoiceAsync(model, user.GatewayCustomerId, user.GatewaySubscriptionId);
return TypedResults.Ok(invoice);
}
}

View File

@@ -1,6 +1,4 @@
#nullable enable
using Bit.Api.Models.Request;
using Bit.Api.Models.Request;
using Bit.Api.Models.Request.Accounts;
using Bit.Api.Models.Response;
using Bit.Api.Utilities;
@@ -26,8 +24,10 @@ public class AccountsController(
IUserService userService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IUserAccountKeysQuery userAccountKeysQuery,
IFeatureService featureService) : Controller
IFeatureService featureService,
ILicensingService licensingService) : Controller
{
// TODO: Remove when pm-24996-implement-upgrade-from-free-dialog is removed
[HttpPost("premium")]
public async Task<PaymentResponseModel> PostPremiumAsync(
PremiumRequestModel model,
@@ -75,10 +75,11 @@ public class AccountsController(
};
}
// TODO: Migrate to Query / AccountBillingVNextController as part of Premium -> Organization upgrade work.
[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)
@@ -97,12 +98,14 @@ public class AccountsController(
var includeMilestone2Discount = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
var subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
var license = await userService.GenerateLicenseAsync(user, subscriptionInfo);
return new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount);
var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license);
return new SubscriptionResponseModel(user, subscriptionInfo, license, claimsPrincipal, includeMilestone2Discount);
}
else
{
var license = await userService.GenerateLicenseAsync(user);
return new SubscriptionResponseModel(user, license);
var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license);
return new SubscriptionResponseModel(user, null, license, claimsPrincipal);
}
}
else
@@ -111,29 +114,7 @@ public class AccountsController(
}
}
[HttpPost("payment")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostPaymentAsync([FromBody] PaymentRequestModel model)
{
var user = await userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
await userService.ReplacePaymentMethodAsync(user, model.PaymentToken, model.PaymentMethodType!.Value,
new TaxInfo
{
BillingAddressLine1 = model.Line1,
BillingAddressLine2 = model.Line2,
BillingAddressCity = model.City,
BillingAddressState = model.State,
BillingAddressCountry = model.Country,
BillingAddressPostalCode = model.PostalCode,
TaxIdNumber = model.TaxId
});
}
// TODO: Migrate to Command / AccountBillingVNextController as PUT /account/billing/vnext/subscription
[HttpPost("storage")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<PaymentResponseModel> PostStorageAsync([FromBody] StorageRequestModel model)
@@ -148,8 +129,11 @@ public class AccountsController(
return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result };
}
/*
* TODO: A new version of this exists in the AccountBillingVNextController.
* The individual-self-hosting-license-uploader.component needs to be updated to use it.
* Then, this can be removed.
*/
[HttpPost("license")]
[SelfHosted(SelfHostedOnly = true)]
public async Task PostLicenseAsync(LicenseRequestModel model)
@@ -169,6 +153,7 @@ public class AccountsController(
await userService.UpdateLicenseAsync(user, license);
}
// TODO: Migrate to Command / AccountBillingVNextController as DELETE /account/billing/vnext/subscription
[HttpPost("cancel")]
public async Task PostCancelAsync(
[FromBody] SubscriptionCancellationRequestModel request,
@@ -186,6 +171,7 @@ public class AccountsController(
user.IsExpired());
}
// TODO: Migrate to Command / AccountBillingVNextController as POST /account/billing/vnext/subscription/reinstate
[HttpPost("reinstate-premium")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostReinstateAsync()
@@ -199,41 +185,6 @@ public class AccountsController(
await userService.ReinstatePremiumAsync(user);
}
[HttpGet("tax")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<TaxInfoResponseModel> GetTaxInfoAsync(
[FromServices] IPaymentService paymentService)
{
var user = await userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var taxInfo = await paymentService.GetTaxInfoAsync(user);
return new TaxInfoResponseModel(taxInfo);
}
[HttpPut("tax")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PutTaxInfoAsync(
[FromBody] TaxInfoUpdateRequestModel model,
[FromServices] IPaymentService paymentService)
{
var user = await userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var taxInfo = new TaxInfo
{
BillingAddressPostalCode = model.PostalCode,
BillingAddressCountry = model.Country,
};
await paymentService.SaveTaxInfoAsync(user, taxInfo);
}
private async Task<IEnumerable<Guid>> GetOrganizationIdsClaimingUserAsync(Guid userId)
{
var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId);

View File

@@ -1,45 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Tax.Requests;
using Bit.Core.Context;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Billing.Controllers;
[Route("invoices")]
[Authorize("Application")]
public class InvoicesController : BaseBillingController
{
[HttpPost("preview-organization")]
public async Task<IResult> PreviewInvoiceAsync(
[FromBody] PreviewOrganizationInvoiceRequestBody model,
[FromServices] ICurrentContext currentContext,
[FromServices] IOrganizationRepository organizationRepository,
[FromServices] IPaymentService paymentService)
{
Organization organization = null;
if (model.OrganizationId != default)
{
if (!await currentContext.EditPaymentMethods(model.OrganizationId))
{
return Error.Unauthorized();
}
organization = await organizationRepository.GetByIdAsync(model.OrganizationId);
if (organization == null)
{
return Error.NotFound();
}
}
var invoice = await paymentService.PreviewInvoiceAsync(model, organization?.GatewayCustomerId,
organization?.GatewaySubscriptionId);
return TypedResults.Ok(invoice);
}
}

View File

@@ -1,91 +0,0 @@
// 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,10 +18,10 @@ public class OrganizationBillingController(
ICurrentContext currentContext,
IOrganizationBillingService organizationBillingService,
IOrganizationRepository organizationRepository,
IPaymentService paymentService,
ISubscriberService subscriberService,
IStripePaymentService paymentService,
IPaymentHistoryService paymentHistoryService) : BaseBillingController
{
// TODO: Remove when pm-25379-use-new-organization-metadata-structure is removed.
[HttpGet("metadata")]
public async Task<IResult> GetMetadataAsync([FromRoute] Guid organizationId)
{
@@ -41,6 +40,7 @@ public class OrganizationBillingController(
return TypedResults.Ok(metadata);
}
// TODO: Migrate to Query / OrganizationBillingVNextController
[HttpGet("history")]
public async Task<IResult> GetHistoryAsync([FromRoute] Guid organizationId)
{
@@ -61,6 +61,7 @@ public class OrganizationBillingController(
return TypedResults.Ok(billingInfo);
}
// TODO: Migrate to Query / OrganizationBillingVNextController
[HttpGet("invoices")]
public async Task<IResult> GetInvoicesAsync([FromRoute] Guid organizationId, [FromQuery] string? status = null, [FromQuery] string? startAfter = null)
{
@@ -85,6 +86,7 @@ public class OrganizationBillingController(
return TypedResults.Ok(invoices);
}
// TODO: Migrate to Query / OrganizationBillingVNextController
[HttpGet("transactions")]
public async Task<IResult> GetTransactionsAsync([FromRoute] Guid organizationId, [FromQuery] DateTime? startAfter = null)
{
@@ -108,6 +110,7 @@ public class OrganizationBillingController(
return TypedResults.Ok(transactions);
}
// TODO: Can be removed once we do away with the organization-plans.component.
[HttpGet]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IResult> GetBillingAsync(Guid organizationId)
@@ -131,127 +134,7 @@ public class OrganizationBillingController(
return TypedResults.Ok(response);
}
[HttpGet("payment-method")]
public async Task<IResult> GetPaymentMethodAsync([FromRoute] Guid organizationId)
{
if (!await currentContext.EditPaymentMethods(organizationId))
{
return Error.Unauthorized();
}
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
return Error.NotFound();
}
var paymentMethod = await subscriberService.GetPaymentMethod(organization);
var response = PaymentMethodResponse.From(paymentMethod);
return TypedResults.Ok(response);
}
[HttpPut("payment-method")]
public async Task<IResult> UpdatePaymentMethodAsync(
[FromRoute] Guid organizationId,
[FromBody] UpdatePaymentMethodRequestBody requestBody)
{
if (!await currentContext.EditPaymentMethods(organizationId))
{
return Error.Unauthorized();
}
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
return Error.NotFound();
}
var tokenizedPaymentSource = requestBody.PaymentSource.ToDomain();
var taxInformation = requestBody.TaxInformation.ToDomain();
await organizationBillingService.UpdatePaymentMethod(organization, tokenizedPaymentSource, taxInformation);
return TypedResults.Ok();
}
[HttpPost("payment-method/verify-bank-account")]
public async Task<IResult> VerifyBankAccountAsync(
[FromRoute] Guid organizationId,
[FromBody] VerifyBankAccountRequestBody requestBody)
{
if (!await currentContext.EditPaymentMethods(organizationId))
{
return Error.Unauthorized();
}
if (requestBody.DescriptorCode.Length != 6 || !requestBody.DescriptorCode.StartsWith("SM"))
{
return Error.BadRequest("Statement descriptor should be a 6-character value that starts with 'SM'");
}
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
return Error.NotFound();
}
await subscriberService.VerifyBankAccount(organization, requestBody.DescriptorCode);
return TypedResults.Ok();
}
[HttpGet("tax-information")]
public async Task<IResult> GetTaxInformationAsync([FromRoute] Guid organizationId)
{
if (!await currentContext.EditPaymentMethods(organizationId))
{
return Error.Unauthorized();
}
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
return Error.NotFound();
}
var taxInformation = await subscriberService.GetTaxInformation(organization);
var response = TaxInformationResponse.From(taxInformation);
return TypedResults.Ok(response);
}
[HttpPut("tax-information")]
public async Task<IResult> UpdateTaxInformationAsync(
[FromRoute] Guid organizationId,
[FromBody] TaxInformationRequestBody requestBody)
{
if (!await currentContext.EditPaymentMethods(organizationId))
{
return Error.Unauthorized();
}
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
return Error.NotFound();
}
var taxInformation = requestBody.ToDomain();
await subscriberService.UpdateTaxInformation(organization, taxInformation);
return TypedResults.Ok();
}
// TODO: Migrate to Command / OrganizationBillingVNextController
[HttpPost("setup-business-unit")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IResult> SetupBusinessUnitAsync(
@@ -280,6 +163,7 @@ public class OrganizationBillingController(
return TypedResults.Ok(providerId);
}
// TODO: Migrate to Command / OrganizationBillingVNextController
[HttpPost("change-frequency")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IResult> ChangePlanSubscriptionFrequencyAsync(

View File

@@ -19,7 +19,6 @@ using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -37,7 +36,7 @@ public class OrganizationsController(
IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService,
IUserService userService,
IPaymentService paymentService,
IStripePaymentService paymentService,
ICurrentContext currentContext,
IGetCloudOrganizationLicenseQuery getCloudOrganizationLicenseQuery,
GlobalSettings globalSettings,
@@ -67,7 +66,8 @@ public class OrganizationsController(
if (globalSettings.SelfHosted)
{
var orgLicense = await licensingService.ReadOrganizationLicenseAsync(organization);
return new OrganizationSubscriptionResponseModel(organization, orgLicense);
var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(orgLicense);
return new OrganizationSubscriptionResponseModel(organization, orgLicense, claimsPrincipal);
}
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
@@ -248,53 +248,6 @@ public class OrganizationsController(
await organizationService.ReinstateSubscriptionAsync(id);
}
[HttpGet("{id:guid}/tax")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<TaxInfoResponseModel> GetTaxInfo(Guid id)
{
if (!await currentContext.OrganizationOwner(id))
{
throw new NotFoundException();
}
var organization = await organizationRepository.GetByIdAsync(id);
if (organization == null)
{
throw new NotFoundException();
}
var taxInfo = await paymentService.GetTaxInfoAsync(organization);
return new TaxInfoResponseModel(taxInfo);
}
[HttpPut("{id:guid}/tax")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PutTaxInfo(Guid id, [FromBody] ExpandedTaxInfoUpdateRequestModel model)
{
if (!await currentContext.OrganizationOwner(id))
{
throw new NotFoundException();
}
var organization = await organizationRepository.GetByIdAsync(id);
if (organization == null)
{
throw new NotFoundException();
}
var taxInfo = new TaxInfo
{
TaxIdNumber = model.TaxId,
BillingAddressLine1 = model.Line1,
BillingAddressLine2 = model.Line2,
BillingAddressCity = model.City,
BillingAddressState = model.State,
BillingAddressPostalCode = model.PostalCode,
BillingAddressCountry = model.Country,
};
await paymentService.SaveTaxInfoAsync(organization, taxInfo);
}
/// <summary>
/// Tries to grant owner access to the Secrets Manager for the organization
/// </summary>

View File

@@ -1,7 +1,6 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Api.Billing.Models.Requests;
using Bit.Api.Billing.Models.Responses;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Pricing;
@@ -9,7 +8,6 @@ using Bit.Core.Billing.Providers.Models;
using Bit.Core.Billing.Providers.Repositories;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Context;
using Bit.Core.Models.BitStripe;
using Bit.Core.Services;
@@ -34,6 +32,7 @@ public class ProviderBillingController(
IStripeAdapter stripeAdapter,
IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService)
{
// TODO: Migrate to Query / ProviderBillingVNextController
[HttpGet("invoices")]
public async Task<IResult> GetInvoicesAsync([FromRoute] Guid providerId)
{
@@ -44,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
});
@@ -54,6 +53,7 @@ public class ProviderBillingController(
return TypedResults.Ok(response);
}
// TODO: Migrate to Query / ProviderBillingVNextController
[HttpGet("invoices/{invoiceId}")]
public async Task<IResult> GenerateClientInvoiceReportAsync([FromRoute] Guid providerId, string invoiceId)
{
@@ -76,51 +76,7 @@ public class ProviderBillingController(
"text/csv");
}
[HttpPut("payment-method")]
public async Task<IResult> UpdatePaymentMethodAsync(
[FromRoute] Guid providerId,
[FromBody] UpdatePaymentMethodRequestBody requestBody)
{
var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId);
if (provider == null)
{
return result;
}
var tokenizedPaymentSource = requestBody.PaymentSource.ToDomain();
var taxInformation = requestBody.TaxInformation.ToDomain();
await providerBillingService.UpdatePaymentMethod(
provider,
tokenizedPaymentSource,
taxInformation);
return TypedResults.Ok();
}
[HttpPost("payment-method/verify-bank-account")]
public async Task<IResult> VerifyBankAccountAsync(
[FromRoute] Guid providerId,
[FromBody] VerifyBankAccountRequestBody requestBody)
{
var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId);
if (provider == null)
{
return result;
}
if (requestBody.DescriptorCode.Length != 6 || !requestBody.DescriptorCode.StartsWith("SM"))
{
return Error.BadRequest("Statement descriptor should be a 6-character value that starts with 'SM'");
}
await subscriberService.VerifyBankAccount(provider, requestBody.DescriptorCode);
return TypedResults.Ok();
}
// TODO: Migrate to Query / ProviderBillingVNextController
[HttpGet("subscription")]
public async Task<IResult> GetSubscriptionAsync([FromRoute] Guid providerId)
{
@@ -131,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);
@@ -140,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
@@ -172,53 +128,4 @@ public class ProviderBillingController(
return TypedResults.Ok(response);
}
[HttpGet("tax-information")]
public async Task<IResult> GetTaxInformationAsync([FromRoute] Guid providerId)
{
var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId);
if (provider == null)
{
return result;
}
var taxInformation = await subscriberService.GetTaxInformation(provider);
var response = TaxInformationResponse.From(taxInformation);
return TypedResults.Ok(response);
}
[HttpPut("tax-information")]
public async Task<IResult> UpdateTaxInformationAsync(
[FromRoute] Guid providerId,
[FromBody] TaxInformationRequestBody requestBody)
{
var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId);
if (provider == null)
{
return result;
}
if (requestBody is not { Country: not null, PostalCode: not null })
{
return Error.BadRequest("Country and postal code are required to update your tax information.");
}
var taxInformation = new TaxInformation(
requestBody.Country,
requestBody.PostalCode,
requestBody.TaxId,
requestBody.TaxIdType,
requestBody.Line1,
requestBody.Line2,
requestBody.City,
requestBody.State);
await subscriberService.UpdateTaxInformation(provider, taxInformation);
return TypedResults.Ok();
}
}

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,5 +1,4 @@
#nullable enable
using Bit.Api.Billing.Attributes;
using Bit.Api.Billing.Attributes;
using Bit.Api.Billing.Models.Requests.Premium;
using Bit.Api.Utilities;
using Bit.Core;
@@ -17,7 +16,7 @@ namespace Bit.Api.Billing.Controllers.VNext;
[Authorize("Application")]
[Route("account/billing/vnext/self-host")]
[SelfHosted(SelfHostedOnly = true)]
public class SelfHostedAccountBillingController(
public class SelfHostedAccountBillingVNextController(
ICreatePremiumSelfHostedSubscriptionCommand createPremiumSelfHostedSubscriptionCommand) : BaseBillingController
{
[HttpPost("license")]

View File

@@ -14,7 +14,7 @@ namespace Bit.Api.Billing.Controllers.VNext;
[Authorize("Application")]
[Route("organizations/{organizationId:guid}/billing/vnext/self-host")]
[SelfHosted(SelfHostedOnly = true)]
public class SelfHostedBillingController(
public class SelfHostedOrganizationBillingVNextController(
IGetOrganizationMetadataQuery getOrganizationMetadataQuery) : BaseBillingController
{
[Authorize<MemberOrProviderRequirement>]

View File

@@ -1,31 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using Bit.Core.Billing.Tax.Models;
namespace Bit.Api.Billing.Models.Requests;
public class TaxInformationRequestBody
{
[Required]
public string Country { get; set; }
[Required]
public string PostalCode { get; set; }
public string TaxId { get; set; }
public string TaxIdType { get; set; }
public string Line1 { get; set; }
public string Line2 { get; set; }
public string City { get; set; }
public string State { get; set; }
public TaxInformation ToDomain() => new(
Country,
PostalCode,
TaxId,
TaxIdType,
Line1,
Line2,
City,
State);
}

View File

@@ -1,25 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using Bit.Api.Utilities;
using Bit.Core.Billing.Models;
using Bit.Core.Enums;
namespace Bit.Api.Billing.Models.Requests;
public class TokenizedPaymentSourceRequestBody
{
[Required]
[EnumMatches<PaymentMethodType>(
PaymentMethodType.BankAccount,
PaymentMethodType.Card,
PaymentMethodType.PayPal,
ErrorMessage = "'type' must be BankAccount, Card or PayPal")]
public PaymentMethodType Type { get; set; }
[Required]
public string Token { get; set; }
public TokenizedPaymentSource ToDomain() => new(Type, Token);
}

View File

@@ -1,15 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
namespace Bit.Api.Billing.Models.Requests;
public class UpdatePaymentMethodRequestBody
{
[Required]
public TokenizedPaymentSourceRequestBody PaymentSource { get; set; }
[Required]
public TaxInformationRequestBody TaxInformation { get; set; }
}

View File

@@ -1,12 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
namespace Bit.Api.Billing.Models.Requests;
public class VerifyBankAccountRequestBody
{
[Required]
public string DescriptorCode { get; set; }
}

View File

@@ -1,20 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Billing.Models;
using Bit.Core.Models.Api;
namespace Bit.Api.Billing.Models.Responses;
public class BillingPaymentResponseModel : ResponseModel
{
public BillingPaymentResponseModel(BillingInfo billing)
: base("billingPayment")
{
Balance = billing.Balance;
PaymentSource = billing.PaymentSource != null ? new BillingSource(billing.PaymentSource) : null;
}
public decimal Balance { get; set; }
public BillingSource PaymentSource { get; set; }
}

View File

@@ -1,18 +0,0 @@
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Tax.Models;
namespace Bit.Api.Billing.Models.Responses;
public record PaymentMethodResponse(
decimal AccountCredit,
PaymentSource PaymentSource,
string SubscriptionStatus,
TaxInformation TaxInformation)
{
public static PaymentMethodResponse From(PaymentMethod paymentMethod) =>
new(
paymentMethod.AccountCredit,
paymentMethod.PaymentSource,
paymentMethod.SubscriptionStatus,
paymentMethod.TaxInformation);
}

View File

@@ -1,16 +0,0 @@
using Bit.Core.Billing.Models;
using Bit.Core.Enums;
namespace Bit.Api.Billing.Models.Responses;
public record PaymentSourceResponse(
PaymentMethodType Type,
string Description,
bool NeedsVerification)
{
public static PaymentSourceResponse From(PaymentSource paymentMethod)
=> new(
paymentMethod.Type,
paymentMethod.Description,
paymentMethod.NeedsVerification);
}

View File

@@ -1,23 +0,0 @@
using Bit.Core.Billing.Tax.Models;
namespace Bit.Api.Billing.Models.Responses;
public record TaxInformationResponse(
string Country,
string PostalCode,
string TaxId,
string Line1,
string Line2,
string City,
string State)
{
public static TaxInformationResponse From(TaxInformation taxInformation)
=> new(
taxInformation.Country,
taxInformation.PostalCode,
taxInformation.TaxId,
taxInformation.Line1,
taxInformation.Line2,
taxInformation.City,
taxInformation.State);
}

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

@@ -66,7 +66,10 @@ public class HibpController : Controller
}
else if (response.StatusCode == HttpStatusCode.NotFound)
{
return new NotFoundResult();
/* 12/1/2025 - Per the HIBP API, If the domain does not have any email addresses in any breaches,
an HTTP 404 response will be returned. API also specifies that "404 Not found is the account could
not be found and has therefore not been pwned". Per REST semantics we will return 200 OK with empty array. */
return Content("[]", "application/json");
}
else if (response.StatusCode == HttpStatusCode.TooManyRequests && retry)
{

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

@@ -1,8 +1,8 @@
#nullable enable
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Auth.Models.Request;
using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Api.KeyManagement.Models.Requests;
using Bit.Api.KeyManagement.Models.Responses;
using Bit.Api.KeyManagement.Validators;
using Bit.Api.Tools.Models.Request;
using Bit.Api.Vault.Models.Request;
@@ -14,6 +14,7 @@ using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Commands.Interfaces;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Core.KeyManagement.UserKey;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -45,11 +46,13 @@ public class AccountsKeyManagementController : Controller
private readonly IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>
_webauthnKeyValidator;
private readonly IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> _deviceValidator;
private readonly IKeyConnectorConfirmationDetailsQuery _keyConnectorConfirmationDetailsQuery;
public AccountsKeyManagementController(IUserService userService,
IFeatureService featureService,
IOrganizationUserRepository organizationUserRepository,
IEmergencyAccessRepository emergencyAccessRepository,
IKeyConnectorConfirmationDetailsQuery keyConnectorConfirmationDetailsQuery,
IRegenerateUserAsymmetricKeysCommand regenerateUserAsymmetricKeysCommand,
IRotateUserAccountKeysCommand rotateUserKeyCommandV2,
IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> cipherValidator,
@@ -75,12 +78,13 @@ public class AccountsKeyManagementController : Controller
_organizationUserValidator = organizationUserValidator;
_webauthnKeyValidator = webAuthnKeyValidator;
_deviceValidator = deviceValidator;
_keyConnectorConfirmationDetailsQuery = keyConnectorConfirmationDetailsQuery;
}
[HttpPost("key-management/regenerate-keys")]
public async Task RegenerateKeysAsync([FromBody] KeyRegenerationRequestModel request)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration))
if (!_featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration) && !_featureService.IsEnabled(FeatureFlagKeys.DataRecoveryTool))
{
throw new NotFoundException();
}
@@ -178,4 +182,17 @@ public class AccountsKeyManagementController : Controller
throw new BadRequestException(ModelState);
}
[HttpGet("key-connector/confirmation-details/{orgSsoIdentifier}")]
public async Task<KeyConnectorConfirmationDetailsResponseModel> GetKeyConnectorConfirmationDetailsAsync(string orgSsoIdentifier)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var details = await _keyConnectorConfirmationDetailsQuery.Run(orgSsoIdentifier, user.Id);
return new KeyConnectorConfirmationDetailsResponseModel(details);
}
}

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.KeyManagement.Models.Api.Request;
namespace Bit.Api.KeyManagement.Models.Requests;

View File

@@ -0,0 +1,24 @@
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Models.Api;
namespace Bit.Api.KeyManagement.Models.Responses;
public class KeyConnectorConfirmationDetailsResponseModel : ResponseModel
{
private const string _objectName = "keyConnectorConfirmationDetails";
public KeyConnectorConfirmationDetailsResponseModel(KeyConnectorConfirmationDetails details,
string obj = _objectName) : base(obj)
{
ArgumentNullException.ThrowIfNull(details);
OrganizationName = details.OrganizationName;
}
public KeyConnectorConfirmationDetailsResponseModel() : base(_objectName)
{
OrganizationName = string.Empty;
}
public string OrganizationName { get; set; }
}

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

@@ -1,4 +1,7 @@
using Bit.Core.Billing.Constants;
using System.Security.Claims;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Licenses;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Entities;
using Bit.Core.Models.Api;
@@ -37,6 +40,46 @@ public class SubscriptionResponseModel : ResponseModel
: null;
}
/// <param name="user">The user entity containing storage and premium subscription information</param>
/// <param name="subscription">Subscription information retrieved from the payment provider (Stripe/Braintree)</param>
/// <param name="license">The user's license containing expiration and feature entitlements</param>
/// <param name="claimsPrincipal">The claims principal containing cryptographically secure token claims</param>
/// <param name="includeMilestone2Discount">
/// Whether to include discount information in the response.
/// Set to true when the PM23341_Milestone_2 feature flag is enabled AND
/// you want to expose Milestone 2 discount information to the client.
/// The discount will only be included if it matches the specific Milestone 2 coupon ID.
/// </param>
public SubscriptionResponseModel(User user, SubscriptionInfo? subscription, UserLicense license, ClaimsPrincipal? claimsPrincipal, bool includeMilestone2Discount = false)
: base("subscription")
{
Subscription = subscription?.Subscription != null ? new BillingSubscription(subscription.Subscription) : null;
UpcomingInvoice = subscription?.UpcomingInvoice != null ?
new BillingSubscriptionUpcomingInvoice(subscription.UpcomingInvoice) : null;
StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null;
StorageGb = user.Storage.HasValue ? Math.Round(user.Storage.Value / 1073741824D, 2) : 0; // 1 GB
MaxStorageGb = user.MaxStorageGb;
License = license;
// CRITICAL: When a license has a Token (JWT), ALWAYS use the expiration from the token claim
// The token's expiration is cryptographically secured and cannot be tampered with
// The file's Expires property can be manually edited and should NOT be trusted for display
if (claimsPrincipal != null)
{
Expiration = claimsPrincipal.GetValue<DateTime?>(UserLicenseConstants.Expires);
}
else
{
// No token - use the license file expiration (for older licenses without tokens)
Expiration = License.Expires;
}
// Only display the Milestone 2 subscription discount on the subscription page.
CustomerDiscount = ShouldIncludeMilestone2Discount(includeMilestone2Discount, subscription?.CustomerDiscount)
? new BillingCustomerDiscount(subscription!.CustomerDiscount!)
: null;
}
public SubscriptionResponseModel(User user, UserLicense? license = null)
: base("subscription")
{

View File

@@ -0,0 +1,337 @@
using Bit.Api.Models.Response;
using Bit.Api.SecretsManager.Models.Request;
using Bit.Api.SecretsManager.Models.Response;
using Bit.Core.Auth.Identity;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.SecretsManager.Controllers;
[Authorize("secrets")]
public class SecretVersionsController : Controller
{
private readonly ICurrentContext _currentContext;
private readonly ISecretVersionRepository _secretVersionRepository;
private readonly ISecretRepository _secretRepository;
private readonly IUserService _userService;
private readonly IOrganizationUserRepository _organizationUserRepository;
public SecretVersionsController(
ICurrentContext currentContext,
ISecretVersionRepository secretVersionRepository,
ISecretRepository secretRepository,
IUserService userService,
IOrganizationUserRepository organizationUserRepository)
{
_currentContext = currentContext;
_secretVersionRepository = secretVersionRepository;
_secretRepository = secretRepository;
_userService = userService;
_organizationUserRepository = organizationUserRepository;
}
[HttpGet("secrets/{secretId}/versions")]
public async Task<ListResponseModel<SecretVersionResponseModel>> GetVersionsBySecretIdAsync([FromRoute] Guid secretId)
{
var secret = await _secretRepository.GetByIdAsync(secretId);
if (secret == null || !_currentContext.AccessSecretsManager(secret.OrganizationId))
{
throw new NotFoundException();
}
// For service accounts and organization API, skip user-level access checks
if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount ||
_currentContext.IdentityClientType == IdentityClientType.Organization)
{
// Already verified Secrets Manager access above
var versionList = await _secretVersionRepository.GetManyBySecretIdAsync(secretId);
var responseList = versionList.Select(v => new SecretVersionResponseModel(v));
return new ListResponseModel<SecretVersionResponseModel>(responseList);
}
var userId = _userService.GetProperUserId(User);
if (!userId.HasValue)
{
throw new NotFoundException();
}
var orgAdmin = await _currentContext.OrganizationAdmin(secret.OrganizationId);
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin);
var access = await _secretRepository.AccessToSecretAsync(secretId, userId.Value, accessClient);
if (!access.Read)
{
throw new NotFoundException();
}
var versions = await _secretVersionRepository.GetManyBySecretIdAsync(secretId);
var responses = versions.Select(v => new SecretVersionResponseModel(v));
return new ListResponseModel<SecretVersionResponseModel>(responses);
}
[HttpGet("secret-versions/{id}")]
public async Task<SecretVersionResponseModel> GetByIdAsync([FromRoute] Guid id)
{
var secretVersion = await _secretVersionRepository.GetByIdAsync(id);
if (secretVersion == null)
{
throw new NotFoundException();
}
var secret = await _secretRepository.GetByIdAsync(secretVersion.SecretId);
if (secret == null || !_currentContext.AccessSecretsManager(secret.OrganizationId))
{
throw new NotFoundException();
}
// For service accounts and organization API, skip user-level access checks
if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount ||
_currentContext.IdentityClientType == IdentityClientType.Organization)
{
// Already verified Secrets Manager access above
return new SecretVersionResponseModel(secretVersion);
}
var userId = _userService.GetProperUserId(User);
if (!userId.HasValue)
{
throw new NotFoundException();
}
var orgAdmin = await _currentContext.OrganizationAdmin(secret.OrganizationId);
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin);
var access = await _secretRepository.AccessToSecretAsync(secretVersion.SecretId, userId.Value, accessClient);
if (!access.Read)
{
throw new NotFoundException();
}
return new SecretVersionResponseModel(secretVersion);
}
[HttpPost("secret-versions/get-by-ids")]
public async Task<ListResponseModel<SecretVersionResponseModel>> GetManyByIdsAsync([FromBody] List<Guid> ids)
{
if (!ids.Any())
{
throw new BadRequestException("No version IDs provided.");
}
// Get all versions
var versions = (await _secretVersionRepository.GetManyByIdsAsync(ids)).ToList();
if (!versions.Any())
{
throw new NotFoundException();
}
// Get all associated secrets and check permissions
var secretIds = versions.Select(v => v.SecretId).Distinct().ToList();
var secrets = (await _secretRepository.GetManyByIds(secretIds)).ToList();
if (!secrets.Any())
{
throw new NotFoundException();
}
// Ensure all secrets belong to the same organization
var organizationId = secrets.First().OrganizationId;
if (secrets.Any(s => s.OrganizationId != organizationId) ||
!_currentContext.AccessSecretsManager(organizationId))
{
throw new NotFoundException();
}
// For service accounts and organization API, skip user-level access checks
if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount ||
_currentContext.IdentityClientType == IdentityClientType.Organization)
{
// Already verified Secrets Manager access and organization ownership above
var serviceAccountResponses = versions.Select(v => new SecretVersionResponseModel(v));
return new ListResponseModel<SecretVersionResponseModel>(serviceAccountResponses);
}
var userId = _userService.GetProperUserId(User);
if (!userId.HasValue)
{
throw new NotFoundException();
}
var isAdmin = await _currentContext.OrganizationAdmin(organizationId);
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, isAdmin);
// Verify read access to all associated secrets
var accessResults = await _secretRepository.AccessToSecretsAsync(secretIds, userId.Value, accessClient);
if (accessResults.Values.Any(access => !access.Read))
{
throw new NotFoundException();
}
var responses = versions.Select(v => new SecretVersionResponseModel(v));
return new ListResponseModel<SecretVersionResponseModel>(responses);
}
[HttpPut("secrets/{secretId}/versions/restore")]
public async Task<SecretResponseModel> RestoreVersionAsync([FromRoute] Guid secretId, [FromBody] RestoreSecretVersionRequestModel request)
{
if (!(_currentContext.IdentityClientType == IdentityClientType.User || _currentContext.IdentityClientType == IdentityClientType.ServiceAccount))
{
throw new NotFoundException();
}
var secret = await _secretRepository.GetByIdAsync(secretId);
if (secret == null || !_currentContext.AccessSecretsManager(secret.OrganizationId))
{
throw new NotFoundException();
}
// Get the version first to validate it belongs to this secret
var version = await _secretVersionRepository.GetByIdAsync(request.VersionId);
if (version == null || version.SecretId != secretId)
{
throw new NotFoundException();
}
// Store the current value before restoration
var currentValue = secret.Value;
// For service accounts and organization API, skip user-level access checks
if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount)
{
// Save current value as a version before restoring
if (currentValue != version.Value)
{
var editorUserId = _userService.GetProperUserId(User);
if (editorUserId.HasValue)
{
var currentVersionSnapshot = new Core.SecretsManager.Entities.SecretVersion
{
SecretId = secretId,
Value = currentValue!,
VersionDate = DateTime.UtcNow,
EditorServiceAccountId = editorUserId.Value
};
await _secretVersionRepository.CreateAsync(currentVersionSnapshot);
}
}
// Already verified Secrets Manager access above
secret.Value = version.Value;
secret.RevisionDate = DateTime.UtcNow;
var updatedSec = await _secretRepository.UpdateAsync(secret);
return new SecretResponseModel(updatedSec, true, true);
}
var userId = _userService.GetProperUserId(User);
if (!userId.HasValue)
{
throw new NotFoundException();
}
var orgAdmin = await _currentContext.OrganizationAdmin(secret.OrganizationId);
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin);
var access = await _secretRepository.AccessToSecretAsync(secretId, userId.Value, accessClient);
if (!access.Write)
{
throw new NotFoundException();
}
// Save current value as a version before restoring
if (currentValue != version.Value)
{
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(secret.OrganizationId, userId.Value);
if (orgUser == null)
{
throw new NotFoundException();
}
var currentVersionSnapshot = new Core.SecretsManager.Entities.SecretVersion
{
SecretId = secretId,
Value = currentValue!,
VersionDate = DateTime.UtcNow,
EditorOrganizationUserId = orgUser.Id
};
await _secretVersionRepository.CreateAsync(currentVersionSnapshot);
}
// Update the secret with the version's value
secret.Value = version.Value;
secret.RevisionDate = DateTime.UtcNow;
var updatedSecret = await _secretRepository.UpdateAsync(secret);
return new SecretResponseModel(updatedSecret, true, true);
}
[HttpPost("secret-versions/delete")]
public async Task<IActionResult> BulkDeleteAsync([FromBody] List<Guid> ids)
{
if (!ids.Any())
{
throw new BadRequestException("No version IDs provided.");
}
var secretVersions = (await _secretVersionRepository.GetManyByIdsAsync(ids)).ToList();
if (secretVersions.Count != ids.Count)
{
throw new NotFoundException();
}
// Ensure all versions belong to secrets in the same organization
var secretIds = secretVersions.Select(v => v.SecretId).Distinct().ToList();
var secrets = await _secretRepository.GetManyByIds(secretIds);
var secretsList = secrets.ToList();
if (!secretsList.Any())
{
throw new NotFoundException();
}
var organizationId = secretsList.First().OrganizationId;
if (secretsList.Any(s => s.OrganizationId != organizationId) ||
!_currentContext.AccessSecretsManager(organizationId))
{
throw new NotFoundException();
}
// For service accounts and organization API, skip user-level access checks
if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount ||
_currentContext.IdentityClientType == IdentityClientType.Organization)
{
// Already verified Secrets Manager access and organization ownership above
await _secretVersionRepository.DeleteManyByIdAsync(ids);
return Ok();
}
var userId = _userService.GetProperUserId(User);
if (!userId.HasValue)
{
throw new NotFoundException();
}
var orgAdmin = await _currentContext.OrganizationAdmin(organizationId);
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin);
// Verify write access to all associated secrets
var accessResults = await _secretRepository.AccessToSecretsAsync(secretIds, userId.Value, accessClient);
if (accessResults.Values.Any(access => !access.Write))
{
throw new NotFoundException();
}
await _secretVersionRepository.DeleteManyByIdAsync(ids);
return Ok();
}
}

View File

@@ -8,6 +8,7 @@ using Bit.Core.Auth.Identity;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.AuthorizationRequirements;
using Bit.Core.SecretsManager.Commands.Secrets.Interfaces;
using Bit.Core.SecretsManager.Entities;
@@ -29,6 +30,7 @@ public class SecretsController : Controller
private readonly ICurrentContext _currentContext;
private readonly IProjectRepository _projectRepository;
private readonly ISecretRepository _secretRepository;
private readonly ISecretVersionRepository _secretVersionRepository;
private readonly ICreateSecretCommand _createSecretCommand;
private readonly IUpdateSecretCommand _updateSecretCommand;
private readonly IDeleteSecretCommand _deleteSecretCommand;
@@ -38,11 +40,13 @@ public class SecretsController : Controller
private readonly IUserService _userService;
private readonly IEventService _eventService;
private readonly IAuthorizationService _authorizationService;
private readonly IOrganizationUserRepository _organizationUserRepository;
public SecretsController(
ICurrentContext currentContext,
IProjectRepository projectRepository,
ISecretRepository secretRepository,
ISecretVersionRepository secretVersionRepository,
ICreateSecretCommand createSecretCommand,
IUpdateSecretCommand updateSecretCommand,
IDeleteSecretCommand deleteSecretCommand,
@@ -51,11 +55,13 @@ public class SecretsController : Controller
ISecretAccessPoliciesUpdatesQuery secretAccessPoliciesUpdatesQuery,
IUserService userService,
IEventService eventService,
IAuthorizationService authorizationService)
IAuthorizationService authorizationService,
IOrganizationUserRepository organizationUserRepository)
{
_currentContext = currentContext;
_projectRepository = projectRepository;
_secretRepository = secretRepository;
_secretVersionRepository = secretVersionRepository;
_createSecretCommand = createSecretCommand;
_updateSecretCommand = updateSecretCommand;
_deleteSecretCommand = deleteSecretCommand;
@@ -65,6 +71,7 @@ public class SecretsController : Controller
_userService = userService;
_eventService = eventService;
_authorizationService = authorizationService;
_organizationUserRepository = organizationUserRepository;
}
@@ -190,6 +197,44 @@ public class SecretsController : Controller
}
}
// Create a version record if the value changed
if (updateRequest.ValueChanged)
{
// Store the old value before updating
var oldValue = secret.Value;
var userId = _userService.GetProperUserId(User)!.Value;
Guid? editorServiceAccountId = null;
Guid? editorOrganizationUserId = null;
if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount)
{
editorServiceAccountId = userId;
}
else if (_currentContext.IdentityClientType == IdentityClientType.User)
{
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(secret.OrganizationId, userId);
if (orgUser != null)
{
editorOrganizationUserId = orgUser.Id;
}
else
{
throw new NotFoundException();
}
}
var secretVersion = new SecretVersion
{
SecretId = id,
Value = oldValue,
VersionDate = DateTime.UtcNow,
EditorServiceAccountId = editorServiceAccountId,
EditorOrganizationUserId = editorOrganizationUserId
};
await _secretVersionRepository.CreateAsync(secretVersion);
}
var result = await _updateSecretCommand.UpdateAsync(updatedSecret, accessPoliciesUpdates);
await LogSecretEventAsync(secret, EventType.Secret_Edited);

View File

@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Api.SecretsManager.Models.Request;
public class RestoreSecretVersionRequestModel
{
[Required]
public Guid VersionId { get; set; }
}

View File

@@ -28,6 +28,8 @@ public class SecretUpdateRequestModel : IValidatableObject
public SecretAccessPoliciesRequestsModel AccessPoliciesRequests { get; set; }
public bool ValueChanged { get; set; } = false;
public Secret ToSecret(Secret secret)
{
secret.Key = Key;

View File

@@ -0,0 +1,28 @@
using Bit.Core.Models.Api;
using Bit.Core.SecretsManager.Entities;
namespace Bit.Api.SecretsManager.Models.Response;
public class SecretVersionResponseModel : ResponseModel
{
private const string _objectName = "secretVersion";
public Guid Id { get; set; }
public Guid SecretId { get; set; }
public string Value { get; set; } = string.Empty;
public DateTime VersionDate { get; set; }
public Guid? EditorServiceAccountId { get; set; }
public Guid? EditorOrganizationUserId { get; set; }
public SecretVersionResponseModel() : base(_objectName) { }
public SecretVersionResponseModel(SecretVersion secretVersion) : base(_objectName)
{
Id = secretVersion.Id;
SecretId = secretVersion.SecretId;
Value = secretVersion.Value;
VersionDate = secretVersion.VersionDate;
EditorServiceAccountId = secretVersion.EditorServiceAccountId;
EditorOrganizationUserId = secretVersion.EditorOrganizationUserId;
}
}

View File

@@ -187,7 +187,6 @@ public class Startup
services.AddBillingOperations();
services.AddReportingServices();
services.AddImportServices();
services.AddPhishingDomainServices(globalSettings);
services.AddSendServices();
@@ -216,7 +215,7 @@ public class Startup
config.Conventions.Add(new PublicApiControllersModelConvention());
});
services.AddSwagger(globalSettings, Environment);
services.AddSwaggerGen(globalSettings, Environment);
Jobs.JobsHostedService.AddJobsServices(services, globalSettings.SelfHosted);
services.AddHostedService<Jobs.JobsHostedService>();
@@ -226,7 +225,8 @@ public class Startup
services.AddHostedService<Core.HostedServices.ApplicationCacheHostedService>();
}
// Add Slack / Teams Services for OAuth API requests - if configured
// Add Event Integrations services
services.AddEventIntegrationsCommandsQueries(globalSettings);
services.AddSlackService(globalSettings);
services.AddTeamsService(globalSettings);
}
@@ -292,17 +292,59 @@ public class Startup
});
// Add Swagger
// Note that the swagger.json generation is configured in the call to AddSwaggerGen above.
if (Environment.IsDevelopment() || globalSettings.SelfHosted)
{
// adds the middleware to serve the swagger.json while the server is running
app.UseSwagger(config =>
{
config.RouteTemplate = "specs/{documentName}/swagger.json";
// Remove all Bitwarden cloud servers and only register the local server
config.PreSerializeFilters.Add((swaggerDoc, httpReq) =>
swaggerDoc.Servers = new List<OpenApiServer>
{
swaggerDoc.Servers.Clear();
swaggerDoc.Servers.Add(new OpenApiServer
{
new OpenApiServer { Url = globalSettings.BaseServiceUri.Api }
Url = globalSettings.BaseServiceUri.Api,
});
swaggerDoc.Components.SecuritySchemes.Clear();
swaggerDoc.Components.SecuritySchemes.Add("oauth2-client-credentials", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.OAuth2,
Flows = new OpenApiOAuthFlows
{
ClientCredentials = new OpenApiOAuthFlow
{
TokenUrl = new Uri($"{globalSettings.BaseServiceUri.Identity}/connect/token"),
Scopes = new Dictionary<string, string>
{
{ ApiScopes.ApiOrganization, "Organization APIs" }
}
}
}
});
swaggerDoc.SecurityRequirements.Clear();
swaggerDoc.SecurityRequirements.Add(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "oauth2-client-credentials"
}
},
[ApiScopes.ApiOrganization]
}
});
});
});
// adds the middleware to display the web UI
app.UseSwaggerUI(config =>
{
config.DocumentTitle = "Bitwarden API Documentation";

View File

@@ -1,15 +1,11 @@
using Bit.Api.AdminConsole.Authorization;
using Bit.Api.Tools.Authorization;
using Bit.Core.Auth.IdentityServer;
using Bit.Core.PhishingDomainFeatures;
using Bit.Core.PhishingDomainFeatures.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Repositories.Implementations;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Core.Vault.Authorization.SecurityTasks;
using Bit.SharedWeb.Health;
using Bit.SharedWeb.Swagger;
using Bit.SharedWeb.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.OpenApi.Models;
@@ -17,7 +13,10 @@ namespace Bit.Api.Utilities;
public static class ServiceCollectionExtensions
{
public static void AddSwagger(this IServiceCollection services, GlobalSettings globalSettings, IWebHostEnvironment environment)
/// <summary>
/// Configures the generation of swagger.json OpenAPI spec.
/// </summary>
public static void AddSwaggerGen(this IServiceCollection services, GlobalSettings globalSettings, IWebHostEnvironment environment)
{
services.AddSwaggerGen(config =>
{
@@ -36,6 +35,8 @@ public static class ServiceCollectionExtensions
organizations tools for managing members, collections, groups, event logs, and policies.
If you are looking for the Vault Management API, refer instead to
[this document](https://bitwarden.com/help/vault-management-api/).
**Note:** your authorization must match the server you have selected.
""",
License = new OpenApiLicense
{
@@ -46,36 +47,20 @@ public static class ServiceCollectionExtensions
config.SwaggerDoc("internal", new OpenApiInfo { Title = "Bitwarden Internal API", Version = "latest" });
config.AddSecurityDefinition("oauth2-client-credentials", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.OAuth2,
Flows = new OpenApiOAuthFlows
{
ClientCredentials = new OpenApiOAuthFlow
{
TokenUrl = new Uri($"{globalSettings.BaseServiceUri.Identity}/connect/token"),
Scopes = new Dictionary<string, string>
{
{ ApiScopes.ApiOrganization, "Organization APIs" },
},
}
},
});
// Configure Bitwarden cloud US and EU servers. These will appear in the swagger.json build artifact
// used for our help center. These are overwritten with the local server when running in self-hosted
// or dev mode (see Api Startup.cs).
config.AddSwaggerServerWithSecurity(
serverId: "US_server",
serverUrl: "https://api.bitwarden.com",
identityTokenUrl: "https://identity.bitwarden.com/connect/token",
serverDescription: "US server");
config.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "oauth2-client-credentials"
},
},
new[] { ApiScopes.ApiOrganization }
}
});
config.AddSwaggerServerWithSecurity(
serverId: "EU_server",
serverUrl: "https://api.bitwarden.eu",
identityTokenUrl: "https://identity.bitwarden.eu/connect/token",
serverDescription: "EU server");
config.DescribeAllParametersInCamelCase();
// config.UseReferencedDefinitionsForEnums();
@@ -114,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

@@ -757,15 +757,10 @@ public class CiphersController : Controller
}
}
if (cipher.ArchivedDate.HasValue)
{
throw new BadRequestException("Cannot move an archived item to an organization.");
}
ValidateClientVersionForFido2CredentialSupport(cipher);
var original = cipher.Clone();
await _cipherService.ShareAsync(original, model.Cipher.ToCipher(cipher), new Guid(model.Cipher.OrganizationId),
await _cipherService.ShareAsync(original, model.Cipher.ToCipher(cipher, user.Id), new Guid(model.Cipher.OrganizationId),
model.CollectionIds.Select(c => new Guid(c)), user.Id, model.Cipher.LastKnownRevisionDate);
var sharedCipher = await GetByIdAsync(id, user.Id);
@@ -1271,11 +1266,6 @@ public class CiphersController : Controller
_logger.LogError("Cipher was not encrypted for the current user. CipherId: {CipherId}, CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", cipher.Id, userId, cipher.EncryptedFor);
throw new BadRequestException("Cipher was not encrypted for the current user. Please try again.");
}
if (cipher.ArchivedDate.HasValue)
{
throw new BadRequestException("Cannot move archived items to an organization.");
}
}
var shareCiphers = new List<(CipherDetails, DateTime?)>();
@@ -1288,11 +1278,6 @@ public class CiphersController : Controller
ValidateClientVersionForFido2CredentialSupport(existingCipher);
if (existingCipher.ArchivedDate.HasValue)
{
throw new BadRequestException("Cannot move archived items to an organization.");
}
shareCiphers.Add((cipher.ToCipherDetails(existingCipher), cipher.LastKnownRevisionDate));
}

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