1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-05 05:00:19 -05:00

Merge branch 'main' into add-docker-arm64-builds

This commit is contained in:
Vince Grassia 2025-04-02 14:01:54 -04:00
commit 997d9b853c
No known key found for this signature in database
GPG Key ID: 9AD7505E8448CC08
129 changed files with 16835 additions and 2362 deletions

1
.github/CODEOWNERS vendored
View File

@ -66,6 +66,7 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev
# Platform team # Platform team
.github/workflows/build.yml @bitwarden/team-platform-dev .github/workflows/build.yml @bitwarden/team-platform-dev
.github/workflows/build_target.yml @bitwarden/team-platform-dev
.github/workflows/cleanup-after-pr.yml @bitwarden/team-platform-dev .github/workflows/cleanup-after-pr.yml @bitwarden/team-platform-dev
.github/workflows/cleanup-rc-branch.yml @bitwarden/team-platform-dev .github/workflows/cleanup-rc-branch.yml @bitwarden/team-platform-dev
.github/workflows/repository-management.yml @bitwarden/team-platform-dev .github/workflows/repository-management.yml @bitwarden/team-platform-dev

View File

@ -7,21 +7,18 @@ on:
- "main" - "main"
- "rc" - "rc"
- "hotfix-rc" - "hotfix-rc"
pull_request_target: pull_request:
types: [opened, synchronize] types: [opened, synchronize]
workflow_call:
inputs: {}
env: env:
_AZ_REGISTRY: "bitwardenprod.azurecr.io" _AZ_REGISTRY: "bitwardenprod.azurecr.io"
jobs: jobs:
check-run:
name: Check PR run
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
lint: lint:
name: Lint name: Lint
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
needs: check-run
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -37,10 +34,11 @@ jobs:
build-container: build-container:
name: Build container images name: Build container images
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
needs: check-run
permissions: permissions:
id-token: write id-token: write
security-events: write security-events: write
outputs:
has_secrets: ${{ steps.check-secrets.outputs.has_secrets }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -76,6 +74,14 @@ jobs:
- project_name: Sso - project_name: Sso
base_path: ./bitwarden_license/src base_path: ./bitwarden_license/src
steps: steps:
- name: Check secrets
id: check-secrets
env:
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
run: |
has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }}
echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
@ -122,7 +128,7 @@ jobs:
- name: Generate container image tag - name: Generate container image tag
id: tag id: tag
run: | run: |
if [[ "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then
IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g") IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g")
else else
IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g") IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g")
@ -180,11 +186,11 @@ jobs:
tags: ${{ steps.image-tags.outputs.tags }} tags: ${{ steps.image-tags.outputs.tags }}
- name: Install Cosign - name: Install Cosign
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0 uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0
- name: Sign image with Cosign - name: Sign image with Cosign
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
env: env:
DIGEST: ${{ steps.build-container.outputs.digest }} DIGEST: ${{ steps.build-container.outputs.digest }}
TAGS: ${{ steps.image-tags.outputs.tags }} TAGS: ${{ steps.image-tags.outputs.tags }}
@ -214,9 +220,7 @@ jobs:
build-stub-swagger: build-stub-swagger:
name: Build Docker-Stub/Swagger name: Build Docker-Stub/Swagger
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
needs: needs: build-container
- build-container
- check-run
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -239,7 +243,7 @@ jobs:
- name: Make Docker stubs - name: Make Docker stubs
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
run: | run: |
# Set proper setup image based on branch # Set proper setup image based on branch
@ -281,7 +285,7 @@ jobs:
- name: Make Docker stub checksums - name: Make Docker stub checksums
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
run: | run: |
sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt
@ -289,7 +293,7 @@ jobs:
- name: Upload Docker stub US artifact - name: Upload Docker stub US artifact
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with: with:
@ -299,7 +303,7 @@ jobs:
- name: Upload Docker stub EU artifact - name: Upload Docker stub EU artifact
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with: with:
@ -309,7 +313,7 @@ jobs:
- name: Upload Docker stub US checksum artifact - name: Upload Docker stub US checksum artifact
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with: with:
@ -319,7 +323,7 @@ jobs:
- name: Upload Docker stub EU checksum artifact - name: Upload Docker stub EU checksum artifact
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with: with:
@ -397,7 +401,6 @@ jobs:
build-mssqlmigratorutility: build-mssqlmigratorutility:
name: Build MSSQL migrator utility name: Build MSSQL migrator utility
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
needs: check-run
defaults: defaults:
run: run:
shell: bash shell: bash
@ -454,12 +457,10 @@ jobs:
self-host-build: self-host-build:
name: Trigger self-host build name: Trigger self-host build
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
needs: needs: build-container
- build-container
- check-run
steps: steps:
- name: Log in to Azure - CI subscription - name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@ -490,11 +491,9 @@ jobs:
trigger-k8s-deploy: trigger-k8s-deploy:
name: Trigger k8s deploy name: Trigger k8s deploy
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
needs: needs: build-container
- build-container
- check-run
steps: steps:
- name: Log in to Azure - CI subscription - name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@ -527,12 +526,11 @@ jobs:
trigger-ee-updates: trigger-ee-updates:
name: Trigger Ephemeral Environment updates name: Trigger Ephemeral Environment updates
if: | if: |
github.event_name == 'pull_request_target' needs.build-container.outputs.has_secrets == 'true'
&& github.event_name == 'pull_request'
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment') && contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
needs: needs: build-container
- build-container
- check-run
steps: steps:
- name: Log in to Azure - CI subscription - name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@ -564,10 +562,11 @@ jobs:
trigger-ephemeral-environment-sync: trigger-ephemeral-environment-sync:
name: Trigger Ephemeral Environment Sync name: Trigger Ephemeral Environment Sync
needs: needs:
- check-run - build-container
- trigger-ee-updates - trigger-ee-updates
if: | if: |
github.event_name == 'pull_request_target' needs.build-container.outputs.has_secrets == 'true'
&& github.event_name == 'pull_request'
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment') && contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main
with: with:
@ -577,7 +576,6 @@ jobs:
pull_request_number: ${{ github.event.number }} pull_request_number: ${{ github.event.number }}
secrets: inherit secrets: inherit
check-failures: check-failures:
name: Check for failures name: Check for failures
if: always() if: always()
@ -587,7 +585,6 @@ jobs:
- build-container - build-container
- build-stub-swagger - build-stub-swagger
- build-mssqlmigratorutility - build-mssqlmigratorutility
- check-run
- self-host-build - self-host-build
- trigger-k8s-deploy - trigger-k8s-deploy
- trigger-ee-updates - trigger-ee-updates
@ -595,7 +592,7 @@ jobs:
steps: steps:
- name: Check if any job failed - name: Check if any job failed
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
&& contains(needs.*.result, 'failure') && contains(needs.*.result, 'failure')
run: exit 1 run: exit 1

21
.github/workflows/build_target.yml vendored Normal file
View File

@ -0,0 +1,21 @@
name: Build on PR Target
on:
pull_request_target:
types: [opened, synchronize]
defaults:
run:
shell: bash
jobs:
check-run:
name: Check PR run
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
run-workflow:
name: Run Build on PR Target
needs: check-run
if: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
uses: ./.github/workflows/build.yml
secrets: inherit

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
@ -7,10 +8,12 @@ using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Microsoft.Extensions.DependencyInjection;
using Stripe; using Stripe;
namespace Bit.Commercial.Core.AdminConsole.Providers; namespace Bit.Commercial.Core.AdminConsole.Providers;
@ -28,6 +31,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
private readonly ISubscriberService _subscriberService; private readonly ISubscriberService _subscriberService;
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
private readonly IAutomaticTaxStrategy _automaticTaxStrategy;
public RemoveOrganizationFromProviderCommand( public RemoveOrganizationFromProviderCommand(
IEventService eventService, IEventService eventService,
@ -40,7 +44,8 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
IProviderBillingService providerBillingService, IProviderBillingService providerBillingService,
ISubscriberService subscriberService, ISubscriberService subscriberService,
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
IPricingClient pricingClient) IPricingClient pricingClient,
[FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy)
{ {
_eventService = eventService; _eventService = eventService;
_mailService = mailService; _mailService = mailService;
@ -53,6 +58,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
_subscriberService = subscriberService; _subscriberService = subscriberService;
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
_pricingClient = pricingClient; _pricingClient = pricingClient;
_automaticTaxStrategy = automaticTaxStrategy;
} }
public async Task RemoveOrganizationFromProvider( public async Task RemoveOrganizationFromProvider(
@ -107,10 +113,11 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
organization.IsValidClient() && organization.IsValidClient() &&
!string.IsNullOrEmpty(organization.GatewayCustomerId)) !string.IsNullOrEmpty(organization.GatewayCustomerId))
{ {
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions var customer = await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
{ {
Description = string.Empty, Description = string.Empty,
Email = organization.BillingEmail Email = organization.BillingEmail,
Expand = ["tax", "tax_ids"]
}); });
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
@ -120,7 +127,6 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
Customer = organization.GatewayCustomerId, Customer = organization.GatewayCustomerId,
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
DaysUntilDue = 30, DaysUntilDue = 30,
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true },
Metadata = new Dictionary<string, string> Metadata = new Dictionary<string, string>
{ {
{ "organizationId", organization.Id.ToString() } { "organizationId", organization.Id.ToString() }
@ -130,6 +136,18 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }] Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }]
}; };
if (_featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
{
_automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer);
}
else
{
subscriptionCreateOptions.AutomaticTax ??= new SubscriptionAutomaticTaxOptions
{
Enabled = true
};
}
var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
organization.GatewaySubscriptionId = subscription.Id; organization.GatewaySubscriptionId = subscription.Id;

View File

@ -14,6 +14,7 @@ using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts; using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
@ -22,6 +23,7 @@ using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using CsvHelper; using CsvHelper;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Stripe; using Stripe;
@ -29,6 +31,7 @@ namespace Bit.Commercial.Core.Billing;
public class ProviderBillingService( public class ProviderBillingService(
IEventService eventService, IEventService eventService,
IFeatureService featureService,
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
ILogger<ProviderBillingService> logger, ILogger<ProviderBillingService> logger,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -40,7 +43,9 @@ public class ProviderBillingService(
IProviderUserRepository providerUserRepository, IProviderUserRepository providerUserRepository,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
ISubscriberService subscriberService, ISubscriberService subscriberService,
ITaxService taxService) : IProviderBillingService ITaxService taxService,
[FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy)
: IProviderBillingService
{ {
[RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)] [RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
public async Task AddExistingOrganization( public async Task AddExistingOrganization(
@ -557,7 +562,8 @@ public class ProviderBillingService(
{ {
ArgumentNullException.ThrowIfNull(provider); ArgumentNullException.ThrowIfNull(provider);
var customer = await subscriberService.GetCustomerOrThrow(provider); var customerGetOptions = new CustomerGetOptions { Expand = ["tax", "tax_ids"] };
var customer = await subscriberService.GetCustomerOrThrow(provider, customerGetOptions);
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
@ -589,10 +595,6 @@ public class ProviderBillingService(
var subscriptionCreateOptions = new SubscriptionCreateOptions var subscriptionCreateOptions = new SubscriptionCreateOptions
{ {
AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
},
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
Customer = customer.Id, Customer = customer.Id,
DaysUntilDue = 30, DaysUntilDue = 30,
@ -605,6 +607,15 @@ public class ProviderBillingService(
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations
}; };
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
{
automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer);
}
else
{
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
}
try try
{ {
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);

View File

@ -1,10 +1,8 @@
using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Scim.Groups.Interfaces; using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models; using Bit.Scim.Models;
using Bit.Scim.Utilities; using Bit.Scim.Utilities;
@ -24,10 +22,8 @@ public class GroupsController : Controller
private readonly IGetGroupsListQuery _getGroupsListQuery; private readonly IGetGroupsListQuery _getGroupsListQuery;
private readonly IDeleteGroupCommand _deleteGroupCommand; private readonly IDeleteGroupCommand _deleteGroupCommand;
private readonly IPatchGroupCommand _patchGroupCommand; private readonly IPatchGroupCommand _patchGroupCommand;
private readonly IPatchGroupCommandvNext _patchGroupCommandvNext;
private readonly IPostGroupCommand _postGroupCommand; private readonly IPostGroupCommand _postGroupCommand;
private readonly IPutGroupCommand _putGroupCommand; private readonly IPutGroupCommand _putGroupCommand;
private readonly IFeatureService _featureService;
public GroupsController( public GroupsController(
IGroupRepository groupRepository, IGroupRepository groupRepository,
@ -35,10 +31,8 @@ public class GroupsController : Controller
IGetGroupsListQuery getGroupsListQuery, IGetGroupsListQuery getGroupsListQuery,
IDeleteGroupCommand deleteGroupCommand, IDeleteGroupCommand deleteGroupCommand,
IPatchGroupCommand patchGroupCommand, IPatchGroupCommand patchGroupCommand,
IPatchGroupCommandvNext patchGroupCommandvNext,
IPostGroupCommand postGroupCommand, IPostGroupCommand postGroupCommand,
IPutGroupCommand putGroupCommand, IPutGroupCommand putGroupCommand
IFeatureService featureService
) )
{ {
_groupRepository = groupRepository; _groupRepository = groupRepository;
@ -46,10 +40,8 @@ public class GroupsController : Controller
_getGroupsListQuery = getGroupsListQuery; _getGroupsListQuery = getGroupsListQuery;
_deleteGroupCommand = deleteGroupCommand; _deleteGroupCommand = deleteGroupCommand;
_patchGroupCommand = patchGroupCommand; _patchGroupCommand = patchGroupCommand;
_patchGroupCommandvNext = patchGroupCommandvNext;
_postGroupCommand = postGroupCommand; _postGroupCommand = postGroupCommand;
_putGroupCommand = putGroupCommand; _putGroupCommand = putGroupCommand;
_featureService = featureService;
} }
[HttpGet("{id}")] [HttpGet("{id}")]
@ -103,21 +95,13 @@ public class GroupsController : Controller
[HttpPatch("{id}")] [HttpPatch("{id}")]
public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model) public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model)
{ {
if (_featureService.IsEnabled(FeatureFlagKeys.ShortcutDuplicatePatchRequests)) var group = await _groupRepository.GetByIdAsync(id);
if (group == null || group.OrganizationId != organizationId)
{ {
var group = await _groupRepository.GetByIdAsync(id); throw new NotFoundException("Group not found.");
if (group == null || group.OrganizationId != organizationId)
{
throw new NotFoundException("Group not found.");
}
await _patchGroupCommandvNext.PatchGroupAsync(group, model);
return new NoContentResult();
} }
var organization = await _organizationRepository.GetByIdAsync(organizationId); await _patchGroupCommand.PatchGroupAsync(group, model);
await _patchGroupCommand.PatchGroupAsync(organization, id, model);
return new NoContentResult(); return new NoContentResult();
} }

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -23,7 +24,7 @@ public class UsersController : Controller
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IPatchUserCommand _patchUserCommand; private readonly IPatchUserCommand _patchUserCommand;
private readonly IPostUserCommand _postUserCommand; private readonly IPostUserCommand _postUserCommand;
private readonly ILogger<UsersController> _logger; private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
public UsersController( public UsersController(
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
@ -32,7 +33,7 @@ public class UsersController : Controller
IRemoveOrganizationUserCommand removeOrganizationUserCommand, IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IPatchUserCommand patchUserCommand, IPatchUserCommand patchUserCommand,
IPostUserCommand postUserCommand, IPostUserCommand postUserCommand,
ILogger<UsersController> logger) IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
{ {
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_organizationService = organizationService; _organizationService = organizationService;
@ -40,7 +41,7 @@ public class UsersController : Controller
_removeOrganizationUserCommand = removeOrganizationUserCommand; _removeOrganizationUserCommand = removeOrganizationUserCommand;
_patchUserCommand = patchUserCommand; _patchUserCommand = patchUserCommand;
_postUserCommand = postUserCommand; _postUserCommand = postUserCommand;
_logger = logger; _restoreOrganizationUserCommand = restoreOrganizationUserCommand;
} }
[HttpGet("{id}")] [HttpGet("{id}")]
@ -93,7 +94,7 @@ public class UsersController : Controller
if (model.Active && orgUser.Status == OrganizationUserStatusType.Revoked) if (model.Active && orgUser.Status == OrganizationUserStatusType.Revoked)
{ {
await _organizationService.RestoreUserAsync(orgUser, EventSystemUser.SCIM); await _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, EventSystemUser.SCIM);
} }
else if (!model.Active && orgUser.Status != OrganizationUserStatusType.Revoked) else if (!model.Active && orgUser.Status != OrganizationUserStatusType.Revoked)
{ {

View File

@ -5,5 +5,5 @@ namespace Bit.Scim.Groups.Interfaces;
public interface IPatchGroupCommand public interface IPatchGroupCommand
{ {
Task PatchGroupAsync(Organization organization, Guid id, ScimPatchModel model); Task PatchGroupAsync(Group group, ScimPatchModel model);
} }

View File

@ -1,9 +0,0 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Scim.Models;
namespace Bit.Scim.Groups.Interfaces;
public interface IPatchGroupCommandvNext
{
Task PatchGroupAsync(Group group, ScimPatchModel model);
}

View File

@ -5,8 +5,10 @@ using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Scim.Groups.Interfaces; using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models; using Bit.Scim.Models;
using Bit.Scim.Utilities;
namespace Bit.Scim.Groups; namespace Bit.Scim.Groups;
@ -16,118 +18,137 @@ public class PatchGroupCommand : IPatchGroupCommand
private readonly IGroupService _groupService; private readonly IGroupService _groupService;
private readonly IUpdateGroupCommand _updateGroupCommand; private readonly IUpdateGroupCommand _updateGroupCommand;
private readonly ILogger<PatchGroupCommand> _logger; private readonly ILogger<PatchGroupCommand> _logger;
private readonly IOrganizationRepository _organizationRepository;
public PatchGroupCommand( public PatchGroupCommand(
IGroupRepository groupRepository, IGroupRepository groupRepository,
IGroupService groupService, IGroupService groupService,
IUpdateGroupCommand updateGroupCommand, IUpdateGroupCommand updateGroupCommand,
ILogger<PatchGroupCommand> logger) ILogger<PatchGroupCommand> logger,
IOrganizationRepository organizationRepository)
{ {
_groupRepository = groupRepository; _groupRepository = groupRepository;
_groupService = groupService; _groupService = groupService;
_updateGroupCommand = updateGroupCommand; _updateGroupCommand = updateGroupCommand;
_logger = logger; _logger = logger;
_organizationRepository = organizationRepository;
} }
public async Task PatchGroupAsync(Organization organization, Guid id, ScimPatchModel model) public async Task PatchGroupAsync(Group group, ScimPatchModel model)
{ {
var group = await _groupRepository.GetByIdAsync(id);
if (group == null || group.OrganizationId != organization.Id)
{
throw new NotFoundException("Group not found.");
}
var operationHandled = false;
foreach (var operation in model.Operations) foreach (var operation in model.Operations)
{ {
// Replace operations await HandleOperationAsync(group, operation);
if (operation.Op?.ToLowerInvariant() == "replace") }
{ }
// Replace a list of members
if (operation.Path?.ToLowerInvariant() == "members") private async Task HandleOperationAsync(Group group, ScimPatchModel.OperationModel operation)
{
switch (operation.Op?.ToLowerInvariant())
{
// Replace a list of members
case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.Members:
{ {
var ids = GetOperationValueIds(operation.Value); var ids = GetOperationValueIds(operation.Value);
await _groupRepository.UpdateUsersAsync(group.Id, ids); await _groupRepository.UpdateUsersAsync(group.Id, ids);
operationHandled = true; break;
} }
// Replace group name from path
else if (operation.Path?.ToLowerInvariant() == "displayname") // Replace group name from path
case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.DisplayName:
{ {
group.Name = operation.Value.GetString(); group.Name = operation.Value.GetString();
var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId);
if (organization == null)
{
throw new NotFoundException();
}
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM); await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
operationHandled = true; break;
} }
// Replace group name from value object
else if (string.IsNullOrWhiteSpace(operation.Path) && // Replace group name from value object
operation.Value.TryGetProperty("displayName", out var displayNameProperty)) case PatchOps.Replace when
string.IsNullOrWhiteSpace(operation.Path) &&
operation.Value.TryGetProperty("displayName", out var displayNameProperty):
{ {
group.Name = displayNameProperty.GetString(); group.Name = displayNameProperty.GetString();
var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId);
if (organization == null)
{
throw new NotFoundException();
}
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM); await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
operationHandled = true; break;
} }
}
// Add a single member // Add a single member
else if (operation.Op?.ToLowerInvariant() == "add" && case PatchOps.Add when
!string.IsNullOrWhiteSpace(operation.Path) && !string.IsNullOrWhiteSpace(operation.Path) &&
operation.Path.ToLowerInvariant().StartsWith("members[value eq ")) operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) &&
{ TryGetOperationPathId(operation.Path, out var addId):
var addId = GetOperationPathId(operation.Path); {
if (addId.HasValue) await AddMembersAsync(group, [addId]);
break;
}
// Add a list of members
case PatchOps.Add when
operation.Path?.ToLowerInvariant() == PatchPaths.Members:
{
await AddMembersAsync(group, GetOperationValueIds(operation.Value));
break;
}
// Remove a single member
case PatchOps.Remove when
!string.IsNullOrWhiteSpace(operation.Path) &&
operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) &&
TryGetOperationPathId(operation.Path, out var removeId):
{
await _groupService.DeleteUserAsync(group, removeId, EventSystemUser.SCIM);
break;
}
// Remove a list of members
case PatchOps.Remove when
operation.Path?.ToLowerInvariant() == PatchPaths.Members:
{ {
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet(); var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
orgUserIds.Add(addId.Value); foreach (var v in GetOperationValueIds(operation.Value))
{
orgUserIds.Remove(v);
}
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds); await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
operationHandled = true; break;
} }
}
// Add a list of members
else if (operation.Op?.ToLowerInvariant() == "add" &&
operation.Path?.ToLowerInvariant() == "members")
{
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
foreach (var v in GetOperationValueIds(operation.Value))
{
orgUserIds.Add(v);
}
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
operationHandled = true;
}
// Remove a single member
else if (operation.Op?.ToLowerInvariant() == "remove" &&
!string.IsNullOrWhiteSpace(operation.Path) &&
operation.Path.ToLowerInvariant().StartsWith("members[value eq "))
{
var removeId = GetOperationPathId(operation.Path);
if (removeId.HasValue)
{
await _groupService.DeleteUserAsync(group, removeId.Value, EventSystemUser.SCIM);
operationHandled = true;
}
}
// Remove a list of members
else if (operation.Op?.ToLowerInvariant() == "remove" &&
operation.Path?.ToLowerInvariant() == "members")
{
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
foreach (var v in GetOperationValueIds(operation.Value))
{
orgUserIds.Remove(v);
}
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
operationHandled = true;
}
}
if (!operationHandled) default:
{ {
_logger.LogWarning("Group patch operation not handled: {0} : ", _logger.LogWarning("Group patch operation not handled: {OperationOp}:{OperationPath}", operation.Op, operation.Path);
string.Join(", ", model.Operations.Select(o => $"{o.Op}:{o.Path}"))); break;
}
} }
} }
private List<Guid> GetOperationValueIds(JsonElement objArray) private async Task AddMembersAsync(Group group, HashSet<Guid> usersToAdd)
{ {
var ids = new List<Guid>(); // Azure Entra ID is known to send redundant "add" requests for each existing member every time any member
// is removed. To avoid excessive load on the database, we check against the high availability replica and
// return early if they already exist.
var groupMembers = await _groupRepository.GetManyUserIdsByIdAsync(group.Id, useReadOnlyReplica: true);
if (usersToAdd.IsSubsetOf(groupMembers))
{
_logger.LogDebug("Ignoring duplicate SCIM request to add members {Members} to group {Group}", usersToAdd, group.Id);
return;
}
await _groupRepository.AddGroupUsersByIdAsync(group.Id, usersToAdd);
}
private static HashSet<Guid> GetOperationValueIds(JsonElement objArray)
{
var ids = new HashSet<Guid>();
foreach (var obj in objArray.EnumerateArray()) foreach (var obj in objArray.EnumerateArray())
{ {
if (obj.TryGetProperty("value", out var valueProperty)) if (obj.TryGetProperty("value", out var valueProperty))
@ -141,13 +162,9 @@ public class PatchGroupCommand : IPatchGroupCommand
return ids; return ids;
} }
private Guid? GetOperationPathId(string path) private static bool TryGetOperationPathId(string path, out Guid pathId)
{ {
// Parse Guid from string like: members[value eq "{GUID}"}] // Parse Guid from string like: members[value eq "{GUID}"}]
if (Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out var id)) return Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out pathId);
{
return id;
}
return null;
} }
} }

View File

@ -1,170 +0,0 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models;
using Bit.Scim.Utilities;
namespace Bit.Scim.Groups;
public class PatchGroupCommandvNext : IPatchGroupCommandvNext
{
private readonly IGroupRepository _groupRepository;
private readonly IGroupService _groupService;
private readonly IUpdateGroupCommand _updateGroupCommand;
private readonly ILogger<PatchGroupCommandvNext> _logger;
private readonly IOrganizationRepository _organizationRepository;
public PatchGroupCommandvNext(
IGroupRepository groupRepository,
IGroupService groupService,
IUpdateGroupCommand updateGroupCommand,
ILogger<PatchGroupCommandvNext> logger,
IOrganizationRepository organizationRepository)
{
_groupRepository = groupRepository;
_groupService = groupService;
_updateGroupCommand = updateGroupCommand;
_logger = logger;
_organizationRepository = organizationRepository;
}
public async Task PatchGroupAsync(Group group, ScimPatchModel model)
{
foreach (var operation in model.Operations)
{
await HandleOperationAsync(group, operation);
}
}
private async Task HandleOperationAsync(Group group, ScimPatchModel.OperationModel operation)
{
switch (operation.Op?.ToLowerInvariant())
{
// Replace a list of members
case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.Members:
{
var ids = GetOperationValueIds(operation.Value);
await _groupRepository.UpdateUsersAsync(group.Id, ids);
break;
}
// Replace group name from path
case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.DisplayName:
{
group.Name = operation.Value.GetString();
var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId);
if (organization == null)
{
throw new NotFoundException();
}
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
break;
}
// Replace group name from value object
case PatchOps.Replace when
string.IsNullOrWhiteSpace(operation.Path) &&
operation.Value.TryGetProperty("displayName", out var displayNameProperty):
{
group.Name = displayNameProperty.GetString();
var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId);
if (organization == null)
{
throw new NotFoundException();
}
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
break;
}
// Add a single member
case PatchOps.Add when
!string.IsNullOrWhiteSpace(operation.Path) &&
operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) &&
TryGetOperationPathId(operation.Path, out var addId):
{
await AddMembersAsync(group, [addId]);
break;
}
// Add a list of members
case PatchOps.Add when
operation.Path?.ToLowerInvariant() == PatchPaths.Members:
{
await AddMembersAsync(group, GetOperationValueIds(operation.Value));
break;
}
// Remove a single member
case PatchOps.Remove when
!string.IsNullOrWhiteSpace(operation.Path) &&
operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) &&
TryGetOperationPathId(operation.Path, out var removeId):
{
await _groupService.DeleteUserAsync(group, removeId, EventSystemUser.SCIM);
break;
}
// Remove a list of members
case PatchOps.Remove when
operation.Path?.ToLowerInvariant() == PatchPaths.Members:
{
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
foreach (var v in GetOperationValueIds(operation.Value))
{
orgUserIds.Remove(v);
}
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
break;
}
default:
{
_logger.LogWarning("Group patch operation not handled: {OperationOp}:{OperationPath}", operation.Op, operation.Path);
break;
}
}
}
private async Task AddMembersAsync(Group group, HashSet<Guid> usersToAdd)
{
// Azure Entra ID is known to send redundant "add" requests for each existing member every time any member
// is removed. To avoid excessive load on the database, we check against the high availability replica and
// return early if they already exist.
var groupMembers = await _groupRepository.GetManyUserIdsByIdAsync(group.Id, useReadOnlyReplica: true);
if (usersToAdd.IsSubsetOf(groupMembers))
{
_logger.LogDebug("Ignoring duplicate SCIM request to add members {Members} to group {Group}", usersToAdd, group.Id);
return;
}
await _groupRepository.AddGroupUsersByIdAsync(group.Id, usersToAdd);
}
private static HashSet<Guid> GetOperationValueIds(JsonElement objArray)
{
var ids = new HashSet<Guid>();
foreach (var obj in objArray.EnumerateArray())
{
if (obj.TryGetProperty("value", out var valueProperty))
{
if (valueProperty.TryGetGuid(out var guid))
{
ids.Add(guid);
}
}
}
return ids;
}
private static bool TryGetOperationPathId(string path, out Guid pathId)
{
// Parse Guid from string like: members[value eq "{GUID}"}]
return Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out pathId);
}
}

