mirror of
https://github.com/bitwarden/server.git
synced 2024-11-25 12:45:18 +01:00
Merge branch 'main' into ac/pm-13013-Add-DeleteManyAsync-Method-to-IUserRepository-and-IUserService-for-Bulk-User-Deletion
This commit is contained in:
commit
1c000ffe86
35
.github/workflows/repository-management.yml
vendored
35
.github/workflows/repository-management.yml
vendored
@ -28,7 +28,6 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
branch: ${{ steps.set-branch.outputs.branch }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
steps:
|
||||
- name: Set branch
|
||||
id: set-branch
|
||||
@ -45,13 +44,6 @@ jobs:
|
||||
|
||||
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.BW_GHAPP_ID }}
|
||||
private-key: ${{ secrets.BW_GHAPP_KEY }}
|
||||
|
||||
|
||||
cut_branch:
|
||||
name: Cut branch
|
||||
@ -59,11 +51,18 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.BW_GHAPP_ID }}
|
||||
private-key: ${{ secrets.BW_GHAPP_KEY }}
|
||||
|
||||
- name: Check out target ref
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ inputs.target_ref }}
|
||||
token: ${{ needs.setup.outputs.token }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Check if ${{ needs.setup.outputs.branch }} branch exists
|
||||
env:
|
||||
@ -98,11 +97,18 @@ jobs:
|
||||
with:
|
||||
version: ${{ inputs.version_number_override }}
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.BW_GHAPP_ID }}
|
||||
private-key: ${{ secrets.BW_GHAPP_KEY }}
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ needs.setup.outputs.token }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
@ -190,11 +196,18 @@ jobs:
|
||||
- bump_version
|
||||
- setup
|
||||
steps:
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.BW_GHAPP_ID }}
|
||||
private-key: ${{ secrets.BW_GHAPP_KEY }}
|
||||
|
||||
- name: Check out main branch
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ needs.setup.outputs.token }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
|
@ -1,5 +1,4 @@
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
@ -10,7 +9,6 @@ using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Commercial.Core.AdminConsole.Providers;
|
||||
|
||||
@ -21,35 +19,28 @@ public class CreateProviderCommand : ICreateProviderCommand
|
||||
private readonly IProviderService _providerService;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IProviderPlanRepository _providerPlanRepository;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public CreateProviderCommand(
|
||||
IProviderRepository providerRepository,
|
||||
IProviderUserRepository providerUserRepository,
|
||||
IProviderService providerService,
|
||||
IUserRepository userRepository,
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
IFeatureService featureService)
|
||||
IProviderPlanRepository providerPlanRepository)
|
||||
{
|
||||
_providerRepository = providerRepository;
|
||||
_providerUserRepository = providerUserRepository;
|
||||
_providerService = providerService;
|
||||
_userRepository = userRepository;
|
||||
_providerPlanRepository = providerPlanRepository;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
public async Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats)
|
||||
{
|
||||
var providerId = await CreateProviderAsync(provider, ownerEmail);
|
||||
|
||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (isConsolidatedBillingEnabled)
|
||||
{
|
||||
await CreateProviderPlanAsync(providerId, PlanType.TeamsMonthly, teamsMinimumSeats);
|
||||
await CreateProviderPlanAsync(providerId, PlanType.EnterpriseMonthly, enterpriseMinimumSeats);
|
||||
}
|
||||
await Task.WhenAll(
|
||||
CreateProviderPlanAsync(providerId, PlanType.TeamsMonthly, teamsMinimumSeats),
|
||||
CreateProviderPlanAsync(providerId, PlanType.EnterpriseMonthly, enterpriseMinimumSeats));
|
||||
}
|
||||
|
||||
public async Task CreateResellerAsync(Provider provider)
|
||||
@ -61,12 +52,7 @@ public class CreateProviderCommand : ICreateProviderCommand
|
||||
{
|
||||
var providerId = await CreateProviderAsync(provider, ownerEmail);
|
||||
|
||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (isConsolidatedBillingEnabled)
|
||||
{
|
||||
await CreateProviderPlanAsync(providerId, plan, minimumSeats);
|
||||
}
|
||||
await CreateProviderPlanAsync(providerId, plan, minimumSeats);
|
||||
}
|
||||
|
||||
private async Task<Guid> CreateProviderAsync(Provider provider, string ownerEmail)
|
||||
@ -77,12 +63,7 @@ public class CreateProviderCommand : ICreateProviderCommand
|
||||
throw new BadRequestException("Invalid owner. Owner must be an existing Bitwarden user.");
|
||||
}
|
||||
|
||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (isConsolidatedBillingEnabled)
|
||||
{
|
||||
provider.Gateway = GatewayType.Stripe;
|
||||
}
|
||||
provider.Gateway = GatewayType.Stripe;
|
||||
|
||||
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Pending);
|
||||
|
||||
|
@ -1,7 +1,5 @@
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
@ -102,11 +100,8 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
Provider provider,
|
||||
IEnumerable<string> organizationOwnerEmails)
|
||||
{
|
||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (isConsolidatedBillingEnabled &&
|
||||
provider.Status == ProviderStatusType.Billable &&
|
||||
organization.Status == OrganizationStatusType.Managed &&
|
||||
if (provider.IsBillable() &&
|
||||
organization.IsValidClient() &&
|
||||
!string.IsNullOrEmpty(organization.GatewayCustomerId))
|
||||
{
|
||||
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
|
||||
|
@ -8,7 +8,6 @@ using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
@ -101,24 +100,16 @@ public class ProviderService : IProviderService
|
||||
throw new BadRequestException("Invalid owner.");
|
||||
}
|
||||
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
||||
if (taxInfo == null || string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode))
|
||||
{
|
||||
provider.Status = ProviderStatusType.Created;
|
||||
await _providerRepository.UpsertAsync(provider);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (taxInfo == null || string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode))
|
||||
{
|
||||
throw new BadRequestException("Both address and postal code are required to set up your provider.");
|
||||
}
|
||||
var customer = await _providerBillingService.SetupCustomer(provider, taxInfo);
|
||||
provider.GatewayCustomerId = customer.Id;
|
||||
var subscription = await _providerBillingService.SetupSubscription(provider);
|
||||
provider.GatewaySubscriptionId = subscription.Id;
|
||||
provider.Status = ProviderStatusType.Billable;
|
||||
await _providerRepository.UpsertAsync(provider);
|
||||
throw new BadRequestException("Both address and postal code are required to set up your provider.");
|
||||
}
|
||||
var customer = await _providerBillingService.SetupCustomer(provider, taxInfo);
|
||||
provider.GatewayCustomerId = customer.Id;
|
||||
var subscription = await _providerBillingService.SetupSubscription(provider);
|
||||
provider.GatewaySubscriptionId = subscription.Id;
|
||||
provider.Status = ProviderStatusType.Billable;
|
||||
await _providerRepository.UpsertAsync(provider);
|
||||
|
||||
providerUser.Key = key;
|
||||
await _providerUserRepository.ReplaceAsync(providerUser);
|
||||
@ -545,13 +536,9 @@ public class ProviderService : IProviderService
|
||||
{
|
||||
var provider = await _providerRepository.GetByIdAsync(providerId);
|
||||
|
||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && provider.IsBillable();
|
||||
ThrowOnInvalidPlanType(provider.Type, organizationSignup.Plan);
|
||||
|
||||
ThrowOnInvalidPlanType(provider.Type, organizationSignup.Plan, consolidatedBillingEnabled);
|
||||
|
||||
var (organization, _, defaultCollection) = consolidatedBillingEnabled
|
||||
? await _organizationService.SignupClientAsync(organizationSignup)
|
||||
: await _organizationService.SignUpAsync(organizationSignup);
|
||||
var (organization, _, defaultCollection) = await _organizationService.SignupClientAsync(organizationSignup);
|
||||
|
||||
var providerOrganization = new ProviderOrganization
|
||||
{
|
||||
@ -687,27 +674,24 @@ public class ProviderService : IProviderService
|
||||
return confirmedOwnersIds.Except(providerUserIds).Any();
|
||||
}
|
||||
|
||||
private void ThrowOnInvalidPlanType(ProviderType providerType, PlanType requestedType, bool consolidatedBillingEnabled = false)
|
||||
private void ThrowOnInvalidPlanType(ProviderType providerType, PlanType requestedType)
|
||||
{
|
||||
if (consolidatedBillingEnabled)
|
||||
switch (providerType)
|
||||
{
|
||||
switch (providerType)
|
||||
{
|
||||
case ProviderType.Msp:
|
||||
if (requestedType is not (PlanType.TeamsMonthly or PlanType.EnterpriseMonthly))
|
||||
{
|
||||
throw new BadRequestException($"Managed Service Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed.");
|
||||
}
|
||||
break;
|
||||
case ProviderType.MultiOrganizationEnterprise:
|
||||
if (requestedType is not (PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually))
|
||||
{
|
||||
throw new BadRequestException($"Multi-organization Enterprise Providers cannot manage organizations with the plan type {requestedType}. Only Enterprise (Monthly) and Enterprise (Annually) are allowed.");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new BadRequestException($"Unsupported provider type {providerType}.");
|
||||
}
|
||||
case ProviderType.Msp:
|
||||
if (requestedType is not (PlanType.TeamsMonthly or PlanType.EnterpriseMonthly))
|
||||
{
|
||||
throw new BadRequestException($"Managed Service Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed.");
|
||||
}
|
||||
break;
|
||||
case ProviderType.MultiOrganizationEnterprise:
|
||||
if (requestedType is not (PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually))
|
||||
{
|
||||
throw new BadRequestException($"Multi-organization Enterprise Providers cannot manage organizations with the plan type {requestedType}. Only Enterprise (Monthly) and Enterprise (Annually) are allowed.");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new BadRequestException($"Unsupported provider type {providerType}.");
|
||||
}
|
||||
|
||||
if (ProviderDisallowedOrganizationTypes.Contains(requestedType))
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
using Bit.Commercial.Core.AdminConsole.Providers;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
@ -155,9 +154,6 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
"b@example.com"
|
||||
]);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||
.Returns(GetSubscription(organization.GatewaySubscriptionId));
|
||||
|
||||
@ -222,9 +218,6 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
"b@example.com"
|
||||
]);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
||||
.Returns(true);
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
|
||||
|
@ -1,6 +1,5 @@
|
||||
using Bit.Commercial.Core.AdminConsole.Services;
|
||||
using Bit.Commercial.Core.Test.AdminConsole.AutoFixture;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
@ -55,36 +54,8 @@ public class ProviderServiceTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key,
|
||||
[ProviderUser(ProviderUserStatusType.Confirmed, ProviderUserType.ProviderAdmin)] ProviderUser providerUser,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
providerUser.ProviderId = provider.Id;
|
||||
providerUser.UserId = user.Id;
|
||||
var userService = sutProvider.GetDependency<IUserService>();
|
||||
userService.GetUserByIdAsync(user.Id).Returns(user);
|
||||
|
||||
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
|
||||
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
|
||||
|
||||
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
|
||||
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
|
||||
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
|
||||
.Returns(protector);
|
||||
sutProvider.Create();
|
||||
|
||||
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||
|
||||
await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key);
|
||||
|
||||
await sutProvider.GetDependency<IProviderRepository>().Received().UpsertAsync(provider);
|
||||
await sutProvider.GetDependency<IProviderUserRepository>().Received()
|
||||
.ReplaceAsync(Arg.Is<ProviderUser>(pu => pu.UserId == user.Id && pu.ProviderId == provider.Id && pu.Key == key));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CompleteSetupAsync_ConsolidatedBilling_Success(User user, Provider provider, string key, TaxInfo taxInfo,
|
||||
[ProviderUser(ProviderUserStatusType.Confirmed, ProviderUserType.ProviderAdmin)] ProviderUser providerUser,
|
||||
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo,
|
||||
[ProviderUser] ProviderUser providerUser,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
providerUser.ProviderId = provider.Id;
|
||||
@ -100,9 +71,6 @@ public class ProviderServiceTests
|
||||
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
|
||||
.Returns(protector);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
||||
.Returns(true);
|
||||
|
||||
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
|
||||
|
||||
var customer = new Customer { Id = "customer_id" };
|
||||
@ -489,7 +457,7 @@ public class ProviderServiceTests
|
||||
public async Task AddOrganization_OrganizationHasSecretsManager_Throws(Provider provider, Organization organization, string key,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
organization.PlanType = PlanType.EnterpriseMonthly;
|
||||
organization.UseSecretsManager = true;
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||
@ -506,7 +474,7 @@ public class ProviderServiceTests
|
||||
public async Task AddOrganization_Success(Provider provider, Organization organization, string key,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
organization.PlanType = PlanType.EnterpriseMonthly;
|
||||
|
||||
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
|
||||
providerRepository.GetByIdAsync(provider.Id).Returns(provider);
|
||||
@ -549,8 +517,8 @@ public class ProviderServiceTests
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||
|
||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||
var expectedPlanType = PlanType.EnterpriseAnnually;
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
var expectedPlanType = PlanType.EnterpriseMonthly;
|
||||
organization.PlanType = PlanType.EnterpriseMonthly;
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||
|
||||
await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key);
|
||||
@ -579,12 +547,12 @@ public class ProviderServiceTests
|
||||
BackdateProviderCreationDate(provider, newCreationDate);
|
||||
provider.Type = ProviderType.Msp;
|
||||
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
organization.Plan = "Enterprise (Annually)";
|
||||
organization.PlanType = PlanType.EnterpriseMonthly;
|
||||
organization.Plan = "Enterprise (Monthly)";
|
||||
|
||||
var expectedPlanType = PlanType.EnterpriseAnnually2020;
|
||||
var expectedPlanType = PlanType.EnterpriseMonthly2020;
|
||||
|
||||
var expectedPlanId = "2020-enterprise-org-seat-annually";
|
||||
var expectedPlanId = "2020-enterprise-org-seat-monthly";
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||
@ -663,11 +631,11 @@ public class ProviderServiceTests
|
||||
public async Task CreateOrganizationAsync_Success(Provider provider, OrganizationSignup organizationSignup,
|
||||
Organization organization, string clientOwnerEmail, User user, SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
organizationSignup.Plan = PlanType.EnterpriseAnnually;
|
||||
organizationSignup.Plan = PlanType.EnterpriseMonthly;
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||
sutProvider.GetDependency<IOrganizationService>().SignUpAsync(organizationSignup)
|
||||
sutProvider.GetDependency<IOrganizationService>().SignupClientAsync(organizationSignup)
|
||||
.Returns((organization, null as OrganizationUser, new Collection()));
|
||||
|
||||
var providerOrganization =
|
||||
@ -688,7 +656,7 @@ public class ProviderServiceTests
|
||||
}
|
||||
|
||||
[Theory, OrganizationCustomize, BitAutoData]
|
||||
public async Task CreateOrganizationAsync_ConsolidatedBillingEnabled_InvalidPlanType_ThrowsBadRequestException(
|
||||
public async Task CreateOrganizationAsync_InvalidPlanType_ThrowsBadRequestException(
|
||||
Provider provider,
|
||||
OrganizationSignup organizationSignup,
|
||||
Organization organization,
|
||||
@ -696,8 +664,6 @@ public class ProviderServiceTests
|
||||
User user,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
||||
|
||||
provider.Type = ProviderType.Msp;
|
||||
provider.Status = ProviderStatusType.Billable;
|
||||
|
||||
@ -717,7 +683,7 @@ public class ProviderServiceTests
|
||||
}
|
||||
|
||||
[Theory, OrganizationCustomize, BitAutoData]
|
||||
public async Task CreateOrganizationAsync_ConsolidatedBillingEnabled_InvokeSignupClientAsync(
|
||||
public async Task CreateOrganizationAsync_InvokeSignupClientAsync(
|
||||
Provider provider,
|
||||
OrganizationSignup organizationSignup,
|
||||
Organization organization,
|
||||
@ -725,8 +691,6 @@ public class ProviderServiceTests
|
||||
User user,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
||||
|
||||
provider.Type = ProviderType.Msp;
|
||||
provider.Status = ProviderStatusType.Billable;
|
||||
|
||||
@ -771,11 +735,11 @@ public class ProviderServiceTests
|
||||
(Provider provider, OrganizationSignup organizationSignup, Organization organization, string clientOwnerEmail,
|
||||
User user, SutProvider<ProviderService> sutProvider, Collection defaultCollection)
|
||||
{
|
||||
organizationSignup.Plan = PlanType.EnterpriseAnnually;
|
||||
organizationSignup.Plan = PlanType.EnterpriseMonthly;
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||
sutProvider.GetDependency<IOrganizationService>().SignUpAsync(organizationSignup)
|
||||
sutProvider.GetDependency<IOrganizationService>().SignupClientAsync(organizationSignup)
|
||||
.Returns((organization, null as OrganizationUser, defaultCollection));
|
||||
|
||||
var providerOrganization =
|
||||
|
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;
|
||||
@ -53,8 +55,8 @@ public class OrganizationsController : Controller
|
||||
private readonly IServiceAccountRepository _serviceAccountRepository;
|
||||
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
|
||||
private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IProviderBillingService _providerBillingService;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public OrganizationsController(
|
||||
IOrganizationService organizationService,
|
||||
@ -80,8 +82,8 @@ public class OrganizationsController : Controller
|
||||
IServiceAccountRepository serviceAccountRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
|
||||
IFeatureService featureService,
|
||||
IProviderBillingService providerBillingService)
|
||||
IProviderBillingService providerBillingService,
|
||||
IFeatureService featureService)
|
||||
{
|
||||
_organizationService = organizationService;
|
||||
_organizationRepository = organizationRepository;
|
||||
@ -106,8 +108,8 @@ public class OrganizationsController : Controller
|
||||
_serviceAccountRepository = serviceAccountRepository;
|
||||
_providerOrganizationRepository = providerOrganizationRepository;
|
||||
_removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand;
|
||||
_featureService = featureService;
|
||||
_providerBillingService = providerBillingService;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
[RequirePermission(Permission.Org_List_View)]
|
||||
@ -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)
|
||||
{
|
||||
@ -262,9 +285,7 @@ public class OrganizationsController : Controller
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (consolidatedBillingEnabled && organization.IsValidClient())
|
||||
if (organization.IsValidClient())
|
||||
{
|
||||
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||
|
||||
@ -394,10 +415,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 +469,62 @@ public class OrganizationsController : Controller
|
||||
organization.GatewayCustomerId = model.GatewayCustomerId;
|
||||
organization.GatewaySubscriptionId = model.GatewaySubscriptionId;
|
||||
}
|
||||
}
|
||||
|
||||
return organization;
|
||||
private async Task HandlePotentialProviderSeatScalingAsync(
|
||||
Organization organization,
|
||||
OrganizationEditModel update)
|
||||
{
|
||||
var scaleMSPOnClientOrganizationUpdate =
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate);
|
||||
|
||||
if (!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;
|
||||
}
|
||||
}
|
||||
|
@ -282,9 +282,7 @@ public class ProvidersController : Controller
|
||||
await _providerRepository.ReplaceAsync(provider);
|
||||
await _applicationCacheService.UpsertProviderAbilityAsync(provider);
|
||||
|
||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (!isConsolidatedBillingEnabled || !provider.IsBillable())
|
||||
if (!provider.IsBillable())
|
||||
{
|
||||
return RedirectToAction("Edit", new { id });
|
||||
}
|
||||
@ -340,10 +338,7 @@ public class ProvidersController : Controller
|
||||
var users = await _providerUserRepository.GetManyDetailsByProviderAsync(id);
|
||||
var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(id);
|
||||
|
||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
|
||||
if (!isConsolidatedBillingEnabled || !provider.IsBillable())
|
||||
if (!provider.IsBillable())
|
||||
{
|
||||
return new ProviderEditModel(provider, users, providerOrganizations, new List<ProviderPlan>());
|
||||
}
|
||||
|
@ -1,10 +1,5 @@
|
||||
@using Bit.Core.AdminConsole.Enums.Provider
|
||||
@using Bit.Core
|
||||
|
||||
@model CreateMspProviderModel
|
||||
|
||||
@inject Bit.Core.Services.IFeatureService FeatureService
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Create Managed Service Provider";
|
||||
}
|
||||
@ -17,8 +12,6 @@
|
||||
<label asp-for="OwnerEmail"></label>
|
||||
<input type="text" class="form-control" asp-for="OwnerEmail">
|
||||
</div>
|
||||
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
@ -33,7 +26,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -48,7 +48,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && Model.Provider.IsBillable())
|
||||
@if (Model.Provider.IsBillable())
|
||||
{
|
||||
switch (Model.Provider.Type)
|
||||
{
|
||||
@ -68,46 +68,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<div class="form-group">
|
||||
<label asp-for="Gateway"></label>
|
||||
<select class="form-control" asp-for="Gateway" asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="GatewayCustomerId"></label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" asp-for="GatewayCustomerId">
|
||||
<div class="input-group-append">
|
||||
<a href="@Model.GatewayCustomerUrl" class="btn btn-secondary" target="_blank">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="GatewaySubscriptionId"></label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" asp-for="GatewaySubscriptionId">
|
||||
<div class="input-group-append">
|
||||
<a href="@Model.GatewaySubscriptionUrl" class="btn btn-secondary" target="_blank">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
break;
|
||||
}
|
||||
case ProviderType.MultiOrganizationEnterprise:
|
||||
@ -141,6 +101,46 @@
|
||||
break;
|
||||
}
|
||||
}
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<div class="form-group">
|
||||
<label asp-for="Gateway"></label>
|
||||
<select class="form-control" asp-for="Gateway" asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="GatewayCustomerId"></label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" asp-for="GatewayCustomerId">
|
||||
<div class="input-group-append">
|
||||
<a href="@Model.GatewayCustomerUrl" class="btn btn-secondary" target="_blank">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="GatewaySubscriptionId"></label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" asp-for="GatewaySubscriptionId">
|
||||
<div class="input-group-append">
|
||||
<a href="@Model.GatewaySubscriptionUrl" class="btn btn-secondary" target="_blank">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
@await Html.PartialAsync("Organizations", Model)
|
||||
|
@ -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;
|
||||
|
@ -289,9 +289,7 @@ public class OrganizationsController : Controller
|
||||
throw new BadRequestException(string.Empty, "User verification failed.");
|
||||
}
|
||||
|
||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (consolidatedBillingEnabled && organization.IsValidClient())
|
||||
if (organization.IsValidClient())
|
||||
{
|
||||
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||
|
||||
@ -322,8 +320,7 @@ public class OrganizationsController : Controller
|
||||
throw new BadRequestException("Invalid token.");
|
||||
}
|
||||
|
||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
if (consolidatedBillingEnabled && organization.IsValidClient())
|
||||
if (organization.IsValidClient())
|
||||
{
|
||||
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||
if (provider.IsBillable())
|
||||
@ -357,7 +354,7 @@ public class OrganizationsController : Controller
|
||||
{
|
||||
// Non-enterprise orgs should not be able to create or view an apikey of billing sync/scim key types
|
||||
var plan = StaticStore.GetPlan(organization.PlanType);
|
||||
if (plan.ProductTier != ProductTierType.Enterprise)
|
||||
if (plan.ProductTier is not ProductTierType.Enterprise and not ProductTierType.Teams)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Api</UserSecretsId>
|
||||
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Context;
|
||||
@ -9,7 +8,6 @@ namespace Bit.Api.Billing.Controllers;
|
||||
|
||||
public abstract class BaseProviderController(
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService,
|
||||
ILogger<BaseProviderController> logger,
|
||||
IProviderRepository providerRepository,
|
||||
IUserService userService) : BaseBillingController
|
||||
@ -26,15 +24,6 @@ public abstract class BaseProviderController(
|
||||
Guid providerId,
|
||||
Func<Guid, bool> checkAuthorization)
|
||||
{
|
||||
if (!featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
||||
{
|
||||
logger.LogError(
|
||||
"Cannot run Consolidated Billing operation for provider ({ProviderID}) while feature flag is disabled",
|
||||
providerId);
|
||||
|
||||
return (null, Error.NotFound());
|
||||
}
|
||||
|
||||
var provider = await providerRepository.GetByIdAsync(providerId);
|
||||
|
||||
if (provider == null)
|
||||
|
@ -19,14 +19,13 @@ namespace Bit.Api.Billing.Controllers;
|
||||
[Authorize("Application")]
|
||||
public class ProviderBillingController(
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService,
|
||||
ILogger<BaseProviderController> logger,
|
||||
IProviderBillingService providerBillingService,
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
IProviderRepository providerRepository,
|
||||
ISubscriberService subscriberService,
|
||||
IStripeAdapter stripeAdapter,
|
||||
IUserService userService) : BaseProviderController(currentContext, featureService, logger, providerRepository, userService)
|
||||
IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService)
|
||||
{
|
||||
[HttpGet("invoices")]
|
||||
public async Task<IResult> GetInvoicesAsync([FromRoute] Guid providerId)
|
||||
|
@ -14,14 +14,13 @@ namespace Bit.Api.Billing.Controllers;
|
||||
[Route("providers/{providerId:guid}/clients")]
|
||||
public class ProviderClientsController(
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService,
|
||||
ILogger<BaseProviderController> logger,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IProviderBillingService providerBillingService,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IProviderRepository providerRepository,
|
||||
IProviderService providerService,
|
||||
IUserService userService) : BaseProviderController(currentContext, featureService, logger, providerRepository, userService)
|
||||
IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService)
|
||||
{
|
||||
[HttpPost]
|
||||
public async Task<IResult> CreateAsync(
|
||||
@ -102,15 +101,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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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,78 +41,52 @@ 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 enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
|
||||
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
|
||||
|
||||
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||
|
||||
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
|
||||
|
||||
var discountedEnterpriseSeatPrice = enterprisePlan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
|
||||
|
||||
var discountedTeamsSeatPrice = teamsPlan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
|
||||
|
||||
var invoiceItems = clients.Select(client => new ProviderInvoiceItem
|
||||
{
|
||||
ProviderId = parsedProviderId,
|
||||
InvoiceId = invoice.Id,
|
||||
InvoiceNumber = invoice.Number,
|
||||
ClientId = client.OrganizationId,
|
||||
ClientName = client.OrganizationName,
|
||||
PlanName = client.Plan,
|
||||
AssignedSeats = client.Seats ?? 0,
|
||||
UsedSeats = client.OccupiedSeats ?? 0,
|
||||
Total = client.Plan == enterprisePlan.Name
|
||||
? (client.Seats ?? 0) * discountedEnterpriseSeatPrice
|
||||
: (client.Seats ?? 0) * discountedTeamsSeatPrice
|
||||
}).ToList();
|
||||
|
||||
if (enterpriseProviderPlan.PurchasedSeats is null or 0)
|
||||
{
|
||||
var enterpriseClientSeats = invoiceItems
|
||||
.Where(item => item.PlanName == enterprisePlan.Name)
|
||||
.Sum(item => item.AssignedSeats);
|
||||
|
||||
var unassignedEnterpriseSeats = enterpriseProviderPlan.SeatMinimum - enterpriseClientSeats ?? 0;
|
||||
var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
|
||||
|
||||
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
|
||||
ClientId = client.OrganizationId,
|
||||
ClientName = client.OrganizationName,
|
||||
PlanName = client.Plan,
|
||||
AssignedSeats = client.Seats ?? 0,
|
||||
UsedSeats = client.OccupiedSeats ?? 0,
|
||||
Total = (client.Seats ?? 0) * discountedSeatPrice
|
||||
});
|
||||
}
|
||||
|
||||
if (teamsProviderPlan.PurchasedSeats is null or 0)
|
||||
foreach (var providerPlan in providerPlans.Where(x => x.PurchasedSeats is null or 0))
|
||||
{
|
||||
var teamsClientSeats = invoiceItems
|
||||
.Where(item => item.PlanName == teamsPlan.Name)
|
||||
var plan = StaticStore.GetPlan(providerPlan.PlanType);
|
||||
|
||||
var clientSeats = invoiceItems
|
||||
.Where(item => item.PlanName == plan.Name)
|
||||
.Sum(item => item.AssignedSeats);
|
||||
|
||||
var unassignedTeamsSeats = teamsProviderPlan.SeatMinimum - teamsClientSeats ?? 0;
|
||||
var unassignedSeats = providerPlan.SeatMinimum - clientSeats ?? 0;
|
||||
|
||||
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
|
||||
|
||||
var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
|
||||
|
||||
invoiceItems.Add(new ProviderInvoiceItem
|
||||
{
|
||||
@ -121,10 +94,10 @@ public class ProviderEventService(
|
||||
InvoiceId = invoice.Id,
|
||||
InvoiceNumber = invoice.Number,
|
||||
ClientName = "Unassigned seats",
|
||||
PlanName = teamsPlan.Name,
|
||||
AssignedSeats = unassignedTeamsSeats,
|
||||
PlanName = plan.Name,
|
||||
AssignedSeats = unassignedSeats,
|
||||
UsedSeats = 0,
|
||||
Total = unassignedTeamsSeats * discountedTeamsSeatPrice
|
||||
Total = unassignedSeats * discountedSeatPrice
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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));
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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
|
@ -15,6 +15,7 @@ using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Models.Sales;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
@ -444,13 +445,6 @@ public class OrganizationService : IOrganizationService
|
||||
|
||||
public async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignupClientAsync(OrganizationSignup signup)
|
||||
{
|
||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (!consolidatedBillingEnabled)
|
||||
{
|
||||
throw new InvalidOperationException($"{nameof(SignupClientAsync)} is only for use within Consolidated Billing");
|
||||
}
|
||||
|
||||
var plan = StaticStore.GetPlan(signup.Plan);
|
||||
|
||||
ValidatePlan(plan, signup.AdditionalSeats, "Password Manager");
|
||||
@ -1443,10 +1437,7 @@ public class OrganizationService : IOrganizationService
|
||||
|
||||
if (provider is { Enabled: true })
|
||||
{
|
||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (consolidatedBillingEnabled && provider.Type == ProviderType.Msp &&
|
||||
provider.Status == ProviderStatusType.Billable)
|
||||
if (provider.IsBillable())
|
||||
{
|
||||
return (false, "Seat limit has been reached. Please contact your provider to add more seats.");
|
||||
}
|
||||
|
@ -11,22 +11,29 @@ namespace Bit.Core.Billing.Extensions;
|
||||
public static class BillingExtensions
|
||||
{
|
||||
public static bool IsBillable(this Provider provider) =>
|
||||
provider.SupportsConsolidatedBilling() && provider.Status == ProviderStatusType.Billable;
|
||||
provider is
|
||||
{
|
||||
Type: ProviderType.Msp or ProviderType.MultiOrganizationEnterprise,
|
||||
Status: ProviderStatusType.Billable
|
||||
};
|
||||
|
||||
public static bool SupportsConsolidatedBilling(this Provider provider)
|
||||
=> provider.Type is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;
|
||||
public static bool SupportsConsolidatedBilling(this ProviderType providerType)
|
||||
=> providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;
|
||||
|
||||
public static bool IsValidClient(this Organization organization)
|
||||
=> organization is
|
||||
{
|
||||
Seats: not null,
|
||||
Status: OrganizationStatusType.Managed,
|
||||
PlanType: PlanType.TeamsMonthly or PlanType.EnterpriseMonthly
|
||||
PlanType: PlanType.TeamsMonthly or PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually
|
||||
};
|
||||
|
||||
public static bool IsStripeEnabled(this ISubscriber subscriber)
|
||||
=> !string.IsNullOrEmpty(subscriber.GatewayCustomerId) &&
|
||||
!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId);
|
||||
=> subscriber is
|
||||
{
|
||||
GatewayCustomerId: not null and not "",
|
||||
GatewaySubscriptionId: not null and not ""
|
||||
};
|
||||
|
||||
public static bool IsUnverifiedBankAccount(this SetupIntent setupIntent) =>
|
||||
setupIntent 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(
|
||||
|
@ -107,11 +107,9 @@ public static class FeatureFlagKeys
|
||||
public const string ItemShare = "item-share";
|
||||
public const string DuoRedirect = "duo-redirect";
|
||||
public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
|
||||
public const string EnableConsolidatedBilling = "enable-consolidated-billing";
|
||||
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";
|
||||
@ -126,7 +124,6 @@ public static class FeatureFlagKeys
|
||||
public const string SSHKeyItemVaultItem = "ssh-key-vault-item";
|
||||
public const string SSHAgent = "ssh-agent";
|
||||
public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token";
|
||||
public const string EnableUpgradePasswordManagerSub = "AC-2708-upgrade-password-manager-sub";
|
||||
public const string IdpAutoSubmitLogin = "idp-auto-submit-login";
|
||||
public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh";
|
||||
public const string GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor";
|
||||
@ -143,6 +140,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";
|
||||
@ -155,6 +153,7 @@ public static class FeatureFlagKeys
|
||||
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;
|
||||
@ -363,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);
|
||||
}
|
||||
|
@ -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
src/Core/Vault/Entities/SecurityTask.cs
Normal file
20
src/Core/Vault/Entities/SecurityTask.cs
Normal file
@ -0,0 +1,20 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.Vault.Entities;
|
||||
|
||||
public class SecurityTask : ITableObject<Guid>
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid OrganizationId { get; set; }
|
||||
public Guid? CipherId { get; set; }
|
||||
public Enums.SecurityTaskType Type { get; set; }
|
||||
public Enums.SecurityTaskStatus Status { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public void SetNewId()
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb();
|
||||
}
|
||||
}
|
18
src/Core/Vault/Enums/SecurityTaskStatus.cs
Normal file
18
src/Core/Vault/Enums/SecurityTaskStatus.cs
Normal file
@ -0,0 +1,18 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Core.Vault.Enums;
|
||||
|
||||
public enum SecurityTaskStatus : byte
|
||||
{
|
||||
/// <summary>
|
||||
/// Default status for newly created tasks that have not been completed.
|
||||
/// </summary>
|
||||
[Display(Name = "Pending")]
|
||||
Pending = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Status when a task is considered complete and has no remaining actions
|
||||
/// </summary>
|
||||
[Display(Name = "Completed")]
|
||||
Completed = 1,
|
||||
}
|
12
src/Core/Vault/Enums/SecurityTaskType.cs
Normal file
12
src/Core/Vault/Enums/SecurityTaskType.cs
Normal file
@ -0,0 +1,12 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Core.Vault.Enums;
|
||||
|
||||
public enum SecurityTaskType : byte
|
||||
{
|
||||
/// <summary>
|
||||
/// Task to update a cipher's password that was found to be at-risk by an administrator
|
||||
/// </summary>
|
||||
[Display(Name = "Update at-risk credential")]
|
||||
UpdateAtRiskCredential = 0
|
||||
}
|
9
src/Core/Vault/Repositories/ISecurityTaskRepository.cs
Normal file
9
src/Core/Vault/Repositories/ISecurityTaskRepository.cs
Normal file
@ -0,0 +1,9 @@
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Vault.Entities;
|
||||
|
||||
namespace Bit.Core.Vault.Repositories;
|
||||
|
||||
public interface ISecurityTaskRepository : IRepository<SecurityTask, Guid>
|
||||
{
|
||||
|
||||
}
|
@ -59,6 +59,7 @@ public static class DapperServiceCollectionExtensions
|
||||
services
|
||||
.AddSingleton<IClientOrganizationMigrationRecordRepository, ClientOrganizationMigrationRecordRepository>();
|
||||
services.AddSingleton<IPasswordHealthReportApplicationRepository, PasswordHealthReportApplicationRepository>();
|
||||
services.AddSingleton<ISecurityTaskRepository, SecurityTaskRepository>();
|
||||
|
||||
if (selfHosted)
|
||||
{
|
||||
|
@ -0,0 +1,18 @@
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Core.Vault.Repositories;
|
||||
using Bit.Infrastructure.Dapper.Repositories;
|
||||
|
||||
namespace Bit.Infrastructure.Dapper.Vault.Repositories;
|
||||
|
||||
public class SecurityTaskRepository : Repository<SecurityTask, Guid>, ISecurityTaskRepository
|
||||
{
|
||||
public SecurityTaskRepository(GlobalSettings globalSettings)
|
||||
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
|
||||
{ }
|
||||
|
||||
public SecurityTaskRepository(string connectionString, string readOnlyConnectionString)
|
||||
: base(connectionString, readOnlyConnectionString)
|
||||
{ }
|
||||
|
||||
}
|
@ -96,6 +96,7 @@ public static class EntityFrameworkServiceCollectionExtensions
|
||||
services
|
||||
.AddSingleton<IClientOrganizationMigrationRecordRepository, ClientOrganizationMigrationRecordRepository>();
|
||||
services.AddSingleton<IPasswordHealthReportApplicationRepository, PasswordHealthReportApplicationRepository>();
|
||||
services.AddSingleton<ISecurityTaskRepository, SecurityTaskRepository>();
|
||||
|
||||
if (selfHosted)
|
||||
{
|
||||
|
@ -77,6 +77,7 @@ public class DatabaseContext : DbContext
|
||||
public DbSet<NotificationStatus> NotificationStatuses { get; set; }
|
||||
public DbSet<ClientOrganizationMigrationRecord> ClientOrganizationMigrationRecords { get; set; }
|
||||
public DbSet<PasswordHealthReportApplication> PasswordHealthReportApplications { get; set; }
|
||||
public DbSet<SecurityTask> SecurityTasks { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
|
@ -0,0 +1,30 @@
|
||||
using Bit.Infrastructure.EntityFramework.Vault.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Vault.Configurations;
|
||||
|
||||
public class SecurityTaskEntityTypeConfiguration : IEntityTypeConfiguration<SecurityTask>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SecurityTask> builder)
|
||||
{
|
||||
builder
|
||||
.Property(s => s.Id)
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder
|
||||
.HasKey(s => s.Id)
|
||||
.IsClustered();
|
||||
|
||||
builder
|
||||
.HasIndex(s => s.OrganizationId)
|
||||
.IsClustered(false);
|
||||
|
||||
builder
|
||||
.HasIndex(s => s.CipherId)
|
||||
.IsClustered(false);
|
||||
|
||||
builder
|
||||
.ToTable(nameof(SecurityTask));
|
||||
}
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
using AutoMapper;
|
||||
using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Vault.Models;
|
||||
|
||||
public class SecurityTask : Core.Vault.Entities.SecurityTask
|
||||
{
|
||||
public virtual Organization Organization { get; set; }
|
||||
public virtual Cipher Cipher { get; set; }
|
||||
}
|
||||
|
||||
public class SecurityTaskMapperProfile : Profile
|
||||
{
|
||||
public SecurityTaskMapperProfile()
|
||||
{
|
||||
CreateMap<Core.Vault.Entities.SecurityTask, SecurityTask>().ReverseMap();
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
using AutoMapper;
|
||||
using Bit.Core.Vault.Repositories;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Bit.Infrastructure.EntityFramework.Vault.Models;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Vault.Repositories;
|
||||
|
||||
public class SecurityTaskRepository : Repository<Core.Vault.Entities.SecurityTask, SecurityTask, Guid>, ISecurityTaskRepository
|
||||
{
|
||||
public SecurityTaskRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
|
||||
: base(serviceScopeFactory, mapper, (context) => context.SecurityTasks)
|
||||
{ }
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
CREATE PROCEDURE [dbo].[SecurityTask_Create]
|
||||
@Id UNIQUEIDENTIFIER OUTPUT,
|
||||
@OrganizationId UNIQUEIDENTIFIER,
|
||||
@CipherId UNIQUEIDENTIFIER,
|
||||
@Type TINYINT,
|
||||
@Status TINYINT,
|
||||
@CreationDate DATETIME2(7),
|
||||
@RevisionDate DATETIME2(7)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
INSERT INTO [dbo].[SecurityTask]
|
||||
(
|
||||
[Id],
|
||||
[OrganizationId],
|
||||
[CipherId],
|
||||
[Type],
|
||||
[Status],
|
||||
[CreationDate],
|
||||
[RevisionDate]
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@Id,
|
||||
@OrganizationId,
|
||||
@CipherId,
|
||||
@Type,
|
||||
@Status,
|
||||
@CreationDate,
|
||||
@RevisionDate
|
||||
)
|
||||
END
|
@ -0,0 +1,13 @@
|
||||
CREATE PROCEDURE [dbo].[SecurityTask_ReadById]
|
||||
@Id UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
[dbo].[SecurityTaskView]
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
END
|
@ -0,0 +1,24 @@
|
||||
CREATE PROCEDURE [dbo].[SecurityTask_Update]
|
||||
@Id UNIQUEIDENTIFIER,
|
||||
@OrganizationId UNIQUEIDENTIFIER,
|
||||
@CipherId UNIQUEIDENTIFIER,
|
||||
@Type TINYINT,
|
||||
@Status TINYINT,
|
||||
@CreationDate DATETIME2(7),
|
||||
@RevisionDate DATETIME2(7)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
UPDATE
|
||||
[dbo].[SecurityTask]
|
||||
SET
|
||||
[OrganizationId] = @OrganizationId,
|
||||
[CipherId] = @CipherId,
|
||||
[Type] = @Type,
|
||||
[Status] = @Status,
|
||||
[CreationDate] = @CreationDate,
|
||||
[RevisionDate] = @RevisionDate
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
END
|
21
src/Sql/Vault/dbo/Tables/SecurityTask.sql
Normal file
21
src/Sql/Vault/dbo/Tables/SecurityTask.sql
Normal file
@ -0,0 +1,21 @@
|
||||
CREATE TABLE [dbo].[SecurityTask]
|
||||
(
|
||||
[Id] UNIQUEIDENTIFIER NOT NULL,
|
||||
[OrganizationId] UNIQUEIDENTIFIER NOT NULL,
|
||||
[CipherId] UNIQUEIDENTIFIER NULL,
|
||||
[Type] TINYINT NOT NULL,
|
||||
[Status] TINYINT NOT NULL,
|
||||
[CreationDate] DATETIME2 (7) NOT NULL,
|
||||
[RevisionDate] DATETIME2 (7) NOT NULL,
|
||||
CONSTRAINT [PK_SecurityTask] PRIMARY KEY CLUSTERED ([Id] ASC),
|
||||
CONSTRAINT [FK_SecurityTask_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE,
|
||||
CONSTRAINT [FK_SecurityTask_Cipher] FOREIGN KEY ([CipherId]) REFERENCES [dbo].[Cipher] ([Id]) ON DELETE CASCADE,
|
||||
);
|
||||
|
||||
GO
|
||||
CREATE NONCLUSTERED INDEX [IX_SecurityTask_CipherId]
|
||||
ON [dbo].[SecurityTask]([CipherId] ASC) WHERE CipherId IS NOT NULL;
|
||||
|
||||
GO
|
||||
CREATE NONCLUSTERED INDEX [IX_SecurityTask_OrganizationId]
|
||||
ON [dbo].[SecurityTask]([OrganizationId] ASC) WHERE OrganizationId IS NOT NULL;
|
6
src/Sql/Vault/dbo/Views/SecurityTaskView.sql
Normal file
6
src/Sql/Vault/dbo/Views/SecurityTaskView.sql
Normal file
@ -0,0 +1,6 @@
|
||||
CREATE VIEW [dbo].[SecurityTaskView]
|
||||
AS
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
[dbo].[SecurityTask]
|
@ -0,0 +1,336 @@
|
||||
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.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.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.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.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.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.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.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);
|
||||
}
|
||||
}
|
@ -218,8 +218,6 @@ public class OrganizationsControllerTests : IDisposable
|
||||
|
||||
_userService.VerifySecretAsync(user, requestModel.Secret).Returns(true);
|
||||
|
||||
_featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
||||
|
||||
_providerRepository.GetByOrganizationIdAsync(organization.Id).Returns(provider);
|
||||
|
||||
await _sut.Delete(organizationId.ToString(), requestModel);
|
||||
|
@ -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]
|
||||
|
@ -1,7 +1,6 @@
|
||||
using Bit.Api.Billing.Controllers;
|
||||
using Bit.Api.Billing.Models.Requests;
|
||||
using Bit.Api.Billing.Models.Responses;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
@ -35,27 +34,11 @@ public class ProviderBillingControllerTests
|
||||
{
|
||||
#region GetInvoicesAsync & TryGetBillableProviderForAdminOperations
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetInvoicesAsync_FFDisabled_NotFound(
|
||||
Guid providerId,
|
||||
SutProvider<ProviderBillingController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
||||
.Returns(false);
|
||||
|
||||
var result = await sutProvider.Sut.GetInvoicesAsync(providerId);
|
||||
|
||||
AssertNotFound(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetInvoicesAsync_NullProvider_NotFound(
|
||||
Guid providerId,
|
||||
SutProvider<ProviderBillingController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(providerId).ReturnsNull();
|
||||
|
||||
var result = await sutProvider.Sut.GetInvoicesAsync(providerId);
|
||||
@ -68,9 +51,6 @@ public class ProviderBillingControllerTests
|
||||
Provider provider,
|
||||
SutProvider<ProviderBillingController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id)
|
||||
@ -86,9 +66,6 @@ public class ProviderBillingControllerTests
|
||||
Provider provider,
|
||||
SutProvider<ProviderBillingController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
||||
.Returns(true);
|
||||
|
||||
provider.Type = ProviderType.Reseller;
|
||||
provider.Status = ProviderStatusType.Created;
|
||||
|
||||
@ -229,27 +206,11 @@ public class ProviderBillingControllerTests
|
||||
|
||||
#region GetSubscriptionAsync & TryGetBillableProviderForServiceUserOperation
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscriptionAsync_FFDisabled_NotFound(
|
||||
Guid providerId,
|
||||
SutProvider<ProviderBillingController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
||||
.Returns(false);
|
||||
|
||||
var result = await sutProvider.Sut.GetSubscriptionAsync(providerId);
|
||||
|
||||
AssertNotFound(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscriptionAsync_NullProvider_NotFound(
|
||||
Guid providerId,
|
||||
SutProvider<ProviderBillingController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(providerId).ReturnsNull();
|
||||
|
||||
var result = await sutProvider.Sut.GetSubscriptionAsync(providerId);
|
||||
@ -262,9 +223,6 @@ public class ProviderBillingControllerTests
|
||||
Provider provider,
|
||||
SutProvider<ProviderBillingController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().ProviderUser(provider.Id)
|
||||
@ -280,9 +238,6 @@ public class ProviderBillingControllerTests
|
||||
Provider provider,
|
||||
SutProvider<ProviderBillingController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
||||
.Returns(true);
|
||||
|
||||
provider.Type = ProviderType.Reseller;
|
||||
provider.Status = ProviderStatusType.Created;
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -1,11 +1,9 @@
|
||||
using Bit.Api.Billing.Controllers;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
@ -25,14 +23,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>(
|
||||
@ -59,9 +57,6 @@ public static class Utilities
|
||||
Provider provider,
|
||||
SutProvider<T> sutProvider) where T : BaseProviderController
|
||||
{
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
||||
.Returns(true);
|
||||
|
||||
provider.Type = ProviderType.Msp;
|
||||
provider.Status = ProviderStatusType.Billable;
|
||||
|
||||
|
@ -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;
|
||||
|
@ -420,8 +420,6 @@ public class OrganizationServiceTests
|
||||
OrganizationSignup signup,
|
||||
SutProvider<OrganizationService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
||||
|
||||
signup.Plan = PlanType.TeamsMonthly;
|
||||
|
||||
var (organization, _, _) = await sutProvider.Sut.SignupClientAsync(signup);
|
||||
|
@ -0,0 +1,123 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Core.Vault.Enums;
|
||||
using Bit.Core.Vault.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Infrastructure.IntegrationTest.Vault.Repositories;
|
||||
|
||||
public class SecurityTaskRepositoryTests
|
||||
{
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task CreateAsync(
|
||||
IOrganizationRepository organizationRepository,
|
||||
ICipherRepository cipherRepository,
|
||||
ISecurityTaskRepository securityTaskRepository)
|
||||
{
|
||||
var organization = await organizationRepository.CreateAsync(new Organization
|
||||
{
|
||||
Name = "Test Org",
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
Plan = "Test Plan",
|
||||
BillingEmail = "billing@email.com"
|
||||
});
|
||||
|
||||
var cipher = await cipherRepository.CreateAsync(new Cipher
|
||||
{
|
||||
Type = CipherType.Login,
|
||||
OrganizationId = organization.Id,
|
||||
Data = "",
|
||||
});
|
||||
|
||||
var task = await securityTaskRepository.CreateAsync(new SecurityTask
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
CipherId = cipher.Id,
|
||||
Status = SecurityTaskStatus.Pending,
|
||||
Type = SecurityTaskType.UpdateAtRiskCredential,
|
||||
});
|
||||
|
||||
Assert.NotNull(task);
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task ReadByIdAsync(
|
||||
IOrganizationRepository organizationRepository,
|
||||
ICipherRepository cipherRepository,
|
||||
ISecurityTaskRepository securityTaskRepository)
|
||||
{
|
||||
var organization = await organizationRepository.CreateAsync(new Organization
|
||||
{
|
||||
Name = "Test Org",
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
Plan = "Test Plan",
|
||||
BillingEmail = "billing@email.com"
|
||||
});
|
||||
|
||||
var cipher = await cipherRepository.CreateAsync(new Cipher
|
||||
{
|
||||
Type = CipherType.Login,
|
||||
OrganizationId = organization.Id,
|
||||
Data = "",
|
||||
});
|
||||
|
||||
var task = await securityTaskRepository.CreateAsync(new SecurityTask
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
CipherId = cipher.Id,
|
||||
Status = SecurityTaskStatus.Pending,
|
||||
Type = SecurityTaskType.UpdateAtRiskCredential,
|
||||
});
|
||||
|
||||
Assert.NotNull(task);
|
||||
|
||||
var readTask = await securityTaskRepository.GetByIdAsync(task.Id);
|
||||
|
||||
Assert.NotNull(readTask);
|
||||
Assert.Equal(task.Id, readTask.Id);
|
||||
Assert.Equal(task.Status, readTask.Status);
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task UpdateAsync(
|
||||
IOrganizationRepository organizationRepository,
|
||||
ICipherRepository cipherRepository,
|
||||
ISecurityTaskRepository securityTaskRepository)
|
||||
{
|
||||
var organization = await organizationRepository.CreateAsync(new Organization
|
||||
{
|
||||
Name = "Test Org",
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
Plan = "Test Plan",
|
||||
BillingEmail = "billing@email.com"
|
||||
});
|
||||
|
||||
var cipher = await cipherRepository.CreateAsync(new Cipher
|
||||
{
|
||||
Type = CipherType.Login,
|
||||
OrganizationId = organization.Id,
|
||||
Data = "",
|
||||
});
|
||||
|
||||
var task = await securityTaskRepository.CreateAsync(new SecurityTask
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
CipherId = cipher.Id,
|
||||
Status = SecurityTaskStatus.Pending,
|
||||
Type = SecurityTaskType.UpdateAtRiskCredential,
|
||||
});
|
||||
|
||||
Assert.NotNull(task);
|
||||
|
||||
task.Status = SecurityTaskStatus.Completed;
|
||||
await securityTaskRepository.ReplaceAsync(task);
|
||||
|
||||
var updatedTask = await securityTaskRepository.GetByIdAsync(task.Id);
|
||||
|
||||
Assert.NotNull(updatedTask);
|
||||
Assert.Equal(task.Id, updatedTask.Id);
|
||||
Assert.Equal(SecurityTaskStatus.Completed, updatedTask.Status);
|
||||
}
|
||||
}
|
114
util/Migrator/DbScripts/2024-11-11_00_InitialSecurityTasks.sql
Normal file
114
util/Migrator/DbScripts/2024-11-11_00_InitialSecurityTasks.sql
Normal file
@ -0,0 +1,114 @@
|
||||
-- Security Tasks
|
||||
|
||||
-- Table
|
||||
IF OBJECT_ID('[dbo].[SecurityTask]') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE [dbo].[SecurityTask]
|
||||
(
|
||||
[Id] UNIQUEIDENTIFIER NOT NULL,
|
||||
[OrganizationId] UNIQUEIDENTIFIER NOT NULL,
|
||||
[CipherId] UNIQUEIDENTIFIER NULL,
|
||||
[Type] TINYINT NOT NULL,
|
||||
[Status] TINYINT NOT NULL,
|
||||
[CreationDate] DATETIME2 (7) NOT NULL,
|
||||
[RevisionDate] DATETIME2 (7) NOT NULL,
|
||||
CONSTRAINT [PK_SecurityTask] PRIMARY KEY CLUSTERED ([Id] ASC),
|
||||
CONSTRAINT [FK_SecurityTask_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE,
|
||||
CONSTRAINT [FK_SecurityTask_Cipher] FOREIGN KEY ([CipherId]) REFERENCES [dbo].[Cipher] ([Id]) ON DELETE CASCADE,
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX [IX_SecurityTask_CipherId]
|
||||
ON [dbo].[SecurityTask]([CipherId] ASC) WHERE CipherId IS NOT NULL;
|
||||
|
||||
CREATE NONCLUSTERED INDEX [IX_SecurityTask_OrganizationId]
|
||||
ON [dbo].[SecurityTask]([OrganizationId] ASC) WHERE OrganizationId IS NOT NULL;
|
||||
END
|
||||
GO
|
||||
|
||||
-- View SecurityTask
|
||||
CREATE OR ALTER VIEW [dbo].[SecurityTaskView]
|
||||
AS
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
[dbo].[SecurityTask]
|
||||
GO
|
||||
|
||||
-- Stored Procedures: Create
|
||||
CREATE OR ALTER PROCEDURE [dbo].[SecurityTask_Create]
|
||||
@Id UNIQUEIDENTIFIER OUTPUT,
|
||||
@OrganizationId UNIQUEIDENTIFIER,
|
||||
@CipherId UNIQUEIDENTIFIER,
|
||||
@Type TINYINT,
|
||||
@Status TINYINT,
|
||||
@CreationDate DATETIME2(7),
|
||||
@RevisionDate DATETIME2(7)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
INSERT INTO [dbo].[SecurityTask]
|
||||
(
|
||||
[Id],
|
||||
[OrganizationId],
|
||||
[CipherId],
|
||||
[Type],
|
||||
[Status],
|
||||
[CreationDate],
|
||||
[RevisionDate]
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@Id,
|
||||
@OrganizationId,
|
||||
@CipherId,
|
||||
@Type,
|
||||
@Status,
|
||||
@CreationDate,
|
||||
@RevisionDate
|
||||
)
|
||||
END
|
||||
GO
|
||||
|
||||
-- Stored Procedures: Update
|
||||
CREATE OR ALTER PROCEDURE [dbo].[SecurityTask_Update]
|
||||
@Id UNIQUEIDENTIFIER,
|
||||
@OrganizationId UNIQUEIDENTIFIER,
|
||||
@CipherId UNIQUEIDENTIFIER,
|
||||
@Type TINYINT,
|
||||
@Status TINYINT,
|
||||
@CreationDate DATETIME2(7),
|
||||
@RevisionDate DATETIME2(7)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
UPDATE
|
||||
[dbo].[SecurityTask]
|
||||
SET
|
||||
[OrganizationId] = @OrganizationId,
|
||||
[CipherId] = @CipherId,
|
||||
[Type] = @Type,
|
||||
[Status] = @Status,
|
||||
[CreationDate] = @CreationDate,
|
||||
[RevisionDate] = @RevisionDate
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
END
|
||||
GO
|
||||
|
||||
-- Stored Procedures: ReadById
|
||||
CREATE OR ALTER PROCEDURE [dbo].[SecurityTask_ReadById]
|
||||
@Id UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
[dbo].[SecurityTaskView]
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
END
|
||||
GO
|
2940
util/MySqlMigrations/Migrations/20241112001902_SecurityTasks.Designer.cs
generated
Normal file
2940
util/MySqlMigrations/Migrations/20241112001902_SecurityTasks.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,59 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bit.MySqlMigrations.Migrations;
|
||||
|
||||
/// <inheritdoc />
|
||||
public partial class SecurityTasks : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SecurityTask",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
OrganizationId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
CipherId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||
Type = table.Column<byte>(type: "tinyint unsigned", nullable: false),
|
||||
Status = table.Column<byte>(type: "tinyint unsigned", nullable: false),
|
||||
CreationDate = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
RevisionDate = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SecurityTask", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_SecurityTask_Cipher_CipherId",
|
||||
column: x => x.CipherId,
|
||||
principalTable: "Cipher",
|
||||
principalColumn: "Id");
|
||||
table.ForeignKey(
|
||||
name: "FK_SecurityTask_Organization_OrganizationId",
|
||||
column: x => x.OrganizationId,
|
||||
principalTable: "Organization",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SecurityTask_CipherId",
|
||||
table: "SecurityTask",
|
||||
column: "CipherId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SecurityTask_OrganizationId",
|
||||
table: "SecurityTask",
|
||||
column: "OrganizationId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "SecurityTask");
|
||||
}
|
||||
}
|
@ -1989,6 +1989,41 @@ namespace Bit.MySqlMigrations.Migrations
|
||||
b.ToTable("Folder", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid?>("CipherId")
|
||||
.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<byte>("Status")
|
||||
.HasColumnType("tinyint unsigned");
|
||||
|
||||
b.Property<byte>("Type")
|
||||
.HasColumnType("tinyint unsigned");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasAnnotation("SqlServer:Clustered", true);
|
||||
|
||||
b.HasIndex("CipherId")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.HasIndex("OrganizationId")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.ToTable("SecurityTask", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ProjectSecret", b =>
|
||||
{
|
||||
b.Property<Guid>("ProjectsId")
|
||||
@ -2643,6 +2678,23 @@ namespace Bit.MySqlMigrations.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher")
|
||||
.WithMany()
|
||||
.HasForeignKey("CipherId");
|
||||
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||
.WithMany()
|
||||
.HasForeignKey("OrganizationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Cipher");
|
||||
|
||||
b.Navigation("Organization");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ProjectSecret", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null)
|
||||
|
2946
util/PostgresMigrations/Migrations/20241112001757_SecurityTasks.Designer.cs
generated
Normal file
2946
util/PostgresMigrations/Migrations/20241112001757_SecurityTasks.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,58 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bit.PostgresMigrations.Migrations;
|
||||
|
||||
/// <inheritdoc />
|
||||
public partial class SecurityTasks : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SecurityTask",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CipherId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
Type = table.Column<byte>(type: "smallint", nullable: false),
|
||||
Status = table.Column<byte>(type: "smallint", nullable: false),
|
||||
CreationDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
RevisionDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SecurityTask", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_SecurityTask_Cipher_CipherId",
|
||||
column: x => x.CipherId,
|
||||
principalTable: "Cipher",
|
||||
principalColumn: "Id");
|
||||
table.ForeignKey(
|
||||
name: "FK_SecurityTask_Organization_OrganizationId",
|
||||
column: x => x.OrganizationId,
|
||||
principalTable: "Organization",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SecurityTask_CipherId",
|
||||
table: "SecurityTask",
|
||||
column: "CipherId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SecurityTask_OrganizationId",
|
||||
table: "SecurityTask",
|
||||
column: "OrganizationId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "SecurityTask");
|
||||
}
|
||||
}
|
@ -1995,6 +1995,41 @@ namespace Bit.PostgresMigrations.Migrations
|
||||
b.ToTable("Folder", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("CipherId")
|
||||
.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<byte>("Status")
|
||||
.HasColumnType("smallint");
|
||||
|
||||
b.Property<byte>("Type")
|
||||
.HasColumnType("smallint");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasAnnotation("SqlServer:Clustered", true);
|
||||
|
||||
b.HasIndex("CipherId")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.HasIndex("OrganizationId")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.ToTable("SecurityTask", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ProjectSecret", b =>
|
||||
{
|
||||
b.Property<Guid>("ProjectsId")
|
||||
@ -2649,6 +2684,23 @@ namespace Bit.PostgresMigrations.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher")
|
||||
.WithMany()
|
||||
.HasForeignKey("CipherId");
|
||||
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||
.WithMany()
|
||||
.HasForeignKey("OrganizationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Cipher");
|
||||
|
||||
b.Navigation("Organization");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ProjectSecret", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null)
|
||||
|
2929
util/SqliteMigrations/Migrations/20241112001814_SecurityTasks.Designer.cs
generated
Normal file
2929
util/SqliteMigrations/Migrations/20241112001814_SecurityTasks.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,58 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bit.SqliteMigrations.Migrations;
|
||||
|
||||
/// <inheritdoc />
|
||||
public partial class SecurityTasks : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SecurityTask",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
OrganizationId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
CipherId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
Type = table.Column<byte>(type: "INTEGER", nullable: false),
|
||||
Status = table.Column<byte>(type: "INTEGER", nullable: false),
|
||||
CreationDate = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
RevisionDate = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SecurityTask", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_SecurityTask_Cipher_CipherId",
|
||||
column: x => x.CipherId,
|
||||
principalTable: "Cipher",
|
||||
principalColumn: "Id");
|
||||
table.ForeignKey(
|
||||
name: "FK_SecurityTask_Organization_OrganizationId",
|
||||
column: x => x.OrganizationId,
|
||||
principalTable: "Organization",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SecurityTask_CipherId",
|
||||
table: "SecurityTask",
|
||||
column: "CipherId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SecurityTask_OrganizationId",
|
||||
table: "SecurityTask",
|
||||
column: "OrganizationId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "SecurityTask");
|
||||
}
|
||||
}
|
@ -1978,6 +1978,41 @@ namespace Bit.SqliteMigrations.Migrations
|
||||
b.ToTable("Folder", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("CipherId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreationDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("RevisionDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<byte>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasAnnotation("SqlServer:Clustered", true);
|
||||
|
||||
b.HasIndex("CipherId")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.HasIndex("OrganizationId")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.ToTable("SecurityTask", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ProjectSecret", b =>
|
||||
{
|
||||
b.Property<Guid>("ProjectsId")
|
||||
@ -2632,6 +2667,23 @@ namespace Bit.SqliteMigrations.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher")
|
||||
.WithMany()
|
||||
.HasForeignKey("CipherId");
|
||||
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||
.WithMany()
|
||||
.HasForeignKey("OrganizationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Cipher");
|
||||
|
||||
b.Navigation("Organization");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ProjectSecret", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null)
|
||||
|
Loading…
Reference in New Issue
Block a user