1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-28 13:15:12 +01:00

[AC-2513] Scaling PM seat count with SM seat count (#4040)

* For SM Trial orgs, now scaling PM seat count with SM seat count adjustments

* Split Billing related organization endpoints into billing owned controller

* Updated billing organizations controller to use a primary constructor to reduce boilerplate

* Fixed error where ID couldn't be mapped to subscription endpoint guid param

* Updated billing OrganizationController endpoints to not manually create the GUID from the string ID

* Banished magic string back to the pit from whence it came

* Resolved errors in unit tests
This commit is contained in:
Conner Turnbull 2024-05-17 14:16:03 -04:00 committed by GitHub
parent 0b5c21acca
commit a60180230d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 731 additions and 594 deletions

View File

@ -6,7 +6,6 @@ using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.Auth.Models.Request.Organizations; using Bit.Api.Auth.Models.Request.Organizations;
using Bit.Api.Auth.Models.Response.Organizations; using Bit.Api.Auth.Models.Response.Organizations;
using Bit.Api.Models.Request;
using Bit.Api.Models.Request.Accounts; using Bit.Api.Models.Request.Accounts;
using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
@ -21,20 +20,12 @@ using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.Services; using Bit.Core.Auth.Services;
using Bit.Core.Billing.Commands; using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Queries;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Business;
using Bit.Core.Tools.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -50,7 +41,6 @@ public class OrganizationsController : Controller
private readonly IPolicyRepository _policyRepository; private readonly IPolicyRepository _policyRepository;
private readonly IOrganizationService _organizationService; private readonly IOrganizationService _organizationService;
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly IPaymentService _paymentService;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly ISsoConfigService _ssoConfigService; private readonly ISsoConfigService _ssoConfigService;
@ -58,17 +48,9 @@ public class OrganizationsController : Controller
private readonly IRotateOrganizationApiKeyCommand _rotateOrganizationApiKeyCommand; private readonly IRotateOrganizationApiKeyCommand _rotateOrganizationApiKeyCommand;
private readonly ICreateOrganizationApiKeyCommand _createOrganizationApiKeyCommand; private readonly ICreateOrganizationApiKeyCommand _createOrganizationApiKeyCommand;
private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository;
private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly ILicensingService _licensingService;
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand;
private readonly IAddSecretsManagerSubscriptionCommand _addSecretsManagerSubscriptionCommand;
private readonly IPushNotificationService _pushNotificationService; private readonly IPushNotificationService _pushNotificationService;
private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand;
private readonly ISubscriberQueries _subscriberQueries;
private readonly IReferenceEventService _referenceEventService;
private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand; private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand;
private readonly IProviderRepository _providerRepository; private readonly IProviderRepository _providerRepository;
private readonly IScaleSeatsCommand _scaleSeatsCommand; private readonly IScaleSeatsCommand _scaleSeatsCommand;
@ -79,7 +61,6 @@ public class OrganizationsController : Controller
IPolicyRepository policyRepository, IPolicyRepository policyRepository,
IOrganizationService organizationService, IOrganizationService organizationService,
IUserService userService, IUserService userService,
IPaymentService paymentService,
ICurrentContext currentContext, ICurrentContext currentContext,
ISsoConfigRepository ssoConfigRepository, ISsoConfigRepository ssoConfigRepository,
ISsoConfigService ssoConfigService, ISsoConfigService ssoConfigService,
@ -87,17 +68,9 @@ public class OrganizationsController : Controller
IRotateOrganizationApiKeyCommand rotateOrganizationApiKeyCommand, IRotateOrganizationApiKeyCommand rotateOrganizationApiKeyCommand,
ICreateOrganizationApiKeyCommand createOrganizationApiKeyCommand, ICreateOrganizationApiKeyCommand createOrganizationApiKeyCommand,
IOrganizationApiKeyRepository organizationApiKeyRepository, IOrganizationApiKeyRepository organizationApiKeyRepository,
ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery,
IFeatureService featureService, IFeatureService featureService,
GlobalSettings globalSettings, GlobalSettings globalSettings,
ILicensingService licensingService,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
IUpgradeOrganizationPlanCommand upgradeOrganizationPlanCommand,
IAddSecretsManagerSubscriptionCommand addSecretsManagerSubscriptionCommand,
IPushNotificationService pushNotificationService, IPushNotificationService pushNotificationService,
ICancelSubscriptionCommand cancelSubscriptionCommand,
ISubscriberQueries subscriberQueries,
IReferenceEventService referenceEventService,
IOrganizationEnableCollectionEnhancementsCommand organizationEnableCollectionEnhancementsCommand, IOrganizationEnableCollectionEnhancementsCommand organizationEnableCollectionEnhancementsCommand,
IProviderRepository providerRepository, IProviderRepository providerRepository,
IScaleSeatsCommand scaleSeatsCommand) IScaleSeatsCommand scaleSeatsCommand)
@ -107,7 +80,6 @@ public class OrganizationsController : Controller
_policyRepository = policyRepository; _policyRepository = policyRepository;
_organizationService = organizationService; _organizationService = organizationService;
_userService = userService; _userService = userService;
_paymentService = paymentService;
_currentContext = currentContext; _currentContext = currentContext;
_ssoConfigRepository = ssoConfigRepository; _ssoConfigRepository = ssoConfigRepository;
_ssoConfigService = ssoConfigService; _ssoConfigService = ssoConfigService;
@ -115,17 +87,9 @@ public class OrganizationsController : Controller
_rotateOrganizationApiKeyCommand = rotateOrganizationApiKeyCommand; _rotateOrganizationApiKeyCommand = rotateOrganizationApiKeyCommand;
_createOrganizationApiKeyCommand = createOrganizationApiKeyCommand; _createOrganizationApiKeyCommand = createOrganizationApiKeyCommand;
_organizationApiKeyRepository = organizationApiKeyRepository; _organizationApiKeyRepository = organizationApiKeyRepository;
_cloudGetOrganizationLicenseQuery = cloudGetOrganizationLicenseQuery;
_featureService = featureService; _featureService = featureService;
_globalSettings = globalSettings; _globalSettings = globalSettings;
_licensingService = licensingService;
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
_upgradeOrganizationPlanCommand = upgradeOrganizationPlanCommand;
_addSecretsManagerSubscriptionCommand = addSecretsManagerSubscriptionCommand;
_pushNotificationService = pushNotificationService; _pushNotificationService = pushNotificationService;
_cancelSubscriptionCommand = cancelSubscriptionCommand;
_subscriberQueries = subscriberQueries;
_referenceEventService = referenceEventService;
_organizationEnableCollectionEnhancementsCommand = organizationEnableCollectionEnhancementsCommand; _organizationEnableCollectionEnhancementsCommand = organizationEnableCollectionEnhancementsCommand;
_providerRepository = providerRepository; _providerRepository = providerRepository;
_scaleSeatsCommand = scaleSeatsCommand; _scaleSeatsCommand = scaleSeatsCommand;
@ -149,83 +113,6 @@ public class OrganizationsController : Controller
return new OrganizationResponseModel(organization); return new OrganizationResponseModel(organization);
} }
[HttpGet("{id}/billing")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<BillingResponseModel> GetBilling(string id)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.ViewBillingHistory(orgIdGuid))
{
throw new NotFoundException();
}
var organization = await _organizationRepository.GetByIdAsync(orgIdGuid);
if (organization == null)
{
throw new NotFoundException();
}
var billingInfo = await _paymentService.GetBillingAsync(organization);
return new BillingResponseModel(billingInfo);
}
[HttpGet("{id}/subscription")]
public async Task<OrganizationSubscriptionResponseModel> GetSubscription(string id)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.ViewSubscription(orgIdGuid))
{
throw new NotFoundException();
}
var organization = await _organizationRepository.GetByIdAsync(orgIdGuid);
if (organization == null)
{
throw new NotFoundException();
}
if (!_globalSettings.SelfHosted && organization.Gateway != null)
{
var subscriptionInfo = await _paymentService.GetSubscriptionAsync(organization);
if (subscriptionInfo == null)
{
throw new NotFoundException();
}
var hideSensitiveData = !await _currentContext.EditSubscription(orgIdGuid);
return new OrganizationSubscriptionResponseModel(organization, subscriptionInfo, hideSensitiveData);
}
if (_globalSettings.SelfHosted)
{
var orgLicense = await _licensingService.ReadOrganizationLicenseAsync(organization);
return new OrganizationSubscriptionResponseModel(organization, orgLicense);
}
return new OrganizationSubscriptionResponseModel(organization);
}
[HttpGet("{id}/license")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<OrganizationLicense> GetLicense(string id, [FromQuery] Guid installationId)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.OrganizationOwner(orgIdGuid))
{
throw new NotFoundException();
}
var org = await _organizationRepository.GetByIdAsync(new Guid(id));
var license = await _cloudGetOrganizationLicenseQuery.GetLicenseAsync(org, installationId);
if (license == null)
{
throw new NotFoundException();
}
return license;
}
[HttpGet("")] [HttpGet("")]
public async Task<ListResponseModel<ProfileOrganizationResponseModel>> GetUser() public async Task<ListResponseModel<ProfileOrganizationResponseModel>> GetUser()
{ {
@ -268,21 +155,6 @@ public class OrganizationsController : Controller
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false); return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false);
} }
[HttpGet("{id}/billing-status")]
public async Task<OrganizationBillingStatusResponseModel> GetBillingStatus(Guid id)
{
if (!await _currentContext.EditPaymentMethods(id))
{
throw new NotFoundException();
}
var organization = await _organizationRepository.GetByIdAsync(id);
var risksSubscriptionFailure = await _paymentService.RisksSubscriptionFailure(organization);
return new OrganizationBillingStatusResponseModel(organization, risksSubscriptionFailure);
}
[HttpPost("")] [HttpPost("")]
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public async Task<OrganizationResponseModel> Post([FromBody] OrganizationCreateRequestModel model) public async Task<OrganizationResponseModel> Post([FromBody] OrganizationCreateRequestModel model)
@ -326,124 +198,6 @@ public class OrganizationsController : Controller
return new OrganizationResponseModel(organization); return new OrganizationResponseModel(organization);
} }
[HttpPost("{id}/payment")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostPayment(string id, [FromBody] PaymentRequestModel model)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.EditPaymentMethods(orgIdGuid))
{
throw new NotFoundException();
}
await _organizationService.ReplacePaymentMethodAsync(orgIdGuid, model.PaymentToken,
model.PaymentMethodType.Value, new TaxInfo
{
BillingAddressLine1 = model.Line1,
BillingAddressLine2 = model.Line2,
BillingAddressState = model.State,
BillingAddressCity = model.City,
BillingAddressPostalCode = model.PostalCode,
BillingAddressCountry = model.Country,
TaxIdNumber = model.TaxId,
});
}
[HttpPost("{id}/upgrade")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<PaymentResponseModel> PostUpgrade(string id, [FromBody] OrganizationUpgradeRequestModel model)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.EditSubscription(orgIdGuid))
{
throw new NotFoundException();
}
var (success, paymentIntentClientSecret) = await _upgradeOrganizationPlanCommand.UpgradePlanAsync(orgIdGuid, model.ToOrganizationUpgrade());
if (model.UseSecretsManager && success)
{
var userId = _userService.GetProperUserId(User).Value;
await TryGrantOwnerAccessToSecretsManagerAsync(orgIdGuid, userId);
}
return new PaymentResponseModel { Success = success, PaymentIntentClientSecret = paymentIntentClientSecret };
}
[HttpPost("{id}/subscription")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostSubscription(string id, [FromBody] OrganizationSubscriptionUpdateRequestModel model)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.EditSubscription(orgIdGuid))
{
throw new NotFoundException();
}
await _organizationService.UpdateSubscription(orgIdGuid, model.SeatAdjustment, model.MaxAutoscaleSeats);
}
[HttpPost("{id}/sm-subscription")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostSmSubscription(Guid id, [FromBody] SecretsManagerSubscriptionUpdateRequestModel model)
{
var organization = await _organizationRepository.GetByIdAsync(id);
if (organization == null)
{
throw new NotFoundException();
}
if (!await _currentContext.EditSubscription(id))
{
throw new NotFoundException();
}
var organizationUpdate = model.ToSecretsManagerSubscriptionUpdate(organization);
await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(organizationUpdate);
}
[HttpPost("{id}/subscribe-secrets-manager")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<ProfileOrganizationResponseModel> PostSubscribeSecretsManagerAsync(Guid id, [FromBody] SecretsManagerSubscribeRequestModel model)
{
var organization = await _organizationRepository.GetByIdAsync(id);
if (organization == null)
{
throw new NotFoundException();
}
if (!await _currentContext.EditSubscription(id))
{
throw new NotFoundException();
}
await _addSecretsManagerSubscriptionCommand.SignUpAsync(organization, model.AdditionalSmSeats,
model.AdditionalServiceAccounts);
var userId = _userService.GetProperUserId(User).Value;
await TryGrantOwnerAccessToSecretsManagerAsync(organization.Id, userId);
var organizationDetails = await _organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id,
OrganizationUserStatusType.Confirmed);
return new ProfileOrganizationResponseModel(organizationDetails);
}
[HttpPost("{id}/seat")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<PaymentResponseModel> PostSeat(string id, [FromBody] OrganizationSeatRequestModel model)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.EditSubscription(orgIdGuid))
{
throw new NotFoundException();
}
var result = await _organizationService.AdjustSeatsAsync(orgIdGuid, model.SeatAdjustment.Value);
return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result };
}
[HttpPost("{id}/storage")] [HttpPost("{id}/storage")]
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public async Task<PaymentResponseModel> PostStorage(string id, [FromBody] StorageRequestModel model) public async Task<PaymentResponseModel> PostStorage(string id, [FromBody] StorageRequestModel model)
@ -458,67 +212,6 @@ public class OrganizationsController : Controller
return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result }; return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result };
} }
[HttpPost("{id}/verify-bank")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostVerifyBank(string id, [FromBody] OrganizationVerifyBankRequestModel model)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.EditSubscription(orgIdGuid))
{
throw new NotFoundException();
}
await _organizationService.VerifyBankAsync(orgIdGuid, model.Amount1.Value, model.Amount2.Value);
}
[HttpPost("{id}/cancel")]
public async Task PostCancel(Guid id, [FromBody] SubscriptionCancellationRequestModel request)
{
if (!await _currentContext.EditSubscription(id))
{
throw new NotFoundException();
}
var organization = await _organizationRepository.GetByIdAsync(id);
if (organization == null)
{
throw new NotFoundException();
}
var subscription = await _subscriberQueries.GetSubscriptionOrThrow(organization);
await _cancelSubscriptionCommand.CancelSubscription(subscription,
new OffboardingSurveyResponse
{
UserId = _currentContext.UserId!.Value,
Reason = request.Reason,
Feedback = request.Feedback
},
organization.IsExpired());
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(
ReferenceEventType.CancelSubscription,
organization,
_currentContext)
{
EndOfPeriod = organization.IsExpired()
});
}
[HttpPost("{id}/reinstate")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostReinstate(string id)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.EditSubscription(orgIdGuid))
{
throw new NotFoundException();
}
await _organizationService.ReinstateSubscriptionAsync(orgIdGuid);
}
[HttpPost("{id}/leave")] [HttpPost("{id}/leave")]
public async Task Leave(string id) public async Task Leave(string id)
{ {
@ -722,55 +415,6 @@ public class OrganizationsController : Controller
}; };
} }
[HttpGet("{id}/tax")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<TaxInfoResponseModel> GetTaxInfo(string id)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.OrganizationOwner(orgIdGuid))
{
throw new NotFoundException();
}
var organization = await _organizationRepository.GetByIdAsync(orgIdGuid);
if (organization == null)
{
throw new NotFoundException();
}
var taxInfo = await _paymentService.GetTaxInfoAsync(organization);
return new TaxInfoResponseModel(taxInfo);
}
[HttpPut("{id}/tax")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PutTaxInfo(string id, [FromBody] ExpandedTaxInfoUpdateRequestModel model)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.OrganizationOwner(orgIdGuid))
{
throw new NotFoundException();
}
var organization = await _organizationRepository.GetByIdAsync(orgIdGuid);
if (organization == null)
{
throw new NotFoundException();
}
var taxInfo = new TaxInfo
{
TaxIdNumber = model.TaxId,
BillingAddressLine1 = model.Line1,
BillingAddressLine2 = model.Line2,
BillingAddressCity = model.City,
BillingAddressState = model.State,
BillingAddressPostalCode = model.PostalCode,
BillingAddressCountry = model.Country,
};
await _paymentService.SaveTaxInfoAsync(organization, taxInfo);
}
[HttpGet("{id}/public-key")] [HttpGet("{id}/public-key")]
public async Task<OrganizationPublicKeyResponseModel> GetPublicKey(string id) public async Task<OrganizationPublicKeyResponseModel> GetPublicKey(string id)
{ {
@ -912,15 +556,4 @@ public class OrganizationsController : Controller
ou.Type is OrganizationUserType.Admin or OrganizationUserType.Owner) ou.Type is OrganizationUserType.Admin or OrganizationUserType.Owner)
.Select(ou => _pushNotificationService.PushSyncOrganizationsAsync(ou.UserId.Value))); .Select(ou => _pushNotificationService.PushSyncOrganizationsAsync(ou.UserId.Value)));
} }
private async Task TryGrantOwnerAccessToSecretsManagerAsync(Guid organizationId, Guid userId)
{
var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId, userId);
if (organizationUser != null)
{
organizationUser.AccessSecretsManager = true;
await _organizationUserRepository.ReplaceAsync(organizationUser);
}
}
} }

