mirror of
https://github.com/bitwarden/server.git
synced 2024-11-25 12:45:18 +01:00
Merge branch 'main' into main
This commit is contained in:
commit
696c0a8a85
91
.github/workflows/build.yml
vendored
91
.github/workflows/build.yml
vendored
@ -7,18 +7,27 @@ on:
|
|||||||
- "main"
|
- "main"
|
||||||
- "rc"
|
- "rc"
|
||||||
- "hotfix-rc"
|
- "hotfix-rc"
|
||||||
pull_request:
|
pull_request_target:
|
||||||
|
types: [opened, synchronize]
|
||||||
|
|
||||||
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-22.04
|
runs-on: ubuntu-22.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
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||||
@ -68,6 +77,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||||
@ -115,24 +126,6 @@ jobs:
|
|||||||
path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip
|
path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
check-akv-secrets:
|
|
||||||
name: Check for AKV secrets
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
outputs:
|
|
||||||
available: ${{ steps.check-akv-secrets.outputs.available }}
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check
|
|
||||||
id: check-akv-secrets
|
|
||||||
run: |
|
|
||||||
if [ "${{ secrets.AZURE_PROD_KV_CREDENTIALS }}" != '' ]; then
|
|
||||||
echo "available=true" >> $GITHUB_OUTPUT;
|
|
||||||
else
|
|
||||||
echo "available=false" >> $GITHUB_OUTPUT;
|
|
||||||
fi
|
|
||||||
|
|
||||||
build-docker:
|
build-docker:
|
||||||
name: Build Docker images
|
name: Build Docker images
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
@ -140,8 +133,6 @@ jobs:
|
|||||||
security-events: write
|
security-events: write
|
||||||
needs:
|
needs:
|
||||||
- build-artifacts
|
- build-artifacts
|
||||||
- check-akv-secrets
|
|
||||||
if: ${{ needs.check-akv-secrets.outputs.available == 'true' }}
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@ -194,6 +185,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
- name: Check branch to publish
|
- name: Check branch to publish
|
||||||
env:
|
env:
|
||||||
@ -233,7 +226,7 @@ jobs:
|
|||||||
- name: Generate Docker image tag
|
- name: Generate Docker image tag
|
||||||
id: tag
|
id: tag
|
||||||
run: |
|
run: |
|
||||||
if [[ $(grep "pull" <<< "${GITHUB_REF}") ]]; then
|
if [[ "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; 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")
|
||||||
@ -313,6 +306,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||||
@ -326,9 +321,9 @@ jobs:
|
|||||||
run: az acr login -n $_AZ_REGISTRY --only-show-errors
|
run: az acr login -n $_AZ_REGISTRY --only-show-errors
|
||||||
|
|
||||||
- name: Make Docker stubs
|
- name: Make Docker stubs
|
||||||
if: github.ref == 'refs/heads/main' ||
|
if: |
|
||||||
github.ref == 'refs/heads/rc' ||
|
github.event_name != 'pull_request_target'
|
||||||
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
|
||||||
case "$GITHUB_REF" in
|
case "$GITHUB_REF" in
|
||||||
@ -368,13 +363,17 @@ jobs:
|
|||||||
cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../..
|
cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../..
|
||||||
|
|
||||||
- name: Make Docker stub checksums
|
- name: Make Docker stub checksums
|
||||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
|
if: |
|
||||||
|
github.event_name != 'pull_request_target'
|
||||||
|
&& (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
|
||||||
sha256sum docker-stub-EU.zip > docker-stub-EU-sha256.txt
|
sha256sum docker-stub-EU.zip > docker-stub-EU-sha256.txt
|
||||||
|
|
||||||
- name: Upload Docker stub US artifact
|
- name: Upload Docker stub US artifact
|
||||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
|
if: |
|
||||||
|
github.event_name != 'pull_request_target'
|
||||||
|
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||||
with:
|
with:
|
||||||
name: docker-stub-US.zip
|
name: docker-stub-US.zip
|
||||||
@ -382,7 +381,9 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Upload Docker stub EU artifact
|
- name: Upload Docker stub EU artifact
|
||||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
|
if: |
|
||||||
|
github.event_name != 'pull_request_target'
|
||||||
|
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||||
with:
|
with:
|
||||||
name: docker-stub-EU.zip
|
name: docker-stub-EU.zip
|
||||||
@ -390,7 +391,9 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Upload Docker stub US checksum artifact
|
- name: Upload Docker stub US checksum artifact
|
||||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
|
if: |
|
||||||
|
github.event_name != 'pull_request_target'
|
||||||
|
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||||
with:
|
with:
|
||||||
name: docker-stub-US-sha256.txt
|
name: docker-stub-US-sha256.txt
|
||||||
@ -398,7 +401,9 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Upload Docker stub EU checksum artifact
|
- name: Upload Docker stub EU checksum artifact
|
||||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
|
if: |
|
||||||
|
github.event_name != 'pull_request_target'
|
||||||
|
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||||
with:
|
with:
|
||||||
name: docker-stub-EU-sha256.txt
|
name: docker-stub-EU-sha256.txt
|
||||||
@ -473,7 +478,8 @@ jobs:
|
|||||||
build-mssqlmigratorutility:
|
build-mssqlmigratorutility:
|
||||||
name: Build MSSQL migrator utility
|
name: Build MSSQL migrator utility
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs: lint
|
needs:
|
||||||
|
- lint
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: bash
|
shell: bash
|
||||||
@ -488,6 +494,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||||
@ -522,8 +530,10 @@ jobs:
|
|||||||
|
|
||||||
self-host-build:
|
self-host-build:
|
||||||
name: Trigger self-host build
|
name: Trigger self-host build
|
||||||
|
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs: build-docker
|
needs:
|
||||||
|
- build-docker
|
||||||
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
|
||||||
@ -554,9 +564,10 @@ jobs:
|
|||||||
|
|
||||||
trigger-k8s-deploy:
|
trigger-k8s-deploy:
|
||||||
name: Trigger k8s deploy
|
name: Trigger k8s deploy
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs: build-docker
|
needs:
|
||||||
|
- build-docker
|
||||||
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
|
||||||
@ -588,9 +599,12 @@ jobs:
|
|||||||
|
|
||||||
trigger-ee-updates:
|
trigger-ee-updates:
|
||||||
name: Trigger Ephemeral Environment updates
|
name: Trigger Ephemeral Environment updates
|
||||||
if: github.ref != 'refs/heads/main' && contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
|
if: |
|
||||||
|
github.event_name == 'pull_request_target'
|
||||||
|
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
needs: build-docker
|
needs:
|
||||||
|
- build-docker
|
||||||
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
|
||||||
@ -634,9 +648,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check if any job failed
|
- name: Check if any job failed
|
||||||
if: |
|
if: |
|
||||||
(github.ref == 'refs/heads/main'
|
github.event_name != 'pull_request_target'
|
||||||
|| github.ref == 'refs/heads/rc'
|
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||||
|| github.ref == 'refs/heads/hotfix-rc')
|
|
||||||
&& contains(needs.*.result, 'failure')
|
&& contains(needs.*.result, 'failure')
|
||||||
run: exit 1
|
run: exit 1
|
||||||
|
|
||||||
|
@ -2,17 +2,14 @@
|
|||||||
using Bit.Commercial.Core.Billing.Models;
|
using Bit.Commercial.Core.Billing.Models;
|
||||||
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.Enums.Provider;
|
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Billing;
|
using Bit.Core.Billing;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
using Bit.Core.Billing.Entities;
|
using Bit.Core.Billing.Entities;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Billing.Extensions;
|
|
||||||
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.Context;
|
|
||||||
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;
|
||||||
@ -27,7 +24,6 @@ using Stripe;
|
|||||||
namespace Bit.Commercial.Core.Billing;
|
namespace Bit.Commercial.Core.Billing;
|
||||||
|
|
||||||
public class ProviderBillingService(
|
public class ProviderBillingService(
|
||||||
ICurrentContext currentContext,
|
|
||||||
IGlobalSettings globalSettings,
|
IGlobalSettings globalSettings,
|
||||||
ILogger<ProviderBillingService> logger,
|
ILogger<ProviderBillingService> logger,
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
@ -35,38 +31,76 @@ public class ProviderBillingService(
|
|||||||
IProviderInvoiceItemRepository providerInvoiceItemRepository,
|
IProviderInvoiceItemRepository providerInvoiceItemRepository,
|
||||||
IProviderOrganizationRepository providerOrganizationRepository,
|
IProviderOrganizationRepository providerOrganizationRepository,
|
||||||
IProviderPlanRepository providerPlanRepository,
|
IProviderPlanRepository providerPlanRepository,
|
||||||
IProviderRepository providerRepository,
|
|
||||||
IStripeAdapter stripeAdapter,
|
IStripeAdapter stripeAdapter,
|
||||||
ISubscriberService subscriberService) : IProviderBillingService
|
ISubscriberService subscriberService) : IProviderBillingService
|
||||||
{
|
{
|
||||||
public async Task AssignSeatsToClientOrganization(
|
public async Task ChangePlan(ChangeProviderPlanCommand command)
|
||||||
Provider provider,
|
|
||||||
Organization organization,
|
|
||||||
int seats)
|
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(organization);
|
var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
|
||||||
|
|
||||||
if (seats < 0)
|
if (plan == null)
|
||||||
{
|
{
|
||||||
throw new BillingException(
|
throw new BadRequestException("Provider plan not found.");
|
||||||
"You cannot assign negative seats to a client.",
|
|
||||||
"MSP cannot assign negative seats to a client organization");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seats == organization.Seats)
|
if (plan.PlanType == command.NewPlan)
|
||||||
{
|
{
|
||||||
logger.LogWarning("Client organization ({ID}) already has {Seats} seats assigned to it", organization.Id, organization.Seats);
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var seatAdjustment = seats - (organization.Seats ?? 0);
|
var oldPlanConfiguration = StaticStore.GetPlan(plan.PlanType);
|
||||||
|
|
||||||
await ScaleSeats(provider, organization.PlanType, seatAdjustment);
|
plan.PlanType = command.NewPlan;
|
||||||
|
await providerPlanRepository.ReplaceAsync(plan);
|
||||||
|
|
||||||
organization.Seats = seats;
|
Subscription subscription;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
throw new ConflictException("Subscription not found.");
|
||||||
|
}
|
||||||
|
|
||||||
await organizationRepository.ReplaceAsync(organization);
|
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x =>
|
||||||
|
x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId);
|
||||||
|
|
||||||
|
var updateOptions = new SubscriptionUpdateOptions
|
||||||
|
{
|
||||||
|
Items =
|
||||||
|
[
|
||||||
|
new SubscriptionItemOptions
|
||||||
|
{
|
||||||
|
Price = StaticStore.GetPlan(command.NewPlan).PasswordManager.StripeProviderPortalSeatPlanId,
|
||||||
|
Quantity = oldSubscriptionItem!.Quantity
|
||||||
|
},
|
||||||
|
new SubscriptionItemOptions
|
||||||
|
{
|
||||||
|
Id = oldSubscriptionItem.Id,
|
||||||
|
Deleted = true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions);
|
||||||
|
|
||||||
|
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
|
||||||
|
// 1. Retrieve PlanType and PlanName for ProviderPlan
|
||||||
|
// 2. Assign PlanType & PlanName to Organization
|
||||||
|
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(plan.ProviderId);
|
||||||
|
|
||||||
|
foreach (var providerOrganization in providerOrganizations)
|
||||||
|
{
|
||||||
|
var organization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
||||||
|
if (organization == null)
|
||||||
|
{
|
||||||
|
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
|
||||||
|
}
|
||||||
|
organization.PlanType = command.NewPlan;
|
||||||
|
organization.Plan = StaticStore.GetPlan(command.NewPlan).Name;
|
||||||
|
await organizationRepository.ReplaceAsync(organization);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task CreateCustomerForClientOrganization(
|
public async Task CreateCustomerForClientOrganization(
|
||||||
@ -171,65 +205,16 @@ public class ProviderBillingService(
|
|||||||
return memoryStream.ToArray();
|
return memoryStream.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> GetAssignedSeatTotalForPlanOrThrow(
|
|
||||||
Guid providerId,
|
|
||||||
PlanType planType)
|
|
||||||
{
|
|
||||||
var provider = await providerRepository.GetByIdAsync(providerId);
|
|
||||||
|
|
||||||
if (provider == null)
|
|
||||||
{
|
|
||||||
logger.LogError(
|
|
||||||
"Could not find provider ({ID}) when retrieving assigned seat total",
|
|
||||||
providerId);
|
|
||||||
|
|
||||||
throw new BillingException();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider.Type == ProviderType.Reseller)
|
|
||||||
{
|
|
||||||
logger.LogError("Assigned seats cannot be retrieved for reseller-type provider ({ID})", providerId);
|
|
||||||
|
|
||||||
throw new BillingException();
|
|
||||||
}
|
|
||||||
|
|
||||||
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId);
|
|
||||||
|
|
||||||
var plan = StaticStore.GetPlan(planType);
|
|
||||||
|
|
||||||
return providerOrganizations
|
|
||||||
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
|
|
||||||
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task ScaleSeats(
|
public async Task ScaleSeats(
|
||||||
Provider provider,
|
Provider provider,
|
||||||
PlanType planType,
|
PlanType planType,
|
||||||
int seatAdjustment)
|
int seatAdjustment)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(provider);
|
var providerPlan = await GetProviderPlanAsync(provider, planType);
|
||||||
|
|
||||||
if (!provider.SupportsConsolidatedBilling())
|
var seatMinimum = providerPlan.SeatMinimum ?? 0;
|
||||||
{
|
|
||||||
logger.LogError("Provider ({ProviderID}) cannot scale their seats", provider.Id);
|
|
||||||
|
|
||||||
throw new BillingException();
|
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType);
|
||||||
}
|
|
||||||
|
|
||||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
|
||||||
|
|
||||||
var providerPlan = providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == planType);
|
|
||||||
|
|
||||||
if (providerPlan == null || !providerPlan.IsConfigured())
|
|
||||||
{
|
|
||||||
logger.LogError("Cannot scale provider ({ProviderID}) seats for plan type {PlanType} when their matching provider plan is not configured", provider.Id, planType);
|
|
||||||
|
|
||||||
throw new BillingException();
|
|
||||||
}
|
|
||||||
|
|
||||||
var seatMinimum = providerPlan.SeatMinimum.GetValueOrDefault(0);
|
|
||||||
|
|
||||||
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalForPlanOrThrow(provider.Id, planType);
|
|
||||||
|
|
||||||
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
|
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
|
||||||
|
|
||||||
@ -256,13 +241,6 @@ public class ProviderBillingService(
|
|||||||
else if (currentlyAssignedSeatTotal <= seatMinimum &&
|
else if (currentlyAssignedSeatTotal <= seatMinimum &&
|
||||||
newlyAssignedSeatTotal > seatMinimum)
|
newlyAssignedSeatTotal > seatMinimum)
|
||||||
{
|
{
|
||||||
if (!currentContext.ProviderProviderAdmin(provider.Id))
|
|
||||||
{
|
|
||||||
logger.LogError("Service user for provider ({ProviderID}) cannot scale a provider's seat count over the seat minimum", provider.Id);
|
|
||||||
|
|
||||||
throw new BillingException();
|
|
||||||
}
|
|
||||||
|
|
||||||
await update(
|
await update(
|
||||||
seatMinimum,
|
seatMinimum,
|
||||||
newlyAssignedSeatTotal);
|
newlyAssignedSeatTotal);
|
||||||
@ -291,6 +269,26 @@ public class ProviderBillingService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SeatAdjustmentResultsInPurchase(
|
||||||
|
Provider provider,
|
||||||
|
PlanType planType,
|
||||||
|
int seatAdjustment)
|
||||||
|
{
|
||||||
|
var providerPlan = await GetProviderPlanAsync(provider, planType);
|
||||||
|
|
||||||
|
var seatMinimum = providerPlan.SeatMinimum;
|
||||||
|
|
||||||
|
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType);
|
||||||
|
|
||||||
|
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
|
||||||
|
|
||||||
|
return
|
||||||
|
// Below the limit to above the limit
|
||||||
|
(currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) ||
|
||||||
|
// Above the limit to further above the limit
|
||||||
|
(currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > currentlyAssignedSeatTotal);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Customer> SetupCustomer(
|
public async Task<Customer> SetupCustomer(
|
||||||
Provider provider,
|
Provider provider,
|
||||||
TaxInfo taxInfo)
|
TaxInfo taxInfo)
|
||||||
@ -431,75 +429,6 @@ public class ProviderBillingService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ChangePlan(ChangeProviderPlanCommand command)
|
|
||||||
{
|
|
||||||
var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
|
|
||||||
|
|
||||||
if (plan == null)
|
|
||||||
{
|
|
||||||
throw new BadRequestException("Provider plan not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (plan.PlanType == command.NewPlan)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var oldPlanConfiguration = StaticStore.GetPlan(plan.PlanType);
|
|
||||||
|
|
||||||
plan.PlanType = command.NewPlan;
|
|
||||||
await providerPlanRepository.ReplaceAsync(plan);
|
|
||||||
|
|
||||||
Subscription subscription;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId);
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException)
|
|
||||||
{
|
|
||||||
throw new ConflictException("Subscription not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x =>
|
|
||||||
x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId);
|
|
||||||
|
|
||||||
var updateOptions = new SubscriptionUpdateOptions
|
|
||||||
{
|
|
||||||
Items =
|
|
||||||
[
|
|
||||||
new SubscriptionItemOptions
|
|
||||||
{
|
|
||||||
Price = StaticStore.GetPlan(command.NewPlan).PasswordManager.StripeProviderPortalSeatPlanId,
|
|
||||||
Quantity = oldSubscriptionItem!.Quantity
|
|
||||||
},
|
|
||||||
new SubscriptionItemOptions
|
|
||||||
{
|
|
||||||
Id = oldSubscriptionItem.Id,
|
|
||||||
Deleted = true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions);
|
|
||||||
|
|
||||||
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
|
|
||||||
// 1. Retrieve PlanType and PlanName for ProviderPlan
|
|
||||||
// 2. Assign PlanType & PlanName to Organization
|
|
||||||
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(plan.ProviderId);
|
|
||||||
|
|
||||||
foreach (var providerOrganization in providerOrganizations)
|
|
||||||
{
|
|
||||||
var organization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
|
||||||
if (organization == null)
|
|
||||||
{
|
|
||||||
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
|
|
||||||
}
|
|
||||||
organization.PlanType = command.NewPlan;
|
|
||||||
organization.Plan = StaticStore.GetPlan(command.NewPlan).Name;
|
|
||||||
await organizationRepository.ReplaceAsync(organization);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
|
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
|
||||||
{
|
{
|
||||||
if (command.Configuration.Any(x => x.SeatsMinimum < 0))
|
if (command.Configuration.Any(x => x.SeatsMinimum < 0))
|
||||||
@ -610,4 +539,32 @@ public class ProviderBillingService(
|
|||||||
|
|
||||||
await providerPlanRepository.ReplaceAsync(providerPlan);
|
await providerPlanRepository.ReplaceAsync(providerPlan);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO: Replace with SPROC
|
||||||
|
private async Task<int> GetAssignedSeatTotalAsync(Provider provider, PlanType planType)
|
||||||
|
{
|
||||||
|
var providerOrganizations =
|
||||||
|
await providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id);
|
||||||
|
|
||||||
|
var plan = StaticStore.GetPlan(planType);
|
||||||
|
|
||||||
|
return providerOrganizations
|
||||||
|
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
|
||||||
|
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Replace with SPROC
|
||||||
|
private async Task<ProviderPlan> GetProviderPlanAsync(Provider provider, PlanType planType)
|
||||||
|
{
|
||||||
|
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||||
|
|
||||||
|
var providerPlan = providerPlans.FirstOrDefault(x => x.PlanType == planType);
|
||||||
|
|
||||||
|
if (providerPlan == null || !providerPlan.IsConfigured())
|
||||||
|
{
|
||||||
|
throw new BillingException(message: "Provider plan is missing or misconfigured");
|
||||||
|
}
|
||||||
|
|
||||||
|
return providerPlan;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -5,8 +5,10 @@ using Bit.Admin.Services;
|
|||||||
using Bit.Admin.Utilities;
|
using Bit.Admin.Utilities;
|
||||||
using Bit.Core;
|
using Bit.Core;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Billing.Extensions;
|
using Bit.Core.Billing.Extensions;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
@ -230,7 +232,23 @@ public class OrganizationsController : Controller
|
|||||||
[SelfHosted(NotSelfHostedOnly = true)]
|
[SelfHosted(NotSelfHostedOnly = true)]
|
||||||
public async Task<IActionResult> Edit(Guid id, OrganizationEditModel model)
|
public async Task<IActionResult> Edit(Guid id, OrganizationEditModel model)
|
||||||
{
|
{
|
||||||
var organization = await GetOrganization(id, model);
|
var organization = await _organizationRepository.GetByIdAsync(id);
|
||||||
|
|
||||||
|
if (organization == null)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Could not find organization to update.";
|
||||||
|
return RedirectToAction("Index");
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingOrganizationData = new Organization
|
||||||
|
{
|
||||||
|
Id = organization.Id,
|
||||||
|
Status = organization.Status,
|
||||||
|
PlanType = organization.PlanType,
|
||||||
|
Seats = organization.Seats
|
||||||
|
};
|
||||||
|
|
||||||
|
UpdateOrganization(organization, model);
|
||||||
|
|
||||||
if (organization.UseSecretsManager &&
|
if (organization.UseSecretsManager &&
|
||||||
!StaticStore.GetPlan(organization.PlanType).SupportsSecretsManager)
|
!StaticStore.GetPlan(organization.PlanType).SupportsSecretsManager)
|
||||||
@ -239,7 +257,12 @@ public class OrganizationsController : Controller
|
|||||||
return RedirectToAction("Edit", new { id });
|
return RedirectToAction("Edit", new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await HandlePotentialProviderSeatScalingAsync(
|
||||||
|
existingOrganizationData,
|
||||||
|
model);
|
||||||
|
|
||||||
await _organizationRepository.ReplaceAsync(organization);
|
await _organizationRepository.ReplaceAsync(organization);
|
||||||
|
|
||||||
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
||||||
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationEditedByAdmin, organization, _currentContext)
|
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationEditedByAdmin, organization, _currentContext)
|
||||||
{
|
{
|
||||||
@ -394,10 +417,9 @@ public class OrganizationsController : Controller
|
|||||||
|
|
||||||
return Json(null);
|
return Json(null);
|
||||||
}
|
}
|
||||||
private async Task<Organization> GetOrganization(Guid id, OrganizationEditModel model)
|
|
||||||
{
|
|
||||||
var organization = await _organizationRepository.GetByIdAsync(id);
|
|
||||||
|
|
||||||
|
private void UpdateOrganization(Organization organization, OrganizationEditModel model)
|
||||||
|
{
|
||||||
if (_accessControlService.UserHasPermission(Permission.Org_CheckEnabledBox))
|
if (_accessControlService.UserHasPermission(Permission.Org_CheckEnabledBox))
|
||||||
{
|
{
|
||||||
organization.Enabled = model.Enabled;
|
organization.Enabled = model.Enabled;
|
||||||
@ -449,7 +471,64 @@ public class OrganizationsController : Controller
|
|||||||
organization.GatewayCustomerId = model.GatewayCustomerId;
|
organization.GatewayCustomerId = model.GatewayCustomerId;
|
||||||
organization.GatewaySubscriptionId = model.GatewaySubscriptionId;
|
organization.GatewaySubscriptionId = model.GatewaySubscriptionId;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return organization;
|
private async Task HandlePotentialProviderSeatScalingAsync(
|
||||||
|
Organization organization,
|
||||||
|
OrganizationEditModel update)
|
||||||
|
{
|
||||||
|
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||||
|
|
||||||
|
var scaleMSPOnClientOrganizationUpdate =
|
||||||
|
_featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate);
|
||||||
|
|
||||||
|
if (!consolidatedBillingEnabled || !scaleMSPOnClientOrganizationUpdate)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||||
|
|
||||||
|
// No scaling required
|
||||||
|
if (provider is not { Type: ProviderType.Msp, Status: ProviderStatusType.Billable } ||
|
||||||
|
organization is not { Status: OrganizationStatusType.Managed } ||
|
||||||
|
!organization.Seats.HasValue ||
|
||||||
|
update is { Seats: null, PlanType: null } ||
|
||||||
|
update is { PlanType: not PlanType.TeamsMonthly and not PlanType.EnterpriseMonthly } ||
|
||||||
|
(PlanTypesMatch() && SeatsMatch()))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only scale the plan
|
||||||
|
if (!PlanTypesMatch() && SeatsMatch())
|
||||||
|
{
|
||||||
|
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
|
||||||
|
await _providerBillingService.ScaleSeats(provider, update.PlanType!.Value, organization.Seats.Value);
|
||||||
|
}
|
||||||
|
// Only scale the seats
|
||||||
|
else if (PlanTypesMatch() && !SeatsMatch())
|
||||||
|
{
|
||||||
|
var seatAdjustment = update.Seats!.Value - organization.Seats.Value;
|
||||||
|
await _providerBillingService.ScaleSeats(provider, organization.PlanType, seatAdjustment);
|
||||||
|
}
|
||||||
|
// Scale both
|
||||||
|
else if (!PlanTypesMatch() && !SeatsMatch())
|
||||||
|
{
|
||||||
|
var seatAdjustment = update.Seats!.Value - organization.Seats.Value;
|
||||||
|
var planTypeAdjustment = organization.Seats.Value;
|
||||||
|
var totalAdjustment = seatAdjustment + planTypeAdjustment;
|
||||||
|
|
||||||
|
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
|
||||||
|
await _providerBillingService.ScaleSeats(provider, update.PlanType!.Value, totalAdjustment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
|
||||||
|
bool PlanTypesMatch()
|
||||||
|
=> update.PlanType.HasValue && update.PlanType.Value == organization.PlanType;
|
||||||
|
|
||||||
|
bool SeatsMatch()
|
||||||
|
=> update.Seats.HasValue && update.Seats.Value == organization.Seats;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -110,7 +110,6 @@ public static class RolePermissionMapping
|
|||||||
Permission.User_Licensing_View,
|
Permission.User_Licensing_View,
|
||||||
Permission.User_Billing_View,
|
Permission.User_Billing_View,
|
||||||
Permission.User_Billing_LaunchGateway,
|
Permission.User_Billing_LaunchGateway,
|
||||||
Permission.User_Delete,
|
|
||||||
Permission.Org_List_View,
|
Permission.Org_List_View,
|
||||||
Permission.Org_OrgInformation_View,
|
Permission.Org_OrgInformation_View,
|
||||||
Permission.Org_GeneralDetails_View,
|
Permission.Org_GeneralDetails_View,
|
||||||
|
@ -2,15 +2,16 @@
|
|||||||
using Bit.Api.AdminConsole.Models.Response;
|
using Bit.Api.AdminConsole.Models.Response;
|
||||||
using Bit.Api.Models.Response;
|
using Bit.Api.Models.Response;
|
||||||
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
||||||
using Bit.Api.Vault.AuthorizationHandlers.Groups;
|
using Bit.Core;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
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 Bit.Core.Utilities;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@ -89,11 +90,34 @@ public class GroupsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
public async Task<ListResponseModel<GroupDetailsResponseModel>> Get(Guid orgId)
|
public async Task<ListResponseModel<GroupDetailsResponseModel>> GetOrganizationGroups(Guid orgId)
|
||||||
{
|
{
|
||||||
var authorized =
|
var authResult = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), GroupOperations.ReadAll);
|
||||||
(await _authorizationService.AuthorizeAsync(User, GroupOperations.ReadAll(orgId))).Succeeded;
|
if (!authResult.Succeeded)
|
||||||
if (!authorized)
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_featureService.IsEnabled(FeatureFlagKeys.SecureOrgGroupDetails))
|
||||||
|
{
|
||||||
|
var groups = await _groupRepository.GetManyByOrganizationIdAsync(orgId);
|
||||||
|
var responses = groups.Select(g => new GroupDetailsResponseModel(g, []));
|
||||||
|
return new ListResponseModel<GroupDetailsResponseModel>(responses);
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupDetails = await _groupRepository.GetManyWithCollectionsByOrganizationIdAsync(orgId);
|
||||||
|
var detailResponses = groupDetails.Select(g => new GroupDetailsResponseModel(g.Item1, g.Item2));
|
||||||
|
return new ListResponseModel<GroupDetailsResponseModel>(detailResponses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("details")]
|
||||||
|
public async Task<ListResponseModel<GroupDetailsResponseModel>> GetOrganizationGroupDetails(Guid orgId)
|
||||||
|
{
|
||||||
|
var authResult = _featureService.IsEnabled(FeatureFlagKeys.SecureOrgGroupDetails)
|
||||||
|
? await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), GroupOperations.ReadAllDetails)
|
||||||
|
: await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), GroupOperations.ReadAll);
|
||||||
|
|
||||||
|
if (!authResult.Succeeded)
|
||||||
{
|
{
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
@ -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.Shared.Authorization;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Auth.Enums;
|
using Bit.Core.Auth.Enums;
|
||||||
using Bit.Core.Auth.Repositories;
|
using Bit.Core.Auth.Repositories;
|
||||||
|
@ -252,6 +252,12 @@ public class OrganizationsController : Controller
|
|||||||
throw new BadRequestException("Your organization's Single Sign-On settings prevent you from leaving.");
|
throw new BadRequestException("Your organization's Single Sign-On settings prevent you from leaving.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||||
|
&& (await _userService.GetOrganizationsManagingUserAsync(user.Id)).Any(x => x.Id == id))
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Managed user account cannot leave managing organization. Contact your organization administrator for additional details.");
|
||||||
|
}
|
||||||
|
|
||||||
await _removeOrganizationUserCommand.RemoveUserAsync(id, user.Id);
|
await _removeOrganizationUserCommand.RemoveUserAsync(id, user.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
using Bit.Api.AdminConsole.Models.Request;
|
using Bit.Api.AdminConsole.Models.Request;
|
||||||
|
using Bit.Api.AdminConsole.Models.Response.Helpers;
|
||||||
|
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
using Bit.Api.Models.Response;
|
using Bit.Api.Models.Response;
|
||||||
|
using Bit.Core;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
using Bit.Core.AdminConsole.Models.Api.Response;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||||
@ -16,7 +20,6 @@ using Bit.Core.Utilities;
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using AdminConsoleEntities = Bit.Core.AdminConsole.Entities;
|
|
||||||
|
|
||||||
namespace Bit.Api.AdminConsole.Controllers;
|
namespace Bit.Api.AdminConsole.Controllers;
|
||||||
|
|
||||||
@ -32,6 +35,8 @@ public class PoliciesController : Controller
|
|||||||
private readonly GlobalSettings _globalSettings;
|
private readonly GlobalSettings _globalSettings;
|
||||||
private readonly IDataProtector _organizationServiceDataProtector;
|
private readonly IDataProtector _organizationServiceDataProtector;
|
||||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||||
|
private readonly IFeatureService _featureService;
|
||||||
|
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
|
||||||
|
|
||||||
public PoliciesController(
|
public PoliciesController(
|
||||||
IPolicyRepository policyRepository,
|
IPolicyRepository policyRepository,
|
||||||
@ -41,7 +46,9 @@ public class PoliciesController : Controller
|
|||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
GlobalSettings globalSettings,
|
GlobalSettings globalSettings,
|
||||||
IDataProtectionProvider dataProtectionProvider,
|
IDataProtectionProvider dataProtectionProvider,
|
||||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory)
|
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||||
|
IFeatureService featureService,
|
||||||
|
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery)
|
||||||
{
|
{
|
||||||
_policyRepository = policyRepository;
|
_policyRepository = policyRepository;
|
||||||
_policyService = policyService;
|
_policyService = policyService;
|
||||||
@ -53,10 +60,12 @@ public class PoliciesController : Controller
|
|||||||
"OrganizationServiceDataProtector");
|
"OrganizationServiceDataProtector");
|
||||||
|
|
||||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
||||||
|
_featureService = featureService;
|
||||||
|
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{type}")]
|
[HttpGet("{type}")]
|
||||||
public async Task<PolicyResponseModel> Get(Guid orgId, int type)
|
public async Task<PolicyDetailResponseModel> Get(Guid orgId, int type)
|
||||||
{
|
{
|
||||||
if (!await _currentContext.ManagePolicies(orgId))
|
if (!await _currentContext.ManagePolicies(orgId))
|
||||||
{
|
{
|
||||||
@ -65,10 +74,15 @@ public class PoliciesController : Controller
|
|||||||
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type);
|
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type);
|
||||||
if (policy == null)
|
if (policy == null)
|
||||||
{
|
{
|
||||||
return new PolicyResponseModel(new AdminConsoleEntities.Policy() { Type = (PolicyType)type, Enabled = false });
|
return new PolicyDetailResponseModel(new Policy { Type = (PolicyType)type });
|
||||||
}
|
}
|
||||||
|
|
||||||
return new PolicyResponseModel(policy);
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && policy.Type is PolicyType.SingleOrg)
|
||||||
|
{
|
||||||
|
return await policy.GetSingleOrgPolicyDetailResponseAsync(_organizationHasVerifiedDomainsQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PolicyDetailResponseModel(policy);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
@ -81,8 +95,8 @@ public class PoliciesController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
var policies = await _policyRepository.GetManyByOrganizationIdAsync(orgIdGuid);
|
var policies = await _policyRepository.GetManyByOrganizationIdAsync(orgIdGuid);
|
||||||
var responses = policies.Select(p => new PolicyResponseModel(p));
|
|
||||||
return new ListResponseModel<PolicyResponseModel>(responses);
|
return new ListResponseModel<PolicyResponseModel>(policies.Select(p => new PolicyResponseModel(p)));
|
||||||
}
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
|
@ -0,0 +1,19 @@
|
|||||||
|
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||||
|
|
||||||
|
namespace Bit.Api.AdminConsole.Models.Response.Helpers;
|
||||||
|
|
||||||
|
public static class PolicyDetailResponses
|
||||||
|
{
|
||||||
|
public static async Task<PolicyDetailResponseModel> GetSingleOrgPolicyDetailResponseAsync(this Policy policy, IOrganizationHasVerifiedDomainsQuery hasVerifiedDomainsQuery)
|
||||||
|
{
|
||||||
|
if (policy.Type is not PolicyType.SingleOrg)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"'{nameof(policy)}' must be of type '{nameof(PolicyType.SingleOrg)}'.", nameof(policy));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PolicyDetailResponseModel(policy, !await hasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policy.OrganizationId));
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
|
||||||
|
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
|
|
||||||
|
public class PolicyDetailResponseModel : PolicyResponseModel
|
||||||
|
{
|
||||||
|
public PolicyDetailResponseModel(Policy policy, string obj = "policy") : base(policy, obj)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public PolicyDetailResponseModel(Policy policy, bool canToggleState) : base(policy)
|
||||||
|
{
|
||||||
|
CanToggleState = canToggleState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates whether the Policy can be enabled/disabled
|
||||||
|
/// </summary>
|
||||||
|
public bool CanToggleState { get; set; } = true;
|
||||||
|
}
|
@ -3,7 +3,7 @@ using Bit.Core.AdminConsole.Entities;
|
|||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
using Bit.Core.Models.Api;
|
using Bit.Core.Models.Api;
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.Models.Api.Response;
|
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
|
|
||||||
public class PolicyResponseModel : ResponseModel
|
public class PolicyResponseModel : ResponseModel
|
||||||
{
|
{
|
@ -41,14 +41,13 @@ public class PoliciesController : Controller
|
|||||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
||||||
public async Task<IActionResult> Get(PolicyType type)
|
public async Task<IActionResult> Get(PolicyType type)
|
||||||
{
|
{
|
||||||
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(
|
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(_currentContext.OrganizationId.Value, type);
|
||||||
_currentContext.OrganizationId.Value, type);
|
|
||||||
if (policy == null)
|
if (policy == null)
|
||||||
{
|
{
|
||||||
return new NotFoundResult();
|
return new NotFoundResult();
|
||||||
}
|
}
|
||||||
var response = new PolicyResponseModel(policy);
|
|
||||||
return new JsonResult(response);
|
return new JsonResult(new PolicyResponseModel(policy));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -62,9 +61,8 @@ public class PoliciesController : Controller
|
|||||||
public async Task<IActionResult> List()
|
public async Task<IActionResult> List()
|
||||||
{
|
{
|
||||||
var policies = await _policyRepository.GetManyByOrganizationIdAsync(_currentContext.OrganizationId.Value);
|
var policies = await _policyRepository.GetManyByOrganizationIdAsync(_currentContext.OrganizationId.Value);
|
||||||
var policyResponses = policies.Select(p => new PolicyResponseModel(p));
|
|
||||||
var response = new ListResponseModel<PolicyResponseModel>(policyResponses);
|
return new JsonResult(new ListResponseModel<PolicyResponseModel>(policies.Select(p => new PolicyResponseModel(p))));
|
||||||
return new JsonResult(response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -18,7 +18,6 @@ public abstract class MemberBaseModel
|
|||||||
|
|
||||||
Type = user.Type;
|
Type = user.Type;
|
||||||
ExternalId = user.ExternalId;
|
ExternalId = user.ExternalId;
|
||||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
|
||||||
|
|
||||||
if (Type == OrganizationUserType.Custom)
|
if (Type == OrganizationUserType.Custom)
|
||||||
{
|
{
|
||||||
@ -35,7 +34,6 @@ public abstract class MemberBaseModel
|
|||||||
|
|
||||||
Type = user.Type;
|
Type = user.Type;
|
||||||
ExternalId = user.ExternalId;
|
ExternalId = user.ExternalId;
|
||||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
|
||||||
|
|
||||||
if (Type == OrganizationUserType.Custom)
|
if (Type == OrganizationUserType.Custom)
|
||||||
{
|
{
|
||||||
@ -55,11 +53,7 @@ public abstract class MemberBaseModel
|
|||||||
/// <example>external_id_123456</example>
|
/// <example>external_id_123456</example>
|
||||||
[StringLength(300)]
|
[StringLength(300)]
|
||||||
public string ExternalId { get; set; }
|
public string ExternalId { get; set; }
|
||||||
/// <summary>
|
|
||||||
/// Returns <c>true</c> if the member has enrolled in Password Reset assistance within the organization
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
public bool ResetPasswordEnrolled { get; set; }
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The member's custom permissions if the member has a Custom role. If not supplied, all custom permissions will
|
/// The member's custom permissions if the member has a Custom role. If not supplied, all custom permissions will
|
||||||
/// default to false.
|
/// default to false.
|
||||||
|
@ -28,6 +28,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
|
|||||||
Email = user.Email;
|
Email = user.Email;
|
||||||
Status = user.Status;
|
Status = user.Status;
|
||||||
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
||||||
|
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public MemberResponseModel(OrganizationUserUserDetails user, bool twoFactorEnabled,
|
public MemberResponseModel(OrganizationUserUserDetails user, bool twoFactorEnabled,
|
||||||
@ -45,6 +46,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
|
|||||||
TwoFactorEnabled = twoFactorEnabled;
|
TwoFactorEnabled = twoFactorEnabled;
|
||||||
Status = user.Status;
|
Status = user.Status;
|
||||||
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
||||||
|
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -93,4 +95,10 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
|
|||||||
/// The associated collections that this member can access.
|
/// The associated collections that this member can access.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IEnumerable<AssociationWithPermissionsResponseModel> Collections { get; set; }
|
public IEnumerable<AssociationWithPermissionsResponseModel> Collections { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns <c>true</c> if the member has enrolled in Password Reset assistance within the organization
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public bool ResetPasswordEnrolled { get; }
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<UserSecretsId>bitwarden-Api</UserSecretsId>
|
<UserSecretsId>bitwarden-Api</UserSecretsId>
|
||||||
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
|
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||||
|
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
using Bit.Api.Auth.Models.Request;
|
using Bit.Api.Auth.Models.Request;
|
||||||
using Bit.Api.Auth.Models.Response;
|
using Bit.Api.Auth.Models.Response;
|
||||||
using Bit.Api.Models.Response;
|
using Bit.Api.Models.Response;
|
||||||
using Bit.Api.Vault.Models.Response;
|
using Bit.Api.Vault.Models.Response;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Models.Api.Response;
|
|
||||||
using Bit.Core.Auth.Services;
|
using Bit.Core.Auth.Services;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
|
@ -22,9 +22,9 @@ public abstract class BaseBillingController : Controller
|
|||||||
new ErrorResponseModel(message),
|
new ErrorResponseModel(message),
|
||||||
statusCode: StatusCodes.Status500InternalServerError);
|
statusCode: StatusCodes.Status500InternalServerError);
|
||||||
|
|
||||||
public static JsonHttpResult<ErrorResponseModel> Unauthorized() =>
|
public static JsonHttpResult<ErrorResponseModel> Unauthorized(string message = "Unauthorized.") =>
|
||||||
TypedResults.Json(
|
TypedResults.Json(
|
||||||
new ErrorResponseModel("Unauthorized."),
|
new ErrorResponseModel(message),
|
||||||
statusCode: StatusCodes.Status401Unauthorized);
|
statusCode: StatusCodes.Status401Unauthorized);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -102,15 +102,27 @@ public class ProviderClientsController(
|
|||||||
|
|
||||||
var clientOrganization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
var clientOrganization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
||||||
|
|
||||||
if (clientOrganization.Seats != requestBody.AssignedSeats)
|
if (clientOrganization is not { Status: OrganizationStatusType.Managed })
|
||||||
{
|
{
|
||||||
await providerBillingService.AssignSeatsToClientOrganization(
|
return Error.ServerError();
|
||||||
provider,
|
|
||||||
clientOrganization,
|
|
||||||
requestBody.AssignedSeats);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var seatAdjustment = requestBody.AssignedSeats - (clientOrganization.Seats ?? 0);
|
||||||
|
|
||||||
|
var seatAdjustmentResultsInPurchase = await providerBillingService.SeatAdjustmentResultsInPurchase(
|
||||||
|
provider,
|
||||||
|
clientOrganization.PlanType,
|
||||||
|
seatAdjustment);
|
||||||
|
|
||||||
|
if (seatAdjustmentResultsInPurchase && !currentContext.ProviderProviderAdmin(provider.Id))
|
||||||
|
{
|
||||||
|
return Error.Unauthorized("Service users cannot purchase additional seats.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await providerBillingService.ScaleSeats(provider, clientOrganization.PlanType, seatAdjustment);
|
||||||
|
|
||||||
clientOrganization.Name = requestBody.Name;
|
clientOrganization.Name = requestBody.Name;
|
||||||
|
clientOrganization.Seats = requestBody.AssignedSeats;
|
||||||
|
|
||||||
await organizationRepository.ReplaceAsync(clientOrganization);
|
await organizationRepository.ReplaceAsync(clientOrganization);
|
||||||
|
|
||||||
|
@ -32,6 +32,8 @@ using Bit.Core.Tools.Entities;
|
|||||||
using Bit.Core.Vault.Entities;
|
using Bit.Core.Vault.Entities;
|
||||||
using Bit.Api.Auth.Models.Request.WebAuthn;
|
using Bit.Api.Auth.Models.Request.WebAuthn;
|
||||||
using Bit.Core.Auth.Models.Data;
|
using Bit.Core.Auth.Models.Data;
|
||||||
|
using Bit.Core.Tools.ReportFeatures;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#if !OSS
|
#if !OSS
|
||||||
@ -176,6 +178,7 @@ public class Startup
|
|||||||
services.AddOrganizationSubscriptionServices();
|
services.AddOrganizationSubscriptionServices();
|
||||||
services.AddCoreLocalizationServices();
|
services.AddCoreLocalizationServices();
|
||||||
services.AddBillingOperations();
|
services.AddBillingOperations();
|
||||||
|
services.AddReportingServices();
|
||||||
|
|
||||||
// Authorization Handlers
|
// Authorization Handlers
|
||||||
services.AddAuthorizationHandlers();
|
services.AddAuthorizationHandlers();
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
using Bit.Api.Tools.Models.Response;
|
using Bit.Api.Tools.Models;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Api.Tools.Models.Response;
|
||||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Tools.Entities;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Tools.Models.Data;
|
||||||
using Bit.Core.Vault.Queries;
|
using Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||||
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||||
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
|
using Bit.Core.Tools.ReportFeatures.Requests;
|
||||||
|
using Bit.Core.Tools.Requests;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@ -17,33 +17,55 @@ namespace Bit.Api.Tools.Controllers;
|
|||||||
[Authorize("Application")]
|
[Authorize("Application")]
|
||||||
public class ReportsController : Controller
|
public class ReportsController : Controller
|
||||||
{
|
{
|
||||||
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
|
|
||||||
private readonly IGroupRepository _groupRepository;
|
|
||||||
private readonly ICollectionRepository _collectionRepository;
|
|
||||||
private readonly ICurrentContext _currentContext;
|
private readonly ICurrentContext _currentContext;
|
||||||
private readonly IOrganizationCiphersQuery _organizationCiphersQuery;
|
private readonly IMemberAccessCipherDetailsQuery _memberAccessCipherDetailsQuery;
|
||||||
private readonly IApplicationCacheService _applicationCacheService;
|
private readonly IAddPasswordHealthReportApplicationCommand _addPwdHealthReportAppCommand;
|
||||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery;
|
||||||
|
|
||||||
public ReportsController(
|
public ReportsController(
|
||||||
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
|
|
||||||
IGroupRepository groupRepository,
|
|
||||||
ICollectionRepository collectionRepository,
|
|
||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
IOrganizationCiphersQuery organizationCiphersQuery,
|
IMemberAccessCipherDetailsQuery memberAccessCipherDetailsQuery,
|
||||||
IApplicationCacheService applicationCacheService,
|
IAddPasswordHealthReportApplicationCommand addPasswordHealthReportApplicationCommand,
|
||||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery
|
IGetPasswordHealthReportApplicationQuery getPasswordHealthReportApplicationQuery
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
|
|
||||||
_groupRepository = groupRepository;
|
|
||||||
_collectionRepository = collectionRepository;
|
|
||||||
_currentContext = currentContext;
|
_currentContext = currentContext;
|
||||||
_organizationCiphersQuery = organizationCiphersQuery;
|
_memberAccessCipherDetailsQuery = memberAccessCipherDetailsQuery;
|
||||||
_applicationCacheService = applicationCacheService;
|
_addPwdHealthReportAppCommand = addPasswordHealthReportApplicationCommand;
|
||||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
_getPwdHealthReportAppQuery = getPasswordHealthReportApplicationQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Organization member information containing a list of cipher ids
|
||||||
|
/// assigned
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="orgId">Organzation Id</param>
|
||||||
|
/// <returns>IEnumerable of MemberCipherDetailsResponseModel</returns>
|
||||||
|
/// <exception cref="NotFoundException">If Access reports permission is not assigned</exception>
|
||||||
|
[HttpGet("member-cipher-details/{orgId}")]
|
||||||
|
public async Task<IEnumerable<MemberCipherDetailsResponseModel>> GetMemberCipherDetails(Guid orgId)
|
||||||
|
{
|
||||||
|
// Using the AccessReports permission here until new permissions
|
||||||
|
// are needed for more control over reports
|
||||||
|
if (!await _currentContext.AccessReports(orgId))
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId });
|
||||||
|
|
||||||
|
var responses = memberCipherDetails.Select(x => new MemberCipherDetailsResponseModel(x));
|
||||||
|
|
||||||
|
return responses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Access details for an organization member. Includes the member information,
|
||||||
|
/// group collection assignment, and item counts
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="orgId">Organization Id</param>
|
||||||
|
/// <returns>IEnumerable of MemberAccessReportResponseModel</returns>
|
||||||
|
/// <exception cref="NotFoundException">If Access reports permission is not assigned</exception>
|
||||||
[HttpGet("member-access/{orgId}")]
|
[HttpGet("member-access/{orgId}")]
|
||||||
public async Task<IEnumerable<MemberAccessReportResponseModel>> GetMemberAccessReport(Guid orgId)
|
public async Task<IEnumerable<MemberAccessReportResponseModel>> GetMemberAccessReport(Guid orgId)
|
||||||
{
|
{
|
||||||
@ -52,26 +74,91 @@ public class ReportsController : Controller
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
var orgUsers = await _organizationUserUserDetailsQuery.GetOrganizationUserUserDetails(
|
var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId });
|
||||||
new OrganizationUserUserDetailsQueryRequest
|
|
||||||
{
|
|
||||||
OrganizationId = orgId,
|
|
||||||
IncludeCollections = true,
|
|
||||||
IncludeGroups = true
|
|
||||||
});
|
|
||||||
|
|
||||||
var orgGroups = await _groupRepository.GetManyByOrganizationIdAsync(orgId);
|
var responses = memberCipherDetails.Select(x => new MemberAccessReportResponseModel(x));
|
||||||
var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(orgId);
|
|
||||||
var orgCollectionsWithAccess = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(orgId);
|
|
||||||
var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(orgId);
|
|
||||||
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers);
|
|
||||||
|
|
||||||
var reports = MemberAccessReportResponseModel.CreateReport(
|
return responses;
|
||||||
orgGroups,
|
}
|
||||||
orgCollectionsWithAccess,
|
|
||||||
orgItems,
|
/// <summary>
|
||||||
organizationUsersTwoFactorEnabled,
|
/// Contains the organization member info, the cipher ids associated with the member,
|
||||||
orgAbility);
|
/// and details on their collections, groups, and permissions
|
||||||
return reports;
|
/// </summary>
|
||||||
|
/// <param name="request">Request to the MemberAccessCipherDetailsQuery</param>
|
||||||
|
/// <returns>IEnumerable of MemberAccessCipherDetails</returns>
|
||||||
|
private async Task<IEnumerable<MemberAccessCipherDetails>> GetMemberCipherDetails(MemberAccessCipherDetailsRequest request)
|
||||||
|
{
|
||||||
|
var memberCipherDetails =
|
||||||
|
await _memberAccessCipherDetailsQuery.GetMemberAccessCipherDetails(request);
|
||||||
|
return memberCipherDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the password health report applications for an organization
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="orgId">A valid Organization Id</param>
|
||||||
|
/// <returns>An Enumerable of PasswordHealthReportApplication </returns>
|
||||||
|
/// <exception cref="NotFoundException">If the user lacks access</exception>
|
||||||
|
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
|
||||||
|
[HttpGet("password-health-report-applications/{orgId}")]
|
||||||
|
public async Task<IEnumerable<PasswordHealthReportApplication>> GetPasswordHealthReportApplications(Guid orgId)
|
||||||
|
{
|
||||||
|
if (!await _currentContext.AccessReports(orgId))
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _getPwdHealthReportAppQuery.GetPasswordHealthReportApplicationAsync(orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a new record into PasswordHealthReportApplication
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">A single instance of PasswordHealthReportApplication Model</param>
|
||||||
|
/// <returns>A single instance of PasswordHealthReportApplication</returns>
|
||||||
|
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
|
||||||
|
/// <exception cref="NotFoundException">If the user lacks access</exception>
|
||||||
|
[HttpPost("password-health-report-application")]
|
||||||
|
public async Task<PasswordHealthReportApplication> AddPasswordHealthReportApplication(
|
||||||
|
[FromBody] PasswordHealthReportApplicationModel request)
|
||||||
|
{
|
||||||
|
if (!await _currentContext.AccessReports(request.OrganizationId))
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var commandRequest = new AddPasswordHealthReportApplicationRequest
|
||||||
|
{
|
||||||
|
OrganizationId = request.OrganizationId,
|
||||||
|
Url = request.Url
|
||||||
|
};
|
||||||
|
|
||||||
|
return await _addPwdHealthReportAppCommand.AddPasswordHealthReportApplicationAsync(commandRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds multiple records into PasswordHealthReportApplication
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">A enumerable of PasswordHealthReportApplicationModel</param>
|
||||||
|
/// <returns>An Enumerable of PasswordHealthReportApplication</returns>
|
||||||
|
/// <exception cref="NotFoundException">If user does not have access to the OrganizationId</exception>
|
||||||
|
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
|
||||||
|
[HttpPost("password-health-report-applications")]
|
||||||
|
public async Task<IEnumerable<PasswordHealthReportApplication>> AddPasswordHealthReportApplications(
|
||||||
|
[FromBody] IEnumerable<PasswordHealthReportApplicationModel> request)
|
||||||
|
{
|
||||||
|
if (request.Any(_ => _currentContext.AccessReports(_.OrganizationId).Result == false))
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var commandRequests = request.Select(request => new AddPasswordHealthReportApplicationRequest
|
||||||
|
{
|
||||||
|
OrganizationId = request.OrganizationId,
|
||||||
|
Url = request.Url
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return await _addPwdHealthReportAppCommand.AddPasswordHealthReportApplicationAsync(commandRequests);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,7 @@
|
|||||||
|
namespace Bit.Api.Tools.Models;
|
||||||
|
|
||||||
|
public class PasswordHealthReportApplicationModel
|
||||||
|
{
|
||||||
|
public Guid OrganizationId { get; set; }
|
||||||
|
public string Url { get; set; }
|
||||||
|
}
|
@ -1,30 +1,7 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.Tools.Models.Data;
|
||||||
using Bit.Core.Entities;
|
|
||||||
using Bit.Core.Models.Data;
|
|
||||||
using Bit.Core.Models.Data.Organizations;
|
|
||||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
|
||||||
using Bit.Core.Vault.Models.Data;
|
|
||||||
|
|
||||||
namespace Bit.Api.Tools.Models.Response;
|
namespace Bit.Api.Tools.Models.Response;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Member access details. The individual item for the detailed member access
|
|
||||||
/// report. A collection can be assigned directly to a user without a group or
|
|
||||||
/// the user can be assigned to a collection through a group. Group level permissions
|
|
||||||
/// can override collection level permissions.
|
|
||||||
/// </summary>
|
|
||||||
public class MemberAccessReportAccessDetails
|
|
||||||
{
|
|
||||||
public Guid? CollectionId { get; set; }
|
|
||||||
public Guid? GroupId { get; set; }
|
|
||||||
public string GroupName { get; set; }
|
|
||||||
public string CollectionName { get; set; }
|
|
||||||
public int ItemCount { get; set; }
|
|
||||||
public bool? ReadOnly { get; set; }
|
|
||||||
public bool? HidePasswords { get; set; }
|
|
||||||
public bool? Manage { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Contains the collections and group collections a user has access to including
|
/// Contains the collections and group collections a user has access to including
|
||||||
/// the permission level for the collection and group collection.
|
/// the permission level for the collection and group collection.
|
||||||
@ -40,134 +17,18 @@ public class MemberAccessReportResponseModel
|
|||||||
public int TotalItemCount { get; set; }
|
public int TotalItemCount { get; set; }
|
||||||
public Guid? UserGuid { get; set; }
|
public Guid? UserGuid { get; set; }
|
||||||
public bool UsesKeyConnector { get; set; }
|
public bool UsesKeyConnector { get; set; }
|
||||||
public IEnumerable<MemberAccessReportAccessDetails> AccessDetails { get; set; }
|
public IEnumerable<MemberAccessDetails> AccessDetails { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
public MemberAccessReportResponseModel(MemberAccessCipherDetails memberAccessCipherDetails)
|
||||||
/// Generates a report for all members of an organization. Containing summary information
|
|
||||||
/// such as item, collection, and group counts. As well as detailed information on the
|
|
||||||
/// user and group collections along with their permissions
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="orgGroups">Organization groups collection</param>
|
|
||||||
/// <param name="orgCollectionsWithAccess">Collections for the organization and the groups/users and permissions</param>
|
|
||||||
/// <param name="orgItems">Cipher items for the organization with the collections associated with them</param>
|
|
||||||
/// <param name="organizationUsersTwoFactorEnabled">Organization users and two factor status</param>
|
|
||||||
/// <param name="orgAbility">Organization ability for account recovery status</param>
|
|
||||||
/// <returns>List of the MemberAccessReportResponseModel</returns>;
|
|
||||||
public static IEnumerable<MemberAccessReportResponseModel> CreateReport(
|
|
||||||
ICollection<Group> orgGroups,
|
|
||||||
ICollection<Tuple<Collection, CollectionAccessDetails>> orgCollectionsWithAccess,
|
|
||||||
IEnumerable<CipherOrganizationDetailsWithCollections> orgItems,
|
|
||||||
IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled,
|
|
||||||
OrganizationAbility orgAbility)
|
|
||||||
{
|
{
|
||||||
var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user);
|
this.UserName = memberAccessCipherDetails.UserName;
|
||||||
// Create a dictionary to lookup the group names later.
|
this.Email = memberAccessCipherDetails.Email;
|
||||||
var groupNameDictionary = orgGroups.ToDictionary(x => x.Id, x => x.Name);
|
this.TwoFactorEnabled = memberAccessCipherDetails.TwoFactorEnabled;
|
||||||
|
this.AccountRecoveryEnabled = memberAccessCipherDetails.AccountRecoveryEnabled;
|
||||||
// Get collections grouped and into a dictionary for counts
|
this.GroupsCount = memberAccessCipherDetails.GroupsCount;
|
||||||
var collectionItems = orgItems
|
this.CollectionsCount = memberAccessCipherDetails.CollectionsCount;
|
||||||
.SelectMany(x => x.CollectionIds,
|
this.TotalItemCount = memberAccessCipherDetails.TotalItemCount;
|
||||||
(x, b) => new { CipherId = x.Id, CollectionId = b })
|
this.UserGuid = memberAccessCipherDetails.UserGuid;
|
||||||
.GroupBy(y => y.CollectionId,
|
this.AccessDetails = memberAccessCipherDetails.AccessDetails;
|
||||||
(key, g) => new { CollectionId = key, Ciphers = g });
|
|
||||||
var collectionItemCounts = collectionItems.ToDictionary(x => x.CollectionId, x => x.Ciphers.Count());
|
|
||||||
|
|
||||||
|
|
||||||
// Loop through the org users and populate report and access data
|
|
||||||
var memberAccessReport = new List<MemberAccessReportResponseModel>();
|
|
||||||
foreach (var user in orgUsers)
|
|
||||||
{
|
|
||||||
// Take the collections/groups and create the access details items
|
|
||||||
var groupAccessDetails = new List<MemberAccessReportAccessDetails>();
|
|
||||||
var userCollectionAccessDetails = new List<MemberAccessReportAccessDetails>();
|
|
||||||
foreach (var tCollect in orgCollectionsWithAccess)
|
|
||||||
{
|
|
||||||
var itemCounts = collectionItemCounts.TryGetValue(tCollect.Item1.Id, out var itemCount) ? itemCount : 0;
|
|
||||||
if (tCollect.Item2.Groups.Count() > 0)
|
|
||||||
{
|
|
||||||
var groupDetails = tCollect.Item2.Groups.Where((tCollectGroups) => user.Groups.Contains(tCollectGroups.Id)).Select(x =>
|
|
||||||
new MemberAccessReportAccessDetails
|
|
||||||
{
|
|
||||||
CollectionId = tCollect.Item1.Id,
|
|
||||||
CollectionName = tCollect.Item1.Name,
|
|
||||||
GroupId = x.Id,
|
|
||||||
GroupName = groupNameDictionary[x.Id],
|
|
||||||
ReadOnly = x.ReadOnly,
|
|
||||||
HidePasswords = x.HidePasswords,
|
|
||||||
Manage = x.Manage,
|
|
||||||
ItemCount = itemCounts,
|
|
||||||
});
|
|
||||||
groupAccessDetails.AddRange(groupDetails);
|
|
||||||
}
|
|
||||||
|
|
||||||
// All collections assigned to users and their permissions
|
|
||||||
if (tCollect.Item2.Users.Count() > 0)
|
|
||||||
{
|
|
||||||
var userCollectionDetails = tCollect.Item2.Users.Where((tCollectUser) => tCollectUser.Id == user.Id).Select(x =>
|
|
||||||
new MemberAccessReportAccessDetails
|
|
||||||
{
|
|
||||||
CollectionId = tCollect.Item1.Id,
|
|
||||||
CollectionName = tCollect.Item1.Name,
|
|
||||||
ReadOnly = x.ReadOnly,
|
|
||||||
HidePasswords = x.HidePasswords,
|
|
||||||
Manage = x.Manage,
|
|
||||||
ItemCount = itemCounts,
|
|
||||||
});
|
|
||||||
userCollectionAccessDetails.AddRange(userCollectionDetails);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var report = new MemberAccessReportResponseModel
|
|
||||||
{
|
|
||||||
UserName = user.Name,
|
|
||||||
Email = user.Email,
|
|
||||||
TwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == user.Id).twoFactorIsEnabled,
|
|
||||||
// Both the user's ResetPasswordKey must be set and the organization can UseResetPassword
|
|
||||||
AccountRecoveryEnabled = !string.IsNullOrEmpty(user.ResetPasswordKey) && orgAbility.UseResetPassword,
|
|
||||||
UserGuid = user.Id,
|
|
||||||
UsesKeyConnector = user.UsesKeyConnector
|
|
||||||
};
|
|
||||||
|
|
||||||
var userAccessDetails = new List<MemberAccessReportAccessDetails>();
|
|
||||||
if (user.Groups.Any())
|
|
||||||
{
|
|
||||||
var userGroups = groupAccessDetails.Where(x => user.Groups.Contains(x.GroupId.GetValueOrDefault()));
|
|
||||||
userAccessDetails.AddRange(userGroups);
|
|
||||||
}
|
|
||||||
|
|
||||||
// There can be edge cases where groups don't have a collection
|
|
||||||
var groupsWithoutCollections = user.Groups.Where(x => !userAccessDetails.Any(y => x == y.GroupId));
|
|
||||||
if (groupsWithoutCollections.Count() > 0)
|
|
||||||
{
|
|
||||||
var emptyGroups = groupsWithoutCollections.Select(x => new MemberAccessReportAccessDetails
|
|
||||||
{
|
|
||||||
GroupId = x,
|
|
||||||
GroupName = groupNameDictionary[x],
|
|
||||||
ItemCount = 0
|
|
||||||
});
|
|
||||||
userAccessDetails.AddRange(emptyGroups);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.Collections.Any())
|
|
||||||
{
|
|
||||||
var userCollections = userCollectionAccessDetails.Where(x => user.Collections.Any(y => x.CollectionId == y.Id));
|
|
||||||
userAccessDetails.AddRange(userCollections);
|
|
||||||
}
|
|
||||||
report.AccessDetails = userAccessDetails;
|
|
||||||
|
|
||||||
report.TotalItemCount = collectionItems
|
|
||||||
.Where(x => report.AccessDetails.Any(y => x.CollectionId == y.CollectionId))
|
|
||||||
.SelectMany(x => x.Ciphers)
|
|
||||||
.GroupBy(g => g.CipherId).Select(grp => grp.FirstOrDefault())
|
|
||||||
.Count();
|
|
||||||
|
|
||||||
// Distinct items only
|
|
||||||
var distinctItems = report.AccessDetails.Where(x => x.CollectionId.HasValue).Select(x => x.CollectionId).Distinct();
|
|
||||||
report.CollectionsCount = distinctItems.Count();
|
|
||||||
report.GroupsCount = report.AccessDetails.Select(x => x.GroupId).Where(y => y.HasValue).Distinct().Count();
|
|
||||||
memberAccessReport.Add(report);
|
|
||||||
}
|
|
||||||
return memberAccessReport;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,24 @@
|
|||||||
|
using Bit.Core.Tools.Models.Data;
|
||||||
|
|
||||||
|
namespace Bit.Api.Tools.Models.Response;
|
||||||
|
|
||||||
|
public class MemberCipherDetailsResponseModel
|
||||||
|
{
|
||||||
|
public string UserName { get; set; }
|
||||||
|
public string Email { get; set; }
|
||||||
|
public bool UsesKeyConnector { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A distinct list of the cipher ids associated with
|
||||||
|
/// the organization member
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<string> CipherIds { get; set; }
|
||||||
|
|
||||||
|
public MemberCipherDetailsResponseModel(MemberAccessCipherDetails memberAccessCipherDetails)
|
||||||
|
{
|
||||||
|
this.UserName = memberAccessCipherDetails.UserName;
|
||||||
|
this.Email = memberAccessCipherDetails.Email;
|
||||||
|
this.UsesKeyConnector = memberAccessCipherDetails.UsesKeyConnector;
|
||||||
|
this.CipherIds = memberAccessCipherDetails.CipherIds;
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
||||||
using Bit.Api.Vault.AuthorizationHandlers.Groups;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
||||||
using Bit.Core.IdentityServer;
|
using Bit.Core.IdentityServer;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
|
@ -1,62 +0,0 @@
|
|||||||
#nullable enable
|
|
||||||
using Bit.Core.Context;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
|
|
||||||
namespace Bit.Api.Vault.AuthorizationHandlers.Groups;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handles authorization logic for Group operations.
|
|
||||||
/// This uses new logic implemented in the Flexible Collections initiative.
|
|
||||||
/// </summary>
|
|
||||||
public class GroupAuthorizationHandler : AuthorizationHandler<GroupOperationRequirement>
|
|
||||||
{
|
|
||||||
private readonly ICurrentContext _currentContext;
|
|
||||||
|
|
||||||
public GroupAuthorizationHandler(ICurrentContext currentContext)
|
|
||||||
{
|
|
||||||
_currentContext = currentContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
|
|
||||||
GroupOperationRequirement requirement)
|
|
||||||
{
|
|
||||||
// Acting user is not authenticated, fail
|
|
||||||
if (!_currentContext.UserId.HasValue)
|
|
||||||
{
|
|
||||||
context.Fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requirement.OrganizationId == default)
|
|
||||||
{
|
|
||||||
context.Fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var org = _currentContext.GetOrganization(requirement.OrganizationId);
|
|
||||||
|
|
||||||
switch (requirement)
|
|
||||||
{
|
|
||||||
case not null when requirement.Name == nameof(GroupOperations.ReadAll):
|
|
||||||
await CanReadAllAsync(context, requirement, org);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CanReadAllAsync(AuthorizationHandlerContext context, GroupOperationRequirement requirement,
|
|
||||||
CurrentContextOrganization? org)
|
|
||||||
{
|
|
||||||
// All users of an organization can read all groups belonging to the organization for collection access management
|
|
||||||
if (org is not null)
|
|
||||||
{
|
|
||||||
context.Succeed(requirement);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow provider users to read all groups if they are a provider for the target organization
|
|
||||||
if (await _currentContext.ProviderUserForOrgAsync(requirement.OrganizationId))
|
|
||||||
{
|
|
||||||
context.Succeed(requirement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,22 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Authorization.Infrastructure;
|
|
||||||
|
|
||||||
namespace Bit.Api.Vault.AuthorizationHandlers.Groups;
|
|
||||||
|
|
||||||
public class GroupOperationRequirement : OperationAuthorizationRequirement
|
|
||||||
{
|
|
||||||
public Guid OrganizationId { get; init; }
|
|
||||||
|
|
||||||
public GroupOperationRequirement(string name, Guid organizationId)
|
|
||||||
{
|
|
||||||
Name = name;
|
|
||||||
OrganizationId = organizationId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class GroupOperations
|
|
||||||
{
|
|
||||||
public static GroupOperationRequirement ReadAll(Guid organizationId)
|
|
||||||
{
|
|
||||||
return new GroupOperationRequirement(nameof(ReadAll), organizationId);
|
|
||||||
}
|
|
||||||
}
|
|
@ -99,7 +99,10 @@ public class CiphersController : Controller
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new CipherMiniResponseModel(cipher, _globalSettings, cipher.OrganizationUseTotp);
|
var collectionCiphers = await _collectionCipherRepository.GetManyByOrganizationIdAsync(cipher.OrganizationId.Value);
|
||||||
|
var collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key);
|
||||||
|
|
||||||
|
return new CipherMiniDetailsResponseModel(cipher, _globalSettings, collectionCiphersGroupDict, cipher.OrganizationUseTotp);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}/full-details")]
|
[HttpGet("{id}/full-details")]
|
||||||
@ -600,10 +603,10 @@ public class CiphersController : Controller
|
|||||||
|
|
||||||
[HttpPut("{id}/collections-admin")]
|
[HttpPut("{id}/collections-admin")]
|
||||||
[HttpPost("{id}/collections-admin")]
|
[HttpPost("{id}/collections-admin")]
|
||||||
public async Task PutCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model)
|
public async Task<CipherMiniDetailsResponseModel> PutCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model)
|
||||||
{
|
{
|
||||||
var userId = _userService.GetProperUserId(User).Value;
|
var userId = _userService.GetProperUserId(User).Value;
|
||||||
var cipher = await _cipherRepository.GetByIdAsync(new Guid(id));
|
var cipher = await _cipherRepository.GetOrganizationDetailsByIdAsync(new Guid(id));
|
||||||
|
|
||||||
if (cipher == null || !cipher.OrganizationId.HasValue ||
|
if (cipher == null || !cipher.OrganizationId.HasValue ||
|
||||||
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
|
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
|
||||||
@ -621,6 +624,11 @@ public class CiphersController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _cipherService.SaveCollectionsAsync(cipher, collectionIds, userId, true);
|
await _cipherService.SaveCollectionsAsync(cipher, collectionIds, userId, true);
|
||||||
|
|
||||||
|
var collectionCiphers = await _collectionCipherRepository.GetManyByOrganizationIdAsync(cipher.OrganizationId.Value);
|
||||||
|
var collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key);
|
||||||
|
|
||||||
|
return new CipherMiniDetailsResponseModel(cipher, _globalSettings, collectionCiphersGroupDict, cipher.OrganizationUseTotp);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("bulk-collections")]
|
[HttpPost("bulk-collections")]
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
using Bit.Api.Models.Response;
|
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
|
using Bit.Api.Models.Response;
|
||||||
using Bit.Api.Tools.Models.Response;
|
using Bit.Api.Tools.Models.Response;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Models.Api.Response;
|
|
||||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Models.Api;
|
using Bit.Core.Models.Api;
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
using Bit.Billing.Constants;
|
using Bit.Billing.Constants;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Billing.Entities;
|
using Bit.Core.Billing.Entities;
|
||||||
using Bit.Core.Billing.Enums;
|
|
||||||
using Bit.Core.Billing.Repositories;
|
using Bit.Core.Billing.Repositories;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
@ -42,96 +41,64 @@ public class ProviderEventService(
|
|||||||
case HandledStripeWebhook.InvoiceCreated:
|
case HandledStripeWebhook.InvoiceCreated:
|
||||||
{
|
{
|
||||||
var clients =
|
var clients =
|
||||||
(await providerOrganizationRepository.GetManyDetailsByProviderAsync(parsedProviderId))
|
await providerOrganizationRepository.GetManyDetailsByProviderAsync(parsedProviderId);
|
||||||
.Where(providerOrganization => providerOrganization.Status == OrganizationStatusType.Managed);
|
|
||||||
|
|
||||||
var providerPlans = await providerPlanRepository.GetByProviderId(parsedProviderId);
|
var providerPlans = await providerPlanRepository.GetByProviderId(parsedProviderId);
|
||||||
|
|
||||||
var enterpriseProviderPlan =
|
var invoiceItems = new List<ProviderInvoiceItem>();
|
||||||
providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
|
|
||||||
|
|
||||||
var teamsProviderPlan =
|
foreach (var client in clients)
|
||||||
providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
|
|
||||||
|
|
||||||
if (enterpriseProviderPlan == null || !enterpriseProviderPlan.IsConfigured() ||
|
|
||||||
teamsProviderPlan == null || !teamsProviderPlan.IsConfigured())
|
|
||||||
{
|
{
|
||||||
logger.LogError("Provider {ProviderID} is missing or has misconfigured provider plans", parsedProviderId);
|
if (client.Status != OrganizationStatusType.Managed)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
throw new Exception("Cannot record invoice line items for Provider with missing or misconfigured provider plans");
|
var plan = StaticStore.Plans.Single(x => x.Name == client.Plan && providerPlans.Any(y => y.PlanType == x.Type));
|
||||||
|
|
||||||
|
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
|
||||||
|
|
||||||
|
var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
|
||||||
|
|
||||||
|
invoiceItems.Add(new ProviderInvoiceItem
|
||||||
|
{
|
||||||
|
ProviderId = parsedProviderId,
|
||||||
|
InvoiceId = invoice.Id,
|
||||||
|
InvoiceNumber = invoice.Number,
|
||||||
|
ClientId = client.OrganizationId,
|
||||||
|
ClientName = client.OrganizationName,
|
||||||
|
PlanName = client.Plan,
|
||||||
|
AssignedSeats = client.Seats ?? 0,
|
||||||
|
UsedSeats = client.OccupiedSeats ?? 0,
|
||||||
|
Total = (client.Seats ?? 0) * discountedSeatPrice
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
|
foreach (var providerPlan in providerPlans.Where(x => x.PurchasedSeats is null or 0))
|
||||||
|
|
||||||
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
|
||||||
|
|
||||||
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
|
|
||||||
|
|
||||||
var discountedEnterpriseSeatPrice = enterprisePlan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
|
|
||||||
|
|
||||||
var discountedTeamsSeatPrice = teamsPlan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
|
|
||||||
|
|
||||||
var invoiceItems = clients.Select(client => new ProviderInvoiceItem
|
|
||||||
{
|
{
|
||||||
ProviderId = parsedProviderId,
|
var plan = StaticStore.GetPlan(providerPlan.PlanType);
|
||||||
InvoiceId = invoice.Id,
|
|
||||||
InvoiceNumber = invoice.Number,
|
|
||||||
ClientId = client.OrganizationId,
|
|
||||||
ClientName = client.OrganizationName,
|
|
||||||
PlanName = client.Plan,
|
|
||||||
AssignedSeats = client.Seats ?? 0,
|
|
||||||
UsedSeats = client.OccupiedSeats ?? 0,
|
|
||||||
Total = client.Plan == enterprisePlan.Name
|
|
||||||
? (client.Seats ?? 0) * discountedEnterpriseSeatPrice
|
|
||||||
: (client.Seats ?? 0) * discountedTeamsSeatPrice
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
if (enterpriseProviderPlan.PurchasedSeats is null or 0)
|
var clientSeats = invoiceItems
|
||||||
{
|
.Where(item => item.PlanName == plan.Name)
|
||||||
var enterpriseClientSeats = invoiceItems
|
|
||||||
.Where(item => item.PlanName == enterprisePlan.Name)
|
|
||||||
.Sum(item => item.AssignedSeats);
|
.Sum(item => item.AssignedSeats);
|
||||||
|
|
||||||
var unassignedEnterpriseSeats = enterpriseProviderPlan.SeatMinimum - enterpriseClientSeats ?? 0;
|
var unassignedSeats = providerPlan.SeatMinimum - clientSeats ?? 0;
|
||||||
|
|
||||||
if (unassignedEnterpriseSeats > 0)
|
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
|
||||||
|
|
||||||
|
var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
|
||||||
|
|
||||||
|
invoiceItems.Add(new ProviderInvoiceItem
|
||||||
{
|
{
|
||||||
invoiceItems.Add(new ProviderInvoiceItem
|
ProviderId = parsedProviderId,
|
||||||
{
|
InvoiceId = invoice.Id,
|
||||||
ProviderId = parsedProviderId,
|
InvoiceNumber = invoice.Number,
|
||||||
InvoiceId = invoice.Id,
|
ClientName = "Unassigned seats",
|
||||||
InvoiceNumber = invoice.Number,
|
PlanName = plan.Name,
|
||||||
ClientName = "Unassigned seats",
|
AssignedSeats = unassignedSeats,
|
||||||
PlanName = enterprisePlan.Name,
|
UsedSeats = 0,
|
||||||
AssignedSeats = unassignedEnterpriseSeats,
|
Total = unassignedSeats * discountedSeatPrice
|
||||||
UsedSeats = 0,
|
});
|
||||||
Total = unassignedEnterpriseSeats * discountedEnterpriseSeatPrice
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (teamsProviderPlan.PurchasedSeats is null or 0)
|
|
||||||
{
|
|
||||||
var teamsClientSeats = invoiceItems
|
|
||||||
.Where(item => item.PlanName == teamsPlan.Name)
|
|
||||||
.Sum(item => item.AssignedSeats);
|
|
||||||
|
|
||||||
var unassignedTeamsSeats = teamsProviderPlan.SeatMinimum - teamsClientSeats ?? 0;
|
|
||||||
|
|
||||||
if (unassignedTeamsSeats > 0)
|
|
||||||
{
|
|
||||||
invoiceItems.Add(new ProviderInvoiceItem
|
|
||||||
{
|
|
||||||
ProviderId = parsedProviderId,
|
|
||||||
InvoiceId = invoice.Id,
|
|
||||||
InvoiceNumber = invoice.Number,
|
|
||||||
ClientName = "Unassigned seats",
|
|
||||||
PlanName = teamsPlan.Name,
|
|
||||||
AssignedSeats = unassignedTeamsSeats,
|
|
||||||
UsedSeats = 0,
|
|
||||||
Total = unassignedTeamsSeats * discountedTeamsSeatPrice
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.WhenAll(invoiceItems.Select(providerInvoiceItemRepository.CreateAsync));
|
await Task.WhenAll(invoiceItems.Select(providerInvoiceItemRepository.CreateAsync));
|
||||||
|
@ -0,0 +1,43 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||||
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
||||||
|
|
||||||
|
public class GroupAuthorizationHandler(ICurrentContext currentContext)
|
||||||
|
: AuthorizationHandler<GroupOperationRequirement, OrganizationScope>
|
||||||
|
{
|
||||||
|
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
|
||||||
|
GroupOperationRequirement requirement, OrganizationScope organizationScope)
|
||||||
|
{
|
||||||
|
var authorized = requirement switch
|
||||||
|
{
|
||||||
|
not null when requirement.Name == nameof(GroupOperations.ReadAll) =>
|
||||||
|
await CanReadAllAsync(organizationScope),
|
||||||
|
not null when requirement.Name == nameof(GroupOperations.ReadAllDetails) =>
|
||||||
|
await CanViewGroupDetailsAsync(organizationScope),
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
|
||||||
|
if (requirement is not null && authorized)
|
||||||
|
{
|
||||||
|
context.Succeed(requirement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> CanReadAllAsync(OrganizationScope organizationScope) =>
|
||||||
|
currentContext.GetOrganization(organizationScope) is not null
|
||||||
|
|| await currentContext.ProviderUserForOrgAsync(organizationScope);
|
||||||
|
|
||||||
|
private async Task<bool> CanViewGroupDetailsAsync(OrganizationScope organizationScope) =>
|
||||||
|
currentContext.GetOrganization(organizationScope) is
|
||||||
|
{ Type: OrganizationUserType.Owner } or
|
||||||
|
{ Type: OrganizationUserType.Admin } or
|
||||||
|
{
|
||||||
|
Permissions: { ManageGroups: true } or
|
||||||
|
{ ManageUsers: true }
|
||||||
|
} ||
|
||||||
|
await currentContext.ProviderUserForOrgAsync(organizationScope);
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization.Infrastructure;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
||||||
|
|
||||||
|
public class GroupOperationRequirement : OperationAuthorizationRequirement
|
||||||
|
{
|
||||||
|
public GroupOperationRequirement(string name)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class GroupOperations
|
||||||
|
{
|
||||||
|
public static readonly GroupOperationRequirement ReadAll = new(nameof(ReadAll));
|
||||||
|
public static readonly GroupOperationRequirement ReadAllDetails = new(nameof(ReadAllDetails));
|
||||||
|
}
|
@ -20,7 +20,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
|
|||||||
private readonly IGlobalSettings _globalSettings;
|
private readonly IGlobalSettings _globalSettings;
|
||||||
private readonly IPolicyService _policyService;
|
private readonly IPolicyService _policyService;
|
||||||
private readonly IFeatureService _featureService;
|
private readonly IFeatureService _featureService;
|
||||||
private readonly IOrganizationService _organizationService;
|
|
||||||
private readonly ILogger<VerifyOrganizationDomainCommand> _logger;
|
private readonly ILogger<VerifyOrganizationDomainCommand> _logger;
|
||||||
|
|
||||||
public VerifyOrganizationDomainCommand(
|
public VerifyOrganizationDomainCommand(
|
||||||
@ -30,7 +29,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
|
|||||||
IGlobalSettings globalSettings,
|
IGlobalSettings globalSettings,
|
||||||
IPolicyService policyService,
|
IPolicyService policyService,
|
||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
IOrganizationService organizationService,
|
|
||||||
ILogger<VerifyOrganizationDomainCommand> logger)
|
ILogger<VerifyOrganizationDomainCommand> logger)
|
||||||
{
|
{
|
||||||
_organizationDomainRepository = organizationDomainRepository;
|
_organizationDomainRepository = organizationDomainRepository;
|
||||||
@ -39,7 +37,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
|
|||||||
_globalSettings = globalSettings;
|
_globalSettings = globalSettings;
|
||||||
_policyService = policyService;
|
_policyService = policyService;
|
||||||
_featureService = featureService;
|
_featureService = featureService;
|
||||||
_organizationService = organizationService;
|
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
#nullable enable
|
#nullable enable
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
using Bit.Core.Context;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Context;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||||
@ -7,14 +7,10 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authoriza
|
|||||||
public class OrganizationUserUserMiniDetailsAuthorizationHandler :
|
public class OrganizationUserUserMiniDetailsAuthorizationHandler :
|
||||||
AuthorizationHandler<OrganizationUserUserMiniDetailsOperationRequirement, OrganizationScope>
|
AuthorizationHandler<OrganizationUserUserMiniDetailsOperationRequirement, OrganizationScope>
|
||||||
{
|
{
|
||||||
private readonly IApplicationCacheService _applicationCacheService;
|
|
||||||
private readonly ICurrentContext _currentContext;
|
private readonly ICurrentContext _currentContext;
|
||||||
|
|
||||||
public OrganizationUserUserMiniDetailsAuthorizationHandler(
|
public OrganizationUserUserMiniDetailsAuthorizationHandler(ICurrentContext currentContext)
|
||||||
IApplicationCacheService applicationCacheService,
|
|
||||||
ICurrentContext currentContext)
|
|
||||||
{
|
{
|
||||||
_applicationCacheService = applicationCacheService;
|
|
||||||
_currentContext = currentContext;
|
_currentContext = currentContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -87,8 +87,7 @@ public class SavePolicyCommand : ISavePolicyCommand
|
|||||||
if (currentPolicy is not { Enabled: true } && policyUpdate.Enabled)
|
if (currentPolicy is not { Enabled: true } && policyUpdate.Enabled)
|
||||||
{
|
{
|
||||||
var missingRequiredPolicyTypes = validator.RequiredPolicies
|
var missingRequiredPolicyTypes = validator.RequiredPolicies
|
||||||
.Where(requiredPolicyType =>
|
.Where(requiredPolicyType => savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true })
|
||||||
savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true })
|
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (missingRequiredPolicyTypes.Count != 0)
|
if (missingRequiredPolicyTypes.Count != 0)
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||||
using Bit.Core.Auth.Enums;
|
using Bit.Core.Auth.Enums;
|
||||||
@ -23,7 +24,9 @@ public class SingleOrgPolicyValidator : IPolicyValidator
|
|||||||
private readonly IOrganizationRepository _organizationRepository;
|
private readonly IOrganizationRepository _organizationRepository;
|
||||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||||
private readonly ICurrentContext _currentContext;
|
private readonly ICurrentContext _currentContext;
|
||||||
|
private readonly IFeatureService _featureService;
|
||||||
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
||||||
|
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
|
||||||
|
|
||||||
public SingleOrgPolicyValidator(
|
public SingleOrgPolicyValidator(
|
||||||
IOrganizationUserRepository organizationUserRepository,
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
@ -31,14 +34,18 @@ public class SingleOrgPolicyValidator : IPolicyValidator
|
|||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
ISsoConfigRepository ssoConfigRepository,
|
ISsoConfigRepository ssoConfigRepository,
|
||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
IRemoveOrganizationUserCommand removeOrganizationUserCommand)
|
IFeatureService featureService,
|
||||||
|
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
|
||||||
|
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery)
|
||||||
{
|
{
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
_mailService = mailService;
|
_mailService = mailService;
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
_ssoConfigRepository = ssoConfigRepository;
|
_ssoConfigRepository = ssoConfigRepository;
|
||||||
_currentContext = currentContext;
|
_currentContext = currentContext;
|
||||||
|
_featureService = featureService;
|
||||||
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
||||||
|
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IEnumerable<PolicyType> RequiredPolicies => [];
|
public IEnumerable<PolicyType> RequiredPolicies => [];
|
||||||
@ -93,9 +100,21 @@ public class SingleOrgPolicyValidator : IPolicyValidator
|
|||||||
if (policyUpdate is not { Enabled: true })
|
if (policyUpdate is not { Enabled: true })
|
||||||
{
|
{
|
||||||
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(policyUpdate.OrganizationId);
|
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(policyUpdate.OrganizationId);
|
||||||
return ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]);
|
|
||||||
|
var validateDecryptionErrorMessage = ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(validateDecryptionErrorMessage))
|
||||||
|
{
|
||||||
|
return validateDecryptionErrorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||||
|
&& await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policyUpdate.OrganizationId))
|
||||||
|
{
|
||||||
|
return "The Single organization policy is required for organizations that have enabled domain verification.";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "";
|
return string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A typed wrapper for an organization Guid. This is used for authorization checks
|
/// A typed wrapper for an organization Guid. This is used for authorization checks
|
@ -289,7 +289,7 @@ public class PolicyService : IPolicyService
|
|||||||
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||||
&& await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(org.Id))
|
&& await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(org.Id))
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Organization has verified domains.");
|
throw new BadRequestException("The Single organization policy is required for organizations that have enabled domain verification.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
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.Enums.Provider;
|
|
||||||
using Bit.Core.Billing.Entities;
|
using Bit.Core.Billing.Entities;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Billing.Services.Contracts;
|
using Bit.Core.Billing.Services.Contracts;
|
||||||
@ -12,18 +11,10 @@ namespace Bit.Core.Billing.Services;
|
|||||||
public interface IProviderBillingService
|
public interface IProviderBillingService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Assigns a specified number of <paramref name="seats"/> to a client <paramref name="organization"/> on behalf of
|
/// Changes the assigned provider plan for the provider.
|
||||||
/// its <paramref name="provider"/>. Seat adjustments for the client organization may autoscale the provider's Stripe
|
|
||||||
/// <see cref="Stripe.Subscription"/> depending on the provider's seat minimum for the client <paramref name="organization"/>'s
|
|
||||||
/// <see cref="PlanType"/>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="provider">The <see cref="Provider"/> that manages the client <paramref name="organization"/>.</param>
|
/// <param name="command">The command to change the provider plan.</param>
|
||||||
/// <param name="organization">The client <see cref="Organization"/> whose <paramref name="seats"/> you want to update.</param>
|
Task ChangePlan(ChangeProviderPlanCommand command);
|
||||||
/// <param name="seats">The number of seats to assign to the client organization.</param>
|
|
||||||
Task AssignSeatsToClientOrganization(
|
|
||||||
Provider provider,
|
|
||||||
Organization organization,
|
|
||||||
int seats);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create a Stripe <see cref="Stripe.Customer"/> for the provided client <paramref name="organization"/> utilizing
|
/// Create a Stripe <see cref="Stripe.Customer"/> for the provided client <paramref name="organization"/> utilizing
|
||||||
@ -44,18 +35,6 @@ public interface IProviderBillingService
|
|||||||
Task<byte[]> GenerateClientInvoiceReport(
|
Task<byte[]> GenerateClientInvoiceReport(
|
||||||
string invoiceId);
|
string invoiceId);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves the number of seats an MSP has assigned to its client organizations with a specified <paramref name="planType"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="providerId">The ID of the MSP to retrieve the assigned seat total for.</param>
|
|
||||||
/// <param name="planType">The type of plan to retrieve the assigned seat total for.</param>
|
|
||||||
/// <returns>An <see cref="int"/> representing the number of seats the provider has assigned to its client organizations with the specified <paramref name="planType"/>.</returns>
|
|
||||||
/// <exception cref="BillingException">Thrown when the provider represented by the <paramref name="providerId"/> is <see langword="null"/>.</exception>
|
|
||||||
/// <exception cref="BillingException">Thrown when the provider represented by the <paramref name="providerId"/> has <see cref="Provider.Type"/> <see cref="ProviderType.Reseller"/>.</exception>
|
|
||||||
Task<int> GetAssignedSeatTotalForPlanOrThrow(
|
|
||||||
Guid providerId,
|
|
||||||
PlanType planType);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scales the <paramref name="provider"/>'s seats for the specified <paramref name="planType"/> using the provided <paramref name="seatAdjustment"/>.
|
/// Scales the <paramref name="provider"/>'s seats for the specified <paramref name="planType"/> using the provided <paramref name="seatAdjustment"/>.
|
||||||
/// This operation may autoscale the provider's Stripe <see cref="Stripe.Subscription"/> depending on the <paramref name="provider"/>'s seat minimum for the
|
/// This operation may autoscale the provider's Stripe <see cref="Stripe.Subscription"/> depending on the <paramref name="provider"/>'s seat minimum for the
|
||||||
@ -69,6 +48,22 @@ public interface IProviderBillingService
|
|||||||
PlanType planType,
|
PlanType planType,
|
||||||
int seatAdjustment);
|
int seatAdjustment);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether the provided <paramref name="seatAdjustment"/> will result in a purchase for the <paramref name="provider"/>'s <see cref="planType"/>.
|
||||||
|
/// Seat adjustments that result in purchases include:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>The <paramref name="provider"/> going from below the seat minimum to above the seat minimum for the provided <paramref name="planType"/></item>
|
||||||
|
/// <item>The <paramref name="provider"/> going from above the seat minimum to further above the seat minimum for the provided <paramref name="planType"/></item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="provider">The provider to check seat adjustments for.</param>
|
||||||
|
/// <param name="planType">The plan type to check seat adjustments for.</param>
|
||||||
|
/// <param name="seatAdjustment">The change in seats for the <paramref name="provider"/>'s <paramref name="planType"/>.</param>
|
||||||
|
Task<bool> SeatAdjustmentResultsInPurchase(
|
||||||
|
Provider provider,
|
||||||
|
PlanType planType,
|
||||||
|
int seatAdjustment);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// For use during the provider setup process, this method creates a Stripe <see cref="Stripe.Customer"/> for the specified <paramref name="provider"/> utilizing the provided <paramref name="taxInfo"/>.
|
/// For use during the provider setup process, this method creates a Stripe <see cref="Stripe.Customer"/> for the specified <paramref name="provider"/> utilizing the provided <paramref name="taxInfo"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -90,12 +85,5 @@ public interface IProviderBillingService
|
|||||||
Task<Subscription> SetupSubscription(
|
Task<Subscription> SetupSubscription(
|
||||||
Provider provider);
|
Provider provider);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Changes the assigned provider plan for the provider.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="command">The command to change the provider plan.</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
Task ChangePlan(ChangeProviderPlanCommand command);
|
|
||||||
|
|
||||||
Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command);
|
Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command);
|
||||||
}
|
}
|
||||||
|
@ -111,7 +111,6 @@ public static class FeatureFlagKeys
|
|||||||
public const string AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section";
|
public const string AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section";
|
||||||
public const string EmailVerification = "email-verification";
|
public const string EmailVerification = "email-verification";
|
||||||
public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays";
|
public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays";
|
||||||
public const string AnhFcmv1Migration = "anh-fcmv1-migration";
|
|
||||||
public const string ExtensionRefresh = "extension-refresh";
|
public const string ExtensionRefresh = "extension-refresh";
|
||||||
public const string RestrictProviderAccess = "restrict-provider-access";
|
public const string RestrictProviderAccess = "restrict-provider-access";
|
||||||
public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service";
|
public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service";
|
||||||
@ -143,6 +142,7 @@ public static class FeatureFlagKeys
|
|||||||
public const string StorageReseedRefactor = "storage-reseed-refactor";
|
public const string StorageReseedRefactor = "storage-reseed-refactor";
|
||||||
public const string TrialPayment = "PM-8163-trial-payment";
|
public const string TrialPayment = "PM-8163-trial-payment";
|
||||||
public const string RemoveServerVersionHeader = "remove-server-version-header";
|
public const string RemoveServerVersionHeader = "remove-server-version-header";
|
||||||
|
public const string SecureOrgGroupDetails = "pm-3479-secure-org-group-details";
|
||||||
public const string AccessIntelligence = "pm-13227-access-intelligence";
|
public const string AccessIntelligence = "pm-13227-access-intelligence";
|
||||||
public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint";
|
public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint";
|
||||||
public const string PM12275_MultiOrganizationEnterprises = "pm-12275-multi-organization-enterprises";
|
public const string PM12275_MultiOrganizationEnterprises = "pm-12275-multi-organization-enterprises";
|
||||||
@ -151,6 +151,11 @@ public static class FeatureFlagKeys
|
|||||||
public const string GeneratorToolsModernization = "generator-tools-modernization";
|
public const string GeneratorToolsModernization = "generator-tools-modernization";
|
||||||
public const string NewDeviceVerification = "new-device-verification";
|
public const string NewDeviceVerification = "new-device-verification";
|
||||||
public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application";
|
public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application";
|
||||||
|
public const string IntegrationPage = "pm-14505-admin-console-integration-page";
|
||||||
|
public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss";
|
||||||
|
public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss";
|
||||||
|
public const string SecurityTasks = "security-tasks";
|
||||||
|
public const string PM14401_ScaleMSPOnClientOrganizationUpdate = "PM-14401-scale-msp-on-client-organization-update";
|
||||||
|
|
||||||
public static List<string> GetAllKeys()
|
public static List<string> GetAllKeys()
|
||||||
{
|
{
|
||||||
|
@ -39,6 +39,7 @@ public class CurrentContext : ICurrentContext
|
|||||||
public virtual int? BotScore { get; set; }
|
public virtual int? BotScore { get; set; }
|
||||||
public virtual string ClientId { get; set; }
|
public virtual string ClientId { get; set; }
|
||||||
public virtual Version ClientVersion { get; set; }
|
public virtual Version ClientVersion { get; set; }
|
||||||
|
public virtual bool ClientVersionIsPrerelease { get; set; }
|
||||||
public virtual IdentityClientType IdentityClientType { get; set; }
|
public virtual IdentityClientType IdentityClientType { get; set; }
|
||||||
public virtual Guid? ServiceAccountOrganizationId { get; set; }
|
public virtual Guid? ServiceAccountOrganizationId { get; set; }
|
||||||
|
|
||||||
@ -97,6 +98,11 @@ public class CurrentContext : ICurrentContext
|
|||||||
{
|
{
|
||||||
ClientVersion = cVersion;
|
ClientVersion = cVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (httpContext.Request.Headers.TryGetValue("Is-Prerelease", out var clientVersionIsPrerelease))
|
||||||
|
{
|
||||||
|
ClientVersionIsPrerelease = clientVersionIsPrerelease == "1";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async virtual Task BuildAsync(ClaimsPrincipal user, GlobalSettings globalSettings)
|
public async virtual Task BuildAsync(ClaimsPrincipal user, GlobalSettings globalSettings)
|
||||||
|
@ -29,12 +29,13 @@ public interface ICurrentContext
|
|||||||
int? BotScore { get; set; }
|
int? BotScore { get; set; }
|
||||||
string ClientId { get; set; }
|
string ClientId { get; set; }
|
||||||
Version ClientVersion { get; set; }
|
Version ClientVersion { get; set; }
|
||||||
|
bool ClientVersionIsPrerelease { get; set; }
|
||||||
|
|
||||||
Task BuildAsync(HttpContext httpContext, GlobalSettings globalSettings);
|
Task BuildAsync(HttpContext httpContext, GlobalSettings globalSettings);
|
||||||
Task BuildAsync(ClaimsPrincipal user, GlobalSettings globalSettings);
|
Task BuildAsync(ClaimsPrincipal user, GlobalSettings globalSettings);
|
||||||
|
|
||||||
Task SetContextAsync(ClaimsPrincipal user);
|
Task SetContextAsync(ClaimsPrincipal user);
|
||||||
|
|
||||||
|
|
||||||
Task<bool> OrganizationUser(Guid orgId);
|
Task<bool> OrganizationUser(Guid orgId);
|
||||||
Task<bool> OrganizationAdmin(Guid orgId);
|
Task<bool> OrganizationAdmin(Guid orgId);
|
||||||
Task<bool> OrganizationOwner(Guid orgId);
|
Task<bool> OrganizationOwner(Guid orgId);
|
||||||
|
@ -21,8 +21,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.401.30" />
|
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.401.37" />
|
||||||
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.40" />
|
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.47" />
|
||||||
<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" />
|
||||||
|
@ -4,7 +4,6 @@ using Bit.Core.Repositories;
|
|||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Microsoft.Azure.NotificationHubs;
|
using Microsoft.Azure.NotificationHubs;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Bit.Core.NotificationHub;
|
namespace Bit.Core.NotificationHub;
|
||||||
@ -60,21 +59,10 @@ public class NotificationHubPushRegistrationService : IPushRegistrationService
|
|||||||
switch (type)
|
switch (type)
|
||||||
{
|
{
|
||||||
case DeviceType.Android:
|
case DeviceType.Android:
|
||||||
var featureService = _serviceProvider.GetRequiredService<IFeatureService>();
|
payloadTemplate = "{\"message\":{\"data\":{\"type\":\"$(type)\",\"payload\":\"$(payload)\"}}}";
|
||||||
if (featureService.IsEnabled(FeatureFlagKeys.AnhFcmv1Migration))
|
messageTemplate = "{\"message\":{\"data\":{\"type\":\"$(type)\"}," +
|
||||||
{
|
"\"notification\":{\"title\":\"$(title)\",\"body\":\"$(message)\"}}}";
|
||||||
payloadTemplate = "{\"message\":{\"data\":{\"type\":\"$(type)\",\"payload\":\"$(payload)\"}}}";
|
installation.Platform = NotificationPlatform.FcmV1;
|
||||||
messageTemplate = "{\"message\":{\"data\":{\"type\":\"$(type)\"}," +
|
|
||||||
"\"notification\":{\"title\":\"$(title)\",\"body\":\"$(message)\"}}}";
|
|
||||||
installation.Platform = NotificationPlatform.FcmV1;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
payloadTemplate = "{\"data\":{\"data\":{\"type\":\"#(type)\",\"payload\":\"$(payload)\"}}}";
|
|
||||||
messageTemplate = "{\"data\":{\"data\":{\"type\":\"#(type)\"}," +
|
|
||||||
"\"notification\":{\"title\":\"$(title)\",\"body\":\"$(message)\"}}}";
|
|
||||||
installation.Platform = NotificationPlatform.Fcm;
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case DeviceType.iOS:
|
case DeviceType.iOS:
|
||||||
payloadTemplate = "{\"data\":{\"type\":\"#(type)\",\"payload\":\"$(payload)\"}," +
|
payloadTemplate = "{\"data\":{\"type\":\"#(type)\",\"payload\":\"$(payload)\"}," +
|
||||||
|
@ -20,6 +20,7 @@ public class LaunchDarklyFeatureService : IFeatureService
|
|||||||
private const string _contextKindServiceAccount = "service-account";
|
private const string _contextKindServiceAccount = "service-account";
|
||||||
|
|
||||||
private const string _contextAttributeClientVersion = "client-version";
|
private const string _contextAttributeClientVersion = "client-version";
|
||||||
|
private const string _contextAttributeClientVersionIsPrerelease = "client-version-is-prerelease";
|
||||||
private const string _contextAttributeDeviceType = "device-type";
|
private const string _contextAttributeDeviceType = "device-type";
|
||||||
private const string _contextAttributeClientType = "client-type";
|
private const string _contextAttributeClientType = "client-type";
|
||||||
private const string _contextAttributeOrganizations = "organizations";
|
private const string _contextAttributeOrganizations = "organizations";
|
||||||
@ -145,6 +146,7 @@ public class LaunchDarklyFeatureService : IFeatureService
|
|||||||
if (_currentContext.ClientVersion != null)
|
if (_currentContext.ClientVersion != null)
|
||||||
{
|
{
|
||||||
builder.Set(_contextAttributeClientVersion, _currentContext.ClientVersion.ToString());
|
builder.Set(_contextAttributeClientVersion, _currentContext.ClientVersion.ToString());
|
||||||
|
builder.Set(_contextAttributeClientVersionIsPrerelease, _currentContext.ClientVersionIsPrerelease);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_currentContext.DeviceType.HasValue)
|
if (_currentContext.DeviceType.HasValue)
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
|
namespace Bit.Core.Tools.Entities;
|
||||||
|
|
||||||
public class PasswordHealthReportApplication : ITableObject<Guid>, IRevisable
|
public class PasswordHealthReportApplication : ITableObject<Guid>, IRevisable
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public Guid OrganizationId { get; set; }
|
public Guid OrganizationId { get; set; }
|
||||||
public string Uri { get; set; }
|
public string? Uri { get; set; }
|
||||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
|
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
43
src/Core/Tools/Models/Data/MemberAccessCipherDetails.cs
Normal file
43
src/Core/Tools/Models/Data/MemberAccessCipherDetails.cs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
namespace Bit.Core.Tools.Models.Data;
|
||||||
|
|
||||||
|
public class MemberAccessDetails
|
||||||
|
{
|
||||||
|
public Guid? CollectionId { get; set; }
|
||||||
|
public Guid? GroupId { get; set; }
|
||||||
|
public string GroupName { get; set; }
|
||||||
|
public string CollectionName { get; set; }
|
||||||
|
public int ItemCount { get; set; }
|
||||||
|
public bool? ReadOnly { get; set; }
|
||||||
|
public bool? HidePasswords { get; set; }
|
||||||
|
public bool? Manage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The CipherIds associated with the group/collection access
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<string> CollectionCipherIds { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MemberAccessCipherDetails
|
||||||
|
{
|
||||||
|
public string UserName { get; set; }
|
||||||
|
public string Email { get; set; }
|
||||||
|
public bool TwoFactorEnabled { get; set; }
|
||||||
|
public bool AccountRecoveryEnabled { get; set; }
|
||||||
|
public int GroupsCount { get; set; }
|
||||||
|
public int CollectionsCount { get; set; }
|
||||||
|
public int TotalItemCount { get; set; }
|
||||||
|
public Guid? UserGuid { get; set; }
|
||||||
|
public bool UsesKeyConnector { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The details for the member's collection access depending
|
||||||
|
/// on the collections and groups they are assigned to
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<MemberAccessDetails> AccessDetails { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A distinct list of the cipher ids associated with
|
||||||
|
/// the organization member
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<string> CipherIds { get; set; }
|
||||||
|
}
|
@ -0,0 +1,101 @@
|
|||||||
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Tools.Entities;
|
||||||
|
using Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||||
|
using Bit.Core.Tools.Repositories;
|
||||||
|
using Bit.Core.Tools.Requests;
|
||||||
|
|
||||||
|
namespace Bit.Core.Tools.ReportFeatures;
|
||||||
|
|
||||||
|
public class AddPasswordHealthReportApplicationCommand : IAddPasswordHealthReportApplicationCommand
|
||||||
|
{
|
||||||
|
private IOrganizationRepository _organizationRepo;
|
||||||
|
private IPasswordHealthReportApplicationRepository _passwordHealthReportApplicationRepo;
|
||||||
|
|
||||||
|
public AddPasswordHealthReportApplicationCommand(
|
||||||
|
IOrganizationRepository organizationRepository,
|
||||||
|
IPasswordHealthReportApplicationRepository passwordHealthReportApplicationRepository)
|
||||||
|
{
|
||||||
|
_organizationRepo = organizationRepository;
|
||||||
|
_passwordHealthReportApplicationRepo = passwordHealthReportApplicationRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PasswordHealthReportApplication> AddPasswordHealthReportApplicationAsync(AddPasswordHealthReportApplicationRequest request)
|
||||||
|
{
|
||||||
|
var (req, IsValid, errorMessage) = await ValidateRequestAsync(request);
|
||||||
|
if (!IsValid)
|
||||||
|
{
|
||||||
|
throw new BadRequestException(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
var passwordHealthReportApplication = new PasswordHealthReportApplication
|
||||||
|
{
|
||||||
|
OrganizationId = request.OrganizationId,
|
||||||
|
Uri = request.Url,
|
||||||
|
};
|
||||||
|
|
||||||
|
passwordHealthReportApplication.SetNewId();
|
||||||
|
|
||||||
|
var data = await _passwordHealthReportApplicationRepo.CreateAsync(passwordHealthReportApplication);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<PasswordHealthReportApplication>> AddPasswordHealthReportApplicationAsync(IEnumerable<AddPasswordHealthReportApplicationRequest> requests)
|
||||||
|
{
|
||||||
|
var requestsList = requests.ToList();
|
||||||
|
|
||||||
|
// create tasks to validate each request
|
||||||
|
var tasks = requestsList.Select(async request =>
|
||||||
|
{
|
||||||
|
var (req, IsValid, errorMessage) = await ValidateRequestAsync(request);
|
||||||
|
if (!IsValid)
|
||||||
|
{
|
||||||
|
throw new BadRequestException(errorMessage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// run validations and allow exceptions to bubble
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
// create PasswordHealthReportApplication entities
|
||||||
|
var passwordHealthReportApplications = requestsList.Select(request =>
|
||||||
|
{
|
||||||
|
var pwdHealthReportApplication = new PasswordHealthReportApplication
|
||||||
|
{
|
||||||
|
OrganizationId = request.OrganizationId,
|
||||||
|
Uri = request.Url,
|
||||||
|
};
|
||||||
|
pwdHealthReportApplication.SetNewId();
|
||||||
|
return pwdHealthReportApplication;
|
||||||
|
});
|
||||||
|
|
||||||
|
// create and return the entities
|
||||||
|
var response = new List<PasswordHealthReportApplication>();
|
||||||
|
foreach (var record in passwordHealthReportApplications)
|
||||||
|
{
|
||||||
|
var data = await _passwordHealthReportApplicationRepo.CreateAsync(record);
|
||||||
|
response.Add(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Tuple<AddPasswordHealthReportApplicationRequest, bool, string>> ValidateRequestAsync(
|
||||||
|
AddPasswordHealthReportApplicationRequest request)
|
||||||
|
{
|
||||||
|
// verify that the organization exists
|
||||||
|
var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId);
|
||||||
|
if (organization == null)
|
||||||
|
{
|
||||||
|
return new Tuple<AddPasswordHealthReportApplicationRequest, bool, string>(request, false, "Invalid Organization");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure that we have a URL
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Url))
|
||||||
|
{
|
||||||
|
return new Tuple<AddPasswordHealthReportApplicationRequest, bool, string>(request, false, "URL is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Tuple<AddPasswordHealthReportApplicationRequest, bool, string>(request, true, string.Empty);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,27 @@
|
|||||||
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Tools.Entities;
|
||||||
|
using Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||||
|
using Bit.Core.Tools.Repositories;
|
||||||
|
|
||||||
|
namespace Bit.Core.Tools.ReportFeatures;
|
||||||
|
|
||||||
|
public class GetPasswordHealthReportApplicationQuery : IGetPasswordHealthReportApplicationQuery
|
||||||
|
{
|
||||||
|
private IPasswordHealthReportApplicationRepository _passwordHealthReportApplicationRepo;
|
||||||
|
|
||||||
|
public GetPasswordHealthReportApplicationQuery(
|
||||||
|
IPasswordHealthReportApplicationRepository passwordHealthReportApplicationRepo)
|
||||||
|
{
|
||||||
|
_passwordHealthReportApplicationRepo = passwordHealthReportApplicationRepo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<PasswordHealthReportApplication>> GetPasswordHealthReportApplicationAsync(Guid organizationId)
|
||||||
|
{
|
||||||
|
if (organizationId == Guid.Empty)
|
||||||
|
{
|
||||||
|
throw new BadRequestException("OrganizationId is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _passwordHealthReportApplicationRepo.GetByOrganizationIdAsync(organizationId);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
using Bit.Core.Tools.Entities;
|
||||||
|
using Bit.Core.Tools.Requests;
|
||||||
|
|
||||||
|
namespace Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||||
|
|
||||||
|
public interface IAddPasswordHealthReportApplicationCommand
|
||||||
|
{
|
||||||
|
Task<PasswordHealthReportApplication> AddPasswordHealthReportApplicationAsync(AddPasswordHealthReportApplicationRequest request);
|
||||||
|
Task<IEnumerable<PasswordHealthReportApplication>> AddPasswordHealthReportApplicationAsync(IEnumerable<AddPasswordHealthReportApplicationRequest> requests);
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
using Bit.Core.Tools.Entities;
|
||||||
|
|
||||||
|
namespace Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||||
|
|
||||||
|
public interface IGetPasswordHealthReportApplicationQuery
|
||||||
|
{
|
||||||
|
Task<IEnumerable<PasswordHealthReportApplication>> GetPasswordHealthReportApplicationAsync(Guid organizationId);
|
||||||
|
}
|
208
src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs
Normal file
208
src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Models.Data;
|
||||||
|
using Bit.Core.Models.Data.Organizations;
|
||||||
|
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Tools.Models.Data;
|
||||||
|
using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||||
|
using Bit.Core.Tools.ReportFeatures.Requests;
|
||||||
|
using Bit.Core.Vault.Models.Data;
|
||||||
|
using Bit.Core.Vault.Queries;
|
||||||
|
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
|
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
|
||||||
|
|
||||||
|
namespace Bit.Core.Tools.ReportFeatures;
|
||||||
|
|
||||||
|
public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery
|
||||||
|
{
|
||||||
|
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
|
||||||
|
private readonly IGroupRepository _groupRepository;
|
||||||
|
private readonly ICollectionRepository _collectionRepository;
|
||||||
|
private readonly IOrganizationCiphersQuery _organizationCiphersQuery;
|
||||||
|
private readonly IApplicationCacheService _applicationCacheService;
|
||||||
|
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||||
|
|
||||||
|
public MemberAccessCipherDetailsQuery(
|
||||||
|
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
|
||||||
|
IGroupRepository groupRepository,
|
||||||
|
ICollectionRepository collectionRepository,
|
||||||
|
IOrganizationCiphersQuery organizationCiphersQuery,
|
||||||
|
IApplicationCacheService applicationCacheService,
|
||||||
|
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
|
||||||
|
_groupRepository = groupRepository;
|
||||||
|
_collectionRepository = collectionRepository;
|
||||||
|
_organizationCiphersQuery = organizationCiphersQuery;
|
||||||
|
_applicationCacheService = applicationCacheService;
|
||||||
|
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<MemberAccessCipherDetails>> GetMemberAccessCipherDetails(MemberAccessCipherDetailsRequest request)
|
||||||
|
{
|
||||||
|
var orgUsers = await _organizationUserUserDetailsQuery.GetOrganizationUserUserDetails(
|
||||||
|
new OrganizationUserUserDetailsQueryRequest
|
||||||
|
{
|
||||||
|
OrganizationId = request.OrganizationId,
|
||||||
|
IncludeCollections = true,
|
||||||
|
IncludeGroups = true
|
||||||
|
});
|
||||||
|
|
||||||
|
var orgGroups = await _groupRepository.GetManyByOrganizationIdAsync(request.OrganizationId);
|
||||||
|
var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(request.OrganizationId);
|
||||||
|
var orgCollectionsWithAccess = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(request.OrganizationId);
|
||||||
|
var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(request.OrganizationId);
|
||||||
|
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers);
|
||||||
|
|
||||||
|
var memberAccessCipherDetails = GenerateAccessData(
|
||||||
|
orgGroups,
|
||||||
|
orgCollectionsWithAccess,
|
||||||
|
orgItems,
|
||||||
|
organizationUsersTwoFactorEnabled,
|
||||||
|
orgAbility
|
||||||
|
);
|
||||||
|
|
||||||
|
return memberAccessCipherDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a report for all members of an organization. Containing summary information
|
||||||
|
/// such as item, collection, and group counts. Including the cipherIds a member is assigned.
|
||||||
|
/// Child collection includes detailed information on the user and group collections along
|
||||||
|
/// with their permissions.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="orgGroups">Organization groups collection</param>
|
||||||
|
/// <param name="orgCollectionsWithAccess">Collections for the organization and the groups/users and permissions</param>
|
||||||
|
/// <param name="orgItems">Cipher items for the organization with the collections associated with them</param>
|
||||||
|
/// <param name="organizationUsersTwoFactorEnabled">Organization users and two factor status</param>
|
||||||
|
/// <param name="orgAbility">Organization ability for account recovery status</param>
|
||||||
|
/// <returns>List of the MemberAccessCipherDetailsModel</returns>;
|
||||||
|
private IEnumerable<MemberAccessCipherDetails> GenerateAccessData(
|
||||||
|
ICollection<Group> orgGroups,
|
||||||
|
ICollection<Tuple<Collection, CollectionAccessDetails>> orgCollectionsWithAccess,
|
||||||
|
IEnumerable<CipherOrganizationDetailsWithCollections> orgItems,
|
||||||
|
IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled,
|
||||||
|
OrganizationAbility orgAbility)
|
||||||
|
{
|
||||||
|
var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user);
|
||||||
|
// Create a dictionary to lookup the group names later.
|
||||||
|
var groupNameDictionary = orgGroups.ToDictionary(x => x.Id, x => x.Name);
|
||||||
|
|
||||||
|
// Get collections grouped and into a dictionary for counts
|
||||||
|
var collectionItems = orgItems
|
||||||
|
.SelectMany(x => x.CollectionIds,
|
||||||
|
(cipher, collectionId) => new { Cipher = cipher, CollectionId = collectionId })
|
||||||
|
.GroupBy(y => y.CollectionId,
|
||||||
|
(key, ciphers) => new { CollectionId = key, Ciphers = ciphers });
|
||||||
|
var itemLookup = collectionItems.ToDictionary(x => x.CollectionId.ToString(), x => x.Ciphers.Select(c => c.Cipher.Id.ToString()));
|
||||||
|
|
||||||
|
// Loop through the org users and populate report and access data
|
||||||
|
var memberAccessCipherDetails = new List<MemberAccessCipherDetails>();
|
||||||
|
foreach (var user in orgUsers)
|
||||||
|
{
|
||||||
|
var groupAccessDetails = new List<MemberAccessDetails>();
|
||||||
|
var userCollectionAccessDetails = new List<MemberAccessDetails>();
|
||||||
|
foreach (var tCollect in orgCollectionsWithAccess)
|
||||||
|
{
|
||||||
|
var hasItems = itemLookup.TryGetValue(tCollect.Item1.Id.ToString(), out var items);
|
||||||
|
var collectionCiphers = hasItems ? items.Select(x => x) : null;
|
||||||
|
|
||||||
|
var itemCounts = hasItems ? collectionCiphers.Count() : 0;
|
||||||
|
if (tCollect.Item2.Groups.Count() > 0)
|
||||||
|
{
|
||||||
|
|
||||||
|
var groupDetails = tCollect.Item2.Groups.Where((tCollectGroups) => user.Groups.Contains(tCollectGroups.Id)).Select(x =>
|
||||||
|
new MemberAccessDetails
|
||||||
|
{
|
||||||
|
CollectionId = tCollect.Item1.Id,
|
||||||
|
CollectionName = tCollect.Item1.Name,
|
||||||
|
GroupId = x.Id,
|
||||||
|
GroupName = groupNameDictionary[x.Id],
|
||||||
|
ReadOnly = x.ReadOnly,
|
||||||
|
HidePasswords = x.HidePasswords,
|
||||||
|
Manage = x.Manage,
|
||||||
|
ItemCount = itemCounts,
|
||||||
|
CollectionCipherIds = items
|
||||||
|
});
|
||||||
|
|
||||||
|
groupAccessDetails.AddRange(groupDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All collections assigned to users and their permissions
|
||||||
|
if (tCollect.Item2.Users.Count() > 0)
|
||||||
|
{
|
||||||
|
var userCollectionDetails = tCollect.Item2.Users.Where((tCollectUser) => tCollectUser.Id == user.Id).Select(x =>
|
||||||
|
new MemberAccessDetails
|
||||||
|
{
|
||||||
|
CollectionId = tCollect.Item1.Id,
|
||||||
|
CollectionName = tCollect.Item1.Name,
|
||||||
|
ReadOnly = x.ReadOnly,
|
||||||
|
HidePasswords = x.HidePasswords,
|
||||||
|
Manage = x.Manage,
|
||||||
|
ItemCount = itemCounts,
|
||||||
|
CollectionCipherIds = items
|
||||||
|
});
|
||||||
|
userCollectionAccessDetails.AddRange(userCollectionDetails);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var report = new MemberAccessCipherDetails
|
||||||
|
{
|
||||||
|
UserName = user.Name,
|
||||||
|
Email = user.Email,
|
||||||
|
TwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == user.Id).twoFactorIsEnabled,
|
||||||
|
// Both the user's ResetPasswordKey must be set and the organization can UseResetPassword
|
||||||
|
AccountRecoveryEnabled = !string.IsNullOrEmpty(user.ResetPasswordKey) && orgAbility.UseResetPassword,
|
||||||
|
UserGuid = user.Id,
|
||||||
|
UsesKeyConnector = user.UsesKeyConnector
|
||||||
|
};
|
||||||
|
|
||||||
|
var userAccessDetails = new List<MemberAccessDetails>();
|
||||||
|
if (user.Groups.Any())
|
||||||
|
{
|
||||||
|
var userGroups = groupAccessDetails.Where(x => user.Groups.Contains(x.GroupId.GetValueOrDefault()));
|
||||||
|
userAccessDetails.AddRange(userGroups);
|
||||||
|
}
|
||||||
|
|
||||||
|
// There can be edge cases where groups don't have a collection
|
||||||
|
var groupsWithoutCollections = user.Groups.Where(x => !userAccessDetails.Any(y => x == y.GroupId));
|
||||||
|
if (groupsWithoutCollections.Count() > 0)
|
||||||
|
{
|
||||||
|
var emptyGroups = groupsWithoutCollections.Select(x => new MemberAccessDetails
|
||||||
|
{
|
||||||
|
GroupId = x,
|
||||||
|
GroupName = groupNameDictionary[x],
|
||||||
|
ItemCount = 0
|
||||||
|
});
|
||||||
|
userAccessDetails.AddRange(emptyGroups);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.Collections.Any())
|
||||||
|
{
|
||||||
|
var userCollections = userCollectionAccessDetails.Where(x => user.Collections.Any(y => x.CollectionId == y.Id));
|
||||||
|
userAccessDetails.AddRange(userCollections);
|
||||||
|
}
|
||||||
|
report.AccessDetails = userAccessDetails;
|
||||||
|
|
||||||
|
var userCiphers =
|
||||||
|
report.AccessDetails
|
||||||
|
.Where(x => x.ItemCount > 0)
|
||||||
|
.SelectMany(y => y.CollectionCipherIds)
|
||||||
|
.Distinct();
|
||||||
|
report.CipherIds = userCiphers;
|
||||||
|
report.TotalItemCount = userCiphers.Count();
|
||||||
|
|
||||||
|
// Distinct items only
|
||||||
|
var distinctItems = report.AccessDetails.Where(x => x.CollectionId.HasValue).Select(x => x.CollectionId).Distinct();
|
||||||
|
report.CollectionsCount = distinctItems.Count();
|
||||||
|
report.GroupsCount = report.AccessDetails.Select(x => x.GroupId).Where(y => y.HasValue).Distinct().Count();
|
||||||
|
memberAccessCipherDetails.Add(report);
|
||||||
|
}
|
||||||
|
return memberAccessCipherDetails;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
using Bit.Core.Tools.Models.Data;
|
||||||
|
using Bit.Core.Tools.ReportFeatures.Requests;
|
||||||
|
|
||||||
|
namespace Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||||
|
|
||||||
|
public interface IMemberAccessCipherDetailsQuery
|
||||||
|
{
|
||||||
|
Task<IEnumerable<MemberAccessCipherDetails>> GetMemberAccessCipherDetails(MemberAccessCipherDetailsRequest request);
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
using Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||||
|
using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Bit.Core.Tools.ReportFeatures;
|
||||||
|
|
||||||
|
public static class ReportingServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static void AddReportingServices(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddScoped<IMemberAccessCipherDetailsQuery, MemberAccessCipherDetailsQuery>();
|
||||||
|
services.AddScoped<IAddPasswordHealthReportApplicationCommand, AddPasswordHealthReportApplicationCommand>();
|
||||||
|
services.AddScoped<IGetPasswordHealthReportApplicationQuery, GetPasswordHealthReportApplicationQuery>();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
namespace Bit.Core.Tools.ReportFeatures.Requests;
|
||||||
|
|
||||||
|
public class MemberAccessCipherDetailsRequest
|
||||||
|
{
|
||||||
|
public Guid OrganizationId { get; set; }
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Tools.Entities;
|
||||||
|
|
||||||
|
namespace Bit.Core.Tools.Repositories;
|
||||||
|
|
||||||
|
public interface IPasswordHealthReportApplicationRepository : IRepository<PasswordHealthReportApplication, Guid>
|
||||||
|
{
|
||||||
|
Task<ICollection<PasswordHealthReportApplication>> GetByOrganizationIdAsync(Guid organizationId);
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
namespace Bit.Core.Tools.Requests;
|
||||||
|
|
||||||
|
public class AddPasswordHealthReportApplicationRequest
|
||||||
|
{
|
||||||
|
public Guid OrganizationId { get; set; }
|
||||||
|
public string Url { get; set; }
|
||||||
|
}
|
@ -58,6 +58,7 @@ public static class DapperServiceCollectionExtensions
|
|||||||
services.AddSingleton<INotificationStatusRepository, NotificationStatusRepository>();
|
services.AddSingleton<INotificationStatusRepository, NotificationStatusRepository>();
|
||||||
services
|
services
|
||||||
.AddSingleton<IClientOrganizationMigrationRecordRepository, ClientOrganizationMigrationRecordRepository>();
|
.AddSingleton<IClientOrganizationMigrationRecordRepository, ClientOrganizationMigrationRecordRepository>();
|
||||||
|
services.AddSingleton<IPasswordHealthReportApplicationRepository, PasswordHealthReportApplicationRepository>();
|
||||||
|
|
||||||
if (selfHosted)
|
if (selfHosted)
|
||||||
{
|
{
|
||||||
|
@ -0,0 +1,33 @@
|
|||||||
|
using System.Data;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
using Bit.Core.Tools.Repositories;
|
||||||
|
using Bit.Infrastructure.Dapper.Repositories;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using ToolsEntities = Bit.Core.Tools.Entities;
|
||||||
|
|
||||||
|
namespace Bit.Infrastructure.Dapper.Tools.Repositories;
|
||||||
|
|
||||||
|
public class PasswordHealthReportApplicationRepository : Repository<ToolsEntities.PasswordHealthReportApplication, Guid>, IPasswordHealthReportApplicationRepository
|
||||||
|
{
|
||||||
|
public PasswordHealthReportApplicationRepository(GlobalSettings globalSettings)
|
||||||
|
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
|
||||||
|
{ }
|
||||||
|
|
||||||
|
public PasswordHealthReportApplicationRepository(string connectionString, string readOnlyConnectionString)
|
||||||
|
: base(connectionString, readOnlyConnectionString)
|
||||||
|
{ }
|
||||||
|
|
||||||
|
public async Task<ICollection<ToolsEntities.PasswordHealthReportApplication>> GetByOrganizationIdAsync(Guid organizationId)
|
||||||
|
{
|
||||||
|
using (var connection = new SqlConnection(ReadOnlyConnectionString))
|
||||||
|
{
|
||||||
|
var results = await connection.QueryAsync<ToolsEntities.PasswordHealthReportApplication>(
|
||||||
|
$"[{Schema}].[PasswordHealthReportApplication_ReadByOrganizationId]",
|
||||||
|
new { OrganizationId = organizationId },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
|
||||||
|
return results.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -95,6 +95,7 @@ public static class EntityFrameworkServiceCollectionExtensions
|
|||||||
services.AddSingleton<INotificationStatusRepository, NotificationStatusRepository>();
|
services.AddSingleton<INotificationStatusRepository, NotificationStatusRepository>();
|
||||||
services
|
services
|
||||||
.AddSingleton<IClientOrganizationMigrationRecordRepository, ClientOrganizationMigrationRecordRepository>();
|
.AddSingleton<IClientOrganizationMigrationRecordRepository, ClientOrganizationMigrationRecordRepository>();
|
||||||
|
services.AddSingleton<IPasswordHealthReportApplicationRepository, PasswordHealthReportApplicationRepository>();
|
||||||
|
|
||||||
if (selfHosted)
|
if (selfHosted)
|
||||||
{
|
{
|
||||||
|
@ -7,6 +7,7 @@ using Bit.Infrastructure.EntityFramework.Converters;
|
|||||||
using Bit.Infrastructure.EntityFramework.Models;
|
using Bit.Infrastructure.EntityFramework.Models;
|
||||||
using Bit.Infrastructure.EntityFramework.NotificationCenter.Models;
|
using Bit.Infrastructure.EntityFramework.NotificationCenter.Models;
|
||||||
using Bit.Infrastructure.EntityFramework.SecretsManager.Models;
|
using Bit.Infrastructure.EntityFramework.SecretsManager.Models;
|
||||||
|
using Bit.Infrastructure.EntityFramework.Tools.Models;
|
||||||
using Bit.Infrastructure.EntityFramework.Vault.Models;
|
using Bit.Infrastructure.EntityFramework.Vault.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
@ -75,6 +76,7 @@ public class DatabaseContext : DbContext
|
|||||||
public DbSet<Notification> Notifications { get; set; }
|
public DbSet<Notification> Notifications { get; set; }
|
||||||
public DbSet<NotificationStatus> NotificationStatuses { get; set; }
|
public DbSet<NotificationStatus> NotificationStatuses { get; set; }
|
||||||
public DbSet<ClientOrganizationMigrationRecord> ClientOrganizationMigrationRecords { get; set; }
|
public DbSet<ClientOrganizationMigrationRecord> ClientOrganizationMigrationRecords { get; set; }
|
||||||
|
public DbSet<PasswordHealthReportApplication> PasswordHealthReportApplications { get; set; }
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
|
@ -0,0 +1,24 @@
|
|||||||
|
using Bit.Infrastructure.EntityFramework.Tools.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace Bit.Infrastructure.EntityFramework.Tools.Configurations;
|
||||||
|
|
||||||
|
public class PasswordHealthReportApplicationEntityTypeConfiguration : IEntityTypeConfiguration<PasswordHealthReportApplication>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<PasswordHealthReportApplication> builder)
|
||||||
|
{
|
||||||
|
builder
|
||||||
|
.Property(s => s.Id)
|
||||||
|
.ValueGeneratedNever();
|
||||||
|
|
||||||
|
builder.HasIndex(s => s.Id)
|
||||||
|
.IsClustered(true);
|
||||||
|
|
||||||
|
builder
|
||||||
|
.HasIndex(s => s.OrganizationId)
|
||||||
|
.IsClustered(false);
|
||||||
|
|
||||||
|
builder.ToTable(nameof(PasswordHealthReportApplication));
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
|
||||||
|
|
||||||
|
namespace Bit.Infrastructure.EntityFramework.Tools.Models;
|
||||||
|
|
||||||
|
public class PasswordHealthReportApplication : Core.Tools.Entities.PasswordHealthReportApplication
|
||||||
|
{
|
||||||
|
public virtual Organization Organization { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PasswordHealthReportApplicationProfile : Profile
|
||||||
|
{
|
||||||
|
public PasswordHealthReportApplicationProfile()
|
||||||
|
{
|
||||||
|
CreateMap<Core.Tools.Entities.PasswordHealthReportApplication, PasswordHealthReportApplication>()
|
||||||
|
.ReverseMap();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using Bit.Core.Tools.Repositories;
|
||||||
|
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||||
|
using Bit.Infrastructure.EntityFramework.Tools.Models;
|
||||||
|
using LinqToDB;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using AdminConsoleEntities = Bit.Core.Tools.Entities;
|
||||||
|
|
||||||
|
namespace Bit.Infrastructure.EntityFramework.Tools.Repositories;
|
||||||
|
|
||||||
|
public class PasswordHealthReportApplicationRepository :
|
||||||
|
Repository<AdminConsoleEntities.PasswordHealthReportApplication, PasswordHealthReportApplication, Guid>,
|
||||||
|
IPasswordHealthReportApplicationRepository
|
||||||
|
{
|
||||||
|
public PasswordHealthReportApplicationRepository(IServiceScopeFactory serviceScopeFactory,
|
||||||
|
IMapper mapper) : base(serviceScopeFactory, mapper, (DatabaseContext context) => context.PasswordHealthReportApplications)
|
||||||
|
{ }
|
||||||
|
|
||||||
|
public async Task<ICollection<AdminConsoleEntities.PasswordHealthReportApplication>> GetByOrganizationIdAsync(Guid organizationId)
|
||||||
|
{
|
||||||
|
using (var scope = ServiceScopeFactory.CreateScope())
|
||||||
|
{
|
||||||
|
var dbContext = GetDatabaseContext(scope);
|
||||||
|
var results = await dbContext.PasswordHealthReportApplications
|
||||||
|
.Where(p => p.OrganizationId == organizationId)
|
||||||
|
.ToListAsync();
|
||||||
|
return Mapper.Map<ICollection<AdminConsoleEntities.PasswordHealthReportApplication>>(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -34,6 +34,7 @@ using Bit.Core.SecretsManager.Repositories.Noop;
|
|||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Tokens;
|
using Bit.Core.Tokens;
|
||||||
|
using Bit.Core.Tools.ReportFeatures;
|
||||||
using Bit.Core.Tools.Services;
|
using Bit.Core.Tools.Services;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Bit.Core.Vault;
|
using Bit.Core.Vault;
|
||||||
@ -116,6 +117,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddLoginServices();
|
services.AddLoginServices();
|
||||||
services.AddScoped<IOrganizationDomainService, OrganizationDomainService>();
|
services.AddScoped<IOrganizationDomainService, OrganizationDomainService>();
|
||||||
services.AddVaultServices();
|
services.AddVaultServices();
|
||||||
|
services.AddReportingServices();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void AddTokenizers(this IServiceCollection services)
|
public static void AddTokenizers(this IServiceCollection services)
|
||||||
|
@ -0,0 +1,343 @@
|
|||||||
|
using Bit.Admin.AdminConsole.Controllers;
|
||||||
|
using Bit.Admin.AdminConsole.Models;
|
||||||
|
using Bit.Core;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Bit.Core.Billing.Services;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using NSubstitute;
|
||||||
|
|
||||||
|
namespace Admin.Test.AdminConsole.Controllers;
|
||||||
|
|
||||||
|
[ControllerCustomize(typeof(OrganizationsController))]
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class OrganizationsControllerTests
|
||||||
|
{
|
||||||
|
#region Edit (POST)
|
||||||
|
|
||||||
|
[BitAutoData]
|
||||||
|
[SutProviderCustomize]
|
||||||
|
[Theory]
|
||||||
|
public async Task Edit_ProviderSeatScaling_RequiredFFDisabled_NoOp(
|
||||||
|
SutProvider<OrganizationsController> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var organizationId = new Guid();
|
||||||
|
var update = new OrganizationEditModel { UseSecretsManager = false };
|
||||||
|
|
||||||
|
var organization = new Organization
|
||||||
|
{
|
||||||
|
Id = organizationId
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
|
||||||
|
.Returns(organization);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
_ = await sutProvider.Sut.Edit(organizationId, update);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await sutProvider.GetDependency<IProviderBillingService>().DidNotReceiveWithAnyArgs()
|
||||||
|
.ScaleSeats(Arg.Any<Provider>(), Arg.Any<PlanType>(), Arg.Any<int>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[BitAutoData]
|
||||||
|
[SutProviderCustomize]
|
||||||
|
[Theory]
|
||||||
|
public async Task Edit_ProviderSeatScaling_NonBillableProvider_NoOp(
|
||||||
|
SutProvider<OrganizationsController> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var organizationId = new Guid();
|
||||||
|
var update = new OrganizationEditModel { UseSecretsManager = false };
|
||||||
|
|
||||||
|
var organization = new Organization
|
||||||
|
{
|
||||||
|
Id = organizationId
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
|
||||||
|
.Returns(organization);
|
||||||
|
|
||||||
|
var featureService = sutProvider.GetDependency<IFeatureService>();
|
||||||
|
|
||||||
|
featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
||||||
|
featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true);
|
||||||
|
|
||||||
|
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Created };
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
_ = await sutProvider.Sut.Edit(organizationId, update);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await sutProvider.GetDependency<IProviderBillingService>().DidNotReceiveWithAnyArgs()
|
||||||
|
.ScaleSeats(Arg.Any<Provider>(), Arg.Any<PlanType>(), Arg.Any<int>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[BitAutoData]
|
||||||
|
[SutProviderCustomize]
|
||||||
|
[Theory]
|
||||||
|
public async Task Edit_ProviderSeatScaling_UnmanagedOrganization_NoOp(
|
||||||
|
SutProvider<OrganizationsController> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var organizationId = new Guid();
|
||||||
|
var update = new OrganizationEditModel { UseSecretsManager = false };
|
||||||
|
|
||||||
|
var organization = new Organization
|
||||||
|
{
|
||||||
|
Id = organizationId,
|
||||||
|
Status = OrganizationStatusType.Created
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
|
||||||
|
.Returns(organization);
|
||||||
|
|
||||||
|
var featureService = sutProvider.GetDependency<IFeatureService>();
|
||||||
|
|
||||||
|
featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
||||||
|
featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true);
|
||||||
|
|
||||||
|
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
_ = await sutProvider.Sut.Edit(organizationId, update);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await sutProvider.GetDependency<IProviderBillingService>().DidNotReceiveWithAnyArgs()
|
||||||
|
.ScaleSeats(Arg.Any<Provider>(), Arg.Any<PlanType>(), Arg.Any<int>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[BitAutoData]
|
||||||
|
[SutProviderCustomize]
|
||||||
|
[Theory]
|
||||||
|
public async Task Edit_ProviderSeatScaling_NonCBPlanType_NoOp(
|
||||||
|
SutProvider<OrganizationsController> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var organizationId = new Guid();
|
||||||
|
|
||||||
|
var update = new OrganizationEditModel
|
||||||
|
{
|
||||||
|
UseSecretsManager = false,
|
||||||
|
Seats = 10,
|
||||||
|
PlanType = PlanType.FamiliesAnnually
|
||||||
|
};
|
||||||
|
|
||||||
|
var organization = new Organization
|
||||||
|
{
|
||||||
|
Id = organizationId,
|
||||||
|
Status = OrganizationStatusType.Managed,
|
||||||
|
Seats = 10
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
|
||||||
|
.Returns(organization);
|
||||||
|
|
||||||
|
var featureService = sutProvider.GetDependency<IFeatureService>();
|
||||||
|
|
||||||
|
featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
||||||
|
featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true);
|
||||||
|
|
||||||
|
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
_ = await sutProvider.Sut.Edit(organizationId, update);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await sutProvider.GetDependency<IProviderBillingService>().DidNotReceiveWithAnyArgs()
|
||||||
|
.ScaleSeats(Arg.Any<Provider>(), Arg.Any<PlanType>(), Arg.Any<int>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[BitAutoData]
|
||||||
|
[SutProviderCustomize]
|
||||||
|
[Theory]
|
||||||
|
public async Task Edit_ProviderSeatScaling_NoUpdateRequired_NoOp(
|
||||||
|
SutProvider<OrganizationsController> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var organizationId = new Guid();
|
||||||
|
var update = new OrganizationEditModel
|
||||||
|
{
|
||||||
|
UseSecretsManager = false,
|
||||||
|
Seats = 10,
|
||||||
|
PlanType = PlanType.EnterpriseMonthly
|
||||||
|
};
|
||||||
|
|
||||||
|
var organization = new Organization
|
||||||
|
{
|
||||||
|
Id = organizationId,
|
||||||
|
Status = OrganizationStatusType.Managed,
|
||||||
|
Seats = 10,
|
||||||
|
PlanType = PlanType.EnterpriseMonthly
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
|
||||||
|
.Returns(organization);
|
||||||
|
|
||||||
|
var featureService = sutProvider.GetDependency<IFeatureService>();
|
||||||
|
|
||||||
|
featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
||||||
|
featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true);
|
||||||
|
|
||||||
|
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
_ = await sutProvider.Sut.Edit(organizationId, update);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await sutProvider.GetDependency<IProviderBillingService>().DidNotReceiveWithAnyArgs()
|
||||||
|
.ScaleSeats(Arg.Any<Provider>(), Arg.Any<PlanType>(), Arg.Any<int>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[BitAutoData]
|
||||||
|
[SutProviderCustomize]
|
||||||
|
[Theory]
|
||||||
|
public async Task Edit_ProviderSeatScaling_PlanTypesUpdate_ScalesSeatsCorrectly(
|
||||||
|
SutProvider<OrganizationsController> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var organizationId = new Guid();
|
||||||
|
var update = new OrganizationEditModel
|
||||||
|
{
|
||||||
|
UseSecretsManager = false,
|
||||||
|
Seats = 10,
|
||||||
|
PlanType = PlanType.EnterpriseMonthly
|
||||||
|
};
|
||||||
|
|
||||||
|
var organization = new Organization
|
||||||
|
{
|
||||||
|
Id = organizationId,
|
||||||
|
Status = OrganizationStatusType.Managed,
|
||||||
|
Seats = 10,
|
||||||
|
PlanType = PlanType.TeamsMonthly
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
|
||||||
|
.Returns(organization);
|
||||||
|
|
||||||
|
var featureService = sutProvider.GetDependency<IFeatureService>();
|
||||||
|
|
||||||
|
featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
||||||
|
featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true);
|
||||||
|
|
||||||
|
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
_ = await sutProvider.Sut.Edit(organizationId, update);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
|
||||||
|
|
||||||
|
await providerBillingService.Received(1).ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
|
||||||
|
await providerBillingService.Received(1).ScaleSeats(provider, update.PlanType!.Value, organization.Seats.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[BitAutoData]
|
||||||
|
[SutProviderCustomize]
|
||||||
|
[Theory]
|
||||||
|
public async Task Edit_ProviderSeatScaling_SeatsUpdate_ScalesSeatsCorrectly(
|
||||||
|
SutProvider<OrganizationsController> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var organizationId = new Guid();
|
||||||
|
var update = new OrganizationEditModel
|
||||||
|
{
|
||||||
|
UseSecretsManager = false,
|
||||||
|
Seats = 15,
|
||||||
|
PlanType = PlanType.EnterpriseMonthly
|
||||||
|
};
|
||||||
|
|
||||||
|
var organization = new Organization
|
||||||
|
{
|
||||||
|
Id = organizationId,
|
||||||
|
Status = OrganizationStatusType.Managed,
|
||||||
|
Seats = 10,
|
||||||
|
PlanType = PlanType.EnterpriseMonthly
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
|
||||||
|
.Returns(organization);
|
||||||
|
|
||||||
|
var featureService = sutProvider.GetDependency<IFeatureService>();
|
||||||
|
|
||||||
|
featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
||||||
|
featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true);
|
||||||
|
|
||||||
|
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
_ = await sutProvider.Sut.Edit(organizationId, update);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
|
||||||
|
|
||||||
|
await providerBillingService.Received(1).ScaleSeats(provider, organization.PlanType, update.Seats!.Value - organization.Seats.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[BitAutoData]
|
||||||
|
[SutProviderCustomize]
|
||||||
|
[Theory]
|
||||||
|
public async Task Edit_ProviderSeatScaling_FullUpdate_ScalesSeatsCorrectly(
|
||||||
|
SutProvider<OrganizationsController> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var organizationId = new Guid();
|
||||||
|
var update = new OrganizationEditModel
|
||||||
|
{
|
||||||
|
UseSecretsManager = false,
|
||||||
|
Seats = 15,
|
||||||
|
PlanType = PlanType.EnterpriseMonthly
|
||||||
|
};
|
||||||
|
|
||||||
|
var organization = new Organization
|
||||||
|
{
|
||||||
|
Id = organizationId,
|
||||||
|
Status = OrganizationStatusType.Managed,
|
||||||
|
Seats = 10,
|
||||||
|
PlanType = PlanType.TeamsMonthly
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
|
||||||
|
.Returns(organization);
|
||||||
|
|
||||||
|
var featureService = sutProvider.GetDependency<IFeatureService>();
|
||||||
|
|
||||||
|
featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
||||||
|
featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true);
|
||||||
|
|
||||||
|
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
_ = await sutProvider.Sut.Edit(organizationId, update);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
|
||||||
|
|
||||||
|
await providerBillingService.Received(1).ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
|
||||||
|
await providerBillingService.Received(1).ScaleSeats(provider, update.PlanType!.Value, update.Seats!.Value - organization.Seats.Value + organization.Seats.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
@ -0,0 +1,223 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||||
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Models.Data;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using NSubstitute;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Api.Test.AdminConsole.AuthorizationHandlers;
|
||||||
|
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class GroupAuthorizationHandlerTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData(OrganizationUserType.Admin)]
|
||||||
|
[BitAutoData(OrganizationUserType.Owner)]
|
||||||
|
[BitAutoData(OrganizationUserType.User)]
|
||||||
|
[BitAutoData(OrganizationUserType.Custom)]
|
||||||
|
public async Task CanReadAllAsync_WhenMemberOfOrg_Success(
|
||||||
|
OrganizationUserType userType,
|
||||||
|
OrganizationScope scope,
|
||||||
|
Guid userId, SutProvider<GroupAuthorizationHandler> sutProvider,
|
||||||
|
CurrentContextOrganization organization)
|
||||||
|
{
|
||||||
|
organization.Type = userType;
|
||||||
|
organization.Permissions = new Permissions();
|
||||||
|
|
||||||
|
var context = new AuthorizationHandlerContext(
|
||||||
|
new[] { GroupOperations.ReadAll },
|
||||||
|
new ClaimsPrincipal(),
|
||||||
|
scope);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(userId);
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().GetOrganization(scope).Returns(organization);
|
||||||
|
|
||||||
|
await sutProvider.Sut.HandleAsync(context);
|
||||||
|
|
||||||
|
Assert.True(context.HasSucceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task CanReadAllAsync_WithProviderUser_Success(
|
||||||
|
Guid userId,
|
||||||
|
OrganizationScope scope,
|
||||||
|
SutProvider<GroupAuthorizationHandler> sutProvider, CurrentContextOrganization organization)
|
||||||
|
{
|
||||||
|
organization.Type = OrganizationUserType.User;
|
||||||
|
organization.Permissions = new Permissions();
|
||||||
|
|
||||||
|
var context = new AuthorizationHandlerContext(
|
||||||
|
new[] { GroupOperations.ReadAll },
|
||||||
|
new ClaimsPrincipal(),
|
||||||
|
scope);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>()
|
||||||
|
.UserId
|
||||||
|
.Returns(userId);
|
||||||
|
sutProvider.GetDependency<ICurrentContext>()
|
||||||
|
.ProviderUserForOrgAsync(scope)
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
await sutProvider.Sut.HandleAsync(context);
|
||||||
|
|
||||||
|
Assert.True(context.HasSucceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task CanReadAllAsync_WhenMissingOrgAccess_NoSuccess(
|
||||||
|
Guid userId,
|
||||||
|
OrganizationScope scope,
|
||||||
|
SutProvider<GroupAuthorizationHandler> sutProvider)
|
||||||
|
{
|
||||||
|
|
||||||
|
var context = new AuthorizationHandlerContext(
|
||||||
|
new[] { GroupOperations.ReadAll },
|
||||||
|
new ClaimsPrincipal(),
|
||||||
|
scope
|
||||||
|
);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(userId);
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().GetOrganization(Arg.Any<Guid>()).Returns((CurrentContextOrganization)null);
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().ProviderUserForOrgAsync(Arg.Any<Guid>()).Returns(false);
|
||||||
|
|
||||||
|
await sutProvider.Sut.HandleAsync(context);
|
||||||
|
Assert.False(context.HasSucceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData(OrganizationUserType.Admin)]
|
||||||
|
[BitAutoData(OrganizationUserType.Owner)]
|
||||||
|
public async Task HandleRequirementsAsync_GivenViewDetailsOperation_WhenUserIsAdminOwner_ThenShouldSucceed(OrganizationUserType userType,
|
||||||
|
OrganizationScope scope,
|
||||||
|
CurrentContextOrganization organization, SutProvider<GroupAuthorizationHandler> sutProvider)
|
||||||
|
{
|
||||||
|
organization.Type = userType;
|
||||||
|
organization.Permissions = new Permissions();
|
||||||
|
|
||||||
|
var context = new AuthorizationHandlerContext(
|
||||||
|
[GroupOperations.ReadAllDetails],
|
||||||
|
new ClaimsPrincipal(),
|
||||||
|
scope
|
||||||
|
);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().GetOrganization(scope).Returns(organization);
|
||||||
|
|
||||||
|
await sutProvider.Sut.HandleAsync(context);
|
||||||
|
Assert.True(context.HasSucceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData(OrganizationUserType.User)]
|
||||||
|
[BitAutoData(OrganizationUserType.Custom)]
|
||||||
|
public async Task HandleRequirementsAsync_GivenViewDetailsOperation_WhenUserIsNotOwnerOrAdmin_ThenShouldFail(OrganizationUserType userType,
|
||||||
|
OrganizationScope scope,
|
||||||
|
CurrentContextOrganization organization, SutProvider<GroupAuthorizationHandler> sutProvider)
|
||||||
|
{
|
||||||
|
organization.Type = userType;
|
||||||
|
organization.Permissions = new Permissions();
|
||||||
|
|
||||||
|
var context = new AuthorizationHandlerContext(
|
||||||
|
[GroupOperations.ReadAllDetails],
|
||||||
|
new ClaimsPrincipal(),
|
||||||
|
scope
|
||||||
|
);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().GetOrganization(scope).Returns(organization);
|
||||||
|
|
||||||
|
await sutProvider.Sut.HandleAsync(context);
|
||||||
|
Assert.False(context.HasSucceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task HandleRequirementsAsync_GivenViewDetailsOperation_WhenUserHasManageGroupPermission_ThenShouldSucceed(
|
||||||
|
OrganizationScope scope,
|
||||||
|
CurrentContextOrganization organization, SutProvider<GroupAuthorizationHandler> sutProvider)
|
||||||
|
{
|
||||||
|
organization.Type = OrganizationUserType.Custom;
|
||||||
|
organization.Permissions = new Permissions
|
||||||
|
{
|
||||||
|
ManageGroups = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var context = new AuthorizationHandlerContext(
|
||||||
|
[GroupOperations.ReadAllDetails],
|
||||||
|
new ClaimsPrincipal(),
|
||||||
|
scope
|
||||||
|
);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().GetOrganization(scope).Returns(organization);
|
||||||
|
|
||||||
|
await sutProvider.Sut.HandleAsync(context);
|
||||||
|
Assert.True(context.HasSucceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task HandleRequirementsAsync_GivenViewDetailsOperation_WhenUserHasManageUserPermission_ThenShouldSucceed(
|
||||||
|
OrganizationScope scope,
|
||||||
|
CurrentContextOrganization organization, SutProvider<GroupAuthorizationHandler> sutProvider)
|
||||||
|
{
|
||||||
|
organization.Type = OrganizationUserType.Custom;
|
||||||
|
organization.Permissions = new Permissions
|
||||||
|
{
|
||||||
|
ManageUsers = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var context = new AuthorizationHandlerContext(
|
||||||
|
[GroupOperations.ReadAllDetails],
|
||||||
|
new ClaimsPrincipal(),
|
||||||
|
scope
|
||||||
|
);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().GetOrganization(scope).Returns(organization);
|
||||||
|
|
||||||
|
await sutProvider.Sut.HandleAsync(context);
|
||||||
|
Assert.True(context.HasSucceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task HandleRequirementsAsync_GivenViewDetailsOperation_WhenUserIsStandardUserTypeWithoutElevatedPermissions_ThenShouldFail(
|
||||||
|
OrganizationScope scope,
|
||||||
|
CurrentContextOrganization organization, SutProvider<GroupAuthorizationHandler> sutProvider)
|
||||||
|
{
|
||||||
|
organization.Type = OrganizationUserType.User;
|
||||||
|
organization.Permissions = new Permissions();
|
||||||
|
|
||||||
|
var context = new AuthorizationHandlerContext(
|
||||||
|
[GroupOperations.ReadAllDetails],
|
||||||
|
new ClaimsPrincipal(),
|
||||||
|
scope
|
||||||
|
);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().GetOrganization(scope).Returns(organization);
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().ProviderUserForOrgAsync(scope).Returns(false);
|
||||||
|
|
||||||
|
await sutProvider.Sut.HandleAsync(context);
|
||||||
|
Assert.False(context.HasSucceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task HandleRequirementsAsync_GivenViewDetailsOperation_WhenIsProviderUser_ThenShouldSucceed(
|
||||||
|
OrganizationScope scope,
|
||||||
|
SutProvider<GroupAuthorizationHandler> sutProvider, CurrentContextOrganization organization)
|
||||||
|
{
|
||||||
|
organization.Type = OrganizationUserType.User;
|
||||||
|
organization.Permissions = new Permissions();
|
||||||
|
|
||||||
|
var context = new AuthorizationHandlerContext(
|
||||||
|
new[] { GroupOperations.ReadAll },
|
||||||
|
new ClaimsPrincipal(),
|
||||||
|
scope);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().GetOrganization(scope).Returns(organization);
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().ProviderUserForOrgAsync(scope).Returns(true);
|
||||||
|
|
||||||
|
await sutProvider.Sut.HandleAsync(context);
|
||||||
|
|
||||||
|
Assert.True(context.HasSucceeded);
|
||||||
|
}
|
||||||
|
}
|
@ -51,7 +51,6 @@ public class OrganizationsControllerTests : IDisposable
|
|||||||
private readonly IProviderBillingService _providerBillingService;
|
private readonly IProviderBillingService _providerBillingService;
|
||||||
private readonly IDataProtectorTokenFactory<OrgDeleteTokenable> _orgDeleteTokenDataFactory;
|
private readonly IDataProtectorTokenFactory<OrgDeleteTokenable> _orgDeleteTokenDataFactory;
|
||||||
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
||||||
|
|
||||||
private readonly OrganizationsController _sut;
|
private readonly OrganizationsController _sut;
|
||||||
|
|
||||||
public OrganizationsControllerTests()
|
public OrganizationsControllerTests()
|
||||||
@ -123,7 +122,8 @@ public class OrganizationsControllerTests : IDisposable
|
|||||||
_currentContext.OrganizationUser(orgId).Returns(true);
|
_currentContext.OrganizationUser(orgId).Returns(true);
|
||||||
_ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig);
|
_ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig);
|
||||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||||
|
_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true);
|
||||||
|
_userService.GetOrganizationsManagingUserAsync(user.Id).Returns(new List<Organization> { null });
|
||||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.Leave(orgId));
|
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.Leave(orgId));
|
||||||
|
|
||||||
Assert.Contains("Your organization's Single Sign-On settings prevent you from leaving.",
|
Assert.Contains("Your organization's Single Sign-On settings prevent you from leaving.",
|
||||||
@ -132,6 +132,36 @@ public class OrganizationsControllerTests : IDisposable
|
|||||||
await _removeOrganizationUserCommand.DidNotReceiveWithAnyArgs().RemoveUserAsync(default, default);
|
await _removeOrganizationUserCommand.DidNotReceiveWithAnyArgs().RemoveUserAsync(default, default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory, AutoData]
|
||||||
|
public async Task OrganizationsController_UserCannotLeaveOrganizationThatManagesUser(
|
||||||
|
Guid orgId, User user)
|
||||||
|
{
|
||||||
|
var ssoConfig = new SsoConfig
|
||||||
|
{
|
||||||
|
Id = default,
|
||||||
|
Data = new SsoConfigurationData
|
||||||
|
{
|
||||||
|
MemberDecryptionType = MemberDecryptionType.KeyConnector
|
||||||
|
}.Serialize(),
|
||||||
|
Enabled = true,
|
||||||
|
OrganizationId = orgId,
|
||||||
|
};
|
||||||
|
var foundOrg = new Organization();
|
||||||
|
foundOrg.Id = orgId;
|
||||||
|
|
||||||
|
_currentContext.OrganizationUser(orgId).Returns(true);
|
||||||
|
_ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig);
|
||||||
|
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||||
|
_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true);
|
||||||
|
_userService.GetOrganizationsManagingUserAsync(user.Id).Returns(new List<Organization> { { foundOrg } });
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.Leave(orgId));
|
||||||
|
|
||||||
|
Assert.Contains("Managed user account cannot leave managing organization. Contact your organization administrator for additional details.",
|
||||||
|
exception.Message);
|
||||||
|
|
||||||
|
await _removeOrganizationUserCommand.DidNotReceiveWithAnyArgs().RemoveUserAsync(default, default);
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineAutoData(true, false)]
|
[InlineAutoData(true, false)]
|
||||||
[InlineAutoData(false, true)]
|
[InlineAutoData(false, true)]
|
||||||
@ -157,6 +187,8 @@ public class OrganizationsControllerTests : IDisposable
|
|||||||
_currentContext.OrganizationUser(orgId).Returns(true);
|
_currentContext.OrganizationUser(orgId).Returns(true);
|
||||||
_ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig);
|
_ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig);
|
||||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||||
|
_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true);
|
||||||
|
_userService.GetOrganizationsManagingUserAsync(user.Id).Returns(new List<Organization>());
|
||||||
|
|
||||||
await _sut.Leave(orgId);
|
await _sut.Leave(orgId);
|
||||||
|
|
||||||
|
@ -0,0 +1,69 @@
|
|||||||
|
using AutoFixture;
|
||||||
|
using Bit.Api.AdminConsole.Models.Response.Helpers;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||||
|
using NSubstitute;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Api.Test.AdminConsole.Models.Response.Helpers;
|
||||||
|
|
||||||
|
public class PolicyDetailResponsesTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSingleOrgPolicyDetailResponseAsync_GivenPolicyEntity_WhenIsSingleOrgTypeAndHasVerifiedDomains_ThenShouldNotBeAbleToToggle()
|
||||||
|
{
|
||||||
|
var fixture = new Fixture();
|
||||||
|
|
||||||
|
var policy = fixture.Build<Policy>()
|
||||||
|
.Without(p => p.Data)
|
||||||
|
.With(p => p.Type, PolicyType.SingleOrg)
|
||||||
|
.Create();
|
||||||
|
|
||||||
|
var querySub = Substitute.For<IOrganizationHasVerifiedDomainsQuery>();
|
||||||
|
querySub.HasVerifiedDomainsAsync(policy.OrganizationId)
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
var result = await policy.GetSingleOrgPolicyDetailResponseAsync(querySub);
|
||||||
|
|
||||||
|
Assert.False(result.CanToggleState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSingleOrgPolicyDetailResponseAsync_GivenPolicyEntity_WhenIsNotSingleOrgType_ThenShouldThrowArgumentException()
|
||||||
|
{
|
||||||
|
var fixture = new Fixture();
|
||||||
|
|
||||||
|
var policy = fixture.Build<Policy>()
|
||||||
|
.Without(p => p.Data)
|
||||||
|
.With(p => p.Type, PolicyType.TwoFactorAuthentication)
|
||||||
|
.Create();
|
||||||
|
|
||||||
|
var querySub = Substitute.For<IOrganizationHasVerifiedDomainsQuery>();
|
||||||
|
querySub.HasVerifiedDomainsAsync(policy.OrganizationId)
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
var action = async () => await policy.GetSingleOrgPolicyDetailResponseAsync(querySub);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ArgumentException>("policy", action);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSingleOrgPolicyDetailResponseAsync_GivenPolicyEntity_WhenIsSingleOrgTypeAndDoesNotHaveVerifiedDomains_ThenShouldBeAbleToToggle()
|
||||||
|
{
|
||||||
|
var fixture = new Fixture();
|
||||||
|
|
||||||
|
var policy = fixture.Build<Policy>()
|
||||||
|
.Without(p => p.Data)
|
||||||
|
.With(p => p.Type, PolicyType.SingleOrg)
|
||||||
|
.Create();
|
||||||
|
|
||||||
|
var querySub = Substitute.For<IOrganizationHasVerifiedDomainsQuery>();
|
||||||
|
querySub.HasVerifiedDomainsAsync(policy.OrganizationId)
|
||||||
|
.Returns(false);
|
||||||
|
|
||||||
|
var result = await policy.GetSingleOrgPolicyDetailResponseAsync(querySub);
|
||||||
|
|
||||||
|
Assert.True(result.CanToggleState);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,41 @@
|
|||||||
|
using Bit.Api.AdminConsole.Public.Models.Response;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Models.Data;
|
||||||
|
using NSubstitute;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Api.Test.AdminConsole.Public.Models.Response;
|
||||||
|
|
||||||
|
|
||||||
|
public class MemberResponseModelTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ResetPasswordEnrolled_ShouldBeTrue_WhenUserHasResetPasswordKey()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = Substitute.For<OrganizationUser>();
|
||||||
|
var collections = Substitute.For<IEnumerable<CollectionAccessSelection>>();
|
||||||
|
user.ResetPasswordKey = "none-empty";
|
||||||
|
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var sut = new MemberResponseModel(user, collections);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(sut.ResetPasswordEnrolled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResetPasswordEnrolled_ShouldBeFalse_WhenUserDoesNotHaveResetPasswordKey()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = Substitute.For<OrganizationUser>();
|
||||||
|
var collections = Substitute.For<IEnumerable<CollectionAccessSelection>>();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var sut = new MemberResponseModel(user, collections);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(sut.ResetPasswordEnrolled);
|
||||||
|
}
|
||||||
|
}
|
@ -5,8 +5,11 @@ using Bit.Core.AdminConsole.Entities;
|
|||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Models.Business;
|
using Bit.Core.Models.Business;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
@ -93,24 +96,7 @@ public class ProviderClientsControllerTests
|
|||||||
#region UpdateAsync
|
#region UpdateAsync
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task UpdateAsync_NoProviderOrganization_NotFound(
|
public async Task UpdateAsync_ServiceUserMakingPurchase_Unauthorized(
|
||||||
Provider provider,
|
|
||||||
Guid providerOrganizationId,
|
|
||||||
UpdateClientOrganizationRequestBody requestBody,
|
|
||||||
SutProvider<ProviderClientsController> sutProvider)
|
|
||||||
{
|
|
||||||
ConfigureStableProviderServiceUserInputs(provider, sutProvider);
|
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganizationId)
|
|
||||||
.ReturnsNull();
|
|
||||||
|
|
||||||
var result = await sutProvider.Sut.UpdateAsync(provider.Id, providerOrganizationId, requestBody);
|
|
||||||
|
|
||||||
AssertNotFound(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
|
||||||
public async Task UpdateAsync_AssignedSeats_Ok(
|
|
||||||
Provider provider,
|
Provider provider,
|
||||||
Guid providerOrganizationId,
|
Guid providerOrganizationId,
|
||||||
UpdateClientOrganizationRequestBody requestBody,
|
UpdateClientOrganizationRequestBody requestBody,
|
||||||
@ -118,6 +104,11 @@ public class ProviderClientsControllerTests
|
|||||||
Organization organization,
|
Organization organization,
|
||||||
SutProvider<ProviderClientsController> sutProvider)
|
SutProvider<ProviderClientsController> sutProvider)
|
||||||
{
|
{
|
||||||
|
organization.PlanType = PlanType.TeamsMonthly;
|
||||||
|
organization.Seats = 10;
|
||||||
|
organization.Status = OrganizationStatusType.Managed;
|
||||||
|
requestBody.AssignedSeats = 20;
|
||||||
|
|
||||||
ConfigureStableProviderServiceUserInputs(provider, sutProvider);
|
ConfigureStableProviderServiceUserInputs(provider, sutProvider);
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganizationId)
|
sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganizationId)
|
||||||
@ -126,49 +117,57 @@ public class ProviderClientsControllerTests
|
|||||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(providerOrganization.OrganizationId)
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(providerOrganization.OrganizationId)
|
||||||
.Returns(organization);
|
.Returns(organization);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(false);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderBillingService>().SeatAdjustmentResultsInPurchase(
|
||||||
|
provider,
|
||||||
|
PlanType.TeamsMonthly,
|
||||||
|
10).Returns(true);
|
||||||
|
|
||||||
|
var result = await sutProvider.Sut.UpdateAsync(provider.Id, providerOrganizationId, requestBody);
|
||||||
|
|
||||||
|
AssertUnauthorized(result, message: "Service users cannot purchase additional seats.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task UpdateAsync_Ok(
|
||||||
|
Provider provider,
|
||||||
|
Guid providerOrganizationId,
|
||||||
|
UpdateClientOrganizationRequestBody requestBody,
|
||||||
|
ProviderOrganization providerOrganization,
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<ProviderClientsController> sutProvider)
|
||||||
|
{
|
||||||
|
organization.PlanType = PlanType.TeamsMonthly;
|
||||||
|
organization.Seats = 10;
|
||||||
|
organization.Status = OrganizationStatusType.Managed;
|
||||||
|
requestBody.AssignedSeats = 20;
|
||||||
|
|
||||||
|
ConfigureStableProviderServiceUserInputs(provider, sutProvider);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganizationId)
|
||||||
|
.Returns(providerOrganization);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(providerOrganization.OrganizationId)
|
||||||
|
.Returns(organization);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(false);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderBillingService>().SeatAdjustmentResultsInPurchase(
|
||||||
|
provider,
|
||||||
|
PlanType.TeamsMonthly,
|
||||||
|
10).Returns(false);
|
||||||
|
|
||||||
var result = await sutProvider.Sut.UpdateAsync(provider.Id, providerOrganizationId, requestBody);
|
var result = await sutProvider.Sut.UpdateAsync(provider.Id, providerOrganizationId, requestBody);
|
||||||
|
|
||||||
await sutProvider.GetDependency<IProviderBillingService>().Received(1)
|
await sutProvider.GetDependency<IProviderBillingService>().Received(1)
|
||||||
.AssignSeatsToClientOrganization(
|
.ScaleSeats(
|
||||||
provider,
|
provider,
|
||||||
organization,
|
PlanType.TeamsMonthly,
|
||||||
requestBody.AssignedSeats);
|
10);
|
||||||
|
|
||||||
await sutProvider.GetDependency<IOrganizationRepository>().Received(1)
|
await sutProvider.GetDependency<IOrganizationRepository>().Received(1)
|
||||||
.ReplaceAsync(Arg.Is<Organization>(org => org.Name == requestBody.Name));
|
.ReplaceAsync(Arg.Is<Organization>(org => org.Seats == requestBody.AssignedSeats && org.Name == requestBody.Name));
|
||||||
|
|
||||||
Assert.IsType<Ok>(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
|
||||||
public async Task UpdateAsync_Name_Ok(
|
|
||||||
Provider provider,
|
|
||||||
Guid providerOrganizationId,
|
|
||||||
UpdateClientOrganizationRequestBody requestBody,
|
|
||||||
ProviderOrganization providerOrganization,
|
|
||||||
Organization organization,
|
|
||||||
SutProvider<ProviderClientsController> sutProvider)
|
|
||||||
{
|
|
||||||
ConfigureStableProviderServiceUserInputs(provider, sutProvider);
|
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganizationId)
|
|
||||||
.Returns(providerOrganization);
|
|
||||||
|
|
||||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(providerOrganization.OrganizationId)
|
|
||||||
.Returns(organization);
|
|
||||||
|
|
||||||
requestBody.AssignedSeats = organization.Seats!.Value;
|
|
||||||
|
|
||||||
var result = await sutProvider.Sut.UpdateAsync(provider.Id, providerOrganizationId, requestBody);
|
|
||||||
|
|
||||||
await sutProvider.GetDependency<IProviderBillingService>().DidNotReceiveWithAnyArgs()
|
|
||||||
.AssignSeatsToClientOrganization(
|
|
||||||
Arg.Any<Provider>(),
|
|
||||||
Arg.Any<Organization>(),
|
|
||||||
Arg.Any<int>());
|
|
||||||
|
|
||||||
await sutProvider.GetDependency<IOrganizationRepository>().Received(1)
|
|
||||||
.ReplaceAsync(Arg.Is<Organization>(org => org.Name == requestBody.Name));
|
|
||||||
|
|
||||||
Assert.IsType<Ok>(result);
|
Assert.IsType<Ok>(result);
|
||||||
}
|
}
|
||||||
|
@ -25,14 +25,14 @@ public static class Utilities
|
|||||||
Assert.Equal("Resource not found.", response.Message);
|
Assert.Equal("Resource not found.", response.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void AssertUnauthorized(IResult result)
|
public static void AssertUnauthorized(IResult result, string message = "Unauthorized.")
|
||||||
{
|
{
|
||||||
Assert.IsType<JsonHttpResult<ErrorResponseModel>>(result);
|
Assert.IsType<JsonHttpResult<ErrorResponseModel>>(result);
|
||||||
|
|
||||||
var response = (JsonHttpResult<ErrorResponseModel>)result;
|
var response = (JsonHttpResult<ErrorResponseModel>)result;
|
||||||
|
|
||||||
Assert.Equal(StatusCodes.Status401Unauthorized, response.StatusCode);
|
Assert.Equal(StatusCodes.Status401Unauthorized, response.StatusCode);
|
||||||
Assert.Equal("Unauthorized.", response.Value.Message);
|
Assert.Equal(message, response.Value.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void ConfigureStableProviderAdminInputs<T>(
|
public static void ConfigureStableProviderAdminInputs<T>(
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Bit.Api.AdminConsole.Controllers;
|
using Bit.Api.AdminConsole.Controllers;
|
||||||
|
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
using Bit.Core.AdminConsole.Models.Api.Response;
|
|
||||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
@ -157,7 +157,7 @@ public class PoliciesControllerTests
|
|||||||
var result = await sutProvider.Sut.Get(orgId, type);
|
var result = await sutProvider.Sut.Get(orgId, type);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.IsType<PolicyResponseModel>(result);
|
Assert.IsType<PolicyDetailResponseModel>(result);
|
||||||
Assert.Equal(policy.Id, result.Id);
|
Assert.Equal(policy.Id, result.Id);
|
||||||
Assert.Equal(policy.Type, result.Type);
|
Assert.Equal(policy.Type, result.Type);
|
||||||
Assert.Equal(policy.Enabled, result.Enabled);
|
Assert.Equal(policy.Enabled, result.Enabled);
|
||||||
@ -182,7 +182,7 @@ public class PoliciesControllerTests
|
|||||||
var result = await sutProvider.Sut.Get(orgId, type);
|
var result = await sutProvider.Sut.Get(orgId, type);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.IsType<PolicyResponseModel>(result);
|
Assert.IsType<PolicyDetailResponseModel>(result);
|
||||||
Assert.Equal(result.Type, (PolicyType)type);
|
Assert.Equal(result.Type, (PolicyType)type);
|
||||||
Assert.False(result.Enabled);
|
Assert.False(result.Enabled);
|
||||||
}
|
}
|
||||||
|
49
test/Api.Test/Tools/Controllers/ReportsControllerTests.cs
Normal file
49
test/Api.Test/Tools/Controllers/ReportsControllerTests.cs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
using Bit.Api.Tools.Controllers;
|
||||||
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using NSubstitute;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Api.Test.Tools.Controllers;
|
||||||
|
|
||||||
|
|
||||||
|
[ControllerCustomize(typeof(ReportsController))]
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class ReportsControllerTests
|
||||||
|
{
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task GetPasswordHealthReportApplicationAsync_Success(SutProvider<ReportsController> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().AccessReports(Arg.Any<Guid>()).Returns(true);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var orgId = Guid.NewGuid();
|
||||||
|
var result = await sutProvider.Sut.GetPasswordHealthReportApplications(orgId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
_ = sutProvider.GetDependency<IGetPasswordHealthReportApplicationQuery>()
|
||||||
|
.Received(1)
|
||||||
|
.GetPasswordHealthReportApplicationAsync(Arg.Is<Guid>(_ => _ == orgId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task GetPasswordHealthReportApplicationAsync_withoutAccess(SutProvider<ReportsController> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().AccessReports(Arg.Any<Guid>()).Returns(false);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
var orgId = Guid.NewGuid();
|
||||||
|
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.GetPasswordHealthReportApplications(orgId));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
_ = sutProvider.GetDependency<IGetPasswordHealthReportApplicationQuery>()
|
||||||
|
.Received(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
@ -1,125 +0,0 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
using Bit.Api.Vault.AuthorizationHandlers.Groups;
|
|
||||||
using Bit.Core.Context;
|
|
||||||
using Bit.Core.Enums;
|
|
||||||
using Bit.Core.Models.Data;
|
|
||||||
using Bit.Test.Common.AutoFixture;
|
|
||||||
using Bit.Test.Common.AutoFixture.Attributes;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using NSubstitute;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace Bit.Api.Test.Vault.AuthorizationHandlers;
|
|
||||||
|
|
||||||
[SutProviderCustomize]
|
|
||||||
public class GroupAuthorizationHandlerTests
|
|
||||||
{
|
|
||||||
[Theory]
|
|
||||||
[BitAutoData(OrganizationUserType.Admin)]
|
|
||||||
[BitAutoData(OrganizationUserType.Owner)]
|
|
||||||
[BitAutoData(OrganizationUserType.User)]
|
|
||||||
[BitAutoData(OrganizationUserType.Custom)]
|
|
||||||
public async Task CanReadAllAsync_WhenMemberOfOrg_Success(
|
|
||||||
OrganizationUserType userType,
|
|
||||||
Guid userId, SutProvider<GroupAuthorizationHandler> sutProvider,
|
|
||||||
CurrentContextOrganization organization)
|
|
||||||
{
|
|
||||||
organization.Type = userType;
|
|
||||||
organization.Permissions = new Permissions();
|
|
||||||
|
|
||||||
var context = new AuthorizationHandlerContext(
|
|
||||||
new[] { GroupOperations.ReadAll(organization.Id) },
|
|
||||||
new ClaimsPrincipal(),
|
|
||||||
null);
|
|
||||||
|
|
||||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(userId);
|
|
||||||
sutProvider.GetDependency<ICurrentContext>().GetOrganization(organization.Id).Returns(organization);
|
|
||||||
|
|
||||||
await sutProvider.Sut.HandleAsync(context);
|
|
||||||
|
|
||||||
Assert.True(context.HasSucceeded);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
|
||||||
public async Task CanReadAllAsync_WithProviderUser_Success(
|
|
||||||
Guid userId,
|
|
||||||
SutProvider<GroupAuthorizationHandler> sutProvider, CurrentContextOrganization organization)
|
|
||||||
{
|
|
||||||
organization.Type = OrganizationUserType.User;
|
|
||||||
organization.Permissions = new Permissions();
|
|
||||||
|
|
||||||
var context = new AuthorizationHandlerContext(
|
|
||||||
new[] { GroupOperations.ReadAll(organization.Id) },
|
|
||||||
new ClaimsPrincipal(),
|
|
||||||
null);
|
|
||||||
|
|
||||||
sutProvider.GetDependency<ICurrentContext>()
|
|
||||||
.UserId
|
|
||||||
.Returns(userId);
|
|
||||||
sutProvider.GetDependency<ICurrentContext>()
|
|
||||||
.ProviderUserForOrgAsync(organization.Id)
|
|
||||||
.Returns(true);
|
|
||||||
|
|
||||||
await sutProvider.Sut.HandleAsync(context);
|
|
||||||
|
|
||||||
Assert.True(context.HasSucceeded);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
|
||||||
public async Task CanReadAllAsync_WhenMissingOrgAccess_NoSuccess(
|
|
||||||
Guid userId,
|
|
||||||
CurrentContextOrganization organization,
|
|
||||||
SutProvider<GroupAuthorizationHandler> sutProvider)
|
|
||||||
{
|
|
||||||
|
|
||||||
var context = new AuthorizationHandlerContext(
|
|
||||||
new[] { GroupOperations.ReadAll(organization.Id) },
|
|
||||||
new ClaimsPrincipal(),
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(userId);
|
|
||||||
sutProvider.GetDependency<ICurrentContext>().GetOrganization(Arg.Any<Guid>()).Returns((CurrentContextOrganization)null);
|
|
||||||
sutProvider.GetDependency<ICurrentContext>().ProviderUserForOrgAsync(Arg.Any<Guid>()).Returns(false);
|
|
||||||
|
|
||||||
await sutProvider.Sut.HandleAsync(context);
|
|
||||||
Assert.False(context.HasSucceeded);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
|
||||||
public async Task HandleRequirementAsync_MissingUserId_Failure(
|
|
||||||
Guid organizationId,
|
|
||||||
SutProvider<GroupAuthorizationHandler> sutProvider)
|
|
||||||
{
|
|
||||||
var context = new AuthorizationHandlerContext(
|
|
||||||
new[] { GroupOperations.ReadAll(organizationId) },
|
|
||||||
new ClaimsPrincipal(),
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
// Simulate missing user id
|
|
||||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns((Guid?)null);
|
|
||||||
|
|
||||||
await sutProvider.Sut.HandleAsync(context);
|
|
||||||
Assert.False(context.HasSucceeded);
|
|
||||||
Assert.True(context.HasFailed);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
|
||||||
public async Task HandleRequirementAsync_NoSpecifiedOrgId_Failure(
|
|
||||||
SutProvider<GroupAuthorizationHandler> sutProvider)
|
|
||||||
{
|
|
||||||
var context = new AuthorizationHandlerContext(
|
|
||||||
new[] { GroupOperations.ReadAll(default) },
|
|
||||||
new ClaimsPrincipal(),
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(new Guid());
|
|
||||||
|
|
||||||
await sutProvider.Sut.HandleAsync(context);
|
|
||||||
|
|
||||||
Assert.False(context.HasSucceeded);
|
|
||||||
Assert.True(context.HasFailed);
|
|
||||||
}
|
|
||||||
}
|
|
@ -8,7 +8,6 @@ using Bit.Core.Billing.Enums;
|
|||||||
using Bit.Core.Billing.Repositories;
|
using Bit.Core.Billing.Repositories;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using FluentAssertions;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
@ -89,54 +88,6 @@ public class ProviderEventServiceTests
|
|||||||
await _providerOrganizationRepository.DidNotReceiveWithAnyArgs().GetManyDetailsByProviderAsync(Arg.Any<Guid>());
|
await _providerOrganizationRepository.DidNotReceiveWithAnyArgs().GetManyDetailsByProviderAsync(Arg.Any<Guid>());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task TryRecordInvoiceLineItems_InvoiceCreated_MisconfiguredProviderPlans_ThrowsException()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated);
|
|
||||||
|
|
||||||
const string subscriptionId = "sub_1";
|
|
||||||
var providerId = Guid.NewGuid();
|
|
||||||
|
|
||||||
var invoice = new Invoice
|
|
||||||
{
|
|
||||||
SubscriptionId = subscriptionId
|
|
||||||
};
|
|
||||||
|
|
||||||
_stripeEventService.GetInvoice(stripeEvent).Returns(invoice);
|
|
||||||
|
|
||||||
var subscription = new Subscription
|
|
||||||
{
|
|
||||||
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } }
|
|
||||||
};
|
|
||||||
|
|
||||||
_stripeFacade.GetSubscription(subscriptionId).Returns(subscription);
|
|
||||||
|
|
||||||
var providerPlans = new List<ProviderPlan>
|
|
||||||
{
|
|
||||||
new ()
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
ProviderId = providerId,
|
|
||||||
PlanType = PlanType.TeamsMonthly,
|
|
||||||
AllocatedSeats = 0,
|
|
||||||
PurchasedSeats = 0,
|
|
||||||
SeatMinimum = 100
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
_providerPlanRepository.GetByProviderId(providerId).Returns(providerPlans);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var function = async () => await _providerEventService.TryRecordInvoiceLineItems(stripeEvent);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
await function
|
|
||||||
.Should()
|
|
||||||
.ThrowAsync<Exception>()
|
|
||||||
.WithMessage("Cannot record invoice line items for Provider with missing or misconfigured provider plans");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TryRecordInvoiceLineItems_InvoiceCreated_Succeeds()
|
public async Task TryRecordInvoiceLineItems_InvoiceCreated_Succeeds()
|
||||||
{
|
{
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Test.AdminConsole.AutoFixture;
|
using Bit.Core.Test.AdminConsole.AutoFixture;
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Test.AdminConsole.AutoFixture;
|
using Bit.Core.Test.AdminConsole.AutoFixture;
|
||||||
|
@ -842,6 +842,6 @@ public class PolicyServiceTests
|
|||||||
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
|
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
|
||||||
() => sutProvider.Sut.SaveAsync(policy, null));
|
() => sutProvider.Sut.SaveAsync(policy, null));
|
||||||
|
|
||||||
Assert.Equal("Organization has verified domains.", badRequestException.Message);
|
Assert.Equal("The Single organization policy is required for organizations that have enabled domain verification.", badRequestException.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,6 +24,7 @@ public class LaunchDarklyFeatureServiceTests
|
|||||||
var currentContext = Substitute.For<ICurrentContext>();
|
var currentContext = Substitute.For<ICurrentContext>();
|
||||||
currentContext.UserId.Returns(Guid.NewGuid());
|
currentContext.UserId.Returns(Guid.NewGuid());
|
||||||
currentContext.ClientVersion.Returns(new Version(AssemblyHelpers.GetVersion()));
|
currentContext.ClientVersion.Returns(new Version(AssemblyHelpers.GetVersion()));
|
||||||
|
currentContext.ClientVersionIsPrerelease.Returns(true);
|
||||||
currentContext.DeviceType.Returns(Enums.DeviceType.ChromeBrowser);
|
currentContext.DeviceType.Returns(Enums.DeviceType.ChromeBrowser);
|
||||||
|
|
||||||
var client = Substitute.For<ILdClient>();
|
var client = Substitute.For<ILdClient>();
|
||||||
|
@ -0,0 +1,149 @@
|
|||||||
|
using AutoFixture;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Tools.Entities;
|
||||||
|
using Bit.Core.Tools.ReportFeatures;
|
||||||
|
using Bit.Core.Tools.Repositories;
|
||||||
|
using Bit.Core.Tools.Requests;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using NSubstitute;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.Tools.ReportFeatures;
|
||||||
|
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class AddPasswordHealthReportApplicationCommandTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task AddPasswordHealthReportApplicationAsync_WithValidRequest_ShouldReturnPasswordHealthReportApplication(
|
||||||
|
SutProvider<AddPasswordHealthReportApplicationCommand> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var fixture = new Fixture();
|
||||||
|
var request = fixture.Create<AddPasswordHealthReportApplicationRequest>();
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>()
|
||||||
|
.GetByIdAsync(Arg.Any<Guid>())
|
||||||
|
.Returns(fixture.Create<Organization>());
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IPasswordHealthReportApplicationRepository>()
|
||||||
|
.CreateAsync(Arg.Any<PasswordHealthReportApplication>())
|
||||||
|
.Returns(c => c.Arg<PasswordHealthReportApplication>());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task AddPasswordHealthReportApplicationAsync_WithInvalidOrganizationId_ShouldThrowError(
|
||||||
|
SutProvider<AddPasswordHealthReportApplicationCommand> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var fixture = new Fixture();
|
||||||
|
var request = fixture.Create<AddPasswordHealthReportApplicationRequest>();
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>()
|
||||||
|
.GetByIdAsync(Arg.Any<Guid>())
|
||||||
|
.Returns(null as Organization);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request));
|
||||||
|
Assert.Equal("Invalid Organization", exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task AddPasswordHealthReportApplicationAsync_WithInvalidUrl_ShouldThrowError(
|
||||||
|
SutProvider<AddPasswordHealthReportApplicationCommand> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var fixture = new Fixture();
|
||||||
|
var request = fixture.Build<AddPasswordHealthReportApplicationRequest>()
|
||||||
|
.Without(_ => _.Url)
|
||||||
|
.Create();
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>()
|
||||||
|
.GetByIdAsync(Arg.Any<Guid>())
|
||||||
|
.Returns(fixture.Create<Organization>());
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request));
|
||||||
|
Assert.Equal("URL is required", exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task AddPasswordHealthReportApplicationAsync_Multiples_WithInvalidOrganizationId_ShouldThrowError(
|
||||||
|
SutProvider<AddPasswordHealthReportApplicationCommand> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var fixture = new Fixture();
|
||||||
|
var request = fixture.Build<AddPasswordHealthReportApplicationRequest>()
|
||||||
|
.Without(_ => _.OrganizationId)
|
||||||
|
.CreateMany(2).ToList();
|
||||||
|
|
||||||
|
request[0].OrganizationId = Guid.NewGuid();
|
||||||
|
request[1].OrganizationId = Guid.Empty;
|
||||||
|
|
||||||
|
// only return an organization for the first request and null for the second
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>()
|
||||||
|
.GetByIdAsync(Arg.Is<Guid>(x => x == request[0].OrganizationId))
|
||||||
|
.Returns(fixture.Create<Organization>());
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request));
|
||||||
|
Assert.Equal("Invalid Organization", exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task AddPasswordHealthReportApplicationAsync_Multiples_WithInvalidUrl_ShouldThrowError(
|
||||||
|
SutProvider<AddPasswordHealthReportApplicationCommand> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var fixture = new Fixture();
|
||||||
|
var request = fixture.Build<AddPasswordHealthReportApplicationRequest>()
|
||||||
|
.CreateMany(2).ToList();
|
||||||
|
|
||||||
|
request[1].Url = string.Empty;
|
||||||
|
|
||||||
|
// return an organization for both requests
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>()
|
||||||
|
.GetByIdAsync(Arg.Any<Guid>())
|
||||||
|
.Returns(fixture.Create<Organization>());
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request));
|
||||||
|
Assert.Equal("URL is required", exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task AddPasswordHealthReportApplicationAsync_Multiples_WithValidRequest_ShouldBeSuccessful(
|
||||||
|
SutProvider<AddPasswordHealthReportApplicationCommand> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var fixture = new Fixture();
|
||||||
|
var request = fixture.CreateMany<AddPasswordHealthReportApplicationRequest>(2);
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>()
|
||||||
|
.GetByIdAsync(Arg.Any<Guid>())
|
||||||
|
.Returns(fixture.Create<Organization>());
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IPasswordHealthReportApplicationRepository>()
|
||||||
|
.CreateAsync(Arg.Any<PasswordHealthReportApplication>())
|
||||||
|
.Returns(c => c.Arg<PasswordHealthReportApplication>());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.Count() == 2);
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>().Received(2);
|
||||||
|
sutProvider.GetDependency<IPasswordHealthReportApplicationRepository>().Received(2);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,53 @@
|
|||||||
|
using AutoFixture;
|
||||||
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Tools.Entities;
|
||||||
|
using Bit.Core.Tools.ReportFeatures;
|
||||||
|
using Bit.Core.Tools.Repositories;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using NSubstitute;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.Tools.ReportFeatures;
|
||||||
|
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class GetPasswordHealthReportApplicationQueryTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task GetPasswordHealthReportApplicationAsync_WithValidOrganizationId_ShouldReturnPasswordHealthReportApplication(
|
||||||
|
SutProvider<GetPasswordHealthReportApplicationQuery> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var fixture = new Fixture();
|
||||||
|
var organizationId = fixture.Create<Guid>();
|
||||||
|
sutProvider.GetDependency<IPasswordHealthReportApplicationRepository>()
|
||||||
|
.GetByOrganizationIdAsync(Arg.Any<Guid>())
|
||||||
|
.Returns(fixture.CreateMany<PasswordHealthReportApplication>(2).ToList());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.GetPasswordHealthReportApplicationAsync(organizationId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.True(result.Count() == 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task GetPasswordHealthReportApplicationAsync_WithInvalidOrganizationId_ShouldFail(
|
||||||
|
SutProvider<GetPasswordHealthReportApplicationQuery> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var fixture = new Fixture();
|
||||||
|
sutProvider.GetDependency<IPasswordHealthReportApplicationRepository>()
|
||||||
|
.GetByOrganizationIdAsync(Arg.Is<Guid>(x => x == Guid.Empty))
|
||||||
|
.Returns(new List<PasswordHealthReportApplication>());
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.GetPasswordHealthReportApplicationAsync(Guid.Empty));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("OrganizationId is required.", exception.Message);
|
||||||
|
}
|
||||||
|
}
|
@ -10,7 +10,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
@ -9,6 +9,7 @@ using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider;
|
|||||||
using Bit.Infrastructure.EntityFramework.Auth.Models;
|
using Bit.Infrastructure.EntityFramework.Auth.Models;
|
||||||
using Bit.Infrastructure.EntityFramework.Models;
|
using Bit.Infrastructure.EntityFramework.Models;
|
||||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||||
|
using Bit.Infrastructure.EntityFramework.Tools.Models;
|
||||||
using Bit.Infrastructure.EntityFramework.Vault.Models;
|
using Bit.Infrastructure.EntityFramework.Vault.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@ -89,6 +90,7 @@ public class EfRepositoryListBuilder<T> : ISpecimenBuilder where T : BaseEntityF
|
|||||||
cfg.AddProfile<TaxRateMapperProfile>();
|
cfg.AddProfile<TaxRateMapperProfile>();
|
||||||
cfg.AddProfile<TransactionMapperProfile>();
|
cfg.AddProfile<TransactionMapperProfile>();
|
||||||
cfg.AddProfile<UserMapperProfile>();
|
cfg.AddProfile<UserMapperProfile>();
|
||||||
|
cfg.AddProfile<PasswordHealthReportApplicationProfile>();
|
||||||
})
|
})
|
||||||
.CreateMapper()));
|
.CreateMapper()));
|
||||||
|
|
||||||
|
@ -0,0 +1,82 @@
|
|||||||
|
using AutoFixture;
|
||||||
|
using AutoFixture.Kernel;
|
||||||
|
using Bit.Core.Tools.Entities;
|
||||||
|
using Bit.Infrastructure.EntityFramework.AdminConsole.Repositories;
|
||||||
|
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||||
|
using Bit.Infrastructure.EntityFramework.Tools.Repositories;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
|
||||||
|
namespace Bit.Infrastructure.EFIntegration.Test.AutoFixture;
|
||||||
|
|
||||||
|
internal class PasswordHealthReportApplicationBuilder : ISpecimenBuilder
|
||||||
|
{
|
||||||
|
public object Create(object request, ISpecimenContext context)
|
||||||
|
{
|
||||||
|
if (context == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
var type = request as Type;
|
||||||
|
if (type == null || type != typeof(PasswordHealthReportApplication))
|
||||||
|
{
|
||||||
|
return new NoSpecimen();
|
||||||
|
}
|
||||||
|
|
||||||
|
var fixture = new Fixture();
|
||||||
|
var obj = fixture.WithAutoNSubstitutions().Create<PasswordHealthReportApplication>();
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class EfPasswordHealthReportApplication : ICustomization
|
||||||
|
{
|
||||||
|
public void Customize(IFixture fixture)
|
||||||
|
{
|
||||||
|
fixture.Customizations.Add(new IgnoreVirtualMembersCustomization());
|
||||||
|
fixture.Customizations.Add(new GlobalSettingsBuilder());
|
||||||
|
fixture.Customizations.Add(new PasswordHealthReportApplicationBuilder());
|
||||||
|
fixture.Customizations.Add(new OrganizationBuilder());
|
||||||
|
fixture.Customizations.Add(new EfRepositoryListBuilder<PasswordHealthReportApplicationRepository>());
|
||||||
|
fixture.Customizations.Add(new EfRepositoryListBuilder<OrganizationRepository>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class EfPasswordHealthReportApplicationApplicableToUser : ICustomization
|
||||||
|
{
|
||||||
|
public void Customize(IFixture fixture)
|
||||||
|
{
|
||||||
|
fixture.Customizations.Add(new IgnoreVirtualMembersCustomization());
|
||||||
|
fixture.Customizations.Add(new GlobalSettingsBuilder());
|
||||||
|
fixture.Customizations.Add(new PasswordHealthReportApplicationBuilder());
|
||||||
|
fixture.Customizations.Add(new OrganizationBuilder());
|
||||||
|
fixture.Customizations.Add(new EfRepositoryListBuilder<PasswordHealthReportApplicationRepository>());
|
||||||
|
fixture.Customizations.Add(new EfRepositoryListBuilder<UserRepository>());
|
||||||
|
fixture.Customizations.Add(new EfRepositoryListBuilder<OrganizationRepository>());
|
||||||
|
fixture.Customizations.Add(new EfRepositoryListBuilder<OrganizationUserRepository>());
|
||||||
|
fixture.Customizations.Add(new EfRepositoryListBuilder<ProviderRepository>());
|
||||||
|
fixture.Customizations.Add(new EfRepositoryListBuilder<ProviderUserRepository>());
|
||||||
|
fixture.Customizations.Add(new EfRepositoryListBuilder<ProviderOrganizationRepository>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class EfPasswordHealthReportApplicationAutoDataAttribute : CustomAutoDataAttribute
|
||||||
|
{
|
||||||
|
public EfPasswordHealthReportApplicationAutoDataAttribute() : base(new SutProviderCustomization(), new EfPasswordHealthReportApplication())
|
||||||
|
{ }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class EfPasswordHealthReportApplicationApplicableToUserInlineAutoDataAttribute : InlineCustomAutoDataAttribute
|
||||||
|
{
|
||||||
|
public EfPasswordHealthReportApplicationApplicableToUserInlineAutoDataAttribute(params object[] values) :
|
||||||
|
base(new[] { typeof(SutProviderCustomization), typeof(EfPasswordHealthReportApplicationApplicableToUser) }, values)
|
||||||
|
{ }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class InlineEfPasswordHealthReportApplicationAutoDataAttribute : InlineCustomAutoDataAttribute
|
||||||
|
{
|
||||||
|
public InlineEfPasswordHealthReportApplicationAutoDataAttribute(params object[] values) : base(new[] { typeof(SutProviderCustomization),
|
||||||
|
typeof(EfPolicy) }, values)
|
||||||
|
{ }
|
||||||
|
}
|
@ -0,0 +1,269 @@
|
|||||||
|
using AutoFixture;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Test.AutoFixture.Attributes;
|
||||||
|
using Bit.Core.Tools.Entities;
|
||||||
|
using Bit.Core.Tools.Repositories;
|
||||||
|
using Bit.Infrastructure.EFIntegration.Test.AutoFixture;
|
||||||
|
using Xunit;
|
||||||
|
using EfRepo = Bit.Infrastructure.EntityFramework.Repositories;
|
||||||
|
using EfToolsRepo = Bit.Infrastructure.EntityFramework.Tools.Repositories;
|
||||||
|
using SqlAdminConsoleRepo = Bit.Infrastructure.Dapper.Tools.Repositories;
|
||||||
|
using SqlRepo = Bit.Infrastructure.Dapper.Repositories;
|
||||||
|
|
||||||
|
namespace Bit.Infrastructure.EFIntegration.Test.Tools.Repositories;
|
||||||
|
|
||||||
|
public class PasswordHealthReportApplicationRepositoryTests
|
||||||
|
{
|
||||||
|
[CiSkippedTheory, EfPasswordHealthReportApplicationAutoData]
|
||||||
|
public async Task CreateAsync_Works_DataMatches(
|
||||||
|
PasswordHealthReportApplication passwordHealthReportApplication,
|
||||||
|
Organization organization,
|
||||||
|
List<EfToolsRepo.PasswordHealthReportApplicationRepository> suts,
|
||||||
|
List<EfRepo.OrganizationRepository> efOrganizationRepos,
|
||||||
|
SqlAdminConsoleRepo.PasswordHealthReportApplicationRepository sqlPasswordHealthReportApplicationRepo,
|
||||||
|
SqlRepo.OrganizationRepository sqlOrganizationRepo
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var passwordHealthReportApplicationRecords = new List<PasswordHealthReportApplication>();
|
||||||
|
foreach (var sut in suts)
|
||||||
|
{
|
||||||
|
var i = suts.IndexOf(sut);
|
||||||
|
|
||||||
|
var efOrganization = await efOrganizationRepos[i].CreateAsync(organization);
|
||||||
|
sut.ClearChangeTracking();
|
||||||
|
|
||||||
|
passwordHealthReportApplication.OrganizationId = efOrganization.Id;
|
||||||
|
var postEfPasswordHeathReportApp = await sut.CreateAsync(passwordHealthReportApplication);
|
||||||
|
sut.ClearChangeTracking();
|
||||||
|
|
||||||
|
var savedPasswordHealthReportApplication = await sut.GetByIdAsync(postEfPasswordHeathReportApp.Id);
|
||||||
|
passwordHealthReportApplicationRecords.Add(savedPasswordHealthReportApplication);
|
||||||
|
}
|
||||||
|
|
||||||
|
var sqlOrganization = await sqlOrganizationRepo.CreateAsync(organization);
|
||||||
|
|
||||||
|
passwordHealthReportApplication.OrganizationId = sqlOrganization.Id;
|
||||||
|
var sqlPasswordHealthReportApplicationRecord = await sqlPasswordHealthReportApplicationRepo.CreateAsync(passwordHealthReportApplication);
|
||||||
|
var savedSqlPasswordHealthReportApplicationRecord = await sqlPasswordHealthReportApplicationRepo.GetByIdAsync(sqlPasswordHealthReportApplicationRecord.Id);
|
||||||
|
passwordHealthReportApplicationRecords.Add(savedSqlPasswordHealthReportApplicationRecord);
|
||||||
|
|
||||||
|
Assert.True(passwordHealthReportApplicationRecords.Count == 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[CiSkippedTheory, EfPasswordHealthReportApplicationAutoData]
|
||||||
|
public async Task RetrieveByOrganisation_Works(
|
||||||
|
SqlAdminConsoleRepo.PasswordHealthReportApplicationRepository sqlPasswordHealthReportApplicationRepo,
|
||||||
|
SqlRepo.OrganizationRepository sqlOrganizationRepo)
|
||||||
|
{
|
||||||
|
var (firstOrg, firstRecord) = await CreateSampleRecord(sqlOrganizationRepo, sqlPasswordHealthReportApplicationRepo);
|
||||||
|
var (secondOrg, secondRecord) = await CreateSampleRecord(sqlOrganizationRepo, sqlPasswordHealthReportApplicationRepo);
|
||||||
|
|
||||||
|
var firstSetOfRecords = await sqlPasswordHealthReportApplicationRepo.GetByOrganizationIdAsync(firstOrg.Id);
|
||||||
|
var nextSetOfRecords = await sqlPasswordHealthReportApplicationRepo.GetByOrganizationIdAsync(secondOrg.Id);
|
||||||
|
|
||||||
|
Assert.True(firstSetOfRecords.Count == 1 && firstSetOfRecords.First().OrganizationId == firstOrg.Id);
|
||||||
|
Assert.True(nextSetOfRecords.Count == 1 && nextSetOfRecords.First().OrganizationId == secondOrg.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[CiSkippedTheory, EfPasswordHealthReportApplicationAutoData]
|
||||||
|
public async Task ReplaceQuery_Works(
|
||||||
|
List<EfToolsRepo.PasswordHealthReportApplicationRepository> suts,
|
||||||
|
List<EfRepo.OrganizationRepository> efOrganizationRepos,
|
||||||
|
SqlAdminConsoleRepo.PasswordHealthReportApplicationRepository sqlPasswordHealthReportApplicationRepo,
|
||||||
|
SqlRepo.OrganizationRepository sqlOrganizationRepo)
|
||||||
|
{
|
||||||
|
var (org, pwdRecord) = await CreateSampleRecord(sqlOrganizationRepo, sqlPasswordHealthReportApplicationRepo);
|
||||||
|
var exampleUri = "http://www.example.com";
|
||||||
|
var exampleRevisionDate = new DateTime(2021, 1, 1);
|
||||||
|
var dbRecords = new List<PasswordHealthReportApplication>();
|
||||||
|
|
||||||
|
foreach (var sut in suts)
|
||||||
|
{
|
||||||
|
var i = suts.IndexOf(sut);
|
||||||
|
|
||||||
|
// create a new organization for each repository
|
||||||
|
var organization = await efOrganizationRepos[i].CreateAsync(org);
|
||||||
|
|
||||||
|
// map the organization Id and create the PasswordHealthReportApp record
|
||||||
|
pwdRecord.OrganizationId = organization.Id;
|
||||||
|
var passwordHealthReportApplication = await sut.CreateAsync(pwdRecord);
|
||||||
|
|
||||||
|
// update the record with new values
|
||||||
|
passwordHealthReportApplication.Uri = exampleUri;
|
||||||
|
passwordHealthReportApplication.RevisionDate = exampleRevisionDate;
|
||||||
|
|
||||||
|
// apply update to the database
|
||||||
|
await sut.ReplaceAsync(passwordHealthReportApplication);
|
||||||
|
sut.ClearChangeTracking();
|
||||||
|
|
||||||
|
// retrieve the data and add to the list for assertions
|
||||||
|
var recordFromDb = await sut.GetByIdAsync(passwordHealthReportApplication.Id);
|
||||||
|
sut.ClearChangeTracking();
|
||||||
|
|
||||||
|
dbRecords.Add(recordFromDb);
|
||||||
|
}
|
||||||
|
|
||||||
|
// sql - create a new organization and PasswordHealthReportApplication record
|
||||||
|
var (sqlOrg, sqlPwdRecord) = await CreateSampleRecord(sqlOrganizationRepo, sqlPasswordHealthReportApplicationRepo);
|
||||||
|
var sqlPasswordHealthReportApplicationRecord = await sqlPasswordHealthReportApplicationRepo.GetByIdAsync(sqlPwdRecord.Id);
|
||||||
|
|
||||||
|
// sql - update the record with new values
|
||||||
|
sqlPasswordHealthReportApplicationRecord.Uri = exampleUri;
|
||||||
|
sqlPasswordHealthReportApplicationRecord.RevisionDate = exampleRevisionDate;
|
||||||
|
await sqlPasswordHealthReportApplicationRepo.ReplaceAsync(sqlPasswordHealthReportApplicationRecord);
|
||||||
|
|
||||||
|
// sql - retrieve the data and add to the list for assertions
|
||||||
|
var sqlDbRecord = await sqlPasswordHealthReportApplicationRepo.GetByIdAsync(sqlPasswordHealthReportApplicationRecord.Id);
|
||||||
|
dbRecords.Add(sqlDbRecord);
|
||||||
|
|
||||||
|
// assertions
|
||||||
|
// the Guids must be distinct across all records
|
||||||
|
Assert.True(dbRecords.Select(_ => _.Id).Distinct().Count() == dbRecords.Count);
|
||||||
|
|
||||||
|
// the Uri and RevisionDate must match the updated values
|
||||||
|
Assert.True(dbRecords.All(_ => _.Uri == exampleUri && _.RevisionDate == exampleRevisionDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
[CiSkippedTheory, EfPasswordHealthReportApplicationAutoData]
|
||||||
|
public async Task Upsert_Works(
|
||||||
|
List<EfToolsRepo.PasswordHealthReportApplicationRepository> suts,
|
||||||
|
List<EfRepo.OrganizationRepository> efOrganizationRepos,
|
||||||
|
SqlAdminConsoleRepo.PasswordHealthReportApplicationRepository sqlPasswordHealthReportApplicationRepo,
|
||||||
|
SqlRepo.OrganizationRepository sqlOrganizationRepo)
|
||||||
|
{
|
||||||
|
var fixture = new Fixture();
|
||||||
|
var rawOrg = fixture.Build<Organization>().Create();
|
||||||
|
var rawPwdRecord = fixture.Build<PasswordHealthReportApplication>()
|
||||||
|
.With(_ => _.OrganizationId, rawOrg.Id)
|
||||||
|
.Without(_ => _.Id)
|
||||||
|
.Create();
|
||||||
|
var exampleUri = "http://www.example.com";
|
||||||
|
var exampleRevisionDate = new DateTime(2021, 1, 1);
|
||||||
|
var dbRecords = new List<PasswordHealthReportApplication>();
|
||||||
|
|
||||||
|
foreach (var sut in suts)
|
||||||
|
{
|
||||||
|
var i = suts.IndexOf(sut);
|
||||||
|
|
||||||
|
// create a new organization for each repository
|
||||||
|
var organization = await efOrganizationRepos[i].CreateAsync(rawOrg);
|
||||||
|
|
||||||
|
// map the organization Id and use Upsert to save new record
|
||||||
|
rawPwdRecord.OrganizationId = organization.Id;
|
||||||
|
rawPwdRecord.Id = default(Guid);
|
||||||
|
await sut.UpsertAsync(rawPwdRecord);
|
||||||
|
sut.ClearChangeTracking();
|
||||||
|
|
||||||
|
// retrieve the data and add to the list for assertions
|
||||||
|
var passwordHealthReportApplication = await sut.GetByIdAsync(rawPwdRecord.Id);
|
||||||
|
|
||||||
|
// update the record with new values
|
||||||
|
passwordHealthReportApplication.Uri = exampleUri;
|
||||||
|
passwordHealthReportApplication.RevisionDate = exampleRevisionDate;
|
||||||
|
|
||||||
|
// apply update using Upsert to make changes to db
|
||||||
|
await sut.UpsertAsync(passwordHealthReportApplication);
|
||||||
|
sut.ClearChangeTracking();
|
||||||
|
|
||||||
|
// retrieve the data and add to the list for assertions
|
||||||
|
var recordFromDb = await sut.GetByIdAsync(passwordHealthReportApplication.Id);
|
||||||
|
dbRecords.Add(recordFromDb);
|
||||||
|
|
||||||
|
sut.ClearChangeTracking();
|
||||||
|
}
|
||||||
|
|
||||||
|
// sql - create new records
|
||||||
|
var organizationForSql = fixture.Create<Organization>();
|
||||||
|
var passwordHealthReportApplicationForSql = fixture.Build<PasswordHealthReportApplication>()
|
||||||
|
.With(_ => _.OrganizationId, organizationForSql.Id)
|
||||||
|
.Without(_ => _.Id)
|
||||||
|
.Create();
|
||||||
|
|
||||||
|
// sql - use Upsert to insert this data
|
||||||
|
var sqlOrganization = await sqlOrganizationRepo.CreateAsync(organizationForSql);
|
||||||
|
await sqlPasswordHealthReportApplicationRepo.UpsertAsync(passwordHealthReportApplicationForSql);
|
||||||
|
var sqlPasswordHealthReportApplicationRecord = await sqlPasswordHealthReportApplicationRepo.GetByIdAsync(passwordHealthReportApplicationForSql.Id);
|
||||||
|
|
||||||
|
// sql - update the record with new values
|
||||||
|
sqlPasswordHealthReportApplicationRecord.Uri = exampleUri;
|
||||||
|
sqlPasswordHealthReportApplicationRecord.RevisionDate = exampleRevisionDate;
|
||||||
|
await sqlPasswordHealthReportApplicationRepo.UpsertAsync(sqlPasswordHealthReportApplicationRecord);
|
||||||
|
|
||||||
|
// sql - retrieve the data and add to the list for assertions
|
||||||
|
var sqlDbRecord = await sqlPasswordHealthReportApplicationRepo.GetByIdAsync(sqlPasswordHealthReportApplicationRecord.Id);
|
||||||
|
dbRecords.Add(sqlDbRecord);
|
||||||
|
|
||||||
|
// assertions
|
||||||
|
// the Guids must be distinct across all records
|
||||||
|
Assert.True(dbRecords.Select(_ => _.Id).Distinct().Count() == dbRecords.Count);
|
||||||
|
|
||||||
|
// the Uri and RevisionDate must match the updated values
|
||||||
|
Assert.True(dbRecords.All(_ => _.Uri == exampleUri && _.RevisionDate == exampleRevisionDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
[CiSkippedTheory, EfPasswordHealthReportApplicationAutoData]
|
||||||
|
public async Task Delete_Works(
|
||||||
|
List<EfToolsRepo.PasswordHealthReportApplicationRepository> suts,
|
||||||
|
List<EfRepo.OrganizationRepository> efOrganizationRepos,
|
||||||
|
SqlAdminConsoleRepo.PasswordHealthReportApplicationRepository sqlPasswordHealthReportApplicationRepo,
|
||||||
|
SqlRepo.OrganizationRepository sqlOrganizationRepo)
|
||||||
|
{
|
||||||
|
var fixture = new Fixture();
|
||||||
|
var rawOrg = fixture.Build<Organization>().Create();
|
||||||
|
var rawPwdRecord = fixture.Build<PasswordHealthReportApplication>()
|
||||||
|
.With(_ => _.OrganizationId, rawOrg.Id)
|
||||||
|
.Create();
|
||||||
|
var dbRecords = new List<PasswordHealthReportApplication>();
|
||||||
|
|
||||||
|
foreach (var sut in suts)
|
||||||
|
{
|
||||||
|
var i = suts.IndexOf(sut);
|
||||||
|
|
||||||
|
// create a new organization for each repository
|
||||||
|
var organization = await efOrganizationRepos[i].CreateAsync(rawOrg);
|
||||||
|
|
||||||
|
// map the organization Id and use Upsert to save new record
|
||||||
|
rawPwdRecord.OrganizationId = organization.Id;
|
||||||
|
rawPwdRecord = await sut.CreateAsync(rawPwdRecord);
|
||||||
|
sut.ClearChangeTracking();
|
||||||
|
|
||||||
|
// apply update using Upsert to make changes to db
|
||||||
|
await sut.DeleteAsync(rawPwdRecord);
|
||||||
|
sut.ClearChangeTracking();
|
||||||
|
|
||||||
|
// retrieve the data and add to the list for assertions
|
||||||
|
var recordFromDb = await sut.GetByIdAsync(rawPwdRecord.Id);
|
||||||
|
dbRecords.Add(recordFromDb);
|
||||||
|
|
||||||
|
sut.ClearChangeTracking();
|
||||||
|
}
|
||||||
|
|
||||||
|
// sql - create new records
|
||||||
|
var (org, passwordHealthReportApplication) = await CreateSampleRecord(sqlOrganizationRepo, sqlPasswordHealthReportApplicationRepo);
|
||||||
|
await sqlPasswordHealthReportApplicationRepo.DeleteAsync(passwordHealthReportApplication);
|
||||||
|
var sqlDbRecord = await sqlPasswordHealthReportApplicationRepo.GetByIdAsync(passwordHealthReportApplication.Id);
|
||||||
|
dbRecords.Add(sqlDbRecord);
|
||||||
|
|
||||||
|
// assertions
|
||||||
|
// all records should be null - as they were deleted before querying
|
||||||
|
Assert.True(dbRecords.Where(_ => _ == null).Count() == 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(Organization, PasswordHealthReportApplication)> CreateSampleRecord(
|
||||||
|
IOrganizationRepository organizationRepo,
|
||||||
|
IPasswordHealthReportApplicationRepository passwordHealthReportApplicationRepo
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var fixture = new Fixture();
|
||||||
|
var organization = fixture.Create<Organization>();
|
||||||
|
var passwordHealthReportApplication = fixture.Build<PasswordHealthReportApplication>()
|
||||||
|
.With(_ => _.OrganizationId, organization.Id)
|
||||||
|
.Create();
|
||||||
|
|
||||||
|
organization = await organizationRepo.CreateAsync(organization);
|
||||||
|
passwordHealthReportApplication = await passwordHealthReportApplicationRepo.CreateAsync(passwordHealthReportApplication);
|
||||||
|
|
||||||
|
return (organization, passwordHealthReportApplication);
|
||||||
|
}
|
||||||
|
}
|
2888
util/MySqlMigrations/Migrations/20241105195202_FixPasswordHealthReportApplication.Designer.cs
generated
Normal file
2888
util/MySqlMigrations/Migrations/20241105195202_FixPasswordHealthReportApplication.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Bit.MySqlMigrations.Migrations;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class FixPasswordHealthReportApplication : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// this file is not required, but the designer file is required
|
||||||
|
// in order to keep the database models in sync with the database
|
||||||
|
// without this - the unit tests will fail when run on your local machine
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
@ -1887,6 +1887,34 @@ namespace Bit.MySqlMigrations.Migrations
|
|||||||
b.ToTable("ServiceAccount", (string)null);
|
b.ToTable("ServiceAccount", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreationDate")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("OrganizationId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("RevisionDate")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Uri")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Id")
|
||||||
|
.HasAnnotation("SqlServer:Clustered", true);
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId")
|
||||||
|
.HasAnnotation("SqlServer:Clustered", false);
|
||||||
|
|
||||||
|
b.ToTable("PasswordHealthReportApplication", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b =>
|
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@ -2578,6 +2606,17 @@ namespace Bit.MySqlMigrations.Migrations
|
|||||||
b.Navigation("Organization");
|
b.Navigation("Organization");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("OrganizationId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Organization");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b =>
|
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||||
|
2894
util/PostgresMigrations/Migrations/20241105202053_FixPasswordHealthReportApplication.Designer.cs
generated
Normal file
2894
util/PostgresMigrations/Migrations/20241105202053_FixPasswordHealthReportApplication.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Bit.PostgresMigrations.Migrations;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class FixPasswordHealthReportApplication : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// this file is not required, but the designer file is required
|
||||||
|
// in order to keep the database models in sync with the database
|
||||||
|
// without this - the unit tests will fail when run on your local machine
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
@ -1893,6 +1893,34 @@ namespace Bit.PostgresMigrations.Migrations
|
|||||||
b.ToTable("ServiceAccount", (string)null);
|
b.ToTable("ServiceAccount", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreationDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("OrganizationId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("RevisionDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Uri")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Id")
|
||||||
|
.HasAnnotation("SqlServer:Clustered", true);
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId")
|
||||||
|
.HasAnnotation("SqlServer:Clustered", false);
|
||||||
|
|
||||||
|
b.ToTable("PasswordHealthReportApplication", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b =>
|
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@ -2584,6 +2612,17 @@ namespace Bit.PostgresMigrations.Migrations
|
|||||||
b.Navigation("Organization");
|
b.Navigation("Organization");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("OrganizationId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Organization");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b =>
|
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||||
|
2877
util/SqliteMigrations/Migrations/20241105202413_FixPasswordHealthReportApplication.Designer.cs
generated
Normal file
2877
util/SqliteMigrations/Migrations/20241105202413_FixPasswordHealthReportApplication.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Bit.SqliteMigrations.Migrations;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class FixPasswordHealthReportApplication : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// this file is not required, but the designer file is required
|
||||||
|
// in order to keep the database models in sync with the database
|
||||||
|
// without this - the unit tests will fail when run on your local machine
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
@ -1876,6 +1876,34 @@ namespace Bit.SqliteMigrations.Migrations
|
|||||||
b.ToTable("ServiceAccount", (string)null);
|
b.ToTable("ServiceAccount", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreationDate")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("OrganizationId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("RevisionDate")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Uri")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Id")
|
||||||
|
.HasAnnotation("SqlServer:Clustered", true);
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId")
|
||||||
|
.HasAnnotation("SqlServer:Clustered", false);
|
||||||
|
|
||||||
|
b.ToTable("PasswordHealthReportApplication", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b =>
|
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@ -2567,6 +2595,17 @@ namespace Bit.SqliteMigrations.Migrations
|
|||||||
b.Navigation("Organization");
|
b.Navigation("Organization");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("OrganizationId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Organization");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b =>
|
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||||
|
Loading…
Reference in New Issue
Block a user