View File

@ -1,4 +1,5 @@
using Bit.Core.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -11,15 +12,18 @@ public class PatchUserCommand : IPatchUserCommand
{ {
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationService _organizationService; private readonly IOrganizationService _organizationService;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
private readonly ILogger<PatchUserCommand> _logger; private readonly ILogger<PatchUserCommand> _logger;
public PatchUserCommand( public PatchUserCommand(
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService, IOrganizationService organizationService,
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
ILogger<PatchUserCommand> logger) ILogger<PatchUserCommand> logger)
{ {
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_organizationService = organizationService; _organizationService = organizationService;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
_logger = logger; _logger = logger;
} }
@ -71,7 +75,7 @@ public class PatchUserCommand : IPatchUserCommand
{ {
if (active && orgUser.Status == OrganizationUserStatusType.Revoked) if (active && orgUser.Status == OrganizationUserStatusType.Revoked)
{ {
await _organizationService.RestoreUserAsync(orgUser, EventSystemUser.SCIM); await _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, EventSystemUser.SCIM);
return true; return true;
} }
else if (!active && orgUser.Status != OrganizationUserStatusType.Revoked) else if (!active && orgUser.Status != OrganizationUserStatusType.Revoked)

View File

@ -10,7 +10,6 @@ public static class ScimServiceCollectionExtensions
public static void AddScimGroupCommands(this IServiceCollection services) public static void AddScimGroupCommands(this IServiceCollection services)
{ {
services.AddScoped<IPatchGroupCommand, PatchGroupCommand>(); services.AddScoped<IPatchGroupCommand, PatchGroupCommand>();
services.AddScoped<IPatchGroupCommandvNext, PatchGroupCommandvNext>();
services.AddScoped<IPostGroupCommand, PostGroupCommand>(); services.AddScoped<IPostGroupCommand, PostGroupCommand>();
services.AddScoped<IPutGroupCommand, PutGroupCommand>(); services.AddScoped<IPutGroupCommand, PutGroupCommand>();
} }

View File

@ -228,6 +228,26 @@ public class RemoveOrganizationFromProviderCommandTests
Id = "subscription_id" Id = "subscription_id"
}); });
sutProvider.GetDependency<IAutomaticTaxStrategy>()
.When(x => x.SetCreateOptions(
Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == organization.GatewayCustomerId &&
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
options.DaysUntilDue == 30 &&
options.Metadata["organizationId"] == organization.Id.ToString() &&
options.OffSession == true &&
options.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations &&
options.Items.First().Price == teamsMonthlyPlan.PasswordManager.StripeSeatPlanId &&
options.Items.First().Quantity == organization.Seats)
, Arg.Any<Customer>()))
.Do(x =>
{
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
};
});
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options => await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>

View File

@ -924,11 +924,15 @@ public class ProviderBillingServiceTests
{ {
provider.GatewaySubscriptionId = null; provider.GatewaySubscriptionId = null;
sutProvider.GetDependency<ISubscriberService>().GetCustomerOrThrow(provider).Returns(new Customer sutProvider.GetDependency<ISubscriberService>()
{ .GetCustomerOrThrow(
Id = "customer_id", provider,
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids")))
}); .Returns(new Customer
{
Id = "customer_id",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
});
var providerPlans = new List<ProviderPlan> var providerPlans = new List<ProviderPlan>
{ {
@ -975,11 +979,15 @@ public class ProviderBillingServiceTests
{ {
provider.GatewaySubscriptionId = null; provider.GatewaySubscriptionId = null;
sutProvider.GetDependency<ISubscriberService>().GetCustomerOrThrow(provider).Returns(new Customer var customer = new Customer
{ {
Id = "customer_id", Id = "customer_id",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
}); };
sutProvider.GetDependency<ISubscriberService>()
.GetCustomerOrThrow(
provider,
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))).Returns(customer);
var providerPlans = new List<ProviderPlan> var providerPlans = new List<ProviderPlan>
{ {
@ -1017,6 +1025,19 @@ public class ProviderBillingServiceTests
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IAutomaticTaxStrategy>()
.When(x => x.SetCreateOptions(
Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == "customer_id")
, Arg.Is<Customer>(p => p == customer)))
.Do(x =>
{
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
};
});
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>( sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
sub => sub =>
sub.AutomaticTax.Enabled == true && sub.AutomaticTax.Enabled == true &&

View File

@ -20,6 +20,7 @@ public class GroupsControllerPatchTests : IClassFixture<ScimApplicationFactory>,
{ {
var databaseContext = _factory.GetDatabaseContext(); var databaseContext = _factory.GetDatabaseContext();
_factory.ReinitializeDbForTests(databaseContext); _factory.ReinitializeDbForTests(databaseContext);
return Task.CompletedTask; return Task.CompletedTask;
} }

View File

@ -1,251 +0,0 @@
using System.Text.Json;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Services;
using Bit.Scim.Groups.Interfaces;
using Bit.Scim.IntegrationTest.Factories;
using Bit.Scim.Models;
using Bit.Scim.Utilities;
using Bit.Test.Common.Helpers;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
namespace Bit.Scim.IntegrationTest.Controllers.v2;
public class GroupsControllerPatchTestsvNext : IClassFixture<ScimApplicationFactory>, IAsyncLifetime
{
private readonly ScimApplicationFactory _factory;
public GroupsControllerPatchTestsvNext(ScimApplicationFactory factory)
{
_factory = factory;
// Enable the feature flag for new PatchGroupsCommand and stub out the old command to be safe
_factory.SubstituteService((IFeatureService featureService)
=> featureService.IsEnabled(FeatureFlagKeys.ShortcutDuplicatePatchRequests).Returns(true));
_factory.SubstituteService((IPatchGroupCommand patchGroupCommand)
=> patchGroupCommand.PatchGroupAsync(Arg.Any<Organization>(), Arg.Any<Guid>(), Arg.Any<ScimPatchModel>())
.ThrowsAsync(new Exception("This test suite should be testing the vNext command, but the existing command was called.")));
}
public Task InitializeAsync()
{
var databaseContext = _factory.GetDatabaseContext();
_factory.ReinitializeDbForTests(databaseContext);
return Task.CompletedTask;
}
Task IAsyncLifetime.DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task Patch_ReplaceDisplayName_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var newDisplayName = "Patch Display Name";
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "replace",
Value = JsonDocument.Parse($"{{\"displayName\":\"{newDisplayName}\"}}").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
var group = databaseContext.Groups.FirstOrDefault(g => g.Id == groupId);
Assert.Equal(newDisplayName, group.Name);
Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount, databaseContext.GroupUsers.Count());
Assert.True(databaseContext.GroupUsers.Any(gu => gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId1));
Assert.True(databaseContext.GroupUsers.Any(gu => gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId4));
}
[Fact]
public async Task Patch_ReplaceMembers_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "replace",
Path = "members",
Value = JsonDocument.Parse($"[{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId2}\"}}]").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.Single(databaseContext.GroupUsers);
Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount - 1, databaseContext.GroupUsers.Count());
var groupUser = databaseContext.GroupUsers.FirstOrDefault();
Assert.Equal(ScimApplicationFactory.TestOrganizationUserId2, groupUser.OrganizationUserId);
}
[Fact]
public async Task Patch_AddSingleMember_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "add",
Path = $"members[value eq \"{ScimApplicationFactory.TestOrganizationUserId2}\"]",
Value = JsonDocument.Parse("{}").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount + 1, databaseContext.GroupUsers.Count());
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId1));
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId2));
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId4));
}
[Fact]
public async Task Patch_AddListMembers_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId2;
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "add",
Path = "members",
Value = JsonDocument.Parse($"[{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId2}\"}},{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId3}\"}}]").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId2));
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId3));
}
[Fact]
public async Task Patch_RemoveSingleMember_ReplaceDisplayName_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var newDisplayName = "Patch Display Name";
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "remove",
Path = $"members[value eq \"{ScimApplicationFactory.TestOrganizationUserId1}\"]",
Value = JsonDocument.Parse("{}").RootElement
},
new ScimPatchModel.OperationModel
{
Op = "replace",
Value = JsonDocument.Parse($"{{\"displayName\":\"{newDisplayName}\"}}").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount - 1, databaseContext.GroupUsers.Count());
Assert.Equal(ScimApplicationFactory.InitialGroupCount, databaseContext.Groups.Count());
var group = databaseContext.Groups.FirstOrDefault(g => g.Id == groupId);
Assert.Equal(newDisplayName, group.Name);
}
[Fact]
public async Task Patch_RemoveListMembers_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "remove",
Path = "members",
Value = JsonDocument.Parse($"[{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId1}\"}}, {{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId4}\"}}]").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.Empty(databaseContext.GroupUsers);
}
[Fact]
public async Task Patch_NotFound()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = Guid.NewGuid();
var inputModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>(),
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var expectedResponse = new ScimErrorResponseModel
{
Status = StatusCodes.Status404NotFound,
Detail = "Group not found.",
Schemas = new List<string> { ScimConstants.Scim2SchemaError }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode);
var responseModel = JsonSerializer.Deserialize<ScimErrorResponseModel>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
}
}

View File

@ -1,15 +1,18 @@
using System.Text.Json; using System.Text.Json;
using AutoFixture;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Scim.Groups; using Bit.Scim.Groups;
using Bit.Scim.Models; using Bit.Scim.Models;
using Bit.Scim.Utilities; using Bit.Scim.Utilities;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.Extensions.Logging;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
@ -20,19 +23,16 @@ public class PatchGroupCommandTests
{ {
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task PatchGroup_ReplaceListMembers_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, IEnumerable<Guid> userIds) public async Task PatchGroup_ReplaceListMembers_Success(SutProvider<PatchGroupCommand> sutProvider,
Organization organization, Group group, IEnumerable<Guid> userIds)
{ {
group.OrganizationId = organization.Id; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>() var scimPatchModel = new ScimPatchModel
.GetByIdAsync(group.Id)
.Returns(group);
var scimPatchModel = new Models.ScimPatchModel
{ {
Operations = new List<ScimPatchModel.OperationModel> Operations = new List<ScimPatchModel.OperationModel>
{ {
new ScimPatchModel.OperationModel new()
{ {
Op = "replace", Op = "replace",
Path = "members", Path = "members",
@ -42,26 +42,31 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser } Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
}; };
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel); await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().Received(1).UpdateUsersAsync(group.Id, Arg.Is<IEnumerable<Guid>>(arg => arg.All(id => userIds.Contains(id)))); await sutProvider.GetDependency<IGroupRepository>().Received(1).UpdateUsersAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == userIds.Count() &&
arg.ToHashSet().SetEquals(userIds)));
} }
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task PatchGroup_ReplaceDisplayNameFromPath_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, string displayName) public async Task PatchGroup_ReplaceDisplayNameFromPath_Success(
SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, string displayName)
{ {
group.OrganizationId = organization.Id; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>() sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(group.Id) .GetByIdAsync(organization.Id)
.Returns(group); .Returns(organization);
var scimPatchModel = new Models.ScimPatchModel var scimPatchModel = new ScimPatchModel
{ {
Operations = new List<ScimPatchModel.OperationModel> Operations = new List<ScimPatchModel.OperationModel>
{ {
new ScimPatchModel.OperationModel new()
{ {
Op = "replace", Op = "replace",
Path = "displayname", Path = "displayname",
@ -71,27 +76,55 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser } Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
}; };
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel); await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IUpdateGroupCommand>().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM); await sutProvider.GetDependency<IUpdateGroupCommand>().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
Assert.Equal(displayName, group.Name); Assert.Equal(displayName, group.Name);
} }
[Theory]
[BitAutoData]
public async Task PatchGroup_ReplaceDisplayNameFromPath_MissingOrganization_Throws(
SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, string displayName)
{
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns((Organization)null);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "replace",
Path = "displayname",
Value = JsonDocument.Parse($"\"{displayName}\"").RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.PatchGroupAsync(group, scimPatchModel));
}
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task PatchGroup_ReplaceDisplayNameFromValueObject_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, string displayName) public async Task PatchGroup_ReplaceDisplayNameFromValueObject_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, string displayName)
{ {
group.OrganizationId = organization.Id; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>() sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(group.Id) .GetByIdAsync(organization.Id)
.Returns(group); .Returns(organization);
var scimPatchModel = new Models.ScimPatchModel var scimPatchModel = new ScimPatchModel
{ {
Operations = new List<ScimPatchModel.OperationModel> Operations = new List<ScimPatchModel.OperationModel>
{ {
new ScimPatchModel.OperationModel new()
{ {
Op = "replace", Op = "replace",
Value = JsonDocument.Parse($"{{\"displayName\":\"{displayName}\"}}").RootElement Value = JsonDocument.Parse($"{{\"displayName\":\"{displayName}\"}}").RootElement
@ -100,12 +133,39 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser } Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
}; };
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel); await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IUpdateGroupCommand>().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM); await sutProvider.GetDependency<IUpdateGroupCommand>().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
Assert.Equal(displayName, group.Name); Assert.Equal(displayName, group.Name);
} }
[Theory]
[BitAutoData]
public async Task PatchGroup_ReplaceDisplayNameFromValueObject_MissingOrganization_Throws(
SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, string displayName)
{
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns((Organization)null);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "replace",
Value = JsonDocument.Parse($"{{\"displayName\":\"{displayName}\"}}").RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.PatchGroupAsync(group, scimPatchModel));
}
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task PatchGroup_AddSingleMember_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, ICollection<Guid> existingMembers, Guid userId) public async Task PatchGroup_AddSingleMember_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, ICollection<Guid> existingMembers, Guid userId)
@ -113,18 +173,14 @@ public class PatchGroupCommandTests
group.OrganizationId = organization.Id; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>() sutProvider.GetDependency<IGroupRepository>()
.GetByIdAsync(group.Id) .GetManyUserIdsByIdAsync(group.Id, true)
.Returns(group);
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id)
.Returns(existingMembers); .Returns(existingMembers);
var scimPatchModel = new Models.ScimPatchModel var scimPatchModel = new ScimPatchModel
{ {
Operations = new List<ScimPatchModel.OperationModel> Operations = new List<ScimPatchModel.OperationModel>
{ {
new ScimPatchModel.OperationModel new()
{ {
Op = "add", Op = "add",
Path = $"members[value eq \"{userId}\"]", Path = $"members[value eq \"{userId}\"]",
@ -133,9 +189,47 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser } Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
}; };
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel); await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().Received(1).UpdateUsersAsync(group.Id, Arg.Is<IEnumerable<Guid>>(arg => arg.All(id => existingMembers.Append(userId).Contains(id)))); await sutProvider.GetDependency<IGroupRepository>().Received(1).AddGroupUsersByIdAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg => arg.Single() == userId));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_AddSingleMember_ReturnsEarlyIfAlreadyInGroup(
SutProvider<PatchGroupCommand> sutProvider,
Organization organization,
Group group,
ICollection<Guid> existingMembers)
{
// User being added is already in group
var userId = existingMembers.First();
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id, true)
.Returns(existingMembers);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "add",
Path = $"members[value eq \"{userId}\"]",
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>()
.DidNotReceiveWithAnyArgs()
.AddGroupUsersByIdAsync(default, default);
} }
[Theory] [Theory]
@ -145,18 +239,14 @@ public class PatchGroupCommandTests
group.OrganizationId = organization.Id; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>() sutProvider.GetDependency<IGroupRepository>()
.GetByIdAsync(group.Id) .GetManyUserIdsByIdAsync(group.Id, true)
.Returns(group);
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id)
.Returns(existingMembers); .Returns(existingMembers);
var scimPatchModel = new Models.ScimPatchModel var scimPatchModel = new ScimPatchModel
{ {
Operations = new List<ScimPatchModel.OperationModel> Operations = new List<ScimPatchModel.OperationModel>
{ {
new ScimPatchModel.OperationModel new()
{ {
Op = "add", Op = "add",
Path = $"members", Path = $"members",
@ -166,9 +256,101 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser } Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
}; };
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel); await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().Received(1).UpdateUsersAsync(group.Id, Arg.Is<IEnumerable<Guid>>(arg => arg.All(id => existingMembers.Concat(userIds).Contains(id)))); await sutProvider.GetDependency<IGroupRepository>().Received(1).AddGroupUsersByIdAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == userIds.Count &&
arg.ToHashSet().SetEquals(userIds)));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_AddListMembers_IgnoresDuplicatesInRequest(
SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group,
ICollection<Guid> existingMembers)
{
// Create 3 userIds
var fixture = new Fixture { RepeatCount = 3 };
var userIds = fixture.CreateMany<Guid>().ToList();
// Copy the list and add a duplicate
var userIdsWithDuplicate = userIds.Append(userIds.First()).ToList();
Assert.Equal(4, userIdsWithDuplicate.Count);
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id, true)
.Returns(existingMembers);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "add",
Path = $"members",
Value = JsonDocument.Parse(JsonSerializer
.Serialize(userIdsWithDuplicate
.Select(uid => new { value = uid })
.ToArray())).RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().Received(1).AddGroupUsersByIdAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == 3 &&
arg.ToHashSet().SetEquals(userIds)));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_AddListMembers_SuccessIfOnlySomeUsersAreInGroup(
SutProvider<PatchGroupCommand> sutProvider,
Organization organization, Group group,
ICollection<Guid> existingMembers,
ICollection<Guid> userIds)
{
// A user is already in the group, but some still need to be added
userIds.Add(existingMembers.First());
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id, true)
.Returns(existingMembers);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "add",
Path = $"members",
Value = JsonDocument.Parse(JsonSerializer.Serialize(userIds.Select(uid => new { value = uid }).ToArray())).RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>()
.Received(1)
.AddGroupUsersByIdAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == userIds.Count &&
arg.ToHashSet().SetEquals(userIds)));
} }
[Theory] [Theory]
@ -177,10 +359,6 @@ public class PatchGroupCommandTests
{ {
group.OrganizationId = organization.Id; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetByIdAsync(group.Id)
.Returns(group);
var scimPatchModel = new Models.ScimPatchModel var scimPatchModel = new Models.ScimPatchModel
{ {
Operations = new List<ScimPatchModel.OperationModel> Operations = new List<ScimPatchModel.OperationModel>
@ -194,21 +372,19 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser } Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
}; };
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel); await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupService>().Received(1).DeleteUserAsync(group, userId, EventSystemUser.SCIM); await sutProvider.GetDependency<IGroupService>().Received(1).DeleteUserAsync(group, userId, EventSystemUser.SCIM);
} }
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task PatchGroup_RemoveListMembers_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, ICollection<Guid> existingMembers) public async Task PatchGroup_RemoveListMembers_Success(SutProvider<PatchGroupCommand> sutProvider,
Organization organization, Group group, ICollection<Guid> existingMembers)
{ {
List<Guid> usersToRemove = [existingMembers.First(), existingMembers.Skip(1).First()];
group.OrganizationId = organization.Id; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetByIdAsync(group.Id)
.Returns(group);
sutProvider.GetDependency<IGroupRepository>() sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id) .GetManyUserIdsByIdAsync(group.Id)
.Returns(existingMembers); .Returns(existingMembers);
@ -217,30 +393,58 @@ public class PatchGroupCommandTests
{ {
Operations = new List<ScimPatchModel.OperationModel> Operations = new List<ScimPatchModel.OperationModel>
{ {
new ScimPatchModel.OperationModel new()
{ {
Op = "remove", Op = "remove",
Path = $"members", Path = $"members",
Value = JsonDocument.Parse(JsonSerializer.Serialize(existingMembers.Select(uid => new { value = uid }).ToArray())).RootElement Value = JsonDocument.Parse(JsonSerializer.Serialize(usersToRemove.Select(uid => new { value = uid }).ToArray())).RootElement
} }
}, },
Schemas = new List<string> { ScimConstants.Scim2SchemaUser } Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
}; };
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel); await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().Received(1).UpdateUsersAsync(group.Id, Arg.Is<IEnumerable<Guid>>(arg => arg.All(id => existingMembers.Contains(id)))); var expectedRemainingUsers = existingMembers.Skip(2).ToList();
await sutProvider.GetDependency<IGroupRepository>()
.Received(1)
.UpdateUsersAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == expectedRemainingUsers.Count &&
arg.ToHashSet().SetEquals(expectedRemainingUsers)));
} }
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task PatchGroup_NoAction_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group) public async Task PatchGroup_InvalidOperation_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group)
{ {
group.OrganizationId = organization.Id; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>() var scimPatchModel = new Models.ScimPatchModel
.GetByIdAsync(group.Id) {
.Returns(group); Operations = [new ScimPatchModel.OperationModel { Op = "invalid operation" }],
Schemas = [ScimConstants.Scim2SchemaUser]
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
// Assert: no operation performed
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().UpdateUsersAsync(default, default);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyUserIdsByIdAsync(default);
await sutProvider.GetDependency<IUpdateGroupCommand>().DidNotReceiveWithAnyArgs().UpdateGroupAsync(default, default);
await sutProvider.GetDependency<IGroupService>().DidNotReceiveWithAnyArgs().DeleteUserAsync(default, default);
// Assert: logging
sutProvider.GetDependency<ILogger<PatchGroupCommand>>().ReceivedWithAnyArgs().LogWarning(default);
}
[Theory]
[BitAutoData]
public async Task PatchGroup_NoOperation_Success(
SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group)
{
group.OrganizationId = organization.Id;
var scimPatchModel = new Models.ScimPatchModel var scimPatchModel = new Models.ScimPatchModel
{ {
@ -248,45 +452,11 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser } Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
}; };
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel); await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().UpdateUsersAsync(default, default); await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().UpdateUsersAsync(default, default);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyUserIdsByIdAsync(default); await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyUserIdsByIdAsync(default);
await sutProvider.GetDependency<IUpdateGroupCommand>().DidNotReceiveWithAnyArgs().UpdateGroupAsync(default, default); await sutProvider.GetDependency<IUpdateGroupCommand>().DidNotReceiveWithAnyArgs().UpdateGroupAsync(default, default);
await sutProvider.GetDependency<IGroupService>().DidNotReceiveWithAnyArgs().DeleteUserAsync(default, default); await sutProvider.GetDependency<IGroupService>().DidNotReceiveWithAnyArgs().DeleteUserAsync(default, default);
} }
[Theory]
[BitAutoData]
public async Task PatchGroup_NotFound_Throws(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Guid groupId)
{
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>(),
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.PatchGroupAsync(organization, groupId, scimPatchModel));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_MismatchingOrganizationId_Throws(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Guid groupId)
{
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>(),
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
sutProvider.GetDependency<IGroupRepository>()
.GetByIdAsync(groupId)
.Returns(new Group
{
Id = groupId,
OrganizationId = Guid.NewGuid()
});
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.PatchGroupAsync(organization, groupId, scimPatchModel));
}
} }

View File