View File

@ -1,5 +1,11 @@
using Bit.Api.Billing.Models.Responses; using Bit.Api.Billing.Models.Responses;
using Bit.Api.Models.Response;
using Bit.Core.Billing.Queries; using Bit.Core.Billing.Queries;
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.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -8,7 +14,10 @@ namespace Bit.Api.Billing.Controllers;
[Route("organizations/{organizationId:guid}/billing")] [Route("organizations/{organizationId:guid}/billing")]
[Authorize("Application")] [Authorize("Application")]
public class OrganizationBillingController( public class OrganizationBillingController(
IOrganizationBillingQueries organizationBillingQueries) : Controller IOrganizationBillingQueries organizationBillingQueries,
ICurrentContext currentContext,
IOrganizationRepository organizationRepository,
IPaymentService paymentService) : Controller
{ {
[HttpGet("metadata")] [HttpGet("metadata")]
public async Task<IResult> GetMetadataAsync([FromRoute] Guid organizationId) public async Task<IResult> GetMetadataAsync([FromRoute] Guid organizationId)
@ -24,4 +33,23 @@ public class OrganizationBillingController(
return TypedResults.Ok(response); return TypedResults.Ok(response);
} }
[HttpGet]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<BillingResponseModel> GetBilling(Guid organizationId)
{
if (!await currentContext.ViewBillingHistory(organizationId))
{
throw new NotFoundException();
}
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
throw new NotFoundException();
}
var billingInfo = await paymentService.GetBillingAsync(organization);
return new BillingResponseModel(billingInfo);
}
} }

