mirror of
https://github.com/bitwarden/server.git
synced 2025-05-06 04:02:15 -05:00
Merge branch 'main' into ac/pm-14613/feature-flag-removal---step-1-remove-flagged-logic-from-clients/server-and-clients-feature-flag
This commit is contained in:
commit
ff53decd60
13
.github/workflows/code-references.yml
vendored
13
.github/workflows/code-references.yml
vendored
@ -1,7 +1,10 @@
|
|||||||
name: Collect code references
|
name: Collect code references
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
push:
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-ld-secret:
|
check-ld-secret:
|
||||||
@ -37,12 +40,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Collect
|
- name: Collect
|
||||||
id: collect
|
id: collect
|
||||||
uses: launchdarkly/find-code-references-in-pull-request@30f4c4ab2949bbf258b797ced2fbf6dea34df9ce # v2.1.0
|
uses: launchdarkly/find-code-references@e3e9da201b87ada54eb4c550c14fb783385c5c8a # v2.13.0
|
||||||
with:
|
with:
|
||||||
project-key: default
|
accessToken: ${{ secrets.LD_ACCESS_TOKEN }}
|
||||||
environment-key: dev
|
projKey: default
|
||||||
access-token: ${{ secrets.LD_ACCESS_TOKEN }}
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Add label
|
- name: Add label
|
||||||
if: steps.collect.outputs.any-changed == 'true'
|
if: steps.collect.outputs.any-changed == 'true'
|
||||||
|
@ -110,9 +110,14 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
|||||||
IEnumerable<string> organizationOwnerEmails)
|
IEnumerable<string> organizationOwnerEmails)
|
||||||
{
|
{
|
||||||
if (provider.IsBillable() &&
|
if (provider.IsBillable() &&
|
||||||
organization.IsValidClient() &&
|
organization.IsValidClient())
|
||||||
!string.IsNullOrEmpty(organization.GatewayCustomerId))
|
|
||||||
{
|
{
|
||||||
|
// An organization converted to a business unit will not have a Customer since it was given to the business unit.
|
||||||
|
if (string.IsNullOrEmpty(organization.GatewayCustomerId))
|
||||||
|
{
|
||||||
|
await _providerBillingService.CreateCustomerForClientOrganization(provider, organization);
|
||||||
|
}
|
||||||
|
|
||||||
var customer = await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
|
var customer = await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
|
||||||
{
|
{
|
||||||
Description = string.Empty,
|
Description = string.Empty,
|
||||||
|
@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
|||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
@ -82,7 +83,7 @@ public class ProviderService : IProviderService
|
|||||||
_pricingClient = pricingClient;
|
_pricingClient = pricingClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo = null)
|
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null)
|
||||||
{
|
{
|
||||||
var owner = await _userService.GetUserByIdAsync(ownerUserId);
|
var owner = await _userService.GetUserByIdAsync(ownerUserId);
|
||||||
if (owner == null)
|
if (owner == null)
|
||||||
@ -111,7 +112,20 @@ public class ProviderService : IProviderService
|
|||||||
{
|
{
|
||||||
throw new BadRequestException("Both address and postal code are required to set up your provider.");
|
throw new BadRequestException("Both address and postal code are required to set up your provider.");
|
||||||
}
|
}
|
||||||
var customer = await _providerBillingService.SetupCustomer(provider, taxInfo);
|
|
||||||
|
var requireProviderPaymentMethodDuringSetup =
|
||||||
|
_featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
|
||||||
|
|
||||||
|
if (requireProviderPaymentMethodDuringSetup && tokenizedPaymentSource is not
|
||||||
|
{
|
||||||
|
Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
|
||||||
|
Token: not null and not ""
|
||||||
|
})
|
||||||
|
{
|
||||||
|
throw new BadRequestException("A payment method is required to set up your provider.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var customer = await _providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
|
||||||
provider.GatewayCustomerId = customer.Id;
|
provider.GatewayCustomerId = customer.Id;
|
||||||
var subscription = await _providerBillingService.SetupSubscription(provider);
|
var subscription = await _providerBillingService.SetupSubscription(provider);
|
||||||
provider.GatewaySubscriptionId = subscription.Id;
|
provider.GatewaySubscriptionId = subscription.Id;
|
||||||
|
@ -6,9 +6,11 @@ using Bit.Core.AdminConsole.Entities.Provider;
|
|||||||
using Bit.Core.AdminConsole.Enums.Provider;
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Billing;
|
using Bit.Core.Billing;
|
||||||
|
using Bit.Core.Billing.Caches;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
using Bit.Core.Billing.Entities;
|
using Bit.Core.Billing.Entities;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Bit.Core.Billing.Extensions;
|
||||||
using Bit.Core.Billing.Models;
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
using Bit.Core.Billing.Repositories;
|
using Bit.Core.Billing.Repositories;
|
||||||
@ -21,15 +23,20 @@ using Bit.Core.Models.Business;
|
|||||||
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.Utilities;
|
using Braintree;
|
||||||
using CsvHelper;
|
using CsvHelper;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
|
|
||||||
|
using static Bit.Core.Billing.Utilities;
|
||||||
|
using Customer = Stripe.Customer;
|
||||||
|
using Subscription = Stripe.Subscription;
|
||||||
|
|
||||||
namespace Bit.Commercial.Core.Billing;
|
namespace Bit.Commercial.Core.Billing;
|
||||||
|
|
||||||
public class ProviderBillingService(
|
public class ProviderBillingService(
|
||||||
|
IBraintreeGateway braintreeGateway,
|
||||||
IEventService eventService,
|
IEventService eventService,
|
||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
IGlobalSettings globalSettings,
|
IGlobalSettings globalSettings,
|
||||||
@ -40,13 +47,13 @@ public class ProviderBillingService(
|
|||||||
IProviderOrganizationRepository providerOrganizationRepository,
|
IProviderOrganizationRepository providerOrganizationRepository,
|
||||||
IProviderPlanRepository providerPlanRepository,
|
IProviderPlanRepository providerPlanRepository,
|
||||||
IProviderUserRepository providerUserRepository,
|
IProviderUserRepository providerUserRepository,
|
||||||
|
ISetupIntentCache setupIntentCache,
|
||||||
IStripeAdapter stripeAdapter,
|
IStripeAdapter stripeAdapter,
|
||||||
ISubscriberService subscriberService,
|
ISubscriberService subscriberService,
|
||||||
ITaxService taxService,
|
ITaxService taxService,
|
||||||
[FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy)
|
[FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy)
|
||||||
: IProviderBillingService
|
: IProviderBillingService
|
||||||
{
|
{
|
||||||
[RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
|
|
||||||
public async Task AddExistingOrganization(
|
public async Task AddExistingOrganization(
|
||||||
Provider provider,
|
Provider provider,
|
||||||
Organization organization,
|
Organization organization,
|
||||||
@ -312,7 +319,6 @@ public class ProviderBillingService(
|
|||||||
return memoryStream.ToArray();
|
return memoryStream.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
|
|
||||||
public async Task<IEnumerable<AddableOrganization>> GetAddableOrganizations(
|
public async Task<IEnumerable<AddableOrganization>> GetAddableOrganizations(
|
||||||
Provider provider,
|
Provider provider,
|
||||||
Guid userId)
|
Guid userId)
|
||||||
@ -466,7 +472,8 @@ public class ProviderBillingService(
|
|||||||
|
|
||||||
public async Task<Customer> SetupCustomer(
|
public async Task<Customer> SetupCustomer(
|
||||||
Provider provider,
|
Provider provider,
|
||||||
TaxInfo taxInfo)
|
TaxInfo taxInfo,
|
||||||
|
TokenizedPaymentSource tokenizedPaymentSource = null)
|
||||||
{
|
{
|
||||||
if (taxInfo is not
|
if (taxInfo is not
|
||||||
{
|
{
|
||||||
@ -535,13 +542,97 @@ public class ProviderBillingService(
|
|||||||
options.Coupon = provider.DiscountId;
|
options.Coupon = provider.DiscountId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var requireProviderPaymentMethodDuringSetup =
|
||||||
|
featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
|
||||||
|
|
||||||
|
var braintreeCustomerId = "";
|
||||||
|
|
||||||
|
if (requireProviderPaymentMethodDuringSetup)
|
||||||
|
{
|
||||||
|
if (tokenizedPaymentSource is not
|
||||||
|
{
|
||||||
|
Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
|
||||||
|
Token: not null and not ""
|
||||||
|
})
|
||||||
|
{
|
||||||
|
logger.LogError("Cannot create customer for provider ({ProviderID}) without a payment method", provider.Id);
|
||||||
|
throw new BillingException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var (type, token) = tokenizedPaymentSource;
|
||||||
|
|
||||||
|
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case PaymentMethodType.BankAccount:
|
||||||
|
{
|
||||||
|
var setupIntent =
|
||||||
|
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token }))
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (setupIntent == null)
|
||||||
|
{
|
||||||
|
logger.LogError("Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account", provider.Id);
|
||||||
|
throw new BillingException();
|
||||||
|
}
|
||||||
|
|
||||||
|
await setupIntentCache.Set(provider.Id, setupIntent.Id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PaymentMethodType.Card:
|
||||||
|
{
|
||||||
|
options.PaymentMethod = token;
|
||||||
|
options.InvoiceSettings.DefaultPaymentMethod = token;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PaymentMethodType.PayPal:
|
||||||
|
{
|
||||||
|
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, token);
|
||||||
|
options.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await stripeAdapter.CustomerCreateAsync(options);
|
return await stripeAdapter.CustomerCreateAsync(options);
|
||||||
}
|
}
|
||||||
catch (StripeException stripeException) when (stripeException.StripeError?.Code == StripeConstants.ErrorCodes.TaxIdInvalid)
|
catch (StripeException stripeException) when (stripeException.StripeError?.Code ==
|
||||||
|
StripeConstants.ErrorCodes.TaxIdInvalid)
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.");
|
await Revert();
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await Revert();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Revert()
|
||||||
|
{
|
||||||
|
if (requireProviderPaymentMethodDuringSetup && tokenizedPaymentSource != null)
|
||||||
|
{
|
||||||
|
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
|
||||||
|
switch (tokenizedPaymentSource.Type)
|
||||||
|
{
|
||||||
|
case PaymentMethodType.BankAccount:
|
||||||
|
{
|
||||||
|
var setupIntentId = await setupIntentCache.Get(provider.Id);
|
||||||
|
await stripeAdapter.SetupIntentCancel(setupIntentId,
|
||||||
|
new SetupIntentCancelOptions { CancellationReason = "abandoned" });
|
||||||
|
await setupIntentCache.Remove(provider.Id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
|
||||||
|
{
|
||||||
|
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -583,18 +674,38 @@ public class ProviderBillingService(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var requireProviderPaymentMethodDuringSetup =
|
||||||
|
featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
|
||||||
|
|
||||||
|
var setupIntentId = await setupIntentCache.Get(provider.Id);
|
||||||
|
|
||||||
|
var setupIntent = !string.IsNullOrEmpty(setupIntentId)
|
||||||
|
? await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions
|
||||||
|
{
|
||||||
|
Expand = ["payment_method"]
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var usePaymentMethod =
|
||||||
|
requireProviderPaymentMethodDuringSetup &&
|
||||||
|
(!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) ||
|
||||||
|
customer.Metadata.ContainsKey(BraintreeCustomerIdKey) ||
|
||||||
|
setupIntent.IsUnverifiedBankAccount());
|
||||||
|
|
||||||
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
||||||
{
|
{
|
||||||
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
CollectionMethod = usePaymentMethod ?
|
||||||
|
StripeConstants.CollectionMethod.ChargeAutomatically : StripeConstants.CollectionMethod.SendInvoice,
|
||||||
Customer = customer.Id,
|
Customer = customer.Id,
|
||||||
DaysUntilDue = 30,
|
DaysUntilDue = usePaymentMethod ? null : 30,
|
||||||
Items = subscriptionItemOptionsList,
|
Items = subscriptionItemOptionsList,
|
||||||
Metadata = new Dictionary<string, string>
|
Metadata = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
{ "providerId", provider.Id.ToString() }
|
{ "providerId", provider.Id.ToString() }
|
||||||
},
|
},
|
||||||
OffSession = true,
|
OffSession = true,
|
||||||
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations
|
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
|
||||||
|
TrialPeriodDays = usePaymentMethod ? 14 : null
|
||||||
};
|
};
|
||||||
|
|
||||||
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
|
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
|
||||||
@ -610,7 +721,10 @@ public class ProviderBillingService(
|
|||||||
{
|
{
|
||||||
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||||
|
|
||||||
if (subscription.Status == StripeConstants.SubscriptionStatus.Active)
|
if (subscription is
|
||||||
|
{
|
||||||
|
Status: StripeConstants.SubscriptionStatus.Active or StripeConstants.SubscriptionStatus.Trialing
|
||||||
|
})
|
||||||
{
|
{
|
||||||
return subscription;
|
return subscription;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
using Bit.Commercial.Core.AdminConsole.Services;
|
using Bit.Commercial.Core.AdminConsole.Services;
|
||||||
using Bit.Commercial.Core.Test.AdminConsole.AutoFixture;
|
using Bit.Commercial.Core.Test.AdminConsole.AutoFixture;
|
||||||
|
using Bit.Core;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Enums.Provider;
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
@ -7,6 +8,7 @@ using Bit.Core.AdminConsole.Models.Business.Provider;
|
|||||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
@ -38,7 +40,7 @@ public class ProviderServiceTests
|
|||||||
public async Task CompleteSetupAsync_UserIdIsInvalid_Throws(SutProvider<ProviderService> sutProvider)
|
public async Task CompleteSetupAsync_UserIdIsInvalid_Throws(SutProvider<ProviderService> sutProvider)
|
||||||
{
|
{
|
||||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||||
() => sutProvider.Sut.CompleteSetupAsync(default, default, default, default));
|
() => sutProvider.Sut.CompleteSetupAsync(default, default, default, default, null));
|
||||||
Assert.Contains("Invalid owner.", exception.Message);
|
Assert.Contains("Invalid owner.", exception.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,12 +52,85 @@ public class ProviderServiceTests
|
|||||||
userService.GetUserByIdAsync(user.Id).Returns(user);
|
userService.GetUserByIdAsync(user.Id).Returns(user);
|
||||||
|
|
||||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||||
() => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default));
|
() => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default, null));
|
||||||
Assert.Contains("Invalid token.", exception.Message);
|
Assert.Contains("Invalid token.", exception.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo,
|
public async Task CompleteSetupAsync_InvalidTaxInfo_ThrowsBadRequestException(
|
||||||
|
User user,
|
||||||
|
Provider provider,
|
||||||
|
string key,
|
||||||
|
TaxInfo taxInfo,
|
||||||
|
TokenizedPaymentSource tokenizedPaymentSource,
|
||||||
|
[ProviderUser] 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)}");
|
||||||
|
|
||||||
|
taxInfo.BillingAddressCountry = null;
|
||||||
|
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||||
|
sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource));
|
||||||
|
|
||||||
|
Assert.Equal("Both address and postal code are required to set up your provider.", exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task CompleteSetupAsync_InvalidTokenizedPaymentSource_ThrowsBadRequestException(
|
||||||
|
User user,
|
||||||
|
Provider provider,
|
||||||
|
string key,
|
||||||
|
TaxInfo taxInfo,
|
||||||
|
TokenizedPaymentSource tokenizedPaymentSource,
|
||||||
|
[ProviderUser] 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)}");
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||||
|
|
||||||
|
tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay };
|
||||||
|
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||||
|
sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource));
|
||||||
|
|
||||||
|
Assert.Equal("A payment method is required to set up your provider.", exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource,
|
||||||
[ProviderUser] ProviderUser providerUser,
|
[ProviderUser] ProviderUser providerUser,
|
||||||
SutProvider<ProviderService> sutProvider)
|
SutProvider<ProviderService> sutProvider)
|
||||||
{
|
{
|
||||||
@ -75,7 +150,7 @@ public class ProviderServiceTests
|
|||||||
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
|
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
|
||||||
|
|
||||||
var customer = new Customer { Id = "customer_id" };
|
var customer = new Customer { Id = "customer_id" };
|
||||||
providerBillingService.SetupCustomer(provider, taxInfo).Returns(customer);
|
providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource).Returns(customer);
|
||||||
|
|
||||||
var subscription = new Subscription { Id = "subscription_id" };
|
var subscription = new Subscription { Id = "subscription_id" };
|
||||||
providerBillingService.SetupSubscription(provider).Returns(subscription);
|
providerBillingService.SetupSubscription(provider).Returns(subscription);
|
||||||
@ -84,7 +159,7 @@ public class ProviderServiceTests
|
|||||||
|
|
||||||
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||||
|
|
||||||
await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo);
|
await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource);
|
||||||
|
|
||||||
await sutProvider.GetDependency<IProviderRepository>().Received().UpsertAsync(Arg.Is<Provider>(
|
await sutProvider.GetDependency<IProviderRepository>().Received().UpsertAsync(Arg.Is<Provider>(
|
||||||
p =>
|
p =>
|
||||||
|
@ -2,14 +2,17 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using Bit.Commercial.Core.Billing;
|
using Bit.Commercial.Core.Billing;
|
||||||
using Bit.Commercial.Core.Billing.Models;
|
using Bit.Commercial.Core.Billing.Models;
|
||||||
|
using Bit.Core;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Enums.Provider;
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.Billing.Caches;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
using Bit.Core.Billing.Entities;
|
using Bit.Core.Billing.Entities;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
using Bit.Core.Billing.Repositories;
|
using Bit.Core.Billing.Repositories;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
@ -24,11 +27,17 @@ using Bit.Core.Settings;
|
|||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Bit.Test.Common.AutoFixture;
|
using Bit.Test.Common.AutoFixture;
|
||||||
using Bit.Test.Common.AutoFixture.Attributes;
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using Braintree;
|
||||||
using CsvHelper;
|
using CsvHelper;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
|
using NSubstitute.ExceptionExtensions;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using static Bit.Core.Test.Billing.Utilities;
|
using static Bit.Core.Test.Billing.Utilities;
|
||||||
|
using Address = Stripe.Address;
|
||||||
|
using Customer = Stripe.Customer;
|
||||||
|
using PaymentMethod = Stripe.PaymentMethod;
|
||||||
|
using Subscription = Stripe.Subscription;
|
||||||
|
|
||||||
namespace Bit.Commercial.Core.Test.Billing;
|
namespace Bit.Commercial.Core.Test.Billing;
|
||||||
|
|
||||||
@ -833,7 +842,7 @@ public class ProviderBillingServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task SetupCustomer_Success(
|
public async Task SetupCustomer_NoPaymentMethod_Success(
|
||||||
SutProvider<ProviderBillingService> sutProvider,
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
Provider provider,
|
Provider provider,
|
||||||
TaxInfo taxInfo)
|
TaxInfo taxInfo)
|
||||||
@ -877,6 +886,301 @@ public class ProviderBillingServiceTests
|
|||||||
Assert.Equivalent(expected, actual);
|
Assert.Equivalent(expected, actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task SetupCustomer_InvalidRequiredPaymentMethod_ThrowsBillingException(
|
||||||
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
|
Provider provider,
|
||||||
|
TaxInfo taxInfo,
|
||||||
|
TokenizedPaymentSource tokenizedPaymentSource)
|
||||||
|
{
|
||||||
|
provider.Name = "MSP";
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ITaxService>()
|
||||||
|
.GetStripeTaxCode(Arg.Is<string>(
|
||||||
|
p => p == taxInfo.BillingAddressCountry),
|
||||||
|
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||||
|
.Returns(taxInfo.TaxIdType);
|
||||||
|
|
||||||
|
taxInfo.BillingAddressCountry = "AD";
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||||
|
|
||||||
|
tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay };
|
||||||
|
|
||||||
|
await ThrowsBillingExceptionAsync(() =>
|
||||||
|
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task SetupCustomer_WithBankAccount_Error_Reverts(
|
||||||
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
|
Provider provider,
|
||||||
|
TaxInfo taxInfo)
|
||||||
|
{
|
||||||
|
provider.Name = "MSP";
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ITaxService>()
|
||||||
|
.GetStripeTaxCode(Arg.Is<string>(
|
||||||
|
p => p == taxInfo.BillingAddressCountry),
|
||||||
|
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||||
|
.Returns(taxInfo.TaxIdType);
|
||||||
|
|
||||||
|
taxInfo.BillingAddressCountry = "AD";
|
||||||
|
|
||||||
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
|
|
||||||
|
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token");
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||||
|
|
||||||
|
stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options =>
|
||||||
|
options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([
|
||||||
|
new SetupIntent { Id = "setup_intent_id" }
|
||||||
|
]);
|
||||||
|
|
||||||
|
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||||
|
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||||
|
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||||
|
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||||
|
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||||
|
o.Address.City == taxInfo.BillingAddressCity &&
|
||||||
|
o.Address.State == taxInfo.BillingAddressState &&
|
||||||
|
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||||
|
o.Email == provider.BillingEmail &&
|
||||||
|
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||||
|
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||||
|
o.Metadata["region"] == "" &&
|
||||||
|
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||||
|
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||||
|
.Throws<StripeException>();
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISetupIntentCache>().Get(provider.Id).Returns("setup_intent_id");
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<StripeException>(() =>
|
||||||
|
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Set(provider.Id, "setup_intent_id");
|
||||||
|
|
||||||
|
await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_id", Arg.Is<SetupIntentCancelOptions>(options =>
|
||||||
|
options.CancellationReason == "abandoned"));
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Remove(provider.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task SetupCustomer_WithPayPal_Error_Reverts(
|
||||||
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
|
Provider provider,
|
||||||
|
TaxInfo taxInfo)
|
||||||
|
{
|
||||||
|
provider.Name = "MSP";
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ITaxService>()
|
||||||
|
.GetStripeTaxCode(Arg.Is<string>(
|
||||||
|
p => p == taxInfo.BillingAddressCountry),
|
||||||
|
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||||
|
.Returns(taxInfo.TaxIdType);
|
||||||
|
|
||||||
|
taxInfo.BillingAddressCountry = "AD";
|
||||||
|
|
||||||
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
|
|
||||||
|
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token");
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token)
|
||||||
|
.Returns("braintree_customer_id");
|
||||||
|
|
||||||
|
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||||
|
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||||
|
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||||
|
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||||
|
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||||
|
o.Address.City == taxInfo.BillingAddressCity &&
|
||||||
|
o.Address.State == taxInfo.BillingAddressState &&
|
||||||
|
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||||
|
o.Email == provider.BillingEmail &&
|
||||||
|
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||||
|
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||||
|
o.Metadata["region"] == "" &&
|
||||||
|
o.Metadata["btCustomerId"] == "braintree_customer_id" &&
|
||||||
|
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||||
|
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||||
|
.Throws<StripeException>();
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<StripeException>(() =>
|
||||||
|
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IBraintreeGateway>().Customer.Received(1).DeleteAsync("braintree_customer_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task SetupCustomer_WithBankAccount_Success(
|
||||||
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
|
Provider provider,
|
||||||
|
TaxInfo taxInfo)
|
||||||
|
{
|
||||||
|
provider.Name = "MSP";
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ITaxService>()
|
||||||
|
.GetStripeTaxCode(Arg.Is<string>(
|
||||||
|
p => p == taxInfo.BillingAddressCountry),
|
||||||
|
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||||
|
.Returns(taxInfo.TaxIdType);
|
||||||
|
|
||||||
|
taxInfo.BillingAddressCountry = "AD";
|
||||||
|
|
||||||
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
|
|
||||||
|
var expected = new Customer
|
||||||
|
{
|
||||||
|
Id = "customer_id",
|
||||||
|
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||||
|
};
|
||||||
|
|
||||||
|
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token");
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||||
|
|
||||||
|
stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options =>
|
||||||
|
options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([
|
||||||
|
new SetupIntent { Id = "setup_intent_id" }
|
||||||
|
]);
|
||||||
|
|
||||||
|
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||||
|
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||||
|
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||||
|
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||||
|
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||||
|
o.Address.City == taxInfo.BillingAddressCity &&
|
||||||
|
o.Address.State == taxInfo.BillingAddressState &&
|
||||||
|
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||||
|
o.Email == provider.BillingEmail &&
|
||||||
|
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||||
|
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||||
|
o.Metadata["region"] == "" &&
|
||||||
|
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||||
|
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||||
|
.Returns(expected);
|
||||||
|
|
||||||
|
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
|
||||||
|
|
||||||
|
Assert.Equivalent(expected, actual);
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Set(provider.Id, "setup_intent_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task SetupCustomer_WithPayPal_Success(
|
||||||
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
|
Provider provider,
|
||||||
|
TaxInfo taxInfo)
|
||||||
|
{
|
||||||
|
provider.Name = "MSP";
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ITaxService>()
|
||||||
|
.GetStripeTaxCode(Arg.Is<string>(
|
||||||
|
p => p == taxInfo.BillingAddressCountry),
|
||||||
|
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||||
|
.Returns(taxInfo.TaxIdType);
|
||||||
|
|
||||||
|
taxInfo.BillingAddressCountry = "AD";
|
||||||
|
|
||||||
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
|
|
||||||
|
var expected = new Customer
|
||||||
|
{
|
||||||
|
Id = "customer_id",
|
||||||
|
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||||
|
};
|
||||||
|
|
||||||
|
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token");
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token)
|
||||||
|
.Returns("braintree_customer_id");
|
||||||
|
|
||||||
|
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||||
|
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||||
|
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||||
|
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||||
|
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||||
|
o.Address.City == taxInfo.BillingAddressCity &&
|
||||||
|
o.Address.State == taxInfo.BillingAddressState &&
|
||||||
|
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||||
|
o.Email == provider.BillingEmail &&
|
||||||
|
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||||
|
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||||
|
o.Metadata["region"] == "" &&
|
||||||
|
o.Metadata["btCustomerId"] == "braintree_customer_id" &&
|
||||||
|
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||||
|
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||||
|
.Returns(expected);
|
||||||
|
|
||||||
|
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
|
||||||
|
|
||||||
|
Assert.Equivalent(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task SetupCustomer_WithCard_Success(
|
||||||
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
|
Provider provider,
|
||||||
|
TaxInfo taxInfo)
|
||||||
|
{
|
||||||
|
provider.Name = "MSP";
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ITaxService>()
|
||||||
|
.GetStripeTaxCode(Arg.Is<string>(
|
||||||
|
p => p == taxInfo.BillingAddressCountry),
|
||||||
|
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||||
|
.Returns(taxInfo.TaxIdType);
|
||||||
|
|
||||||
|
taxInfo.BillingAddressCountry = "AD";
|
||||||
|
|
||||||
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
|
|
||||||
|
var expected = new Customer
|
||||||
|
{
|
||||||
|
Id = "customer_id",
|
||||||
|
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||||
|
};
|
||||||
|
|
||||||
|
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token");
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||||
|
|
||||||
|
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||||
|
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||||
|
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||||
|
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||||
|
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||||
|
o.Address.City == taxInfo.BillingAddressCity &&
|
||||||
|
o.Address.State == taxInfo.BillingAddressState &&
|
||||||
|
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||||
|
o.Email == provider.BillingEmail &&
|
||||||
|
o.PaymentMethod == tokenizedPaymentSource.Token &&
|
||||||
|
o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token &&
|
||||||
|
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||||
|
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||||
|
o.Metadata["region"] == "" &&
|
||||||
|
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||||
|
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||||
|
.Returns(expected);
|
||||||
|
|
||||||
|
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
|
||||||
|
|
||||||
|
Assert.Equivalent(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task SetupCustomer_Throws_BadRequestException_WhenTaxIdIsInvalid(
|
public async Task SetupCustomer_Throws_BadRequestException_WhenTaxIdIsInvalid(
|
||||||
SutProvider<ProviderBillingService> sutProvider,
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
@ -1044,7 +1348,7 @@ public class ProviderBillingServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task SetupSubscription_Succeeds(
|
public async Task SetupSubscription_SendInvoice_Succeeds(
|
||||||
SutProvider<ProviderBillingService> sutProvider,
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
Provider provider)
|
Provider provider)
|
||||||
{
|
{
|
||||||
@ -1127,6 +1431,303 @@ public class ProviderBillingServiceTests
|
|||||||
Assert.Equivalent(expected, actual);
|
Assert.Equivalent(expected, actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task SetupSubscription_ChargeAutomatically_HasCard_Succeeds(
|
||||||
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
|
Provider provider)
|
||||||
|
{
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
|
provider.GatewaySubscriptionId = null;
|
||||||
|
|
||||||
|
var customer = new Customer
|
||||||
|
{
|
||||||
|
Id = "customer_id",
|
||||||
|
InvoiceSettings = new CustomerInvoiceSettings
|
||||||
|
{
|
||||||
|
DefaultPaymentMethodId = "pm_123"
|
||||||
|
},
|
||||||
|
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetCustomerOrThrow(
|
||||||
|
provider,
|
||||||
|
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))).Returns(customer);
|
||||||
|
|
||||||
|
var providerPlans = new List<ProviderPlan>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ProviderId = provider.Id,
|
||||||
|
PlanType = PlanType.TeamsMonthly,
|
||||||
|
SeatMinimum = 100,
|
||||||
|
PurchasedSeats = 0,
|
||||||
|
AllocatedSeats = 0
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ProviderId = provider.Id,
|
||||||
|
PlanType = PlanType.EnterpriseMonthly,
|
||||||
|
SeatMinimum = 100,
|
||||||
|
PurchasedSeats = 0,
|
||||||
|
AllocatedSeats = 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var plan in providerPlans)
|
||||||
|
{
|
||||||
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||||
|
.Returns(StaticStore.GetPlan(plan.PlanType));
|
||||||
|
}
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||||
|
.Returns(providerPlans);
|
||||||
|
|
||||||
|
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IAutomaticTaxStrategy>()
|
||||||
|
.When(x => x.SetCreateOptions(
|
||||||
|
Arg.Is<SubscriptionCreateOptions>(options =>
|
||||||
|
options.Customer == "customer_id")
|
||||||
|
, Arg.Is<Customer>(p => p == customer)))
|
||||||
|
.Do(x =>
|
||||||
|
{
|
||||||
|
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||||
|
{
|
||||||
|
Enabled = true
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
|
||||||
|
sub =>
|
||||||
|
sub.AutomaticTax.Enabled == true &&
|
||||||
|
sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically &&
|
||||||
|
sub.Customer == "customer_id" &&
|
||||||
|
sub.DaysUntilDue == null &&
|
||||||
|
sub.Items.Count == 2 &&
|
||||||
|
sub.Items.ElementAt(0).Price == ProviderPriceAdapter.MSP.Active.Teams &&
|
||||||
|
sub.Items.ElementAt(0).Quantity == 100 &&
|
||||||
|
sub.Items.ElementAt(1).Price == ProviderPriceAdapter.MSP.Active.Enterprise &&
|
||||||
|
sub.Items.ElementAt(1).Quantity == 100 &&
|
||||||
|
sub.Metadata["providerId"] == provider.Id.ToString() &&
|
||||||
|
sub.OffSession == true &&
|
||||||
|
sub.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations &&
|
||||||
|
sub.TrialPeriodDays == 14)).Returns(expected);
|
||||||
|
|
||||||
|
var actual = await sutProvider.Sut.SetupSubscription(provider);
|
||||||
|
|
||||||
|
Assert.Equivalent(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task SetupSubscription_ChargeAutomatically_HasBankAccount_Succeeds(
|
||||||
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
|
Provider provider)
|
||||||
|
{
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
|
provider.GatewaySubscriptionId = null;
|
||||||
|
|
||||||
|
var customer = new Customer
|
||||||
|
{
|
||||||
|
Id = "customer_id",
|
||||||
|
InvoiceSettings = new CustomerInvoiceSettings(),
|
||||||
|
Metadata = new Dictionary<string, string>(),
|
||||||
|
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetCustomerOrThrow(
|
||||||
|
provider,
|
||||||
|
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))).Returns(customer);
|
||||||
|
|
||||||
|
var providerPlans = new List<ProviderPlan>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ProviderId = provider.Id,
|
||||||
|
PlanType = PlanType.TeamsMonthly,
|
||||||
|
SeatMinimum = 100,
|
||||||
|
PurchasedSeats = 0,
|
||||||
|
AllocatedSeats = 0
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ProviderId = provider.Id,
|
||||||
|
PlanType = PlanType.EnterpriseMonthly,
|
||||||
|
SeatMinimum = 100,
|
||||||
|
PurchasedSeats = 0,
|
||||||
|
AllocatedSeats = 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var plan in providerPlans)
|
||||||
|
{
|
||||||
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||||
|
.Returns(StaticStore.GetPlan(plan.PlanType));
|
||||||
|
}
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||||
|
.Returns(providerPlans);
|
||||||
|
|
||||||
|
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IAutomaticTaxStrategy>()
|
||||||
|
.When(x => x.SetCreateOptions(
|
||||||
|
Arg.Is<SubscriptionCreateOptions>(options =>
|
||||||
|
options.Customer == "customer_id")
|
||||||
|
, Arg.Is<Customer>(p => p == customer)))
|
||||||
|
.Do(x =>
|
||||||
|
{
|
||||||
|
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||||
|
{
|
||||||
|
Enabled = true
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||||
|
|
||||||
|
const string setupIntentId = "seti_123";
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISetupIntentCache>().Get(provider.Id).Returns(setupIntentId);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().SetupIntentGet(setupIntentId, Arg.Is<SetupIntentGetOptions>(options =>
|
||||||
|
options.Expand.Contains("payment_method"))).Returns(new SetupIntent
|
||||||
|
{
|
||||||
|
Id = setupIntentId,
|
||||||
|
Status = "requires_action",
|
||||||
|
NextAction = new SetupIntentNextAction
|
||||||
|
{
|
||||||
|
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
|
||||||
|
},
|
||||||
|
PaymentMethod = new PaymentMethod
|
||||||
|
{
|
||||||
|
UsBankAccount = new PaymentMethodUsBankAccount()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
|
||||||
|
sub =>
|
||||||
|
sub.AutomaticTax.Enabled == true &&
|
||||||
|
sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically &&
|
||||||
|
sub.Customer == "customer_id" &&
|
||||||
|
sub.DaysUntilDue == null &&
|
||||||
|
sub.Items.Count == 2 &&
|
||||||
|
sub.Items.ElementAt(0).Price == ProviderPriceAdapter.MSP.Active.Teams &&
|
||||||
|
sub.Items.ElementAt(0).Quantity == 100 &&
|
||||||
|
sub.Items.ElementAt(1).Price == ProviderPriceAdapter.MSP.Active.Enterprise &&
|
||||||
|
sub.Items.ElementAt(1).Quantity == 100 &&
|
||||||
|
sub.Metadata["providerId"] == provider.Id.ToString() &&
|
||||||
|
sub.OffSession == true &&
|
||||||
|
sub.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations &&
|
||||||
|
sub.TrialPeriodDays == 14)).Returns(expected);
|
||||||
|
|
||||||
|
var actual = await sutProvider.Sut.SetupSubscription(provider);
|
||||||
|
|
||||||
|
Assert.Equivalent(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task SetupSubscription_ChargeAutomatically_HasPayPal_Succeeds(
|
||||||
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
|
Provider provider)
|
||||||
|
{
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
|
provider.GatewaySubscriptionId = null;
|
||||||
|
|
||||||
|
var customer = new Customer
|
||||||
|
{
|
||||||
|
Id = "customer_id",
|
||||||
|
InvoiceSettings = new CustomerInvoiceSettings(),
|
||||||
|
Metadata = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["btCustomerId"] = "braintree_customer_id"
|
||||||
|
},
|
||||||
|
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetCustomerOrThrow(
|
||||||
|
provider,
|
||||||
|
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))).Returns(customer);
|
||||||
|
|
||||||
|
var providerPlans = new List<ProviderPlan>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ProviderId = provider.Id,
|
||||||
|
PlanType = PlanType.TeamsMonthly,
|
||||||
|
SeatMinimum = 100,
|
||||||
|
PurchasedSeats = 0,
|
||||||
|
AllocatedSeats = 0
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ProviderId = provider.Id,
|
||||||
|
PlanType = PlanType.EnterpriseMonthly,
|
||||||
|
SeatMinimum = 100,
|
||||||
|
PurchasedSeats = 0,
|
||||||
|
AllocatedSeats = 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var plan in providerPlans)
|
||||||
|
{
|
||||||
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||||
|
.Returns(StaticStore.GetPlan(plan.PlanType));
|
||||||
|
}
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||||
|
.Returns(providerPlans);
|
||||||
|
|
||||||
|
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IAutomaticTaxStrategy>()
|
||||||
|
.When(x => x.SetCreateOptions(
|
||||||
|
Arg.Is<SubscriptionCreateOptions>(options =>
|
||||||
|
options.Customer == "customer_id")
|
||||||
|
, Arg.Is<Customer>(p => p == customer)))
|
||||||
|
.Do(x =>
|
||||||
|
{
|
||||||
|
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||||
|
{
|
||||||
|
Enabled = true
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
|
||||||
|
sub =>
|
||||||
|
sub.AutomaticTax.Enabled == true &&
|
||||||
|
sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically &&
|
||||||
|
sub.Customer == "customer_id" &&
|
||||||
|
sub.DaysUntilDue == null &&
|
||||||
|
sub.Items.Count == 2 &&
|
||||||
|
sub.Items.ElementAt(0).Price == ProviderPriceAdapter.MSP.Active.Teams &&
|
||||||
|
sub.Items.ElementAt(0).Quantity == 100 &&
|
||||||
|
sub.Items.ElementAt(1).Price == ProviderPriceAdapter.MSP.Active.Enterprise &&
|
||||||
|
sub.Items.ElementAt(1).Quantity == 100 &&
|
||||||
|
sub.Metadata["providerId"] == provider.Id.ToString() &&
|
||||||
|
sub.OffSession == true &&
|
||||||
|
sub.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations &&
|
||||||
|
sub.TrialPeriodDays == 14)).Returns(expected);
|
||||||
|
|
||||||
|
var actual = await sutProvider.Sut.SetupSubscription(provider);
|
||||||
|
|
||||||
|
Assert.Equivalent(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region UpdateSeatMinimums
|
#region UpdateSeatMinimums
|
||||||
|
@ -124,8 +124,20 @@ services:
|
|||||||
profiles:
|
profiles:
|
||||||
- servicebus
|
- servicebus
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:alpine
|
||||||
|
container_name: bw-redis
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
profiles:
|
||||||
|
- redis
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mssql_dev_data:
|
mssql_dev_data:
|
||||||
postgres_dev_data:
|
postgres_dev_data:
|
||||||
mysql_dev_data:
|
mysql_dev_data:
|
||||||
rabbitmq_data:
|
rabbitmq_data:
|
||||||
|
redis_data:
|
||||||
|
@ -470,6 +470,19 @@ public class ProvidersController : Controller
|
|||||||
[RequirePermission(Permission.Provider_Edit)]
|
[RequirePermission(Permission.Provider_Edit)]
|
||||||
public async Task<IActionResult> Delete(Guid id, string providerName)
|
public async Task<IActionResult> Delete(Guid id, string providerName)
|
||||||
{
|
{
|
||||||
|
var provider = await _providerRepository.GetByIdAsync(id);
|
||||||
|
|
||||||
|
if (provider is null)
|
||||||
|
{
|
||||||
|
return BadRequest("Provider does not exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.Status == ProviderStatusType.Pending)
|
||||||
|
{
|
||||||
|
await _providerService.DeleteAsync(provider);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(providerName))
|
if (string.IsNullOrWhiteSpace(providerName))
|
||||||
{
|
{
|
||||||
return BadRequest("Invalid provider name");
|
return BadRequest("Invalid provider name");
|
||||||
@ -482,13 +495,6 @@ public class ProvidersController : Controller
|
|||||||
return BadRequest("You must unlink all clients before you can delete a provider");
|
return BadRequest("You must unlink all clients before you can delete a provider");
|
||||||
}
|
}
|
||||||
|
|
||||||
var provider = await _providerRepository.GetByIdAsync(id);
|
|
||||||
|
|
||||||
if (provider is null)
|
|
||||||
{
|
|
||||||
return BadRequest("Provider does not exist");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.Equals(providerName.Trim(), provider.DisplayName(), StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(providerName.Trim(), provider.DisplayName(), StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return BadRequest("Invalid provider name");
|
return BadRequest("Invalid provider name");
|
||||||
|
@ -183,6 +183,17 @@
|
|||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<h4 class="fw-bolder" id="exampleModalLabel">Delete provider</h4>
|
<h4 class="fw-bolder" id="exampleModalLabel">Delete provider</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (Model.Provider.Status == ProviderStatusType.Pending)
|
||||||
|
{
|
||||||
|
<div class="modal-body">
|
||||||
|
<span class="fw-light">
|
||||||
|
This action is permanent and irreversible.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<span class="fw-light">
|
<span class="fw-light">
|
||||||
This action is permanent and irreversible. Enter the provider name to complete deletion of the provider and associated data.
|
This action is permanent and irreversible. Enter the provider name to complete deletion of the provider and associated data.
|
||||||
@ -194,6 +205,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-primary btn-pill" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-outline-primary btn-pill" data-bs-dismiss="modal">Cancel</button>
|
||||||
<button type="button" class="btn btn-danger btn-pill" onclick="deleteProvider('@Model.Provider.Id');">Delete provider</button>
|
<button type="button" class="btn btn-danger btn-pill" onclick="deleteProvider('@Model.Provider.Id');">Delete provider</button>
|
||||||
|
@ -9,7 +9,7 @@ namespace Bit.Api.AdminConsole.Authorization;
|
|||||||
public static class HttpContextExtensions
|
public static class HttpContextExtensions
|
||||||
{
|
{
|
||||||
public const string NoOrgIdError =
|
public const string NoOrgIdError =
|
||||||
"A route decorated with with '[Authorize<Requirement>]' must include a route value named 'orgId' either through the [Controller] attribute or through a '[Http*]' attribute.";
|
"A route decorated with with '[Authorize<Requirement>]' must include a route value named 'orgId' or 'organizationId' either through the [Controller] attribute or through a '[Http*]' attribute.";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the result of the callback, caching it in HttpContext.Features for the lifetime of the request.
|
/// Returns the result of the callback, caching it in HttpContext.Features for the lifetime of the request.
|
||||||
@ -61,19 +61,27 @@ public static class HttpContextExtensions
|
|||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses the {orgId} route parameter into a Guid, or throws if the {orgId} is not present or not a valid guid.
|
/// Parses the {orgId} or {organizationId} route parameter into a Guid, or throws if neither are present or are not valid guids.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="httpContext"></param>
|
/// <param name="httpContext"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
/// <exception cref="InvalidOperationException"></exception>
|
/// <exception cref="InvalidOperationException"></exception>
|
||||||
public static Guid GetOrganizationId(this HttpContext httpContext)
|
public static Guid GetOrganizationId(this HttpContext httpContext)
|
||||||
{
|
{
|
||||||
httpContext.GetRouteData().Values.TryGetValue("orgId", out var orgIdParam);
|
var routeValues = httpContext.GetRouteData().Values;
|
||||||
if (orgIdParam == null || !Guid.TryParse(orgIdParam.ToString(), out var orgId))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(NoOrgIdError);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
routeValues.TryGetValue("orgId", out var orgIdParam);
|
||||||
|
if (orgIdParam != null && Guid.TryParse(orgIdParam.ToString(), out var orgId))
|
||||||
|
{
|
||||||
return orgId;
|
return orgId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
routeValues.TryGetValue("organizationId", out var organizationIdParam);
|
||||||
|
if (organizationIdParam != null && Guid.TryParse(organizationIdParam.ToString(), out var organizationId))
|
||||||
|
{
|
||||||
|
return organizationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(NoOrgIdError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,16 @@
|
|||||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
|
using Bit.Core;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Bit.Api.AdminConsole.Controllers;
|
namespace Bit.Api.AdminConsole.Controllers;
|
||||||
|
|
||||||
|
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
|
||||||
[Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")]
|
[Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")]
|
||||||
[Authorize("Application")]
|
[Authorize("Application")]
|
||||||
public class OrganizationIntegrationConfigurationController(
|
public class OrganizationIntegrationConfigurationController(
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
|
using Bit.Core;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@ -10,6 +12,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
|
|
||||||
namespace Bit.Api.AdminConsole.Controllers;
|
namespace Bit.Api.AdminConsole.Controllers;
|
||||||
|
|
||||||
|
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
|
||||||
[Route("organizations/{organizationId:guid}/integrations")]
|
[Route("organizations/{organizationId:guid}/integrations")]
|
||||||
[Authorize("Application")]
|
[Authorize("Application")]
|
||||||
public class OrganizationIntegrationController(
|
public class OrganizationIntegrationController(
|
||||||
|
@ -63,6 +63,7 @@ public class OrganizationUsersController : Controller
|
|||||||
private readonly IPricingClient _pricingClient;
|
private readonly IPricingClient _pricingClient;
|
||||||
private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
|
private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
|
||||||
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
|
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
|
||||||
|
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
|
||||||
|
|
||||||
public OrganizationUsersController(
|
public OrganizationUsersController(
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
@ -89,7 +90,8 @@ public class OrganizationUsersController : Controller
|
|||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
IPricingClient pricingClient,
|
IPricingClient pricingClient,
|
||||||
IConfirmOrganizationUserCommand confirmOrganizationUserCommand,
|
IConfirmOrganizationUserCommand confirmOrganizationUserCommand,
|
||||||
IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
|
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
|
||||||
|
IInitPendingOrganizationCommand initPendingOrganizationCommand)
|
||||||
{
|
{
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
@ -116,6 +118,7 @@ public class OrganizationUsersController : Controller
|
|||||||
_pricingClient = pricingClient;
|
_pricingClient = pricingClient;
|
||||||
_confirmOrganizationUserCommand = confirmOrganizationUserCommand;
|
_confirmOrganizationUserCommand = confirmOrganizationUserCommand;
|
||||||
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
|
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
|
||||||
|
_initPendingOrganizationCommand = initPendingOrganizationCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
@ -313,7 +316,7 @@ public class OrganizationUsersController : Controller
|
|||||||
throw new UnauthorizedAccessException();
|
throw new UnauthorizedAccessException();
|
||||||
}
|
}
|
||||||
|
|
||||||
await _organizationService.InitPendingOrganization(user, orgId, organizationUserId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName, model.Token);
|
await _initPendingOrganizationCommand.InitPendingOrganizationAsync(user, orgId, organizationUserId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName, model.Token);
|
||||||
await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService);
|
await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService);
|
||||||
await _confirmOrganizationUserCommand.ConfirmUserAsync(orgId, organizationUserId, model.Key, user.Id);
|
await _confirmOrganizationUserCommand.ConfirmUserAsync(orgId, organizationUserId, model.Key, user.Id);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
using Bit.Api.Billing.Controllers;
|
using Bit.Api.Billing.Controllers;
|
||||||
using Bit.Api.Billing.Models.Requests;
|
using Bit.Api.Billing.Models.Requests;
|
||||||
using Bit.Core;
|
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
@ -17,7 +16,6 @@ namespace Bit.Api.AdminConsole.Controllers;
|
|||||||
[Route("providers/{providerId:guid}/clients")]
|
[Route("providers/{providerId:guid}/clients")]
|
||||||
public class ProviderClientsController(
|
public class ProviderClientsController(
|
||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
IFeatureService featureService,
|
|
||||||
ILogger<BaseProviderController> logger,
|
ILogger<BaseProviderController> logger,
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
IProviderBillingService providerBillingService,
|
IProviderBillingService providerBillingService,
|
||||||
@ -140,11 +138,6 @@ public class ProviderClientsController(
|
|||||||
[SelfHosted(NotSelfHostedOnly = true)]
|
[SelfHosted(NotSelfHostedOnly = true)]
|
||||||
public async Task<IResult> GetAddableOrganizationsAsync([FromRoute] Guid providerId)
|
public async Task<IResult> GetAddableOrganizationsAsync([FromRoute] Guid providerId)
|
||||||
{
|
{
|
||||||
if (!featureService.IsEnabled(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal))
|
|
||||||
{
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
var (provider, result) = await TryGetBillableProviderForServiceUserOperation(providerId);
|
var (provider, result) = await TryGetBillableProviderForServiceUserOperation(providerId);
|
||||||
|
|
||||||
if (provider == null)
|
if (provider == null)
|
||||||
|
@ -84,8 +84,7 @@ public class ProvidersController : Controller
|
|||||||
|
|
||||||
var userId = _userService.GetProperUserId(User).Value;
|
var userId = _userService.GetProperUserId(User).Value;
|
||||||
|
|
||||||
var taxInfo = model.TaxInfo != null
|
var taxInfo = new TaxInfo
|
||||||
? new TaxInfo
|
|
||||||
{
|
{
|
||||||
BillingAddressCountry = model.TaxInfo.Country,
|
BillingAddressCountry = model.TaxInfo.Country,
|
||||||
BillingAddressPostalCode = model.TaxInfo.PostalCode,
|
BillingAddressPostalCode = model.TaxInfo.PostalCode,
|
||||||
@ -94,12 +93,13 @@ public class ProvidersController : Controller
|
|||||||
BillingAddressLine2 = model.TaxInfo.Line2,
|
BillingAddressLine2 = model.TaxInfo.Line2,
|
||||||
BillingAddressCity = model.TaxInfo.City,
|
BillingAddressCity = model.TaxInfo.City,
|
||||||
BillingAddressState = model.TaxInfo.State
|
BillingAddressState = model.TaxInfo.State
|
||||||
}
|
};
|
||||||
: null;
|
|
||||||
|
var tokenizedPaymentSource = model.PaymentSource?.ToDomain();
|
||||||
|
|
||||||
var response =
|
var response =
|
||||||
await _providerService.CompleteSetupAsync(model.ToProvider(provider), userId, model.Token, model.Key,
|
await _providerService.CompleteSetupAsync(model.ToProvider(provider), userId, model.Token, model.Key,
|
||||||
taxInfo);
|
taxInfo, tokenizedPaymentSource);
|
||||||
|
|
||||||
return new ProviderResponseModel(response);
|
return new ProviderResponseModel(response);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
|
using Bit.Core;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
@ -7,11 +8,13 @@ using Bit.Core.Exceptions;
|
|||||||
using Bit.Core.Models.Data.Integrations;
|
using Bit.Core.Models.Data.Integrations;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Bit.Api.AdminConsole.Controllers;
|
namespace Bit.Api.AdminConsole.Controllers;
|
||||||
|
|
||||||
|
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
|
||||||
[Route("organizations/{organizationId:guid}/integrations/slack")]
|
[Route("organizations/{organizationId:guid}/integrations/slack")]
|
||||||
[Authorize("Application")]
|
[Authorize("Application")]
|
||||||
public class SlackIntegrationController(
|
public class SlackIntegrationController(
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using Bit.Api.Billing.Models.Requests;
|
||||||
using Bit.Api.Models.Request;
|
using Bit.Api.Models.Request;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
@ -23,7 +24,9 @@ public class ProviderSetupRequestModel
|
|||||||
public string Token { get; set; }
|
public string Token { get; set; }
|
||||||
[Required]
|
[Required]
|
||||||
public string Key { get; set; }
|
public string Key { get; set; }
|
||||||
|
[Required]
|
||||||
public ExpandedTaxInfoUpdateRequestModel TaxInfo { get; set; }
|
public ExpandedTaxInfoUpdateRequestModel TaxInfo { get; set; }
|
||||||
|
public TokenizedPaymentSourceRequestBody PaymentSource { get; set; }
|
||||||
|
|
||||||
public virtual Provider ToProvider(Provider provider)
|
public virtual Provider ToProvider(Provider provider)
|
||||||
{
|
{
|
||||||
|
@ -221,8 +221,7 @@ public class MembersController : Controller
|
|||||||
/// Remove a member.
|
/// Remove a member.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Permanently removes a member from the organization. This cannot be undone.
|
/// Removes a member from the organization. This cannot be undone. The user account will still remain.
|
||||||
/// The user account will still remain. The user is only removed from the organization.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="id">The identifier of the member to be removed.</param>
|
/// <param name="id">The identifier of the member to be removed.</param>
|
||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||||
using Bit.Api.Billing.Models.Requests;
|
using Bit.Api.Billing.Models.Requests;
|
||||||
using Bit.Api.Billing.Models.Responses;
|
using Bit.Api.Billing.Models.Responses;
|
||||||
|
using Bit.Api.Billing.Queries.Organizations;
|
||||||
using Bit.Core;
|
using Bit.Core;
|
||||||
using Bit.Core.Billing.Models;
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Billing.Models.Sales;
|
using Bit.Core.Billing.Models.Sales;
|
||||||
@ -24,6 +25,7 @@ public class OrganizationBillingController(
|
|||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
IOrganizationBillingService organizationBillingService,
|
IOrganizationBillingService organizationBillingService,
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
|
IOrganizationWarningsQuery organizationWarningsQuery,
|
||||||
IPaymentService paymentService,
|
IPaymentService paymentService,
|
||||||
IPricingClient pricingClient,
|
IPricingClient pricingClient,
|
||||||
ISubscriberService subscriberService,
|
ISubscriberService subscriberService,
|
||||||
@ -335,4 +337,28 @@ public class OrganizationBillingController(
|
|||||||
|
|
||||||
return TypedResults.Ok(providerId);
|
return TypedResults.Ok(providerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("warnings")]
|
||||||
|
public async Task<IResult> GetWarningsAsync([FromRoute] Guid organizationId)
|
||||||
|
{
|
||||||
|
/*
|
||||||
|
* We'll keep these available at the User level, because we're hiding any pertinent information and
|
||||||
|
* we want to throw as few errors as possible since these are not core features.
|
||||||
|
*/
|
||||||
|
if (!await currentContext.OrganizationUser(organizationId))
|
||||||
|
{
|
||||||
|
return Error.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||||
|
|
||||||
|
if (organization == null)
|
||||||
|
{
|
||||||
|
return Error.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await organizationWarningsQuery.Run(organization);
|
||||||
|
|
||||||
|
return TypedResults.Ok(response);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using Bit.Api.Models.Request.Organizations;
|
using Bit.Api.Models.Request.Organizations;
|
||||||
|
using Bit.Api.Models.Response;
|
||||||
using Bit.Api.Models.Response.Organizations;
|
using Bit.Api.Models.Response.Organizations;
|
||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
|
||||||
@ -8,6 +9,7 @@ using Bit.Core.Entities;
|
|||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Models.Api.Request.OrganizationSponsorships;
|
using Bit.Core.Models.Api.Request.OrganizationSponsorships;
|
||||||
using Bit.Core.Models.Api.Response.OrganizationSponsorships;
|
using Bit.Core.Models.Api.Response.OrganizationSponsorships;
|
||||||
|
using Bit.Core.Models.Data.Organizations.OrganizationSponsorships;
|
||||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
@ -105,8 +107,11 @@ public class OrganizationSponsorshipsController : Controller
|
|||||||
model.FriendlyName,
|
model.FriendlyName,
|
||||||
model.IsAdminInitiated.GetValueOrDefault(),
|
model.IsAdminInitiated.GetValueOrDefault(),
|
||||||
model.Notes);
|
model.Notes);
|
||||||
|
if (sponsorship.OfferedToEmail != null)
|
||||||
|
{
|
||||||
await _sendSponsorshipOfferCommand.SendSponsorshipOfferAsync(sponsorship, sponsoringOrg.Name);
|
await _sendSponsorshipOfferCommand.SendSponsorshipOfferAsync(sponsorship, sponsoringOrg.Name);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Authorize("Application")]
|
[Authorize("Application")]
|
||||||
[HttpPost("{sponsoringOrgId}/families-for-enterprise/resend")]
|
[HttpPost("{sponsoringOrgId}/families-for-enterprise/resend")]
|
||||||
@ -246,5 +251,27 @@ public class OrganizationSponsorshipsController : Controller
|
|||||||
return new OrganizationSponsorshipSyncStatusResponseModel(lastSyncDate);
|
return new OrganizationSponsorshipSyncStatusResponseModel(lastSyncDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize("Application")]
|
||||||
|
[HttpGet("{sponsoringOrgId}/sponsored")]
|
||||||
|
[SelfHosted(NotSelfHostedOnly = true)]
|
||||||
|
public async Task<ListResponseModel<OrganizationSponsorshipInvitesResponseModel>> GetSponsoredOrganizations(Guid sponsoringOrgId)
|
||||||
|
{
|
||||||
|
var sponsoringOrg = await _organizationRepository.GetByIdAsync(sponsoringOrgId);
|
||||||
|
if (sponsoringOrg == null)
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
var organization = _currentContext.Organizations.First(x => x.Id == sponsoringOrg.Id);
|
||||||
|
if (!await _currentContext.OrganizationOwner(sponsoringOrg.Id) && !await _currentContext.OrganizationAdmin(sponsoringOrg.Id) && !organization.Permissions.ManageUsers)
|
||||||
|
{
|
||||||
|
throw new UnauthorizedAccessException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var sponsorships = await _organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(sponsoringOrgId);
|
||||||
|
return new ListResponseModel<OrganizationSponsorshipInvitesResponseModel>(sponsorships.Select(s =>
|
||||||
|
new OrganizationSponsorshipInvitesResponseModel(new OrganizationSponsorshipData(s))));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
private Task<User> CurrentUser => _userService.GetUserByIdAsync(_currentContext.UserId.Value);
|
private Task<User> CurrentUser => _userService.GetUserByIdAsync(_currentContext.UserId.Value);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,43 @@
|
|||||||
|
#nullable enable
|
||||||
|
namespace Bit.Api.Billing.Models.Responses.Organizations;
|
||||||
|
|
||||||
|
public record OrganizationWarningsResponse
|
||||||
|
{
|
||||||
|
public FreeTrialWarning? FreeTrial { get; set; }
|
||||||
|
public InactiveSubscriptionWarning? InactiveSubscription { get; set; }
|
||||||
|
public ResellerRenewalWarning? ResellerRenewal { get; set; }
|
||||||
|
|
||||||
|
public record FreeTrialWarning
|
||||||
|
{
|
||||||
|
public int RemainingTrialDays { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record InactiveSubscriptionWarning
|
||||||
|
{
|
||||||
|
public required string Resolution { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ResellerRenewalWarning
|
||||||
|
{
|
||||||
|
public required string Type { get; set; }
|
||||||
|
public UpcomingRenewal? Upcoming { get; set; }
|
||||||
|
public IssuedRenewal? Issued { get; set; }
|
||||||
|
public PastDueRenewal? PastDue { get; set; }
|
||||||
|
|
||||||
|
public record UpcomingRenewal
|
||||||
|
{
|
||||||
|
public required DateTime RenewalDate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record IssuedRenewal
|
||||||
|
{
|
||||||
|
public required DateTime IssuedDate { get; set; }
|
||||||
|
public required DateTime DueDate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PastDueRenewal
|
||||||
|
{
|
||||||
|
public required DateTime SuspensionDate { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,214 @@
|
|||||||
|
// ReSharper disable InconsistentNaming
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using Bit.Api.Billing.Models.Responses.Organizations;
|
||||||
|
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.Constants;
|
||||||
|
using Bit.Core.Billing.Services;
|
||||||
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Stripe;
|
||||||
|
using FreeTrialWarning = Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.FreeTrialWarning;
|
||||||
|
using InactiveSubscriptionWarning =
|
||||||
|
Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.InactiveSubscriptionWarning;
|
||||||
|
using ResellerRenewalWarning =
|
||||||
|
Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.ResellerRenewalWarning;
|
||||||
|
|
||||||
|
namespace Bit.Api.Billing.Queries.Organizations;
|
||||||
|
|
||||||
|
public interface IOrganizationWarningsQuery
|
||||||
|
{
|
||||||
|
Task<OrganizationWarningsResponse> Run(
|
||||||
|
Organization organization);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OrganizationWarningsQuery(
|
||||||
|
ICurrentContext currentContext,
|
||||||
|
IProviderRepository providerRepository,
|
||||||
|
IStripeAdapter stripeAdapter,
|
||||||
|
ISubscriberService subscriberService) : IOrganizationWarningsQuery
|
||||||
|
{
|
||||||
|
public async Task<OrganizationWarningsResponse> Run(
|
||||||
|
Organization organization)
|
||||||
|
{
|
||||||
|
var response = new OrganizationWarningsResponse();
|
||||||
|
|
||||||
|
var subscription =
|
||||||
|
await subscriberService.GetSubscription(organization,
|
||||||
|
new SubscriptionGetOptions { Expand = ["customer", "latest_invoice", "test_clock"] });
|
||||||
|
|
||||||
|
if (subscription == null)
|
||||||
|
{
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.FreeTrial = await GetFreeTrialWarning(organization, subscription);
|
||||||
|
|
||||||
|
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||||
|
|
||||||
|
response.InactiveSubscription = await GetInactiveSubscriptionWarning(organization, provider, subscription);
|
||||||
|
|
||||||
|
response.ResellerRenewal = await GetResellerRenewalWarning(provider, subscription);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<FreeTrialWarning?> GetFreeTrialWarning(
|
||||||
|
Organization organization,
|
||||||
|
Subscription subscription)
|
||||||
|
{
|
||||||
|
if (!await currentContext.EditSubscription(organization.Id))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription is not
|
||||||
|
{
|
||||||
|
Status: StripeConstants.SubscriptionStatus.Trialing,
|
||||||
|
TrialEnd: not null,
|
||||||
|
Customer: not null
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var customer = subscription.Customer;
|
||||||
|
|
||||||
|
var hasPaymentMethod =
|
||||||
|
!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) ||
|
||||||
|
!string.IsNullOrEmpty(customer.DefaultSourceId) ||
|
||||||
|
customer.Metadata.ContainsKey(StripeConstants.MetadataKeys.BraintreeCustomerId);
|
||||||
|
|
||||||
|
if (hasPaymentMethod)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
|
||||||
|
|
||||||
|
var remainingTrialDays = (int)Math.Ceiling((subscription.TrialEnd.Value - now).TotalDays);
|
||||||
|
|
||||||
|
return new FreeTrialWarning { RemainingTrialDays = remainingTrialDays };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<InactiveSubscriptionWarning?> GetInactiveSubscriptionWarning(
|
||||||
|
Organization organization,
|
||||||
|
Provider? provider,
|
||||||
|
Subscription subscription)
|
||||||
|
{
|
||||||
|
if (organization.Enabled ||
|
||||||
|
subscription.Status is not StripeConstants.SubscriptionStatus.Unpaid
|
||||||
|
and not StripeConstants.SubscriptionStatus.Canceled)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider != null)
|
||||||
|
{
|
||||||
|
return new InactiveSubscriptionWarning { Resolution = "contact_provider" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await currentContext.OrganizationOwner(organization.Id))
|
||||||
|
{
|
||||||
|
return subscription.Status switch
|
||||||
|
{
|
||||||
|
StripeConstants.SubscriptionStatus.Unpaid => new InactiveSubscriptionWarning
|
||||||
|
{
|
||||||
|
Resolution = "add_payment_method"
|
||||||
|
},
|
||||||
|
StripeConstants.SubscriptionStatus.Canceled => new InactiveSubscriptionWarning
|
||||||
|
{
|
||||||
|
Resolution = "resubscribe"
|
||||||
|
},
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InactiveSubscriptionWarning { Resolution = "contact_owner" };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ResellerRenewalWarning?> GetResellerRenewalWarning(
|
||||||
|
Provider? provider,
|
||||||
|
Subscription subscription)
|
||||||
|
{
|
||||||
|
if (provider is not
|
||||||
|
{
|
||||||
|
Type: ProviderType.Reseller
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.CollectionMethod != StripeConstants.CollectionMethod.SendInvoice)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
|
||||||
|
|
||||||
|
// ReSharper disable once ConvertIfStatementToSwitchStatement
|
||||||
|
if (subscription is
|
||||||
|
{
|
||||||
|
Status: StripeConstants.SubscriptionStatus.Trialing or StripeConstants.SubscriptionStatus.Active,
|
||||||
|
LatestInvoice: null or { Status: StripeConstants.InvoiceStatus.Paid }
|
||||||
|
} && (subscription.CurrentPeriodEnd - now).TotalDays <= 14)
|
||||||
|
{
|
||||||
|
return new ResellerRenewalWarning
|
||||||
|
{
|
||||||
|
Type = "upcoming",
|
||||||
|
Upcoming = new ResellerRenewalWarning.UpcomingRenewal
|
||||||
|
{
|
||||||
|
RenewalDate = subscription.CurrentPeriodEnd
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription is
|
||||||
|
{
|
||||||
|
Status: StripeConstants.SubscriptionStatus.Active,
|
||||||
|
LatestInvoice: { Status: StripeConstants.InvoiceStatus.Open, DueDate: not null }
|
||||||
|
} && subscription.LatestInvoice.DueDate > now)
|
||||||
|
{
|
||||||
|
return new ResellerRenewalWarning
|
||||||
|
{
|
||||||
|
Type = "issued",
|
||||||
|
Issued = new ResellerRenewalWarning.IssuedRenewal
|
||||||
|
{
|
||||||
|
IssuedDate = subscription.LatestInvoice.Created,
|
||||||
|
DueDate = subscription.LatestInvoice.DueDate.Value
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReSharper disable once InvertIf
|
||||||
|
if (subscription.Status == StripeConstants.SubscriptionStatus.PastDue)
|
||||||
|
{
|
||||||
|
var openInvoices = await stripeAdapter.InvoiceSearchAsync(new InvoiceSearchOptions
|
||||||
|
{
|
||||||
|
Query = $"subscription:'{subscription.Id}' status:'open'"
|
||||||
|
});
|
||||||
|
|
||||||
|
var earliestOverdueInvoice = openInvoices
|
||||||
|
.Where(invoice => invoice.DueDate != null && invoice.DueDate < now)
|
||||||
|
.MinBy(invoice => invoice.Created);
|
||||||
|
|
||||||
|
if (earliestOverdueInvoice != null)
|
||||||
|
{
|
||||||
|
return new ResellerRenewalWarning
|
||||||
|
{
|
||||||
|
Type = "past_due",
|
||||||
|
PastDue = new ResellerRenewalWarning.PastDueRenewal
|
||||||
|
{
|
||||||
|
SuspensionDate = earliestOverdueInvoice.DueDate!.Value.AddDays(30)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
11
src/Api/Billing/Registrations.cs
Normal file
11
src/Api/Billing/Registrations.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using Bit.Api.Billing.Queries.Organizations;
|
||||||
|
|
||||||
|
namespace Bit.Api.Billing;
|
||||||
|
|
||||||
|
public static class Registrations
|
||||||
|
{
|
||||||
|
public static void AddBillingQueries(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddTransient<IOrganizationWarningsQuery, OrganizationWarningsQuery>();
|
||||||
|
}
|
||||||
|
}
|
34
src/Api/Controllers/PhishingDomainsController.cs
Normal file
34
src/Api/Controllers/PhishingDomainsController.cs
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
using Bit.Core;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Bit.Api.Controllers;
|
||||||
|
|
||||||
|
[Route("phishing-domains")]
|
||||||
|
public class PhishingDomainsController(IPhishingDomainRepository phishingDomainRepository, IFeatureService featureService) : Controller
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<ICollection<string>>> GetPhishingDomainsAsync()
|
||||||
|
{
|
||||||
|
if (!featureService.IsEnabled(FeatureFlagKeys.PhishingDetection))
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var domains = await phishingDomainRepository.GetActivePhishingDomainsAsync();
|
||||||
|
return Ok(domains);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("checksum")]
|
||||||
|
public async Task<ActionResult<string>> GetChecksumAsync()
|
||||||
|
{
|
||||||
|
if (!featureService.IsEnabled(FeatureFlagKeys.PhishingDetection))
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var checksum = await phishingDomainRepository.GetCurrentChecksumAsync();
|
||||||
|
return Ok(checksum);
|
||||||
|
}
|
||||||
|
}
|
@ -58,6 +58,13 @@ public class JobsHostedService : BaseJobsHostedService
|
|||||||
.StartNow()
|
.StartNow()
|
||||||
.WithCronSchedule("0 0 * * * ?")
|
.WithCronSchedule("0 0 * * * ?")
|
||||||
.Build();
|
.Build();
|
||||||
|
var updatePhishingDomainsTrigger = TriggerBuilder.Create()
|
||||||
|
.WithIdentity("UpdatePhishingDomainsTrigger")
|
||||||
|
.StartNow()
|
||||||
|
.WithSimpleSchedule(x => x
|
||||||
|
.WithIntervalInHours(24)
|
||||||
|
.RepeatForever())
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
|
||||||
var jobs = new List<Tuple<Type, ITrigger>>
|
var jobs = new List<Tuple<Type, ITrigger>>
|
||||||
@ -68,6 +75,7 @@ public class JobsHostedService : BaseJobsHostedService
|
|||||||
new Tuple<Type, ITrigger>(typeof(ValidateUsersJob), everyTopOfTheSixthHourTrigger),
|
new Tuple<Type, ITrigger>(typeof(ValidateUsersJob), everyTopOfTheSixthHourTrigger),
|
||||||
new Tuple<Type, ITrigger>(typeof(ValidateOrganizationsJob), everyTwelfthHourAndThirtyMinutesTrigger),
|
new Tuple<Type, ITrigger>(typeof(ValidateOrganizationsJob), everyTwelfthHourAndThirtyMinutesTrigger),
|
||||||
new Tuple<Type, ITrigger>(typeof(ValidateOrganizationDomainJob), validateOrganizationDomainTrigger),
|
new Tuple<Type, ITrigger>(typeof(ValidateOrganizationDomainJob), validateOrganizationDomainTrigger),
|
||||||
|
new Tuple<Type, ITrigger>(typeof(UpdatePhishingDomainsJob), updatePhishingDomainsTrigger),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (_globalSettings.SelfHosted && _globalSettings.EnableCloudCommunication)
|
if (_globalSettings.SelfHosted && _globalSettings.EnableCloudCommunication)
|
||||||
@ -96,6 +104,7 @@ public class JobsHostedService : BaseJobsHostedService
|
|||||||
services.AddTransient<ValidateUsersJob>();
|
services.AddTransient<ValidateUsersJob>();
|
||||||
services.AddTransient<ValidateOrganizationsJob>();
|
services.AddTransient<ValidateOrganizationsJob>();
|
||||||
services.AddTransient<ValidateOrganizationDomainJob>();
|
services.AddTransient<ValidateOrganizationDomainJob>();
|
||||||
|
services.AddTransient<UpdatePhishingDomainsJob>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void AddCommercialSecretsManagerJobServices(IServiceCollection services)
|
public static void AddCommercialSecretsManagerJobServices(IServiceCollection services)
|
||||||
|
97
src/Api/Jobs/UpdatePhishingDomainsJob.cs
Normal file
97
src/Api/Jobs/UpdatePhishingDomainsJob.cs
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
using Bit.Core;
|
||||||
|
using Bit.Core.Jobs;
|
||||||
|
using Bit.Core.PhishingDomainFeatures.Interfaces;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
using Quartz;
|
||||||
|
|
||||||
|
namespace Bit.Api.Jobs;
|
||||||
|
|
||||||
|
public class UpdatePhishingDomainsJob : BaseJob
|
||||||
|
{
|
||||||
|
private readonly GlobalSettings _globalSettings;
|
||||||
|
private readonly IPhishingDomainRepository _phishingDomainRepository;
|
||||||
|
private readonly ICloudPhishingDomainQuery _cloudPhishingDomainQuery;
|
||||||
|
private readonly IFeatureService _featureService;
|
||||||
|
public UpdatePhishingDomainsJob(
|
||||||
|
GlobalSettings globalSettings,
|
||||||
|
IPhishingDomainRepository phishingDomainRepository,
|
||||||
|
ICloudPhishingDomainQuery cloudPhishingDomainQuery,
|
||||||
|
IFeatureService featureService,
|
||||||
|
ILogger<UpdatePhishingDomainsJob> logger)
|
||||||
|
: base(logger)
|
||||||
|
{
|
||||||
|
_globalSettings = globalSettings;
|
||||||
|
_phishingDomainRepository = phishingDomainRepository;
|
||||||
|
_cloudPhishingDomainQuery = cloudPhishingDomainQuery;
|
||||||
|
_featureService = featureService;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteJobAsync(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
if (!_featureService.IsEnabled(FeatureFlagKeys.PhishingDetection))
|
||||||
|
{
|
||||||
|
_logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. Feature flag is disabled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_globalSettings.PhishingDomain?.UpdateUrl))
|
||||||
|
{
|
||||||
|
_logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. No URL configured.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_globalSettings.SelfHosted && !_globalSettings.EnableCloudCommunication)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. Cloud communication is disabled in global settings.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var remoteChecksum = await _cloudPhishingDomainQuery.GetRemoteChecksumAsync();
|
||||||
|
if (string.IsNullOrWhiteSpace(remoteChecksum))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(Constants.BypassFiltersEventId, "Could not retrieve remote checksum. Skipping update.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentChecksum = await _phishingDomainRepository.GetCurrentChecksumAsync();
|
||||||
|
|
||||||
|
if (string.Equals(currentChecksum, remoteChecksum, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_logger.LogInformation(Constants.BypassFiltersEventId,
|
||||||
|
"Phishing domains list is up to date (checksum: {Checksum}). Skipping update.",
|
||||||
|
currentChecksum);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(Constants.BypassFiltersEventId,
|
||||||
|
"Checksums differ (current: {CurrentChecksum}, remote: {RemoteChecksum}). Fetching updated domains from {Source}.",
|
||||||
|
currentChecksum, remoteChecksum, _globalSettings.SelfHosted ? "Bitwarden cloud API" : "external source");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var domains = await _cloudPhishingDomainQuery.GetPhishingDomainsAsync();
|
||||||
|
if (!domains.Contains("phishing.testcategory.com", StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
domains.Add("phishing.testcategory.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domains.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(Constants.BypassFiltersEventId, "Updating {Count} phishing domains with checksum {Checksum}.",
|
||||||
|
domains.Count, remoteChecksum);
|
||||||
|
await _phishingDomainRepository.UpdatePhishingDomainsAsync(domains, remoteChecksum);
|
||||||
|
_logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated phishing domains.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning(Constants.BypassFiltersEventId, "No valid domains found in the response. Skipping update.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(Constants.BypassFiltersEventId, ex, "Error updating phishing domains.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -27,6 +27,7 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions;
|
|||||||
using Bit.Core.Tools.Entities;
|
using Bit.Core.Tools.Entities;
|
||||||
using Bit.Core.Vault.Entities;
|
using Bit.Core.Vault.Entities;
|
||||||
using Bit.Api.Auth.Models.Request.WebAuthn;
|
using Bit.Api.Auth.Models.Request.WebAuthn;
|
||||||
|
using Bit.Api.Billing;
|
||||||
using Bit.Core.AdminConsole.Services.NoopImplementations;
|
using Bit.Core.AdminConsole.Services.NoopImplementations;
|
||||||
using Bit.Core.Auth.Models.Data;
|
using Bit.Core.Auth.Models.Data;
|
||||||
using Bit.Core.Auth.Identity.TokenProviders;
|
using Bit.Core.Auth.Identity.TokenProviders;
|
||||||
@ -182,6 +183,9 @@ public class Startup
|
|||||||
services.AddBillingOperations();
|
services.AddBillingOperations();
|
||||||
services.AddReportingServices();
|
services.AddReportingServices();
|
||||||
services.AddImportServices();
|
services.AddImportServices();
|
||||||
|
services.AddPhishingDomainServices(globalSettings);
|
||||||
|
|
||||||
|
services.AddBillingQueries();
|
||||||
|
|
||||||
// Authorization Handlers
|
// Authorization Handlers
|
||||||
services.AddAuthorizationHandlers();
|
services.AddAuthorizationHandlers();
|
||||||
|
@ -3,6 +3,10 @@ using Bit.Api.Tools.Authorization;
|
|||||||
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
||||||
using Bit.Core.IdentityServer;
|
using Bit.Core.IdentityServer;
|
||||||
|
using Bit.Core.PhishingDomainFeatures;
|
||||||
|
using Bit.Core.PhishingDomainFeatures.Interfaces;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Repositories.Implementations;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Bit.Core.Vault.Authorization.SecurityTasks;
|
using Bit.Core.Vault.Authorization.SecurityTasks;
|
||||||
@ -109,4 +113,25 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
services.AddScoped<IAuthorizationHandler, OrganizationRequirementHandler>();
|
services.AddScoped<IAuthorizationHandler, OrganizationRequirementHandler>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void AddPhishingDomainServices(this IServiceCollection services, GlobalSettings globalSettings)
|
||||||
|
{
|
||||||
|
services.AddHttpClient("PhishingDomains", client =>
|
||||||
|
{
|
||||||
|
client.DefaultRequestHeaders.Add("User-Agent", globalSettings.SelfHosted ? "Bitwarden Self-Hosted" : "Bitwarden");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(1000); // the source list is very slow
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddSingleton<AzurePhishingDomainStorageService>();
|
||||||
|
services.AddSingleton<IPhishingDomainRepository, AzurePhishingDomainRepository>();
|
||||||
|
|
||||||
|
if (globalSettings.SelfHosted)
|
||||||
|
{
|
||||||
|
services.AddScoped<ICloudPhishingDomainQuery, CloudPhishingDomainRelayQuery>();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
services.AddScoped<ICloudPhishingDomainQuery, CloudPhishingDomainDirectQuery>();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -37,6 +37,10 @@
|
|||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
"connectionString": "UseDevelopmentStorage=true"
|
"connectionString": "UseDevelopmentStorage=true"
|
||||||
|
},
|
||||||
|
"phishingDomain": {
|
||||||
|
"updateUrl": "https://phish.co.za/latest/phishing-domains-ACTIVE.txt",
|
||||||
|
"checksumUrl": "https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-domains-ACTIVE.txt.sha256"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -71,6 +71,9 @@
|
|||||||
"accessKeySecret": "SECRET",
|
"accessKeySecret": "SECRET",
|
||||||
"region": "SECRET"
|
"region": "SECRET"
|
||||||
},
|
},
|
||||||
|
"phishingDomain": {
|
||||||
|
"updateUrl": "SECRET"
|
||||||
|
},
|
||||||
"distributedIpRateLimiting": {
|
"distributedIpRateLimiting": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"maxRedisTimeoutsThreshold": 10,
|
"maxRedisTimeoutsThreshold": 10,
|
||||||
|
@ -44,7 +44,7 @@ public class InviteUsersPasswordManagerValidator(
|
|||||||
return new Invalid<PasswordManagerSubscriptionUpdate>(new PasswordManagerMustHaveSeatsError(subscriptionUpdate));
|
return new Invalid<PasswordManagerSubscriptionUpdate>(new PasswordManagerMustHaveSeatsError(subscriptionUpdate));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subscriptionUpdate.MaxSeatsReached)
|
if (subscriptionUpdate.MaxSeatsExceeded)
|
||||||
{
|
{
|
||||||
return new Invalid<PasswordManagerSubscriptionUpdate>(
|
return new Invalid<PasswordManagerSubscriptionUpdate>(
|
||||||
new PasswordManagerSeatLimitHasBeenReachedError(subscriptionUpdate));
|
new PasswordManagerSeatLimitHasBeenReachedError(subscriptionUpdate));
|
||||||
|
@ -48,6 +48,11 @@ public class PasswordManagerSubscriptionUpdate
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool MaxSeatsReached => UpdatedSeatTotal.HasValue && MaxAutoScaleSeats.HasValue && UpdatedSeatTotal.Value >= MaxAutoScaleSeats.Value;
|
public bool MaxSeatsReached => UpdatedSeatTotal.HasValue && MaxAutoScaleSeats.HasValue && UpdatedSeatTotal.Value >= MaxAutoScaleSeats.Value;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// If the new seat total exceeds the organization's auto-scale seat limit
|
||||||
|
/// </summary>
|
||||||
|
public bool MaxSeatsExceeded => UpdatedSeatTotal.HasValue && MaxAutoScaleSeats.HasValue && UpdatedSeatTotal.Value > MaxAutoScaleSeats.Value;
|
||||||
|
|
||||||
public Plan.PasswordManagerPlanFeatures PasswordManagerPlan { get; }
|
public Plan.PasswordManagerPlanFeatures PasswordManagerPlan { get; }
|
||||||
|
|
||||||
public InviteOrganization InviteOrganization { get; }
|
public InviteOrganization InviteOrganization { get; }
|
||||||
|
@ -0,0 +1,128 @@
|
|||||||
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.Services;
|
||||||
|
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Models.Data;
|
||||||
|
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
using Bit.Core.Tokens;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
|
||||||
|
|
||||||
|
public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand
|
||||||
|
{
|
||||||
|
|
||||||
|
private readonly IOrganizationService _organizationService;
|
||||||
|
private readonly ICollectionRepository _collectionRepository;
|
||||||
|
private readonly IOrganizationRepository _organizationRepository;
|
||||||
|
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||||
|
private readonly IDataProtector _dataProtector;
|
||||||
|
private readonly IGlobalSettings _globalSettings;
|
||||||
|
private readonly IPolicyService _policyService;
|
||||||
|
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||||
|
|
||||||
|
public InitPendingOrganizationCommand(
|
||||||
|
IOrganizationService organizationService,
|
||||||
|
ICollectionRepository collectionRepository,
|
||||||
|
IOrganizationRepository organizationRepository,
|
||||||
|
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||||
|
IDataProtectionProvider dataProtectionProvider,
|
||||||
|
IGlobalSettings globalSettings,
|
||||||
|
IPolicyService policyService,
|
||||||
|
IOrganizationUserRepository organizationUserRepository
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_organizationService = organizationService;
|
||||||
|
_collectionRepository = collectionRepository;
|
||||||
|
_organizationRepository = organizationRepository;
|
||||||
|
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
||||||
|
_dataProtector = dataProtectionProvider.CreateProtector(OrgUserInviteTokenable.DataProtectorPurpose);
|
||||||
|
_globalSettings = globalSettings;
|
||||||
|
_policyService = policyService;
|
||||||
|
_organizationUserRepository = organizationUserRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitPendingOrganizationAsync(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken)
|
||||||
|
{
|
||||||
|
await ValidateSignUpPoliciesAsync(user.Id);
|
||||||
|
|
||||||
|
var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId);
|
||||||
|
if (orgUser == null)
|
||||||
|
{
|
||||||
|
throw new BadRequestException("User invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenValid = ValidateInviteToken(orgUser, user, emailToken);
|
||||||
|
|
||||||
|
if (!tokenValid)
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Invalid token");
|
||||||
|
}
|
||||||
|
|
||||||
|
var org = await _organizationRepository.GetByIdAsync(organizationId);
|
||||||
|
|
||||||
|
if (org.Enabled)
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Organization is already enabled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (org.Status != OrganizationStatusType.Pending)
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Organization is not on a Pending status.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(org.PublicKey))
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Organization already has a Public Key.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(org.PrivateKey))
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Organization already has a Private Key.");
|
||||||
|
}
|
||||||
|
|
||||||
|
org.Enabled = true;
|
||||||
|
org.Status = OrganizationStatusType.Created;
|
||||||
|
org.PublicKey = publicKey;
|
||||||
|
org.PrivateKey = privateKey;
|
||||||
|
|
||||||
|
await _organizationService.UpdateAsync(org);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(collectionName))
|
||||||
|
{
|
||||||
|
// give the owner Can Manage access over the default collection
|
||||||
|
List<CollectionAccessSelection> defaultOwnerAccess =
|
||||||
|
[new CollectionAccessSelection { Id = orgUser.Id, HidePasswords = false, ReadOnly = false, Manage = true }];
|
||||||
|
|
||||||
|
var defaultCollection = new Collection
|
||||||
|
{
|
||||||
|
Name = collectionName,
|
||||||
|
OrganizationId = org.Id
|
||||||
|
};
|
||||||
|
await _collectionRepository.CreateAsync(defaultCollection, null, defaultOwnerAccess);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ValidateSignUpPoliciesAsync(Guid ownerId)
|
||||||
|
{
|
||||||
|
var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg);
|
||||||
|
if (anySingleOrgPolicies)
|
||||||
|
{
|
||||||
|
throw new BadRequestException("You may not create an organization. You belong to an organization " +
|
||||||
|
"which has a policy that prohibits you from being a member of any other organization.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ValidateInviteToken(OrganizationUser orgUser, User user, string emailToken)
|
||||||
|
{
|
||||||
|
var tokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken(
|
||||||
|
_orgUserInviteTokenDataFactory, emailToken, orgUser);
|
||||||
|
|
||||||
|
return tokenValid;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
using Bit.Core.Entities;
|
||||||
|
namespace Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
|
|
||||||
|
public interface IInitPendingOrganizationCommand
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Update an Organization entry by setting the public/private keys, set it as 'Enabled' and move the Status from 'Pending' to 'Created'.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This method must target a disabled Organization that has null keys and status as 'Pending'.
|
||||||
|
/// </remarks>
|
||||||
|
Task InitPendingOrganizationAsync(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken);
|
||||||
|
}
|
@ -48,14 +48,8 @@ public interface IOrganizationService
|
|||||||
Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
|
Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
|
||||||
IEnumerable<Guid> organizationUserIds, Guid? revokingUserId);
|
IEnumerable<Guid> organizationUserIds, Guid? revokingUserId);
|
||||||
Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted);
|
Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted);
|
||||||
/// <summary>
|
|
||||||
/// Update an Organization entry by setting the public/private keys, set it as 'Enabled' and move the Status from 'Pending' to 'Created'.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// This method must target a disabled Organization that has null keys and status as 'Pending'.
|
|
||||||
/// </remarks>
|
|
||||||
Task InitPendingOrganization(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken);
|
|
||||||
Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null);
|
Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null);
|
||||||
|
Task<(bool canScale, string failureReason)> CanScaleAsync(Organization organization, int seatsToAdd);
|
||||||
|
|
||||||
void ValidatePasswordManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade);
|
void ValidatePasswordManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade);
|
||||||
void ValidateSecretsManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade);
|
void ValidateSecretsManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade);
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Models.Business.Provider;
|
using Bit.Core.AdminConsole.Models.Business.Provider;
|
||||||
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Models.Business;
|
using Bit.Core.Models.Business;
|
||||||
|
|
||||||
@ -7,7 +8,8 @@ namespace Bit.Core.AdminConsole.Services;
|
|||||||
|
|
||||||
public interface IProviderService
|
public interface IProviderService
|
||||||
{
|
{
|
||||||
Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo = null);
|
Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo,
|
||||||
|
TokenizedPaymentSource tokenizedPaymentSource = null);
|
||||||
Task UpdateAsync(Provider provider, bool updateBilling = false);
|
Task UpdateAsync(Provider provider, bool updateBilling = false);
|
||||||
|
|
||||||
Task<List<ProviderUser>> InviteUserAsync(ProviderUserInvite<string> invite);
|
Task<List<ProviderUser>> InviteUserAsync(ProviderUserInvite<string> invite);
|
||||||
|
@ -13,7 +13,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
|||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
using Bit.Core.Auth.Enums;
|
using Bit.Core.Auth.Enums;
|
||||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
|
||||||
using Bit.Core.Auth.Repositories;
|
using Bit.Core.Auth.Repositories;
|
||||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
@ -31,12 +30,10 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
|||||||
using Bit.Core.Platform.Push;
|
using Bit.Core.Platform.Push;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Tokens;
|
|
||||||
using Bit.Core.Tools.Enums;
|
using Bit.Core.Tools.Enums;
|
||||||
using Bit.Core.Tools.Models.Business;
|
using Bit.Core.Tools.Models.Business;
|
||||||
using Bit.Core.Tools.Services;
|
using Bit.Core.Tools.Services;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
|
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
|
||||||
@ -77,8 +74,6 @@ public class OrganizationService : IOrganizationService
|
|||||||
private readonly IPricingClient _pricingClient;
|
private readonly IPricingClient _pricingClient;
|
||||||
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
||||||
private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand;
|
private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand;
|
||||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
|
||||||
private readonly IDataProtector _dataProtector;
|
|
||||||
|
|
||||||
public OrganizationService(
|
public OrganizationService(
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
@ -112,9 +107,7 @@ public class OrganizationService : IOrganizationService
|
|||||||
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
|
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
|
||||||
IPricingClient pricingClient,
|
IPricingClient pricingClient,
|
||||||
IPolicyRequirementQuery policyRequirementQuery,
|
IPolicyRequirementQuery policyRequirementQuery,
|
||||||
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand,
|
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand
|
||||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
|
||||||
IDataProtectionProvider dataProtectionProvider
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
@ -149,8 +142,6 @@ public class OrganizationService : IOrganizationService
|
|||||||
_pricingClient = pricingClient;
|
_pricingClient = pricingClient;
|
||||||
_policyRequirementQuery = policyRequirementQuery;
|
_policyRequirementQuery = policyRequirementQuery;
|
||||||
_sendOrganizationInvitesCommand = sendOrganizationInvitesCommand;
|
_sendOrganizationInvitesCommand = sendOrganizationInvitesCommand;
|
||||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
|
||||||
_dataProtector = dataProtectionProvider.CreateProtector(OrgUserInviteTokenable.DataProtectorPurpose);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
|
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
|
||||||
@ -1067,7 +1058,7 @@ public class OrganizationService : IOrganizationService
|
|||||||
organization: organization,
|
organization: organization,
|
||||||
initOrganization: initOrganization));
|
initOrganization: initOrganization));
|
||||||
|
|
||||||
internal async Task<(bool canScale, string failureReason)> CanScaleAsync(
|
public async Task<(bool canScale, string failureReason)> CanScaleAsync(
|
||||||
Organization organization,
|
Organization organization,
|
||||||
int seatsToAdd)
|
int seatsToAdd)
|
||||||
{
|
{
|
||||||
@ -1921,71 +1912,4 @@ public class OrganizationService : IOrganizationService
|
|||||||
SalesAssistedTrialStarted = salesAssistedTrialStarted,
|
SalesAssistedTrialStarted = salesAssistedTrialStarted,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task InitPendingOrganization(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken)
|
|
||||||
{
|
|
||||||
await ValidateSignUpPoliciesAsync(user.Id);
|
|
||||||
|
|
||||||
var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId);
|
|
||||||
if (orgUser == null)
|
|
||||||
{
|
|
||||||
throw new BadRequestException("User invalid.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete
|
|
||||||
var newTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken(
|
|
||||||
_orgUserInviteTokenDataFactory, emailToken, orgUser);
|
|
||||||
|
|
||||||
var tokenValid = newTokenValid ||
|
|
||||||
CoreHelpers.UserInviteTokenIsValid(_dataProtector, emailToken, user.Email, orgUser.Id,
|
|
||||||
_globalSettings);
|
|
||||||
|
|
||||||
if (!tokenValid)
|
|
||||||
{
|
|
||||||
throw new BadRequestException("Invalid token.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var org = await GetOrgById(organizationId);
|
|
||||||
|
|
||||||
if (org.Enabled)
|
|
||||||
{
|
|
||||||
throw new BadRequestException("Organization is already enabled.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (org.Status != OrganizationStatusType.Pending)
|
|
||||||
{
|
|
||||||
throw new BadRequestException("Organization is not on a Pending status.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(org.PublicKey))
|
|
||||||
{
|
|
||||||
throw new BadRequestException("Organization already has a Public Key.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(org.PrivateKey))
|
|
||||||
{
|
|
||||||
throw new BadRequestException("Organization already has a Private Key.");
|
|
||||||
}
|
|
||||||
|
|
||||||
org.Enabled = true;
|
|
||||||
org.Status = OrganizationStatusType.Created;
|
|
||||||
org.PublicKey = publicKey;
|
|
||||||
org.PrivateKey = privateKey;
|
|
||||||
|
|
||||||
await UpdateAsync(org);
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(collectionName))
|
|
||||||
{
|
|
||||||
// give the owner Can Manage access over the default collection
|
|
||||||
List<CollectionAccessSelection> defaultOwnerAccess =
|
|
||||||
[new CollectionAccessSelection { Id = organizationUserId, HidePasswords = false, ReadOnly = false, Manage = true }];
|
|
||||||
|
|
||||||
var defaultCollection = new Collection
|
|
||||||
{
|
|
||||||
Name = collectionName,
|
|
||||||
OrganizationId = org.Id
|
|
||||||
};
|
|
||||||
await _collectionRepository.CreateAsync(defaultCollection, null, defaultOwnerAccess);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Models.Business.Provider;
|
using Bit.Core.AdminConsole.Models.Business.Provider;
|
||||||
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Models.Business;
|
using Bit.Core.Models.Business;
|
||||||
|
|
||||||
@ -7,7 +8,7 @@ namespace Bit.Core.AdminConsole.Services.NoopImplementations;
|
|||||||
|
|
||||||
public class NoopProviderService : IProviderService
|
public class NoopProviderService : IProviderService
|
||||||
{
|
{
|
||||||
public Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo = null) => throw new NotImplementedException();
|
public Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null) => throw new NotImplementedException();
|
||||||
|
|
||||||
public Task UpdateAsync(Provider provider, bool updateBilling = false) => throw new NotImplementedException();
|
public Task UpdateAsync(Provider provider, bool updateBilling = false) => throw new NotImplementedException();
|
||||||
|
|
||||||
|
@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
public static class StripeConstants
|
public static class StripeConstants
|
||||||
{
|
{
|
||||||
|
public static class Prices
|
||||||
|
{
|
||||||
|
public const string StoragePlanPersonal = "personal-storage-gb-annually";
|
||||||
|
}
|
||||||
public static class AutomaticTaxStatus
|
public static class AutomaticTaxStatus
|
||||||
{
|
{
|
||||||
public const string Failed = "failed";
|
public const string Failed = "failed";
|
||||||
@ -42,10 +46,12 @@ public static class StripeConstants
|
|||||||
{
|
{
|
||||||
public const string Draft = "draft";
|
public const string Draft = "draft";
|
||||||
public const string Open = "open";
|
public const string Open = "open";
|
||||||
|
public const string Paid = "paid";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class MetadataKeys
|
public static class MetadataKeys
|
||||||
{
|
{
|
||||||
|
public const string BraintreeCustomerId = "btCustomerId";
|
||||||
public const string InvoiceApproved = "invoice_approved";
|
public const string InvoiceApproved = "invoice_approved";
|
||||||
public const string OrganizationId = "organizationId";
|
public const string OrganizationId = "organizationId";
|
||||||
public const string ProviderId = "providerId";
|
public const string ProviderId = "providerId";
|
||||||
|
@ -41,6 +41,7 @@ public static class OrganizationLicenseConstants
|
|||||||
public const string Refresh = nameof(Refresh);
|
public const string Refresh = nameof(Refresh);
|
||||||
public const string ExpirationWithoutGracePeriod = nameof(ExpirationWithoutGracePeriod);
|
public const string ExpirationWithoutGracePeriod = nameof(ExpirationWithoutGracePeriod);
|
||||||
public const string Trial = nameof(Trial);
|
public const string Trial = nameof(Trial);
|
||||||
|
public const string UseAdminSponsoredFamilies = nameof(UseAdminSponsoredFamilies);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class UserLicenseConstants
|
public static class UserLicenseConstants
|
||||||
|
@ -53,6 +53,7 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory<Organizati
|
|||||||
new(nameof(OrganizationLicenseConstants.Refresh), refresh.ToString(CultureInfo.InvariantCulture)),
|
new(nameof(OrganizationLicenseConstants.Refresh), refresh.ToString(CultureInfo.InvariantCulture)),
|
||||||
new(nameof(OrganizationLicenseConstants.ExpirationWithoutGracePeriod), expirationWithoutGracePeriod.ToString(CultureInfo.InvariantCulture)),
|
new(nameof(OrganizationLicenseConstants.ExpirationWithoutGracePeriod), expirationWithoutGracePeriod.ToString(CultureInfo.InvariantCulture)),
|
||||||
new(nameof(OrganizationLicenseConstants.Trial), trial.ToString()),
|
new(nameof(OrganizationLicenseConstants.Trial), trial.ToString()),
|
||||||
|
new(nameof(OrganizationLicenseConstants.UseAdminSponsoredFamilies), entity.UseAdminSponsoredFamilies.ToString()),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (entity.Name is not null)
|
if (entity.Name is not null)
|
||||||
@ -109,6 +110,7 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory<Organizati
|
|||||||
{
|
{
|
||||||
claims.Add(new Claim(nameof(OrganizationLicenseConstants.SmServiceAccounts), entity.SmServiceAccounts.ToString()));
|
claims.Add(new Claim(nameof(OrganizationLicenseConstants.SmServiceAccounts), entity.SmServiceAccounts.ToString()));
|
||||||
}
|
}
|
||||||
|
claims.Add(new Claim(nameof(OrganizationLicenseConstants.UseAdminSponsoredFamilies), entity.UseAdminSponsoredFamilies.ToString()));
|
||||||
|
|
||||||
return Task.FromResult(claims);
|
return Task.FromResult(claims);
|
||||||
}
|
}
|
||||||
|
@ -38,7 +38,7 @@ public record Families2019Plan : Plan
|
|||||||
HasPremiumAccessOption = true;
|
HasPremiumAccessOption = true;
|
||||||
|
|
||||||
StripePlanId = "personal-org-annually";
|
StripePlanId = "personal-org-annually";
|
||||||
StripeStoragePlanId = "storage-gb-annually";
|
StripeStoragePlanId = "personal-storage-gb-annually";
|
||||||
StripePremiumAccessPlanId = "personal-org-premium-access-annually";
|
StripePremiumAccessPlanId = "personal-org-premium-access-annually";
|
||||||
BasePrice = 12;
|
BasePrice = 12;
|
||||||
AdditionalStoragePricePerGb = 4;
|
AdditionalStoragePricePerGb = 4;
|
||||||
|
@ -37,7 +37,7 @@ public record FamiliesPlan : Plan
|
|||||||
HasAdditionalStorageOption = true;
|
HasAdditionalStorageOption = true;
|
||||||
|
|
||||||
StripePlanId = "2020-families-org-annually";
|
StripePlanId = "2020-families-org-annually";
|
||||||
StripeStoragePlanId = "storage-gb-annually";
|
StripeStoragePlanId = "personal-storage-gb-annually";
|
||||||
BasePrice = 40;
|
BasePrice = 40;
|
||||||
AdditionalStoragePricePerGb = 4;
|
AdditionalStoragePricePerGb = 4;
|
||||||
|
|
||||||
|
@ -79,10 +79,12 @@ public interface IProviderBillingService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="provider">The <see cref="Provider"/> to create a Stripe customer for.</param>
|
/// <param name="provider">The <see cref="Provider"/> to create a Stripe customer for.</param>
|
||||||
/// <param name="taxInfo">The <see cref="TaxInfo"/> to use for calculating the customer's automatic tax.</param>
|
/// <param name="taxInfo">The <see cref="TaxInfo"/> to use for calculating the customer's automatic tax.</param>
|
||||||
|
/// <param name="tokenizedPaymentSource">The <see cref="TokenizedPaymentSource"/> (ex. Credit Card) to attach to the customer.</param>
|
||||||
/// <returns>The newly created <see cref="Stripe.Customer"/> for the <paramref name="provider"/>.</returns>
|
/// <returns>The newly created <see cref="Stripe.Customer"/> for the <paramref name="provider"/>.</returns>
|
||||||
Task<Customer> SetupCustomer(
|
Task<Customer> SetupCustomer(
|
||||||
Provider provider,
|
Provider provider,
|
||||||
TaxInfo taxInfo);
|
TaxInfo taxInfo,
|
||||||
|
TokenizedPaymentSource tokenizedPaymentSource = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// For use during the provider setup process, this method starts a Stripe <see cref="Stripe.Subscription"/> for the given <paramref name="provider"/>.
|
/// For use during the provider setup process, this method starts a Stripe <see cref="Stripe.Subscription"/> for the given <paramref name="provider"/>.
|
||||||
|
@ -313,7 +313,7 @@ public class PremiumUserBillingService(
|
|||||||
{
|
{
|
||||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||||
{
|
{
|
||||||
Price = "storage-gb-annually",
|
Price = StripeConstants.Prices.StoragePlanPersonal,
|
||||||
Quantity = storage
|
Quantity = storage
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -108,6 +108,7 @@ public static class FeatureFlagKeys
|
|||||||
public const string PolicyRequirements = "pm-14439-policy-requirements";
|
public const string PolicyRequirements = "pm-14439-policy-requirements";
|
||||||
public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility";
|
public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility";
|
||||||
public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast";
|
public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast";
|
||||||
|
public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations";
|
||||||
|
|
||||||
/* Auth Team */
|
/* Auth Team */
|
||||||
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
|
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
|
||||||
@ -142,13 +143,18 @@ public static class FeatureFlagKeys
|
|||||||
public const string TrialPayment = "PM-8163-trial-payment";
|
public const string TrialPayment = "PM-8163-trial-payment";
|
||||||
public const string PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships";
|
public const string PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships";
|
||||||
public const string UsePricingService = "use-pricing-service";
|
public const string UsePricingService = "use-pricing-service";
|
||||||
public const string P15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal";
|
|
||||||
public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features";
|
public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features";
|
||||||
public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method";
|
public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method";
|
||||||
public const string PM19147_AutomaticTaxImprovements = "pm-19147-automatic-tax-improvements";
|
public const string PM19147_AutomaticTaxImprovements = "pm-19147-automatic-tax-improvements";
|
||||||
public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates";
|
public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates";
|
||||||
public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion";
|
public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion";
|
||||||
public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically";
|
public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically";
|
||||||
|
public const string PM19956_RequireProviderPaymentMethodDuringSetup = "pm-19956-require-provider-payment-method-during-setup";
|
||||||
|
public const string UseOrganizationWarningsService = "use-organization-warnings-service";
|
||||||
|
|
||||||
|
/* Data Insights and Reporting Team */
|
||||||
|
public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application";
|
||||||
|
public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications";
|
||||||
|
|
||||||
/* Key Management Team */
|
/* Key Management Team */
|
||||||
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
|
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
|
||||||
@ -186,8 +192,6 @@ public static class FeatureFlagKeys
|
|||||||
|
|
||||||
/* Tools Team */
|
/* Tools Team */
|
||||||
public const string ItemShare = "item-share";
|
public const string ItemShare = "item-share";
|
||||||
public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application";
|
|
||||||
public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications";
|
|
||||||
public const string DesktopSendUIRefresh = "desktop-send-ui-refresh";
|
public const string DesktopSendUIRefresh = "desktop-send-ui-refresh";
|
||||||
|
|
||||||
/* Vault Team */
|
/* Vault Team */
|
||||||
@ -195,13 +199,13 @@ public static class FeatureFlagKeys
|
|||||||
public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form";
|
public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form";
|
||||||
public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss";
|
public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss";
|
||||||
public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss";
|
public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss";
|
||||||
public const string VaultBulkManagementAction = "vault-bulk-management-action";
|
|
||||||
public const string RestrictProviderAccess = "restrict-provider-access";
|
public const string RestrictProviderAccess = "restrict-provider-access";
|
||||||
public const string SecurityTasks = "security-tasks";
|
public const string SecurityTasks = "security-tasks";
|
||||||
public const string CipherKeyEncryption = "cipher-key-encryption";
|
public const string CipherKeyEncryption = "cipher-key-encryption";
|
||||||
public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms";
|
public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms";
|
||||||
public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk";
|
public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk";
|
||||||
public const string EndUserNotifications = "pm-10609-end-user-notifications";
|
public const string EndUserNotifications = "pm-10609-end-user-notifications";
|
||||||
|
public const string PhishingDetection = "phishing-detection";
|
||||||
|
|
||||||
public static List<string> GetAllKeys()
|
public static List<string> GetAllKeys()
|
||||||
{
|
{
|
||||||
|
@ -38,7 +38,7 @@
|
|||||||
<PackageReference Include="Handlebars.Net" Version="2.1.6" />
|
<PackageReference Include="Handlebars.Net" Version="2.1.6" />
|
||||||
<PackageReference Include="MailKit" Version="4.11.0" />
|
<PackageReference Include="MailKit" Version="4.11.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
|
||||||
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.46.1" />
|
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.49.0" />
|
||||||
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />
|
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.7.0" />
|
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.7.0" />
|
||||||
|
@ -0,0 +1,37 @@
|
|||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Models.Data.Organizations.OrganizationSponsorships;
|
||||||
|
|
||||||
|
namespace Bit.Core.Models.Api.Response.OrganizationSponsorships;
|
||||||
|
|
||||||
|
public class OrganizationSponsorshipInvitesResponseModel : ResponseModel
|
||||||
|
{
|
||||||
|
public OrganizationSponsorshipInvitesResponseModel(OrganizationSponsorshipData sponsorshipData, string obj = "organizationSponsorship") : base(obj)
|
||||||
|
{
|
||||||
|
if (sponsorshipData == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(sponsorshipData));
|
||||||
|
}
|
||||||
|
|
||||||
|
SponsoringOrganizationUserId = sponsorshipData.SponsoringOrganizationUserId;
|
||||||
|
FriendlyName = sponsorshipData.FriendlyName;
|
||||||
|
OfferedToEmail = sponsorshipData.OfferedToEmail;
|
||||||
|
PlanSponsorshipType = sponsorshipData.PlanSponsorshipType;
|
||||||
|
LastSyncDate = sponsorshipData.LastSyncDate;
|
||||||
|
ValidUntil = sponsorshipData.ValidUntil;
|
||||||
|
ToDelete = sponsorshipData.ToDelete;
|
||||||
|
IsAdminInitiated = sponsorshipData.IsAdminInitiated;
|
||||||
|
Notes = sponsorshipData.Notes;
|
||||||
|
CloudSponsorshipRemoved = sponsorshipData.CloudSponsorshipRemoved;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid SponsoringOrganizationUserId { get; set; }
|
||||||
|
public string FriendlyName { get; set; }
|
||||||
|
public string OfferedToEmail { get; set; }
|
||||||
|
public PlanSponsorshipType PlanSponsorshipType { get; set; }
|
||||||
|
public DateTime? LastSyncDate { get; set; }
|
||||||
|
public DateTime? ValidUntil { get; set; }
|
||||||
|
public bool ToDelete { get; set; }
|
||||||
|
public bool IsAdminInitiated { get; set; }
|
||||||
|
public string Notes { get; set; }
|
||||||
|
public bool CloudSponsorshipRemoved { get; set; }
|
||||||
|
}
|
@ -19,6 +19,34 @@ public class OrganizationLicense : ILicense
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="OrganizationLicense"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ⚠️ DEPRECATED: This constructor and the entire property-based licensing system is deprecated.
|
||||||
|
/// Do not add new properties to this constructor or extend its functionality.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// This implementation has been replaced by a new claims-based licensing system that provides better security
|
||||||
|
/// and flexibility. The new system uses JWT claims to store and validate license information, making it more
|
||||||
|
/// secure and easier to extend without requiring changes to the license format.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// For new license-related features or modifications:
|
||||||
|
/// 1. Use the claims-based system instead of adding properties here
|
||||||
|
/// 2. Add new claims to the license token
|
||||||
|
/// 3. Validate claims in the <see cref="CanUse"/> and <see cref="VerifyData"/> methods
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// This constructor is maintained only for backward compatibility with existing licenses.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="org">The organization to create the license for.</param>
|
||||||
|
/// <param name="subscriptionInfo">Information about the organization's subscription.</param>
|
||||||
|
/// <param name="installationId">The ID of the current installation.</param>
|
||||||
|
/// <param name="licenseService">The service used to sign the license.</param>
|
||||||
|
/// <param name="version">Optional version number for the license format.</param>
|
||||||
public OrganizationLicense(Organization org, SubscriptionInfo subscriptionInfo, Guid installationId,
|
public OrganizationLicense(Organization org, SubscriptionInfo subscriptionInfo, Guid installationId,
|
||||||
ILicensingService licenseService, int? version = null)
|
ILicensingService licenseService, int? version = null)
|
||||||
{
|
{
|
||||||
@ -105,6 +133,7 @@ public class OrganizationLicense : ILicense
|
|||||||
Trial = false;
|
Trial = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UseAdminSponsoredFamilies = org.UseAdminSponsoredFamilies;
|
||||||
Hash = Convert.ToBase64String(ComputeHash());
|
Hash = Convert.ToBase64String(ComputeHash());
|
||||||
Signature = Convert.ToBase64String(licenseService.SignLicense(this));
|
Signature = Convert.ToBase64String(licenseService.SignLicense(this));
|
||||||
}
|
}
|
||||||
@ -153,6 +182,7 @@ public class OrganizationLicense : ILicense
|
|||||||
|
|
||||||
public bool Trial { get; set; }
|
public bool Trial { get; set; }
|
||||||
public LicenseType? LicenseType { get; set; }
|
public LicenseType? LicenseType { get; set; }
|
||||||
|
public bool UseAdminSponsoredFamilies { get; set; }
|
||||||
public string Hash { get; set; }
|
public string Hash { get; set; }
|
||||||
public string Signature { get; set; }
|
public string Signature { get; set; }
|
||||||
public string Token { get; set; }
|
public string Token { get; set; }
|
||||||
@ -292,13 +322,35 @@ public class OrganizationLicense : ILicense
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Do not extend this method. It is only here for backwards compatibility with old licenses.
|
/// Validates an obsolete license format using property-based validation.
|
||||||
/// Instead, extend the CanUse method using the ClaimsPrincipal.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="globalSettings"></param>
|
/// <remarks>
|
||||||
/// <param name="licensingService"></param>
|
/// <para>
|
||||||
/// <param name="exception"></param>
|
/// ⚠️ DEPRECATED: This method is deprecated and should not be extended or modified.
|
||||||
/// <returns></returns>
|
/// It is maintained only for backward compatibility with old license formats.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// This method has been replaced by a new claims-based validation system that provides:
|
||||||
|
/// - Better security through JWT claims
|
||||||
|
/// - More flexible validation rules
|
||||||
|
/// - Easier extensibility without changing the license format
|
||||||
|
/// - Better separation of concerns
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// To add new license validation rules:
|
||||||
|
/// 1. Add new claims to the license token in the claims-based system
|
||||||
|
/// 2. Extend the <see cref="CanUse(IGlobalSettings, ILicensingService, ClaimsPrincipal, out string)"/> method
|
||||||
|
/// 3. Validate the new claims using the ClaimsPrincipal parameter
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// This method will be removed in a future version once all old licenses have been migrated
|
||||||
|
/// to the new claims-based system.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="globalSettings">The global settings containing installation information.</param>
|
||||||
|
/// <param name="licensingService">The service used to verify the license signature.</param>
|
||||||
|
/// <param name="exception">When the method returns false, contains the error message explaining why the license is invalid.</param>
|
||||||
|
/// <returns>True if the license is valid, false otherwise.</returns>
|
||||||
private bool ObsoleteCanUse(IGlobalSettings globalSettings, ILicensingService licensingService, out string exception)
|
private bool ObsoleteCanUse(IGlobalSettings globalSettings, ILicensingService licensingService, out string exception)
|
||||||
{
|
{
|
||||||
// Do not extend this method. It is only here for backwards compatibility with old licenses.
|
// Do not extend this method. It is only here for backwards compatibility with old licenses.
|
||||||
@ -392,6 +444,7 @@ public class OrganizationLicense : ILicense
|
|||||||
var usePasswordManager = claimsPrincipal.GetValue<bool>(nameof(UsePasswordManager));
|
var usePasswordManager = claimsPrincipal.GetValue<bool>(nameof(UsePasswordManager));
|
||||||
var smSeats = claimsPrincipal.GetValue<int?>(nameof(SmSeats));
|
var smSeats = claimsPrincipal.GetValue<int?>(nameof(SmSeats));
|
||||||
var smServiceAccounts = claimsPrincipal.GetValue<int?>(nameof(SmServiceAccounts));
|
var smServiceAccounts = claimsPrincipal.GetValue<int?>(nameof(SmServiceAccounts));
|
||||||
|
var useAdminSponsoredFamilies = claimsPrincipal.GetValue<bool>(nameof(UseAdminSponsoredFamilies));
|
||||||
|
|
||||||
return issued <= DateTime.UtcNow &&
|
return issued <= DateTime.UtcNow &&
|
||||||
expires >= DateTime.UtcNow &&
|
expires >= DateTime.UtcNow &&
|
||||||
@ -419,7 +472,9 @@ public class OrganizationLicense : ILicense
|
|||||||
useSecretsManager == organization.UseSecretsManager &&
|
useSecretsManager == organization.UseSecretsManager &&
|
||||||
usePasswordManager == organization.UsePasswordManager &&
|
usePasswordManager == organization.UsePasswordManager &&
|
||||||
smSeats == organization.SmSeats &&
|
smSeats == organization.SmSeats &&
|
||||||
smServiceAccounts == organization.SmServiceAccounts;
|
smServiceAccounts == organization.SmServiceAccounts &&
|
||||||
|
useAdminSponsoredFamilies == organization.UseAdminSponsoredFamilies;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -193,6 +193,7 @@ public static class OrganizationServiceCollectionExtensions
|
|||||||
services.AddScoped<IInviteUsersOrganizationValidator, InviteUsersOrganizationValidator>();
|
services.AddScoped<IInviteUsersOrganizationValidator, InviteUsersOrganizationValidator>();
|
||||||
services.AddScoped<IInviteUsersPasswordManagerValidator, InviteUsersPasswordManagerValidator>();
|
services.AddScoped<IInviteUsersPasswordManagerValidator, InviteUsersPasswordManagerValidator>();
|
||||||
services.AddScoped<IInviteUsersEnvironmentValidator, InviteUsersEnvironmentValidator>();
|
services.AddScoped<IInviteUsersEnvironmentValidator, InviteUsersEnvironmentValidator>();
|
||||||
|
services.AddScoped<IInitPendingOrganizationCommand, InitPendingOrganizationCommand>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of
|
// TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of
|
||||||
|
@ -15,7 +15,8 @@ public class CreateSponsorshipCommand(
|
|||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
IOrganizationSponsorshipRepository organizationSponsorshipRepository,
|
IOrganizationSponsorshipRepository organizationSponsorshipRepository,
|
||||||
IUserService userService,
|
IUserService userService,
|
||||||
IOrganizationService organizationService) : ICreateSponsorshipCommand
|
IOrganizationService organizationService,
|
||||||
|
IOrganizationUserRepository organizationUserRepository) : ICreateSponsorshipCommand
|
||||||
{
|
{
|
||||||
public async Task<OrganizationSponsorship> CreateSponsorshipAsync(
|
public async Task<OrganizationSponsorship> CreateSponsorshipAsync(
|
||||||
Organization sponsoringOrganization,
|
Organization sponsoringOrganization,
|
||||||
@ -47,11 +48,12 @@ public class CreateSponsorshipCommand(
|
|||||||
throw new BadRequestException("Only confirmed users can sponsor other organizations.");
|
throw new BadRequestException("Only confirmed users can sponsor other organizations.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var existingOrgSponsorship = await organizationSponsorshipRepository
|
var sponsorships =
|
||||||
.GetBySponsoringOrganizationUserIdAsync(sponsoringMember.Id);
|
await organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(sponsoringOrganization.Id);
|
||||||
if (existingOrgSponsorship?.SponsoredOrganizationId != null)
|
var existingSponsorship = sponsorships.FirstOrDefault(s => s.FriendlyName == friendlyName);
|
||||||
|
if (existingSponsorship != null)
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Can only sponsor one organization per Organization User.");
|
return existingSponsorship;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAdminInitiated)
|
if (isAdminInitiated)
|
||||||
@ -70,15 +72,37 @@ public class CreateSponsorshipCommand(
|
|||||||
Notes = notes
|
Notes = notes
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!isAdminInitiated)
|
||||||
|
{
|
||||||
|
var existingOrgSponsorship = await organizationSponsorshipRepository
|
||||||
|
.GetBySponsoringOrganizationUserIdAsync(sponsoringMember.Id);
|
||||||
|
if (existingOrgSponsorship?.SponsoredOrganizationId != null)
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Can only sponsor one organization per Organization User.");
|
||||||
|
}
|
||||||
|
|
||||||
if (existingOrgSponsorship != null)
|
if (existingOrgSponsorship != null)
|
||||||
{
|
{
|
||||||
// Replace existing invalid offer with our new sponsorship offer
|
|
||||||
sponsorship.Id = existingOrgSponsorship.Id;
|
sponsorship.Id = existingOrgSponsorship.Id;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isAdminInitiated && sponsoringOrganization.Seats.HasValue)
|
if (isAdminInitiated && sponsoringOrganization.Seats.HasValue)
|
||||||
{
|
{
|
||||||
await organizationService.AutoAddSeatsAsync(sponsoringOrganization, 1);
|
var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrganization.Id);
|
||||||
|
var availableSeats = sponsoringOrganization.Seats.Value - occupiedSeats;
|
||||||
|
|
||||||
|
if (availableSeats <= 0)
|
||||||
|
{
|
||||||
|
var newSeatsRequired = 1;
|
||||||
|
var (canScale, failureReason) = await organizationService.CanScaleAsync(sponsoringOrganization, newSeatsRequired);
|
||||||
|
if (!canScale)
|
||||||
|
{
|
||||||
|
throw new BadRequestException(failureReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
await organizationService.AutoAddSeatsAsync(sponsoringOrganization, newSeatsRequired);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
|
@ -0,0 +1,92 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Azure.Storage.Blobs;
|
||||||
|
using Azure.Storage.Blobs.Models;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Bit.Core.PhishingDomainFeatures;
|
||||||
|
|
||||||
|
public class AzurePhishingDomainStorageService
|
||||||
|
{
|
||||||
|
private const string _containerName = "phishingdomains";
|
||||||
|
private const string _domainsFileName = "domains.txt";
|
||||||
|
private const string _checksumFileName = "checksum.txt";
|
||||||
|
|
||||||
|
private readonly BlobServiceClient _blobServiceClient;
|
||||||
|
private readonly ILogger<AzurePhishingDomainStorageService> _logger;
|
||||||
|
private BlobContainerClient _containerClient;
|
||||||
|
|
||||||
|
public AzurePhishingDomainStorageService(
|
||||||
|
GlobalSettings globalSettings,
|
||||||
|
ILogger<AzurePhishingDomainStorageService> logger)
|
||||||
|
{
|
||||||
|
_blobServiceClient = new BlobServiceClient(globalSettings.Storage.ConnectionString);
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ICollection<string>> GetDomainsAsync()
|
||||||
|
{
|
||||||
|
await InitAsync();
|
||||||
|
|
||||||
|
var blobClient = _containerClient.GetBlobClient(_domainsFileName);
|
||||||
|
if (!await blobClient.ExistsAsync())
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await blobClient.DownloadAsync();
|
||||||
|
using var streamReader = new StreamReader(response.Value.Content);
|
||||||
|
var content = await streamReader.ReadToEndAsync();
|
||||||
|
|
||||||
|
return [.. content
|
||||||
|
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(line => line.Trim())
|
||||||
|
.Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith('#'))];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetChecksumAsync()
|
||||||
|
{
|
||||||
|
await InitAsync();
|
||||||
|
|
||||||
|
var blobClient = _containerClient.GetBlobClient(_checksumFileName);
|
||||||
|
if (!await blobClient.ExistsAsync())
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await blobClient.DownloadAsync();
|
||||||
|
using var streamReader = new StreamReader(response.Value.Content);
|
||||||
|
return (await streamReader.ReadToEndAsync()).Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateDomainsAsync(IEnumerable<string> domains, string checksum)
|
||||||
|
{
|
||||||
|
await InitAsync();
|
||||||
|
|
||||||
|
var domainsContent = string.Join(Environment.NewLine, domains);
|
||||||
|
var domainsStream = new MemoryStream(Encoding.UTF8.GetBytes(domainsContent));
|
||||||
|
var domainsBlobClient = _containerClient.GetBlobClient(_domainsFileName);
|
||||||
|
|
||||||
|
await domainsBlobClient.UploadAsync(domainsStream, new BlobUploadOptions
|
||||||
|
{
|
||||||
|
HttpHeaders = new BlobHttpHeaders { ContentType = "text/plain" }
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
var checksumStream = new MemoryStream(Encoding.UTF8.GetBytes(checksum));
|
||||||
|
var checksumBlobClient = _containerClient.GetBlobClient(_checksumFileName);
|
||||||
|
|
||||||
|
await checksumBlobClient.UploadAsync(checksumStream, new BlobUploadOptions
|
||||||
|
{
|
||||||
|
HttpHeaders = new BlobHttpHeaders { ContentType = "text/plain" }
|
||||||
|
}, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InitAsync()
|
||||||
|
{
|
||||||
|
if (_containerClient is null)
|
||||||
|
{
|
||||||
|
_containerClient = _blobServiceClient.GetBlobContainerClient(_containerName);
|
||||||
|
await _containerClient.CreateIfNotExistsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,100 @@
|
|||||||
|
using Bit.Core.PhishingDomainFeatures.Interfaces;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Bit.Core.PhishingDomainFeatures;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implementation of ICloudPhishingDomainQuery for cloud environments
|
||||||
|
/// that directly calls the external phishing domain source
|
||||||
|
/// </summary>
|
||||||
|
public class CloudPhishingDomainDirectQuery : ICloudPhishingDomainQuery
|
||||||
|
{
|
||||||
|
private readonly IGlobalSettings _globalSettings;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly ILogger<CloudPhishingDomainDirectQuery> _logger;
|
||||||
|
|
||||||
|
public CloudPhishingDomainDirectQuery(
|
||||||
|
IGlobalSettings globalSettings,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
ILogger<CloudPhishingDomainDirectQuery> logger)
|
||||||
|
{
|
||||||
|
_globalSettings = globalSettings;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<string>> GetPhishingDomainsAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_globalSettings.PhishingDomain?.UpdateUrl))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Phishing domain update URL is not configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var httpClient = _httpClientFactory.CreateClient("PhishingDomains");
|
||||||
|
var response = await httpClient.GetAsync(_globalSettings.PhishingDomain.UpdateUrl);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
return ParseDomains(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the SHA256 checksum of the remote phishing domains list
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The SHA256 checksum as a lowercase hex string</returns>
|
||||||
|
public async Task<string> GetRemoteChecksumAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_globalSettings.PhishingDomain?.ChecksumUrl))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Phishing domain checksum URL is not configured.");
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var httpClient = _httpClientFactory.CreateClient("PhishingDomains");
|
||||||
|
var response = await httpClient.GetAsync(_globalSettings.PhishingDomain.ChecksumUrl);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
return ParseChecksumResponse(content);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error retrieving phishing domain checksum from {Url}",
|
||||||
|
_globalSettings.PhishingDomain.ChecksumUrl);
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a checksum response in the format "hash *filename"
|
||||||
|
/// </summary>
|
||||||
|
private static string ParseChecksumResponse(string checksumContent)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(checksumContent))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format is typically "hash *filename"
|
||||||
|
var parts = checksumContent.Split(' ', 2);
|
||||||
|
|
||||||
|
return parts.Length > 0 ? parts[0].Trim() : string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> ParseDomains(string content)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(line => line.Trim())
|
||||||
|
.Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith("#"))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,66 @@
|
|||||||
|
using Bit.Core.PhishingDomainFeatures.Interfaces;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Bit.Core.PhishingDomainFeatures;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implementation of ICloudPhishingDomainQuery for self-hosted environments
|
||||||
|
/// that relays the request to the Bitwarden cloud API
|
||||||
|
/// </summary>
|
||||||
|
public class CloudPhishingDomainRelayQuery : BaseIdentityClientService, ICloudPhishingDomainQuery
|
||||||
|
{
|
||||||
|
private readonly IGlobalSettings _globalSettings;
|
||||||
|
|
||||||
|
public CloudPhishingDomainRelayQuery(
|
||||||
|
IHttpClientFactory httpFactory,
|
||||||
|
IGlobalSettings globalSettings,
|
||||||
|
ILogger<CloudPhishingDomainRelayQuery> logger)
|
||||||
|
: base(
|
||||||
|
httpFactory,
|
||||||
|
globalSettings.Installation.ApiUri,
|
||||||
|
globalSettings.Installation.IdentityUri,
|
||||||
|
"api.licensing",
|
||||||
|
$"installation.{globalSettings.Installation.Id}",
|
||||||
|
globalSettings.Installation.Key,
|
||||||
|
logger)
|
||||||
|
{
|
||||||
|
_globalSettings = globalSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<string>> GetPhishingDomainsAsync()
|
||||||
|
{
|
||||||
|
if (!_globalSettings.SelfHosted || !_globalSettings.EnableCloudCommunication)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("This query is only for self-hosted installations with cloud communication enabled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await SendAsync<object, string[]>(HttpMethod.Get, "phishing-domains", null, true);
|
||||||
|
return result?.ToList() ?? new List<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the SHA256 checksum of the remote phishing domains list
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The SHA256 checksum as a lowercase hex string</returns>
|
||||||
|
public async Task<string> GetRemoteChecksumAsync()
|
||||||
|
{
|
||||||
|
if (!_globalSettings.SelfHosted || !_globalSettings.EnableCloudCommunication)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("This query is only for self-hosted installations with cloud communication enabled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// For self-hosted environments, we get the checksum from the Bitwarden cloud API
|
||||||
|
var result = await SendAsync<object, string>(HttpMethod.Get, "phishing-domains/checksum", null, true);
|
||||||
|
return result ?? string.Empty;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error retrieving phishing domain checksum from Bitwarden cloud API");
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
namespace Bit.Core.PhishingDomainFeatures.Interfaces;
|
||||||
|
|
||||||
|
public interface ICloudPhishingDomainQuery
|
||||||
|
{
|
||||||
|
Task<List<string>> GetPhishingDomainsAsync();
|
||||||
|
Task<string> GetRemoteChecksumAsync();
|
||||||
|
}
|
8
src/Core/Repositories/IPhishingDomainRepository.cs
Normal file
8
src/Core/Repositories/IPhishingDomainRepository.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
namespace Bit.Core.Repositories;
|
||||||
|
|
||||||
|
public interface IPhishingDomainRepository
|
||||||
|
{
|
||||||
|
Task<ICollection<string>> GetActivePhishingDomainsAsync();
|
||||||
|
Task UpdatePhishingDomainsAsync(IEnumerable<string> domains, string checksum);
|
||||||
|
Task<string> GetCurrentChecksumAsync();
|
||||||
|
}
|
@ -0,0 +1,126 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Bit.Core.PhishingDomainFeatures;
|
||||||
|
using Microsoft.Extensions.Caching.Distributed;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Bit.Core.Repositories.Implementations;
|
||||||
|
|
||||||
|
public class AzurePhishingDomainRepository : IPhishingDomainRepository
|
||||||
|
{
|
||||||
|
private readonly AzurePhishingDomainStorageService _storageService;
|
||||||
|
private readonly IDistributedCache _cache;
|
||||||
|
private readonly ILogger<AzurePhishingDomainRepository> _logger;
|
||||||
|
private const string _domainsCacheKey = "PhishingDomains_v1";
|
||||||
|
private const string _checksumCacheKey = "PhishingDomains_Checksum_v1";
|
||||||
|
private static readonly DistributedCacheEntryOptions _cacheOptions = new()
|
||||||
|
{
|
||||||
|
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24),
|
||||||
|
SlidingExpiration = TimeSpan.FromHours(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
public AzurePhishingDomainRepository(
|
||||||
|
AzurePhishingDomainStorageService storageService,
|
||||||
|
IDistributedCache cache,
|
||||||
|
ILogger<AzurePhishingDomainRepository> logger)
|
||||||
|
{
|
||||||
|
_storageService = storageService;
|
||||||
|
_cache = cache;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ICollection<string>> GetActivePhishingDomainsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cachedDomains = await _cache.GetStringAsync(_domainsCacheKey);
|
||||||
|
if (!string.IsNullOrEmpty(cachedDomains))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Retrieved phishing domains from cache");
|
||||||
|
return JsonSerializer.Deserialize<ICollection<string>>(cachedDomains) ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to retrieve phishing domains from cache");
|
||||||
|
}
|
||||||
|
|
||||||
|
var domains = await _storageService.GetDomainsAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _cache.SetStringAsync(
|
||||||
|
_domainsCacheKey,
|
||||||
|
JsonSerializer.Serialize(domains),
|
||||||
|
_cacheOptions);
|
||||||
|
_logger.LogDebug("Stored {Count} phishing domains in cache", domains.Count);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to store phishing domains in cache");
|
||||||
|
}
|
||||||
|
|
||||||
|
return domains;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetCurrentChecksumAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cachedChecksum = await _cache.GetStringAsync(_checksumCacheKey);
|
||||||
|
if (!string.IsNullOrEmpty(cachedChecksum))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Retrieved phishing domain checksum from cache");
|
||||||
|
return cachedChecksum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to retrieve phishing domain checksum from cache");
|
||||||
|
}
|
||||||
|
|
||||||
|
var checksum = await _storageService.GetChecksumAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(checksum))
|
||||||
|
{
|
||||||
|
await _cache.SetStringAsync(
|
||||||
|
_checksumCacheKey,
|
||||||
|
checksum,
|
||||||
|
_cacheOptions);
|
||||||
|
_logger.LogDebug("Stored phishing domain checksum in cache");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to store phishing domain checksum in cache");
|
||||||
|
}
|
||||||
|
|
||||||
|
return checksum;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdatePhishingDomainsAsync(IEnumerable<string> domains, string checksum)
|
||||||
|
{
|
||||||
|
var domainsList = domains.ToList();
|
||||||
|
await _storageService.UpdateDomainsAsync(domainsList, checksum);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _cache.SetStringAsync(
|
||||||
|
_domainsCacheKey,
|
||||||
|
JsonSerializer.Serialize(domainsList),
|
||||||
|
_cacheOptions);
|
||||||
|
|
||||||
|
await _cache.SetStringAsync(
|
||||||
|
_checksumCacheKey,
|
||||||
|
checksum,
|
||||||
|
_cacheOptions);
|
||||||
|
|
||||||
|
_logger.LogDebug("Updated phishing domains cache after update operation");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to update phishing domains in cache");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -11,6 +11,7 @@ using Bit.Core.AdminConsole.Services;
|
|||||||
using Bit.Core.Auth.Enums;
|
using Bit.Core.Auth.Enums;
|
||||||
using Bit.Core.Auth.Models;
|
using Bit.Core.Auth.Models;
|
||||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
using Bit.Core.Billing.Models;
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Billing.Models.Sales;
|
using Bit.Core.Billing.Models.Sales;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
@ -45,7 +46,6 @@ namespace Bit.Core.Services;
|
|||||||
public class UserService : UserManager<User>, IUserService, IDisposable
|
public class UserService : UserManager<User>, IUserService, IDisposable
|
||||||
{
|
{
|
||||||
private const string PremiumPlanId = "premium-annually";
|
private const string PremiumPlanId = "premium-annually";
|
||||||
private const string StoragePlanId = "storage-gb-annually";
|
|
||||||
|
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
private readonly ICipherRepository _cipherRepository;
|
private readonly ICipherRepository _cipherRepository;
|
||||||
@ -1106,12 +1106,12 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, user, storageAdjustmentGb,
|
var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, user, storageAdjustmentGb,
|
||||||
StoragePlanId);
|
StripeConstants.Prices.StoragePlanPersonal);
|
||||||
await _referenceEventService.RaiseEventAsync(
|
await _referenceEventService.RaiseEventAsync(
|
||||||
new ReferenceEvent(ReferenceEventType.AdjustStorage, user, _currentContext)
|
new ReferenceEvent(ReferenceEventType.AdjustStorage, user, _currentContext)
|
||||||
{
|
{
|
||||||
Storage = storageAdjustmentGb,
|
Storage = storageAdjustmentGb,
|
||||||
PlanName = StoragePlanId,
|
PlanName = StripeConstants.Prices.StoragePlanPersonal,
|
||||||
});
|
});
|
||||||
await SaveUserAsync(user);
|
await SaveUserAsync(user);
|
||||||
return secret;
|
return secret;
|
||||||
|
@ -85,6 +85,7 @@ public class GlobalSettings : IGlobalSettings
|
|||||||
public virtual ILaunchDarklySettings LaunchDarkly { get; set; } = new LaunchDarklySettings();
|
public virtual ILaunchDarklySettings LaunchDarkly { get; set; } = new LaunchDarklySettings();
|
||||||
public virtual string DevelopmentDirectory { get; set; }
|
public virtual string DevelopmentDirectory { get; set; }
|
||||||
public virtual IWebPushSettings WebPush { get; set; } = new WebPushSettings();
|
public virtual IWebPushSettings WebPush { get; set; } = new WebPushSettings();
|
||||||
|
public virtual IPhishingDomainSettings PhishingDomain { get; set; } = new PhishingDomainSettings();
|
||||||
|
|
||||||
public virtual bool EnableEmailVerification { get; set; }
|
public virtual bool EnableEmailVerification { get; set; }
|
||||||
public virtual string KdfDefaultHashKey { get; set; }
|
public virtual string KdfDefaultHashKey { get; set; }
|
||||||
@ -644,6 +645,12 @@ public class GlobalSettings : IGlobalSettings
|
|||||||
public int MaxNetworkRetries { get; set; } = 2;
|
public int MaxNetworkRetries { get; set; } = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class PhishingDomainSettings : IPhishingDomainSettings
|
||||||
|
{
|
||||||
|
public string UpdateUrl { get; set; }
|
||||||
|
public string ChecksumUrl { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class DistributedIpRateLimitingSettings
|
public class DistributedIpRateLimitingSettings
|
||||||
{
|
{
|
||||||
public string RedisConnectionString { get; set; }
|
public string RedisConnectionString { get; set; }
|
||||||
|
@ -29,4 +29,5 @@ public interface IGlobalSettings
|
|||||||
string DevelopmentDirectory { get; set; }
|
string DevelopmentDirectory { get; set; }
|
||||||
IWebPushSettings WebPush { get; set; }
|
IWebPushSettings WebPush { get; set; }
|
||||||
GlobalSettings.EventLoggingSettings EventLogging { get; set; }
|
GlobalSettings.EventLoggingSettings EventLogging { get; set; }
|
||||||
|
IPhishingDomainSettings PhishingDomain { get; set; }
|
||||||
}
|
}
|
||||||
|
7
src/Core/Settings/IPhishingDomainSettings.cs
Normal file
7
src/Core/Settings/IPhishingDomainSettings.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
namespace Bit.Core.Settings;
|
||||||
|
|
||||||
|
public interface IPhishingDomainSettings
|
||||||
|
{
|
||||||
|
string UpdateUrl { get; set; }
|
||||||
|
string ChecksumUrl { get; set; }
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using Bit.Core;
|
||||||
using Bit.Core.AdminConsole.Services.Implementations;
|
using Bit.Core.AdminConsole.Services.Implementations;
|
||||||
using Bit.Core.AdminConsole.Services.NoopImplementations;
|
using Bit.Core.AdminConsole.Services.NoopImplementations;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
@ -62,33 +63,45 @@ public class Startup
|
|||||||
{
|
{
|
||||||
services.AddSingleton<IApplicationCacheService, InMemoryApplicationCacheService>();
|
services.AddSingleton<IApplicationCacheService, InMemoryApplicationCacheService>();
|
||||||
}
|
}
|
||||||
services.AddScoped<IEventService, EventService>();
|
|
||||||
if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString))
|
if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString))
|
||||||
{
|
{
|
||||||
|
services.AddKeyedSingleton<IEventWriteService, AzureQueueEventWriteService>("storage");
|
||||||
|
|
||||||
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) &&
|
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) &&
|
||||||
CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.TopicName))
|
CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.TopicName))
|
||||||
{
|
{
|
||||||
services.AddSingleton<IEventWriteService, AzureServiceBusEventWriteService>();
|
services.AddKeyedSingleton<IEventWriteService, AzureServiceBusEventWriteService>("broadcast");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
services.AddSingleton<IEventWriteService, AzureQueueEventWriteService>();
|
services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
services.AddKeyedSingleton<IEventWriteService, RepositoryEventWriteService>("storage");
|
||||||
|
|
||||||
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.HostName) &&
|
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.HostName) &&
|
||||||
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Username) &&
|
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Username) &&
|
||||||
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Password) &&
|
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Password) &&
|
||||||
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.ExchangeName))
|
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.ExchangeName))
|
||||||
{
|
{
|
||||||
services.AddSingleton<IEventWriteService, RabbitMqEventWriteService>();
|
services.AddKeyedSingleton<IEventWriteService, RabbitMqEventWriteService>("broadcast");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
services.AddSingleton<IEventWriteService, RepositoryEventWriteService>();
|
services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
services.AddScoped<IEventWriteService>(sp =>
|
||||||
|
{
|
||||||
|
var featureService = sp.GetRequiredService<IFeatureService>();
|
||||||
|
var key = featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations)
|
||||||
|
? "broadcast" : "storage";
|
||||||
|
return sp.GetRequiredKeyedService<IEventWriteService>(key);
|
||||||
|
});
|
||||||
|
services.AddScoped<IEventService, EventService>();
|
||||||
|
|
||||||
services.AddOptionality();
|
services.AddOptionality();
|
||||||
|
|
||||||
|
@ -150,6 +150,8 @@ public static class DapperHelpers
|
|||||||
os => os.LastSyncDate,
|
os => os.LastSyncDate,
|
||||||
os => os.ValidUntil,
|
os => os.ValidUntil,
|
||||||
os => os.ToDelete,
|
os => os.ToDelete,
|
||||||
|
os => os.IsAdminInitiated,
|
||||||
|
os => os.Notes,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Dapper" Version="2.1.35" />
|
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
@ -23,7 +23,19 @@ public class OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery : IQuery
|
|||||||
var sponsorshipsQuery = from os in dbContext.OrganizationSponsorships
|
var sponsorshipsQuery = from os in dbContext.OrganizationSponsorships
|
||||||
where os.SponsoringOrganizationId == _organizationId &&
|
where os.SponsoringOrganizationId == _organizationId &&
|
||||||
os.IsAdminInitiated &&
|
os.IsAdminInitiated &&
|
||||||
!os.ToDelete
|
(
|
||||||
|
// Not marked for deletion - always count
|
||||||
|
(!os.ToDelete) ||
|
||||||
|
// Marked for deletion but has a valid until date in the future (RevokeWhenExpired status)
|
||||||
|
(os.ToDelete && os.ValidUntil.HasValue && os.ValidUntil.Value > DateTime.UtcNow)
|
||||||
|
) &&
|
||||||
|
(
|
||||||
|
// SENT status: When SponsoredOrganizationId is null
|
||||||
|
os.SponsoredOrganizationId == null ||
|
||||||
|
// ACCEPTED status: When SponsoredOrganizationId is not null and ValidUntil is null or in the future
|
||||||
|
(os.SponsoredOrganizationId != null &&
|
||||||
|
(!os.ValidUntil.HasValue || os.ValidUntil.Value > DateTime.UtcNow))
|
||||||
|
)
|
||||||
select new OrganizationUser
|
select new OrganizationUser
|
||||||
{
|
{
|
||||||
Id = os.Id,
|
Id = os.Id,
|
||||||
|
@ -103,6 +103,7 @@ public static class EntityFrameworkServiceCollectionExtensions
|
|||||||
services.AddSingleton<IPasswordHealthReportApplicationRepository, PasswordHealthReportApplicationRepository>();
|
services.AddSingleton<IPasswordHealthReportApplicationRepository, PasswordHealthReportApplicationRepository>();
|
||||||
services.AddSingleton<ISecurityTaskRepository, SecurityTaskRepository>();
|
services.AddSingleton<ISecurityTaskRepository, SecurityTaskRepository>();
|
||||||
services.AddSingleton<IUserAsymmetricKeysRepository, UserAsymmetricKeysRepository>();
|
services.AddSingleton<IUserAsymmetricKeysRepository, UserAsymmetricKeysRepository>();
|
||||||
|
services.AddSingleton<IOrganizationInstallationRepository, OrganizationInstallationRepository>();
|
||||||
|
|
||||||
if (selfHosted)
|
if (selfHosted)
|
||||||
{
|
{
|
||||||
|
@ -8,12 +8,12 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
|
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
|
||||||
<PackageReference Include="linq2db" Version="5.4.1" />
|
<PackageReference Include="linq2db" Version="5.4.1" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.8" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="[8.0.8]" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.8" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="[8.0.8]" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.8" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="[8.0.8]" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="[8.0.4]" />
|
||||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
|
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="[8.0.2]" />
|
||||||
<PackageReference Include="linq2db.EntityFrameworkCore" Version="8.1.0" />
|
<PackageReference Include="linq2db.EntityFrameworkCore" Version="[8.1.0]" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
@ -4,6 +4,7 @@ using System.Security.Claims;
|
|||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using AspNetCoreRateLimit;
|
using AspNetCoreRateLimit;
|
||||||
using Azure.Storage.Queues;
|
using Azure.Storage.Queues;
|
||||||
|
using Bit.Core;
|
||||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
@ -332,34 +333,46 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString))
|
if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString))
|
||||||
{
|
{
|
||||||
|
services.AddKeyedSingleton<IEventWriteService, AzureQueueEventWriteService>("storage");
|
||||||
|
|
||||||
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) &&
|
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) &&
|
||||||
CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.TopicName))
|
CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.TopicName))
|
||||||
{
|
{
|
||||||
services.AddSingleton<IEventWriteService, AzureServiceBusEventWriteService>();
|
services.AddKeyedSingleton<IEventWriteService, AzureServiceBusEventWriteService>("broadcast");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
services.AddSingleton<IEventWriteService, AzureQueueEventWriteService>();
|
services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (globalSettings.SelfHosted)
|
else if (globalSettings.SelfHosted)
|
||||||
{
|
{
|
||||||
|
services.AddKeyedSingleton<IEventWriteService, RepositoryEventWriteService>("storage");
|
||||||
|
|
||||||
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.HostName) &&
|
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.HostName) &&
|
||||||
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Username) &&
|
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Username) &&
|
||||||
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Password) &&
|
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Password) &&
|
||||||
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.ExchangeName))
|
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.ExchangeName))
|
||||||
{
|
{
|
||||||
services.AddSingleton<IEventWriteService, RabbitMqEventWriteService>();
|
services.AddKeyedSingleton<IEventWriteService, RabbitMqEventWriteService>("broadcast");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
services.AddSingleton<IEventWriteService, RepositoryEventWriteService>();
|
services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
services.AddSingleton<IEventWriteService, NoopEventWriteService>();
|
services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("storage");
|
||||||
|
services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
|
||||||
}
|
}
|
||||||
|
services.AddScoped<IEventWriteService>(sp =>
|
||||||
|
{
|
||||||
|
var featureService = sp.GetRequiredService<IFeatureService>();
|
||||||
|
var key = featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations)
|
||||||
|
? "broadcast" : "storage";
|
||||||
|
return sp.GetRequiredKeyedService<IEventWriteService>(key);
|
||||||
|
});
|
||||||
|
|
||||||
if (CoreHelpers.SettingHasValue(globalSettings.Attachment.ConnectionString))
|
if (CoreHelpers.SettingHasValue(globalSettings.Attachment.ConnectionString))
|
||||||
{
|
{
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
@Data NVARCHAR(MAX),
|
@Data NVARCHAR(MAX),
|
||||||
@Favorites NVARCHAR(MAX), -- not used
|
@Favorites NVARCHAR(MAX), -- not used
|
||||||
@Folders NVARCHAR(MAX), -- not used
|
@Folders NVARCHAR(MAX), -- not used
|
||||||
@Attachments NVARCHAR(MAX),
|
@Attachments NVARCHAR(MAX), -- not used
|
||||||
@CreationDate DATETIME2(7),
|
@CreationDate DATETIME2(7),
|
||||||
@RevisionDate DATETIME2(7),
|
@RevisionDate DATETIME2(7),
|
||||||
@FolderId UNIQUEIDENTIFIER,
|
@FolderId UNIQUEIDENTIFIER,
|
||||||
@ -50,7 +50,6 @@ BEGIN
|
|||||||
ELSE
|
ELSE
|
||||||
JSON_MODIFY([Favorites], @UserIdPath, NULL)
|
JSON_MODIFY([Favorites], @UserIdPath, NULL)
|
||||||
END,
|
END,
|
||||||
[Attachments] = @Attachments,
|
|
||||||
[Reprompt] = @Reprompt,
|
[Reprompt] = @Reprompt,
|
||||||
[CreationDate] = @CreationDate,
|
[CreationDate] = @CreationDate,
|
||||||
[RevisionDate] = @RevisionDate,
|
[RevisionDate] = @RevisionDate,
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
@Data NVARCHAR(MAX),
|
@Data NVARCHAR(MAX),
|
||||||
@Favorites NVARCHAR(MAX),
|
@Favorites NVARCHAR(MAX),
|
||||||
@Folders NVARCHAR(MAX),
|
@Folders NVARCHAR(MAX),
|
||||||
@Attachments NVARCHAR(MAX),
|
@Attachments NVARCHAR(MAX), -- not used
|
||||||
@CreationDate DATETIME2(7),
|
@CreationDate DATETIME2(7),
|
||||||
@RevisionDate DATETIME2(7),
|
@RevisionDate DATETIME2(7),
|
||||||
@DeletedDate DATETIME2(7),
|
@DeletedDate DATETIME2(7),
|
||||||
@ -25,7 +25,6 @@ BEGIN
|
|||||||
[Data],
|
[Data],
|
||||||
[Favorites],
|
[Favorites],
|
||||||
[Folders],
|
[Folders],
|
||||||
[Attachments],
|
|
||||||
[CreationDate],
|
[CreationDate],
|
||||||
[RevisionDate],
|
[RevisionDate],
|
||||||
[DeletedDate],
|
[DeletedDate],
|
||||||
@ -41,7 +40,6 @@ BEGIN
|
|||||||
@Data,
|
@Data,
|
||||||
@Favorites,
|
@Favorites,
|
||||||
@Folders,
|
@Folders,
|
||||||
@Attachments,
|
|
||||||
@CreationDate,
|
@CreationDate,
|
||||||
@RevisionDate,
|
@RevisionDate,
|
||||||
@DeletedDate,
|
@DeletedDate,
|
||||||
|
@ -10,20 +10,59 @@ BEGIN
|
|||||||
|
|
||||||
DECLARE @UserId UNIQUEIDENTIFIER
|
DECLARE @UserId UNIQUEIDENTIFIER
|
||||||
DECLARE @OrganizationId UNIQUEIDENTIFIER
|
DECLARE @OrganizationId UNIQUEIDENTIFIER
|
||||||
|
DECLARE @CurrentAttachments NVARCHAR(MAX)
|
||||||
|
DECLARE @NewAttachments NVARCHAR(MAX)
|
||||||
|
|
||||||
|
-- Get current cipher data
|
||||||
SELECT
|
SELECT
|
||||||
@UserId = [UserId],
|
@UserId = [UserId],
|
||||||
@OrganizationId = [OrganizationId]
|
@OrganizationId = [OrganizationId],
|
||||||
|
@CurrentAttachments = [Attachments]
|
||||||
FROM
|
FROM
|
||||||
[dbo].[Cipher]
|
[dbo].[Cipher]
|
||||||
WHERE [Id] = @Id
|
WHERE [Id] = @Id
|
||||||
|
|
||||||
UPDATE
|
-- If there are no attachments, nothing to do
|
||||||
[dbo].[Cipher]
|
IF @CurrentAttachments IS NULL
|
||||||
SET
|
BEGIN
|
||||||
[Attachments] = JSON_MODIFY([Attachments], @AttachmentIdPath, NULL)
|
RETURN;
|
||||||
WHERE
|
END
|
||||||
[Id] = @Id
|
|
||||||
|
-- Validate the initial JSON
|
||||||
|
IF ISJSON(@CurrentAttachments) = 0
|
||||||
|
BEGIN
|
||||||
|
THROW 50000, 'Current initial attachments data is not valid JSON', 1;
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Check if the attachment exists before trying to remove it
|
||||||
|
IF JSON_PATH_EXISTS(@CurrentAttachments, @AttachmentIdPath) = 0
|
||||||
|
BEGIN
|
||||||
|
-- Attachment doesn't exist, nothing to do
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Create the new attachments JSON with the specified attachment removed
|
||||||
|
SET @NewAttachments = JSON_MODIFY(@CurrentAttachments, @AttachmentIdPath, NULL)
|
||||||
|
|
||||||
|
-- Validate the resulting JSON
|
||||||
|
IF ISJSON(@NewAttachments) = 0
|
||||||
|
BEGIN
|
||||||
|
THROW 50000, 'Failed to create valid JSON when removing attachment', 1;
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Check if we've removed all attachments and have an empty object
|
||||||
|
IF @NewAttachments = '{}'
|
||||||
|
BEGIN
|
||||||
|
-- If we have an empty JSON object, set to NULL instead
|
||||||
|
SET @NewAttachments = NULL;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Update with validated JSON
|
||||||
|
UPDATE [dbo].[Cipher]
|
||||||
|
SET [Attachments] = @NewAttachments
|
||||||
|
WHERE [Id] = @Id
|
||||||
|
|
||||||
IF @OrganizationId IS NOT NULL
|
IF @OrganizationId IS NOT NULL
|
||||||
BEGIN
|
BEGIN
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
@Data NVARCHAR(MAX),
|
@Data NVARCHAR(MAX),
|
||||||
@Favorites NVARCHAR(MAX),
|
@Favorites NVARCHAR(MAX),
|
||||||
@Folders NVARCHAR(MAX),
|
@Folders NVARCHAR(MAX),
|
||||||
@Attachments NVARCHAR(MAX),
|
@Attachments NVARCHAR(MAX), -- not used
|
||||||
@CreationDate DATETIME2(7),
|
@CreationDate DATETIME2(7),
|
||||||
@RevisionDate DATETIME2(7),
|
@RevisionDate DATETIME2(7),
|
||||||
@DeletedDate DATETIME2(7),
|
@DeletedDate DATETIME2(7),
|
||||||
@ -25,7 +25,6 @@ BEGIN
|
|||||||
[Data] = @Data,
|
[Data] = @Data,
|
||||||
[Favorites] = @Favorites,
|
[Favorites] = @Favorites,
|
||||||
[Folders] = @Folders,
|
[Folders] = @Folders,
|
||||||
[Attachments] = @Attachments,
|
|
||||||
[CreationDate] = @CreationDate,
|
[CreationDate] = @CreationDate,
|
||||||
[RevisionDate] = @RevisionDate,
|
[RevisionDate] = @RevisionDate,
|
||||||
[DeletedDate] = @DeletedDate,
|
[DeletedDate] = @DeletedDate,
|
||||||
|
@ -8,21 +8,75 @@ AS
|
|||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON
|
SET NOCOUNT ON
|
||||||
|
|
||||||
|
-- Validate that AttachmentData is valid JSON
|
||||||
|
IF ISJSON(@AttachmentData) = 0
|
||||||
|
BEGIN
|
||||||
|
THROW 50000, 'Invalid JSON format in AttachmentData parameter', 1;
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Validate that AttachmentData has the expected structure
|
||||||
|
-- Check for required fields
|
||||||
|
IF JSON_VALUE(@AttachmentData, '$.FileName') IS NULL OR
|
||||||
|
JSON_VALUE(@AttachmentData, '$.Size') IS NULL
|
||||||
|
BEGIN
|
||||||
|
THROW 50000, 'AttachmentData is missing required fields (FileName, Size)', 1;
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Validate data types for critical fields
|
||||||
|
DECLARE @Size BIGINT = TRY_CAST(JSON_VALUE(@AttachmentData, '$.Size') AS BIGINT)
|
||||||
|
IF @Size IS NULL OR @Size <= 0
|
||||||
|
BEGIN
|
||||||
|
THROW 50000, 'AttachmentData has invalid Size value', 1;
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
DECLARE @AttachmentIdKey VARCHAR(50) = CONCAT('"', @AttachmentId, '"')
|
DECLARE @AttachmentIdKey VARCHAR(50) = CONCAT('"', @AttachmentId, '"')
|
||||||
DECLARE @AttachmentIdPath VARCHAR(50) = CONCAT('$.', @AttachmentIdKey)
|
DECLARE @AttachmentIdPath VARCHAR(50) = CONCAT('$.', @AttachmentIdKey)
|
||||||
|
DECLARE @NewAttachments NVARCHAR(MAX)
|
||||||
|
|
||||||
UPDATE
|
-- Get current attachments
|
||||||
[dbo].[Cipher]
|
DECLARE @CurrentAttachments NVARCHAR(MAX)
|
||||||
SET
|
SELECT @CurrentAttachments = [Attachments] FROM [dbo].[Cipher] WHERE [Id] = @Id
|
||||||
[Attachments] =
|
|
||||||
CASE
|
-- Prepare the new attachments value based on current state
|
||||||
WHEN [Attachments] IS NULL THEN
|
IF @CurrentAttachments IS NULL
|
||||||
CONCAT('{', @AttachmentIdKey, ':', @AttachmentData, '}')
|
BEGIN
|
||||||
ELSE
|
-- Create new JSON object with the attachment
|
||||||
JSON_MODIFY([Attachments], @AttachmentIdPath, JSON_QUERY(@AttachmentData, '$'))
|
SET @NewAttachments = CONCAT('{', @AttachmentIdKey, ':', @AttachmentData, '}')
|
||||||
|
|
||||||
|
-- Validate the constructed JSON
|
||||||
|
IF ISJSON(@NewAttachments) = 0
|
||||||
|
BEGIN
|
||||||
|
THROW 50000, 'Failed to create valid JSON when adding new attachment', 1;
|
||||||
|
RETURN;
|
||||||
END
|
END
|
||||||
WHERE
|
END
|
||||||
[Id] = @Id
|
ELSE
|
||||||
|
BEGIN
|
||||||
|
-- Validate existing attachments
|
||||||
|
IF ISJSON(@CurrentAttachments) = 0
|
||||||
|
BEGIN
|
||||||
|
THROW 50000, 'Current attachments data is not valid JSON', 1;
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Modify existing JSON
|
||||||
|
SET @NewAttachments = JSON_MODIFY(@CurrentAttachments, @AttachmentIdPath, JSON_QUERY(@AttachmentData, '$'))
|
||||||
|
|
||||||
|
-- Validate the modified JSON
|
||||||
|
IF ISJSON(@NewAttachments) = 0
|
||||||
|
BEGIN
|
||||||
|
THROW 50000, 'Failed to create valid JSON when updating existing attachments', 1;
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Update with validated JSON
|
||||||
|
UPDATE [dbo].[Cipher]
|
||||||
|
SET [Attachments] = @NewAttachments
|
||||||
|
WHERE [Id] = @Id
|
||||||
|
|
||||||
IF @OrganizationId IS NOT NULL
|
IF @OrganizationId IS NOT NULL
|
||||||
BEGIN
|
BEGIN
|
||||||
|
@ -19,5 +19,19 @@ BEGIN
|
|||||||
FROM [dbo].[OrganizationSponsorship]
|
FROM [dbo].[OrganizationSponsorship]
|
||||||
WHERE SponsoringOrganizationId = @OrganizationId
|
WHERE SponsoringOrganizationId = @OrganizationId
|
||||||
AND IsAdminInitiated = 1
|
AND IsAdminInitiated = 1
|
||||||
|
AND (
|
||||||
|
-- Not marked for deletion - always count
|
||||||
|
(ToDelete = 0)
|
||||||
|
OR
|
||||||
|
-- Marked for deletion but has a valid until date in the future (RevokeWhenExpired status)
|
||||||
|
(ToDelete = 1 AND ValidUntil IS NOT NULL AND ValidUntil > GETUTCDATE())
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
-- SENT status: When SponsoredOrganizationId is null
|
||||||
|
SponsoredOrganizationId IS NULL
|
||||||
|
OR
|
||||||
|
-- ACCEPTED status: When SponsoredOrganizationId is not null and ValidUntil is null or in the future
|
||||||
|
(SponsoredOrganizationId IS NOT NULL AND (ValidUntil IS NULL OR ValidUntil > GETUTCDATE()))
|
||||||
|
)
|
||||||
)
|
)
|
||||||
END
|
END
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
using Bit.Api.AdminConsole.Authorization;
|
using AutoFixture.Xunit2;
|
||||||
|
using Bit.Api.AdminConsole.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@ -25,4 +27,42 @@ public class HttpContextExtensionsTests
|
|||||||
await callback.ReceivedWithAnyArgs(1).Invoke();
|
await callback.ReceivedWithAnyArgs(1).Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineAutoData("orgId")]
|
||||||
|
[InlineAutoData("organizationId")]
|
||||||
|
public void GetOrganizationId_GivenValidParameter_ReturnsOrganizationId(string paramName, Guid orgId)
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
Request = { RouteValues = new RouteValueDictionary
|
||||||
|
{
|
||||||
|
{ "userId", "someGuid" },
|
||||||
|
{ paramName, orgId.ToString() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = httpContext.GetOrganizationId();
|
||||||
|
Assert.Equal(orgId, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineAutoData("orgId")]
|
||||||
|
[InlineAutoData("organizationId")]
|
||||||
|
[InlineAutoData("missingParameter")]
|
||||||
|
public void GetOrganizationId_GivenMissingOrInvalidGuid_Throws(string paramName)
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
Request = { RouteValues = new RouteValueDictionary
|
||||||
|
{
|
||||||
|
{ "userId", "someGuid" },
|
||||||
|
{ paramName, "invalidGuid" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var exception = Assert.Throws<InvalidOperationException>(() => httpContext.GetOrganizationId());
|
||||||
|
Assert.Equal(HttpContextExtensions.NoOrgIdError, exception.Message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ 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.Data;
|
||||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
@ -13,6 +14,7 @@ using Bit.Core.Utilities;
|
|||||||
using Bit.Test.Common.AutoFixture;
|
using Bit.Test.Common.AutoFixture;
|
||||||
using Bit.Test.Common.AutoFixture.Attributes;
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
|
using NSubstitute.ReturnsExtensions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Bit.Api.Test.Billing.Controllers;
|
namespace Bit.Api.Test.Billing.Controllers;
|
||||||
@ -146,4 +148,80 @@ public class OrganizationSponsorshipsControllerTests
|
|||||||
.DidNotReceiveWithAnyArgs()
|
.DidNotReceiveWithAnyArgs()
|
||||||
.RemoveSponsorshipAsync(default);
|
.RemoveSponsorshipAsync(default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task GetSponsoredOrganizations_OrganizationNotFound_ThrowsNotFound(
|
||||||
|
Guid sponsoringOrgId,
|
||||||
|
SutProvider<OrganizationSponsorshipsController> sutProvider)
|
||||||
|
{
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(sponsoringOrgId).ReturnsNull();
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<NotFoundException>(() =>
|
||||||
|
sutProvider.Sut.GetSponsoredOrganizations(sponsoringOrgId));
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>()
|
||||||
|
.DidNotReceiveWithAnyArgs()
|
||||||
|
.GetManyBySponsoringOrganizationAsync(default);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task GetSponsoredOrganizations_NotOrganizationOwner_ThrowsNotFound(
|
||||||
|
Organization sponsoringOrg,
|
||||||
|
SutProvider<OrganizationSponsorshipsController> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(sponsoringOrg.Id).Returns(sponsoringOrg);
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(sponsoringOrg.Id).Returns(false);
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(sponsoringOrg.Id).Returns(false);
|
||||||
|
|
||||||
|
// Create a CurrentContextOrganization with ManageUsers set to false
|
||||||
|
var currentContextOrg = new CurrentContextOrganization
|
||||||
|
{
|
||||||
|
Id = sponsoringOrg.Id,
|
||||||
|
Permissions = new Permissions { ManageUsers = false }
|
||||||
|
};
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().Organizations.Returns(new List<CurrentContextOrganization> { currentContextOrg });
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
|
||||||
|
sutProvider.Sut.GetSponsoredOrganizations(sponsoringOrg.Id));
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>()
|
||||||
|
.DidNotReceiveWithAnyArgs()
|
||||||
|
.GetManyBySponsoringOrganizationAsync(default);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task GetSponsoredOrganizations_Success_ReturnsSponsorships(
|
||||||
|
Organization sponsoringOrg,
|
||||||
|
List<OrganizationSponsorship> sponsorships,
|
||||||
|
SutProvider<OrganizationSponsorshipsController> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(sponsoringOrg.Id).Returns(sponsoringOrg);
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(sponsoringOrg.Id).Returns(true);
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(sponsoringOrg.Id).Returns(false);
|
||||||
|
|
||||||
|
// Create a CurrentContextOrganization from the sponsoringOrg
|
||||||
|
var currentContextOrg = new CurrentContextOrganization
|
||||||
|
{
|
||||||
|
Id = sponsoringOrg.Id,
|
||||||
|
Permissions = new Permissions { ManageUsers = true }
|
||||||
|
};
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().Organizations.Returns(new List<CurrentContextOrganization> { currentContextOrg });
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationSponsorshipRepository>()
|
||||||
|
.GetManyBySponsoringOrganizationAsync(sponsoringOrg.Id).Returns(sponsorships);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.GetSponsoredOrganizations(sponsoringOrg.Id);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(sponsorships.Count, result.Data.Count());
|
||||||
|
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().Received(1)
|
||||||
|
.GetManyBySponsoringOrganizationAsync(sponsoringOrg.Id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,315 @@
|
|||||||
|
using Bit.Api.Billing.Queries.Organizations;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Billing.Services;
|
||||||
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using NSubstitute;
|
||||||
|
using NSubstitute.ReturnsExtensions;
|
||||||
|
using Stripe;
|
||||||
|
using Stripe.TestHelpers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Api.Test.Billing.Queries.Organizations;
|
||||||
|
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class OrganizationWarningsQueryTests
|
||||||
|
{
|
||||||
|
private static readonly string[] _requiredExpansions = ["customer", "latest_invoice", "test_clock"];
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_NoSubscription_NoWarnings(
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.ReturnsNull();
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(organization);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
FreeTrial: null,
|
||||||
|
InactiveSubscription: null,
|
||||||
|
ResellerRenewal: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_Has_FreeTrialWarning(
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = StripeConstants.SubscriptionStatus.Trialing,
|
||||||
|
TrialEnd = now.AddDays(7),
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
InvoiceSettings = new CustomerInvoiceSettings(),
|
||||||
|
Metadata = new Dictionary<string, string>()
|
||||||
|
},
|
||||||
|
TestClock = new TestClock
|
||||||
|
{
|
||||||
|
FrozenTime = now
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().EditSubscription(organization.Id).Returns(true);
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(organization);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
FreeTrial.RemainingTrialDays: 7
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_Has_InactiveSubscriptionWarning_ContactProvider(
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
organization.Enabled = false;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = StripeConstants.SubscriptionStatus.Unpaid
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organization.Id)
|
||||||
|
.Returns(new Provider());
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(organization);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
InactiveSubscription.Resolution: "contact_provider"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_Has_InactiveSubscriptionWarning_AddPaymentMethod(
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
organization.Enabled = false;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = StripeConstants.SubscriptionStatus.Unpaid
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(true);
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(organization);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
InactiveSubscription.Resolution: "add_payment_method"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_Has_InactiveSubscriptionWarning_Resubscribe(
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
organization.Enabled = false;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = StripeConstants.SubscriptionStatus.Canceled
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(true);
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(organization);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
InactiveSubscription.Resolution: "resubscribe"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_Has_InactiveSubscriptionWarning_ContactOwner(
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
organization.Enabled = false;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = StripeConstants.SubscriptionStatus.Unpaid
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(false);
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(organization);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
InactiveSubscription.Resolution: "contact_owner"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_Has_ResellerRenewalWarning_Upcoming(
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
||||||
|
Status = StripeConstants.SubscriptionStatus.Active,
|
||||||
|
CurrentPeriodEnd = now.AddDays(10),
|
||||||
|
TestClock = new TestClock
|
||||||
|
{
|
||||||
|
FrozenTime = now
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organization.Id)
|
||||||
|
.Returns(new Provider
|
||||||
|
{
|
||||||
|
Type = ProviderType.Reseller
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(organization);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
ResellerRenewal.Type: "upcoming"
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(now.AddDays(10), response.ResellerRenewal.Upcoming!.RenewalDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_Has_ResellerRenewalWarning_Issued(
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
||||||
|
Status = StripeConstants.SubscriptionStatus.Active,
|
||||||
|
LatestInvoice = new Invoice
|
||||||
|
{
|
||||||
|
Status = StripeConstants.InvoiceStatus.Open,
|
||||||
|
DueDate = now.AddDays(30),
|
||||||
|
Created = now
|
||||||
|
},
|
||||||
|
TestClock = new TestClock
|
||||||
|
{
|
||||||
|
FrozenTime = now
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organization.Id)
|
||||||
|
.Returns(new Provider
|
||||||
|
{
|
||||||
|
Type = ProviderType.Reseller
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(organization);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
ResellerRenewal.Type: "issued"
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(now, response.ResellerRenewal.Issued!.IssuedDate);
|
||||||
|
Assert.Equal(now.AddDays(30), response.ResellerRenewal.Issued!.DueDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_Has_ResellerRenewalWarning_PastDue(
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
const string subscriptionId = "subscription_id";
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Id = subscriptionId,
|
||||||
|
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
||||||
|
Status = StripeConstants.SubscriptionStatus.PastDue,
|
||||||
|
TestClock = new TestClock
|
||||||
|
{
|
||||||
|
FrozenTime = now
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organization.Id)
|
||||||
|
.Returns(new Provider
|
||||||
|
{
|
||||||
|
Type = ProviderType.Reseller
|
||||||
|
});
|
||||||
|
|
||||||
|
var dueDate = now.AddDays(-10);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().InvoiceSearchAsync(Arg.Is<InvoiceSearchOptions>(options =>
|
||||||
|
options.Query == $"subscription:'{subscriptionId}' status:'open'")).Returns([
|
||||||
|
new Invoice { DueDate = dueDate, Created = dueDate.AddDays(-30) }
|
||||||
|
]);
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(organization);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
ResellerRenewal.Type: "past_due"
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(dueDate.AddDays(30), response.ResellerRenewal.PastDue!.SuspensionDate);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,169 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
|
||||||
|
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Models.Data;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Tokens;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using Bit.Test.Common.Fakes;
|
||||||
|
using NSubstitute;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations;
|
||||||
|
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class InitPendingOrganizationCommandTests
|
||||||
|
{
|
||||||
|
|
||||||
|
private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory = Substitute.For<IOrgUserInviteTokenableFactory>();
|
||||||
|
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory = new FakeDataProtectorTokenFactory<OrgUserInviteTokenable>();
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Init_Organization_Success(User user, Guid orgId, Guid orgUserId, string publicKey,
|
||||||
|
string privateKey, SutProvider<InitPendingOrganizationCommand> sutProvider, Organization org, OrganizationUser orgUser)
|
||||||
|
{
|
||||||
|
var token = CreateToken(orgUser, orgUserId, sutProvider);
|
||||||
|
|
||||||
|
org.PrivateKey = null;
|
||||||
|
org.PublicKey = null;
|
||||||
|
|
||||||
|
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||||
|
organizationRepository.GetByIdAsync(orgId).Returns(org);
|
||||||
|
|
||||||
|
var organizationServcie = sutProvider.GetDependency<IOrganizationService>();
|
||||||
|
var collectionRepository = sutProvider.GetDependency<ICollectionRepository>();
|
||||||
|
|
||||||
|
await sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token);
|
||||||
|
|
||||||
|
await organizationRepository.Received().GetByIdAsync(orgId);
|
||||||
|
await organizationServcie.Received().UpdateAsync(org);
|
||||||
|
await collectionRepository.DidNotReceiveWithAnyArgs().CreateAsync(default);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Init_Organization_With_CollectionName_Success(User user, Guid orgId, Guid orgUserId, string publicKey,
|
||||||
|
string privateKey, SutProvider<InitPendingOrganizationCommand> sutProvider, Organization org, string collectionName, OrganizationUser orgUser)
|
||||||
|
{
|
||||||
|
var token = CreateToken(orgUser, orgUserId, sutProvider);
|
||||||
|
|
||||||
|
org.PrivateKey = null;
|
||||||
|
org.PublicKey = null;
|
||||||
|
org.Id = orgId;
|
||||||
|
|
||||||
|
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||||
|
organizationRepository.GetByIdAsync(orgId).Returns(org);
|
||||||
|
|
||||||
|
var organizationServcie = sutProvider.GetDependency<IOrganizationService>();
|
||||||
|
var collectionRepository = sutProvider.GetDependency<ICollectionRepository>();
|
||||||
|
|
||||||
|
await sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, collectionName, token);
|
||||||
|
|
||||||
|
await organizationRepository.Received().GetByIdAsync(orgId);
|
||||||
|
await organizationServcie.Received().UpdateAsync(org);
|
||||||
|
|
||||||
|
await collectionRepository.Received().CreateAsync(
|
||||||
|
Arg.Any<Collection>(),
|
||||||
|
Arg.Is<List<CollectionAccessSelection>>(l => l == null),
|
||||||
|
Arg.Is<List<CollectionAccessSelection>>(l => l.Any(i => i.Manage == true)));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Init_Organization_When_Organization_Is_Enabled(User user, Guid orgId, Guid orgUserId, string publicKey,
|
||||||
|
string privateKey, SutProvider<InitPendingOrganizationCommand> sutProvider, Organization org, OrganizationUser orgUser)
|
||||||
|
|
||||||
|
{
|
||||||
|
var token = CreateToken(orgUser, orgUserId, sutProvider);
|
||||||
|
|
||||||
|
org.Enabled = true;
|
||||||
|
|
||||||
|
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||||
|
organizationRepository.GetByIdAsync(orgId).Returns(org);
|
||||||
|
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token));
|
||||||
|
|
||||||
|
Assert.Equal("Organization is already enabled.", exception.Message);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Init_Organization_When_Organization_Is_Not_Pending(User user, Guid orgId, Guid orgUserId, string publicKey,
|
||||||
|
string privateKey, SutProvider<InitPendingOrganizationCommand> sutProvider, Organization org, OrganizationUser orgUser)
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
var token = CreateToken(orgUser, orgUserId, sutProvider);
|
||||||
|
|
||||||
|
org.Status = Enums.OrganizationStatusType.Created;
|
||||||
|
|
||||||
|
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||||
|
organizationRepository.GetByIdAsync(orgId).Returns(org);
|
||||||
|
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token));
|
||||||
|
|
||||||
|
Assert.Equal("Organization is not on a Pending status.", exception.Message);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Init_Organization_When_Organization_Has_Public_Key(User user, Guid orgId, Guid orgUserId, string publicKey,
|
||||||
|
string privateKey, SutProvider<InitPendingOrganizationCommand> sutProvider, Organization org, OrganizationUser orgUser)
|
||||||
|
|
||||||
|
{
|
||||||
|
var token = CreateToken(orgUser, orgUserId, sutProvider);
|
||||||
|
|
||||||
|
org.PublicKey = publicKey;
|
||||||
|
|
||||||
|
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||||
|
organizationRepository.GetByIdAsync(orgId).Returns(org);
|
||||||
|
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token));
|
||||||
|
|
||||||
|
Assert.Equal("Organization already has a Public Key.", exception.Message);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Init_Organization_When_Organization_Has_Private_Key(User user, Guid orgId, Guid orgUserId, string publicKey,
|
||||||
|
string privateKey, SutProvider<InitPendingOrganizationCommand> sutProvider, Organization org, OrganizationUser orgUser)
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
var token = CreateToken(orgUser, orgUserId, sutProvider);
|
||||||
|
|
||||||
|
org.PublicKey = null;
|
||||||
|
org.PrivateKey = privateKey;
|
||||||
|
org.Enabled = false;
|
||||||
|
|
||||||
|
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||||
|
organizationRepository.GetByIdAsync(orgId).Returns(org);
|
||||||
|
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token));
|
||||||
|
|
||||||
|
Assert.Equal("Organization already has a Private Key.", exception.Message);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CreateToken(OrganizationUser orgUser, Guid orgUserId, SutProvider<InitPendingOrganizationCommand> sutProvider)
|
||||||
|
{
|
||||||
|
sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory");
|
||||||
|
sutProvider.Create();
|
||||||
|
|
||||||
|
_orgUserInviteTokenableFactory.CreateToken(orgUser).Returns(new OrgUserInviteTokenable(orgUser)
|
||||||
|
{
|
||||||
|
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
||||||
|
});
|
||||||
|
|
||||||
|
var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser);
|
||||||
|
var protectedToken = _orgUserInviteTokenDataFactory.Protect(orgUserInviteTokenable);
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(orgUserId).Returns(orgUser);
|
||||||
|
|
||||||
|
return protectedToken;
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,6 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text.Json;
|
|
||||||
using Bit.Core.Models.Business;
|
using Bit.Core.Models.Business;
|
||||||
using Bit.Core.Services;
|
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Utilities;
|
|
||||||
using Bit.Test.Common.AutoFixture.Attributes;
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@ -12,22 +9,6 @@ namespace Bit.Core.Test.Models.Business;
|
|||||||
|
|
||||||
public class OrganizationLicenseTests
|
public class OrganizationLicenseTests
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Verifies that when the license file is loaded from disk using the current OrganizationLicense class,
|
|
||||||
/// its hash does not change.
|
|
||||||
/// This guards against the risk that properties added in later versions are accidentally included in the hash,
|
|
||||||
/// or that a property is added without incrementing the version number.
|
|
||||||
/// </summary>
|
|
||||||
[Theory]
|
|
||||||
[BitAutoData(OrganizationLicense.CurrentLicenseFileVersion)] // Previous version (this property is 1 behind)
|
|
||||||
[BitAutoData(OrganizationLicense.CurrentLicenseFileVersion + 1)] // Current version
|
|
||||||
public void OrganizationLicense_LoadFromDisk_HashDoesNotChange(int licenseVersion)
|
|
||||||
{
|
|
||||||
var license = OrganizationLicenseFileFixtures.GetVersion(licenseVersion);
|
|
||||||
|
|
||||||
// Compare the hash loaded from the json to the hash generated by the current class
|
|
||||||
Assert.Equal(Convert.FromBase64String(license.Hash), license.ComputeHash());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that when the license file is loaded from disk using the current OrganizationLicense class,
|
/// Verifies that when the license file is loaded from disk using the current OrganizationLicense class,
|
||||||
@ -52,22 +33,4 @@ public class OrganizationLicenseTests
|
|||||||
});
|
});
|
||||||
Assert.True(license.VerifyData(organization, claimsPrincipal, globalSettings));
|
Assert.True(license.VerifyData(organization, claimsPrincipal, globalSettings));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Helper used to generate a new json string to be added in OrganizationLicenseFileFixtures.
|
|
||||||
/// Uncomment [Fact], run the test and copy the value of the `result` variable into OrganizationLicenseFileFixtures,
|
|
||||||
/// following the instructions in that class.
|
|
||||||
/// </summary>
|
|
||||||
// [Fact]
|
|
||||||
private void GenerateLicenseFileJsonString()
|
|
||||||
{
|
|
||||||
var organization = OrganizationLicenseFileFixtures.OrganizationFactory();
|
|
||||||
var licensingService = Substitute.For<ILicensingService>();
|
|
||||||
var installationId = new Guid(OrganizationLicenseFileFixtures.InstallationId);
|
|
||||||
|
|
||||||
var license = new OrganizationLicense(organization, null, installationId, licensingService);
|
|
||||||
|
|
||||||
var result = JsonSerializer.Serialize(license, JsonHelpers.Indented).Replace("\"", "'");
|
|
||||||
// Put a break after this line, then copy and paste the value of `result` into OrganizationLicenseFileFixtures
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -168,6 +168,11 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
|
|||||||
});
|
});
|
||||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(sponsoringOrgUser.UserId.Value);
|
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(sponsoringOrgUser.UserId.Value);
|
||||||
|
|
||||||
|
// Setup for checking available seats
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
|
.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id)
|
||||||
|
.Returns(0);
|
||||||
|
|
||||||
|
|
||||||
await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
|
await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
|
||||||
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, false, null);
|
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, false, null);
|
||||||
@ -293,6 +298,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
|
|||||||
{
|
{
|
||||||
sponsoringOrg.PlanType = PlanType.EnterpriseAnnually;
|
sponsoringOrg.PlanType = PlanType.EnterpriseAnnually;
|
||||||
sponsoringOrg.UseAdminSponsoredFamilies = true;
|
sponsoringOrg.UseAdminSponsoredFamilies = true;
|
||||||
|
sponsoringOrg.Seats = 10;
|
||||||
sponsoringOrgUser.Status = OrganizationUserStatusType.Confirmed;
|
sponsoringOrgUser.Status = OrganizationUserStatusType.Confirmed;
|
||||||
|
|
||||||
sutProvider.GetDependency<IUserService>().GetUserByIdAsync(sponsoringOrgUser.UserId!.Value).Returns(user);
|
sutProvider.GetDependency<IUserService>().GetUserByIdAsync(sponsoringOrgUser.UserId!.Value).Returns(user);
|
||||||
@ -311,6 +317,11 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Setup for checking available seats - organization has plenty of seats
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
|
.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id)
|
||||||
|
.Returns(5);
|
||||||
|
|
||||||
var actual = await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
|
var actual = await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
|
||||||
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes);
|
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes);
|
||||||
|
|
||||||
@ -331,5 +342,121 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
|
|||||||
|
|
||||||
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().Received(1)
|
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().Received(1)
|
||||||
.CreateAsync(Arg.Is<OrganizationSponsorship>(s => SponsorshipValidator(s, expectedSponsorship)));
|
.CreateAsync(Arg.Is<OrganizationSponsorship>(s => SponsorshipValidator(s, expectedSponsorship)));
|
||||||
|
|
||||||
|
// Verify we didn't need to add seats
|
||||||
|
await sutProvider.GetDependency<IOrganizationService>().DidNotReceive()
|
||||||
|
.AutoAddSeatsAsync(Arg.Any<Organization>(), Arg.Any<int>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData(OrganizationUserType.Admin)]
|
||||||
|
[BitAutoData(OrganizationUserType.Owner)]
|
||||||
|
public async Task CreateSponsorship_CreatesAdminInitiatedSponsorship_AutoscalesWhenNeeded(
|
||||||
|
OrganizationUserType organizationUserType,
|
||||||
|
Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, User user, string sponsoredEmail,
|
||||||
|
string friendlyName, Guid sponsorshipId, Guid currentUserId, string notes, SutProvider<CreateSponsorshipCommand> sutProvider)
|
||||||
|
{
|
||||||
|
sponsoringOrg.PlanType = PlanType.EnterpriseAnnually;
|
||||||
|
sponsoringOrg.UseAdminSponsoredFamilies = true;
|
||||||
|
sponsoringOrg.Seats = 10;
|
||||||
|
sponsoringOrgUser.Status = OrganizationUserStatusType.Confirmed;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IUserService>().GetUserByIdAsync(sponsoringOrgUser.UserId!.Value).Returns(user);
|
||||||
|
sutProvider.GetDependency<IOrganizationSponsorshipRepository>().WhenForAnyArgs(x => x.UpsertAsync(null!)).Do(callInfo =>
|
||||||
|
{
|
||||||
|
var sponsorship = callInfo.Arg<OrganizationSponsorship>();
|
||||||
|
sponsorship.Id = sponsorshipId;
|
||||||
|
});
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(currentUserId);
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().Organizations.Returns([
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = sponsoringOrg.Id,
|
||||||
|
Permissions = new Permissions { ManageUsers = true },
|
||||||
|
Type = organizationUserType
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Setup for checking available seats - organization has no available seats
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
|
.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id)
|
||||||
|
.Returns(10);
|
||||||
|
|
||||||
|
// Setup for checking if can scale
|
||||||
|
sutProvider.GetDependency<IOrganizationService>()
|
||||||
|
.CanScaleAsync(sponsoringOrg, 1)
|
||||||
|
.Returns((true, ""));
|
||||||
|
|
||||||
|
var actual = await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
|
||||||
|
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes);
|
||||||
|
|
||||||
|
|
||||||
|
var expectedSponsorship = new OrganizationSponsorship
|
||||||
|
{
|
||||||
|
Id = sponsorshipId,
|
||||||
|
SponsoringOrganizationId = sponsoringOrg.Id,
|
||||||
|
SponsoringOrganizationUserId = sponsoringOrgUser.Id,
|
||||||
|
FriendlyName = friendlyName,
|
||||||
|
OfferedToEmail = sponsoredEmail,
|
||||||
|
PlanSponsorshipType = PlanSponsorshipType.FamiliesForEnterprise,
|
||||||
|
IsAdminInitiated = true,
|
||||||
|
Notes = notes
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.True(SponsorshipValidator(expectedSponsorship, actual));
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().Received(1)
|
||||||
|
.CreateAsync(Arg.Is<OrganizationSponsorship>(s => SponsorshipValidator(s, expectedSponsorship)));
|
||||||
|
|
||||||
|
// Verify we needed to add seats
|
||||||
|
await sutProvider.GetDependency<IOrganizationService>().Received(1)
|
||||||
|
.AutoAddSeatsAsync(sponsoringOrg, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData(OrganizationUserType.Admin)]
|
||||||
|
[BitAutoData(OrganizationUserType.Owner)]
|
||||||
|
public async Task CreateSponsorship_CreatesAdminInitiatedSponsorship_ThrowsWhenCannotAutoscale(
|
||||||
|
OrganizationUserType organizationUserType,
|
||||||
|
Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, User user, string sponsoredEmail,
|
||||||
|
string friendlyName, Guid sponsorshipId, Guid currentUserId, string notes, SutProvider<CreateSponsorshipCommand> sutProvider)
|
||||||
|
{
|
||||||
|
sponsoringOrg.PlanType = PlanType.EnterpriseAnnually;
|
||||||
|
sponsoringOrg.UseAdminSponsoredFamilies = true;
|
||||||
|
sponsoringOrg.Seats = 10;
|
||||||
|
sponsoringOrgUser.Status = OrganizationUserStatusType.Confirmed;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IUserService>().GetUserByIdAsync(sponsoringOrgUser.UserId!.Value).Returns(user);
|
||||||
|
sutProvider.GetDependency<IOrganizationSponsorshipRepository>().WhenForAnyArgs(x => x.UpsertAsync(null!)).Do(callInfo =>
|
||||||
|
{
|
||||||
|
var sponsorship = callInfo.Arg<OrganizationSponsorship>();
|
||||||
|
sponsorship.Id = sponsorshipId;
|
||||||
|
});
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(currentUserId);
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().Organizations.Returns([
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = sponsoringOrg.Id,
|
||||||
|
Permissions = new Permissions { ManageUsers = true },
|
||||||
|
Type = organizationUserType
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Setup for checking available seats - organization has no available seats
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
|
.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id)
|
||||||
|
.Returns(10);
|
||||||
|
|
||||||
|
// Setup for checking if can scale - cannot scale
|
||||||
|
var failureReason = "Seat limit has been reached.";
|
||||||
|
sutProvider.GetDependency<IOrganizationService>()
|
||||||
|
.CanScaleAsync(sponsoringOrg, 1)
|
||||||
|
.Returns((false, failureReason));
|
||||||
|
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||||
|
sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
|
||||||
|
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes));
|
||||||
|
|
||||||
|
Assert.Equal(failureReason, exception.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,350 @@
|
|||||||
|
CREATE OR ALTER PROCEDURE [dbo].[Cipher_UpdateAttachment]
|
||||||
|
@Id UNIQUEIDENTIFIER,
|
||||||
|
@UserId UNIQUEIDENTIFIER,
|
||||||
|
@OrganizationId UNIQUEIDENTIFIER,
|
||||||
|
@AttachmentId VARCHAR(50),
|
||||||
|
@AttachmentData NVARCHAR(MAX)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON
|
||||||
|
|
||||||
|
-- Validate that AttachmentData is valid JSON
|
||||||
|
IF ISJSON(@AttachmentData) = 0
|
||||||
|
BEGIN
|
||||||
|
THROW 50000, 'Invalid JSON format in AttachmentData parameter', 1;
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Validate that AttachmentData has the expected structure
|
||||||
|
-- Check for required fields
|
||||||
|
IF JSON_VALUE(@AttachmentData, '$.FileName') IS NULL OR
|
||||||
|
JSON_VALUE(@AttachmentData, '$.Size') IS NULL
|
||||||
|
BEGIN
|
||||||
|
THROW 50000, 'AttachmentData is missing required fields (FileName, Size)', 1;
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Validate data types for critical fields
|
||||||
|
DECLARE @Size BIGINT = TRY_CAST(JSON_VALUE(@AttachmentData, '$.Size') AS BIGINT)
|
||||||
|
IF @Size IS NULL OR @Size <= 0
|
||||||
|
BEGIN
|
||||||
|
THROW 50000, 'AttachmentData has invalid Size value', 1;
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
DECLARE @AttachmentIdKey VARCHAR(50) = CONCAT('"', @AttachmentId, '"')
|
||||||
|
DECLARE @AttachmentIdPath VARCHAR(50) = CONCAT('$.', @AttachmentIdKey)
|
||||||
|
DECLARE @NewAttachments NVARCHAR(MAX)
|
||||||
|
|
||||||
|
-- Get current attachments
|
||||||
|
DECLARE @CurrentAttachments NVARCHAR(MAX)
|
||||||
|
SELECT @CurrentAttachments = [Attachments] FROM [dbo].[Cipher] WHERE [Id] = @Id
|
||||||
|
|
||||||
|
-- Prepare the new attachments value based on current state
|
||||||
|
IF @CurrentAttachments IS NULL
|
||||||
|
BEGIN
|
||||||
|
-- Create new JSON object with the attachment
|
||||||
|
SET @NewAttachments = CONCAT('{', @AttachmentIdKey, ':', @AttachmentData, '}')
|
||||||
|
|
||||||
|
-- Validate the constructed JSON
|
||||||
|
IF ISJSON(@NewAttachments) = 0
|
||||||
|
BEGIN
|
||||||
|
THROW 50000, 'Failed to create valid JSON when adding new attachment', 1;
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
BEGIN
|
||||||
|
-- Validate existing attachments
|
||||||
|
IF ISJSON(@CurrentAttachments) = 0
|
||||||
|
BEGIN
|
||||||
|
THROW 50000, 'Current attachments data is not valid JSON', 1;
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Modify existing JSON
|
||||||
|
SET @NewAttachments = JSON_MODIFY(@CurrentAttachments, @AttachmentIdPath, JSON_QUERY(@AttachmentData, '$'))
|
||||||
|
|
||||||
|
-- Validate the modified JSON
|
||||||
|
IF ISJSON(@NewAttachments) = 0
|
||||||
|
BEGIN
|
||||||
|
THROW 50000, 'Failed to create valid JSON when updating existing attachments', 1;
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Update with validated JSON
|
||||||
|
UPDATE [dbo].[Cipher]
|
||||||
|
SET [Attachments] = @NewAttachments
|
||||||
|
WHERE [Id] = @Id
|
||||||
|
|
||||||
|
IF @OrganizationId IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
EXEC [dbo].[Organization_UpdateStorage] @OrganizationId
|
||||||
|
EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId
|
||||||
|
END
|
||||||
|
ELSE IF @UserId IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
EXEC [dbo].[User_UpdateStorage] @UserId
|
||||||
|
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
|
||||||
|
END
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE [dbo].[Cipher_DeleteAttachment]
|
||||||
|
@Id UNIQUEIDENTIFIER,
|
||||||
|
@AttachmentId VARCHAR(50)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON
|
||||||
|
|
||||||
|
DECLARE @AttachmentIdKey VARCHAR(50) = CONCAT('"', @AttachmentId, '"')
|
||||||
|
DECLARE @AttachmentIdPath VARCHAR(50) = CONCAT('$.', @AttachmentIdKey)
|
||||||
|
|
||||||
|
DECLARE @UserId UNIQUEIDENTIFIER
|
||||||
|
DECLARE @OrganizationId UNIQUEIDENTIFIER
|
||||||
|
DECLARE @CurrentAttachments NVARCHAR(MAX)
|
||||||
|
DECLARE @NewAttachments NVARCHAR(MAX)
|
||||||
|
|
||||||
|
-- Get current cipher data
|
||||||
|
SELECT
|
||||||
|
@UserId = [UserId],
|
||||||
|
@OrganizationId = [OrganizationId],
|
||||||
|
@CurrentAttachments = [Attachments]
|
||||||
|
FROM
|
||||||
|
[dbo].[Cipher]
|
||||||
|
WHERE [Id] = @Id
|
||||||
|
|
||||||
|
-- If there are no attachments, nothing to do
|
||||||
|
IF @CurrentAttachments IS NULL
|
||||||
|
BEGIN
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Validate the initial JSON
|
||||||
|
IF ISJSON(@CurrentAttachments) = 0
|
||||||
|
BEGIN
|
||||||
|
THROW 50000, 'Current initial attachments data is not valid JSON', 1;
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Check if the attachment exists before trying to remove it
|
||||||
|
IF JSON_PATH_EXISTS(@CurrentAttachments, @AttachmentIdPath) = 0
|
||||||
|
BEGIN
|
||||||
|
-- Attachment doesn't exist, nothing to do
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Create the new attachments JSON with the specified attachment removed
|
||||||
|
SET @NewAttachments = JSON_MODIFY(@CurrentAttachments, @AttachmentIdPath, NULL)
|
||||||
|
|
||||||
|
-- Validate the resulting JSON
|
||||||
|
IF ISJSON(@NewAttachments) = 0
|
||||||
|
BEGIN
|
||||||
|
THROW 50000, 'Failed to create valid JSON when removing attachment', 1;
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Check if we've removed all attachments and have an empty object
|
||||||
|
IF @NewAttachments = '{}'
|
||||||
|
BEGIN
|
||||||
|
-- If we have an empty JSON object, set to NULL instead
|
||||||
|
SET @NewAttachments = NULL;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Update with validated JSON
|
||||||
|
UPDATE [dbo].[Cipher]
|
||||||
|
SET [Attachments] = @NewAttachments
|
||||||
|
WHERE [Id] = @Id
|
||||||
|
|
||||||
|
IF @OrganizationId IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
EXEC [dbo].[Organization_UpdateStorage] @OrganizationId
|
||||||
|
EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId
|
||||||
|
END
|
||||||
|
ELSE IF @UserId IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
EXEC [dbo].[User_UpdateStorage] @UserId
|
||||||
|
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
|
||||||
|
END
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- Remove [Attachments] assignment from Cipher_Create, Cipher_Update, and CipherDetails_Update procedures
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE [dbo].[Cipher_Create]
|
||||||
|
@Id UNIQUEIDENTIFIER OUTPUT,
|
||||||
|
@UserId UNIQUEIDENTIFIER,
|
||||||
|
@OrganizationId UNIQUEIDENTIFIER,
|
||||||
|
@Type TINYINT,
|
||||||
|
@Data NVARCHAR(MAX),
|
||||||
|
@Favorites NVARCHAR(MAX),
|
||||||
|
@Folders NVARCHAR(MAX),
|
||||||
|
@Attachments NVARCHAR(MAX), -- not used
|
||||||
|
@CreationDate DATETIME2(7),
|
||||||
|
@RevisionDate DATETIME2(7),
|
||||||
|
@DeletedDate DATETIME2(7),
|
||||||
|
@Reprompt TINYINT,
|
||||||
|
@Key VARCHAR(MAX) = NULL
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON
|
||||||
|
|
||||||
|
INSERT INTO [dbo].[Cipher]
|
||||||
|
(
|
||||||
|
[Id],
|
||||||
|
[UserId],
|
||||||
|
[OrganizationId],
|
||||||
|
[Type],
|
||||||
|
[Data],
|
||||||
|
[Favorites],
|
||||||
|
[Folders],
|
||||||
|
[CreationDate],
|
||||||
|
[RevisionDate],
|
||||||
|
[DeletedDate],
|
||||||
|
[Reprompt],
|
||||||
|
[Key]
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
@Id,
|
||||||
|
CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END,
|
||||||
|
@OrganizationId,
|
||||||
|
@Type,
|
||||||
|
@Data,
|
||||||
|
@Favorites,
|
||||||
|
@Folders,
|
||||||
|
@CreationDate,
|
||||||
|
@RevisionDate,
|
||||||
|
@DeletedDate,
|
||||||
|
@Reprompt,
|
||||||
|
@Key
|
||||||
|
)
|
||||||
|
|
||||||
|
IF @OrganizationId IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId
|
||||||
|
END
|
||||||
|
ELSE IF @UserId IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
|
||||||
|
END
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_Update]
|
||||||
|
@Id UNIQUEIDENTIFIER,
|
||||||
|
@UserId UNIQUEIDENTIFIER,
|
||||||
|
@OrganizationId UNIQUEIDENTIFIER,
|
||||||
|
@Type TINYINT,
|
||||||
|
@Data NVARCHAR(MAX),
|
||||||
|
@Favorites NVARCHAR(MAX), -- not used
|
||||||
|
@Folders NVARCHAR(MAX), -- not used
|
||||||
|
@Attachments NVARCHAR(MAX), -- not used
|
||||||
|
@CreationDate DATETIME2(7),
|
||||||
|
@RevisionDate DATETIME2(7),
|
||||||
|
@FolderId UNIQUEIDENTIFIER,
|
||||||
|
@Favorite BIT,
|
||||||
|
@Edit BIT, -- not used
|
||||||
|
@ViewPassword BIT, -- not used
|
||||||
|
@Manage BIT, -- not used
|
||||||
|
@OrganizationUseTotp BIT, -- not used
|
||||||
|
@DeletedDate DATETIME2(2),
|
||||||
|
@Reprompt TINYINT,
|
||||||
|
@Key VARCHAR(MAX) = NULL
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON
|
||||||
|
|
||||||
|
DECLARE @UserIdKey VARCHAR(50) = CONCAT('"', @UserId, '"')
|
||||||
|
DECLARE @UserIdPath VARCHAR(50) = CONCAT('$.', @UserIdKey)
|
||||||
|
|
||||||
|
UPDATE
|
||||||
|
[dbo].[Cipher]
|
||||||
|
SET
|
||||||
|
[UserId] = CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END,
|
||||||
|
[OrganizationId] = @OrganizationId,
|
||||||
|
[Type] = @Type,
|
||||||
|
[Data] = @Data,
|
||||||
|
[Folders] =
|
||||||
|
CASE
|
||||||
|
WHEN @FolderId IS NOT NULL AND [Folders] IS NULL THEN
|
||||||
|
CONCAT('{', @UserIdKey, ':"', @FolderId, '"', '}')
|
||||||
|
WHEN @FolderId IS NOT NULL THEN
|
||||||
|
JSON_MODIFY([Folders], @UserIdPath, CAST(@FolderId AS VARCHAR(50)))
|
||||||
|
ELSE
|
||||||
|
JSON_MODIFY([Folders], @UserIdPath, NULL)
|
||||||
|
END,
|
||||||
|
[Favorites] =
|
||||||
|
CASE
|
||||||
|
WHEN @Favorite = 1 AND [Favorites] IS NULL THEN
|
||||||
|
CONCAT('{', @UserIdKey, ':true}')
|
||||||
|
WHEN @Favorite = 1 THEN
|
||||||
|
JSON_MODIFY([Favorites], @UserIdPath, CAST(1 AS BIT))
|
||||||
|
ELSE
|
||||||
|
JSON_MODIFY([Favorites], @UserIdPath, NULL)
|
||||||
|
END,
|
||||||
|
[Reprompt] = @Reprompt,
|
||||||
|
[CreationDate] = @CreationDate,
|
||||||
|
[RevisionDate] = @RevisionDate,
|
||||||
|
[DeletedDate] = @DeletedDate,
|
||||||
|
[Key] = @Key
|
||||||
|
WHERE
|
||||||
|
[Id] = @Id
|
||||||
|
|
||||||
|
IF @OrganizationId IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId
|
||||||
|
END
|
||||||
|
ELSE IF @UserId IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
|
||||||
|
END
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE [dbo].[Cipher_Update]
|
||||||
|
@Id UNIQUEIDENTIFIER,
|
||||||
|
@UserId UNIQUEIDENTIFIER,
|
||||||
|
@OrganizationId UNIQUEIDENTIFIER,
|
||||||
|
@Type TINYINT,
|
||||||
|
@Data NVARCHAR(MAX),
|
||||||
|
@Favorites NVARCHAR(MAX),
|
||||||
|
@Folders NVARCHAR(MAX),
|
||||||
|
@Attachments NVARCHAR(MAX), -- not used
|
||||||
|
@CreationDate DATETIME2(7),
|
||||||
|
@RevisionDate DATETIME2(7),
|
||||||
|
@DeletedDate DATETIME2(7),
|
||||||
|
@Reprompt TINYINT,
|
||||||
|
@Key VARCHAR(MAX) = NULL
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON
|
||||||
|
|
||||||
|
UPDATE
|
||||||
|
[dbo].[Cipher]
|
||||||
|
SET
|
||||||
|
[UserId] = CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END,
|
||||||
|
[OrganizationId] = @OrganizationId,
|
||||||
|
[Type] = @Type,
|
||||||
|
[Data] = @Data,
|
||||||
|
[Favorites] = @Favorites,
|
||||||
|
[Folders] = @Folders,
|
||||||
|
[CreationDate] = @CreationDate,
|
||||||
|
[RevisionDate] = @RevisionDate,
|
||||||
|
[DeletedDate] = @DeletedDate,
|
||||||
|
[Reprompt] = @Reprompt,
|
||||||
|
[Key] = @Key
|
||||||
|
WHERE
|
||||||
|
[Id] = @Id
|
||||||
|
|
||||||
|
IF @OrganizationId IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId
|
||||||
|
END
|
||||||
|
ELSE IF @UserId IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
|
||||||
|
END
|
||||||
|
END
|
||||||
|
GO
|
@ -0,0 +1,41 @@
|
|||||||
|
IF OBJECT_ID('[dbo].[OrganizationUser_ReadOccupiedSeatCountByOrganizationId]') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP PROCEDURE [dbo].[OrganizationUser_ReadOccupiedSeatCountByOrganizationId]
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE PROCEDURE [dbo].[OrganizationUser_ReadOccupiedSeatCountByOrganizationId]
|
||||||
|
@OrganizationId UNIQUEIDENTIFIER
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
(
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM [dbo].[OrganizationUserView]
|
||||||
|
WHERE OrganizationId = @OrganizationId
|
||||||
|
AND Status >= 0 --Invited
|
||||||
|
) +
|
||||||
|
(
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM [dbo].[OrganizationSponsorship]
|
||||||
|
WHERE SponsoringOrganizationId = @OrganizationId
|
||||||
|
AND IsAdminInitiated = 1
|
||||||
|
AND (
|
||||||
|
-- Not marked for deletion - always count
|
||||||
|
(ToDelete = 0)
|
||||||
|
OR
|
||||||
|
-- Marked for deletion but has a valid until date in the future (RevokeWhenExpired status)
|
||||||
|
(ToDelete = 1 AND ValidUntil IS NOT NULL AND ValidUntil > GETUTCDATE())
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
-- SENT status: When SponsoredOrganizationId is null
|
||||||
|
SponsoredOrganizationId IS NULL
|
||||||
|
OR
|
||||||
|
-- ACCEPTED status: When SponsoredOrganizationId is not null and ValidUntil is null or in the future
|
||||||
|
(SponsoredOrganizationId IS NOT NULL AND (ValidUntil IS NULL OR ValidUntil > GETUTCDATE()))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
END
|
||||||
|
GO
|
@ -10,7 +10,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="[8.0.8]">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="[8.0.8]">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="[8.0.8]">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="[8.0.8]">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user