@ -1,381 +0,0 @@
using System.Text.Json;
using AutoFixture;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Scim.Groups;
using Bit.Scim.Models;
using Bit.Scim.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Scim.Test.Groups;
[SutProviderCustomize]
public class PatchGroupCommandvNextTests
{
[Theory]
[BitAutoData]
public async Task PatchGroup_ReplaceListMembers_Success(SutProvider<PatchGroupCommandvNext> sutProvider,
Organization organization, Group group, IEnumerable<Guid> userIds)
{
group.OrganizationId = organization.Id;
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "replace",
Path = "members",
Value = JsonDocument.Parse(JsonSerializer.Serialize(userIds.Select(uid => new { value = uid }).ToArray())).RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().Received(1).UpdateUsersAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == userIds.Count() &&
arg.ToHashSet().SetEquals(userIds)));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_ReplaceDisplayNameFromPath_Success(
SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group, string displayName)
{
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "replace",
Path = "displayname",
Value = JsonDocument.Parse($"\"{displayName}\"").RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IUpdateGroupCommand>().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
Assert.Equal(displayName, group.Name);
}
[Theory]
[BitAutoData]
public async Task PatchGroup_ReplaceDisplayNameFromValueObject_Success(SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group, string displayName)
{
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "replace",
Value = JsonDocument.Parse($"{{\"displayName\":\"{displayName}\"}}").RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IUpdateGroupCommand>().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
Assert.Equal(displayName, group.Name);
}
[Theory]
[BitAutoData]
public async Task PatchGroup_AddSingleMember_Success(SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group, ICollection<Guid> existingMembers, Guid userId)
{
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id, true)
.Returns(existingMembers);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "add",
Path = $"members[value eq \"{userId}\"]",
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().Received(1).AddGroupUsersByIdAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg => arg.Single() == userId));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_AddSingleMember_ReturnsEarlyIfAlreadyInGroup(
SutProvider<PatchGroupCommandvNext> sutProvider,
Organization organization,
Group group,
ICollection<Guid> existingMembers)
{
// User being added is already in group
var userId = existingMembers.First();
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id, true)
.Returns(existingMembers);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "add",
Path = $"members[value eq \"{userId}\"]",
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>()
.DidNotReceiveWithAnyArgs()
.AddGroupUsersByIdAsync(default, default);
}
[Theory]
[BitAutoData]
public async Task PatchGroup_AddListMembers_Success(SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group, ICollection<Guid> existingMembers, ICollection<Guid> userIds)
{
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id, true)
.Returns(existingMembers);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "add",
Path = $"members",
Value = JsonDocument.Parse(JsonSerializer.Serialize(userIds.Select(uid => new { value = uid }).ToArray())).RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().Received(1).AddGroupUsersByIdAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == userIds.Count &&
arg.ToHashSet().SetEquals(userIds)));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_AddListMembers_IgnoresDuplicatesInRequest(
SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group,
ICollection<Guid> existingMembers)
{
// Create 3 userIds
var fixture = new Fixture { RepeatCount = 3 };
var userIds = fixture.CreateMany<Guid>().ToList();
// Copy the list and add a duplicate
var userIdsWithDuplicate = userIds.Append(userIds.First()).ToList();
Assert.Equal(4, userIdsWithDuplicate.Count);
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id, true)
.Returns(existingMembers);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "add",
Path = $"members",
Value = JsonDocument.Parse(JsonSerializer
.Serialize(userIdsWithDuplicate
.Select(uid => new { value = uid })
.ToArray())).RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().Received(1).AddGroupUsersByIdAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == 3 &&
arg.ToHashSet().SetEquals(userIds)));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_AddListMembers_SuccessIfOnlySomeUsersAreInGroup(
SutProvider<PatchGroupCommandvNext> sutProvider,
Organization organization, Group group,
ICollection<Guid> existingMembers,
ICollection<Guid> userIds)
{
// A user is already in the group, but some still need to be added
userIds.Add(existingMembers.First());
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id, true)
.Returns(existingMembers);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "add",
Path = $"members",
Value = JsonDocument.Parse(JsonSerializer.Serialize(userIds.Select(uid => new { value = uid }).ToArray())).RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>()
.Received(1)
.AddGroupUsersByIdAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == userIds.Count &&
arg.ToHashSet().SetEquals(userIds)));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_RemoveSingleMember_Success(SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group, Guid userId)
{
group.OrganizationId = organization.Id;
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new ScimPatchModel.OperationModel
{
Op = "remove",
Path = $"members[value eq \"{userId}\"]",
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupService>().Received(1).DeleteUserAsync(group, userId, EventSystemUser.SCIM);
}
[Theory]
[BitAutoData]
public async Task PatchGroup_RemoveListMembers_Success(SutProvider<PatchGroupCommandvNext> sutProvider,
Organization organization, Group group, ICollection<Guid> existingMembers)
{
List<Guid> usersToRemove = [existingMembers.First(), existingMembers.Skip(1).First()];
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id)
.Returns(existingMembers);
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "remove",
Path = $"members",
Value = JsonDocument.Parse(JsonSerializer.Serialize(usersToRemove.Select(uid => new { value = uid }).ToArray())).RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
var expectedRemainingUsers = existingMembers.Skip(2).ToList();
await sutProvider.GetDependency<IGroupRepository>()
.Received(1)
.UpdateUsersAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == expectedRemainingUsers.Count &&
arg.ToHashSet().SetEquals(expectedRemainingUsers)));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_NoAction_Success(
SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group)
{
group.OrganizationId = organization.Id;
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>(),
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().UpdateUsersAsync(default, default);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyUserIdsByIdAsync(default);
await sutProvider.GetDependency<IUpdateGroupCommand>().DidNotReceiveWithAnyArgs().UpdateGroupAsync(default, default);
await sutProvider.GetDependency<IGroupService>().DidNotReceiveWithAnyArgs().DeleteUserAsync(default, default);
}
}

View File

@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -43,7 +44,7 @@ public class PatchUserCommandTests
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
await sutProvider.GetDependency<IOrganizationService>().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM); await sutProvider.GetDependency<IRestoreOrganizationUserCommand>().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM);
} }
[Theory] [Theory]
@ -71,7 +72,7 @@ public class PatchUserCommandTests
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
await sutProvider.GetDependency<IOrganizationService>().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM); await sutProvider.GetDependency<IRestoreOrganizationUserCommand>().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM);
} }
[Theory] [Theory]
@ -147,7 +148,7 @@ public class PatchUserCommandTests
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
await sutProvider.GetDependency<IOrganizationService>().DidNotReceiveWithAnyArgs().RestoreUserAsync(default, EventSystemUser.SCIM); await sutProvider.GetDependency<IRestoreOrganizationUserCommand>().DidNotReceiveWithAnyArgs().RestoreUserAsync(default, EventSystemUser.SCIM);
await sutProvider.GetDependency<IOrganizationService>().DidNotReceiveWithAnyArgs().RevokeUserAsync(default, EventSystemUser.SCIM); await sutProvider.GetDependency<IOrganizationService>().DidNotReceiveWithAnyArgs().RevokeUserAsync(default, EventSystemUser.SCIM);
} }

90
perf/load/sync.js Normal file
View File