View File

@ -0,0 +1,385 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Api.Models.Request;
using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Queries;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Business;
using Bit.Core.Tools.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Billing.Controllers;
[Route("organizations")]
[Authorize("Application")]
public class OrganizationsController(
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService,
IUserService userService,
IPaymentService paymentService,
ICurrentContext currentContext,
ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery,
GlobalSettings globalSettings,
ILicensingService licensingService,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
IUpgradeOrganizationPlanCommand upgradeOrganizationPlanCommand,
IAddSecretsManagerSubscriptionCommand addSecretsManagerSubscriptionCommand,
ICancelSubscriptionCommand cancelSubscriptionCommand,
ISubscriberQueries subscriberQueries,
IReferenceEventService referenceEventService)
: Controller
{
[HttpGet("{id}/billing-status")]
public async Task<OrganizationBillingStatusResponseModel> GetBillingStatus(Guid id)
{
if (!await currentContext.EditPaymentMethods(id))
{
throw new NotFoundException();
}
var organization = await organizationRepository.GetByIdAsync(id);
var risksSubscriptionFailure = await paymentService.RisksSubscriptionFailure(organization);
return new OrganizationBillingStatusResponseModel(organization, risksSubscriptionFailure);
}
[HttpGet("{id:guid}/subscription")]
public async Task<OrganizationSubscriptionResponseModel> GetSubscription(Guid id)
{
if (!await currentContext.ViewSubscription(id))
{
throw new NotFoundException();
}
var organization = await organizationRepository.GetByIdAsync(id);
if (organization == null)
{
throw new NotFoundException();
}
if (!globalSettings.SelfHosted && organization.Gateway != null)
{
var subscriptionInfo = await paymentService.GetSubscriptionAsync(organization);
if (subscriptionInfo == null)
{
throw new NotFoundException();
}
var hideSensitiveData = !await currentContext.EditSubscription(id);
return new OrganizationSubscriptionResponseModel(organization, subscriptionInfo, hideSensitiveData);
}
if (globalSettings.SelfHosted)
{
var orgLicense = await licensingService.ReadOrganizationLicenseAsync(organization);
return new OrganizationSubscriptionResponseModel(organization, orgLicense);
}
return new OrganizationSubscriptionResponseModel(organization);
}
[HttpGet("{id:guid}/license")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<OrganizationLicense> GetLicense(Guid id, [FromQuery] Guid installationId)
{
if (!await currentContext.OrganizationOwner(id))
{
throw new NotFoundException();
}
var org = await organizationRepository.GetByIdAsync(id);
var license = await cloudGetOrganizationLicenseQuery.GetLicenseAsync(org, installationId);
if (license == null)
{
throw new NotFoundException();
}
return license;
}
[HttpPost("{id:guid}/payment")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostPayment(Guid id, [FromBody] PaymentRequestModel model)
{
if (!await currentContext.EditPaymentMethods(id))
{
throw new NotFoundException();
}
await organizationService.ReplacePaymentMethodAsync(id, model.PaymentToken,
model.PaymentMethodType.Value, new TaxInfo
{
BillingAddressLine1 = model.Line1,
BillingAddressLine2 = model.Line2,
BillingAddressState = model.State,
BillingAddressCity = model.City,
BillingAddressPostalCode = model.PostalCode,
BillingAddressCountry = model.Country,
TaxIdNumber = model.TaxId,
});
}
[HttpPost("{id:guid}/upgrade")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<PaymentResponseModel> PostUpgrade(Guid id, [FromBody] OrganizationUpgradeRequestModel model)
{
if (!await currentContext.EditSubscription(id))
{
throw new NotFoundException();
}
var (success, paymentIntentClientSecret) = await upgradeOrganizationPlanCommand.UpgradePlanAsync(id, model.ToOrganizationUpgrade());
if (model.UseSecretsManager && success)
{
var userId = userService.GetProperUserId(User).Value;
await TryGrantOwnerAccessToSecretsManagerAsync(id, userId);
}
return new PaymentResponseModel { Success = success, PaymentIntentClientSecret = paymentIntentClientSecret };
}
[HttpPost("{id}/sm-subscription")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostSmSubscription(Guid id, [FromBody] SecretsManagerSubscriptionUpdateRequestModel model)
{
var organization = await organizationRepository.GetByIdAsync(id);
if (organization == null)
{
throw new NotFoundException();
}
if (!await currentContext.EditSubscription(id))
{
throw new NotFoundException();
}
organization = await AdjustOrganizationSeatsForSmTrialAsync(id, organization, model);
var organizationUpdate = model.ToSecretsManagerSubscriptionUpdate(organization);
await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(organizationUpdate);
}
[HttpPost("{id:guid}/subscription")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostSubscription(Guid id, [FromBody] OrganizationSubscriptionUpdateRequestModel model)
{
if (!await currentContext.EditSubscription(id))
{
throw new NotFoundException();
}
await organizationService.UpdateSubscription(id, model.SeatAdjustment, model.MaxAutoscaleSeats);
}
[HttpPost("{id:guid}/subscribe-secrets-manager")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<ProfileOrganizationResponseModel> PostSubscribeSecretsManagerAsync(Guid id, [FromBody] SecretsManagerSubscribeRequestModel model)
{
var organization = await organizationRepository.GetByIdAsync(id);
if (organization == null)
{
throw new NotFoundException();
}
if (!await currentContext.EditSubscription(id))
{
throw new NotFoundException();
}
await addSecretsManagerSubscriptionCommand.SignUpAsync(organization, model.AdditionalSmSeats,
model.AdditionalServiceAccounts);
var userId = userService.GetProperUserId(User).Value;
await TryGrantOwnerAccessToSecretsManagerAsync(organization.Id, userId);
var organizationDetails = await organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id,
OrganizationUserStatusType.Confirmed);
return new ProfileOrganizationResponseModel(organizationDetails);
}
[HttpPost("{id:guid}/seat")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<PaymentResponseModel> PostSeat(Guid id, [FromBody] OrganizationSeatRequestModel model)
{
if (!await currentContext.EditSubscription(id))
{
throw new NotFoundException();
}
var result = await organizationService.AdjustSeatsAsync(id, model.SeatAdjustment.Value);
return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result };
}
[HttpPost("{id:guid}/verify-bank")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostVerifyBank(Guid id, [FromBody] OrganizationVerifyBankRequestModel model)
{
if (!await currentContext.EditSubscription(id))
{
throw new NotFoundException();
}
await organizationService.VerifyBankAsync(id, model.Amount1.Value, model.Amount2.Value);
}
[HttpPost("{id}/cancel")]
public async Task PostCancel(Guid id, [FromBody] SubscriptionCancellationRequestModel request)
{
if (!await currentContext.EditSubscription(id))
{
throw new NotFoundException();
}
var organization = await organizationRepository.GetByIdAsync(id);
if (organization == null)
{
throw new NotFoundException();
}
var subscription = await subscriberQueries.GetSubscriptionOrThrow(organization);
await cancelSubscriptionCommand.CancelSubscription(subscription,
new OffboardingSurveyResponse
{
UserId = currentContext.UserId!.Value,
Reason = request.Reason,
Feedback = request.Feedback
},
organization.IsExpired());
await referenceEventService.RaiseEventAsync(new ReferenceEvent(
ReferenceEventType.CancelSubscription,
organization,
currentContext)
{
EndOfPeriod = organization.IsExpired()
});
}
[HttpPost("{id:guid}/reinstate")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostReinstate(Guid id)
{
if (!await currentContext.EditSubscription(id))
{
throw new NotFoundException();
}
await organizationService.ReinstateSubscriptionAsync(id);
}
[HttpGet("{id:guid}/tax")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<TaxInfoResponseModel> GetTaxInfo(Guid id)
{
if (!await currentContext.OrganizationOwner(id))
{
throw new NotFoundException();
}
var organization = await organizationRepository.GetByIdAsync(id);
if (organization == null)
{
throw new NotFoundException();
}
var taxInfo = await paymentService.GetTaxInfoAsync(organization);
return new TaxInfoResponseModel(taxInfo);
}
[HttpPut("{id:guid}/tax")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PutTaxInfo(Guid id, [FromBody] ExpandedTaxInfoUpdateRequestModel model)
{
if (!await currentContext.OrganizationOwner(id))
{
throw new NotFoundException();
}
var organization = await organizationRepository.GetByIdAsync(id);
if (organization == null)
{
throw new NotFoundException();
}
var taxInfo = new TaxInfo
{
TaxIdNumber = model.TaxId,
BillingAddressLine1 = model.Line1,
BillingAddressLine2 = model.Line2,
BillingAddressCity = model.City,
BillingAddressState = model.State,
BillingAddressPostalCode = model.PostalCode,
BillingAddressCountry = model.Country,
};
await paymentService.SaveTaxInfoAsync(organization, taxInfo);
}
/// <summary>
/// Tries to grant owner access to the Secrets Manager for the organization
/// </summary>
/// <param name="organizationId"></param>
/// <param name="userId"></param>
private async Task TryGrantOwnerAccessToSecretsManagerAsync(Guid organizationId, Guid userId)
{
var organizationUser = await organizationUserRepository.GetByOrganizationAsync(organizationId, userId);
if (organizationUser != null)
{
organizationUser.AccessSecretsManager = true;
await organizationUserRepository.ReplaceAsync(organizationUser);
}
}
/// <summary>
/// Adjusts the organization seats for the Secrets Manager trial to match the new seat count for secrets manager
/// </summary>
/// <param name="id"></param>
/// <param name="organization"></param>
/// <param name="model"></param>
private async Task<Organization> AdjustOrganizationSeatsForSmTrialAsync(Guid id, Organization organization,
SecretsManagerSubscriptionUpdateRequestModel model)
{
if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId) ||
string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId) ||
model.SeatAdjustment == 0)
{
return organization;
}
var subscriptionInfo = await paymentService.GetSubscriptionAsync(organization);
if (subscriptionInfo?.CustomerDiscount?.Id != StripeConstants.CouponIDs.SecretsManagerStandalone)
{
return organization;
}
await organizationService.UpdateSubscription(id, model.SeatAdjustment, null);
return await organizationRepository.GetByIdAsync(id);
}
}

View File

@ -1,9 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using AutoFixture.Xunit2; using AutoFixture.Xunit2;
using Bit.Api.AdminConsole.Controllers; using Bit.Api.AdminConsole.Controllers;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.Models.Request.Organizations;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
@ -16,21 +14,14 @@ using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.Services; using Bit.Core.Auth.Services;
using Bit.Core.Billing.Commands; using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Queries;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Tools.Services;
using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider;
using NSubstitute; using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Xunit; using Xunit;
using GlobalSettings = Bit.Core.Settings.GlobalSettings; using GlobalSettings = Bit.Core.Settings.GlobalSettings;
@ -43,7 +34,6 @@ public class OrganizationsControllerTests : IDisposable
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationService _organizationService; private readonly IOrganizationService _organizationService;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IPaymentService _paymentService;
private readonly IPolicyRepository _policyRepository; private readonly IPolicyRepository _policyRepository;
private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly ISsoConfigService _ssoConfigService; private readonly ISsoConfigService _ssoConfigService;
@ -51,17 +41,9 @@ public class OrganizationsControllerTests : IDisposable
private readonly IGetOrganizationApiKeyQuery _getOrganizationApiKeyQuery; private readonly IGetOrganizationApiKeyQuery _getOrganizationApiKeyQuery;
private readonly IRotateOrganizationApiKeyCommand _rotateOrganizationApiKeyCommand; private readonly IRotateOrganizationApiKeyCommand _rotateOrganizationApiKeyCommand;
private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository;
private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery;
private readonly ICreateOrganizationApiKeyCommand _createOrganizationApiKeyCommand; private readonly ICreateOrganizationApiKeyCommand _createOrganizationApiKeyCommand;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly ILicensingService _licensingService;
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand;
private readonly IAddSecretsManagerSubscriptionCommand _addSecretsManagerSubscriptionCommand;
private readonly IPushNotificationService _pushNotificationService; private readonly IPushNotificationService _pushNotificationService;
private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand;
private readonly ISubscriberQueries _subscriberQueries;
private readonly IReferenceEventService _referenceEventService;
private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand; private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand;
private readonly IProviderRepository _providerRepository; private readonly IProviderRepository _providerRepository;
private readonly IScaleSeatsCommand _scaleSeatsCommand; private readonly IScaleSeatsCommand _scaleSeatsCommand;
@ -75,7 +57,6 @@ public class OrganizationsControllerTests : IDisposable
_organizationRepository = Substitute.For<IOrganizationRepository>(); _organizationRepository = Substitute.For<IOrganizationRepository>();
_organizationService = Substitute.For<IOrganizationService>(); _organizationService = Substitute.For<IOrganizationService>();
_organizationUserRepository = Substitute.For<IOrganizationUserRepository>(); _organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
_paymentService = Substitute.For<IPaymentService>();
_policyRepository = Substitute.For<IPolicyRepository>(); _policyRepository = Substitute.For<IPolicyRepository>();
_ssoConfigRepository = Substitute.For<ISsoConfigRepository>(); _ssoConfigRepository = Substitute.For<ISsoConfigRepository>();
_ssoConfigService = Substitute.For<ISsoConfigService>(); _ssoConfigService = Substitute.For<ISsoConfigService>();
@ -83,17 +64,9 @@ public class OrganizationsControllerTests : IDisposable
_rotateOrganizationApiKeyCommand = Substitute.For<IRotateOrganizationApiKeyCommand>(); _rotateOrganizationApiKeyCommand = Substitute.For<IRotateOrganizationApiKeyCommand>();
_organizationApiKeyRepository = Substitute.For<IOrganizationApiKeyRepository>(); _organizationApiKeyRepository = Substitute.For<IOrganizationApiKeyRepository>();
_userService = Substitute.For<IUserService>(); _userService = Substitute.For<IUserService>();
_cloudGetOrganizationLicenseQuery = Substitute.For<ICloudGetOrganizationLicenseQuery>();
_createOrganizationApiKeyCommand = Substitute.For<ICreateOrganizationApiKeyCommand>(); _createOrganizationApiKeyCommand = Substitute.For<ICreateOrganizationApiKeyCommand>();
_featureService = Substitute.For<IFeatureService>(); _featureService = Substitute.For<IFeatureService>();
_licensingService = Substitute.For<ILicensingService>();
_updateSecretsManagerSubscriptionCommand = Substitute.For<IUpdateSecretsManagerSubscriptionCommand>();
_upgradeOrganizationPlanCommand = Substitute.For<IUpgradeOrganizationPlanCommand>();
_addSecretsManagerSubscriptionCommand = Substitute.For<IAddSecretsManagerSubscriptionCommand>();
_pushNotificationService = Substitute.For<IPushNotificationService>(); _pushNotificationService = Substitute.For<IPushNotificationService>();
_cancelSubscriptionCommand = Substitute.For<ICancelSubscriptionCommand>();
_subscriberQueries = Substitute.For<ISubscriberQueries>();
_referenceEventService = Substitute.For<IReferenceEventService>();
_organizationEnableCollectionEnhancementsCommand = Substitute.For<IOrganizationEnableCollectionEnhancementsCommand>(); _organizationEnableCollectionEnhancementsCommand = Substitute.For<IOrganizationEnableCollectionEnhancementsCommand>();
_providerRepository = Substitute.For<IProviderRepository>(); _providerRepository = Substitute.For<IProviderRepository>();
_scaleSeatsCommand = Substitute.For<IScaleSeatsCommand>(); _scaleSeatsCommand = Substitute.For<IScaleSeatsCommand>();
@ -104,7 +77,6 @@ public class OrganizationsControllerTests : IDisposable
_policyRepository, _policyRepository,
_organizationService, _organizationService,
_userService, _userService,
_paymentService,
_currentContext, _currentContext,
_ssoConfigRepository, _ssoConfigRepository,
_ssoConfigService, _ssoConfigService,
@ -112,17 +84,9 @@ public class OrganizationsControllerTests : IDisposable
_rotateOrganizationApiKeyCommand, _rotateOrganizationApiKeyCommand,
_createOrganizationApiKeyCommand, _createOrganizationApiKeyCommand,
_organizationApiKeyRepository, _organizationApiKeyRepository,
_cloudGetOrganizationLicenseQuery,
_featureService, _featureService,
_globalSettings, _globalSettings,
_licensingService,
_updateSecretsManagerSubscriptionCommand,
_upgradeOrganizationPlanCommand,
_addSecretsManagerSubscriptionCommand,
_pushNotificationService, _pushNotificationService,
_cancelSubscriptionCommand,
_subscriberQueries,
_referenceEventService,
_organizationEnableCollectionEnhancementsCommand, _organizationEnableCollectionEnhancementsCommand,
_providerRepository, _providerRepository,
_scaleSeatsCommand); _scaleSeatsCommand);
@ -193,196 +157,6 @@ public class OrganizationsControllerTests : IDisposable
await _organizationService.Received(1).DeleteUserAsync(orgId, user.Id); await _organizationService.Received(1).DeleteUserAsync(orgId, user.Id);
} }
[Theory, AutoData]
public async Task OrganizationsController_PostUpgrade_UserCannotEditSubscription_ThrowsNotFoundException(
Guid organizationId,
OrganizationUpgradeRequestModel model)
{
_currentContext.EditSubscription(organizationId).Returns(false);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.PostUpgrade(organizationId.ToString(), model));
}
[Theory, AutoData]
public async Task OrganizationsController_PostUpgrade_NonSMUpgrade_ReturnsCorrectResponse(
Guid organizationId,
OrganizationUpgradeRequestModel model,
bool success,
string paymentIntentClientSecret)
{
model.UseSecretsManager = false;
_currentContext.EditSubscription(organizationId).Returns(true);
_upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any<OrganizationUpgrade>())
.Returns(new Tuple<bool, string>(success, paymentIntentClientSecret));
var response = await _sut.PostUpgrade(organizationId.ToString(), model);
Assert.Equal(success, response.Success);
Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret);
}
[Theory, AutoData]
public async Task OrganizationsController_PostUpgrade_SMUpgrade_ProvidesAccess_ReturnsCorrectResponse(
Guid organizationId,
Guid userId,
OrganizationUpgradeRequestModel model,
bool success,
string paymentIntentClientSecret,
OrganizationUser organizationUser)
{
model.UseSecretsManager = true;
organizationUser.AccessSecretsManager = false;
_currentContext.EditSubscription(organizationId).Returns(true);
_upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any<OrganizationUpgrade>())
.Returns(new Tuple<bool, string>(success, paymentIntentClientSecret));
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
_organizationUserRepository.GetByOrganizationAsync(organizationId, userId).Returns(organizationUser);
var response = await _sut.PostUpgrade(organizationId.ToString(), model);
Assert.Equal(success, response.Success);
Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret);
await _organizationUserRepository.Received(1).ReplaceAsync(Arg.Is<OrganizationUser>(orgUser =>
orgUser.Id == organizationUser.Id && orgUser.AccessSecretsManager == true));
}
[Theory, AutoData]
public async Task OrganizationsController_PostUpgrade_SMUpgrade_NullOrgUser_ReturnsCorrectResponse(
Guid organizationId,
Guid userId,
OrganizationUpgradeRequestModel model,
bool success,
string paymentIntentClientSecret)
{
model.UseSecretsManager = true;
_currentContext.EditSubscription(organizationId).Returns(true);
_upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any<OrganizationUpgrade>())
.Returns(new Tuple<bool, string>(success, paymentIntentClientSecret));
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
_organizationUserRepository.GetByOrganizationAsync(organizationId, userId).ReturnsNull();
var response = await _sut.PostUpgrade(organizationId.ToString(), model);
Assert.Equal(success, response.Success);
Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret);
await _organizationUserRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any<OrganizationUser>());
}
[Theory, AutoData]
public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_NullOrg_ThrowsNotFoundException(
Guid organizationId,
SecretsManagerSubscribeRequestModel model)
{
_organizationRepository.GetByIdAsync(organizationId).ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(() => _sut.PostSubscribeSecretsManagerAsync(organizationId, model));
}
[Theory, AutoData]
public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_UserCannotEditSubscription_ThrowsNotFoundException(
Guid organizationId,
SecretsManagerSubscribeRequestModel model,
Organization organization)
{
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_currentContext.EditSubscription(organizationId).Returns(false);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.PostSubscribeSecretsManagerAsync(organizationId, model));
}
[Theory, AutoData]
public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_ProvidesAccess_ReturnsCorrectResponse(
Guid organizationId,
SecretsManagerSubscribeRequestModel model,
Organization organization,
Guid userId,
OrganizationUser organizationUser,
OrganizationUserOrganizationDetails organizationUserOrganizationDetails)
{
organizationUser.AccessSecretsManager = false;
var ssoConfigurationData = new SsoConfigurationData
{
MemberDecryptionType = MemberDecryptionType.KeyConnector,
KeyConnectorUrl = "https://example.com"
};
organizationUserOrganizationDetails.Permissions = string.Empty;
organizationUserOrganizationDetails.SsoConfig = ssoConfigurationData.Serialize();
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_currentContext.EditSubscription(organizationId).Returns(true);
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
_organizationUserRepository.GetByOrganizationAsync(organization.Id, userId).Returns(organizationUser);
_organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, OrganizationUserStatusType.Confirmed)
.Returns(organizationUserOrganizationDetails);
var response = await _sut.PostSubscribeSecretsManagerAsync(organizationId, model);
Assert.Equal(response.Id, organizationUserOrganizationDetails.OrganizationId);
Assert.Equal(response.Name, organizationUserOrganizationDetails.Name);
await _addSecretsManagerSubscriptionCommand.Received(1)
.SignUpAsync(organization, model.AdditionalSmSeats, model.AdditionalServiceAccounts);
await _organizationUserRepository.Received(1).ReplaceAsync(Arg.Is<OrganizationUser>(orgUser =>
orgUser.Id == organizationUser.Id && orgUser.AccessSecretsManager == true));
}
[Theory, AutoData]
public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_NullOrgUser_ReturnsCorrectResponse(
Guid organizationId,
SecretsManagerSubscribeRequestModel model,
Organization organization,
Guid userId,
OrganizationUserOrganizationDetails organizationUserOrganizationDetails)
{
var ssoConfigurationData = new SsoConfigurationData
{
MemberDecryptionType = MemberDecryptionType.KeyConnector,
KeyConnectorUrl = "https://example.com"
};
organizationUserOrganizationDetails.Permissions = string.Empty;
organizationUserOrganizationDetails.SsoConfig = ssoConfigurationData.Serialize();
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_currentContext.EditSubscription(organizationId).Returns(true);
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
_organizationUserRepository.GetByOrganizationAsync(organization.Id, userId).ReturnsNull();
_organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, OrganizationUserStatusType.Confirmed)
.Returns(organizationUserOrganizationDetails);
var response = await _sut.PostSubscribeSecretsManagerAsync(organizationId, model);
Assert.Equal(response.Id, organizationUserOrganizationDetails.OrganizationId);
Assert.Equal(response.Name, organizationUserOrganizationDetails.Name);
await _addSecretsManagerSubscriptionCommand.Received(1)
.SignUpAsync(organization, model.AdditionalSmSeats, model.AdditionalServiceAccounts);
await _organizationUserRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any<OrganizationUser>());
}
[Theory, AutoData] [Theory, AutoData]
public async Task EnableCollectionEnhancements_Success(Organization organization) public async Task EnableCollectionEnhancements_Success(Organization organization)
{ {

View File

@ -0,0 +1,317 @@
using System.Security.Claims;
using AutoFixture.Xunit2;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Billing.Controllers;
using Bit.Api.Models.Request.Organizations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.Services;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Queries;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Tools.Services;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Xunit;
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
namespace Bit.Api.Test.Billing.Controllers;
public class OrganizationsControllerTests : IDisposable
{
private readonly GlobalSettings _globalSettings;
private readonly ICurrentContext _currentContext;
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationService _organizationService;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IPaymentService _paymentService;
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly IUserService _userService;
private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery;
private readonly ILicensingService _licensingService;
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand;
private readonly IAddSecretsManagerSubscriptionCommand _addSecretsManagerSubscriptionCommand;
private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand;
private readonly ISubscriberQueries _subscriberQueries;
private readonly IReferenceEventService _referenceEventService;
private readonly OrganizationsController _sut;
public OrganizationsControllerTests()
{
_currentContext = Substitute.For<ICurrentContext>();
_globalSettings = Substitute.For<GlobalSettings>();
_organizationRepository = Substitute.For<IOrganizationRepository>();
_organizationService = Substitute.For<IOrganizationService>();
_organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
_paymentService = Substitute.For<IPaymentService>();
Substitute.For<IPolicyRepository>();
_ssoConfigRepository = Substitute.For<ISsoConfigRepository>();
Substitute.For<ISsoConfigService>();
_userService = Substitute.For<IUserService>();
_cloudGetOrganizationLicenseQuery = Substitute.For<ICloudGetOrganizationLicenseQuery>();
_licensingService = Substitute.For<ILicensingService>();
_updateSecretsManagerSubscriptionCommand = Substitute.For<IUpdateSecretsManagerSubscriptionCommand>();
_upgradeOrganizationPlanCommand = Substitute.For<IUpgradeOrganizationPlanCommand>();
_addSecretsManagerSubscriptionCommand = Substitute.For<IAddSecretsManagerSubscriptionCommand>();
_cancelSubscriptionCommand = Substitute.For<ICancelSubscriptionCommand>();
_subscriberQueries = Substitute.For<ISubscriberQueries>();
_referenceEventService = Substitute.For<IReferenceEventService>();
_sut = new OrganizationsController(
_organizationRepository,
_organizationUserRepository,
_organizationService,
_userService,
_paymentService,
_currentContext,
_cloudGetOrganizationLicenseQuery,
_globalSettings,
_licensingService,
_updateSecretsManagerSubscriptionCommand,
_upgradeOrganizationPlanCommand,
_addSecretsManagerSubscriptionCommand,
_cancelSubscriptionCommand,
_subscriberQueries,
_referenceEventService);
}
public void Dispose()
{
_sut?.Dispose();
}
[Theory]
[InlineAutoData(true, false)]
[InlineAutoData(false, true)]
[InlineAutoData(false, false)]
public async Task OrganizationsController_UserCanLeaveOrganizationThatDoesntProvideKeyConnector(
bool keyConnectorEnabled, bool userUsesKeyConnector, Guid orgId, User user)
{
var ssoConfig = new SsoConfig
{
Id = default,
Data = new SsoConfigurationData
{
MemberDecryptionType = keyConnectorEnabled
? MemberDecryptionType.KeyConnector
: MemberDecryptionType.MasterPassword
}.Serialize(),
Enabled = true,
OrganizationId = orgId,
};
user.UsesKeyConnector = userUsesKeyConnector;
_currentContext.OrganizationUser(orgId).Returns(true);
_ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig);
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
await _organizationService.DeleteUserAsync(orgId, user.Id);
await _organizationService.Received(1).DeleteUserAsync(orgId, user.Id);
}
[Theory, AutoData]
public async Task OrganizationsController_PostUpgrade_UserCannotEditSubscription_ThrowsNotFoundException(
Guid organizationId,
OrganizationUpgradeRequestModel model)
{
_currentContext.EditSubscription(organizationId).Returns(false);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.PostUpgrade(organizationId, model));
}
[Theory, AutoData]
public async Task OrganizationsController_PostUpgrade_NonSMUpgrade_ReturnsCorrectResponse(
Guid organizationId,
OrganizationUpgradeRequestModel model,
bool success,
string paymentIntentClientSecret)
{
model.UseSecretsManager = false;
_currentContext.EditSubscription(organizationId).Returns(true);
_upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any<OrganizationUpgrade>())
.Returns(new Tuple<bool, string>(success, paymentIntentClientSecret));
var response = await _sut.PostUpgrade(organizationId, model);
Assert.Equal(success, response.Success);
Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret);
}
[Theory, AutoData]
public async Task OrganizationsController_PostUpgrade_SMUpgrade_ProvidesAccess_ReturnsCorrectResponse(
Guid organizationId,
Guid userId,
OrganizationUpgradeRequestModel model,
bool success,
string paymentIntentClientSecret,
OrganizationUser organizationUser)
{
model.UseSecretsManager = true;
organizationUser.AccessSecretsManager = false;
_currentContext.EditSubscription(organizationId).Returns(true);
_upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any<OrganizationUpgrade>())
.Returns(new Tuple<bool, string>(success, paymentIntentClientSecret));
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
_organizationUserRepository.GetByOrganizationAsync(organizationId, userId).Returns(organizationUser);
var response = await _sut.PostUpgrade(organizationId, model);
Assert.Equal(success, response.Success);
Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret);
await _organizationUserRepository.Received(1).ReplaceAsync(Arg.Is<OrganizationUser>(orgUser =>
orgUser.Id == organizationUser.Id && orgUser.AccessSecretsManager == true));
}
[Theory, AutoData]
public async Task OrganizationsController_PostUpgrade_SMUpgrade_NullOrgUser_ReturnsCorrectResponse(
Guid organizationId,
Guid userId,
OrganizationUpgradeRequestModel model,
bool success,
string paymentIntentClientSecret)
{
model.UseSecretsManager = true;
_currentContext.EditSubscription(organizationId).Returns(true);
_upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any<OrganizationUpgrade>())
.Returns(new Tuple<bool, string>(success, paymentIntentClientSecret));
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
_organizationUserRepository.GetByOrganizationAsync(organizationId, userId).ReturnsNull();
var response = await _sut.PostUpgrade(organizationId, model);
Assert.Equal(success, response.Success);
Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret);
await _organizationUserRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any<OrganizationUser>());
}
[Theory, AutoData]
public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_NullOrg_ThrowsNotFoundException(
Guid organizationId,
SecretsManagerSubscribeRequestModel model)
{
_organizationRepository.GetByIdAsync(organizationId).ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(() => _sut.PostSubscribeSecretsManagerAsync(organizationId, model));
}
[Theory, AutoData]
public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_UserCannotEditSubscription_ThrowsNotFoundException(
Guid organizationId,
SecretsManagerSubscribeRequestModel model,
Organization organization)
{
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_currentContext.EditSubscription(organizationId).Returns(false);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.PostSubscribeSecretsManagerAsync(organizationId, model));
}
[Theory, AutoData]
public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_ProvidesAccess_ReturnsCorrectResponse(
Guid organizationId,
SecretsManagerSubscribeRequestModel model,
Organization organization,
Guid userId,
OrganizationUser organizationUser,
OrganizationUserOrganizationDetails organizationUserOrganizationDetails)
{
organizationUser.AccessSecretsManager = false;
var ssoConfigurationData = new SsoConfigurationData
{
MemberDecryptionType = MemberDecryptionType.KeyConnector,
KeyConnectorUrl = "https://example.com"
};
organizationUserOrganizationDetails.Permissions = string.Empty;
organizationUserOrganizationDetails.SsoConfig = ssoConfigurationData.Serialize();
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_currentContext.EditSubscription(organizationId).Returns(true);
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
_organizationUserRepository.GetByOrganizationAsync(organization.Id, userId).Returns(organizationUser);
_organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, OrganizationUserStatusType.Confirmed)
.Returns(organizationUserOrganizationDetails);
var response = await _sut.PostSubscribeSecretsManagerAsync(organizationId, model);
Assert.Equal(response.Id, organizationUserOrganizationDetails.OrganizationId);
Assert.Equal(response.Name, organizationUserOrganizationDetails.Name);
await _addSecretsManagerSubscriptionCommand.Received(1)
.SignUpAsync(organization, model.AdditionalSmSeats, model.AdditionalServiceAccounts);
await _organizationUserRepository.Received(1).ReplaceAsync(Arg.Is<OrganizationUser>(orgUser =>
orgUser.Id == organizationUser.Id && orgUser.AccessSecretsManager == true));
}
[Theory, AutoData]
public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_NullOrgUser_ReturnsCorrectResponse(
Guid organizationId,
SecretsManagerSubscribeRequestModel model,
Organization organization,
Guid userId,
OrganizationUserOrganizationDetails organizationUserOrganizationDetails)
{
var ssoConfigurationData = new SsoConfigurationData
{
MemberDecryptionType = MemberDecryptionType.KeyConnector,
KeyConnectorUrl = "https://example.com"
};
organizationUserOrganizationDetails.Permissions = string.Empty;
organizationUserOrganizationDetails.SsoConfig = ssoConfigurationData.Serialize();
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_currentContext.EditSubscription(organizationId).Returns(true);
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
_organizationUserRepository.GetByOrganizationAsync(organization.Id, userId).ReturnsNull();
_organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, OrganizationUserStatusType.Confirmed)
.Returns(organizationUserOrganizationDetails);
var response = await _sut.PostSubscribeSecretsManagerAsync(organizationId, model);
Assert.Equal(response.Id, organizationUserOrganizationDetails.OrganizationId);
Assert.Equal(response.Name, organizationUserOrganizationDetails.Name);
await _addSecretsManagerSubscriptionCommand.Received(1)
.SignUpAsync(organization, model.AdditionalSmSeats, model.AdditionalServiceAccounts);
await _organizationUserRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any<OrganizationUser>());
}
}