1
0
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:
Brandon Treston 2024-11-18 12:19:43 -05:00 committed by GitHub
commit 1c000ffe86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
77 changed files with 11183 additions and 1536 deletions

View File

@ -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: |

View File

@ -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);

View File

@ -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

View File

@ -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))

View File

@ -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;
}
}

View File

@ -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

View File

@ -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 =

View File

@ -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;
}
}

View File

@ -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>());
}

View File

@ -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>

View File

@ -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)

View File

@ -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();
}

View File

@ -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;

View File

@ -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();
}

View File

@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<UserSecretsId>bitwarden-Api</UserSecretsId>
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>

View File

@ -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);
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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);

View File

@ -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);
}

View File

@ -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;

View File

@ -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);
}
}
}

View File

@ -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);
}
}

View File

@ -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
});
}

View File

@ -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);
}

View File

@ -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));
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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

View File

@ -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.");
}

View File

@ -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

View File

@ -4,4 +4,5 @@ public record OrganizationMetadata(
bool IsEligibleForSelfHost,
bool IsManaged,
bool IsOnSecretsManagerStandalone,
bool IsSubscriptionUnpaid);
bool IsSubscriptionUnpaid,
bool HasSubscription);

View File

@ -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);
}

View File

@ -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(

View File

@ -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()
{

View File

@ -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);
}

View File

@ -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)\"}," +

View 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();
}
}

View 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,
}

View 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
}

View 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>
{
}

View File

@ -59,6 +59,7 @@ public static class DapperServiceCollectionExtensions
services
.AddSingleton<IClientOrganizationMigrationRecordRepository, ClientOrganizationMigrationRecordRepository>();
services.AddSingleton<IPasswordHealthReportApplicationRepository, PasswordHealthReportApplicationRepository>();
services.AddSingleton<ISecurityTaskRepository, SecurityTaskRepository>();
if (selfHosted)
{

View File

@ -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)
{ }
}

View File

@ -96,6 +96,7 @@ public static class EntityFrameworkServiceCollectionExtensions
services
.AddSingleton<IClientOrganizationMigrationRecordRepository, ClientOrganizationMigrationRecordRepository>();
services.AddSingleton<IPasswordHealthReportApplicationRepository, PasswordHealthReportApplicationRepository>();
services.AddSingleton<ISecurityTaskRepository, SecurityTaskRepository>();
if (selfHosted)
{

View File

@ -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)
{

View File

@ -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));
}
}

View File

@ -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();
}
}

View File

@ -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)
{ }
}

View File

@ -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

View File

@ -0,0 +1,13 @@
CREATE PROCEDURE [dbo].[SecurityTask_ReadById]
@Id UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[SecurityTaskView]
WHERE
[Id] = @Id
END

View File

@ -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

View 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;

View File

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

View File

@ -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
}

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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]

View File

@ -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;

View File

@ -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);
}

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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()
{

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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);
}
}

View 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

File diff suppressed because it is too large Load Diff

View File

@ -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");
}
}

View File

@ -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)

File diff suppressed because it is too large Load Diff

View File

@ -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");
}
}

View File

@ -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)

File diff suppressed because it is too large Load Diff

View File

@ -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");
}
}

View File

@ -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)