@ -0,0 +1,90 @@
import http from "k6/http";
import { check, fail } from "k6";
import { authenticate } from "./helpers/auth.js";
const IDENTITY_URL = __ENV.IDENTITY_URL;
const API_URL = __ENV.API_URL;
const CLIENT_ID = __ENV.CLIENT_ID;
const AUTH_USERNAME = __ENV.AUTH_USER_EMAIL;
const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH;
export const options = {
ext: {
loadimpact: {
projectID: 3639465,
name: "Sync",
},
},
scenarios: {
constant_load: {
executor: "constant-arrival-rate",
rate: 30,
timeUnit: "1m", // 0.5 requests / second
duration: "10m",
preAllocatedVUs: 5,
},
ramping_load: {
executor: "ramping-arrival-rate",
startRate: 30,
timeUnit: "1m", // 0.5 requests / second to start
stages: [
{ duration: "30s", target: 30 },
{ duration: "2m", target: 75 },
{ duration: "1m", target: 60 },
{ duration: "2m", target: 100 },
{ duration: "2m", target: 90 },
{ duration: "1m", target: 120 },
{ duration: "30s", target: 150 },
{ duration: "30s", target: 60 },
{ duration: "30s", target: 0 },
],
preAllocatedVUs: 20,
},
},
thresholds: {
http_req_failed: ["rate<0.01"],
http_req_duration: ["p(95)<1200"],
},
};
export function setup() {
return authenticate(IDENTITY_URL, CLIENT_ID, AUTH_USERNAME, AUTH_PASSWORD);
}
export default function (data) {
const params = {
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${data.access_token}`,
"X-ClientId": CLIENT_ID,
},
tags: { name: "Sync" },
};
const excludeDomains = Math.random() > 0.5;
const syncRes = http.get(`${API_URL}/sync?excludeDomains=${excludeDomains}`, params);
if (
!check(syncRes, {
"sync status is 200": (r) => r.status === 200,
})
) {
console.error(`Sync failed with status ${syncRes.status}: ${syncRes.body}`);
fail("sync status code was *not* 200");
}
if (syncRes.status === 200) {
const syncJson = syncRes.json();
check(syncJson, {
"sync response has profile": (j) => j.profile !== undefined,
"sync response has folders": (j) => Array.isArray(j.folders),
"sync response has collections": (j) => Array.isArray(j.collections),
"sync response has ciphers": (j) => Array.isArray(j.ciphers),
"sync response has policies": (j) => Array.isArray(j.policies),
"sync response has sends": (j) => Array.isArray(j.sends),
"sync response has correct object type": (j) => j.object === "sync"
});
}
}

View File

@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
@ -61,6 +62,7 @@ public class OrganizationUsersController : Controller
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand; private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
public OrganizationUsersController( public OrganizationUsersController(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -86,7 +88,8 @@ public class OrganizationUsersController : Controller
IPolicyRequirementQuery policyRequirementQuery, IPolicyRequirementQuery policyRequirementQuery,
IFeatureService featureService, IFeatureService featureService,
IPricingClient pricingClient, IPricingClient pricingClient,
IConfirmOrganizationUserCommand confirmOrganizationUserCommand) IConfirmOrganizationUserCommand confirmOrganizationUserCommand,
IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@ -112,6 +115,7 @@ public class OrganizationUsersController : Controller
_featureService = featureService; _featureService = featureService;
_pricingClient = pricingClient; _pricingClient = pricingClient;
_confirmOrganizationUserCommand = confirmOrganizationUserCommand; _confirmOrganizationUserCommand = confirmOrganizationUserCommand;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
} }
[HttpGet("{id}")] [HttpGet("{id}")]
@ -630,14 +634,14 @@ public class OrganizationUsersController : Controller
[HttpPut("{id}/restore")] [HttpPut("{id}/restore")]
public async Task RestoreAsync(Guid orgId, Guid id) public async Task RestoreAsync(Guid orgId, Guid id)
{ {
await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _organizationService.RestoreUserAsync(orgUser, userId)); await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId));
} }
[HttpPatch("restore")] [HttpPatch("restore")]
[HttpPut("restore")] [HttpPut("restore")]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{ {
return await RestoreOrRevokeUsersAsync(orgId, model, (orgId, orgUserIds, restoringUserId) => _organizationService.RestoreUsersAsync(orgId, orgUserIds, restoringUserId, _userService)); return await RestoreOrRevokeUsersAsync(orgId, model, (orgId, orgUserIds, restoringUserId) => _restoreOrganizationUserCommand.RestoreUsersAsync(orgId, orgUserIds, restoringUserId, _userService));
} }
[HttpPatch("enable-secrets-manager")] [HttpPatch("enable-secrets-manager")]

View File

@ -13,7 +13,17 @@ public static class PolicyDetailResponses
{ {
throw new ArgumentException($"'{nameof(policy)}' must be of type '{nameof(PolicyType.SingleOrg)}'.", nameof(policy)); throw new ArgumentException($"'{nameof(policy)}' must be of type '{nameof(PolicyType.SingleOrg)}'.", nameof(policy));
} }
return new PolicyDetailResponseModel(policy, await CanToggleState());
return new PolicyDetailResponseModel(policy, !await hasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policy.OrganizationId)); async Task<bool> CanToggleState()
{
if (!await hasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policy.OrganizationId))
{
return true;
}
return !policy.Enabled;
}
} }
} }

View File

@ -355,6 +355,7 @@ public class AccountsController : Controller
throw new BadRequestException(ModelState); throw new BadRequestException(ModelState);
} }
[Obsolete("Replaced by the safer rotate-user-account-keys endpoint.")]
[HttpPost("key")] [HttpPost("key")]
public async Task PostKey([FromBody] UpdateKeyRequestModel model) public async Task PostKey([FromBody] UpdateKeyRequestModel model)
{ {

View File

@ -0,0 +1,66 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Utilities;
namespace Bit.Api.Auth.Models.Request.Accounts;
public class MasterPasswordUnlockDataModel : IValidatableObject
{
public required KdfType KdfType { get; set; }
public required int KdfIterations { get; set; }
public int? KdfMemory { get; set; }
public int? KdfParallelism { get; set; }
[StrictEmailAddress]
[StringLength(256)]
public required string Email { get; set; }
[StringLength(300)]
public required string MasterKeyAuthenticationHash { get; set; }
[EncryptedString] public required string MasterKeyEncryptedUserKey { get; set; }
[StringLength(50)]
public string? MasterPasswordHint { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (KdfType == KdfType.PBKDF2_SHA256)
{
if (KdfMemory.HasValue || KdfParallelism.HasValue)
{
yield return new ValidationResult("KdfMemory and KdfParallelism must be null for PBKDF2_SHA256", new[] { nameof(KdfMemory), nameof(KdfParallelism) });
}
}
else if (KdfType == KdfType.Argon2id)
{
if (!KdfMemory.HasValue || !KdfParallelism.HasValue)
{
yield return new ValidationResult("KdfMemory and KdfParallelism must have values for Argon2id", new[] { nameof(KdfMemory), nameof(KdfParallelism) });
}
}
else
{
yield return new ValidationResult("Invalid KdfType", new[] { nameof(KdfType) });
}
}
public MasterPasswordUnlockData ToUnlockData()
{
var data = new MasterPasswordUnlockData
{
KdfType = KdfType,
KdfIterations = KdfIterations,
KdfMemory = KdfMemory,
KdfParallelism = KdfParallelism,
Email = Email,
MasterKeyAuthenticationHash = MasterKeyAuthenticationHash,
MasterKeyEncryptedUserKey = MasterKeyEncryptedUserKey,
MasterPasswordHint = MasterPasswordHint
};
return data;
}
}

View File

@ -76,6 +76,13 @@ public class OrganizationSponsorshipsController : Controller
public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model) public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model)
{ {
var sponsoringOrg = await _organizationRepository.GetByIdAsync(sponsoringOrgId); var sponsoringOrg = await _organizationRepository.GetByIdAsync(sponsoringOrgId);
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId,
PolicyType.FreeFamiliesSponsorshipPolicy);
if (freeFamiliesSponsorshipPolicy?.Enabled == true)
{
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
}
var sponsorship = await _createSponsorshipCommand.CreateSponsorshipAsync( var sponsorship = await _createSponsorshipCommand.CreateSponsorshipAsync(
sponsoringOrg, sponsoringOrg,
@ -89,6 +96,14 @@ public class OrganizationSponsorshipsController : Controller
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public async Task ResendSponsorshipOffer(Guid sponsoringOrgId) public async Task ResendSponsorshipOffer(Guid sponsoringOrgId)
{ {
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId,
PolicyType.FreeFamiliesSponsorshipPolicy);
if (freeFamiliesSponsorshipPolicy?.Enabled == true)
{
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
}
var sponsoringOrgUser = await _organizationUserRepository var sponsoringOrgUser = await _organizationUserRepository
.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default); .GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default);
@ -135,6 +150,14 @@ public class OrganizationSponsorshipsController : Controller
throw new BadRequestException("Can only redeem sponsorship for an organization you own."); throw new BadRequestException("Can only redeem sponsorship for an organization you own.");
} }
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(
model.SponsoredOrganizationId, PolicyType.FreeFamiliesSponsorshipPolicy);
if (freeFamiliesSponsorshipPolicy?.Enabled == true)
{
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
}
await _setUpSponsorshipCommand.SetUpSponsorshipAsync( await _setUpSponsorshipCommand.SetUpSponsorshipAsync(
sponsorship, sponsorship,
await _organizationRepository.GetByIdAsync(model.SponsoredOrganizationId)); await _organizationRepository.GetByIdAsync(model.SponsoredOrganizationId));

View File

@ -1,10 +1,23 @@
#nullable enable #nullable enable
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.Requests;
using Bit.Api.KeyManagement.Validators;
using Bit.Api.Tools.Models.Request;
using Bit.Api.Vault.Models.Request;
using Bit.Core; using Bit.Core;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Commands.Interfaces; using Bit.Core.KeyManagement.Commands.Interfaces;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.UserKey;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -19,18 +32,45 @@ public class AccountsKeyManagementController : Controller
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IRegenerateUserAsymmetricKeysCommand _regenerateUserAsymmetricKeysCommand; private readonly IRegenerateUserAsymmetricKeysCommand _regenerateUserAsymmetricKeysCommand;
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly IRotateUserAccountKeysCommand _rotateUserAccountKeysCommand;
private readonly IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> _cipherValidator;
private readonly IRotationValidator<IEnumerable<FolderWithIdRequestModel>, IEnumerable<Folder>> _folderValidator;
private readonly IRotationValidator<IEnumerable<SendWithIdRequestModel>, IReadOnlyList<Send>> _sendValidator;
private readonly IRotationValidator<IEnumerable<EmergencyAccessWithIdRequestModel>, IEnumerable<EmergencyAccess>>
_emergencyAccessValidator;
private readonly IRotationValidator<IEnumerable<ResetPasswordWithOrgIdRequestModel>,
IReadOnlyList<OrganizationUser>>
_organizationUserValidator;
private readonly IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>
_webauthnKeyValidator;
public AccountsKeyManagementController(IUserService userService, public AccountsKeyManagementController(IUserService userService,
IFeatureService featureService, IFeatureService featureService,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IEmergencyAccessRepository emergencyAccessRepository, IEmergencyAccessRepository emergencyAccessRepository,
IRegenerateUserAsymmetricKeysCommand regenerateUserAsymmetricKeysCommand) IRegenerateUserAsymmetricKeysCommand regenerateUserAsymmetricKeysCommand,
IRotateUserAccountKeysCommand rotateUserKeyCommandV2,
IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> cipherValidator,
IRotationValidator<IEnumerable<FolderWithIdRequestModel>, IEnumerable<Folder>> folderValidator,
IRotationValidator<IEnumerable<SendWithIdRequestModel>, IReadOnlyList<Send>> sendValidator,
IRotationValidator<IEnumerable<EmergencyAccessWithIdRequestModel>, IEnumerable<EmergencyAccess>>
emergencyAccessValidator,
IRotationValidator<IEnumerable<ResetPasswordWithOrgIdRequestModel>, IReadOnlyList<OrganizationUser>>
organizationUserValidator,
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>> webAuthnKeyValidator)
{ {
_userService = userService; _userService = userService;
_featureService = featureService; _featureService = featureService;
_regenerateUserAsymmetricKeysCommand = regenerateUserAsymmetricKeysCommand; _regenerateUserAsymmetricKeysCommand = regenerateUserAsymmetricKeysCommand;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_emergencyAccessRepository = emergencyAccessRepository; _emergencyAccessRepository = emergencyAccessRepository;
_rotateUserAccountKeysCommand = rotateUserKeyCommandV2;
_cipherValidator = cipherValidator;
_folderValidator = folderValidator;
_sendValidator = sendValidator;
_emergencyAccessValidator = emergencyAccessValidator;
_organizationUserValidator = organizationUserValidator;
_webauthnKeyValidator = webAuthnKeyValidator;
} }
[HttpPost("regenerate-keys")] [HttpPost("regenerate-keys")]
@ -47,4 +87,45 @@ public class AccountsKeyManagementController : Controller
await _regenerateUserAsymmetricKeysCommand.RegenerateKeysAsync(request.ToUserAsymmetricKeys(user.Id), await _regenerateUserAsymmetricKeysCommand.RegenerateKeysAsync(request.ToUserAsymmetricKeys(user.Id),
usersOrganizationAccounts, designatedEmergencyAccess); usersOrganizationAccounts, designatedEmergencyAccess);
} }
[HttpPost("rotate-user-account-keys")]
public async Task RotateUserAccountKeysAsync([FromBody] RotateUserAccountKeysAndDataRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var dataModel = new RotateUserAccountKeysData
{
OldMasterKeyAuthenticationHash = model.OldMasterKeyAuthenticationHash,
UserKeyEncryptedAccountPrivateKey = model.AccountKeys.UserKeyEncryptedAccountPrivateKey,
AccountPublicKey = model.AccountKeys.AccountPublicKey,
MasterPasswordUnlockData = model.AccountUnlockData.MasterPasswordUnlockData.ToUnlockData(),
EmergencyAccesses = await _emergencyAccessValidator.ValidateAsync(user, model.AccountUnlockData.EmergencyAccessUnlockData),
OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.AccountUnlockData.OrganizationAccountRecoveryUnlockData),
WebAuthnKeys = await _webauthnKeyValidator.ValidateAsync(user, model.AccountUnlockData.PasskeyUnlockData),
Ciphers = await _cipherValidator.ValidateAsync(user, model.AccountData.Ciphers),
Folders = await _folderValidator.ValidateAsync(user, model.AccountData.Folders),
Sends = await _sendValidator.ValidateAsync(user, model.AccountData.Sends),
};
var result = await _rotateUserAccountKeysCommand.RotateUserAccountKeysAsync(user, dataModel);
if (result.Succeeded)
{
return;
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
throw new BadRequestException(ModelState);
}
} }

View File

@ -0,0 +1,10 @@
#nullable enable
using Bit.Core.Utilities;
namespace Bit.Api.KeyManagement.Models.Requests;
public class AccountKeysRequestModel
{
[EncryptedString] public required string UserKeyEncryptedAccountPrivateKey { get; set; }
public required string AccountPublicKey { get; set; }
}

View File

@ -0,0 +1,13 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
namespace Bit.Api.KeyManagement.Models.Requests;
public class RotateUserAccountKeysAndDataRequestModel
{
[StringLength(300)]
public required string OldMasterKeyAuthenticationHash { get; set; }
public required UnlockDataRequestModel AccountUnlockData { get; set; }
public required AccountKeysRequestModel AccountKeys { get; set; }
public required AccountDataRequestModel AccountData { get; set; }
}

View File

@ -0,0 +1,16 @@
#nullable enable
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Auth.Models.Request;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.Auth.Models.Request.WebAuthn;
namespace Bit.Api.KeyManagement.Models.Requests;
public class UnlockDataRequestModel
{
// All methods to get to the userkey
public required MasterPasswordUnlockDataModel MasterPasswordUnlockData { get; set; }
public required IEnumerable<EmergencyAccessWithIdRequestModel> EmergencyAccessUnlockData { get; set; }
public required IEnumerable<ResetPasswordWithOrgIdRequestModel> OrganizationAccountRecoveryUnlockData { get; set; }
public required IEnumerable<WebAuthnLoginRotateKeyRequestModel> PasskeyUnlockData { get; set; }
}

View File

@ -0,0 +1,12 @@
#nullable enable
using Bit.Api.Tools.Models.Request;
using Bit.Api.Vault.Models.Request;
namespace Bit.Api.KeyManagement.Models.Requests;
public class AccountDataRequestModel
{
public required IEnumerable<CipherWithIdRequestModel> Ciphers { get; set; }
public required IEnumerable<FolderWithIdRequestModel> Folders { get; set; }
public required IEnumerable<SendWithIdRequestModel> Sends { get; set; }
}

View File

@ -16,6 +16,7 @@ using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Tools.Services; using Bit.Core.Tools.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Core.Vault.Authorization.Permissions;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Models.Data;
using Bit.Core.Vault.Queries; using Bit.Core.Vault.Queries;
@ -345,6 +346,77 @@ public class CiphersController : Controller
return await CanEditCiphersAsync(organizationId, cipherIds); return await CanEditCiphersAsync(organizationId, cipherIds);
} }
private async Task<bool> CanDeleteOrRestoreCipherAsAdminAsync(Guid organizationId, IEnumerable<Guid> cipherIds)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.LimitItemDeletion))
{
return await CanEditCipherAsAdminAsync(organizationId, cipherIds);
}
var org = _currentContext.GetOrganization(organizationId);
// If we're not an "admin", we don't need to check the ciphers
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }))
{
// Are we a provider user? If so, we need to be sure we're not restricted
// Once the feature flag is removed, this check can be combined with the above
if (await _currentContext.ProviderUserForOrgAsync(organizationId))
{
// Provider is restricted from editing ciphers, so we're not an "admin"
if (_featureService.IsEnabled(FeatureFlagKeys.RestrictProviderAccess))
{
return false;
}
// Provider is unrestricted, so we're an "admin", don't return early
}
else
{
// Not a provider or admin
return false;
}
}
// If the user can edit all ciphers for the organization, just check they all belong to the org
if (await CanEditAllCiphersAsync(organizationId))
{
// TODO: This can likely be optimized to only query the requested ciphers and then checking they belong to the org
var orgCiphers = (await _cipherRepository.GetManyByOrganizationIdAsync(organizationId)).ToDictionary(c => c.Id);
// Ensure all requested ciphers are in orgCiphers
return cipherIds.All(c => orgCiphers.ContainsKey(c));
}
// The user cannot access any ciphers for the organization, we're done
if (!await CanAccessOrganizationCiphersAsync(organizationId))
{
return false;
}
var user = await _userService.GetUserByPrincipalAsync(User);
// Select all deletable ciphers for this user belonging to the organization
var deletableOrgCipherList = (await _cipherRepository.GetManyByUserIdAsync(user.Id, true))
.Where(c => c.OrganizationId == organizationId && c.UserId == null).ToList();
// Special case for unassigned ciphers
if (await CanAccessUnassignedCiphersAsync(organizationId))
{
var unassignedCiphers =
(await _cipherRepository.GetManyUnassignedOrganizationDetailsByOrganizationIdAsync(
organizationId));
// Users that can access unassigned ciphers can also delete them
deletableOrgCipherList.AddRange(unassignedCiphers.Select(c => new CipherDetails(c) { Manage = true }));
}
var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId);
var deletableOrgCiphers = deletableOrgCipherList
.Where(c => NormalCipherPermissions.CanDelete(user, c, organizationAbility))
.ToDictionary(c => c.Id);
return cipherIds.All(c => deletableOrgCiphers.ContainsKey(c));
}
/// <summary> /// <summary>
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062 /// TODO: Move this to its own authorization handler or equivalent service - AC-2062
/// </summary> /// </summary>
@ -763,12 +835,12 @@ public class CiphersController : Controller
[HttpDelete("{id}/admin")] [HttpDelete("{id}/admin")]
[HttpPost("{id}/delete-admin")] [HttpPost("{id}/delete-admin")]
public async Task DeleteAdmin(string id) public async Task DeleteAdmin(Guid id)
{ {
var userId = _userService.GetProperUserId(User).Value; var userId = _userService.GetProperUserId(User).Value;
var cipher = await _cipherRepository.GetByIdAsync(new Guid(id)); var cipher = await GetByIdAsync(id, userId);
if (cipher == null || !cipher.OrganizationId.HasValue || if (cipher == null || !cipher.OrganizationId.HasValue ||
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id })) !await CanDeleteOrRestoreCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
@ -808,7 +880,7 @@ public class CiphersController : Controller
var cipherIds = model.Ids.Select(i => new Guid(i)).ToList(); var cipherIds = model.Ids.Select(i => new Guid(i)).ToList();
if (string.IsNullOrWhiteSpace(model.OrganizationId) || if (string.IsNullOrWhiteSpace(model.OrganizationId) ||
!await CanEditCipherAsAdminAsync(new Guid(model.OrganizationId), cipherIds)) !await CanDeleteOrRestoreCipherAsAdminAsync(new Guid(model.OrganizationId), cipherIds))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
@ -830,12 +902,12 @@ public class CiphersController : Controller
} }
[HttpPut("{id}/delete-admin")] [HttpPut("{id}/delete-admin")]
public async Task PutDeleteAdmin(string id) public async Task PutDeleteAdmin(Guid id)
{ {
var userId = _userService.GetProperUserId(User).Value; var userId = _userService.GetProperUserId(User).Value;
var cipher = await _cipherRepository.GetByIdAsync(new Guid(id)); var cipher = await GetByIdAsync(id, userId);
if (cipher == null || !cipher.OrganizationId.HasValue || if (cipher == null || !cipher.OrganizationId.HasValue ||
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id })) !await CanDeleteOrRestoreCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
@ -871,7 +943,7 @@ public class CiphersController : Controller
var cipherIds = model.Ids.Select(i => new Guid(i)).ToList(); var cipherIds = model.Ids.Select(i => new Guid(i)).ToList();
if (string.IsNullOrWhiteSpace(model.OrganizationId) || if (string.IsNullOrWhiteSpace(model.OrganizationId) ||
!await CanEditCipherAsAdminAsync(new Guid(model.OrganizationId), cipherIds)) !await CanDeleteOrRestoreCipherAsAdminAsync(new Guid(model.OrganizationId), cipherIds))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
@ -899,12 +971,12 @@ public class CiphersController : Controller
} }
[HttpPut("{id}/restore-admin")] [HttpPut("{id}/restore-admin")]
public async Task<CipherMiniResponseModel> PutRestoreAdmin(string id) public async Task<CipherMiniResponseModel> PutRestoreAdmin(Guid id)
{ {
var userId = _userService.GetProperUserId(User).Value; var userId = _userService.GetProperUserId(User).Value;
var cipher = await _cipherRepository.GetOrganizationDetailsByIdAsync(new Guid(id)); var cipher = await GetByIdAsync(id, userId);
if (cipher == null || !cipher.OrganizationId.HasValue || if (cipher == null || !cipher.OrganizationId.HasValue ||
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id })) !await CanDeleteOrRestoreCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
@ -944,7 +1016,7 @@ public class CiphersController : Controller
var cipherIdsToRestore = new HashSet<Guid>(model.Ids.Select(i => new Guid(i))); var cipherIdsToRestore = new HashSet<Guid>(model.Ids.Select(i => new Guid(i)));
if (model.OrganizationId == default || !await CanEditCipherAsAdminAsync(model.OrganizationId, cipherIdsToRestore)) if (model.OrganizationId == default || !await CanDeleteOrRestoreCipherAsAdminAsync(model.OrganizationId, cipherIdsToRestore))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }

View File

@ -5,6 +5,7 @@ using Bit.Core;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Core.Vault.Commands.Interfaces; using Bit.Core.Vault.Commands.Interfaces;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Enums; using Bit.Core.Vault.Enums;
using Bit.Core.Vault.Queries; using Bit.Core.Vault.Queries;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -89,11 +90,28 @@ public class SecurityTaskController : Controller
public async Task<ListResponseModel<SecurityTasksResponseModel>> BulkCreateTasks(Guid orgId, public async Task<ListResponseModel<SecurityTasksResponseModel>> BulkCreateTasks(Guid orgId,
[FromBody] BulkCreateSecurityTasksRequestModel model) [FromBody] BulkCreateSecurityTasksRequestModel model)
{ {
var securityTasks = await _createManyTasksCommand.CreateAsync(orgId, model.Tasks); // Retrieve existing pending security tasks for the organization
var pendingSecurityTasks = await _getTasksForOrganizationQuery.GetTasksAsync(orgId, SecurityTaskStatus.Pending);
await _createManyTaskNotificationsCommand.CreateAsync(orgId, securityTasks); // Get the security tasks that are already associated with a cipher within the submitted model
var existingTasks = pendingSecurityTasks.Where(x => model.Tasks.Any(y => y.CipherId == x.CipherId)).ToList();
var response = securityTasks.Select(x => new SecurityTasksResponseModel(x)).ToList(); // Get tasks that need to be created
var tasksToCreateFromModel = model.Tasks.Where(x => !existingTasks.Any(y => y.CipherId == x.CipherId)).ToList();
ICollection<SecurityTask> newSecurityTasks = new List<SecurityTask>();
if (tasksToCreateFromModel.Count != 0)
{
newSecurityTasks = await _createManyTasksCommand.CreateAsync(orgId, tasksToCreateFromModel);
}
// Combine existing tasks and newly created tasks
var allTasks = existingTasks.Concat(newSecurityTasks);
await _createManyTaskNotificationsCommand.CreateAsync(orgId, allTasks);
var response = allTasks.Select(x => new SecurityTasksResponseModel(x)).ToList();
return new ListResponseModel<SecurityTasksResponseModel>(response); return new ListResponseModel<SecurityTasksResponseModel>(response);
} }
} }

View File

@ -1,8 +1,11 @@
using Bit.Core.AdminConsole.Repositories; using Bit.Core;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -12,6 +15,7 @@ using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations; namespace Bit.Billing.Services.Implementations;
public class UpcomingInvoiceHandler( public class UpcomingInvoiceHandler(
IFeatureService featureService,
ILogger<StripeEventProcessor> logger, ILogger<StripeEventProcessor> logger,
IMailService mailService, IMailService mailService,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -21,7 +25,8 @@ public class UpcomingInvoiceHandler(
IStripeEventService stripeEventService, IStripeEventService stripeEventService,
IStripeEventUtilityService stripeEventUtilityService, IStripeEventUtilityService stripeEventUtilityService,
IUserRepository userRepository, IUserRepository userRepository,
IValidateSponsorshipCommand validateSponsorshipCommand) IValidateSponsorshipCommand validateSponsorshipCommand,
IAutomaticTaxFactory automaticTaxFactory)
: IUpcomingInvoiceHandler : IUpcomingInvoiceHandler
{ {
public async Task HandleAsync(Event parsedEvent) public async Task HandleAsync(Event parsedEvent)
@ -136,6 +141,21 @@ public class UpcomingInvoiceHandler(
private async Task TryEnableAutomaticTaxAsync(Subscription subscription) private async Task TryEnableAutomaticTaxAsync(Subscription subscription)
{ {
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
{
var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscription.Items.Select(x => x.Price.Id));
var automaticTaxStrategy = await automaticTaxFactory.CreateAsync(automaticTaxParameters);
var updateOptions = automaticTaxStrategy.GetUpdateOptions(subscription);
if (updateOptions == null)
{
return;
}
await stripeFacade.UpdateSubscription(subscription.Id, updateOptions);
return;
}
if (subscription.AutomaticTax.Enabled || if (subscription.AutomaticTax.Enabled ||
!subscription.Customer.HasBillingLocation() || !subscription.Customer.HasBillingLocation() ||
await IsNonTaxableNonUSBusinessUseSubscription(subscription)) await IsNonTaxableNonUSBusinessUseSubscription(subscription))

View File

@ -0,0 +1,18 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Utilities;
#nullable enable
namespace Bit.Core.AdminConsole.Entities;
public class OrganizationIntegration : ITableObject<Guid>
{
public Guid Id { get; set; }
public Guid OrganizationId { get; set; }
public IntegrationType Type { get; set; }
public string? Configuration { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
public void SetNewId() => Id = CoreHelpers.GenerateComb();
}

View File

@ -0,0 +1,19 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Utilities;
#nullable enable
namespace Bit.Core.AdminConsole.Entities;
public class OrganizationIntegrationConfiguration : ITableObject<Guid>
{
public Guid Id { get; set; }
public Guid OrganizationIntegrationId { get; set; }
public EventType EventType { get; set; }
public string? Configuration { get; set; }
public string? Template { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
public void SetNewId() => Id = CoreHelpers.GenerateComb();
}

View File

@ -0,0 +1,7 @@
namespace Bit.Core.Enums;
public enum IntegrationType : int
{
Slack = 1,
Webhook = 2,
}

View File

@ -0,0 +1,54 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
/// <summary>
/// Restores a user back to their previous status.
/// </summary>
public interface IRestoreOrganizationUserCommand
{
/// <summary>
/// Validates that the requesting user can perform the action. There is also a check done to ensure the organization
/// can re-add this user based on their current occupied seats.
///
/// Checks are performed to make sure the user is conforming to all policies enforced by the organization as well as
/// other organizations the user may belong to.
///
/// Reference Events and Push Notifications are fired off for this as well.
/// </summary>
/// <param name="organizationUser">Revoked user to be restored.</param>
/// <param name="restoringUserId">UserId of the user performing the action.</param>
Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId);
/// <summary>
/// Validates that the requesting user can perform the action. There is also a check done to ensure the organization
/// can re-add this user based on their current occupied seats.
///
/// Checks are performed to make sure the user is conforming to all policies enforced by the organization as well as
/// other organizations the user may belong to.
///
/// Reference Events and Push Notifications are fired off for this as well.
/// </summary>
/// <param name="organizationUser">Revoked user to be restored.</param>
/// <param name="systemUser">System that is performing the action on behalf of the organization (Public API, SCIM, etc.)</param>
Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser);
/// <summary>
/// Validates that the requesting user can perform the action. There is also a check done to ensure the organization
/// can re-add this user based on their current occupied seats.
///
/// Checks are performed to make sure the user is conforming to all policies enforced by the organization as well as
/// other organizations the user may belong to.
///
/// Reference Events and Push Notifications are fired off for this as well.
/// </summary>
/// <param name="organizationId">Organization the users should be restored to.</param>
/// <param name="organizationUserIds">List of organization user ids to restore to previous status.</param>
/// <param name="restoringUserId">UserId of the user performing the action.</param>
/// <param name="userService">Passed in from caller to avoid circular dependency</param>
/// <returns>List of organization user Ids and strings. A successful restoration will have an empty string.
/// If an error occurs, the error message will be provided.</returns>
Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService);
}

View File

@ -0,0 +1,295 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Enums;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
public class RestoreOrganizationUserCommand(
ICurrentContext currentContext,
IEventService eventService,
IFeatureService featureService,
IPushNotificationService pushNotificationService,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IPolicyService policyService,
IUserRepository userRepository,
IOrganizationService organizationService) : IRestoreOrganizationUserCommand
{
public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId)
{
if (restoringUserId.HasValue && organizationUser.UserId == restoringUserId.Value)
{
throw new BadRequestException("You cannot restore yourself.");
}
if (organizationUser.Type == OrganizationUserType.Owner && restoringUserId.HasValue &&
!await currentContext.OrganizationOwner(organizationUser.OrganizationId))
{
throw new BadRequestException("Only owners can restore other owners.");
}
await RepositoryRestoreUserAsync(organizationUser);
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
if (featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) &&
organizationUser.UserId.HasValue)
{
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
}
}
public async Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser)
{
await RepositoryRestoreUserAsync(organizationUser);
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored,
systemUser);
if (featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) &&
organizationUser.UserId.HasValue)
{
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
}
}
private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser)
{
if (organizationUser.Status != OrganizationUserStatusType.Revoked)
{
throw new BadRequestException("Already active.");
}
var organization = await organizationRepository.GetByIdAsync(organizationUser.OrganizationId);
var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats;
if (availableSeats < 1)
{
await organizationService.AutoAddSeatsAsync(organization, 1); // Hooray
}
var userTwoFactorIsEnabled = false;
// Only check 2FA status if the user is linked to a user account
if (organizationUser.UserId.HasValue)
{
userTwoFactorIsEnabled =
(await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync([organizationUser.UserId.Value]))
.FirstOrDefault()
.twoFactorIsEnabled;
}
await CheckUserForOtherFreeOrganizationOwnershipAsync(organizationUser);
await CheckPoliciesBeforeRestoreAsync(organizationUser, userTwoFactorIsEnabled);
var status = OrganizationService.GetPriorActiveOrganizationUserStatusType(organizationUser);
await organizationUserRepository.RestoreAsync(organizationUser.Id, status);
organizationUser.Status = status;
}
private async Task CheckUserForOtherFreeOrganizationOwnershipAsync(OrganizationUser organizationUser)
{
var relatedOrgUsersFromOtherOrgs = await organizationUserRepository.GetManyByUserAsync(organizationUser.UserId.Value);
var otherOrgs = await organizationRepository.GetManyByUserIdAsync(organizationUser.UserId.Value);
var orgOrgUserDict = relatedOrgUsersFromOtherOrgs
.Where(x => x.Id != organizationUser.Id)
.ToDictionary(x => x, x => otherOrgs.FirstOrDefault(y => y.Id == x.OrganizationId));
CheckForOtherFreeOrganizationOwnership(organizationUser, orgOrgUserDict);
}
private async Task<Dictionary<OrganizationUser, Organization>> GetRelatedOrganizationUsersAndOrganizations(
IEnumerable<OrganizationUser> organizationUsers)
{
var allUserIds = organizationUsers.Select(x => x.UserId.Value);
var otherOrganizationUsers = (await organizationUserRepository.GetManyByManyUsersAsync(allUserIds))
.Where(x => organizationUsers.Any(y => y.Id == x.Id) == false);
var otherOrgs = await organizationRepository.GetManyByIdsAsync(otherOrganizationUsers
.Select(x => x.OrganizationId)
.Distinct());
return otherOrganizationUsers
.ToDictionary(x => x, x => otherOrgs.FirstOrDefault(y => y.Id == x.OrganizationId));
}
private static void CheckForOtherFreeOrganizationOwnership(OrganizationUser organizationUser,
Dictionary<OrganizationUser, Organization> otherOrgUsersAndOrgs)
{
var ownerOrAdminList = new[] { OrganizationUserType.Owner, OrganizationUserType.Admin };
if (otherOrgUsersAndOrgs.Any(x =>
x.Key.UserId == organizationUser.UserId &&
ownerOrAdminList.Any(userType => userType == x.Key.Type) &&
x.Key.Status == OrganizationUserStatusType.Confirmed &&
x.Value.PlanType == PlanType.Free))
{
throw new BadRequestException(
"User is an owner/admin of another free organization. Please have them upgrade to a paid plan to restore their account.");
}
}
public async Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId,
IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService)
{
var orgUsers = await organizationUserRepository.GetManyAsync(organizationUserIds);
var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId)
.ToList();
if (filteredUsers.Count == 0)
{
throw new BadRequestException("Users invalid.");
}
var organization = await organizationRepository.GetByIdAsync(organizationId);
var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats;
var newSeatsRequired = organizationUserIds.Count() - availableSeats;
await organizationService.AutoAddSeatsAsync(organization, newSeatsRequired);
var deletingUserIsOwner = false;
if (restoringUserId.HasValue)
{
deletingUserIsOwner = await currentContext.OrganizationOwner(organizationId);
}
// Query Two Factor Authentication status for all users in the organization
// This is an optimization to avoid querying the Two Factor Authentication status for each user individually
var organizationUsersTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(
filteredUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId.Value));
var orgUsersAndOrgs = await GetRelatedOrganizationUsersAndOrganizations(filteredUsers);
var result = new List<Tuple<OrganizationUser, string>>();
foreach (var organizationUser in filteredUsers)
{
try
{
if (organizationUser.Status != OrganizationUserStatusType.Revoked)
{
throw new BadRequestException("Already active.");
}
if (restoringUserId.HasValue && organizationUser.UserId == restoringUserId)
{
throw new BadRequestException("You cannot restore yourself.");
}
if (organizationUser.Type == OrganizationUserType.Owner && restoringUserId.HasValue &&
!deletingUserIsOwner)
{
throw new BadRequestException("Only owners can restore other owners.");
}
var twoFactorIsEnabled = organizationUser.UserId.HasValue
&& organizationUsersTwoFactorEnabled
.FirstOrDefault(ou => ou.userId == organizationUser.UserId.Value)
.twoFactorIsEnabled;
await CheckPoliciesBeforeRestoreAsync(organizationUser, twoFactorIsEnabled);
CheckForOtherFreeOrganizationOwnership(organizationUser, orgUsersAndOrgs);
var status = OrganizationService.GetPriorActiveOrganizationUserStatusType(organizationUser);
await organizationUserRepository.RestoreAsync(organizationUser.Id, status);
organizationUser.Status = status;
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
if (featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) &&
organizationUser.UserId.HasValue)
{
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
}
result.Add(Tuple.Create(organizationUser, ""));
}
catch (BadRequestException e)
{
result.Add(Tuple.Create(organizationUser, e.Message));
}
}
return result;
}
private async Task CheckPoliciesBeforeRestoreAsync(OrganizationUser orgUser, bool userHasTwoFactorEnabled)
{
// An invited OrganizationUser isn't linked with a user account yet, so these checks are irrelevant
// The user will be subject to the same checks when they try to accept the invite
if (OrganizationService.GetPriorActiveOrganizationUserStatusType(orgUser) == OrganizationUserStatusType.Invited)
{
return;
}
var userId = orgUser.UserId.Value;
// Enforce Single Organization Policy of organization user is being restored to
var allOrgUsers = await organizationUserRepository.GetManyByUserAsync(userId);
var hasOtherOrgs = allOrgUsers.Any(ou => ou.OrganizationId != orgUser.OrganizationId);
var singleOrgPoliciesApplyingToRevokedUsers = await policyService.GetPoliciesApplicableToUserAsync(userId,
PolicyType.SingleOrg, OrganizationUserStatusType.Revoked);
var singleOrgPolicyApplies =
singleOrgPoliciesApplyingToRevokedUsers.Any(p => p.OrganizationId == orgUser.OrganizationId);
var singleOrgCompliant = true;
var belongsToOtherOrgCompliant = true;
var twoFactorCompliant = true;
if (hasOtherOrgs && singleOrgPolicyApplies)
{
singleOrgCompliant = false;
}
// Enforce Single Organization Policy of other organizations user is a member of
var anySingleOrgPolicies = await policyService.AnyPoliciesApplicableToUserAsync(userId, PolicyType.SingleOrg);
if (anySingleOrgPolicies)
{
belongsToOtherOrgCompliant = false;
}
// Enforce 2FA Policy of organization user is trying to join
if (!userHasTwoFactorEnabled)
{
var invitedTwoFactorPolicies = await policyService.GetPoliciesApplicableToUserAsync(userId,
PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Revoked);
if (invitedTwoFactorPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId))
{
twoFactorCompliant = false;
}
}
var user = await userRepository.GetByIdAsync(userId);
if (!singleOrgCompliant && !twoFactorCompliant)
{
throw new BadRequestException(user.Email +
" is not compliant with the single organization and two-step login policy");
}
else if (!singleOrgCompliant)
{
throw new BadRequestException(user.Email + " is not compliant with the single organization policy");
}
else if (!belongsToOtherOrgCompliant)
{
throw new BadRequestException(user.Email +
" belongs to an organization that doesn't allow them to join multiple organizations");
}
else if (!twoFactorCompliant)
{
throw new BadRequestException(user.Email + " is not compliant with the two-step login policy");
}
}
}

View File

@ -0,0 +1,26 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
/// <summary>
/// Policy requirements for the Disable Personal Ownership policy.
/// </summary>
public class PersonalOwnershipPolicyRequirement : IPolicyRequirement
{
/// <summary>
/// Indicates whether Personal Ownership is disabled for the user. If true, members are required to save items to an organization.
/// </summary>
public bool DisablePersonalOwnership { get; init; }
}
public class PersonalOwnershipPolicyRequirementFactory : BasePolicyRequirementFactory<PersonalOwnershipPolicyRequirement>
{
public override PolicyType PolicyType => PolicyType.PersonalOwnership;
public override PersonalOwnershipPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails)
{
var result = new PersonalOwnershipPolicyRequirement { DisablePersonalOwnership = policyDetails.Any() };
return result;
}
}

View File

@ -34,5 +34,6 @@ public static class PolicyServiceCollectionExtensions
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, DisableSendPolicyRequirementFactory>(); services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, DisableSendPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, SendOptionsPolicyRequirementFactory>(); services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, SendOptionsPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, ResetPasswordPolicyRequirementFactory>(); services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, ResetPasswordPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, PersonalOwnershipPolicyRequirementFactory>();
} }
} }

View File

@ -24,4 +24,5 @@ public interface IOrganizationRepository : IRepository<Organization, Guid>
/// </summary> /// </summary>
Task<ICollection<Organization>> GetByVerifiedUserEmailDomainAsync(Guid userId); Task<ICollection<Organization>> GetByVerifiedUserEmailDomainAsync(Guid userId);
Task<ICollection<Organization>> GetAddableToProviderByUserIdAsync(Guid userId, ProviderType providerType); Task<ICollection<Organization>> GetAddableToProviderByUserIdAsync(Guid userId, ProviderType providerType);
Task<ICollection<Organization>> GetManyByIdsAsync(IEnumerable<Guid> ids);
} }

View File

@ -48,10 +48,6 @@ public interface IOrganizationService
Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser); Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser);
Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId, Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
IEnumerable<Guid> organizationUserIds, Guid? revokingUserId); IEnumerable<Guid> organizationUserIds, Guid? revokingUserId);
Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId);
Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser);
Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId,
IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService);
Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted); Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted);
/// <summary> /// <summary>
/// Update an Organization entry by setting the public/private keys, set it as 'Enabled' and move the Status from 'Pending' to 'Created'. /// Update an Organization entry by setting the public/private keys, set it as 'Enabled' and move the Status from 'Pending' to 'Created'.

View File

@ -19,7 +19,6 @@ using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
@ -75,7 +74,6 @@ public class OrganizationService : IOrganizationService
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory; private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IOrganizationBillingService _organizationBillingService;
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IPolicyRequirementQuery _policyRequirementQuery;
@ -112,7 +110,6 @@ public class OrganizationService : IOrganizationService
IProviderRepository providerRepository, IProviderRepository providerRepository,
IFeatureService featureService, IFeatureService featureService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IOrganizationBillingService organizationBillingService,
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
IPricingClient pricingClient, IPricingClient pricingClient,
IPolicyRequirementQuery policyRequirementQuery) IPolicyRequirementQuery policyRequirementQuery)
@ -148,7 +145,6 @@ public class OrganizationService : IOrganizationService
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
_featureService = featureService; _featureService = featureService;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_organizationBillingService = organizationBillingService;
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
_pricingClient = pricingClient; _pricingClient = pricingClient;
_policyRequirementQuery = policyRequirementQuery; _policyRequirementQuery = policyRequirementQuery;
@ -579,6 +575,7 @@ public class OrganizationService : IOrganizationService
UseSecretsManager = license.UseSecretsManager, UseSecretsManager = license.UseSecretsManager,
SmSeats = license.SmSeats, SmSeats = license.SmSeats,
SmServiceAccounts = license.SmServiceAccounts, SmServiceAccounts = license.SmServiceAccounts,
UseRiskInsights = license.UseRiskInsights,
}; };
var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false); var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false);
@ -1891,144 +1888,6 @@ public class OrganizationService : IOrganizationService
return result; return result;
} }
public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId)
{
if (restoringUserId.HasValue && organizationUser.UserId == restoringUserId.Value)
{
throw new BadRequestException("You cannot restore yourself.");
}
if (organizationUser.Type == OrganizationUserType.Owner && restoringUserId.HasValue &&
!await _currentContext.OrganizationOwner(organizationUser.OrganizationId))
{
throw new BadRequestException("Only owners can restore other owners.");
}
await RepositoryRestoreUserAsync(organizationUser);
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue)
{
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
}
}
public async Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser)
{
await RepositoryRestoreUserAsync(organizationUser);
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored, systemUser);
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue)
{
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
}
}
private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser)
{
if (organizationUser.Status != OrganizationUserStatusType.Revoked)
{
throw new BadRequestException("Already active.");
}
var organization = await _organizationRepository.GetByIdAsync(organizationUser.OrganizationId);
var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats;
if (availableSeats < 1)
{
await AutoAddSeatsAsync(organization, 1);
}
var userTwoFactorIsEnabled = false;
// Only check Two Factor Authentication status if the user is linked to a user account
if (organizationUser.UserId.HasValue)
{
userTwoFactorIsEnabled = (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(new[] { organizationUser.UserId.Value })).FirstOrDefault().twoFactorIsEnabled;
}
await CheckPoliciesBeforeRestoreAsync(organizationUser, userTwoFactorIsEnabled);
var status = GetPriorActiveOrganizationUserStatusType(organizationUser);
await _organizationUserRepository.RestoreAsync(organizationUser.Id, status);
organizationUser.Status = status;
}
public async Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId,
IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService)
{
var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUserIds);
var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId)
.ToList();
if (!filteredUsers.Any())
{
throw new BadRequestException("Users invalid.");
}
var organization = await _organizationRepository.GetByIdAsync(organizationId);
var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats;
var newSeatsRequired = organizationUserIds.Count() - availableSeats;
await AutoAddSeatsAsync(organization, newSeatsRequired);
var deletingUserIsOwner = false;
if (restoringUserId.HasValue)
{
deletingUserIsOwner = await _currentContext.OrganizationOwner(organizationId);
}
// Query Two Factor Authentication status for all users in the organization
// This is an optimization to avoid querying the Two Factor Authentication status for each user individually
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(
filteredUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId.Value));
var result = new List<Tuple<OrganizationUser, string>>();
foreach (var organizationUser in filteredUsers)
{
try
{
if (organizationUser.Status != OrganizationUserStatusType.Revoked)
{
throw new BadRequestException("Already active.");
}
if (restoringUserId.HasValue && organizationUser.UserId == restoringUserId)
{
throw new BadRequestException("You cannot restore yourself.");
}
if (organizationUser.Type == OrganizationUserType.Owner && restoringUserId.HasValue && !deletingUserIsOwner)
{
throw new BadRequestException("Only owners can restore other owners.");
}
var twoFactorIsEnabled = organizationUser.UserId.HasValue
&& organizationUsersTwoFactorEnabled.FirstOrDefault(ou => ou.userId == organizationUser.UserId.Value).twoFactorIsEnabled;
await CheckPoliciesBeforeRestoreAsync(organizationUser, twoFactorIsEnabled);
var status = GetPriorActiveOrganizationUserStatusType(organizationUser);
await _organizationUserRepository.RestoreAsync(organizationUser.Id, status);
organizationUser.Status = status;
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue)
{
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
}
result.Add(Tuple.Create(organizationUser, ""));
}
catch (BadRequestException e)
{
result.Add(Tuple.Create(organizationUser, e.Message));
}
}
return result;
}
private async Task CheckPoliciesBeforeRestoreAsync(OrganizationUser orgUser, bool userHasTwoFactorEnabled) private async Task CheckPoliciesBeforeRestoreAsync(OrganizationUser orgUser, bool userHasTwoFactorEnabled)
{ {
// An invited OrganizationUser isn't linked with a user account yet, so these checks are irrelevant // An invited OrganizationUser isn't linked with a user account yet, so these checks are irrelevant
@ -2095,7 +1954,7 @@ public class OrganizationService : IOrganizationService
} }
} }
static OrganizationUserStatusType GetPriorActiveOrganizationUserStatusType(OrganizationUser organizationUser) public static OrganizationUserStatusType GetPriorActiveOrganizationUserStatusType(OrganizationUser organizationUser)
{ {
// Determine status to revert back to // Determine status to revert back to
var status = OrganizationUserStatusType.Invited; var status = OrganizationUserStatusType.Invited;

View File

@ -287,12 +287,6 @@ public class AuthRequestService : IAuthRequestService
private async Task NotifyAdminsOfDeviceApprovalRequestAsync(OrganizationUser organizationUser, User user) private async Task NotifyAdminsOfDeviceApprovalRequestAsync(OrganizationUser organizationUser, User user)
{ {
if (!_featureService.IsEnabled(FeatureFlagKeys.DeviceApprovalRequestAdminNotifications))
{
_logger.LogWarning("Skipped sending device approval notification to admins - feature flag disabled");
return;
}
var adminEmails = await GetAdminAndAccountRecoveryEmailsAsync(organizationUser.OrganizationId); var adminEmails = await GetAdminAndAccountRecoveryEmailsAsync(organizationUser.OrganizationId);
await _mailService.SendDeviceApprovalRequestedNotificationEmailAsync( await _mailService.SendDeviceApprovalRequestedNotificationEmailAsync(

View File

@ -32,6 +32,7 @@ public static class UserServiceCollectionExtensions
public static void AddUserKeyCommands(this IServiceCollection services, IGlobalSettings globalSettings) public static void AddUserKeyCommands(this IServiceCollection services, IGlobalSettings globalSettings)
{ {
services.AddScoped<IRotateUserKeyCommand, RotateUserKeyCommand>(); services.AddScoped<IRotateUserKeyCommand, RotateUserKeyCommand>();
services.AddScoped<IRotateUserAccountKeysCommand, RotateUserAccountKeysCommand>();
} }
private static void AddUserPasswordCommands(this IServiceCollection services) private static void AddUserPasswordCommands(this IServiceCollection services)

View File

@ -47,6 +47,8 @@ public static class StripeConstants
public static class MetadataKeys public static class MetadataKeys
{ {
public const string OrganizationId = "organizationId"; public const string OrganizationId = "organizationId";
public const string ProviderId = "providerId";
public const string UserId = "userId";
} }
public static class PaymentBehavior public static class PaymentBehavior

View File

@ -21,7 +21,7 @@ public static class CustomerExtensions
/// <param name="customer"></param> /// <param name="customer"></param>
/// <returns></returns> /// <returns></returns>
public static bool HasTaxLocationVerified(this Customer customer) => public static bool HasTaxLocationVerified(this Customer customer) =>
customer?.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported; customer?.Tax?.AutomaticTax != StripeConstants.AutomaticTaxStatus.UnrecognizedLocation;
public static decimal GetBillingBalance(this Customer customer) public static decimal GetBillingBalance(this Customer customer)
{ {

View File

@ -4,6 +4,7 @@ using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Implementations; using Bit.Core.Billing.Services.Implementations;
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
namespace Bit.Core.Billing.Extensions; namespace Bit.Core.Billing.Extensions;
@ -18,6 +19,9 @@ public static class ServiceCollectionExtensions
services.AddTransient<IPremiumUserBillingService, PremiumUserBillingService>(); services.AddTransient<IPremiumUserBillingService, PremiumUserBillingService>();
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>(); services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();
services.AddTransient<ISubscriberService, SubscriberService>(); services.AddTransient<ISubscriberService, SubscriberService>();
services.AddKeyedTransient<IAutomaticTaxStrategy, PersonalUseAutomaticTaxStrategy>(AutomaticTaxFactory.PersonalUse);
services.AddKeyedTransient<IAutomaticTaxStrategy, BusinessUseAutomaticTaxStrategy>(AutomaticTaxFactory.BusinessUse);
services.AddTransient<IAutomaticTaxFactory, AutomaticTaxFactory>();
services.AddLicenseServices(); services.AddLicenseServices();
services.AddPricingClient(); services.AddPricingClient();
} }

View File

@ -1,26 +0,0 @@
using Stripe;
namespace Bit.Core.Billing.Extensions;
public static class SubscriptionCreateOptionsExtensions
{
/// <summary>
/// Attempts to enable automatic tax for given new subscription options.
/// </summary>
/// <param name="options"></param>
/// <param name="customer">The existing customer.</param>
/// <returns>Returns true when successful, false when conditions are not met.</returns>
public static bool EnableAutomaticTax(this SubscriptionCreateOptions options, Customer customer)
{
// We might only need to check the automatic tax status.
if (!customer.HasTaxLocationVerified() && string.IsNullOrWhiteSpace(customer.Address?.Country))
{
return false;
}
options.DefaultTaxRates = [];
options.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
return true;
}
}

View File

@ -0,0 +1,30 @@
#nullable enable
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
namespace Bit.Core.Billing.Services.Contracts;
public class AutomaticTaxFactoryParameters
{
public AutomaticTaxFactoryParameters(PlanType planType)
{
PlanType = planType;
}
public AutomaticTaxFactoryParameters(ISubscriber subscriber, IEnumerable<string> prices)
{
Subscriber = subscriber;
Prices = prices;
}
public AutomaticTaxFactoryParameters(IEnumerable<string> prices)
{
Prices = prices;
}
public ISubscriber? Subscriber { get; init; }
public PlanType? PlanType { get; init; }
public IEnumerable<string>? Prices { get; init; }
}

View File

@ -0,0 +1,11 @@
using Bit.Core.Billing.Services.Contracts;
namespace Bit.Core.Billing.Services;
/// <summary>
/// Responsible for defining the correct automatic tax strategy for either personal use of business use.
/// </summary>
public interface IAutomaticTaxFactory
{
Task<IAutomaticTaxStrategy> CreateAsync(AutomaticTaxFactoryParameters parameters);
}

View File

@ -0,0 +1,33 @@
#nullable enable
using Stripe;
namespace Bit.Core.Billing.Services;
public interface IAutomaticTaxStrategy
{
/// <summary>
///
/// </summary>
/// <param name="subscription"></param>
/// <returns>
/// Returns <see cref="SubscriptionUpdateOptions" /> if changes are to be applied to the subscription, returns null
/// otherwise.
/// </returns>
SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription);
/// <summary>
/// Modifies an existing <see cref="SubscriptionCreateOptions" /> object with the automatic tax flag set correctly.
/// </summary>
/// <param name="options"></param>
/// <param name="customer"></param>
void SetCreateOptions(SubscriptionCreateOptions options, Customer customer);
/// <summary>
/// Modifies an existing <see cref="SubscriptionUpdateOptions" /> object with the automatic tax flag set correctly.
/// </summary>
/// <param name="options"></param>
/// <param name="subscription"></param>
void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription);
void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options);
}

View File

@ -0,0 +1,50 @@
#nullable enable
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Entities;
using Bit.Core.Services;
namespace Bit.Core.Billing.Services.Implementations.AutomaticTax;
public class AutomaticTaxFactory(
IFeatureService featureService,
IPricingClient pricingClient) : IAutomaticTaxFactory
{
public const string BusinessUse = "business-use";
public const string PersonalUse = "personal-use";
private readonly Lazy<Task<IEnumerable<string>>> _personalUsePlansTask = new(async () =>
{
var plans = await Task.WhenAll(
pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019),
pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually));
return plans.Select(plan => plan.PasswordManager.StripePlanId);
});
public async Task<IAutomaticTaxStrategy> CreateAsync(AutomaticTaxFactoryParameters parameters)
{
if (parameters.Subscriber is User)
{
return new PersonalUseAutomaticTaxStrategy(featureService);
}
if (parameters.PlanType.HasValue)
{
var plan = await pricingClient.GetPlanOrThrow(parameters.PlanType.Value);
return plan.CanBeUsedByBusiness
? new BusinessUseAutomaticTaxStrategy(featureService)
: new PersonalUseAutomaticTaxStrategy(featureService);
}
var personalUsePlans = await _personalUsePlansTask.Value;
if (parameters.Prices != null && parameters.Prices.Any(x => personalUsePlans.Any(y => y == x)))
{
return new PersonalUseAutomaticTaxStrategy(featureService);
}
return new BusinessUseAutomaticTaxStrategy(featureService);
}
}

View File

@ -0,0 +1,96 @@
#nullable enable
using Bit.Core.Billing.Extensions;
using Bit.Core.Services;
using Stripe;
namespace Bit.Core.Billing.Services.Implementations.AutomaticTax;
public class BusinessUseAutomaticTaxStrategy(IFeatureService featureService) : IAutomaticTaxStrategy
{
public SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription)
{
if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
{
return null;
}
var shouldBeEnabled = ShouldBeEnabled(subscription.Customer);
if (subscription.AutomaticTax.Enabled == shouldBeEnabled)
{
return null;
}
var options = new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = shouldBeEnabled
},
DefaultTaxRates = []
};
return options;
}
public void SetCreateOptions(SubscriptionCreateOptions options, Customer customer)
{
options.AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = ShouldBeEnabled(customer)
};
}
public void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription)
{
if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
{
return;
}
var shouldBeEnabled = ShouldBeEnabled(subscription.Customer);
if (subscription.AutomaticTax.Enabled == shouldBeEnabled)
{
return;
}
options.AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = shouldBeEnabled
};
options.DefaultTaxRates = [];
}
public void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options)
{
options.AutomaticTax ??= new InvoiceAutomaticTaxOptions();
if (options.CustomerDetails.Address.Country == "US")
{
options.AutomaticTax.Enabled = true;
return;
}
options.AutomaticTax.Enabled = options.CustomerDetails.TaxIds != null && options.CustomerDetails.TaxIds.Any();
}
private bool ShouldBeEnabled(Customer customer)
{
if (!customer.HasTaxLocationVerified())
{
return false;
}
if (customer.Address.Country == "US")
{
return true;
}
if (customer.TaxIds == null)
{
throw new ArgumentNullException(nameof(customer.TaxIds), "`customer.tax_ids` must be expanded.");
}
return customer.TaxIds.Any();
}
}

View File

@ -0,0 +1,64 @@
#nullable enable
using Bit.Core.Billing.Extensions;
using Bit.Core.Services;
using Stripe;
namespace Bit.Core.Billing.Services.Implementations.AutomaticTax;
public class PersonalUseAutomaticTaxStrategy(IFeatureService featureService) : IAutomaticTaxStrategy
{
public void SetCreateOptions(SubscriptionCreateOptions options, Customer customer)
{
options.AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = ShouldBeEnabled(customer)
};
}
public void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription)
{
if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
{
return;
}
options.AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = ShouldBeEnabled(subscription.Customer)
};
options.DefaultTaxRates = [];
}
public SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription)
{
if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
{
return null;
}
if (subscription.AutomaticTax.Enabled == ShouldBeEnabled(subscription.Customer))
{
return null;
}
var options = new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = ShouldBeEnabled(subscription.Customer),
},
DefaultTaxRates = []
};
return options;
}
public void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options)
{
options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true };
}
private static bool ShouldBeEnabled(Customer customer)
{
return customer.HasTaxLocationVerified();
}
}

View File

@ -1,9 +1,11 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Caches; using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -23,6 +25,7 @@ namespace Bit.Core.Billing.Services.Implementations;
public class OrganizationBillingService( public class OrganizationBillingService(
IBraintreeGateway braintreeGateway, IBraintreeGateway braintreeGateway,
IFeatureService featureService,
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
ILogger<OrganizationBillingService> logger, ILogger<OrganizationBillingService> logger,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -30,7 +33,8 @@ public class OrganizationBillingService(
ISetupIntentCache setupIntentCache, ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
ISubscriberService subscriberService, ISubscriberService subscriberService,
ITaxService taxService) : IOrganizationBillingService ITaxService taxService,
IAutomaticTaxFactory automaticTaxFactory) : IOrganizationBillingService
{ {
public async Task Finalize(OrganizationSale sale) public async Task Finalize(OrganizationSale sale)
{ {
@ -143,7 +147,7 @@ public class OrganizationBillingService(
Coupon = customerSetup.Coupon, Coupon = customerSetup.Coupon,
Description = organization.DisplayBusinessName(), Description = organization.DisplayBusinessName(),
Email = organization.BillingEmail, Email = organization.BillingEmail,
Expand = ["tax"], Expand = ["tax", "tax_ids"],
InvoiceSettings = new CustomerInvoiceSettingsOptions InvoiceSettings = new CustomerInvoiceSettingsOptions
{ {
CustomFields = [ CustomFields = [
@ -369,21 +373,8 @@ public class OrganizationBillingService(
} }
} }
var customerHasTaxInfo = customer is
{
Address:
{
Country: not null and not "",
PostalCode: not null and not ""
}
};
var subscriptionCreateOptions = new SubscriptionCreateOptions var subscriptionCreateOptions = new SubscriptionCreateOptions
{ {
AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = customerHasTaxInfo
},
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically, CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
Customer = customer.Id, Customer = customer.Id,
Items = subscriptionItemOptionsList, Items = subscriptionItemOptionsList,
@ -395,6 +386,18 @@ public class OrganizationBillingService(
TrialPeriodDays = subscriptionSetup.SkipTrial ? 0 : plan.TrialPeriodDays TrialPeriodDays = subscriptionSetup.SkipTrial ? 0 : plan.TrialPeriodDays
}; };
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
{
var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscriptionSetup.PlanType);
var automaticTaxStrategy = await automaticTaxFactory.CreateAsync(automaticTaxParameters);
automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer);
}
else
{
subscriptionCreateOptions.AutomaticTax ??= new SubscriptionAutomaticTaxOptions();
subscriptionCreateOptions.AutomaticTax.Enabled = customer.HasBillingLocation();
}
return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
} }

View File

@ -2,6 +2,7 @@
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -9,6 +10,7 @@ using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Braintree; using Braintree;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Stripe; using Stripe;
using Customer = Stripe.Customer; using Customer = Stripe.Customer;
@ -20,19 +22,21 @@ using static Utilities;
public class PremiumUserBillingService( public class PremiumUserBillingService(
IBraintreeGateway braintreeGateway, IBraintreeGateway braintreeGateway,
IFeatureService featureService,
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
ILogger<PremiumUserBillingService> logger, ILogger<PremiumUserBillingService> logger,
ISetupIntentCache setupIntentCache, ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
ISubscriberService subscriberService, ISubscriberService subscriberService,
IUserRepository userRepository) : IPremiumUserBillingService IUserRepository userRepository,
[FromKeyedServices(AutomaticTaxFactory.PersonalUse)] IAutomaticTaxStrategy automaticTaxStrategy) : IPremiumUserBillingService
{ {
public async Task Credit(User user, decimal amount) public async Task Credit(User user, decimal amount)
{ {
var customer = await subscriberService.GetCustomer(user); var customer = await subscriberService.GetCustomer(user);
// Negative credit represents a balance and all Stripe denomination is in cents. // Negative credit represents a balance and all Stripe denomination is in cents.
var credit = (long)amount * -100; var credit = (long)(amount * -100);
if (customer == null) if (customer == null)
{ {
@ -318,10 +322,6 @@ public class PremiumUserBillingService(
var subscriptionCreateOptions = new SubscriptionCreateOptions var subscriptionCreateOptions = new SubscriptionCreateOptions
{ {
AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = customer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported,
},
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically, CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
Customer = customer.Id, Customer = customer.Id,
Items = subscriptionItemOptionsList, Items = subscriptionItemOptionsList,
@ -335,6 +335,18 @@ public class PremiumUserBillingService(
OffSession = true OffSession = true
}; };
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
{
automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer);
}
else
{
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = customer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported,
};
}
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
if (usingPayPal) if (usingPayPal)

View File

@ -1,6 +1,7 @@
using Bit.Core.Billing.Caches; using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -20,11 +21,13 @@ namespace Bit.Core.Billing.Services.Implementations;
public class SubscriberService( public class SubscriberService(
IBraintreeGateway braintreeGateway, IBraintreeGateway braintreeGateway,
IFeatureService featureService,
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
ILogger<SubscriberService> logger, ILogger<SubscriberService> logger,
ISetupIntentCache setupIntentCache, ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
ITaxService taxService) : ISubscriberService ITaxService taxService,
IAutomaticTaxFactory automaticTaxFactory) : ISubscriberService
{ {
public async Task CancelSubscription( public async Task CancelSubscription(
ISubscriber subscriber, ISubscriber subscriber,
@ -438,7 +441,8 @@ public class SubscriberService(
ArgumentNullException.ThrowIfNull(subscriber); ArgumentNullException.ThrowIfNull(subscriber);
ArgumentNullException.ThrowIfNull(tokenizedPaymentSource); ArgumentNullException.ThrowIfNull(tokenizedPaymentSource);
var customer = await GetCustomerOrThrow(subscriber); var customerGetOptions = new CustomerGetOptions { Expand = ["tax", "tax_ids"] };
var customer = await GetCustomerOrThrow(subscriber, customerGetOptions);
var (type, token) = tokenizedPaymentSource; var (type, token) = tokenizedPaymentSource;
@ -597,7 +601,7 @@ public class SubscriberService(
Expand = ["subscriptions", "tax", "tax_ids"] Expand = ["subscriptions", "tax", "tax_ids"]
}); });
await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions customer = await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
{ {
Address = new AddressOptions Address = new AddressOptions
{ {
@ -607,7 +611,8 @@ public class SubscriberService(
Line2 = taxInformation.Line2, Line2 = taxInformation.Line2,
City = taxInformation.City, City = taxInformation.City,
State = taxInformation.State State = taxInformation.State
} },
Expand = ["subscriptions", "tax", "tax_ids"]
}); });
var taxId = customer.TaxIds?.FirstOrDefault(); var taxId = customer.TaxIds?.FirstOrDefault();
@ -661,21 +666,42 @@ public class SubscriberService(
} }
} }
if (SubscriberIsEligibleForAutomaticTax(subscriber, customer)) if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
{ {
await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId, if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId))
new SubscriptionUpdateOptions {
var subscriptionGetOptions = new SubscriptionGetOptions
{ {
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } Expand = ["customer.tax", "customer.tax_ids"]
}); };
var subscription = await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions);
var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscriber, subscription.Items.Select(x => x.Price.Id));
var automaticTaxStrategy = await automaticTaxFactory.CreateAsync(automaticTaxParameters);
var automaticTaxOptions = automaticTaxStrategy.GetUpdateOptions(subscription);
if (automaticTaxOptions?.AutomaticTax?.Enabled != null)
{
await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId, automaticTaxOptions);
}
}
} }
else
{
if (SubscriberIsEligibleForAutomaticTax(subscriber, customer))
{
await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
return; return;
bool SubscriberIsEligibleForAutomaticTax(ISubscriber localSubscriber, Customer localCustomer) bool SubscriberIsEligibleForAutomaticTax(ISubscriber localSubscriber, Customer localCustomer)
=> !string.IsNullOrEmpty(localSubscriber.GatewaySubscriptionId) && => !string.IsNullOrEmpty(localSubscriber.GatewaySubscriptionId) &&
(localCustomer.Subscriptions?.Any(sub => sub.Id == localSubscriber.GatewaySubscriptionId && !sub.AutomaticTax.Enabled) ?? false) && (localCustomer.Subscriptions?.Any(sub => sub.Id == localSubscriber.GatewaySubscriptionId && !sub.AutomaticTax.Enabled) ?? false) &&
localCustomer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported; localCustomer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported;
}
} }
public async Task VerifyBankAccount( public async Task VerifyBankAccount(

View File

@ -106,9 +106,79 @@ public static class FeatureFlagKeys
public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint"; public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint";
public const string DeviceApprovalRequestAdminNotifications = "pm-15637-device-approval-request-admin-notifications"; public const string DeviceApprovalRequestAdminNotifications = "pm-15637-device-approval-request-admin-notifications";
public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission"; public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission";
public const string ShortcutDuplicatePatchRequests = "pm-16812-shortcut-duplicate-patch-requests";
public const string PushSyncOrgKeysOnRevokeRestore = "pm-17168-push-sync-org-keys-on-revoke-restore"; public const string PushSyncOrgKeysOnRevokeRestore = "pm-17168-push-sync-org-keys-on-revoke-restore";
public const string PolicyRequirements = "pm-14439-policy-requirements"; public const string PolicyRequirements = "pm-14439-policy-requirements";
public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility";
/* Auth Team */
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence";
public const string DuoRedirect = "duo-redirect";
public const string EmailVerification = "email-verification";
public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays";
public const string DeviceTrustLogging = "pm-8285-device-trust-logging";
public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token";
public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh";
public const string NewDeviceVerification = "new-device-verification";
public const string SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor";
public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor";
public const string RecoveryCodeLogin = "pm-17128-recovery-code-login";
/* Autofill Team */
public const string IdpAutoSubmitLogin = "idp-auto-submit-login";
public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection";
public const string InlineMenuFieldQualification = "inline-menu-field-qualification";
public const string InlineMenuPositioningImprovements = "inline-menu-positioning-improvements";
public const string SSHAgent = "ssh-agent";
public const string SSHVersionCheckQAOverride = "ssh-version-check-qa-override";
public const string GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor";
public const string DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2";
public const string NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements";
public const string BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain";
public const string NotificationRefresh = "notification-refresh";
public const string EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill";
public const string MacOsNativeCredentialSync = "macos-native-credential-sync";
public const string InlineMenuTotp = "inline-menu-totp";
/* Billing Team */
public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
public const string TrialPayment = "PM-8163-trial-payment";
public const string ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs";
public const string UsePricingService = "use-pricing-service";
public const string P15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal";
public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features";
public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method";
public const string PM19147_AutomaticTaxImprovements = "pm-19147-automatic-tax-improvements";
public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates";
/* Key Management Team */
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service";
public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration";
public const string Argon2Default = "argon2-default";
public const string UserkeyRotationV2 = "userkey-rotation-v2";
public const string SSHKeyItemVaultItem = "ssh-key-vault-item";
/* Mobile Team */
public const string NativeCarouselFlow = "native-carousel-flow";
public const string NativeCreateAccountFlow = "native-create-account-flow";
public const string AndroidImportLoginsFlow = "import-logins-flow";
public const string AppReviewPrompt = "app-review-prompt";
public const string EnablePasswordManagerSyncAndroid = "enable-password-manager-sync-android";
public const string EnablePasswordManagerSynciOS = "enable-password-manager-sync-ios";
public const string AndroidMutualTls = "mutual-tls";
public const string SingleTapPasskeyCreation = "single-tap-passkey-creation";
public const string SingleTapPasskeyAuthentication = "single-tap-passkey-authentication";
public const string EnablePMAuthenticatorSync = "enable-pm-bwa-sync";
public const string PM3503_MobileAnonAddySelfHostAlias = "anon-addy-self-host-alias";
public const string PM3553_MobileSimpleLoginSelfHostAlias = "simple-login-self-host-alias";
/* Platform Team */
public const string PersistPopupView = "persist-popup-view";
public const string StorageReseedRefactor = "storage-reseed-refactor";
public const string WebPush = "web-push";
public const string RecordInstallationLastActivityDate = "installation-last-activity-date";
/* Tools Team */ /* Tools Team */
public const string ItemShare = "item-share"; public const string ItemShare = "item-share";
@ -116,6 +186,7 @@ public static class FeatureFlagKeys
public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications"; public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications";
public const string DesktopSendUIRefresh = "desktop-send-ui-refresh"; public const string DesktopSendUIRefresh = "desktop-send-ui-refresh";
public const string ExportAttachments = "export-attachments"; public const string ExportAttachments = "export-attachments";
public const string GeneratorToolsModernization = "generator-tools-modernization";
/* Vault Team */ /* Vault Team */
public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge"; public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge";
@ -125,64 +196,7 @@ public static class FeatureFlagKeys
public const string VaultBulkManagementAction = "vault-bulk-management-action"; public const string VaultBulkManagementAction = "vault-bulk-management-action";
public const string RestrictProviderAccess = "restrict-provider-access"; public const string RestrictProviderAccess = "restrict-provider-access";
public const string SecurityTasks = "security-tasks"; public const string SecurityTasks = "security-tasks";
/* Auth Team */
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection";
public const string DuoRedirect = "duo-redirect";
public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
public const string EmailVerification = "email-verification";
public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays";
public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service";
public const string InlineMenuFieldQualification = "inline-menu-field-qualification";
public const string InlineMenuPositioningImprovements = "inline-menu-positioning-improvements";
public const string DeviceTrustLogging = "pm-8285-device-trust-logging";
public const string SSHKeyItemVaultItem = "ssh-key-vault-item";
public const string SSHAgent = "ssh-agent";
public const string SSHVersionCheckQAOverride = "ssh-version-check-qa-override";
public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token";
public const string IdpAutoSubmitLogin = "idp-auto-submit-login";
public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh";
public const string GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor";
public const string DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2";
public const string NativeCarouselFlow = "native-carousel-flow";
public const string NativeCreateAccountFlow = "native-create-account-flow";
public const string NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements";
public const string BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain";
public const string NotificationRefresh = "notification-refresh";
public const string PersistPopupView = "persist-popup-view";
public const string CipherKeyEncryption = "cipher-key-encryption"; public const string CipherKeyEncryption = "cipher-key-encryption";
public const string EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill";
public const string StorageReseedRefactor = "storage-reseed-refactor";
public const string TrialPayment = "PM-8163-trial-payment";
public const string RemoveServerVersionHeader = "remove-server-version-header";
public const string GeneratorToolsModernization = "generator-tools-modernization";
public const string NewDeviceVerification = "new-device-verification";
public const string MacOsNativeCredentialSync = "macos-native-credential-sync";
public const string InlineMenuTotp = "inline-menu-totp";
public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration";
public const string AppReviewPrompt = "app-review-prompt";
public const string ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs";
public const string Argon2Default = "argon2-default";
public const string UsePricingService = "use-pricing-service";
public const string RecordInstallationLastActivityDate = "installation-last-activity-date";
public const string AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner";
public const string SingleTapPasskeyCreation = "single-tap-passkey-creation";
public const string SingleTapPasskeyAuthentication = "single-tap-passkey-authentication";
public const string EnablePMAuthenticatorSync = "enable-pm-bwa-sync";
public const string P15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal";
public const string AndroidMutualTls = "mutual-tls";
public const string RecoveryCodeLogin = "pm-17128-recovery-code-login";
public const string PM3503_MobileAnonAddySelfHostAlias = "anon-addy-self-host-alias";
public const string WebPush = "web-push";
public const string AndroidImportLoginsFlow = "import-logins-flow";
public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features";
public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method";
public const string PM3553_MobileSimpleLoginSelfHostAlias = "simple-login-self-host-alias";
public const string SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor";
public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor";
public static List<string> GetAllKeys() public static List<string> GetAllKeys()
{ {

View File

@ -23,8 +23,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" /> <PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.402.28" /> <PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.402.61" />
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.85" /> <PackageReference Include="AWSSDK.SQS" Version="3.7.400.118" />
<PackageReference Include="Azure.Data.Tables" Version="12.9.0" /> <PackageReference Include="Azure.Data.Tables" Version="12.9.0" />
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" /> <PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.10" /> <PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.10" />
@ -61,7 +61,7 @@
<PackageReference Include="Otp.NET" Version="1.4.0" /> <PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" /> <PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.10" /> <PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.10" />
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.6.0" /> <PackageReference Include="LaunchDarkly.ServerSdk" Version="8.7.0" />
<PackageReference Include="Quartz" Version="3.13.1" /> <PackageReference Include="Quartz" Version="3.13.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.13.1" /> <PackageReference Include="Quartz.Extensions.Hosting" Version="3.13.1" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.13.1" /> <PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.13.1" />

View File

@ -14,5 +14,7 @@ public enum ClientType : byte
[Display(Name = "Desktop App")] [Display(Name = "Desktop App")]
Desktop = 3, Desktop = 3,
[Display(Name = "Mobile App")] [Display(Name = "Mobile App")]
Mobile = 4 Mobile = 4,
[Display(Name = "CLI")]
Cli = 5
} }

View File

@ -0,0 +1,34 @@
#nullable enable
using Bit.Core.Entities;
using Bit.Core.Enums;
namespace Bit.Core.KeyManagement.Models.Data;
public class MasterPasswordUnlockData
{
public KdfType KdfType { get; set; }
public int KdfIterations { get; set; }
public int? KdfMemory { get; set; }
public int? KdfParallelism { get; set; }
public required string Email { get; set; }
public required string MasterKeyAuthenticationHash { get; set; }
public required string MasterKeyEncryptedUserKey { get; set; }
public string? MasterPasswordHint { get; set; }
public bool ValidateForUser(User user)
{
if (KdfType != user.Kdf || KdfMemory != user.KdfMemory || KdfParallelism != user.KdfParallelism || KdfIterations != user.KdfIterations)
{
return false;
}
else if (Email != user.Email)
{
return false;
}
else
{
return true;
}
}
}

View File

@ -0,0 +1,28 @@
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities;
using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities;
namespace Bit.Core.KeyManagement.Models.Data;
public class RotateUserAccountKeysData
{
// Authentication for this requests
public string OldMasterKeyAuthenticationHash { get; set; }
// Other keys encrypted by the userkey
public string UserKeyEncryptedAccountPrivateKey { get; set; }
public string AccountPublicKey { get; set; }
// All methods to get to the userkey
public MasterPasswordUnlockData MasterPasswordUnlockData { get; set; }
public IEnumerable<EmergencyAccess> EmergencyAccesses { get; set; }
public IReadOnlyList<OrganizationUser> OrganizationUsers { get; set; }
public IEnumerable<WebAuthnLoginRotateKeyData> WebAuthnKeys { get; set; }
// User vault data encrypted by the userkey
public IEnumerable<Cipher> Ciphers { get; set; }
public IEnumerable<Folder> Folders { get; set; }
public IReadOnlyList<Send> Sends { get; set; }
}

View File

@ -0,0 +1,20 @@
using Bit.Core.Entities;
using Bit.Core.KeyManagement.Models.Data;
using Microsoft.AspNetCore.Identity;
namespace Bit.Core.KeyManagement.UserKey;
/// <summary>
/// Responsible for rotation of a user key and updating database with re-encrypted data
/// </summary>
public interface IRotateUserAccountKeysCommand
{
/// <summary>
/// Sets a new user key and updates all encrypted data.
/// </summary>
/// <param name="model">All necessary information for rotation. If data is not included, this will lead to the change being rejected.</param>
/// <returns>An IdentityResult for verification of the master password hash</returns>
/// <exception cref="ArgumentNullException">User must be provided.</exception>
/// <exception cref="InvalidOperationException">User KDF settings and email must match the model provided settings.</exception>
Task<IdentityResult> RotateUserAccountKeysAsync(User user, RotateUserAccountKeysData model);
}

View File

@ -0,0 +1,134 @@
using Bit.Core.Auth.Repositories;
using Bit.Core.Entities;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Tools.Repositories;
using Bit.Core.Vault.Repositories;
using Microsoft.AspNetCore.Identity;
namespace Bit.Core.KeyManagement.UserKey.Implementations;
/// <inheritdoc />
public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
{
private readonly IUserService _userService;
private readonly IUserRepository _userRepository;
private readonly ICipherRepository _cipherRepository;
private readonly IFolderRepository _folderRepository;
private readonly ISendRepository _sendRepository;
private readonly IEmergencyAccessRepository _emergencyAccessRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IPushNotificationService _pushService;
private readonly IdentityErrorDescriber _identityErrorDescriber;
private readonly IWebAuthnCredentialRepository _credentialRepository;
private readonly IPasswordHasher<User> _passwordHasher;
/// <summary>
/// Instantiates a new <see cref="RotateUserAccountKeysCommand"/>
/// </summary>
/// <param name="userService">Master password hash validation</param>
/// <param name="userRepository">Updates user keys and re-encrypted data if needed</param>
/// <param name="cipherRepository">Provides a method to update re-encrypted cipher data</param>
/// <param name="folderRepository">Provides a method to update re-encrypted folder data</param>
/// <param name="sendRepository">Provides a method to update re-encrypted send data</param>
/// <param name="emergencyAccessRepository">Provides a method to update re-encrypted emergency access data</param>
/// <param name="organizationUserRepository">Provides a method to update re-encrypted organization user data</param>
/// <param name="passwordHasher">Hashes the new master password</param>
/// <param name="pushService">Logs out user from other devices after successful rotation</param>
/// <param name="errors">Provides a password mismatch error if master password hash validation fails</param>
/// <param name="credentialRepository">Provides a method to update re-encrypted WebAuthn keys</param>
public RotateUserAccountKeysCommand(IUserService userService, IUserRepository userRepository,
ICipherRepository cipherRepository, IFolderRepository folderRepository, ISendRepository sendRepository,
IEmergencyAccessRepository emergencyAccessRepository, IOrganizationUserRepository organizationUserRepository,
IPasswordHasher<User> passwordHasher,
IPushNotificationService pushService, IdentityErrorDescriber errors, IWebAuthnCredentialRepository credentialRepository)
{
_userService = userService;
_userRepository = userRepository;
_cipherRepository = cipherRepository;
_folderRepository = folderRepository;
_sendRepository = sendRepository;
_emergencyAccessRepository = emergencyAccessRepository;
_organizationUserRepository = organizationUserRepository;
_pushService = pushService;
_identityErrorDescriber = errors;
_credentialRepository = credentialRepository;
_passwordHasher = passwordHasher;
}
/// <inheritdoc />
public async Task<IdentityResult> RotateUserAccountKeysAsync(User user, RotateUserAccountKeysData model)
{
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
if (!await _userService.CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash))
{
return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch());
}
var now = DateTime.UtcNow;
user.RevisionDate = user.AccountRevisionDate = now;
user.LastKeyRotationDate = now;
user.SecurityStamp = Guid.NewGuid().ToString();
if (
!model.MasterPasswordUnlockData.ValidateForUser(user)
)
{
throw new InvalidOperationException("The provided master password unlock data is not valid for this user.");
}
if (
model.AccountPublicKey != user.PublicKey
)
{
throw new InvalidOperationException("The provided account public key does not match the user's current public key, and changing the account asymmetric keypair is currently not supported during key rotation.");
}
user.Key = model.MasterPasswordUnlockData.MasterKeyEncryptedUserKey;
user.PrivateKey = model.UserKeyEncryptedAccountPrivateKey;
user.MasterPassword = _passwordHasher.HashPassword(user, model.MasterPasswordUnlockData.MasterKeyAuthenticationHash);
user.MasterPasswordHint = model.MasterPasswordUnlockData.MasterPasswordHint;
List<UpdateEncryptedDataForKeyRotation> saveEncryptedDataActions = new();
if (model.Ciphers.Any())
{
saveEncryptedDataActions.Add(_cipherRepository.UpdateForKeyRotation(user.Id, model.Ciphers));
}
if (model.Folders.Any())
{
saveEncryptedDataActions.Add(_folderRepository.UpdateForKeyRotation(user.Id, model.Folders));
}
if (model.Sends.Any())
{
saveEncryptedDataActions.Add(_sendRepository.UpdateForKeyRotation(user.Id, model.Sends));
}
if (model.EmergencyAccesses.Any())
{
saveEncryptedDataActions.Add(
_emergencyAccessRepository.UpdateForKeyRotation(user.Id, model.EmergencyAccesses));
}
if (model.OrganizationUsers.Any())
{
saveEncryptedDataActions.Add(
_organizationUserRepository.UpdateForKeyRotation(user.Id, model.OrganizationUsers));
}
if (model.WebAuthnKeys.Any())
{
saveEncryptedDataActions.Add(_credentialRepository.UpdateKeysForRotationAsync(user.Id, model.WebAuthnKeys));
}
await _userRepository.UpdateUserKeyAndEncryptedDataV2Async(user, saveEncryptedDataActions);
await _pushService.PushLogOutAsync(user.Id);
return IdentityResult.Success;
}
}

View File

@ -14,18 +14,17 @@
</td> </td>
</tr> </tr>
</table> </table>
<table width="100%" border="0" cellpadding="0" cellspacing="0" <table width="100%" border="0" cellpadding="0" cellspacing="0" style="padding-bottom: 24px; padding-left: 24px; padding-right: 24px; text-align: center;" align="center">
style="display: table; width:100%; padding-bottom: 24px; text-align: center;" align="center">
<tr> <tr>
<td display="display: table-cell"> <td>
<a href="{{ReviewPasswordsUrl}}" clicktracking=off target="_blank" <a href="{{ReviewPasswordsUrl}}" clicktracking=off target="_blank"
style="display: inline-block; font-weight: bold; color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; border-radius: 999px; background-color: #175DDC; border-color: #175DDC; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;"> style="display: inline-block; font-weight: bold; color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; border-radius: 999px; background-color: #175DDC; border-color: #175DDC; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
Review at-risk passwords Review at-risk passwords
</a> </a>
</td> </td>
</tr> </tr>
<table width="100%" border="0" cellpadding="0" cellspacing="0" </table>
style="display: table; width:100%; padding-bottom: 24px; text-align: center;" align="center"> <table width="100%" border="0" cellpadding="0" cellspacing="0" style="padding-bottom: 24px; padding-left: 24px; padding-right: 24px; text-align: center;" align="center">
<tr> <tr>
<td display="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-style: normal; font-weight: 400; font-size: 12px; line-height: 16px;"> <td display="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-style: normal; font-weight: 400; font-size: 12px; line-height: 16px;">
{{formatAdminOwnerEmails AdminOwnerEmails}} {{formatAdminOwnerEmails AdminOwnerEmails}}

View File

@ -6,12 +6,14 @@ Launch the Bitwarden extension to review your at-risk passwords.
Review at-risk passwords ({{{ReviewPasswordsUrl}}}) Review at-risk passwords ({{{ReviewPasswordsUrl}}})
{{#if (eq (length AdminOwnerEmails) 1)}} {{#if AdminOwnerEmails.[0]}}
This request was initiated by {{AdminOwnerEmails.[0]}}. {{#if AdminOwnerEmails.[1]}}
{{else}} This request was initiated by
This request was initiated by {{#each AdminOwnerEmails}}
{{#each AdminOwnerEmails}} {{#if @last}}and {{/if}}{{this}}{{#unless @last}}, {{/unless}}
{{#if @last}}and {{/if}}{{this}}{{#unless @last}}, {{/unless}} {{/each}}.
{{/each}}. {{else}}
This request was initiated by {{AdminOwnerEmails.[0]}}.
{{/if}}
{{/if}} {{/if}}
{{/SecurityTasksHtmlLayout}} {{/SecurityTasksHtmlLayout}}

View File

@ -55,6 +55,7 @@ public class OrganizationLicense : ILicense
UseSecretsManager = org.UseSecretsManager; UseSecretsManager = org.UseSecretsManager;
SmSeats = org.SmSeats; SmSeats = org.SmSeats;
SmServiceAccounts = org.SmServiceAccounts; SmServiceAccounts = org.SmServiceAccounts;
UseRiskInsights = org.UseRiskInsights;
// Deprecated. Left for backwards compatibility with old license versions. // Deprecated. Left for backwards compatibility with old license versions.
LimitCollectionCreationDeletion = org.LimitCollectionCreation || org.LimitCollectionDeletion; LimitCollectionCreationDeletion = org.LimitCollectionCreation || org.LimitCollectionDeletion;
@ -143,6 +144,7 @@ public class OrganizationLicense : ILicense
public bool UseSecretsManager { get; set; } public bool UseSecretsManager { get; set; }
public int? SmSeats { get; set; } public int? SmSeats { get; set; }
public int? SmServiceAccounts { get; set; } public int? SmServiceAccounts { get; set; }
public bool UseRiskInsights { get; set; }
// Deprecated. Left for backwards compatibility with old license versions. // Deprecated. Left for backwards compatibility with old license versions.
public bool LimitCollectionCreationDeletion { get; set; } = true; public bool LimitCollectionCreationDeletion { get; set; } = true;
@ -218,7 +220,8 @@ public class OrganizationLicense : ILicense
!p.Name.Equals(nameof(Issued)) && !p.Name.Equals(nameof(Issued)) &&
!p.Name.Equals(nameof(Refresh)) !p.Name.Equals(nameof(Refresh))
) )
)) ) &&
!p.Name.Equals(nameof(UseRiskInsights)))
.OrderBy(p => p.Name) .OrderBy(p => p.Name)
.Select(p => $"{p.Name}:{Utilities.CoreHelpers.FormatLicenseSignatureValue(p.GetValue(this, null))}") .Select(p => $"{p.Name}:{Utilities.CoreHelpers.FormatLicenseSignatureValue(p.GetValue(this, null))}")
.Aggregate((c, n) => $"{c}|{n}"); .Aggregate((c, n) => $"{c}|{n}");

View File

@ -8,7 +8,7 @@ public class SecurityTaskNotificationViewModel : BaseMailModel
public bool TaskCountPlural => TaskCount != 1; public bool TaskCountPlural => TaskCount != 1;
public IEnumerable<string> AdminOwnerEmails { get; set; } public List<string> AdminOwnerEmails { get; set; }
public string ReviewPasswordsUrl => $"{WebVaultUrl}/browser-extension-prompt"; public string ReviewPasswordsUrl => $"{WebVaultUrl}/browser-extension-prompt";
} }

View File

@ -13,6 +13,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.Models.Business.Tokenables; using Bit.Core.Models.Business.Tokenables;
using Bit.Core.OrganizationFeatures.OrganizationCollections; using Bit.Core.OrganizationFeatures.OrganizationCollections;
using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces;
@ -168,6 +169,8 @@ public static class OrganizationServiceCollectionExtensions
services.AddScoped<IOrganizationUserUserDetailsQuery, OrganizationUserUserDetailsQuery>(); services.AddScoped<IOrganizationUserUserDetailsQuery, OrganizationUserUserDetailsQuery>();
services.AddScoped<IGetOrganizationUsersManagementStatusQuery, GetOrganizationUsersManagementStatusQuery>(); services.AddScoped<IGetOrganizationUsersManagementStatusQuery, GetOrganizationUsersManagementStatusQuery>();
services.AddScoped<IRestoreOrganizationUserCommand, RestoreOrganizationUserCommand>();
services.AddScoped<IAuthorizationHandler, OrganizationUserUserMiniDetailsAuthorizationHandler>(); services.AddScoped<IAuthorizationHandler, OrganizationUserUserMiniDetailsAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, OrganizationUserUserDetailsAuthorizationHandler>(); services.AddScoped<IAuthorizationHandler, OrganizationUserUserDetailsAuthorizationHandler>();
services.AddScoped<IHasConfirmedOwnersExceptQuery, HasConfirmedOwnersExceptQuery>(); services.AddScoped<IHasConfirmedOwnersExceptQuery, HasConfirmedOwnersExceptQuery>();

View File

@ -32,5 +32,7 @@ public interface IUserRepository : IRepository<User, Guid>
/// <param name="updateDataActions">Registered database calls to update re-encrypted data.</param> /// <param name="updateDataActions">Registered database calls to update re-encrypted data.</param>
Task UpdateUserKeyAndEncryptedDataAsync(User user, Task UpdateUserKeyAndEncryptedDataAsync(User user,
IEnumerable<UpdateEncryptedDataForKeyRotation> updateDataActions); IEnumerable<UpdateEncryptedDataForKeyRotation> updateDataActions);
Task UpdateUserKeyAndEncryptedDataV2Async(User user,
IEnumerable<UpdateEncryptedDataForKeyRotation> updateDataActions);
Task DeleteManyAsync(IEnumerable<User> users); Task DeleteManyAsync(IEnumerable<User> users);
} }

View File

@ -1,5 +1,6 @@
using System.Net; using System.Net;
using System.Reflection; using System.Reflection;
using System.Text.Json;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Models.Mail; using Bit.Core.AdminConsole.Models.Mail;
@ -752,7 +753,21 @@ public class HandlebarsMailService : IMailService
return; return;
} }
var emailList = ((IEnumerable<string>)parameters[0]).ToList(); var emailList = new List<string>();
if (parameters[0] is JsonElement jsonElement && jsonElement.ValueKind == JsonValueKind.Array)
{
emailList = jsonElement.EnumerateArray().Select(e => e.GetString()).ToList();
}
else if (parameters[0] is IEnumerable<string> emails)
{
emailList = emails.ToList();
}
else
{
writer.WriteSafeString(string.Empty);
return;
}
if (emailList.Count == 0) if (emailList.Count == 0)
{ {
writer.WriteSafeString(string.Empty); writer.WriteSafeString(string.Empty);
@ -774,7 +789,7 @@ public class HandlebarsMailService : IMailService
{ {
outputMessage += string.Join(", ", emailList.Take(emailList.Count - 1) outputMessage += string.Join(", ", emailList.Take(emailList.Count - 1)
.Select(email => constructAnchorElement(email))); .Select(email => constructAnchorElement(email)));
outputMessage += $", and {constructAnchorElement(emailList.Last())}."; outputMessage += $" and {constructAnchorElement(emailList.Last())}.";
} }
writer.WriteSafeString($"{outputMessage}"); writer.WriteSafeString($"{outputMessage}");
@ -1250,7 +1265,7 @@ public class HandlebarsMailService : IMailService
{ {
OrgName = CoreHelpers.SanitizeForEmail(sanitizedOrgName, false), OrgName = CoreHelpers.SanitizeForEmail(sanitizedOrgName, false),
TaskCount = notification.TaskCount, TaskCount = notification.TaskCount,
AdminOwnerEmails = adminOwnerEmails, AdminOwnerEmails = adminOwnerEmails.ToList(),
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
}; };
message.Category = "SecurityTasksNotification"; message.Category = "SecurityTasksNotification";

View File

@ -9,6 +9,8 @@ using Bit.Core.Billing.Models.Api.Responses;
using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Models.Business;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -16,6 +18,7 @@ using Bit.Core.Models.BitStripe;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Settings; using Bit.Core.Settings;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Stripe; using Stripe;
using PaymentMethod = Stripe.PaymentMethod; using PaymentMethod = Stripe.PaymentMethod;
@ -36,6 +39,8 @@ public class StripePaymentService : IPaymentService
private readonly ITaxService _taxService; private readonly ITaxService _taxService;
private readonly ISubscriberService _subscriberService; private readonly ISubscriberService _subscriberService;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
private readonly IAutomaticTaxFactory _automaticTaxFactory;
private readonly IAutomaticTaxStrategy _personalUseTaxStrategy;
public StripePaymentService( public StripePaymentService(
ITransactionRepository transactionRepository, ITransactionRepository transactionRepository,
@ -46,7 +51,9 @@ public class StripePaymentService : IPaymentService
IFeatureService featureService, IFeatureService featureService,
ITaxService taxService, ITaxService taxService,
ISubscriberService subscriberService, ISubscriberService subscriberService,
IPricingClient pricingClient) IPricingClient pricingClient,
IAutomaticTaxFactory automaticTaxFactory,
[FromKeyedServices(AutomaticTaxFactory.PersonalUse)] IAutomaticTaxStrategy personalUseTaxStrategy)
{ {
_transactionRepository = transactionRepository; _transactionRepository = transactionRepository;
_logger = logger; _logger = logger;
@ -57,6 +64,8 @@ public class StripePaymentService : IPaymentService
_taxService = taxService; _taxService = taxService;
_subscriberService = subscriberService; _subscriberService = subscriberService;
_pricingClient = pricingClient; _pricingClient = pricingClient;
_automaticTaxFactory = automaticTaxFactory;
_personalUseTaxStrategy = personalUseTaxStrategy;
} }
private async Task ChangeOrganizationSponsorship( private async Task ChangeOrganizationSponsorship(
@ -91,9 +100,7 @@ public class StripePaymentService : IPaymentService
SubscriptionUpdate subscriptionUpdate, bool invoiceNow = false) SubscriptionUpdate subscriptionUpdate, bool invoiceNow = false)
{ {
// remember, when in doubt, throw // remember, when in doubt, throw
var subGetOptions = new SubscriptionGetOptions(); var subGetOptions = new SubscriptionGetOptions { Expand = ["customer.tax", "customer.tax_ids"] };
// subGetOptions.AddExpand("customer");
subGetOptions.AddExpand("customer.tax");
var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subGetOptions); var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subGetOptions);
if (sub == null) if (sub == null)
{ {
@ -124,7 +131,19 @@ public class StripePaymentService : IPaymentService
new SubscriptionPendingInvoiceItemIntervalOptions { Interval = "month" }; new SubscriptionPendingInvoiceItemIntervalOptions { Interval = "month" };
} }
subUpdateOptions.EnableAutomaticTax(sub.Customer, sub); if (subscriptionUpdate is CompleteSubscriptionUpdate)
{
if (_featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
{
var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscriber, updatedItemOptions.Select(x => x.Plan ?? x.Price));
var automaticTaxStrategy = await _automaticTaxFactory.CreateAsync(automaticTaxParameters);
automaticTaxStrategy.SetUpdateOptions(subUpdateOptions, sub);
}
else
{
subUpdateOptions.EnableAutomaticTax(sub.Customer, sub);
}
}
if (!subscriptionUpdate.UpdateNeeded(sub)) if (!subscriptionUpdate.UpdateNeeded(sub))
{ {
@ -811,21 +830,46 @@ public class StripePaymentService : IPaymentService
}); });
} }
if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId) && if (_featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
customer.Subscriptions.Any(sub =>
sub.Id == subscriber.GatewaySubscriptionId &&
!sub.AutomaticTax.Enabled) &&
customer.HasTaxLocationVerified())
{ {
var subscriptionUpdateOptions = new SubscriptionUpdateOptions if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId))
{ {
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }, var subscriptionGetOptions = new SubscriptionGetOptions
DefaultTaxRates = [] {
}; Expand = ["customer.tax", "customer.tax_ids"]
};
var subscription = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions);
_ = await _stripeAdapter.SubscriptionUpdateAsync( var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscriber, subscription.Items.Select(x => x.Price.Id));
subscriber.GatewaySubscriptionId, var automaticTaxStrategy = await _automaticTaxFactory.CreateAsync(automaticTaxParameters);
subscriptionUpdateOptions); var subscriptionUpdateOptions = automaticTaxStrategy.GetUpdateOptions(subscription);
if (subscriptionUpdateOptions != null)
{
_ = await _stripeAdapter.SubscriptionUpdateAsync(
subscriber.GatewaySubscriptionId,
subscriptionUpdateOptions);
}
}
}
else
{
if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId) &&
customer.Subscriptions.Any(sub =>
sub.Id == subscriber.GatewaySubscriptionId &&
!sub.AutomaticTax.Enabled) &&
customer.HasTaxLocationVerified())
{
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true },
DefaultTaxRates = []
};
_ = await _stripeAdapter.SubscriptionUpdateAsync(
subscriber.GatewaySubscriptionId,
subscriptionUpdateOptions);
}
} }
} }
catch catch
@ -1214,6 +1258,8 @@ public class StripePaymentService : IPaymentService
} }
} }
_personalUseTaxStrategy.SetInvoiceCreatePreviewOptions(options);
try try
{ {
var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options);
@ -1256,10 +1302,6 @@ public class StripePaymentService : IPaymentService
var options = new InvoiceCreatePreviewOptions var options = new InvoiceCreatePreviewOptions
{ {
AutomaticTax = new InvoiceAutomaticTaxOptions
{
Enabled = true,
},
Currency = "usd", Currency = "usd",
SubscriptionDetails = new InvoiceSubscriptionDetailsOptions SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
{ {
@ -1347,9 +1389,11 @@ public class StripePaymentService : IPaymentService
]; ];
} }
Customer gatewayCustomer = null;
if (!string.IsNullOrWhiteSpace(gatewayCustomerId)) if (!string.IsNullOrWhiteSpace(gatewayCustomerId))
{ {
var gatewayCustomer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId); gatewayCustomer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId);
if (gatewayCustomer.Discount != null) if (gatewayCustomer.Discount != null)
{ {
@ -1367,6 +1411,10 @@ public class StripePaymentService : IPaymentService
} }
} }
var automaticTaxFactoryParameters = new AutomaticTaxFactoryParameters(parameters.PasswordManager.Plan);
var automaticTaxStrategy = await _automaticTaxFactory.CreateAsync(automaticTaxFactoryParameters);
automaticTaxStrategy.SetInvoiceCreatePreviewOptions(options);
try try
{ {
var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options);

View File

@ -1,10 +1,13 @@
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Platform.Push; using Bit.Core.Platform.Push;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Tools.Enums; using Bit.Core.Tools.Enums;
using Bit.Core.Tools.ImportFeatures.Interfaces; using Bit.Core.Tools.ImportFeatures.Interfaces;
using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Models.Business;
@ -26,7 +29,8 @@ public class ImportCiphersCommand : IImportCiphersCommand
private readonly ICollectionRepository _collectionRepository; private readonly ICollectionRepository _collectionRepository;
private readonly IReferenceEventService _referenceEventService; private readonly IReferenceEventService _referenceEventService;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IFeatureService _featureService;
public ImportCiphersCommand( public ImportCiphersCommand(
ICipherRepository cipherRepository, ICipherRepository cipherRepository,
@ -37,7 +41,9 @@ public class ImportCiphersCommand : IImportCiphersCommand
IPushNotificationService pushService, IPushNotificationService pushService,
IPolicyService policyService, IPolicyService policyService,
IReferenceEventService referenceEventService, IReferenceEventService referenceEventService,
ICurrentContext currentContext) ICurrentContext currentContext,
IPolicyRequirementQuery policyRequirementQuery,
IFeatureService featureService)
{ {
_cipherRepository = cipherRepository; _cipherRepository = cipherRepository;
_folderRepository = folderRepository; _folderRepository = folderRepository;
@ -48,9 +54,10 @@ public class ImportCiphersCommand : IImportCiphersCommand
_policyService = policyService; _policyService = policyService;
_referenceEventService = referenceEventService; _referenceEventService = referenceEventService;
_currentContext = currentContext; _currentContext = currentContext;
_policyRequirementQuery = policyRequirementQuery;
_featureService = featureService;
} }
public async Task ImportIntoIndividualVaultAsync( public async Task ImportIntoIndividualVaultAsync(
List<Folder> folders, List<Folder> folders,
List<CipherDetails> ciphers, List<CipherDetails> ciphers,
@ -58,8 +65,11 @@ public class ImportCiphersCommand : IImportCiphersCommand
Guid importingUserId) Guid importingUserId)
{ {
// Make sure the user can save new ciphers to their personal vault // Make sure the user can save new ciphers to their personal vault
var anyPersonalOwnershipPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(importingUserId, PolicyType.PersonalOwnership); var isPersonalVaultRestricted = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)
if (anyPersonalOwnershipPolicies) ? (await _policyRequirementQuery.GetAsync<PersonalOwnershipPolicyRequirement>(importingUserId)).DisablePersonalOwnership
: await _policyService.AnyPoliciesApplicableToUserAsync(importingUserId, PolicyType.PersonalOwnership);
if (isPersonalVaultRestricted)
{ {
throw new BadRequestException("You cannot import items into your personal vault because you are " + throw new BadRequestException("You cannot import items into your personal vault because you are " +
"a member of an organization which forbids it."); "a member of an organization which forbids it.");

View File

@ -16,7 +16,11 @@ public static class DeviceTypes
DeviceType.LinuxDesktop, DeviceType.LinuxDesktop,
DeviceType.MacOsDesktop, DeviceType.MacOsDesktop,
DeviceType.WindowsDesktop, DeviceType.WindowsDesktop,
DeviceType.UWP, DeviceType.UWP
];
public static IReadOnlyCollection<DeviceType> CliTypes { get; } =
[
DeviceType.WindowsCLI, DeviceType.WindowsCLI,
DeviceType.MacOsCLI, DeviceType.MacOsCLI,
DeviceType.LinuxCLI DeviceType.LinuxCLI
@ -50,6 +54,7 @@ public static class DeviceTypes
{ {
not null when MobileTypes.Contains(deviceType.Value) => ClientType.Mobile, not null when MobileTypes.Contains(deviceType.Value) => ClientType.Mobile,
not null when DesktopTypes.Contains(deviceType.Value) => ClientType.Desktop, not null when DesktopTypes.Contains(deviceType.Value) => ClientType.Desktop,
not null when CliTypes.Contains(deviceType.Value) => ClientType.Cli,
not null when BrowserExtensionTypes.Contains(deviceType.Value) => ClientType.Browser, not null when BrowserExtensionTypes.Contains(deviceType.Value) => ClientType.Browser,
not null when BrowserTypes.Contains(deviceType.Value) => ClientType.Web, not null when BrowserTypes.Contains(deviceType.Value) => ClientType.Web,
_ => ClientType.All _ => ClientType.All

View File

@ -48,9 +48,16 @@ public class CreateManyTaskNotificationsCommand : ICreateManyTaskNotificationsCo
}).ToList(); }).ToList();
var organization = await _organizationRepository.GetByIdAsync(orgId); var organization = await _organizationRepository.GetByIdAsync(orgId);
var orgAdminEmails = await _organizationUserRepository.GetManyDetailsByRoleAsync(orgId, OrganizationUserType.Admin); var orgAdminEmails = (await _organizationUserRepository.GetManyDetailsByRoleAsync(orgId, OrganizationUserType.Admin))
var orgOwnerEmails = await _organizationUserRepository.GetManyDetailsByRoleAsync(orgId, OrganizationUserType.Owner); .Select(u => u.Email)
var orgAdminAndOwnerEmails = orgAdminEmails.Concat(orgOwnerEmails).Select(x => x.Email).Distinct().ToList(); .ToList();
var orgOwnerEmails = (await _organizationUserRepository.GetManyDetailsByRoleAsync(orgId, OrganizationUserType.Owner))
.Select(u => u.Email)
.ToList();
// Ensure proper deserialization of emails
var orgAdminAndOwnerEmails = orgAdminEmails.Concat(orgOwnerEmails).Distinct().ToList();
await _mailService.SendBulkSecurityTaskNotificationsAsync(organization, userTaskCount, orgAdminAndOwnerEmails); await _mailService.SendBulkSecurityTaskNotificationsAsync(organization, userTaskCount, orgAdminAndOwnerEmails);

View File

@ -15,7 +15,7 @@ public interface ICipherService
long requestLength, Guid savingUserId, bool orgAdmin = false); long requestLength, Guid savingUserId, bool orgAdmin = false);
Task CreateAttachmentShareAsync(Cipher cipher, Stream stream, string fileName, string key, long requestLength, Task CreateAttachmentShareAsync(Cipher cipher, Stream stream, string fileName, string key, long requestLength,
string attachmentId, Guid organizationShareId); string attachmentId, Guid organizationShareId);
Task DeleteAsync(Cipher cipher, Guid deletingUserId, bool orgAdmin = false); Task DeleteAsync(CipherDetails cipherDetails, Guid deletingUserId, bool orgAdmin = false);
Task DeleteManyAsync(IEnumerable<Guid> cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false); Task DeleteManyAsync(IEnumerable<Guid> cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false);
Task<DeleteAttachmentResponseData> DeleteAttachmentAsync(Cipher cipher, string attachmentId, Guid deletingUserId, bool orgAdmin = false); Task<DeleteAttachmentResponseData> DeleteAttachmentAsync(Cipher cipher, string attachmentId, Guid deletingUserId, bool orgAdmin = false);
Task PurgeAsync(Guid organizationId); Task PurgeAsync(Guid organizationId);
@ -27,9 +27,9 @@ public interface ICipherService
Task ShareManyAsync(IEnumerable<(Cipher cipher, DateTime? lastKnownRevisionDate)> ciphers, Guid organizationId, Task ShareManyAsync(IEnumerable<(Cipher cipher, DateTime? lastKnownRevisionDate)> ciphers, Guid organizationId,
IEnumerable<Guid> collectionIds, Guid sharingUserId); IEnumerable<Guid> collectionIds, Guid sharingUserId);
Task SaveCollectionsAsync(Cipher cipher, IEnumerable<Guid> collectionIds, Guid savingUserId, bool orgAdmin); Task SaveCollectionsAsync(Cipher cipher, IEnumerable<Guid> collectionIds, Guid savingUserId, bool orgAdmin);
Task SoftDeleteAsync(Cipher cipher, Guid deletingUserId, bool orgAdmin = false); Task SoftDeleteAsync(CipherDetails cipherDetails, Guid deletingUserId, bool orgAdmin = false);
Task SoftDeleteManyAsync(IEnumerable<Guid> cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false); Task SoftDeleteManyAsync(IEnumerable<Guid> cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false);
Task RestoreAsync(Cipher cipher, Guid restoringUserId, bool orgAdmin = false); Task RestoreAsync(CipherDetails cipherDetails, Guid restoringUserId, bool orgAdmin = false);
Task<ICollection<CipherOrganizationDetails>> RestoreManyAsync(IEnumerable<Guid> cipherIds, Guid restoringUserId, Guid? organizationId = null, bool orgAdmin = false); Task<ICollection<CipherOrganizationDetails>> RestoreManyAsync(IEnumerable<Guid> cipherIds, Guid restoringUserId, Guid? organizationId = null, bool orgAdmin = false);
Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachmentId); Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachmentId);
Task<AttachmentResponseData> GetAttachmentDownloadDataAsync(Cipher cipher, string attachmentId); Task<AttachmentResponseData> GetAttachmentDownloadDataAsync(Cipher cipher, string attachmentId);

View File

@ -1,5 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
@ -12,6 +14,7 @@ using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Models.Business;
using Bit.Core.Tools.Services; using Bit.Core.Tools.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Core.Vault.Authorization.Permissions;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Enums; using Bit.Core.Vault.Enums;
using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Models.Data;
@ -41,6 +44,9 @@ public class CipherService : ICipherService
private readonly IReferenceEventService _referenceEventService; private readonly IReferenceEventService _referenceEventService;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IGetCipherPermissionsForUserQuery _getCipherPermissionsForUserQuery; private readonly IGetCipherPermissionsForUserQuery _getCipherPermissionsForUserQuery;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IApplicationCacheService _applicationCacheService;
private readonly IFeatureService _featureService;
public CipherService( public CipherService(
ICipherRepository cipherRepository, ICipherRepository cipherRepository,
@ -58,7 +64,10 @@ public class CipherService : ICipherService
GlobalSettings globalSettings, GlobalSettings globalSettings,
IReferenceEventService referenceEventService, IReferenceEventService referenceEventService,
ICurrentContext currentContext, ICurrentContext currentContext,
IGetCipherPermissionsForUserQuery getCipherPermissionsForUserQuery) IGetCipherPermissionsForUserQuery getCipherPermissionsForUserQuery,
IPolicyRequirementQuery policyRequirementQuery,
IApplicationCacheService applicationCacheService,
IFeatureService featureService)
{ {
_cipherRepository = cipherRepository; _cipherRepository = cipherRepository;
_folderRepository = folderRepository; _folderRepository = folderRepository;
@ -76,6 +85,9 @@ public class CipherService : ICipherService
_referenceEventService = referenceEventService; _referenceEventService = referenceEventService;
_currentContext = currentContext; _currentContext = currentContext;
_getCipherPermissionsForUserQuery = getCipherPermissionsForUserQuery; _getCipherPermissionsForUserQuery = getCipherPermissionsForUserQuery;
_policyRequirementQuery = policyRequirementQuery;
_applicationCacheService = applicationCacheService;
_featureService = featureService;
} }
public async Task SaveAsync(Cipher cipher, Guid savingUserId, DateTime? lastKnownRevisionDate, public async Task SaveAsync(Cipher cipher, Guid savingUserId, DateTime? lastKnownRevisionDate,
@ -143,9 +155,11 @@ public class CipherService : ICipherService
} }
else else
{ {
// Make sure the user can save new ciphers to their personal vault var isPersonalVaultRestricted = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)
var anyPersonalOwnershipPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(savingUserId, PolicyType.PersonalOwnership); ? (await _policyRequirementQuery.GetAsync<PersonalOwnershipPolicyRequirement>(savingUserId)).DisablePersonalOwnership
if (anyPersonalOwnershipPolicies) : await _policyService.AnyPoliciesApplicableToUserAsync(savingUserId, PolicyType.PersonalOwnership);
if (isPersonalVaultRestricted)
{ {
throw new BadRequestException("Due to an Enterprise Policy, you are restricted from saving items to your personal vault."); throw new BadRequestException("Due to an Enterprise Policy, you are restricted from saving items to your personal vault.");
} }
@ -411,19 +425,19 @@ public class CipherService : ICipherService
return response; return response;
} }
public async Task DeleteAsync(Cipher cipher, Guid deletingUserId, bool orgAdmin = false) public async Task DeleteAsync(CipherDetails cipherDetails, Guid deletingUserId, bool orgAdmin = false)
{ {
if (!orgAdmin && !(await UserCanEditAsync(cipher, deletingUserId))) if (!orgAdmin && !await UserCanDeleteAsync(cipherDetails, deletingUserId))
{ {
throw new BadRequestException("You do not have permissions to delete this."); throw new BadRequestException("You do not have permissions to delete this.");
} }
await _cipherRepository.DeleteAsync(cipher); await _cipherRepository.DeleteAsync(cipherDetails);
await _attachmentStorageService.DeleteAttachmentsForCipherAsync(cipher.Id); await _attachmentStorageService.DeleteAttachmentsForCipherAsync(cipherDetails.Id);
await _eventService.LogCipherEventAsync(cipher, EventType.Cipher_Deleted); await _eventService.LogCipherEventAsync(cipherDetails, EventType.Cipher_Deleted);
// push // push
await _pushService.PushSyncCipherDeleteAsync(cipher); await _pushService.PushSyncCipherDeleteAsync(cipherDetails);
} }
public async Task DeleteManyAsync(IEnumerable<Guid> cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false) public async Task DeleteManyAsync(IEnumerable<Guid> cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false)
@ -440,8 +454,8 @@ public class CipherService : ICipherService
else else
{ {
var ciphers = await _cipherRepository.GetManyByUserIdAsync(deletingUserId); var ciphers = await _cipherRepository.GetManyByUserIdAsync(deletingUserId);
deletingCiphers = ciphers.Where(c => cipherIdsSet.Contains(c.Id) && c.Edit).Select(x => (Cipher)x).ToList(); var filteredCiphers = await FilterCiphersByDeletePermission(ciphers, cipherIdsSet, deletingUserId);
deletingCiphers = filteredCiphers.Select(c => (Cipher)c).ToList();
await _cipherRepository.DeleteAsync(deletingCiphers.Select(c => c.Id), deletingUserId); await _cipherRepository.DeleteAsync(deletingCiphers.Select(c => c.Id), deletingUserId);
} }
@ -693,33 +707,26 @@ public class CipherService : ICipherService
await _pushService.PushSyncCipherUpdateAsync(cipher, collectionIds); await _pushService.PushSyncCipherUpdateAsync(cipher, collectionIds);
} }
public async Task SoftDeleteAsync(Cipher cipher, Guid deletingUserId, bool orgAdmin = false) public async Task SoftDeleteAsync(CipherDetails cipherDetails, Guid deletingUserId, bool orgAdmin = false)
{ {
if (!orgAdmin && !(await UserCanEditAsync(cipher, deletingUserId))) if (!orgAdmin && !await UserCanDeleteAsync(cipherDetails, deletingUserId))
{ {
throw new BadRequestException("You do not have permissions to soft delete this."); throw new BadRequestException("You do not have permissions to soft delete this.");
} }
if (cipher.DeletedDate.HasValue) if (cipherDetails.DeletedDate.HasValue)
{ {
// Already soft-deleted, we can safely ignore this // Already soft-deleted, we can safely ignore this
return; return;
} }
cipher.DeletedDate = cipher.RevisionDate = DateTime.UtcNow; cipherDetails.DeletedDate = cipherDetails.RevisionDate = DateTime.UtcNow;
if (cipher is CipherDetails details) await _cipherRepository.UpsertAsync(cipherDetails);
{ await _eventService.LogCipherEventAsync(cipherDetails, EventType.Cipher_SoftDeleted);
await _cipherRepository.UpsertAsync(details);
}
else
{
await _cipherRepository.UpsertAsync(cipher);
}
await _eventService.LogCipherEventAsync(cipher, EventType.Cipher_SoftDeleted);
// push // push
await _pushService.PushSyncCipherUpdateAsync(cipher, null); await _pushService.PushSyncCipherUpdateAsync(cipherDetails, null);
} }
public async Task SoftDeleteManyAsync(IEnumerable<Guid> cipherIds, Guid deletingUserId, Guid? organizationId, bool orgAdmin) public async Task SoftDeleteManyAsync(IEnumerable<Guid> cipherIds, Guid deletingUserId, Guid? organizationId, bool orgAdmin)
@ -736,8 +743,8 @@ public class CipherService : ICipherService
else else
{ {
var ciphers = await _cipherRepository.GetManyByUserIdAsync(deletingUserId); var ciphers = await _cipherRepository.GetManyByUserIdAsync(deletingUserId);
deletingCiphers = ciphers.Where(c => cipherIdsSet.Contains(c.Id) && c.Edit).Select(x => (Cipher)x).ToList(); var filteredCiphers = await FilterCiphersByDeletePermission(ciphers, cipherIdsSet, deletingUserId);
deletingCiphers = filteredCiphers.Select(c => (Cipher)c).ToList();
await _cipherRepository.SoftDeleteAsync(deletingCiphers.Select(c => c.Id), deletingUserId); await _cipherRepository.SoftDeleteAsync(deletingCiphers.Select(c => c.Id), deletingUserId);
} }
@ -752,34 +759,27 @@ public class CipherService : ICipherService
await _pushService.PushSyncCiphersAsync(deletingUserId); await _pushService.PushSyncCiphersAsync(deletingUserId);
} }
public async Task RestoreAsync(Cipher cipher, Guid restoringUserId, bool orgAdmin = false) public async Task RestoreAsync(CipherDetails cipherDetails, Guid restoringUserId, bool orgAdmin = false)
{ {
if (!orgAdmin && !(await UserCanEditAsync(cipher, restoringUserId))) if (!orgAdmin && !await UserCanRestoreAsync(cipherDetails, restoringUserId))
{ {
throw new BadRequestException("You do not have permissions to delete this."); throw new BadRequestException("You do not have permissions to delete this.");
} }
if (!cipher.DeletedDate.HasValue) if (!cipherDetails.DeletedDate.HasValue)
{ {
// Already restored, we can safely ignore this // Already restored, we can safely ignore this
return; return;
} }
cipher.DeletedDate = null; cipherDetails.DeletedDate = null;
cipher.RevisionDate = DateTime.UtcNow; cipherDetails.RevisionDate = DateTime.UtcNow;
if (cipher is CipherDetails details) await _cipherRepository.UpsertAsync(cipherDetails);
{ await _eventService.LogCipherEventAsync(cipherDetails, EventType.Cipher_Restored);
await _cipherRepository.UpsertAsync(details);
}
else
{
await _cipherRepository.UpsertAsync(cipher);
}
await _eventService.LogCipherEventAsync(cipher, EventType.Cipher_Restored);
// push // push
await _pushService.PushSyncCipherUpdateAsync(cipher, null); await _pushService.PushSyncCipherUpdateAsync(cipherDetails, null);
} }
public async Task<ICollection<CipherOrganizationDetails>> RestoreManyAsync(IEnumerable<Guid> cipherIds, Guid restoringUserId, Guid? organizationId = null, bool orgAdmin = false) public async Task<ICollection<CipherOrganizationDetails>> RestoreManyAsync(IEnumerable<Guid> cipherIds, Guid restoringUserId, Guid? organizationId = null, bool orgAdmin = false)
@ -802,8 +802,8 @@ public class CipherService : ICipherService
else else
{ {
var ciphers = await _cipherRepository.GetManyByUserIdAsync(restoringUserId); var ciphers = await _cipherRepository.GetManyByUserIdAsync(restoringUserId);
restoringCiphers = ciphers.Where(c => cipherIdsSet.Contains(c.Id) && c.Edit).Select(c => (CipherOrganizationDetails)c).ToList(); var filteredCiphers = await FilterCiphersByDeletePermission(ciphers, cipherIdsSet, restoringUserId);
restoringCiphers = filteredCiphers.Select(c => (CipherOrganizationDetails)c).ToList();
revisionDate = await _cipherRepository.RestoreAsync(restoringCiphers.Select(c => c.Id), restoringUserId); revisionDate = await _cipherRepository.RestoreAsync(restoringCiphers.Select(c => c.Id), restoringUserId);
} }
@ -834,6 +834,34 @@ public class CipherService : ICipherService
return await _cipherRepository.GetCanEditByIdAsync(userId, cipher.Id); return await _cipherRepository.GetCanEditByIdAsync(userId, cipher.Id);
} }
private async Task<bool> UserCanDeleteAsync(CipherDetails cipher, Guid userId)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.LimitItemDeletion))
{
return await UserCanEditAsync(cipher, userId);
}
var user = await _userService.GetUserByIdAsync(userId);
var organizationAbility = cipher.OrganizationId.HasValue ?
await _applicationCacheService.GetOrganizationAbilityAsync(cipher.OrganizationId.Value) : null;
return NormalCipherPermissions.CanDelete(user, cipher, organizationAbility);
}
private async Task<bool> UserCanRestoreAsync(CipherDetails cipher, Guid userId)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.LimitItemDeletion))
{
return await UserCanEditAsync(cipher, userId);
}
var user = await _userService.GetUserByIdAsync(userId);
var organizationAbility = cipher.OrganizationId.HasValue ?
await _applicationCacheService.GetOrganizationAbilityAsync(cipher.OrganizationId.Value) : null;
return NormalCipherPermissions.CanRestore(user, cipher, organizationAbility);
}
private void ValidateCipherLastKnownRevisionDateAsync(Cipher cipher, DateTime? lastKnownRevisionDate) private void ValidateCipherLastKnownRevisionDateAsync(Cipher cipher, DateTime? lastKnownRevisionDate)
{ {
if (cipher.Id == default || !lastKnownRevisionDate.HasValue) if (cipher.Id == default || !lastKnownRevisionDate.HasValue)
@ -1000,4 +1028,35 @@ public class CipherService : ICipherService
cipher.Data = JsonSerializer.Serialize(newCipherData); cipher.Data = JsonSerializer.Serialize(newCipherData);
} }
} }
// This method is used to filter ciphers based on the user's permissions to delete them.
// It supports both the old and new logic depending on the feature flag.
private async Task<List<T>> FilterCiphersByDeletePermission<T>(
IEnumerable<T> ciphers,
HashSet<Guid> cipherIdsSet,
Guid userId) where T : CipherDetails
{
if (!_featureService.IsEnabled(FeatureFlagKeys.LimitItemDeletion))
{
return ciphers.Where(c => cipherIdsSet.Contains(c.Id) && c.Edit).ToList();
}
var user = await _userService.GetUserByIdAsync(userId);
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
var filteredCiphers = ciphers
.Where(c => cipherIdsSet.Contains(c.Id))
.GroupBy(c => c.OrganizationId)
.SelectMany(group =>
{
var organizationAbility = group.Key.HasValue &&
organizationAbilities.TryGetValue(group.Key.Value, out var ability) ?
ability : null;
return group.Where(c => NormalCipherPermissions.CanDelete(user, c, organizationAbility));
})
.ToList();
return filteredCiphers;
}
} }

View File

@ -196,4 +196,15 @@ public class OrganizationRepository : Repository<Organization, Guid>, IOrganizat
return result.ToList(); return result.ToList();
} }
} }
public async Task<ICollection<Organization>> GetManyByIdsAsync(IEnumerable<Guid> ids)
{
await using var connection = new SqlConnection(ConnectionString);
return (await connection.QueryAsync<Organization>(
$"[{Schema}].[{Table}_ReadManyByIds]",
new { OrganizationIds = ids.ToGuidIdArrayTVP() },
commandType: CommandType.StoredProcedure))
.ToList();
}
} }

View File

@ -254,6 +254,42 @@ public class UserRepository : Repository<User, Guid>, IUserRepository
} }
public async Task UpdateUserKeyAndEncryptedDataV2Async(
User user,
IEnumerable<UpdateEncryptedDataForKeyRotation> updateDataActions)
{
await using var connection = new SqlConnection(ConnectionString);
connection.Open();
await using var transaction = connection.BeginTransaction();
try
{
user.AccountRevisionDate = user.RevisionDate;
ProtectData(user);
await connection.ExecuteAsync(
$"[{Schema}].[{Table}_Update]",
user,
transaction: transaction,
commandType: CommandType.StoredProcedure);
// Update re-encrypted data
foreach (var action in updateDataActions)
{
await action(connection, transaction);
}
transaction.Commit();
}
catch
{
transaction.Rollback();
UnprotectData(user);
throw;
}
UnprotectData(user);
}
public async Task<IEnumerable<User>> GetManyAsync(IEnumerable<Guid> ids) public async Task<IEnumerable<User>> GetManyAsync(IEnumerable<Guid> ids)
{ {
using (var connection = new SqlConnection(ReadOnlyConnectionString)) using (var connection = new SqlConnection(ReadOnlyConnectionString))
@ -295,6 +331,18 @@ public class UserRepository : Repository<User, Guid>, IUserRepository
var originalKey = user.Key; var originalKey = user.Key;
// Protect values // Protect values
ProtectData(user);
// Save
await saveTask();
// Restore original values
user.MasterPassword = originalMasterPassword;
user.Key = originalKey;
}
private void ProtectData(User user)
{
if (!user.MasterPassword?.StartsWith(Constants.DatabaseFieldProtectedPrefix) ?? false) if (!user.MasterPassword?.StartsWith(Constants.DatabaseFieldProtectedPrefix) ?? false)
{ {
user.MasterPassword = string.Concat(Constants.DatabaseFieldProtectedPrefix, user.MasterPassword = string.Concat(Constants.DatabaseFieldProtectedPrefix,
@ -306,13 +354,6 @@ public class UserRepository : Repository<User, Guid>, IUserRepository
user.Key = string.Concat(Constants.DatabaseFieldProtectedPrefix, user.Key = string.Concat(Constants.DatabaseFieldProtectedPrefix,
_dataProtector.Protect(user.Key!)); _dataProtector.Protect(user.Key!));
} }
// Save
await saveTask();
// Restore original values
user.MasterPassword = originalMasterPassword;
user.Key = originalKey;
} }
private void UnprotectData(User? user) private void UnprotectData(User? user)

View File

@ -0,0 +1,17 @@
using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Bit.Infrastructure.EntityFramework.AdminConsole.Configurations;
public class OrganizationIntegrationConfigurationEntityTypeConfiguration : IEntityTypeConfiguration<OrganizationIntegrationConfiguration>
{
public void Configure(EntityTypeBuilder<OrganizationIntegrationConfiguration> builder)
{
builder
.Property(p => p.Id)
.ValueGeneratedNever();
builder.ToTable(nameof(OrganizationIntegrationConfiguration));
}
}

View File

@ -0,0 +1,26 @@
using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Bit.Infrastructure.EntityFramework.AdminConsole.Configurations;
public class OrganizationIntegrationEntityTypeConfiguration : IEntityTypeConfiguration<OrganizationIntegration>
{
public void Configure(EntityTypeBuilder<OrganizationIntegration> builder)
{
builder
.Property(p => p.Id)
.ValueGeneratedNever();
builder
.HasIndex(p => p.OrganizationId)
.IsClustered(false);
builder
.HasIndex(p => new { p.OrganizationId, p.Type })
.IsUnique()
.IsClustered(false);
builder.ToTable(nameof(OrganizationIntegration));
}
}

View File

@ -0,0 +1,16 @@
using AutoMapper;
namespace Bit.Infrastructure.EntityFramework.AdminConsole.Models;
public class OrganizationIntegration : Core.AdminConsole.Entities.OrganizationIntegration
{
public virtual Organization Organization { get; set; }
}
public class OrganizationIntegrationMapperProfile : Profile
{
public OrganizationIntegrationMapperProfile()
{
CreateMap<Core.AdminConsole.Entities.OrganizationIntegration, OrganizationIntegration>().ReverseMap();
}
}

View File

@ -0,0 +1,16 @@
using AutoMapper;
namespace Bit.Infrastructure.EntityFramework.AdminConsole.Models;
public class OrganizationIntegrationConfiguration : Core.AdminConsole.Entities.OrganizationIntegrationConfiguration
{
public virtual OrganizationIntegration OrganizationIntegration { get; set; }
}
public class OrganizationIntegrationConfigurationMapperProfile : Profile
{
public OrganizationIntegrationConfigurationMapperProfile()
{
CreateMap<Core.AdminConsole.Entities.OrganizationIntegrationConfiguration, OrganizationIntegrationConfiguration>().ReverseMap();
}
}

View File

@ -354,6 +354,19 @@ public class OrganizationRepository : Repository<Core.AdminConsole.Entities.Orga
} }
} }
public async Task<ICollection<Core.AdminConsole.Entities.Organization>> GetManyByIdsAsync(IEnumerable<Guid> ids)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var query = from organization in dbContext.Organizations
where ids.Contains(organization.Id)
select organization;
return await query.ToArrayAsync();
}
public Task EnableCollectionEnhancements(Guid organizationId) public Task EnableCollectionEnhancements(Guid organizationId)
{ {
throw new NotImplementedException("Collection enhancements migration is not yet supported for Entity Framework."); throw new NotImplementedException("Collection enhancements migration is not yet supported for Entity Framework.");

View File

@ -68,12 +68,11 @@ public class WebAuthnCredentialRepository : Repository<Core.Auth.Entities.WebAut
var newCreds = credentials.ToList(); var newCreds = credentials.ToList();
using var scope = ServiceScopeFactory.CreateScope(); using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope); var dbContext = GetDatabaseContext(scope);
var userWebauthnCredentials = await GetDbSet(dbContext)
.Where(wc => wc.Id == wc.Id) var newCredIds = newCreds.Select(nwc => nwc.Id).ToList();
var validUserWebauthnCredentials = await GetDbSet(dbContext)
.Where(wc => wc.UserId == userId && newCredIds.Contains(wc.Id))
.ToListAsync(); .ToListAsync();
var validUserWebauthnCredentials = userWebauthnCredentials
.Where(wc => newCreds.Any(nwc => nwc.Id == wc.Id))
.Where(wc => wc.UserId == userId);
foreach (var wc in validUserWebauthnCredentials) foreach (var wc in validUserWebauthnCredentials)
{ {

View File

@ -170,6 +170,7 @@ public class UserRepository : Repository<Core.Entities.User, User, Guid>, IUserR
entity.SecurityStamp = user.SecurityStamp; entity.SecurityStamp = user.SecurityStamp;
entity.Key = user.Key; entity.Key = user.Key;
entity.PrivateKey = user.PrivateKey; entity.PrivateKey = user.PrivateKey;
entity.LastKeyRotationDate = user.LastKeyRotationDate; entity.LastKeyRotationDate = user.LastKeyRotationDate;
entity.AccountRevisionDate = user.AccountRevisionDate; entity.AccountRevisionDate = user.AccountRevisionDate;
@ -194,6 +195,52 @@ public class UserRepository : Repository<Core.Entities.User, User, Guid>, IUserR
} }
public async Task UpdateUserKeyAndEncryptedDataV2Async(Core.Entities.User user,
IEnumerable<UpdateEncryptedDataForKeyRotation> updateDataActions)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
await using var transaction = await dbContext.Database.BeginTransactionAsync();
// Update user
var userEntity = await dbContext.Users.FindAsync(user.Id);
if (userEntity == null)
{
throw new ArgumentException("User not found", nameof(user));
}
userEntity.SecurityStamp = user.SecurityStamp;
userEntity.Key = user.Key;
userEntity.PrivateKey = user.PrivateKey;
userEntity.Kdf = user.Kdf;
userEntity.KdfIterations = user.KdfIterations;
userEntity.KdfMemory = user.KdfMemory;
userEntity.KdfParallelism = user.KdfParallelism;
userEntity.Email = user.Email;
userEntity.MasterPassword = user.MasterPassword;
userEntity.MasterPasswordHint = user.MasterPasswordHint;
userEntity.LastKeyRotationDate = user.LastKeyRotationDate;
userEntity.AccountRevisionDate = user.AccountRevisionDate;
userEntity.RevisionDate = user.RevisionDate;
await dbContext.SaveChangesAsync();
// Update re-encrypted data
foreach (var action in updateDataActions)
{
// connection and transaction aren't used in EF
await action();
}
await transaction.CommitAsync();
}
public async Task<IEnumerable<Core.Entities.User>> GetManyAsync(IEnumerable<Guid> ids) public async Task<IEnumerable<Core.Entities.User>> GetManyAsync(IEnumerable<Guid> ids)
{ {
using (var scope = ServiceScopeFactory.CreateScope()) using (var scope = ServiceScopeFactory.CreateScope())

View File

@ -1,5 +1,4 @@
using System.Collections; using System.Collections;
using Bit.Core;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
@ -25,15 +24,6 @@ public sealed class RequestLoggingMiddleware
public Task Invoke(HttpContext context, IFeatureService featureService) public Task Invoke(HttpContext context, IFeatureService featureService)
{ {
if (!featureService.IsEnabled(FeatureFlagKeys.RemoveServerVersionHeader))
{
context.Response.OnStarting(() =>
{
context.Response.Headers.Append("Server-Version", AssemblyHelpers.GetVersion());
return Task.CompletedTask;
});
}
using (_logger.BeginScope( using (_logger.BeginScope(
new RequestLogScope(context.GetIpAddress(_globalSettings), new RequestLogScope(context.GetIpAddress(_globalSettings),
GetHeaderValue(context, "user-agent"), GetHeaderValue(context, "user-agent"),

View File

@ -0,0 +1,20 @@
CREATE PROCEDURE [dbo].[OrganizationIntegrationConfigurationDetails_ReadManyByEventTypeOrganizationIdIntegrationType]
@EventType SMALLINT,
@OrganizationId UNIQUEIDENTIFIER,
@IntegrationType SMALLINT
AS
BEGIN
SET NOCOUNT ON
SELECT
oic.*
FROM
[dbo].[OrganizationIntegrationConfigurationDetailsView] oic
WHERE
oic.[EventType] = @EventType
AND
oic.[OrganizationId] = @OrganizationId
AND
oic.[IntegrationType] = @IntegrationType
END
GO

View File

@ -0,0 +1,67 @@
CREATE PROCEDURE [dbo].[Organization_ReadManyByIds] @OrganizationIds AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON
SELECT o.[Id],
o.[Identifier],
o.[Name],
o.[BusinessName],
o.[BusinessAddress1],
o.[BusinessAddress2],
o.[BusinessAddress3],
o.[BusinessCountry],
o.[BusinessTaxNumber],
o.[BillingEmail],
o.[Plan],
o.[PlanType],
o.[Seats],
o.[MaxCollections],
o.[UsePolicies],
o.[UseSso],
o.[UseGroups],
o.[UseDirectory],
o.[UseEvents],
o.[UseTotp],
o.[Use2fa],
o.[UseApi],
o.[UseResetPassword],
o.[SelfHost],
o.[UsersGetPremium],
o.[Storage],
o.[MaxStorageGb],
o.[Gateway],
o.[GatewayCustomerId],
o.[GatewaySubscriptionId],
o.[ReferenceData],
o.[Enabled],
o.[LicenseKey],
o.[PublicKey],
o.[PrivateKey],
o.[TwoFactorProviders],
o.[ExpirationDate],
o.[CreationDate],
o.[RevisionDate],
o.[OwnersNotifiedOfAutoscaling],
o.[MaxAutoscaleSeats],
o.[UseKeyConnector],
o.[UseScim],
o.[UseCustomPermissions],
o.[UseSecretsManager],
o.[Status],
o.[UsePasswordManager],
o.[SmSeats],
o.[SmServiceAccounts],
o.[MaxAutoscaleSmSeats],
o.[MaxAutoscaleSmServiceAccounts],
o.[SecretsManagerBeta],
o.[LimitCollectionCreation],
o.[LimitCollectionDeletion],
o.[LimitItemDeletion],
o.[AllowAdminAccessToAllCollectionItems],
o.[UseRiskInsights]
FROM [dbo].[OrganizationView] o
INNER JOIN @OrganizationIds ids ON o.[Id] = ids.[Id]
END

View File

@ -0,0 +1,20 @@
CREATE TABLE [dbo].[OrganizationIntegration]
(
[Id] UNIQUEIDENTIFIER NOT NULL,
[OrganizationId] UNIQUEIDENTIFIER NOT NULL,
[Type] SMALLINT NOT NULL,
[Configuration] VARCHAR (MAX) NULL,
[CreationDate] DATETIME2 (7) NOT NULL,
[RevisionDate] DATETIME2 (7) NOT NULL,
CONSTRAINT [PK_OrganizationIntegration] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_OrganizationIntegration_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id])
);
GO
CREATE NONCLUSTERED INDEX [IX_OrganizationIntegration_OrganizationId]
ON [dbo].[OrganizationIntegration]([OrganizationId] ASC);
GO
CREATE UNIQUE INDEX [IX_OrganizationIntegration_Organization_Type]
ON [dbo].[OrganizationIntegration]([OrganizationId], [Type]);
GO

View File

@ -0,0 +1,13 @@
CREATE TABLE [dbo].[OrganizationIntegrationConfiguration]
(
[Id] UNIQUEIDENTIFIER NOT NULL,
[OrganizationIntegrationId] UNIQUEIDENTIFIER NOT NULL,
[EventType] SMALLINT NOT NULL,
[Configuration] VARCHAR (MAX) NULL,
[Template] VARCHAR (MAX) NULL,
[CreationDate] DATETIME2 (7) NOT NULL,
[RevisionDate] DATETIME2 (7) NOT NULL,
CONSTRAINT [PK_OrganizationIntegrationConfiguration] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_OrganizationIntegrationConfiguration_OrganizationIntegration] FOREIGN KEY ([OrganizationIntegrationId]) REFERENCES [dbo].[OrganizationIntegration] ([Id])
);
GO

View File

@ -0,0 +1,13 @@
CREATE VIEW [dbo].[OrganizationIntegrationConfigurationDetailsView]
AS
SELECT
oi.[OrganizationId],
oi.[Type] AS [IntegrationType],
oic.[EventType],
oic.[Configuration],
oi.[Configuration] AS [IntegrationConfiguration],
oic.[Template]
FROM
[dbo].[OrganizationIntegrationConfiguration] oic
INNER JOIN
[dbo].[OrganizationIntegration] oi ON oi.[Id] = oic.[OrganizationIntegrationId]

View File

@ -0,0 +1,6 @@
CREATE VIEW [dbo].[OrganizationIntegrationConfigurationView]
AS
SELECT
*
FROM
[dbo].[OrganizationIntegrationConfiguration]

View File

@ -0,0 +1,6 @@
CREATE VIEW [dbo].[OrganizationIntegrationView]
AS
SELECT
*
FROM
[dbo].[OrganizationIntegration]

View File

@ -2,12 +2,18 @@
using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers; using Bit.Api.IntegrationTest.Helpers;
using Bit.Api.KeyManagement.Models.Requests; using Bit.Api.KeyManagement.Models.Requests;
using Bit.Api.Tools.Models.Request;
using Bit.Api.Vault.Models;
using Bit.Api.Vault.Models.Request;
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Vault.Enums;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Identity;
using Xunit; using Xunit;
namespace Bit.Api.IntegrationTest.KeyManagement.Controllers; namespace Bit.Api.IntegrationTest.KeyManagement.Controllers;
@ -23,6 +29,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
private readonly ApiApplicationFactory _factory; private readonly ApiApplicationFactory _factory;
private readonly LoginHelper _loginHelper; private readonly LoginHelper _loginHelper;
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly IPasswordHasher<User> _passwordHasher;
private string _ownerEmail = null!; private string _ownerEmail = null!;
public AccountsKeyManagementControllerTests(ApiApplicationFactory factory) public AccountsKeyManagementControllerTests(ApiApplicationFactory factory)
@ -35,6 +42,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
_userRepository = _factory.GetService<IUserRepository>(); _userRepository = _factory.GetService<IUserRepository>();
_emergencyAccessRepository = _factory.GetService<IEmergencyAccessRepository>(); _emergencyAccessRepository = _factory.GetService<IEmergencyAccessRepository>();
_organizationUserRepository = _factory.GetService<IOrganizationUserRepository>(); _organizationUserRepository = _factory.GetService<IOrganizationUserRepository>();
_passwordHasher = _factory.GetService<IPasswordHasher<User>>();
} }
public async Task InitializeAsync() public async Task InitializeAsync()
@ -161,4 +169,87 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
}; };
await _emergencyAccessRepository.CreateAsync(emergencyAccess); await _emergencyAccessRepository.CreateAsync(emergencyAccess);
} }
[Theory]
[BitAutoData]
public async Task RotateUserAccountKeysAsync_NotLoggedIn_Unauthorized(RotateUserAccountKeysAndDataRequestModel request)
{
var response = await _client.PostAsJsonAsync("/accounts/key-management/rotate-user-account-keys", request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Theory]
[BitAutoData]
public async Task RotateUserAccountKeysAsync_Success(RotateUserAccountKeysAndDataRequestModel request)
{
await _loginHelper.LoginAsync(_ownerEmail);
var user = await _userRepository.GetByEmailAsync(_ownerEmail);
if (user == null)
{
throw new InvalidOperationException("User not found.");
}
var password = _passwordHasher.HashPassword(user, "newMasterPassword");
user.MasterPassword = password;
user.PublicKey = "publicKey";
await _userRepository.ReplaceAsync(user);
request.AccountUnlockData.MasterPasswordUnlockData.KdfType = user.Kdf;
request.AccountUnlockData.MasterPasswordUnlockData.KdfIterations = user.KdfIterations;
request.AccountUnlockData.MasterPasswordUnlockData.KdfMemory = user.KdfMemory;
request.AccountUnlockData.MasterPasswordUnlockData.KdfParallelism = user.KdfParallelism;
request.AccountUnlockData.MasterPasswordUnlockData.Email = user.Email;
request.AccountKeys.AccountPublicKey = "publicKey";
request.AccountKeys.UserKeyEncryptedAccountPrivateKey = _mockEncryptedString;
request.OldMasterKeyAuthenticationHash = "newMasterPassword";
request.AccountData.Ciphers =
[
new CipherWithIdRequestModel
{
Id = Guid.NewGuid(),
Type = CipherType.Login,
Name = _mockEncryptedString,
Login = new CipherLoginModel
{
Username = _mockEncryptedString,
Password = _mockEncryptedString,
},
},
];
request.AccountData.Folders = [
new FolderWithIdRequestModel
{
Id = Guid.NewGuid(),
Name = _mockEncryptedString,
},
];
request.AccountData.Sends = [
new SendWithIdRequestModel
{
Id = Guid.NewGuid(),
Name = _mockEncryptedString,
Key = _mockEncryptedString,
Disabled = false,
DeletionDate = DateTime.UtcNow.AddDays(1),
},
];
request.AccountUnlockData.MasterPasswordUnlockData.MasterKeyEncryptedUserKey = _mockEncryptedString;
request.AccountUnlockData.PasskeyUnlockData = [];
request.AccountUnlockData.EmergencyAccessUnlockData = [];
request.AccountUnlockData.OrganizationAccountRecoveryUnlockData = [];
var response = await _client.PostAsJsonAsync("/accounts/key-management/rotate-user-account-keys", request);
response.EnsureSuccessStatusCode();
var userNewState = await _userRepository.GetByEmailAsync(_ownerEmail);
Assert.NotNull(userNewState);
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.Email, userNewState.Email);
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.KdfType, userNewState.Kdf);
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.KdfIterations, userNewState.KdfIterations);
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.KdfMemory, userNewState.KdfMemory);
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.KdfParallelism, userNewState.KdfParallelism);
}
} }

View File

@ -10,14 +10,19 @@ namespace Bit.Api.Test.AdminConsole.Models.Response.Helpers;
public class PolicyDetailResponsesTests public class PolicyDetailResponsesTests
{ {
[Fact] [Theory]
public async Task GetSingleOrgPolicyDetailResponseAsync_GivenPolicyEntity_WhenIsSingleOrgTypeAndHasVerifiedDomains_ThenShouldNotBeAbleToToggle() [InlineData(true, false)]
[InlineData(false, true)]
public async Task GetSingleOrgPolicyDetailResponseAsync_WhenIsSingleOrgTypeAndHasVerifiedDomains_ShouldReturnExpectedToggleState(
bool policyEnabled,
bool expectedCanToggle)
{ {
var fixture = new Fixture(); var fixture = new Fixture();
var policy = fixture.Build<Policy>() var policy = fixture.Build<Policy>()
.Without(p => p.Data) .Without(p => p.Data)
.With(p => p.Type, PolicyType.SingleOrg) .With(p => p.Type, PolicyType.SingleOrg)
.With(p => p.Enabled, policyEnabled)
.Create(); .Create();
var querySub = Substitute.For<IOrganizationHasVerifiedDomainsQuery>(); var querySub = Substitute.For<IOrganizationHasVerifiedDomainsQuery>();
@ -26,11 +31,11 @@ public class PolicyDetailResponsesTests
var result = await policy.GetSingleOrgPolicyDetailResponseAsync(querySub); var result = await policy.GetSingleOrgPolicyDetailResponseAsync(querySub);
Assert.False(result.CanToggleState); Assert.Equal(expectedCanToggle, result.CanToggleState);
} }
[Fact] [Fact]
public async Task GetSingleOrgPolicyDetailResponseAsync_GivenPolicyEntity_WhenIsNotSingleOrgType_ThenShouldThrowArgumentException() public async Task GetSingleOrgPolicyDetailResponseAsync_WhenIsNotSingleOrgType_ThenShouldThrowArgumentException()
{ {
var fixture = new Fixture(); var fixture = new Fixture();
@ -49,7 +54,7 @@ public class PolicyDetailResponsesTests
} }
[Fact] [Fact]
public async Task GetSingleOrgPolicyDetailResponseAsync_GivenPolicyEntity_WhenIsSingleOrgTypeAndDoesNotHaveVerifiedDomains_ThenShouldBeAbleToToggle() public async Task GetSingleOrgPolicyDetailResponseAsync_WhenIsSingleOrgTypeAndDoesNotHaveVerifiedDomains_ThenShouldBeAbleToToggle()
{ {
var fixture = new Fixture(); var fixture = new Fixture();

View File

@ -1,17 +1,28 @@
#nullable enable #nullable enable
using System.Security.Claims; using System.Security.Claims;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Auth.Models.Request;
using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Api.KeyManagement.Controllers; using Bit.Api.KeyManagement.Controllers;
using Bit.Api.KeyManagement.Models.Requests; using Bit.Api.KeyManagement.Models.Requests;
using Bit.Api.KeyManagement.Validators;
using Bit.Api.Tools.Models.Request;
using Bit.Api.Vault.Models.Request;
using Bit.Core; using Bit.Core;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Commands.Interfaces; using Bit.Core.KeyManagement.Commands.Interfaces;
using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.UserKey;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Identity;
using NSubstitute; using NSubstitute;
using NSubstitute.ReturnsExtensions; using NSubstitute.ReturnsExtensions;
using Xunit; using Xunit;
@ -93,4 +104,78 @@ public class AccountsKeyManagementControllerTests
Arg.Is(orgUsers), Arg.Is(orgUsers),
Arg.Is(accessDetails)); Arg.Is(accessDetails));
} }
[Theory]
[BitAutoData]
public async Task RotateUserAccountKeysSuccess(SutProvider<AccountsKeyManagementController> sutProvider,
RotateUserAccountKeysAndDataRequestModel data, User user)
{
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
sutProvider.GetDependency<IRotateUserAccountKeysCommand>().RotateUserAccountKeysAsync(Arg.Any<User>(), Arg.Any<RotateUserAccountKeysData>())
.Returns(IdentityResult.Success);
await sutProvider.Sut.RotateUserAccountKeysAsync(data);
await sutProvider.GetDependency<IRotationValidator<IEnumerable<EmergencyAccessWithIdRequestModel>, IEnumerable<EmergencyAccess>>>().Received(1)
.ValidateAsync(Arg.Any<User>(), Arg.Is(data.AccountUnlockData.EmergencyAccessUnlockData));
await sutProvider.GetDependency<IRotationValidator<IEnumerable<ResetPasswordWithOrgIdRequestModel>, IReadOnlyList<OrganizationUser>>>().Received(1)
.ValidateAsync(Arg.Any<User>(), Arg.Is(data.AccountUnlockData.OrganizationAccountRecoveryUnlockData));
await sutProvider.GetDependency<IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>>().Received(1)
.ValidateAsync(Arg.Any<User>(), Arg.Is(data.AccountUnlockData.PasskeyUnlockData));
await sutProvider.GetDependency<IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>>>().Received(1)
.ValidateAsync(Arg.Any<User>(), Arg.Is(data.AccountData.Ciphers));
await sutProvider.GetDependency<IRotationValidator<IEnumerable<FolderWithIdRequestModel>, IEnumerable<Folder>>>().Received(1)
.ValidateAsync(Arg.Any<User>(), Arg.Is(data.AccountData.Folders));
await sutProvider.GetDependency<IRotationValidator<IEnumerable<SendWithIdRequestModel>, IReadOnlyList<Send>>>().Received(1)
.ValidateAsync(Arg.Any<User>(), Arg.Is(data.AccountData.Sends));
await sutProvider.GetDependency<IRotateUserAccountKeysCommand>().Received(1)
.RotateUserAccountKeysAsync(Arg.Is(user), Arg.Is<RotateUserAccountKeysData>(d =>
d.OldMasterKeyAuthenticationHash == data.OldMasterKeyAuthenticationHash
&& d.MasterPasswordUnlockData.KdfType == data.AccountUnlockData.MasterPasswordUnlockData.KdfType
&& d.MasterPasswordUnlockData.KdfIterations == data.AccountUnlockData.MasterPasswordUnlockData.KdfIterations
&& d.MasterPasswordUnlockData.KdfMemory == data.AccountUnlockData.MasterPasswordUnlockData.KdfMemory
&& d.MasterPasswordUnlockData.KdfParallelism == data.AccountUnlockData.MasterPasswordUnlockData.KdfParallelism
&& d.MasterPasswordUnlockData.Email == data.AccountUnlockData.MasterPasswordUnlockData.Email
&& d.MasterPasswordUnlockData.MasterKeyAuthenticationHash == data.AccountUnlockData.MasterPasswordUnlockData.MasterKeyAuthenticationHash
&& d.MasterPasswordUnlockData.MasterKeyEncryptedUserKey == data.AccountUnlockData.MasterPasswordUnlockData.MasterKeyEncryptedUserKey
&& d.AccountPublicKey == data.AccountKeys.AccountPublicKey
&& d.UserKeyEncryptedAccountPrivateKey == data.AccountKeys.UserKeyEncryptedAccountPrivateKey
));
}
[Theory]
[BitAutoData]
public async Task RotateUserKeyNoUser_Throws(SutProvider<AccountsKeyManagementController> sutProvider,
RotateUserAccountKeysAndDataRequestModel data)
{
User? user = null;
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
sutProvider.GetDependency<IRotateUserAccountKeysCommand>().RotateUserAccountKeysAsync(Arg.Any<User>(), Arg.Any<RotateUserAccountKeysData>())
.Returns(IdentityResult.Success);
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => sutProvider.Sut.RotateUserAccountKeysAsync(data));
}
[Theory]
[BitAutoData]
public async Task RotateUserKeyWrongData_Throws(SutProvider<AccountsKeyManagementController> sutProvider,
RotateUserAccountKeysAndDataRequestModel data, User user, IdentityErrorDescriber _identityErrorDescriber)
{
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
sutProvider.GetDependency<IRotateUserAccountKeysCommand>().RotateUserAccountKeysAsync(Arg.Any<User>(), Arg.Any<RotateUserAccountKeysData>())
.Returns(IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()));
try
{
await sutProvider.Sut.RotateUserAccountKeysAsync(data);
Assert.Fail("Should have thrown");
}
catch (BadRequestException ex)
{
Assert.NotEmpty(ex.ModelState.Values);
}
}
} }

View File

@ -0,0 +1,68 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Core.Enums;
using Xunit;
namespace Bit.Api.Test.KeyManagement.Models.Request;
public class MasterPasswordUnlockDataModelTests
{
readonly string _mockEncryptedString = "2.3Uk+WNBIoU5xzmVFNcoWzz==|1MsPIYuRfdOHfu/0uY6H2Q==|/98sp4wb6pHP1VTZ9JcNCYgQjEUMFPlqJgCwRk1YXKg=";
[Theory]
[InlineData(KdfType.PBKDF2_SHA256, 5000, null, null)]
[InlineData(KdfType.PBKDF2_SHA256, 100000, null, null)]
[InlineData(KdfType.PBKDF2_SHA256, 600000, null, null)]
[InlineData(KdfType.Argon2id, 3, 64, 4)]
public void Validate_Success(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism)
{
var model = new MasterPasswordUnlockDataModel
{
KdfType = kdfType,
KdfIterations = kdfIterations,
KdfMemory = kdfMemory,
KdfParallelism = kdfParallelism,
Email = "example@example.com",
MasterKeyAuthenticationHash = "hash",
MasterKeyEncryptedUserKey = _mockEncryptedString,
MasterPasswordHint = "hint"
};
var result = Validate(model);
Assert.Empty(result);
}
[Theory]
[InlineData(KdfType.Argon2id, 1, null, 1)]
[InlineData(KdfType.Argon2id, 1, 64, null)]
[InlineData(KdfType.PBKDF2_SHA256, 5000, 0, null)]
[InlineData(KdfType.PBKDF2_SHA256, 5000, null, 0)]
[InlineData(KdfType.PBKDF2_SHA256, 5000, 0, 0)]
[InlineData((KdfType)2, 100000, null, null)]
[InlineData((KdfType)2, 2, 64, 4)]
public void Validate_Failure(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism)
{
var model = new MasterPasswordUnlockDataModel
{
KdfType = kdfType,
KdfIterations = kdfIterations,
KdfMemory = kdfMemory,
KdfParallelism = kdfParallelism,
Email = "example@example.com",
MasterKeyAuthenticationHash = "hash",
MasterKeyEncryptedUserKey = _mockEncryptedString,
MasterPasswordHint = "hint"
};
var result = Validate(model);
Assert.Single(result);
Assert.NotNull(result.First().ErrorMessage);
}
private static List<ValidationResult> Validate(MasterPasswordUnlockDataModel model)
{
var results = new List<ValidationResult>();
Validator.TryValidateObject(model, new ValidationContext(model), results, true);
return results;
}
}

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