mirror of
https://github.com/bitwarden/server.git
synced 2024-11-21 12:05:42 +01:00
Merge branch 'bitwarden:main' into main
This commit is contained in:
commit
2fd6ea38fa
91
.github/workflows/build.yml
vendored
91
.github/workflows/build.yml
vendored
@ -7,18 +7,27 @@ on:
|
||||
- "main"
|
||||
- "rc"
|
||||
- "hotfix-rc"
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
|
||||
env:
|
||||
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
|
||||
|
||||
jobs:
|
||||
check-run:
|
||||
name: Check PR run
|
||||
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- check-run
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||
@ -68,6 +77,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||
@ -115,24 +126,6 @@ jobs:
|
||||
path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip
|
||||
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:
|
||||
name: Build Docker images
|
||||
runs-on: ubuntu-22.04
|
||||
@ -140,8 +133,6 @@ jobs:
|
||||
security-events: write
|
||||
needs:
|
||||
- build-artifacts
|
||||
- check-akv-secrets
|
||||
if: ${{ needs.check-akv-secrets.outputs.available == 'true' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@ -194,6 +185,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Check branch to publish
|
||||
env:
|
||||
@ -233,7 +226,7 @@ jobs:
|
||||
- name: Generate Docker image tag
|
||||
id: tag
|
||||
run: |
|
||||
if [[ $(grep "pull" <<< "${GITHUB_REF}") ]]; then
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then
|
||||
IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g")
|
||||
else
|
||||
IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g")
|
||||
@ -313,6 +306,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||
@ -326,9 +321,9 @@ jobs:
|
||||
run: az acr login -n $_AZ_REGISTRY --only-show-errors
|
||||
|
||||
- name: Make Docker stubs
|
||||
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: |
|
||||
# Set proper setup image based on branch
|
||||
case "$GITHUB_REF" in
|
||||
@ -368,13 +363,17 @@ jobs:
|
||||
cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../..
|
||||
|
||||
- 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: |
|
||||
sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt
|
||||
sha256sum docker-stub-EU.zip > docker-stub-EU-sha256.txt
|
||||
|
||||
- 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
|
||||
with:
|
||||
name: docker-stub-US.zip
|
||||
@ -382,7 +381,9 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- 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
|
||||
with:
|
||||
name: docker-stub-EU.zip
|
||||
@ -390,7 +391,9 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- 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
|
||||
with:
|
||||
name: docker-stub-US-sha256.txt
|
||||
@ -398,7 +401,9 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- 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
|
||||
with:
|
||||
name: docker-stub-EU-sha256.txt
|
||||
@ -473,7 +478,8 @@ jobs:
|
||||
build-mssqlmigratorutility:
|
||||
name: Build MSSQL migrator utility
|
||||
runs-on: ubuntu-22.04
|
||||
needs: lint
|
||||
needs:
|
||||
- lint
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@ -488,6 +494,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||
@ -522,8 +530,10 @@ jobs:
|
||||
|
||||
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
|
||||
needs: build-docker
|
||||
needs:
|
||||
- build-docker
|
||||
steps:
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
@ -554,9 +564,10 @@ jobs:
|
||||
|
||||
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
|
||||
needs: build-docker
|
||||
needs:
|
||||
- build-docker
|
||||
steps:
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
@ -588,9 +599,12 @@ jobs:
|
||||
|
||||
trigger-ee-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
|
||||
needs: build-docker
|
||||
needs:
|
||||
- build-docker
|
||||
steps:
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
@ -634,9 +648,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check if any job failed
|
||||
if: |
|
||||
(github.ref == 'refs/heads/main'
|
||||
|| github.ref == 'refs/heads/rc'
|
||||
|| github.ref == 'refs/heads/hotfix-rc')
|
||||
github.event_name != 'pull_request_target'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
&& contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
|
||||
|
@ -2,17 +2,14 @@
|
||||
using Bit.Commercial.Core.Billing.Models;
|
||||
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;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Services.Contracts;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
@ -27,7 +24,6 @@ using Stripe;
|
||||
namespace Bit.Commercial.Core.Billing;
|
||||
|
||||
public class ProviderBillingService(
|
||||
ICurrentContext currentContext,
|
||||
IGlobalSettings globalSettings,
|
||||
ILogger<ProviderBillingService> logger,
|
||||
IOrganizationRepository organizationRepository,
|
||||
@ -35,38 +31,76 @@ public class ProviderBillingService(
|
||||
IProviderInvoiceItemRepository providerInvoiceItemRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
IProviderRepository providerRepository,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService) : IProviderBillingService
|
||||
{
|
||||
public async Task AssignSeatsToClientOrganization(
|
||||
Provider provider,
|
||||
Organization organization,
|
||||
int seats)
|
||||
public async Task ChangePlan(ChangeProviderPlanCommand command)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(organization);
|
||||
var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
|
||||
|
||||
if (seats < 0)
|
||||
if (plan == null)
|
||||
{
|
||||
throw new BillingException(
|
||||
"You cannot assign negative seats to a client.",
|
||||
"MSP cannot assign negative seats to a client organization");
|
||||
throw new BadRequestException("Provider plan not found.");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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(
|
||||
@ -171,65 +205,16 @@ public class ProviderBillingService(
|
||||
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(
|
||||
Provider provider,
|
||||
PlanType planType,
|
||||
int seatAdjustment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
var providerPlan = await GetProviderPlanAsync(provider, planType);
|
||||
|
||||
if (!provider.SupportsConsolidatedBilling())
|
||||
{
|
||||
logger.LogError("Provider ({ProviderID}) cannot scale their seats", provider.Id);
|
||||
var seatMinimum = providerPlan.SeatMinimum ?? 0;
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
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 currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType);
|
||||
|
||||
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
|
||||
|
||||
@ -256,13 +241,6 @@ public class ProviderBillingService(
|
||||
else if (currentlyAssignedSeatTotal <= 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(
|
||||
seatMinimum,
|
||||
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(
|
||||
Provider provider,
|
||||
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)
|
||||
{
|
||||
if (command.Configuration.Any(x => x.SeatsMinimum < 0))
|
||||
@ -610,4 +539,32 @@ public class ProviderBillingService(
|
||||
|
||||
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.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
@ -230,7 +232,23 @@ public class OrganizationsController : Controller
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
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 &&
|
||||
!StaticStore.GetPlan(organization.PlanType).SupportsSecretsManager)
|
||||
@ -239,7 +257,12 @@ public class OrganizationsController : Controller
|
||||
return RedirectToAction("Edit", new { id });
|
||||
}
|
||||
|
||||
await HandlePotentialProviderSeatScalingAsync(
|
||||
existingOrganizationData,
|
||||
model);
|
||||
|
||||
await _organizationRepository.ReplaceAsync(organization);
|
||||
|
||||
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
||||
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationEditedByAdmin, organization, _currentContext)
|
||||
{
|
||||
@ -394,10 +417,9 @@ public class OrganizationsController : Controller
|
||||
|
||||
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))
|
||||
{
|
||||
organization.Enabled = model.Enabled;
|
||||
@ -449,7 +471,64 @@ public class OrganizationsController : Controller
|
||||
organization.GatewayCustomerId = model.GatewayCustomerId;
|
||||
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_Billing_View,
|
||||
Permission.User_Billing_LaunchGateway,
|
||||
Permission.User_Delete,
|
||||
Permission.Org_List_View,
|
||||
Permission.Org_OrgInformation_View,
|
||||
Permission.Org_GeneralDetails_View,
|
||||
|
@ -2,15 +2,16 @@
|
||||
using Bit.Api.AdminConsole.Models.Response;
|
||||
using Bit.Api.Models.Response;
|
||||
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.Shared.Authorization;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@ -89,11 +90,34 @@ public class GroupsController : Controller
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
public async Task<ListResponseModel<GroupDetailsResponseModel>> Get(Guid orgId)
|
||||
public async Task<ListResponseModel<GroupDetailsResponseModel>> GetOrganizationGroups(Guid orgId)
|
||||
{
|
||||
var authorized =
|
||||
(await _authorizationService.AuthorizeAsync(User, GroupOperations.ReadAll(orgId))).Succeeded;
|
||||
if (!authorized)
|
||||
var authResult = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), GroupOperations.ReadAll);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.Enums;
|
||||
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.");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,11 @@
|
||||
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.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
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.Services;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
@ -16,7 +20,6 @@ using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using AdminConsoleEntities = Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Controllers;
|
||||
|
||||
@ -32,6 +35,8 @@ public class PoliciesController : Controller
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IDataProtector _organizationServiceDataProtector;
|
||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
|
||||
|
||||
public PoliciesController(
|
||||
IPolicyRepository policyRepository,
|
||||
@ -41,7 +46,9 @@ public class PoliciesController : Controller
|
||||
ICurrentContext currentContext,
|
||||
GlobalSettings globalSettings,
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory)
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||
IFeatureService featureService,
|
||||
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery)
|
||||
{
|
||||
_policyRepository = policyRepository;
|
||||
_policyService = policyService;
|
||||
@ -53,10 +60,12 @@ public class PoliciesController : Controller
|
||||
"OrganizationServiceDataProtector");
|
||||
|
||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
||||
_featureService = featureService;
|
||||
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
|
||||
}
|
||||
|
||||
[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))
|
||||
{
|
||||
@ -65,10 +74,15 @@ public class PoliciesController : Controller
|
||||
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type);
|
||||
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("")]
|
||||
@ -81,8 +95,8 @@ public class PoliciesController : Controller
|
||||
}
|
||||
|
||||
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]
|
||||
|
@ -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.Models.Api;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Api.Response;
|
||||
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
|
||||
public class PolicyResponseModel : ResponseModel
|
||||
{
|
@ -41,14 +41,13 @@ public class PoliciesController : Controller
|
||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
||||
public async Task<IActionResult> Get(PolicyType type)
|
||||
{
|
||||
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(
|
||||
_currentContext.OrganizationId.Value, type);
|
||||
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(_currentContext.OrganizationId.Value, type);
|
||||
if (policy == null)
|
||||
{
|
||||
return new NotFoundResult();
|
||||
}
|
||||
var response = new PolicyResponseModel(policy);
|
||||
return new JsonResult(response);
|
||||
|
||||
return new JsonResult(new PolicyResponseModel(policy));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -62,9 +61,8 @@ public class PoliciesController : Controller
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
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(response);
|
||||
|
||||
return new JsonResult(new ListResponseModel<PolicyResponseModel>(policies.Select(p => new PolicyResponseModel(p))));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -18,7 +18,6 @@ public abstract class MemberBaseModel
|
||||
|
||||
Type = user.Type;
|
||||
ExternalId = user.ExternalId;
|
||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
||||
|
||||
if (Type == OrganizationUserType.Custom)
|
||||
{
|
||||
@ -35,7 +34,6 @@ public abstract class MemberBaseModel
|
||||
|
||||
Type = user.Type;
|
||||
ExternalId = user.ExternalId;
|
||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
||||
|
||||
if (Type == OrganizationUserType.Custom)
|
||||
{
|
||||
@ -55,11 +53,7 @@ public abstract class MemberBaseModel
|
||||
/// <example>external_id_123456</example>
|
||||
[StringLength(300)]
|
||||
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>
|
||||
/// The member's custom permissions if the member has a Custom role. If not supplied, all custom permissions will
|
||||
/// default to false.
|
||||
|
@ -28,6 +28,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
|
||||
Email = user.Email;
|
||||
Status = user.Status;
|
||||
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
||||
}
|
||||
|
||||
public MemberResponseModel(OrganizationUserUserDetails user, bool twoFactorEnabled,
|
||||
@ -45,6 +46,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
|
||||
TwoFactorEnabled = twoFactorEnabled;
|
||||
Status = user.Status;
|
||||
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -93,4 +95,10 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
|
||||
/// The associated collections that this member can access.
|
||||
/// </summary>
|
||||
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">
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Api</UserSecretsId>
|
||||
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
|
||||
|
@ -1,10 +1,10 @@
|
||||
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.Response;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.Vault.Models.Response;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Api.Response;
|
||||
using Bit.Core.Auth.Services;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
|
@ -22,9 +22,9 @@ public abstract class BaseBillingController : Controller
|
||||
new ErrorResponseModel(message),
|
||||
statusCode: StatusCodes.Status500InternalServerError);
|
||||
|
||||
public static JsonHttpResult<ErrorResponseModel> Unauthorized() =>
|
||||
public static JsonHttpResult<ErrorResponseModel> Unauthorized(string message = "Unauthorized.") =>
|
||||
TypedResults.Json(
|
||||
new ErrorResponseModel("Unauthorized."),
|
||||
new ErrorResponseModel(message),
|
||||
statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
}
|
||||
|
@ -102,15 +102,27 @@ public class ProviderClientsController(
|
||||
|
||||
var clientOrganization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
||||
|
||||
if (clientOrganization.Seats != requestBody.AssignedSeats)
|
||||
if (clientOrganization is not { Status: OrganizationStatusType.Managed })
|
||||
{
|
||||
await providerBillingService.AssignSeatsToClientOrganization(
|
||||
provider,
|
||||
clientOrganization,
|
||||
requestBody.AssignedSeats);
|
||||
return Error.ServerError();
|
||||
}
|
||||
|
||||
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.Seats = requestBody.AssignedSeats;
|
||||
|
||||
await organizationRepository.ReplaceAsync(clientOrganization);
|
||||
|
||||
|
@ -6,12 +6,14 @@ public record OrganizationMetadataResponse(
|
||||
bool IsEligibleForSelfHost,
|
||||
bool IsManaged,
|
||||
bool IsOnSecretsManagerStandalone,
|
||||
bool IsSubscriptionUnpaid)
|
||||
bool IsSubscriptionUnpaid,
|
||||
bool HasSubscription)
|
||||
{
|
||||
public static OrganizationMetadataResponse From(OrganizationMetadata metadata)
|
||||
=> new(
|
||||
metadata.IsEligibleForSelfHost,
|
||||
metadata.IsManaged,
|
||||
metadata.IsOnSecretsManagerStandalone,
|
||||
metadata.IsSubscriptionUnpaid);
|
||||
metadata.IsSubscriptionUnpaid,
|
||||
metadata.HasSubscription);
|
||||
}
|
||||
|
@ -32,6 +32,8 @@ using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Api.Auth.Models.Request.WebAuthn;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Tools.ReportFeatures;
|
||||
|
||||
|
||||
|
||||
#if !OSS
|
||||
@ -176,6 +178,7 @@ public class Startup
|
||||
services.AddOrganizationSubscriptionServices();
|
||||
services.AddCoreLocalizationServices();
|
||||
services.AddBillingOperations();
|
||||
services.AddReportingServices();
|
||||
|
||||
// Authorization Handlers
|
||||
services.AddAuthorizationHandlers();
|
||||
|
@ -1,13 +1,13 @@
|
||||
using Bit.Api.Tools.Models.Response;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Api.Tools.Models;
|
||||
using Bit.Api.Tools.Models.Response;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Vault.Queries;
|
||||
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||
using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||
using Bit.Core.Tools.ReportFeatures.Requests;
|
||||
using Bit.Core.Tools.Requests;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@ -17,33 +17,55 @@ namespace Bit.Api.Tools.Controllers;
|
||||
[Authorize("Application")]
|
||||
public class ReportsController : Controller
|
||||
{
|
||||
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
|
||||
private readonly IGroupRepository _groupRepository;
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IOrganizationCiphersQuery _organizationCiphersQuery;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
private readonly IMemberAccessCipherDetailsQuery _memberAccessCipherDetailsQuery;
|
||||
private readonly IAddPasswordHealthReportApplicationCommand _addPwdHealthReportAppCommand;
|
||||
private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery;
|
||||
|
||||
public ReportsController(
|
||||
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
|
||||
IGroupRepository groupRepository,
|
||||
ICollectionRepository collectionRepository,
|
||||
ICurrentContext currentContext,
|
||||
IOrganizationCiphersQuery organizationCiphersQuery,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery
|
||||
IMemberAccessCipherDetailsQuery memberAccessCipherDetailsQuery,
|
||||
IAddPasswordHealthReportApplicationCommand addPasswordHealthReportApplicationCommand,
|
||||
IGetPasswordHealthReportApplicationQuery getPasswordHealthReportApplicationQuery
|
||||
)
|
||||
{
|
||||
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
|
||||
_groupRepository = groupRepository;
|
||||
_collectionRepository = collectionRepository;
|
||||
_currentContext = currentContext;
|
||||
_organizationCiphersQuery = organizationCiphersQuery;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||
_memberAccessCipherDetailsQuery = memberAccessCipherDetailsQuery;
|
||||
_addPwdHealthReportAppCommand = addPasswordHealthReportApplicationCommand;
|
||||
_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}")]
|
||||
public async Task<IEnumerable<MemberAccessReportResponseModel>> GetMemberAccessReport(Guid orgId)
|
||||
{
|
||||
@ -52,26 +74,91 @@ public class ReportsController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var orgUsers = await _organizationUserUserDetailsQuery.GetOrganizationUserUserDetails(
|
||||
new OrganizationUserUserDetailsQueryRequest
|
||||
{
|
||||
OrganizationId = orgId,
|
||||
IncludeCollections = true,
|
||||
IncludeGroups = true
|
||||
});
|
||||
var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId });
|
||||
|
||||
var orgGroups = await _groupRepository.GetManyByOrganizationIdAsync(orgId);
|
||||
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 responses = memberCipherDetails.Select(x => new MemberAccessReportResponseModel(x));
|
||||
|
||||
var reports = MemberAccessReportResponseModel.CreateReport(
|
||||
orgGroups,
|
||||
orgCollectionsWithAccess,
|
||||
orgItems,
|
||||
organizationUsersTwoFactorEnabled,
|
||||
orgAbility);
|
||||
return reports;
|
||||
return responses;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contains the organization member info, the cipher ids associated with the member,
|
||||
/// and details on their collections, groups, and permissions
|
||||
/// </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.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;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
|
||||
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>
|
||||
/// Contains the collections and group collections a user has access to including
|
||||
/// the permission level for the collection and group collection.
|
||||
@ -40,134 +17,18 @@ public class MemberAccessReportResponseModel
|
||||
public int TotalItemCount { get; set; }
|
||||
public Guid? UserGuid { get; set; }
|
||||
public bool UsesKeyConnector { get; set; }
|
||||
public IEnumerable<MemberAccessReportAccessDetails> AccessDetails { get; set; }
|
||||
public IEnumerable<MemberAccessDetails> AccessDetails { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
public MemberAccessReportResponseModel(MemberAccessCipherDetails memberAccessCipherDetails)
|
||||
{
|
||||
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,
|
||||
(x, b) => new { CipherId = x.Id, CollectionId = b })
|
||||
.GroupBy(y => y.CollectionId,
|
||||
(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;
|
||||
this.UserName = memberAccessCipherDetails.UserName;
|
||||
this.Email = memberAccessCipherDetails.Email;
|
||||
this.TwoFactorEnabled = memberAccessCipherDetails.TwoFactorEnabled;
|
||||
this.AccountRecoveryEnabled = memberAccessCipherDetails.AccountRecoveryEnabled;
|
||||
this.GroupsCount = memberAccessCipherDetails.GroupsCount;
|
||||
this.CollectionsCount = memberAccessCipherDetails.CollectionsCount;
|
||||
this.TotalItemCount = memberAccessCipherDetails.TotalItemCount;
|
||||
this.UserGuid = memberAccessCipherDetails.UserGuid;
|
||||
this.AccessDetails = memberAccessCipherDetails.AccessDetails;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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.Groups;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
||||
using Bit.Core.IdentityServer;
|
||||
using Bit.Core.Settings;
|
||||
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();
|
||||
}
|
||||
|
||||
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")]
|
||||
@ -600,10 +603,10 @@ public class CiphersController : Controller
|
||||
|
||||
[HttpPut("{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 cipher = await _cipherRepository.GetByIdAsync(new Guid(id));
|
||||
var cipher = await _cipherRepository.GetOrganizationDetailsByIdAsync(new Guid(id));
|
||||
|
||||
if (cipher == null || !cipher.OrganizationId.HasValue ||
|
||||
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
|
||||
@ -621,6 +624,11 @@ public class CiphersController : Controller
|
||||
}
|
||||
|
||||
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")]
|
||||
|
@ -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.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Api.Response;
|
||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Api;
|
||||
|
@ -1,7 +1,6 @@
|
||||
using Bit.Billing.Constants;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
@ -42,96 +41,64 @@ public class ProviderEventService(
|
||||
case HandledStripeWebhook.InvoiceCreated:
|
||||
{
|
||||
var clients =
|
||||
(await providerOrganizationRepository.GetManyDetailsByProviderAsync(parsedProviderId))
|
||||
.Where(providerOrganization => providerOrganization.Status == OrganizationStatusType.Managed);
|
||||
await providerOrganizationRepository.GetManyDetailsByProviderAsync(parsedProviderId);
|
||||
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(parsedProviderId);
|
||||
|
||||
var enterpriseProviderPlan =
|
||||
providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
|
||||
var invoiceItems = new List<ProviderInvoiceItem>();
|
||||
|
||||
var teamsProviderPlan =
|
||||
providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
|
||||
|
||||
if (enterpriseProviderPlan == null || !enterpriseProviderPlan.IsConfigured() ||
|
||||
teamsProviderPlan == null || !teamsProviderPlan.IsConfigured())
|
||||
foreach (var client in clients)
|
||||
{
|
||||
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);
|
||||
|
||||
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
|
||||
foreach (var providerPlan in providerPlans.Where(x => x.PurchasedSeats is null or 0))
|
||||
{
|
||||
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.Plan == enterprisePlan.Name
|
||||
? (client.Seats ?? 0) * discountedEnterpriseSeatPrice
|
||||
: (client.Seats ?? 0) * discountedTeamsSeatPrice
|
||||
}).ToList();
|
||||
var plan = StaticStore.GetPlan(providerPlan.PlanType);
|
||||
|
||||
if (enterpriseProviderPlan.PurchasedSeats is null or 0)
|
||||
{
|
||||
var enterpriseClientSeats = invoiceItems
|
||||
.Where(item => item.PlanName == enterprisePlan.Name)
|
||||
var clientSeats = invoiceItems
|
||||
.Where(item => item.PlanName == plan.Name)
|
||||
.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,
|
||||
InvoiceNumber = invoice.Number,
|
||||
ClientName = "Unassigned seats",
|
||||
PlanName = enterprisePlan.Name,
|
||||
AssignedSeats = unassignedEnterpriseSeats,
|
||||
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
|
||||
});
|
||||
}
|
||||
ProviderId = parsedProviderId,
|
||||
InvoiceId = invoice.Id,
|
||||
InvoiceNumber = invoice.Number,
|
||||
ClientName = "Unassigned seats",
|
||||
PlanName = plan.Name,
|
||||
AssignedSeats = unassignedSeats,
|
||||
UsedSeats = 0,
|
||||
Total = unassignedSeats * discountedSeatPrice
|
||||
});
|
||||
}
|
||||
|
||||
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 IPolicyService _policyService;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly ILogger<VerifyOrganizationDomainCommand> _logger;
|
||||
|
||||
public VerifyOrganizationDomainCommand(
|
||||
@ -30,7 +29,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
|
||||
IGlobalSettings globalSettings,
|
||||
IPolicyService policyService,
|
||||
IFeatureService featureService,
|
||||
IOrganizationService organizationService,
|
||||
ILogger<VerifyOrganizationDomainCommand> logger)
|
||||
{
|
||||
_organizationDomainRepository = organizationDomainRepository;
|
||||
@ -39,7 +37,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
|
||||
_globalSettings = globalSettings;
|
||||
_policyService = policyService;
|
||||
_featureService = featureService;
|
||||
_organizationService = organizationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
#nullable enable
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
@ -1,5 +1,5 @@
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||
using Bit.Core.Context;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||
@ -7,14 +7,10 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authoriza
|
||||
public class OrganizationUserUserMiniDetailsAuthorizationHandler :
|
||||
AuthorizationHandler<OrganizationUserUserMiniDetailsOperationRequirement, OrganizationScope>
|
||||
{
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
|
||||
public OrganizationUserUserMiniDetailsAuthorizationHandler(
|
||||
IApplicationCacheService applicationCacheService,
|
||||
ICurrentContext currentContext)
|
||||
public OrganizationUserUserMiniDetailsAuthorizationHandler(ICurrentContext currentContext)
|
||||
{
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_currentContext = currentContext;
|
||||
}
|
||||
|
||||
|
@ -87,8 +87,7 @@ public class SavePolicyCommand : ISavePolicyCommand
|
||||
if (currentPolicy is not { Enabled: true } && policyUpdate.Enabled)
|
||||
{
|
||||
var missingRequiredPolicyTypes = validator.RequiredPolicies
|
||||
.Where(requiredPolicyType =>
|
||||
savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true })
|
||||
.Where(requiredPolicyType => savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true })
|
||||
.ToList();
|
||||
|
||||
if (missingRequiredPolicyTypes.Count != 0)
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
using Bit.Core.Auth.Enums;
|
||||
@ -23,7 +24,9 @@ public class SingleOrgPolicyValidator : IPolicyValidator
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
||||
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
|
||||
|
||||
public SingleOrgPolicyValidator(
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
@ -31,14 +34,18 @@ public class SingleOrgPolicyValidator : IPolicyValidator
|
||||
IOrganizationRepository organizationRepository,
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
ICurrentContext currentContext,
|
||||
IRemoveOrganizationUserCommand removeOrganizationUserCommand)
|
||||
IFeatureService featureService,
|
||||
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
|
||||
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery)
|
||||
{
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_mailService = mailService;
|
||||
_organizationRepository = organizationRepository;
|
||||
_ssoConfigRepository = ssoConfigRepository;
|
||||
_currentContext = currentContext;
|
||||
_featureService = featureService;
|
||||
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
||||
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
|
||||
}
|
||||
|
||||
public IEnumerable<PolicyType> RequiredPolicies => [];
|
||||
@ -93,9 +100,21 @@ public class SingleOrgPolicyValidator : IPolicyValidator
|
||||
if (policyUpdate is not { Enabled: true })
|
||||
{
|
||||
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
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
&& 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.");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -14,7 +14,10 @@ public static class BillingExtensions
|
||||
provider.SupportsConsolidatedBilling() && provider.Status == ProviderStatusType.Billable;
|
||||
|
||||
public static bool SupportsConsolidatedBilling(this Provider provider)
|
||||
=> provider.Type is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;
|
||||
=> provider.Type.SupportsConsolidatedBilling();
|
||||
|
||||
public static bool SupportsConsolidatedBilling(this ProviderType providerType)
|
||||
=> providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;
|
||||
|
||||
public static bool IsValidClient(this Organization organization)
|
||||
=> organization is
|
||||
|
@ -4,4 +4,5 @@ public record OrganizationMetadata(
|
||||
bool IsEligibleForSelfHost,
|
||||
bool IsManaged,
|
||||
bool IsOnSecretsManagerStandalone,
|
||||
bool IsSubscriptionUnpaid);
|
||||
bool IsSubscriptionUnpaid,
|
||||
bool HasSubscription);
|
||||
|
@ -1,6 +1,5 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Services.Contracts;
|
||||
@ -12,18 +11,10 @@ namespace Bit.Core.Billing.Services;
|
||||
public interface IProviderBillingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Assigns a specified number of <paramref name="seats"/> to a client <paramref name="organization"/> on behalf of
|
||||
/// 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"/>.
|
||||
/// Changes the assigned provider plan for the provider.
|
||||
/// </summary>
|
||||
/// <param name="provider">The <see cref="Provider"/> that manages the client <paramref name="organization"/>.</param>
|
||||
/// <param name="organization">The client <see cref="Organization"/> whose <paramref name="seats"/> you want to update.</param>
|
||||
/// <param name="seats">The number of seats to assign to the client organization.</param>
|
||||
Task AssignSeatsToClientOrganization(
|
||||
Provider provider,
|
||||
Organization organization,
|
||||
int seats);
|
||||
/// <param name="command">The command to change the provider plan.</param>
|
||||
Task ChangePlan(ChangeProviderPlanCommand command);
|
||||
|
||||
/// <summary>
|
||||
/// 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(
|
||||
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>
|
||||
/// 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
|
||||
@ -69,6 +48,22 @@ public interface IProviderBillingService
|
||||
PlanType planType,
|
||||
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>
|
||||
/// 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>
|
||||
@ -90,12 +85,5 @@ public interface IProviderBillingService
|
||||
Task<Subscription> SetupSubscription(
|
||||
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);
|
||||
}
|
||||
|
@ -62,18 +62,25 @@ public class OrganizationBillingService(
|
||||
return null;
|
||||
}
|
||||
|
||||
var isEligibleForSelfHost = IsEligibleForSelfHost(organization);
|
||||
var isManaged = organization.Status == OrganizationStatusType.Managed;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
|
||||
{
|
||||
return new OrganizationMetadata(isEligibleForSelfHost, isManaged, false,
|
||||
false, false);
|
||||
}
|
||||
|
||||
var customer = await subscriberService.GetCustomer(organization,
|
||||
new CustomerGetOptions { Expand = ["discount.coupon.applies_to"] });
|
||||
|
||||
var subscription = await subscriberService.GetSubscription(organization);
|
||||
|
||||
var isEligibleForSelfHost = IsEligibleForSelfHost(organization);
|
||||
var isManaged = organization.Status == OrganizationStatusType.Managed;
|
||||
var isOnSecretsManagerStandalone = IsOnSecretsManagerStandalone(organization, customer, subscription);
|
||||
var isSubscriptionUnpaid = IsSubscriptionUnpaid(subscription);
|
||||
var hasSubscription = true;
|
||||
|
||||
return new OrganizationMetadata(isEligibleForSelfHost, isManaged, isOnSecretsManagerStandalone,
|
||||
isSubscriptionUnpaid);
|
||||
isSubscriptionUnpaid, hasSubscription);
|
||||
}
|
||||
|
||||
public async Task UpdatePaymentMethod(
|
||||
|
@ -111,7 +111,6 @@ public static class FeatureFlagKeys
|
||||
public const string AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section";
|
||||
public const string EmailVerification = "email-verification";
|
||||
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 RestrictProviderAccess = "restrict-provider-access";
|
||||
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 TrialPayment = "PM-8163-trial-payment";
|
||||
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 VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint";
|
||||
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 NewDeviceVerification = "new-device-verification";
|
||||
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()
|
||||
{
|
||||
|
@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Context;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Identity;
|
||||
@ -39,6 +40,7 @@ public class CurrentContext : ICurrentContext
|
||||
public virtual int? BotScore { get; set; }
|
||||
public virtual string ClientId { get; set; }
|
||||
public virtual Version ClientVersion { get; set; }
|
||||
public virtual bool ClientVersionIsPrerelease { get; set; }
|
||||
public virtual IdentityClientType IdentityClientType { get; set; }
|
||||
public virtual Guid? ServiceAccountOrganizationId { get; set; }
|
||||
|
||||
@ -97,6 +99,11 @@ public class CurrentContext : ICurrentContext
|
||||
{
|
||||
ClientVersion = cVersion;
|
||||
}
|
||||
|
||||
if (httpContext.Request.Headers.TryGetValue("Is-Prerelease", out var clientVersionIsPrerelease))
|
||||
{
|
||||
ClientVersionIsPrerelease = clientVersionIsPrerelease == "1";
|
||||
}
|
||||
}
|
||||
|
||||
public async virtual Task BuildAsync(ClaimsPrincipal user, GlobalSettings globalSettings)
|
||||
@ -357,9 +364,9 @@ public class CurrentContext : ICurrentContext
|
||||
|
||||
public async Task<bool> ViewSubscription(Guid orgId)
|
||||
{
|
||||
var orgManagedByMspProvider = (await GetOrganizationProviderDetails()).Any(po => po.OrganizationId == orgId && po.ProviderType == ProviderType.Msp);
|
||||
var isManagedByBillableProvider = (await GetOrganizationProviderDetails()).Any(po => po.OrganizationId == orgId && po.ProviderType.SupportsConsolidatedBilling());
|
||||
|
||||
return orgManagedByMspProvider
|
||||
return isManagedByBillableProvider
|
||||
? await ProviderUserForOrgAsync(orgId)
|
||||
: await OrganizationOwner(orgId);
|
||||
}
|
||||
|
@ -29,12 +29,13 @@ public interface ICurrentContext
|
||||
int? BotScore { get; set; }
|
||||
string ClientId { get; set; }
|
||||
Version ClientVersion { get; set; }
|
||||
bool ClientVersionIsPrerelease { get; set; }
|
||||
|
||||
Task BuildAsync(HttpContext httpContext, GlobalSettings globalSettings);
|
||||
Task BuildAsync(ClaimsPrincipal user, GlobalSettings globalSettings);
|
||||
|
||||
Task SetContextAsync(ClaimsPrincipal user);
|
||||
|
||||
|
||||
Task<bool> OrganizationUser(Guid orgId);
|
||||
Task<bool> OrganizationAdmin(Guid orgId);
|
||||
Task<bool> OrganizationOwner(Guid orgId);
|
||||
|
@ -21,8 +21,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
|
||||
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.401.30" />
|
||||
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.40" />
|
||||
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.401.37" />
|
||||
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.47" />
|
||||
<PackageReference Include="Azure.Data.Tables" Version="12.9.0" />
|
||||
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.10" />
|
||||
|
@ -4,7 +4,6 @@ using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Microsoft.Azure.NotificationHubs;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.NotificationHub;
|
||||
@ -60,21 +59,10 @@ public class NotificationHubPushRegistrationService : IPushRegistrationService
|
||||
switch (type)
|
||||
{
|
||||
case DeviceType.Android:
|
||||
var featureService = _serviceProvider.GetRequiredService<IFeatureService>();
|
||||
if (featureService.IsEnabled(FeatureFlagKeys.AnhFcmv1Migration))
|
||||
{
|
||||
payloadTemplate = "{\"message\":{\"data\":{\"type\":\"$(type)\",\"payload\":\"$(payload)\"}}}";
|
||||
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;
|
||||
}
|
||||
payloadTemplate = "{\"message\":{\"data\":{\"type\":\"$(type)\",\"payload\":\"$(payload)\"}}}";
|
||||
messageTemplate = "{\"message\":{\"data\":{\"type\":\"$(type)\"}," +
|
||||
"\"notification\":{\"title\":\"$(title)\",\"body\":\"$(message)\"}}}";
|
||||
installation.Platform = NotificationPlatform.FcmV1;
|
||||
break;
|
||||
case DeviceType.iOS:
|
||||
payloadTemplate = "{\"data\":{\"type\":\"#(type)\",\"payload\":\"$(payload)\"}," +
|
||||
|
@ -20,6 +20,7 @@ public class LaunchDarklyFeatureService : IFeatureService
|
||||
private const string _contextKindServiceAccount = "service-account";
|
||||
|
||||
private const string _contextAttributeClientVersion = "client-version";
|
||||
private const string _contextAttributeClientVersionIsPrerelease = "client-version-is-prerelease";
|
||||
private const string _contextAttributeDeviceType = "device-type";
|
||||
private const string _contextAttributeClientType = "client-type";
|
||||
private const string _contextAttributeOrganizations = "organizations";
|
||||
@ -145,6 +146,7 @@ public class LaunchDarklyFeatureService : IFeatureService
|
||||
if (_currentContext.ClientVersion != null)
|
||||
{
|
||||
builder.Set(_contextAttributeClientVersion, _currentContext.ClientVersion.ToString());
|
||||
builder.Set(_contextAttributeClientVersionIsPrerelease, _currentContext.ClientVersionIsPrerelease);
|
||||
}
|
||||
|
||||
if (_currentContext.DeviceType.HasValue)
|
||||
|
@ -1,11 +1,15 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Tools.Entities;
|
||||
|
||||
public class PasswordHealthReportApplication : ITableObject<Guid>, IRevisable
|
||||
{
|
||||
public Guid Id { 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 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; }
|
||||
}
|
@ -121,8 +121,8 @@ public class TwoFactorAuthenticationValidator(
|
||||
|
||||
var twoFactorResultDict = new Dictionary<string, object>
|
||||
{
|
||||
{ "TwoFactorProviders", null },
|
||||
{ "TwoFactorProviders2", providers }, // backwards compatibility
|
||||
{ "TwoFactorProviders", providers.Keys }, // backwards compatibility
|
||||
{ "TwoFactorProviders2", providers },
|
||||
};
|
||||
|
||||
// If we have email as a 2FA provider, we might need an SsoEmail2fa Session Token
|
||||
|
@ -58,6 +58,7 @@ public static class DapperServiceCollectionExtensions
|
||||
services.AddSingleton<INotificationStatusRepository, NotificationStatusRepository>();
|
||||
services
|
||||
.AddSingleton<IClientOrganizationMigrationRecordRepository, ClientOrganizationMigrationRecordRepository>();
|
||||
services.AddSingleton<IPasswordHealthReportApplicationRepository, PasswordHealthReportApplicationRepository>();
|
||||
|
||||
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<IClientOrganizationMigrationRecordRepository, ClientOrganizationMigrationRecordRepository>();
|
||||
services.AddSingleton<IPasswordHealthReportApplicationRepository, PasswordHealthReportApplicationRepository>();
|
||||
|
||||
if (selfHosted)
|
||||
{
|
||||
|
@ -7,6 +7,7 @@ using Bit.Infrastructure.EntityFramework.Converters;
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
using Bit.Infrastructure.EntityFramework.NotificationCenter.Models;
|
||||
using Bit.Infrastructure.EntityFramework.SecretsManager.Models;
|
||||
using Bit.Infrastructure.EntityFramework.Tools.Models;
|
||||
using Bit.Infrastructure.EntityFramework.Vault.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
@ -75,6 +76,7 @@ public class DatabaseContext : DbContext
|
||||
public DbSet<Notification> Notifications { get; set; }
|
||||
public DbSet<NotificationStatus> NotificationStatuses { get; set; }
|
||||
public DbSet<ClientOrganizationMigrationRecord> ClientOrganizationMigrationRecords { get; set; }
|
||||
public DbSet<PasswordHealthReportApplication> PasswordHealthReportApplications { get; set; }
|
||||
|
||||
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.Settings;
|
||||
using Bit.Core.Tokens;
|
||||
using Bit.Core.Tools.ReportFeatures;
|
||||
using Bit.Core.Tools.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Vault;
|
||||
@ -116,6 +117,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddLoginServices();
|
||||
services.AddScoped<IOrganizationDomainService, OrganizationDomainService>();
|
||||
services.AddVaultServices();
|
||||
services.AddReportingServices();
|
||||
}
|
||||
|
||||
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 IDataProtectorTokenFactory<OrgDeleteTokenable> _orgDeleteTokenDataFactory;
|
||||
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
||||
|
||||
private readonly OrganizationsController _sut;
|
||||
|
||||
public OrganizationsControllerTests()
|
||||
@ -123,7 +122,8 @@ public class OrganizationsControllerTests : IDisposable
|
||||
_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> { null });
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.Leave(orgId));
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[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]
|
||||
[InlineAutoData(true, false)]
|
||||
[InlineAutoData(false, true)]
|
||||
@ -157,6 +187,8 @@ public class OrganizationsControllerTests : IDisposable
|
||||
_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>());
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
@ -52,7 +52,7 @@ public class OrganizationBillingControllerTests
|
||||
{
|
||||
sutProvider.GetDependency<ICurrentContext>().OrganizationUser(organizationId).Returns(true);
|
||||
sutProvider.GetDependency<IOrganizationBillingService>().GetMetadata(organizationId)
|
||||
.Returns(new OrganizationMetadata(true, true, true, true));
|
||||
.Returns(new OrganizationMetadata(true, true, true, true, true));
|
||||
|
||||
var result = await sutProvider.Sut.GetMetadataAsync(organizationId);
|
||||
|
||||
@ -64,6 +64,7 @@ public class OrganizationBillingControllerTests
|
||||
Assert.True(response.IsManaged);
|
||||
Assert.True(response.IsOnSecretsManagerStandalone);
|
||||
Assert.True(response.IsSubscriptionUnpaid);
|
||||
Assert.True(response.HasSubscription);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
|
@ -5,8 +5,11 @@ using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@ -93,24 +96,7 @@ public class ProviderClientsControllerTests
|
||||
#region UpdateAsync
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAsync_NoProviderOrganization_NotFound(
|
||||
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(
|
||||
public async Task UpdateAsync_ServiceUserMakingPurchase_Unauthorized(
|
||||
Provider provider,
|
||||
Guid providerOrganizationId,
|
||||
UpdateClientOrganizationRequestBody requestBody,
|
||||
@ -118,6 +104,11 @@ public class ProviderClientsControllerTests
|
||||
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)
|
||||
@ -126,49 +117,57 @@ public class ProviderClientsControllerTests
|
||||
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(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);
|
||||
|
||||
await sutProvider.GetDependency<IProviderBillingService>().Received(1)
|
||||
.AssignSeatsToClientOrganization(
|
||||
.ScaleSeats(
|
||||
provider,
|
||||
organization,
|
||||
requestBody.AssignedSeats);
|
||||
PlanType.TeamsMonthly,
|
||||
10);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationRepository>().Received(1)
|
||||
.ReplaceAsync(Arg.Is<Organization>(org => 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));
|
||||
.ReplaceAsync(Arg.Is<Organization>(org => org.Seats == requestBody.AssignedSeats && org.Name == requestBody.Name));
|
||||
|
||||
Assert.IsType<Ok>(result);
|
||||
}
|
||||
|
@ -25,14 +25,14 @@ public static class Utilities
|
||||
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);
|
||||
|
||||
var response = (JsonHttpResult<ErrorResponseModel>)result;
|
||||
|
||||
Assert.Equal(StatusCodes.Status401Unauthorized, response.StatusCode);
|
||||
Assert.Equal("Unauthorized.", response.Value.Message);
|
||||
Assert.Equal(message, response.Value.Message);
|
||||
}
|
||||
|
||||
public static void ConfigureStableProviderAdminInputs<T>(
|
||||
|
@ -1,9 +1,9 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Bit.Api.AdminConsole.Controllers;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Api.Response;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Context;
|
||||
@ -157,7 +157,7 @@ public class PoliciesControllerTests
|
||||
var result = await sutProvider.Sut.Get(orgId, type);
|
||||
|
||||
// Assert
|
||||
Assert.IsType<PolicyResponseModel>(result);
|
||||
Assert.IsType<PolicyDetailResponseModel>(result);
|
||||
Assert.Equal(policy.Id, result.Id);
|
||||
Assert.Equal(policy.Type, result.Type);
|
||||
Assert.Equal(policy.Enabled, result.Enabled);
|
||||
@ -182,7 +182,7 @@ public class PoliciesControllerTests
|
||||
var result = await sutProvider.Sut.Get(orgId, type);
|
||||
|
||||
// Assert
|
||||
Assert.IsType<PolicyResponseModel>(result);
|
||||
Assert.IsType<PolicyDetailResponseModel>(result);
|
||||
Assert.Equal(result.Type, (PolicyType)type);
|
||||
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.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Stripe;
|
||||
@ -89,54 +88,6 @@ public class ProviderEventServiceTests
|
||||
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]
|
||||
public async Task TryRecordInvoiceLineItems_InvoiceCreated_Succeeds()
|
||||
{
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System.Security.Claims;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Test.AdminConsole.AutoFixture;
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System.Security.Claims;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Test.AdminConsole.AutoFixture;
|
||||
|
@ -842,6 +842,6 @@ public class PolicyServiceTests
|
||||
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => 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>();
|
||||
currentContext.UserId.Returns(Guid.NewGuid());
|
||||
currentContext.ClientVersion.Returns(new Version(AssemblyHelpers.GetVersion()));
|
||||
currentContext.ClientVersionIsPrerelease.Returns(true);
|
||||
currentContext.DeviceType.Returns(Enums.DeviceType.ChromeBrowser);
|
||||
|
||||
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>
|
||||
|
||||
<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.runner.visualstudio" Version="2.8.2">
|
||||
<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.Models;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Bit.Infrastructure.EntityFramework.Tools.Models;
|
||||
using Bit.Infrastructure.EntityFramework.Vault.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@ -89,6 +90,7 @@ public class EfRepositoryListBuilder<T> : ISpecimenBuilder where T : BaseEntityF
|
||||
cfg.AddProfile<TaxRateMapperProfile>();
|
||||
cfg.AddProfile<TransactionMapperProfile>();
|
||||
cfg.AddProfile<UserMapperProfile>();
|
||||
cfg.AddProfile<PasswordHealthReportApplicationProfile>();
|
||||
})
|
||||
.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);
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@ -2578,6 +2606,17 @@ namespace Bit.MySqlMigrations.Migrations
|
||||
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 =>
|
||||
{
|
||||
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);
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@ -2584,6 +2612,17 @@ namespace Bit.PostgresMigrations.Migrations
|
||||
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 =>
|
||||
{
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user