mirror of
https://github.com/bitwarden/server.git
synced 2025-04-21 04:55:08 -05:00
Merge branch 'main' into add-docker-arm64-builds
This commit is contained in:
commit
632f9bcdbe
7
.github/workflows/version-bump.yml
vendored
7
.github/workflows/version-bump.yml
vendored
@ -198,6 +198,13 @@ jobs:
|
||||
PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }}
|
||||
run: gh pr merge $PR_NUMBER --squash --auto --delete-branch
|
||||
|
||||
- name: Report upcoming release version to Slack
|
||||
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
|
||||
uses: bitwarden/gh-actions/report-upcoming-release-version@main
|
||||
with:
|
||||
version: ${{ steps.set-final-version-output.outputs.version }}
|
||||
project: ${{ github.repository }}
|
||||
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
cut_rc:
|
||||
name: Cut RC branch
|
||||
|
@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
<Version>2024.5.0</Version>
|
||||
<Version>2024.5.1</Version>
|
||||
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
@ -46,6 +46,13 @@ public class CreateProviderCommand : ICreateProviderCommand
|
||||
throw new BadRequestException("Invalid owner. Owner must be an existing Bitwarden user.");
|
||||
}
|
||||
|
||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (isConsolidatedBillingEnabled)
|
||||
{
|
||||
provider.Gateway = GatewayType.Stripe;
|
||||
}
|
||||
|
||||
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Pending);
|
||||
|
||||
var providerUser = new ProviderUser
|
||||
@ -56,8 +63,6 @@ public class CreateProviderCommand : ICreateProviderCommand
|
||||
Status = ProviderUserStatusType.Confirmed,
|
||||
};
|
||||
|
||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (isConsolidatedBillingEnabled)
|
||||
{
|
||||
var providerPlans = new List<ProviderPlan>
|
||||
|
@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
@ -76,6 +77,35 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
|
||||
organization.BillingEmail = organizationOwnerEmails.MinBy(email => email);
|
||||
|
||||
await ResetOrganizationBillingAsync(organization, provider, organizationOwnerEmails);
|
||||
|
||||
await _organizationRepository.ReplaceAsync(organization);
|
||||
|
||||
await _providerOrganizationRepository.DeleteAsync(providerOrganization);
|
||||
|
||||
await _eventService.LogProviderOrganizationEventAsync(
|
||||
providerOrganization,
|
||||
EventType.ProviderOrganization_Removed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When a client organization is unlinked from a provider, we have to check if they're Stripe-enabled
|
||||
/// and, if they are, we remove their MSP discount and set their Subscription to `send_invoice`. This is because
|
||||
/// the provider's payment method will be removed from their Stripe customer causing ensuing charges to fail. Lastly,
|
||||
/// we email the organization owners letting them know they need to add a new payment method.
|
||||
/// </summary>
|
||||
private async Task ResetOrganizationBillingAsync(
|
||||
Organization organization,
|
||||
Provider provider,
|
||||
IEnumerable<string> organizationOwnerEmails)
|
||||
{
|
||||
if (!organization.IsStripeEnabled())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
var customerUpdateOptions = new CustomerUpdateOptions
|
||||
{
|
||||
Coupon = string.Empty,
|
||||
@ -84,11 +114,10 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
|
||||
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, customerUpdateOptions);
|
||||
|
||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (isConsolidatedBillingEnabled && provider.Status == ProviderStatusType.Billable)
|
||||
{
|
||||
var plan = StaticStore.GetPlan(organization.PlanType).PasswordManager;
|
||||
|
||||
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
||||
{
|
||||
Customer = organization.GatewayCustomerId,
|
||||
@ -103,8 +132,11 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
|
||||
Items = [new SubscriptionItemOptions { Price = plan.StripeSeatPlanId, Quantity = organization.Seats }]
|
||||
};
|
||||
|
||||
var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||
|
||||
organization.GatewaySubscriptionId = subscription.Id;
|
||||
|
||||
await _scaleSeatsCommand.ScalePasswordManagerSeats(provider, organization.PlanType,
|
||||
-(organization.Seats ?? 0));
|
||||
}
|
||||
@ -115,21 +147,14 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
CollectionMethod = "send_invoice",
|
||||
DaysUntilDue = 30
|
||||
};
|
||||
|
||||
await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, subscriptionUpdateOptions);
|
||||
}
|
||||
|
||||
await _organizationRepository.ReplaceAsync(organization);
|
||||
|
||||
await _mailService.SendProviderUpdatePaymentMethod(
|
||||
organization.Id,
|
||||
organization.Name,
|
||||
provider.Name,
|
||||
organizationOwnerEmails);
|
||||
|
||||
await _providerOrganizationRepository.DeleteAsync(providerOrganization);
|
||||
|
||||
await _eventService.LogProviderOrganizationEventAsync(
|
||||
providerOrganization,
|
||||
EventType.ProviderOrganization_Removed);
|
||||
}
|
||||
}
|
||||
|
@ -14,17 +14,23 @@ namespace Bit.Scim.Users;
|
||||
|
||||
public class PostUserCommand : IPostUserCommand
|
||||
{
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IPaymentService _paymentService;
|
||||
private readonly IScimContext _scimContext;
|
||||
|
||||
public PostUserCommand(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationService organizationService,
|
||||
IPaymentService paymentService,
|
||||
IScimContext scimContext)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationService = organizationService;
|
||||
_paymentService = paymentService;
|
||||
_scimContext = scimContext;
|
||||
}
|
||||
|
||||
@ -80,8 +86,13 @@ public class PostUserCommand : IPostUserCommand
|
||||
throw new ConflictException();
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization);
|
||||
|
||||
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, EventSystemUser.SCIM, email,
|
||||
OrganizationUserType.User, false, externalId, new List<CollectionAccessSelection>(), new List<Guid>());
|
||||
OrganizationUserType.User, false, externalId, new List<CollectionAccessSelection>(), new List<Guid>(), hasStandaloneSecretsManager);
|
||||
|
||||
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
|
||||
|
||||
return orgUser;
|
||||
|
@ -14,6 +14,7 @@ using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
using IMailService = Bit.Core.Services.IMailService;
|
||||
|
||||
namespace Bit.Commercial.Core.Test.AdminConsole.ProviderFeatures;
|
||||
|
||||
@ -83,6 +84,55 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
Assert.Equal("Organization must have at least one confirmed owner.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemoveOrganizationFromProvider_NoStripeObjects_MakesCorrectInvocations(
|
||||
Provider provider,
|
||||
ProviderOrganization providerOrganization,
|
||||
Organization organization,
|
||||
SutProvider<RemoveOrganizationFromProviderCommand> sutProvider)
|
||||
{
|
||||
organization.GatewayCustomerId = null;
|
||||
organization.GatewaySubscriptionId = null;
|
||||
|
||||
providerOrganization.ProviderId = provider.Id;
|
||||
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
|
||||
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
|
||||
providerOrganization.OrganizationId,
|
||||
Array.Empty<Guid>(),
|
||||
includeProvider: false)
|
||||
.Returns(true);
|
||||
|
||||
var organizationOwnerEmails = new List<string> { "a@gmail.com", "b@gmail.com" };
|
||||
|
||||
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns(organizationOwnerEmails);
|
||||
|
||||
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
|
||||
|
||||
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(
|
||||
org => org.Id == organization.Id && org.BillingEmail == "a@gmail.com"));
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
await stripeAdapter.DidNotReceiveWithAnyArgs().CustomerUpdateAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
|
||||
|
||||
await stripeAdapter.DidNotReceiveWithAnyArgs().SubscriptionUpdateAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
|
||||
|
||||
await sutProvider.GetDependency<IMailService>().DidNotReceiveWithAnyArgs().SendProviderUpdatePaymentMethod(
|
||||
Arg.Any<Guid>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<IEnumerable<string>>());
|
||||
|
||||
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
|
||||
.DeleteAsync(providerOrganization);
|
||||
|
||||
await sutProvider.GetDependency<IEventService>().Received(1).LogProviderOrganizationEventAsync(
|
||||
providerOrganization,
|
||||
EventType.ProviderOrganization_Removed);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemoveOrganizationFromProvider_MakesCorrectInvocations__FeatureFlagOff(
|
||||
Provider provider,
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
@ -19,7 +20,7 @@ public class PostUserCommandTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PostUser_Success(SutProvider<PostUserCommand> sutProvider, string externalId, Guid organizationId, List<BaseScimUserModel.EmailModel> emails, ICollection<OrganizationUserUserDetails> organizationUsers, Core.Entities.OrganizationUser newUser)
|
||||
public async Task PostUser_Success(SutProvider<PostUserCommand> sutProvider, string externalId, Guid organizationId, List<BaseScimUserModel.EmailModel> emails, ICollection<OrganizationUserUserDetails> organizationUsers, Core.Entities.OrganizationUser newUser, Organization organization)
|
||||
{
|
||||
var scimUserRequestModel = new ScimUserRequestModel
|
||||
{
|
||||
@ -33,16 +34,20 @@ public class PostUserCommandTests
|
||||
.GetManyDetailsByOrganizationAsync(organizationId)
|
||||
.Returns(organizationUsers);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
sutProvider.GetDependency<IPaymentService>().HasSecretsManagerStandalone(organization).Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationService>()
|
||||
.InviteUserAsync(organizationId, EventSystemUser.SCIM, scimUserRequestModel.PrimaryEmail.ToLowerInvariant(),
|
||||
OrganizationUserType.User, false, externalId, Arg.Any<List<CollectionAccessSelection>>(),
|
||||
Arg.Any<List<Guid>>())
|
||||
Arg.Any<List<Guid>>(), true)
|
||||
.Returns(newUser);
|
||||
|
||||
var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationService>().Received(1).InviteUserAsync(organizationId, EventSystemUser.SCIM, scimUserRequestModel.PrimaryEmail.ToLowerInvariant(),
|
||||
OrganizationUserType.User, false, scimUserRequestModel.ExternalId, Arg.Any<List<CollectionAccessSelection>>(), Arg.Any<List<Guid>>());
|
||||
OrganizationUserType.User, false, scimUserRequestModel.ExternalId, Arg.Any<List<CollectionAccessSelection>>(), Arg.Any<List<Guid>>(), true);
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).GetDetailsByIdAsync(newUser.Id);
|
||||
}
|
||||
|
||||
|
@ -269,6 +269,35 @@ public class OrganizationsController : Controller
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
[RequirePermission(Permission.Org_Delete)]
|
||||
public async Task<IActionResult> DeleteInitiation(Guid id, OrganizationInitiateDeleteModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
TempData["Error"] = ModelState.GetErrorMessage();
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var organization = await _organizationRepository.GetByIdAsync(id);
|
||||
if (organization != null)
|
||||
{
|
||||
await _organizationService.InitiateDeleteAsync(organization, model.AdminEmail);
|
||||
TempData["Success"] = "The request to initiate deletion of the organization has been sent.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TempData["Error"] = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
return RedirectToAction("Edit", new { id });
|
||||
}
|
||||
|
||||
public async Task<IActionResult> TriggerBillingSync(Guid id)
|
||||
{
|
||||
var organization = await _organizationRepository.GetByIdAsync(id);
|
||||
@ -349,7 +378,10 @@ public class OrganizationsController : Controller
|
||||
providerOrganization,
|
||||
organization);
|
||||
|
||||
if (organization.IsStripeEnabled())
|
||||
{
|
||||
await _removePaymentMethodCommand.RemovePaymentMethod(organization);
|
||||
}
|
||||
|
||||
return Json(null);
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ using Bit.Admin.Utilities;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Utilities;
|
||||
@ -69,7 +70,10 @@ public class ProviderOrganizationsController : Controller
|
||||
}
|
||||
|
||||
|
||||
if (organization.IsStripeEnabled())
|
||||
{
|
||||
await _removePaymentMethodCommand.RemovePaymentMethod(organization);
|
||||
}
|
||||
|
||||
return Json(null);
|
||||
}
|
||||
|
@ -17,7 +17,6 @@ using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tools.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@ -36,7 +35,6 @@ public class ProvidersController : Controller
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly IProviderService _providerService;
|
||||
private readonly IReferenceEventService _referenceEventService;
|
||||
private readonly IUserService _userService;
|
||||
private readonly ICreateProviderCommand _createProviderCommand;
|
||||
private readonly IFeatureService _featureService;
|
||||
@ -51,7 +49,6 @@ public class ProvidersController : Controller
|
||||
IProviderService providerService,
|
||||
GlobalSettings globalSettings,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
IReferenceEventService referenceEventService,
|
||||
IUserService userService,
|
||||
ICreateProviderCommand createProviderCommand,
|
||||
IFeatureService featureService,
|
||||
@ -65,7 +62,6 @@ public class ProvidersController : Controller
|
||||
_providerService = providerService;
|
||||
_globalSettings = globalSettings;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_referenceEventService = referenceEventService;
|
||||
_userService = userService;
|
||||
_createProviderCommand = createProviderCommand;
|
||||
_featureService = featureService;
|
||||
@ -104,8 +100,8 @@ public class ProvidersController : Controller
|
||||
return View(new CreateProviderModel
|
||||
{
|
||||
OwnerEmail = ownerEmail,
|
||||
TeamsMinimumSeats = teamsMinimumSeats,
|
||||
EnterpriseMinimumSeats = enterpriseMinimumSeats
|
||||
TeamsMonthlySeatMinimum = teamsMinimumSeats,
|
||||
EnterpriseMonthlySeatMinimum = enterpriseMinimumSeats
|
||||
});
|
||||
}
|
||||
|
||||
@ -123,8 +119,11 @@ public class ProvidersController : Controller
|
||||
switch (provider.Type)
|
||||
{
|
||||
case ProviderType.Msp:
|
||||
await _createProviderCommand.CreateMspAsync(provider, model.OwnerEmail, model.TeamsMinimumSeats,
|
||||
model.EnterpriseMinimumSeats);
|
||||
await _createProviderCommand.CreateMspAsync(
|
||||
provider,
|
||||
model.OwnerEmail,
|
||||
model.TeamsMonthlySeatMinimum,
|
||||
model.EnterpriseMonthlySeatMinimum);
|
||||
break;
|
||||
case ProviderType.Reseller:
|
||||
await _createProviderCommand.CreateResellerAsync(provider);
|
||||
@ -167,8 +166,9 @@ public class ProvidersController : Controller
|
||||
return View(new ProviderEditModel(provider, users, providerOrganizations, new List<ProviderPlan>()));
|
||||
}
|
||||
|
||||
var providerPlan = await _providerPlanRepository.GetByProviderId(id);
|
||||
return View(new ProviderEditModel(provider, users, providerOrganizations, providerPlan));
|
||||
var providerPlans = await _providerPlanRepository.GetByProviderId(id);
|
||||
|
||||
return View(new ProviderEditModel(provider, users, providerOrganizations, providerPlans.ToList()));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
@ -177,14 +177,15 @@ public class ProvidersController : Controller
|
||||
[RequirePermission(Permission.Provider_Edit)]
|
||||
public async Task<IActionResult> Edit(Guid id, ProviderEditModel model)
|
||||
{
|
||||
var providerPlans = await _providerPlanRepository.GetByProviderId(id);
|
||||
var provider = await _providerRepository.GetByIdAsync(id);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
model.ToProvider(provider);
|
||||
|
||||
await _providerRepository.ReplaceAsync(provider);
|
||||
await _applicationCacheService.UpsertProviderAbilityAsync(provider);
|
||||
|
||||
@ -195,13 +196,14 @@ public class ProvidersController : Controller
|
||||
return RedirectToAction("Edit", new { id });
|
||||
}
|
||||
|
||||
model.ToProviderPlan(providerPlans);
|
||||
var providerPlans = await _providerPlanRepository.GetByProviderId(id);
|
||||
|
||||
if (providerPlans.Count == 0)
|
||||
{
|
||||
var newProviderPlans = new List<ProviderPlan>
|
||||
{
|
||||
new() {ProviderId = provider.Id, PlanType = PlanType.TeamsMonthly, SeatMinimum= model.TeamsMinimumSeats, PurchasedSeats = 0, AllocatedSeats = 0},
|
||||
new() {ProviderId = provider.Id, PlanType = PlanType.EnterpriseMonthly, SeatMinimum= model.EnterpriseMinimumSeats, PurchasedSeats = 0, AllocatedSeats = 0}
|
||||
new () { ProviderId = provider.Id, PlanType = PlanType.TeamsMonthly, SeatMinimum = model.TeamsMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 },
|
||||
new () { ProviderId = provider.Id, PlanType = PlanType.EnterpriseMonthly, SeatMinimum = model.EnterpriseMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 }
|
||||
};
|
||||
|
||||
foreach (var newProviderPlan in newProviderPlans)
|
||||
@ -213,6 +215,15 @@ public class ProvidersController : Controller
|
||||
{
|
||||
foreach (var providerPlan in providerPlans)
|
||||
{
|
||||
if (providerPlan.PlanType == PlanType.EnterpriseMonthly)
|
||||
{
|
||||
providerPlan.SeatMinimum = model.EnterpriseMonthlySeatMinimum;
|
||||
}
|
||||
else if (providerPlan.PlanType == PlanType.TeamsMonthly)
|
||||
{
|
||||
providerPlan.SeatMinimum = model.TeamsMonthlySeatMinimum;
|
||||
}
|
||||
|
||||
await _providerPlanRepository.ReplaceAsync(providerPlan);
|
||||
}
|
||||
}
|
||||
@ -294,9 +305,8 @@ public class ProvidersController : Controller
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
var flexibleCollectionsSignupEnabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup);
|
||||
var flexibleCollectionsV1Enabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1);
|
||||
var organization = model.CreateOrganization(provider, flexibleCollectionsSignupEnabled, flexibleCollectionsV1Enabled);
|
||||
var organization = model.CreateOrganization(provider, flexibleCollectionsV1Enabled);
|
||||
await _organizationService.CreatePendingOrganization(organization, model.Owners, User, _userService, model.SalesAssistedTrialStarted);
|
||||
await _providerService.AddOrganization(providerId, organization.Id, null);
|
||||
|
||||
|
@ -24,11 +24,11 @@ public class CreateProviderModel : IValidatableObject
|
||||
[Display(Name = "Primary Billing Email")]
|
||||
public string BillingEmail { get; set; }
|
||||
|
||||
[Display(Name = "Teams minimum seats")]
|
||||
public int TeamsMinimumSeats { get; set; }
|
||||
[Display(Name = "Teams (Monthly) Seat Minimum")]
|
||||
public int TeamsMonthlySeatMinimum { get; set; }
|
||||
|
||||
[Display(Name = "Enterprise minimum seats")]
|
||||
public int EnterpriseMinimumSeats { get; set; }
|
||||
[Display(Name = "Enterprise (Monthly) Seat Minimum")]
|
||||
public int EnterpriseMonthlySeatMinimum { get; set; }
|
||||
|
||||
public virtual Provider ToProvider()
|
||||
{
|
||||
@ -51,14 +51,14 @@ public class CreateProviderModel : IValidatableObject
|
||||
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(OwnerEmail);
|
||||
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
|
||||
}
|
||||
if (TeamsMinimumSeats < 0)
|
||||
if (TeamsMonthlySeatMinimum < 0)
|
||||
{
|
||||
var teamsMinimumSeatsDisplayName = nameof(TeamsMinimumSeats).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(TeamsMinimumSeats);
|
||||
var teamsMinimumSeatsDisplayName = nameof(TeamsMonthlySeatMinimum).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(TeamsMonthlySeatMinimum);
|
||||
yield return new ValidationResult($"The {teamsMinimumSeatsDisplayName} field can not be negative.");
|
||||
}
|
||||
if (EnterpriseMinimumSeats < 0)
|
||||
if (EnterpriseMonthlySeatMinimum < 0)
|
||||
{
|
||||
var enterpriseMinimumSeatsDisplayName = nameof(EnterpriseMinimumSeats).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(EnterpriseMinimumSeats);
|
||||
var enterpriseMinimumSeatsDisplayName = nameof(EnterpriseMonthlySeatMinimum).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(EnterpriseMonthlySeatMinimum);
|
||||
yield return new ValidationResult($"The {enterpriseMinimumSeatsDisplayName} field can not be negative.");
|
||||
}
|
||||
break;
|
||||
|
@ -162,19 +162,18 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
{ "baseServiceAccount", p.SecretsManager.BaseServiceAccount }
|
||||
});
|
||||
|
||||
public Organization CreateOrganization(Provider provider, bool flexibleCollectionsSignupEnabled, bool flexibleCollectionsV1Enabled)
|
||||
public Organization CreateOrganization(Provider provider, bool flexibleCollectionsV1Enabled)
|
||||
{
|
||||
BillingEmail = provider.BillingEmail;
|
||||
|
||||
var newOrg = new Organization
|
||||
{
|
||||
// This feature flag indicates that new organizations should be automatically onboarded to
|
||||
// Flexible Collections enhancements
|
||||
FlexibleCollections = flexibleCollectionsSignupEnabled,
|
||||
// These collection management settings smooth the migration for existing organizations by disabling some FC behavior.
|
||||
// If the organization is onboarded to Flexible Collections on signup, we turn them OFF to enable all new behaviour.
|
||||
// If the organization is NOT onboarded now, they will have to be migrated later, so they default to ON to limit FC changes on migration.
|
||||
LimitCollectionCreationDeletion = !flexibleCollectionsSignupEnabled,
|
||||
// Flexible Collections MVP is fully released and all organizations must always have this setting enabled.
|
||||
// AC-1714 will remove this flag after all old code has been removed.
|
||||
FlexibleCollections = true,
|
||||
|
||||
// This is a transitional setting that defaults to ON until Flexible Collections v1 is released
|
||||
// (to preserve existing behavior) and defaults to OFF after release (enabling new behavior)
|
||||
AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1Enabled
|
||||
};
|
||||
return ToOrganization(newOrg);
|
||||
|
@ -0,0 +1,12 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Admin.AdminConsole.Models;
|
||||
|
||||
public class OrganizationInitiateDeleteModel
|
||||
{
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
[StringLength(256)]
|
||||
[Display(Name = "Admin Email")]
|
||||
public string AdminEmail { get; set; }
|
||||
}
|
@ -10,16 +10,21 @@ public class ProviderEditModel : ProviderViewModel
|
||||
{
|
||||
public ProviderEditModel() { }
|
||||
|
||||
public ProviderEditModel(Provider provider, IEnumerable<ProviderUserUserDetails> providerUsers,
|
||||
IEnumerable<ProviderOrganizationOrganizationDetails> organizations, IEnumerable<ProviderPlan> providerPlans)
|
||||
: base(provider, providerUsers, organizations)
|
||||
public ProviderEditModel(
|
||||
Provider provider,
|
||||
IEnumerable<ProviderUserUserDetails> providerUsers,
|
||||
IEnumerable<ProviderOrganizationOrganizationDetails> organizations,
|
||||
IReadOnlyCollection<ProviderPlan> providerPlans) : base(provider, providerUsers, organizations)
|
||||
{
|
||||
Name = provider.DisplayName();
|
||||
BusinessName = provider.DisplayBusinessName();
|
||||
BillingEmail = provider.BillingEmail;
|
||||
BillingPhone = provider.BillingPhone;
|
||||
TeamsMinimumSeats = GetMinimumSeats(providerPlans, PlanType.TeamsMonthly);
|
||||
EnterpriseMinimumSeats = GetMinimumSeats(providerPlans, PlanType.EnterpriseMonthly);
|
||||
TeamsMonthlySeatMinimum = GetSeatMinimum(providerPlans, PlanType.TeamsMonthly);
|
||||
EnterpriseMonthlySeatMinimum = GetSeatMinimum(providerPlans, PlanType.EnterpriseMonthly);
|
||||
Gateway = provider.Gateway;
|
||||
GatewayCustomerId = provider.GatewayCustomerId;
|
||||
GatewaySubscriptionId = provider.GatewaySubscriptionId;
|
||||
}
|
||||
|
||||
[Display(Name = "Billing Email")]
|
||||
@ -29,38 +34,28 @@ public class ProviderEditModel : ProviderViewModel
|
||||
[Display(Name = "Business Name")]
|
||||
public string BusinessName { get; set; }
|
||||
public string Name { get; set; }
|
||||
[Display(Name = "Teams minimum seats")]
|
||||
public int TeamsMinimumSeats { get; set; }
|
||||
[Display(Name = "Teams (Monthly) Seat Minimum")]
|
||||
public int TeamsMonthlySeatMinimum { get; set; }
|
||||
|
||||
[Display(Name = "Enterprise minimum seats")]
|
||||
public int EnterpriseMinimumSeats { get; set; }
|
||||
[Display(Name = "Events")]
|
||||
[Display(Name = "Enterprise (Monthly) Seat Minimum")]
|
||||
public int EnterpriseMonthlySeatMinimum { get; set; }
|
||||
[Display(Name = "Gateway")]
|
||||
public GatewayType? Gateway { get; set; }
|
||||
[Display(Name = "Gateway Customer Id")]
|
||||
public string GatewayCustomerId { get; set; }
|
||||
[Display(Name = "Gateway Subscription Id")]
|
||||
public string GatewaySubscriptionId { get; set; }
|
||||
|
||||
public IEnumerable<ProviderPlan> ToProviderPlan(IEnumerable<ProviderPlan> existingProviderPlans)
|
||||
public virtual Provider ToProvider(Provider existingProvider)
|
||||
{
|
||||
var providerPlans = existingProviderPlans.ToList();
|
||||
foreach (var existingProviderPlan in providerPlans)
|
||||
{
|
||||
existingProviderPlan.SeatMinimum = existingProviderPlan.PlanType switch
|
||||
{
|
||||
PlanType.TeamsMonthly => TeamsMinimumSeats,
|
||||
PlanType.EnterpriseMonthly => EnterpriseMinimumSeats,
|
||||
_ => existingProviderPlan.SeatMinimum
|
||||
};
|
||||
}
|
||||
return providerPlans;
|
||||
}
|
||||
|
||||
public Provider ToProvider(Provider existingProvider)
|
||||
{
|
||||
existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant()?.Trim();
|
||||
existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant()?.Trim();
|
||||
existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant().Trim();
|
||||
existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant().Trim();
|
||||
existingProvider.Gateway = Gateway;
|
||||
existingProvider.GatewayCustomerId = GatewayCustomerId;
|
||||
existingProvider.GatewaySubscriptionId = GatewaySubscriptionId;
|
||||
return existingProvider;
|
||||
}
|
||||
|
||||
|
||||
private int GetMinimumSeats(IEnumerable<ProviderPlan> providerPlans, PlanType planType)
|
||||
{
|
||||
return (from providerPlan in providerPlans where providerPlan.PlanType == planType select (int)providerPlan.SeatMinimum).FirstOrDefault();
|
||||
}
|
||||
private static int GetSeatMinimum(IEnumerable<ProviderPlan> providerPlans, PlanType planType)
|
||||
=> providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == planType)?.SeatMinimum ?? 0;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
@using Bit.Admin.Enums;
|
||||
@using Bit.Admin.Enums;
|
||||
@using Bit.Admin.Models
|
||||
@using Bit.Core.Enums
|
||||
@inject Bit.Admin.Services.IAccessControlService AccessControlService
|
||||
@ -18,7 +18,9 @@
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
document.getElementById('teams-trial').addEventListener('click', () => {
|
||||
const treamsTrialButton = document.getElementById('teams-trial');
|
||||
if (treamsTrialButton != null) {
|
||||
treamsTrialButton.addEventListener('click', () => {
|
||||
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
|
||||
alert('Organization is not on a free plan.');
|
||||
return;
|
||||
@ -27,7 +29,11 @@
|
||||
togglePlanFeatures('@((byte)PlanType.TeamsAnnually)');
|
||||
document.getElementById('@(nameof(Model.Plan))').value = 'Teams (Trial)';
|
||||
});
|
||||
document.getElementById('enterprise-trial').addEventListener('click', () => {
|
||||
}
|
||||
|
||||
const entTrialButton = document.getElementById('enterprise-trial');
|
||||
if (entTrialButton != null) {
|
||||
entTrialButton.addEventListener('click', () => {
|
||||
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
|
||||
alert('Organization is not on a free plan.');
|
||||
return;
|
||||
@ -36,6 +42,19 @@
|
||||
togglePlanFeatures('@((byte)PlanType.EnterpriseAnnually)');
|
||||
document.getElementById('@(nameof(Model.Plan))').value = 'Enterprise (Trial)';
|
||||
});
|
||||
}
|
||||
|
||||
const initDeleteButton = document.getElementById('initiate-delete-form');
|
||||
if (initDeleteButton != null) {
|
||||
initDeleteButton.addEventListener('submit', (e) => {
|
||||
const email = prompt('Enter the email address of the owner/admin that your want to ' +
|
||||
'request the organization delete verification process with.');
|
||||
document.getElementById('AdminEmail').value = email;
|
||||
if (email == null || email === '') {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setTrialDefaults(planType) {
|
||||
// Plan
|
||||
@ -95,18 +114,20 @@
|
||||
}
|
||||
@if (canUnlinkFromProvider && Model.Provider is not null)
|
||||
{
|
||||
<button
|
||||
class="btn btn-outline-danger mr-2"
|
||||
onclick="return unlinkProvider('@Model.Organization.Id');"
|
||||
>
|
||||
<button class="btn btn-outline-danger mr-2"
|
||||
onclick="return unlinkProvider('@Model.Organization.Id');">
|
||||
Unlink provider
|
||||
</button>
|
||||
}
|
||||
@if (canDelete)
|
||||
{
|
||||
<form asp-action="DeleteInitiation" asp-route-id="@Model.Organization.Id" id="initiate-delete-form">
|
||||
<input type="hidden" name="AdminEmail" id="AdminEmail" />
|
||||
<button class="btn btn-danger mr-2" type="submit">Request Delete</button>
|
||||
</form>
|
||||
<form asp-action="Delete" asp-route-id="@Model.Organization.Id"
|
||||
onsubmit="return confirm('Are you sure you want to delete this organization?')">
|
||||
<button class="btn btn-danger" type="submit">Delete</button>
|
||||
onsubmit="return confirm('Are you sure you want to hard delete this organization?')">
|
||||
<button class="btn btn-outline-danger" type="submit">Delete</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
|
@ -46,14 +46,14 @@
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="TeamsMinimumSeats"></label>
|
||||
<input type="number" class="form-control" asp-for="TeamsMinimumSeats">
|
||||
<label asp-for="TeamsMonthlySeatMinimum"></label>
|
||||
<input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="EnterpriseMinimumSeats"></label>
|
||||
<input type="number" class="form-control" asp-for="EnterpriseMinimumSeats">
|
||||
<label asp-for="EnterpriseMonthlySeatMinimum"></label>
|
||||
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -48,14 +48,54 @@
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="TeamsMinimumSeats"></label>
|
||||
<input type="number" class="form-control" asp-for="TeamsMinimumSeats">
|
||||
<label asp-for="TeamsMonthlySeatMinimum"></label>
|
||||
<input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="EnterpriseMinimumSeats"></label>
|
||||
<input type="number" class="form-control" asp-for="EnterpriseMinimumSeats">
|
||||
<label asp-for="EnterpriseMonthlySeatMinimum"></label>
|
||||
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<div class="form-group">
|
||||
<label asp-for="Gateway"></label>
|
||||
<select class="form-control" asp-for="Gateway" asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="GatewayCustomerId"></label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" asp-for="GatewayCustomerId">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-secondary" type="button" id="gateway-customer-link">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="GatewaySubscriptionId"></label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" asp-for="GatewaySubscriptionId">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-secondary" type="button" id="gateway-subscription-link">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -146,5 +186,4 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
}
|
||||
|
@ -70,9 +70,10 @@
|
||||
@{
|
||||
var planTypes = Enum.GetValues<PlanType>()
|
||||
.Where(p =>
|
||||
Model.Provider == null ||
|
||||
(Model.Provider != null
|
||||
&& p is >= PlanType.TeamsMonthly2019 and <= PlanType.EnterpriseAnnually2019 or >= PlanType.TeamsMonthly2020 and <= PlanType.EnterpriseAnnually)
|
||||
(Model.Provider == null ||
|
||||
p is >= PlanType.TeamsMonthly2019 and <= PlanType.EnterpriseAnnually2019 or
|
||||
>= PlanType.TeamsMonthly2020 and <= PlanType.EnterpriseAnnually) &&
|
||||
p != PlanType.TeamsStarter
|
||||
)
|
||||
.Select(e => new SelectListItem
|
||||
{
|
||||
|
@ -174,6 +174,14 @@
|
||||
});
|
||||
</script>
|
||||
}
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
toastr.success("@TempData["Success"]")
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
@RenderSection("Scripts", required: false)
|
||||
</body>
|
||||
|
@ -109,7 +109,7 @@
|
||||
<option asp-selected="Model.Filter.Price == null" value="@null">All</option>
|
||||
@foreach (var price in Model.Prices)
|
||||
{
|
||||
<option asp-selected='@Model.Filter.Price == @price.Id' value="@price.Id">@price.Id</option>
|
||||
<option asp-selected='@(Model.Filter.Price == price.Id)' value="@price.Id">@price.Id</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@ -119,7 +119,7 @@
|
||||
<option asp-selected="Model.Filter.TestClock == null" value="@null">All</option>
|
||||
@foreach (var clock in Model.TestClocks)
|
||||
{
|
||||
<option asp-selected='@Model.Filter.TestClock == @clock.Id' value="@clock.Id">@clock.Name</option>
|
||||
<option asp-selected='@(Model.Filter.TestClock == clock.Id)' value="@clock.Id">@clock.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
@ -137,7 +137,9 @@ public class GroupsController : Controller
|
||||
}
|
||||
|
||||
// Flexible Collections - check the user has permission to grant access to the collections for the new group
|
||||
if (await FlexibleCollectionsIsEnabledAsync(orgId) && _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1))
|
||||
if (await FlexibleCollectionsIsEnabledAsync(orgId) &&
|
||||
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) &&
|
||||
model.Collections?.Any() == true)
|
||||
{
|
||||
var collections = await _collectionRepository.GetManyByManyIdsAsync(model.Collections.Select(a => a.Id));
|
||||
var authorized =
|
||||
@ -198,7 +200,8 @@ public class GroupsController : Controller
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(orgId, userId);
|
||||
var currentGroupUsers = await _groupRepository.GetManyUserIdsByIdAsync(id);
|
||||
if (!currentGroupUsers.Contains(organizationUser.Id) && model.Users.Contains(organizationUser.Id))
|
||||
// OrganizationUser may be null if the current user is a provider
|
||||
if (organizationUser != null && !currentGroupUsers.Contains(organizationUser.Id) && model.Users.Contains(organizationUser.Id))
|
||||
{
|
||||
throw new BadRequestException("You cannot add yourself to groups.");
|
||||
}
|
||||
|
@ -195,7 +195,9 @@ public class OrganizationUsersController : Controller
|
||||
}
|
||||
|
||||
// Flexible Collections - check the user has permission to grant access to the collections for the new user
|
||||
if (await FlexibleCollectionsIsEnabledAsync(orgId) && _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1))
|
||||
if (await FlexibleCollectionsIsEnabledAsync(orgId) &&
|
||||
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) &&
|
||||
model.Collections?.Any() == true)
|
||||
{
|
||||
var collections = await _collectionRepository.GetManyByManyIdsAsync(model.Collections.Select(a => a.Id));
|
||||
var authorized =
|
||||
|
@ -6,12 +6,12 @@ using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Api.Auth.Models.Request.Accounts;
|
||||
using Bit.Api.Auth.Models.Request.Organizations;
|
||||
using Bit.Api.Auth.Models.Response.Organizations;
|
||||
using Bit.Api.Models.Request;
|
||||
using Bit.Api.Models.Request.Accounts;
|
||||
using Bit.Api.Models.Request.Organizations;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements.Interfaces;
|
||||
@ -21,20 +21,13 @@ using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Auth.Services;
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Queries;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tools.Enums;
|
||||
using Bit.Core.Tools.Models.Business;
|
||||
using Bit.Core.Tools.Services;
|
||||
using Bit.Core.Tokens;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@ -50,7 +43,6 @@ public class OrganizationsController : Controller
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IPaymentService _paymentService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||
private readonly ISsoConfigService _ssoConfigService;
|
||||
@ -58,20 +50,13 @@ public class OrganizationsController : Controller
|
||||
private readonly IRotateOrganizationApiKeyCommand _rotateOrganizationApiKeyCommand;
|
||||
private readonly ICreateOrganizationApiKeyCommand _createOrganizationApiKeyCommand;
|
||||
private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository;
|
||||
private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly ILicensingService _licensingService;
|
||||
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
|
||||
private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand;
|
||||
private readonly IAddSecretsManagerSubscriptionCommand _addSecretsManagerSubscriptionCommand;
|
||||
private readonly IPushNotificationService _pushNotificationService;
|
||||
private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand;
|
||||
private readonly ISubscriberQueries _subscriberQueries;
|
||||
private readonly IReferenceEventService _referenceEventService;
|
||||
private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand;
|
||||
private readonly IProviderRepository _providerRepository;
|
||||
private readonly IScaleSeatsCommand _scaleSeatsCommand;
|
||||
private readonly IDataProtectorTokenFactory<OrgDeleteTokenable> _orgDeleteTokenDataFactory;
|
||||
|
||||
public OrganizationsController(
|
||||
IOrganizationRepository organizationRepository,
|
||||
@ -79,7 +64,6 @@ public class OrganizationsController : Controller
|
||||
IPolicyRepository policyRepository,
|
||||
IOrganizationService organizationService,
|
||||
IUserService userService,
|
||||
IPaymentService paymentService,
|
||||
ICurrentContext currentContext,
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
ISsoConfigService ssoConfigService,
|
||||
@ -87,27 +71,19 @@ public class OrganizationsController : Controller
|
||||
IRotateOrganizationApiKeyCommand rotateOrganizationApiKeyCommand,
|
||||
ICreateOrganizationApiKeyCommand createOrganizationApiKeyCommand,
|
||||
IOrganizationApiKeyRepository organizationApiKeyRepository,
|
||||
ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery,
|
||||
IFeatureService featureService,
|
||||
GlobalSettings globalSettings,
|
||||
ILicensingService licensingService,
|
||||
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
|
||||
IUpgradeOrganizationPlanCommand upgradeOrganizationPlanCommand,
|
||||
IAddSecretsManagerSubscriptionCommand addSecretsManagerSubscriptionCommand,
|
||||
IPushNotificationService pushNotificationService,
|
||||
ICancelSubscriptionCommand cancelSubscriptionCommand,
|
||||
ISubscriberQueries subscriberQueries,
|
||||
IReferenceEventService referenceEventService,
|
||||
IOrganizationEnableCollectionEnhancementsCommand organizationEnableCollectionEnhancementsCommand,
|
||||
IProviderRepository providerRepository,
|
||||
IScaleSeatsCommand scaleSeatsCommand)
|
||||
IScaleSeatsCommand scaleSeatsCommand,
|
||||
IDataProtectorTokenFactory<OrgDeleteTokenable> orgDeleteTokenDataFactory)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_policyRepository = policyRepository;
|
||||
_organizationService = organizationService;
|
||||
_userService = userService;
|
||||
_paymentService = paymentService;
|
||||
_currentContext = currentContext;
|
||||
_ssoConfigRepository = ssoConfigRepository;
|
||||
_ssoConfigService = ssoConfigService;
|
||||
@ -115,20 +91,13 @@ public class OrganizationsController : Controller
|
||||
_rotateOrganizationApiKeyCommand = rotateOrganizationApiKeyCommand;
|
||||
_createOrganizationApiKeyCommand = createOrganizationApiKeyCommand;
|
||||
_organizationApiKeyRepository = organizationApiKeyRepository;
|
||||
_cloudGetOrganizationLicenseQuery = cloudGetOrganizationLicenseQuery;
|
||||
_featureService = featureService;
|
||||
_globalSettings = globalSettings;
|
||||
_licensingService = licensingService;
|
||||
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
|
||||
_upgradeOrganizationPlanCommand = upgradeOrganizationPlanCommand;
|
||||
_addSecretsManagerSubscriptionCommand = addSecretsManagerSubscriptionCommand;
|
||||
_pushNotificationService = pushNotificationService;
|
||||
_cancelSubscriptionCommand = cancelSubscriptionCommand;
|
||||
_subscriberQueries = subscriberQueries;
|
||||
_referenceEventService = referenceEventService;
|
||||
_organizationEnableCollectionEnhancementsCommand = organizationEnableCollectionEnhancementsCommand;
|
||||
_providerRepository = providerRepository;
|
||||
_scaleSeatsCommand = scaleSeatsCommand;
|
||||
_orgDeleteTokenDataFactory = orgDeleteTokenDataFactory;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
@ -149,83 +118,6 @@ public class OrganizationsController : Controller
|
||||
return new OrganizationResponseModel(organization);
|
||||
}
|
||||
|
||||
[HttpGet("{id}/billing")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<BillingResponseModel> GetBilling(string id)
|
||||
{
|
||||
var orgIdGuid = new Guid(id);
|
||||
if (!await _currentContext.ViewBillingHistory(orgIdGuid))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(orgIdGuid);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var billingInfo = await _paymentService.GetBillingAsync(organization);
|
||||
return new BillingResponseModel(billingInfo);
|
||||
}
|
||||
|
||||
[HttpGet("{id}/subscription")]
|
||||
public async Task<OrganizationSubscriptionResponseModel> GetSubscription(string id)
|
||||
{
|
||||
var orgIdGuid = new Guid(id);
|
||||
if (!await _currentContext.ViewSubscription(orgIdGuid))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(orgIdGuid);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!_globalSettings.SelfHosted && organization.Gateway != null)
|
||||
{
|
||||
var subscriptionInfo = await _paymentService.GetSubscriptionAsync(organization);
|
||||
if (subscriptionInfo == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var hideSensitiveData = !await _currentContext.EditSubscription(orgIdGuid);
|
||||
|
||||
return new OrganizationSubscriptionResponseModel(organization, subscriptionInfo, hideSensitiveData);
|
||||
}
|
||||
|
||||
if (_globalSettings.SelfHosted)
|
||||
{
|
||||
var orgLicense = await _licensingService.ReadOrganizationLicenseAsync(organization);
|
||||
return new OrganizationSubscriptionResponseModel(organization, orgLicense);
|
||||
}
|
||||
|
||||
return new OrganizationSubscriptionResponseModel(organization);
|
||||
}
|
||||
|
||||
[HttpGet("{id}/license")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<OrganizationLicense> GetLicense(string id, [FromQuery] Guid installationId)
|
||||
{
|
||||
var orgIdGuid = new Guid(id);
|
||||
if (!await _currentContext.OrganizationOwner(orgIdGuid))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var org = await _organizationRepository.GetByIdAsync(new Guid(id));
|
||||
var license = await _cloudGetOrganizationLicenseQuery.GetLicenseAsync(org, installationId);
|
||||
if (license == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return license;
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
public async Task<ListResponseModel<ProfileOrganizationResponseModel>> GetUser()
|
||||
{
|
||||
@ -268,21 +160,6 @@ public class OrganizationsController : Controller
|
||||
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false);
|
||||
}
|
||||
|
||||
[HttpGet("{id}/billing-status")]
|
||||
public async Task<OrganizationBillingStatusResponseModel> GetBillingStatus(Guid id)
|
||||
{
|
||||
if (!await _currentContext.EditPaymentMethods(id))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(id);
|
||||
|
||||
var risksSubscriptionFailure = await _paymentService.RisksSubscriptionFailure(organization);
|
||||
|
||||
return new OrganizationBillingStatusResponseModel(organization, risksSubscriptionFailure);
|
||||
}
|
||||
|
||||
[HttpPost("")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<OrganizationResponseModel> Post([FromBody] OrganizationCreateRequestModel model)
|
||||
@ -326,124 +203,6 @@ public class OrganizationsController : Controller
|
||||
return new OrganizationResponseModel(organization);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/payment")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task PostPayment(string id, [FromBody] PaymentRequestModel model)
|
||||
{
|
||||
var orgIdGuid = new Guid(id);
|
||||
if (!await _currentContext.EditPaymentMethods(orgIdGuid))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await _organizationService.ReplacePaymentMethodAsync(orgIdGuid, model.PaymentToken,
|
||||
model.PaymentMethodType.Value, new TaxInfo
|
||||
{
|
||||
BillingAddressLine1 = model.Line1,
|
||||
BillingAddressLine2 = model.Line2,
|
||||
BillingAddressState = model.State,
|
||||
BillingAddressCity = model.City,
|
||||
BillingAddressPostalCode = model.PostalCode,
|
||||
BillingAddressCountry = model.Country,
|
||||
TaxIdNumber = model.TaxId,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("{id}/upgrade")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<PaymentResponseModel> PostUpgrade(string id, [FromBody] OrganizationUpgradeRequestModel model)
|
||||
{
|
||||
var orgIdGuid = new Guid(id);
|
||||
if (!await _currentContext.EditSubscription(orgIdGuid))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var (success, paymentIntentClientSecret) = await _upgradeOrganizationPlanCommand.UpgradePlanAsync(orgIdGuid, model.ToOrganizationUpgrade());
|
||||
|
||||
if (model.UseSecretsManager && success)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
|
||||
await TryGrantOwnerAccessToSecretsManagerAsync(orgIdGuid, userId);
|
||||
}
|
||||
|
||||
return new PaymentResponseModel { Success = success, PaymentIntentClientSecret = paymentIntentClientSecret };
|
||||
}
|
||||
|
||||
[HttpPost("{id}/subscription")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task PostSubscription(string id, [FromBody] OrganizationSubscriptionUpdateRequestModel model)
|
||||
{
|
||||
var orgIdGuid = new Guid(id);
|
||||
if (!await _currentContext.EditSubscription(orgIdGuid))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
await _organizationService.UpdateSubscription(orgIdGuid, model.SeatAdjustment, model.MaxAutoscaleSeats);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/sm-subscription")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task PostSmSubscription(Guid id, [FromBody] SecretsManagerSubscriptionUpdateRequestModel model)
|
||||
{
|
||||
var organization = await _organizationRepository.GetByIdAsync(id);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!await _currentContext.EditSubscription(id))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var organizationUpdate = model.ToSecretsManagerSubscriptionUpdate(organization);
|
||||
await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(organizationUpdate);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/subscribe-secrets-manager")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<ProfileOrganizationResponseModel> PostSubscribeSecretsManagerAsync(Guid id, [FromBody] SecretsManagerSubscribeRequestModel model)
|
||||
{
|
||||
var organization = await _organizationRepository.GetByIdAsync(id);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!await _currentContext.EditSubscription(id))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await _addSecretsManagerSubscriptionCommand.SignUpAsync(organization, model.AdditionalSmSeats,
|
||||
model.AdditionalServiceAccounts);
|
||||
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
|
||||
await TryGrantOwnerAccessToSecretsManagerAsync(organization.Id, userId);
|
||||
|
||||
var organizationDetails = await _organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id,
|
||||
OrganizationUserStatusType.Confirmed);
|
||||
|
||||
return new ProfileOrganizationResponseModel(organizationDetails);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/seat")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<PaymentResponseModel> PostSeat(string id, [FromBody] OrganizationSeatRequestModel model)
|
||||
{
|
||||
var orgIdGuid = new Guid(id);
|
||||
if (!await _currentContext.EditSubscription(orgIdGuid))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var result = await _organizationService.AdjustSeatsAsync(orgIdGuid, model.SeatAdjustment.Value);
|
||||
return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result };
|
||||
}
|
||||
|
||||
[HttpPost("{id}/storage")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<PaymentResponseModel> PostStorage(string id, [FromBody] StorageRequestModel model)
|
||||
@ -458,67 +217,6 @@ public class OrganizationsController : Controller
|
||||
return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result };
|
||||
}
|
||||
|
||||
[HttpPost("{id}/verify-bank")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task PostVerifyBank(string id, [FromBody] OrganizationVerifyBankRequestModel model)
|
||||
{
|
||||
var orgIdGuid = new Guid(id);
|
||||
if (!await _currentContext.EditSubscription(orgIdGuid))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await _organizationService.VerifyBankAsync(orgIdGuid, model.Amount1.Value, model.Amount2.Value);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/cancel")]
|
||||
public async Task PostCancel(Guid id, [FromBody] SubscriptionCancellationRequestModel request)
|
||||
{
|
||||
if (!await _currentContext.EditSubscription(id))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(id);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var subscription = await _subscriberQueries.GetSubscriptionOrThrow(organization);
|
||||
|
||||
await _cancelSubscriptionCommand.CancelSubscription(subscription,
|
||||
new OffboardingSurveyResponse
|
||||
{
|
||||
UserId = _currentContext.UserId!.Value,
|
||||
Reason = request.Reason,
|
||||
Feedback = request.Feedback
|
||||
},
|
||||
organization.IsExpired());
|
||||
|
||||
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(
|
||||
ReferenceEventType.CancelSubscription,
|
||||
organization,
|
||||
_currentContext)
|
||||
{
|
||||
EndOfPeriod = organization.IsExpired()
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("{id}/reinstate")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task PostReinstate(string id)
|
||||
{
|
||||
var orgIdGuid = new Guid(id);
|
||||
if (!await _currentContext.EditSubscription(orgIdGuid))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await _organizationService.ReinstateSubscriptionAsync(orgIdGuid);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/leave")]
|
||||
public async Task Leave(string id)
|
||||
{
|
||||
@ -586,6 +284,37 @@ public class OrganizationsController : Controller
|
||||
await _organizationService.DeleteAsync(organization);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/delete-recover-token")]
|
||||
[AllowAnonymous]
|
||||
public async Task PostDeleteRecoverToken(Guid id, [FromBody] OrganizationVerifyDeleteRecoverRequestModel model)
|
||||
{
|
||||
var organization = await _organizationRepository.GetByIdAsync(id);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!_orgDeleteTokenDataFactory.TryUnprotect(model.Token, out var data) || !data.IsValid(organization))
|
||||
{
|
||||
throw new BadRequestException("Invalid token.");
|
||||
}
|
||||
|
||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
if (consolidatedBillingEnabled && organization.IsValidClient())
|
||||
{
|
||||
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||
if (provider.IsBillable())
|
||||
{
|
||||
await _scaleSeatsCommand.ScalePasswordManagerSeats(
|
||||
provider,
|
||||
organization.PlanType,
|
||||
-organization.Seats ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
await _organizationService.DeleteAsync(organization);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/import")]
|
||||
public async Task Import(string id, [FromBody] ImportOrganizationUsersRequestModel model)
|
||||
{
|
||||
@ -722,55 +451,6 @@ public class OrganizationsController : Controller
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("{id}/tax")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<TaxInfoResponseModel> GetTaxInfo(string id)
|
||||
{
|
||||
var orgIdGuid = new Guid(id);
|
||||
if (!await _currentContext.OrganizationOwner(orgIdGuid))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(orgIdGuid);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var taxInfo = await _paymentService.GetTaxInfoAsync(organization);
|
||||
return new TaxInfoResponseModel(taxInfo);
|
||||
}
|
||||
|
||||
[HttpPut("{id}/tax")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task PutTaxInfo(string id, [FromBody] ExpandedTaxInfoUpdateRequestModel model)
|
||||
{
|
||||
var orgIdGuid = new Guid(id);
|
||||
if (!await _currentContext.OrganizationOwner(orgIdGuid))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(orgIdGuid);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var taxInfo = new TaxInfo
|
||||
{
|
||||
TaxIdNumber = model.TaxId,
|
||||
BillingAddressLine1 = model.Line1,
|
||||
BillingAddressLine2 = model.Line2,
|
||||
BillingAddressCity = model.City,
|
||||
BillingAddressState = model.State,
|
||||
BillingAddressPostalCode = model.PostalCode,
|
||||
BillingAddressCountry = model.Country,
|
||||
};
|
||||
await _paymentService.SaveTaxInfoAsync(organization, taxInfo);
|
||||
}
|
||||
|
||||
[HttpGet("{id}/public-key")]
|
||||
public async Task<OrganizationPublicKeyResponseModel> GetPublicKey(string id)
|
||||
{
|
||||
@ -912,15 +592,4 @@ public class OrganizationsController : Controller
|
||||
ou.Type is OrganizationUserType.Admin or OrganizationUserType.Owner)
|
||||
.Select(ou => _pushNotificationService.PushSyncOrganizationsAsync(ou.UserId.Value)));
|
||||
}
|
||||
|
||||
private async Task TryGrantOwnerAccessToSecretsManagerAsync(Guid organizationId, Guid userId)
|
||||
{
|
||||
var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId, userId);
|
||||
|
||||
if (organizationUser != null)
|
||||
{
|
||||
organizationUser.AccessSecretsManager = true;
|
||||
await _organizationUserRepository.ReplaceAsync(organizationUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
@ -112,6 +113,9 @@ public class ProviderOrganizationsController : Controller
|
||||
providerOrganization,
|
||||
organization);
|
||||
|
||||
if (organization.IsStripeEnabled())
|
||||
{
|
||||
await _removePaymentMethodCommand.RemovePaymentMethod(organization);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
|
||||
public class OrganizationVerifyDeleteRecoverRequestModel
|
||||
{
|
||||
[Required]
|
||||
public string Token { get; set; }
|
||||
}
|
@ -34,7 +34,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.0" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.0" />
|
||||
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.10.0" />
|
||||
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.24.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
@ -43,7 +43,6 @@ using Bit.Core.Utilities;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Core.Vault.Repositories;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.Auth.Controllers;
|
||||
@ -438,9 +437,6 @@ public class AccountsController : Controller
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
IdentityResult result;
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.KeyRotationImprovements))
|
||||
{
|
||||
var dataModel = new RotateUserKeyData
|
||||
{
|
||||
MasterPasswordHash = model.MasterPasswordHash,
|
||||
@ -453,44 +449,7 @@ public class AccountsController : Controller
|
||||
OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.ResetPasswordKeys)
|
||||
};
|
||||
|
||||
result = await _rotateUserKeyCommand.RotateUserKeyAsync(user, dataModel);
|
||||
}
|
||||
else
|
||||
{
|
||||
var ciphers = new List<Cipher>();
|
||||
if (model.Ciphers.Any())
|
||||
{
|
||||
var existingCiphers = await _cipherRepository.GetManyByUserIdAsync(user.Id, useFlexibleCollections: UseFlexibleCollections);
|
||||
ciphers.AddRange(existingCiphers
|
||||
.Join(model.Ciphers, c => c.Id, c => c.Id, (existing, c) => c.ToCipher(existing)));
|
||||
}
|
||||
|
||||
var folders = new List<Folder>();
|
||||
if (model.Folders.Any())
|
||||
{
|
||||
var existingFolders = await _folderRepository.GetManyByUserIdAsync(user.Id);
|
||||
folders.AddRange(existingFolders
|
||||
.Join(model.Folders, f => f.Id, f => f.Id, (existing, f) => f.ToFolder(existing)));
|
||||
}
|
||||
|
||||
var sends = new List<Send>();
|
||||
if (model.Sends?.Any() == true)
|
||||
{
|
||||
var existingSends = await _sendRepository.GetManyByUserIdAsync(user.Id);
|
||||
sends.AddRange(existingSends
|
||||
.Join(model.Sends, s => s.Id, s => s.Id, (existing, s) => s.ToSend(existing, _sendService)));
|
||||
}
|
||||
|
||||
result = await _userService.UpdateKeyAsync(
|
||||
user,
|
||||
model.MasterPasswordHash,
|
||||
model.Key,
|
||||
model.PrivateKey,
|
||||
ciphers,
|
||||
folders,
|
||||
sends);
|
||||
}
|
||||
|
||||
var result = await _rotateUserKeyCommand.RotateUserKeyAsync(user, dataModel);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
|
@ -1,5 +1,11 @@
|
||||
using Bit.Api.Billing.Models.Responses;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core.Billing.Queries;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@ -8,7 +14,10 @@ namespace Bit.Api.Billing.Controllers;
|
||||
[Route("organizations/{organizationId:guid}/billing")]
|
||||
[Authorize("Application")]
|
||||
public class OrganizationBillingController(
|
||||
IOrganizationBillingQueries organizationBillingQueries) : Controller
|
||||
IOrganizationBillingQueries organizationBillingQueries,
|
||||
ICurrentContext currentContext,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IPaymentService paymentService) : Controller
|
||||
{
|
||||
[HttpGet("metadata")]
|
||||
public async Task<IResult> GetMetadataAsync([FromRoute] Guid organizationId)
|
||||
@ -24,4 +33,23 @@ public class OrganizationBillingController(
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<BillingResponseModel> GetBilling(Guid organizationId)
|
||||
{
|
||||
if (!await currentContext.ViewBillingHistory(organizationId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var billingInfo = await paymentService.GetBillingAsync(organization);
|
||||
return new BillingResponseModel(billingInfo);
|
||||
}
|
||||
}
|
||||
|
385
src/Api/Billing/Controllers/OrganizationsController.cs
Normal file
385
src/Api/Billing/Controllers/OrganizationsController.cs
Normal file
@ -0,0 +1,385 @@
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.AdminConsole.Models.Response;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Api.Models.Request;
|
||||
using Bit.Api.Models.Request.Organizations;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Queries;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tools.Enums;
|
||||
using Bit.Core.Tools.Models.Business;
|
||||
using Bit.Core.Tools.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.Billing.Controllers;
|
||||
|
||||
[Route("organizations")]
|
||||
[Authorize("Application")]
|
||||
public class OrganizationsController(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationService organizationService,
|
||||
IUserService userService,
|
||||
IPaymentService paymentService,
|
||||
ICurrentContext currentContext,
|
||||
ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery,
|
||||
GlobalSettings globalSettings,
|
||||
ILicensingService licensingService,
|
||||
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
|
||||
IUpgradeOrganizationPlanCommand upgradeOrganizationPlanCommand,
|
||||
IAddSecretsManagerSubscriptionCommand addSecretsManagerSubscriptionCommand,
|
||||
ICancelSubscriptionCommand cancelSubscriptionCommand,
|
||||
ISubscriberQueries subscriberQueries,
|
||||
IReferenceEventService referenceEventService)
|
||||
: Controller
|
||||
{
|
||||
[HttpGet("{id}/billing-status")]
|
||||
public async Task<OrganizationBillingStatusResponseModel> GetBillingStatus(Guid id)
|
||||
{
|
||||
if (!await currentContext.EditPaymentMethods(id))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(id);
|
||||
|
||||
var risksSubscriptionFailure = await paymentService.RisksSubscriptionFailure(organization);
|
||||
|
||||
return new OrganizationBillingStatusResponseModel(organization, risksSubscriptionFailure);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/subscription")]
|
||||
public async Task<OrganizationSubscriptionResponseModel> GetSubscription(Guid id)
|
||||
{
|
||||
if (!await currentContext.ViewSubscription(id))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(id);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!globalSettings.SelfHosted && organization.Gateway != null)
|
||||
{
|
||||
var subscriptionInfo = await paymentService.GetSubscriptionAsync(organization);
|
||||
if (subscriptionInfo == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var hideSensitiveData = !await currentContext.EditSubscription(id);
|
||||
|
||||
return new OrganizationSubscriptionResponseModel(organization, subscriptionInfo, hideSensitiveData);
|
||||
}
|
||||
|
||||
if (globalSettings.SelfHosted)
|
||||
{
|
||||
var orgLicense = await licensingService.ReadOrganizationLicenseAsync(organization);
|
||||
return new OrganizationSubscriptionResponseModel(organization, orgLicense);
|
||||
}
|
||||
|
||||
return new OrganizationSubscriptionResponseModel(organization);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/license")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<OrganizationLicense> GetLicense(Guid id, [FromQuery] Guid installationId)
|
||||
{
|
||||
if (!await currentContext.OrganizationOwner(id))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var org = await organizationRepository.GetByIdAsync(id);
|
||||
var license = await cloudGetOrganizationLicenseQuery.GetLicenseAsync(org, installationId);
|
||||
if (license == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return license;
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/payment")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task PostPayment(Guid id, [FromBody] PaymentRequestModel model)
|
||||
{
|
||||
if (!await currentContext.EditPaymentMethods(id))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await organizationService.ReplacePaymentMethodAsync(id, model.PaymentToken,
|
||||
model.PaymentMethodType.Value, new TaxInfo
|
||||
{
|
||||
BillingAddressLine1 = model.Line1,
|
||||
BillingAddressLine2 = model.Line2,
|
||||
BillingAddressState = model.State,
|
||||
BillingAddressCity = model.City,
|
||||
BillingAddressPostalCode = model.PostalCode,
|
||||
BillingAddressCountry = model.Country,
|
||||
TaxIdNumber = model.TaxId,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/upgrade")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<PaymentResponseModel> PostUpgrade(Guid id, [FromBody] OrganizationUpgradeRequestModel model)
|
||||
{
|
||||
if (!await currentContext.EditSubscription(id))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var (success, paymentIntentClientSecret) = await upgradeOrganizationPlanCommand.UpgradePlanAsync(id, model.ToOrganizationUpgrade());
|
||||
|
||||
if (model.UseSecretsManager && success)
|
||||
{
|
||||
var userId = userService.GetProperUserId(User).Value;
|
||||
|
||||
await TryGrantOwnerAccessToSecretsManagerAsync(id, userId);
|
||||
}
|
||||
|
||||
return new PaymentResponseModel { Success = success, PaymentIntentClientSecret = paymentIntentClientSecret };
|
||||
}
|
||||
|
||||
[HttpPost("{id}/sm-subscription")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task PostSmSubscription(Guid id, [FromBody] SecretsManagerSubscriptionUpdateRequestModel model)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(id);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!await currentContext.EditSubscription(id))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
organization = await AdjustOrganizationSeatsForSmTrialAsync(id, organization, model);
|
||||
|
||||
var organizationUpdate = model.ToSecretsManagerSubscriptionUpdate(organization);
|
||||
|
||||
await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(organizationUpdate);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/subscription")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task PostSubscription(Guid id, [FromBody] OrganizationSubscriptionUpdateRequestModel model)
|
||||
{
|
||||
if (!await currentContext.EditSubscription(id))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
await organizationService.UpdateSubscription(id, model.SeatAdjustment, model.MaxAutoscaleSeats);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/subscribe-secrets-manager")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<ProfileOrganizationResponseModel> PostSubscribeSecretsManagerAsync(Guid id, [FromBody] SecretsManagerSubscribeRequestModel model)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(id);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!await currentContext.EditSubscription(id))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await addSecretsManagerSubscriptionCommand.SignUpAsync(organization, model.AdditionalSmSeats,
|
||||
model.AdditionalServiceAccounts);
|
||||
|
||||
var userId = userService.GetProperUserId(User).Value;
|
||||
|
||||
await TryGrantOwnerAccessToSecretsManagerAsync(organization.Id, userId);
|
||||
|
||||
var organizationDetails = await organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id,
|
||||
OrganizationUserStatusType.Confirmed);
|
||||
|
||||
return new ProfileOrganizationResponseModel(organizationDetails);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/seat")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<PaymentResponseModel> PostSeat(Guid id, [FromBody] OrganizationSeatRequestModel model)
|
||||
{
|
||||
if (!await currentContext.EditSubscription(id))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var result = await organizationService.AdjustSeatsAsync(id, model.SeatAdjustment.Value);
|
||||
return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result };
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/verify-bank")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task PostVerifyBank(Guid id, [FromBody] OrganizationVerifyBankRequestModel model)
|
||||
{
|
||||
if (!await currentContext.EditSubscription(id))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await organizationService.VerifyBankAsync(id, model.Amount1.Value, model.Amount2.Value);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/cancel")]
|
||||
public async Task PostCancel(Guid id, [FromBody] SubscriptionCancellationRequestModel request)
|
||||
{
|
||||
if (!await currentContext.EditSubscription(id))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(id);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var subscription = await subscriberQueries.GetSubscriptionOrThrow(organization);
|
||||
|
||||
await cancelSubscriptionCommand.CancelSubscription(subscription,
|
||||
new OffboardingSurveyResponse
|
||||
{
|
||||
UserId = currentContext.UserId!.Value,
|
||||
Reason = request.Reason,
|
||||
Feedback = request.Feedback
|
||||
},
|
||||
organization.IsExpired());
|
||||
|
||||
await referenceEventService.RaiseEventAsync(new ReferenceEvent(
|
||||
ReferenceEventType.CancelSubscription,
|
||||
organization,
|
||||
currentContext)
|
||||
{
|
||||
EndOfPeriod = organization.IsExpired()
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/reinstate")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task PostReinstate(Guid id)
|
||||
{
|
||||
if (!await currentContext.EditSubscription(id))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await organizationService.ReinstateSubscriptionAsync(id);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/tax")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<TaxInfoResponseModel> GetTaxInfo(Guid id)
|
||||
{
|
||||
if (!await currentContext.OrganizationOwner(id))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(id);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var taxInfo = await paymentService.GetTaxInfoAsync(organization);
|
||||
return new TaxInfoResponseModel(taxInfo);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/tax")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task PutTaxInfo(Guid id, [FromBody] ExpandedTaxInfoUpdateRequestModel model)
|
||||
{
|
||||
if (!await currentContext.OrganizationOwner(id))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(id);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var taxInfo = new TaxInfo
|
||||
{
|
||||
TaxIdNumber = model.TaxId,
|
||||
BillingAddressLine1 = model.Line1,
|
||||
BillingAddressLine2 = model.Line2,
|
||||
BillingAddressCity = model.City,
|
||||
BillingAddressState = model.State,
|
||||
BillingAddressPostalCode = model.PostalCode,
|
||||
BillingAddressCountry = model.Country,
|
||||
};
|
||||
await paymentService.SaveTaxInfoAsync(organization, taxInfo);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to grant owner access to the Secrets Manager for the organization
|
||||
/// </summary>
|
||||
/// <param name="organizationId"></param>
|
||||
/// <param name="userId"></param>
|
||||
private async Task TryGrantOwnerAccessToSecretsManagerAsync(Guid organizationId, Guid userId)
|
||||
{
|
||||
var organizationUser = await organizationUserRepository.GetByOrganizationAsync(organizationId, userId);
|
||||
|
||||
if (organizationUser != null)
|
||||
{
|
||||
organizationUser.AccessSecretsManager = true;
|
||||
await organizationUserRepository.ReplaceAsync(organizationUser);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adjusts the organization seats for the Secrets Manager trial to match the new seat count for secrets manager
|
||||
/// </summary>
|
||||
/// <param name="id"></param>
|
||||
/// <param name="organization"></param>
|
||||
/// <param name="model"></param>
|
||||
private async Task<Organization> AdjustOrganizationSeatsForSmTrialAsync(Guid id, Organization organization,
|
||||
SecretsManagerSubscriptionUpdateRequestModel model)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId) ||
|
||||
string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId) ||
|
||||
model.SeatAdjustment == 0)
|
||||
{
|
||||
return organization;
|
||||
}
|
||||
|
||||
var subscriptionInfo = await paymentService.GetSubscriptionAsync(organization);
|
||||
if (subscriptionInfo?.CustomerDiscount?.Id != StripeConstants.CouponIDs.SecretsManagerStandalone)
|
||||
{
|
||||
return organization;
|
||||
}
|
||||
|
||||
await organizationService.UpdateSubscription(id, model.SeatAdjustment, null);
|
||||
|
||||
return await organizationRepository.GetByIdAsync(id);
|
||||
}
|
||||
}
|
@ -133,10 +133,17 @@ public class ProviderClientsController(
|
||||
return TypedResults.Problem();
|
||||
}
|
||||
|
||||
if (clientOrganization.Seats != requestBody.AssignedSeats)
|
||||
{
|
||||
await assignSeatsToClientOrganizationCommand.AssignSeatsToClientOrganization(
|
||||
provider,
|
||||
clientOrganization,
|
||||
requestBody.AssignedSeats);
|
||||
}
|
||||
|
||||
clientOrganization.Name = requestBody.Name;
|
||||
|
||||
await organizationRepository.ReplaceAsync(clientOrganization);
|
||||
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
|
@ -7,4 +7,7 @@ public class UpdateClientOrganizationRequestBody
|
||||
[Required]
|
||||
[Range(0, int.MaxValue, ErrorMessage = "You cannot assign negative seats to a client organization.")]
|
||||
public int AssignedSeats { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Name { get; set; }
|
||||
}
|
||||
|
@ -89,6 +89,7 @@ public class CollectionAccessDetailsResponseModel : CollectionResponseModel
|
||||
ReadOnly = collection.ReadOnly;
|
||||
HidePasswords = collection.HidePasswords;
|
||||
Manage = collection.Manage;
|
||||
Unmanaged = collection.Unmanaged;
|
||||
Groups = collection.Groups?.Select(g => new SelectionReadOnlyResponseModel(g)) ?? Enumerable.Empty<SelectionReadOnlyResponseModel>();
|
||||
Users = collection.Users?.Select(g => new SelectionReadOnlyResponseModel(g)) ?? Enumerable.Empty<SelectionReadOnlyResponseModel>();
|
||||
}
|
||||
@ -104,4 +105,5 @@ public class CollectionAccessDetailsResponseModel : CollectionResponseModel
|
||||
public bool ReadOnly { get; set; }
|
||||
public bool HidePasswords { get; set; }
|
||||
public bool Manage { get; set; }
|
||||
public bool Unmanaged { get; set; }
|
||||
}
|
||||
|
@ -217,34 +217,49 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
|
||||
|
||||
private async Task<bool> CanUpdateUserAccessAsync(ICollection<Collection> resources, CurrentContextOrganization? org)
|
||||
{
|
||||
return await CanUpdateCollectionAsync(resources, org) || org?.Permissions.ManageUsers == true;
|
||||
if (await AllowAdminAccessToAllCollectionItems(org) && org?.Permissions.ManageUsers == true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return await CanUpdateCollectionAsync(resources, org);
|
||||
}
|
||||
|
||||
private async Task<bool> CanUpdateGroupAccessAsync(ICollection<Collection> resources, CurrentContextOrganization? org)
|
||||
{
|
||||
return await CanUpdateCollectionAsync(resources, org) || org?.Permissions.ManageGroups == true;
|
||||
if (await AllowAdminAccessToAllCollectionItems(org) && org?.Permissions.ManageGroups == true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return await CanUpdateCollectionAsync(resources, org);
|
||||
}
|
||||
|
||||
private async Task<bool> CanDeleteAsync(ICollection<Collection> resources, CurrentContextOrganization? org)
|
||||
{
|
||||
// Owners, Admins, and users with DeleteAnyCollection permission can always delete collections
|
||||
if (org is
|
||||
{ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
|
||||
{ Permissions.DeleteAnyCollection: true })
|
||||
// Users with DeleteAnyCollection permission can always delete collections
|
||||
if (org is { Permissions.DeleteAnyCollection: true })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for non-null org here: the user must be apart of the organization for this setting to take affect
|
||||
// The limit collection management setting is disabled,
|
||||
// ensure acting user has manage permissions for all collections being deleted
|
||||
if (await GetOrganizationAbilityAsync(org) is { LimitCollectionCreationDeletion: false })
|
||||
{
|
||||
var canManageCollections = await CanManageCollectionsAsync(resources, org);
|
||||
if (canManageCollections)
|
||||
// If AllowAdminAccessToAllCollectionItems is true, Owners and Admins can delete any collection, regardless of LimitCollectionCreationDeletion setting
|
||||
var organizationAbility = await GetOrganizationAbilityAsync(org);
|
||||
var allowAdminAccessToAllCollectionItems = !_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) ||
|
||||
organizationAbility is { AllowAdminAccessToAllCollectionItems: true };
|
||||
if (allowAdminAccessToAllCollectionItems && org is { Type: OrganizationUserType.Owner or OrganizationUserType.Admin })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// If LimitCollectionCreationDeletion is false, AllowAdminAccessToAllCollectionItems setting is irrelevant.
|
||||
// Ensure acting user has manage permissions for all collections being deleted
|
||||
// If LimitCollectionCreationDeletion is true, only Owners and Admins can delete collections they manage
|
||||
var canDeleteManagedCollections = organizationAbility is { LimitCollectionCreationDeletion: false } ||
|
||||
org is { Type: OrganizationUserType.Owner or OrganizationUserType.Admin };
|
||||
if (canDeleteManagedCollections && await CanManageCollectionsAsync(resources, org))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Allow providers to delete collections if they are a provider for the target organization
|
||||
@ -308,4 +323,11 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
|
||||
|
||||
return await _applicationCacheService.GetOrganizationAbilityAsync(organization.Id);
|
||||
}
|
||||
|
||||
private async Task<bool> AllowAdminAccessToAllCollectionItems(CurrentContextOrganization? org)
|
||||
{
|
||||
var organizationAbility = await GetOrganizationAbilityAsync(org);
|
||||
return !_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) ||
|
||||
organizationAbility is { AllowAdminAccessToAllCollectionItems: true };
|
||||
}
|
||||
}
|
||||
|
@ -619,9 +619,39 @@ public class CiphersController : Controller
|
||||
|
||||
var updatedCipher = await GetByIdAsync(id, userId);
|
||||
var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, id, UseFlexibleCollections);
|
||||
|
||||
return new CipherDetailsResponseModel(updatedCipher, _globalSettings, collectionCiphers);
|
||||
}
|
||||
|
||||
[HttpPut("{id}/collections_v2")]
|
||||
[HttpPost("{id}/collections_v2")]
|
||||
public async Task<OptionalCipherDetailsResponseModel> PutCollections_vNext(Guid id, [FromBody] CipherCollectionsRequestModel model)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var cipher = await GetByIdAsync(id, userId);
|
||||
if (cipher == null || !cipher.OrganizationId.HasValue ||
|
||||
!await _currentContext.OrganizationUser(cipher.OrganizationId.Value))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await _cipherService.SaveCollectionsAsync(cipher,
|
||||
model.CollectionIds.Select(c => new Guid(c)), userId, false);
|
||||
|
||||
var updatedCipher = await GetByIdAsync(id, userId);
|
||||
var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, id, UseFlexibleCollections);
|
||||
// If a user removes the last Can Manage access of a cipher, the "updatedCipher" will return null
|
||||
// We will be returning an "Unavailable" property so the client knows the user can no longer access this
|
||||
var response = new OptionalCipherDetailsResponseModel()
|
||||
{
|
||||
Unavailable = updatedCipher is null,
|
||||
Cipher = updatedCipher is null
|
||||
? null
|
||||
: new CipherDetailsResponseModel(updatedCipher, _globalSettings, collectionCiphers)
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
[HttpPut("{id}/collections-admin")]
|
||||
[HttpPost("{id}/collections-admin")]
|
||||
public async Task PutCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model)
|
||||
|
@ -0,0 +1,13 @@
|
||||
using Bit.Api.Vault.Models.Response;
|
||||
using Bit.Core.Models.Api;
|
||||
|
||||
public class OptionalCipherDetailsResponseModel : ResponseModel
|
||||
{
|
||||
public bool Unavailable { get; set; }
|
||||
|
||||
public CipherDetailsResponseModel? Cipher { get; set; }
|
||||
|
||||
public OptionalCipherDetailsResponseModel()
|
||||
: base("optionalCipherDetails")
|
||||
{ }
|
||||
}
|
@ -3,6 +3,7 @@ using Bit.Billing.Models;
|
||||
using Bit.Billing.Services;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
@ -55,6 +56,7 @@ public class StripeController : Controller
|
||||
private readonly IStripeEventService _stripeEventService;
|
||||
private readonly IStripeFacade _stripeFacade;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IProviderRepository _providerRepository;
|
||||
|
||||
public StripeController(
|
||||
GlobalSettings globalSettings,
|
||||
@ -74,7 +76,8 @@ public class StripeController : Controller
|
||||
ICurrentContext currentContext,
|
||||
IStripeEventService stripeEventService,
|
||||
IStripeFacade stripeFacade,
|
||||
IFeatureService featureService)
|
||||
IFeatureService featureService,
|
||||
IProviderRepository providerRepository)
|
||||
{
|
||||
_billingSettings = billingSettings?.Value;
|
||||
_hostingEnvironment = hostingEnvironment;
|
||||
@ -102,6 +105,7 @@ public class StripeController : Controller
|
||||
_stripeEventService = stripeEventService;
|
||||
_stripeFacade = stripeFacade;
|
||||
_featureService = featureService;
|
||||
_providerRepository = providerRepository;
|
||||
}
|
||||
|
||||
[HttpPost("webhook")]
|
||||
@ -425,7 +429,61 @@ public class StripeController : Controller
|
||||
}
|
||||
|
||||
var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata);
|
||||
if (organizationId.HasValue)
|
||||
|
||||
if (providerId.HasValue)
|
||||
{
|
||||
var provider = await _providerRepository.GetByIdAsync(providerId.Value);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Received invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) that does not exist",
|
||||
parsedEvent.Id,
|
||||
providerId.Value);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var teamsMonthly = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||
|
||||
var enterpriseMonthly = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
|
||||
|
||||
var teamsMonthlyLineItem =
|
||||
subscription.Items.Data.FirstOrDefault(item =>
|
||||
item.Plan.Id == teamsMonthly.PasswordManager.StripeSeatPlanId);
|
||||
|
||||
var enterpriseMonthlyLineItem =
|
||||
subscription.Items.Data.FirstOrDefault(item =>
|
||||
item.Plan.Id == enterpriseMonthly.PasswordManager.StripeSeatPlanId);
|
||||
|
||||
if (teamsMonthlyLineItem == null || enterpriseMonthlyLineItem == null)
|
||||
{
|
||||
_logger.LogError("invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) indicates missing subscription line items",
|
||||
parsedEvent.Id,
|
||||
provider.Id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await _referenceEventService.RaiseEventAsync(new ReferenceEvent
|
||||
{
|
||||
Type = ReferenceEventType.Rebilled,
|
||||
Source = ReferenceEventSource.Provider,
|
||||
Id = provider.Id,
|
||||
PlanType = PlanType.TeamsMonthly,
|
||||
Seats = (int)teamsMonthlyLineItem.Quantity
|
||||
});
|
||||
|
||||
await _referenceEventService.RaiseEventAsync(new ReferenceEvent
|
||||
{
|
||||
Type = ReferenceEventType.Rebilled,
|
||||
Source = ReferenceEventSource.Provider,
|
||||
Id = provider.Id,
|
||||
PlanType = PlanType.EnterpriseMonthly,
|
||||
Seats = (int)enterpriseMonthlyLineItem.Quantity
|
||||
});
|
||||
}
|
||||
else if (organizationId.HasValue)
|
||||
{
|
||||
if (!subscription.Items.Any(i =>
|
||||
StaticStore.Plans.Any(p => p.PasswordManager.StripePlanId == i.Plan.Id)))
|
||||
@ -657,6 +715,23 @@ public class StripeController : Controller
|
||||
await SendEmails(new List<string> { user.Email });
|
||||
}
|
||||
}
|
||||
else if (providerId.HasValue)
|
||||
{
|
||||
var provider = await _providerRepository.GetByIdAsync(providerId.Value);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Received invoice.Upcoming webhook ({EventID}) for Provider ({ProviderID}) that does not exist",
|
||||
parsedEvent.Id,
|
||||
providerId.Value);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await SendEmails(new List<string> { provider.BillingEmail });
|
||||
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
|
@ -86,20 +86,20 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
|
||||
public int? MaxAutoscaleSmSeats { get; set; }
|
||||
public int? MaxAutoscaleSmServiceAccounts { get; set; }
|
||||
/// <summary>
|
||||
/// Refers to the ability for an organization to limit collection creation and deletion to owners and admins only
|
||||
/// If set to true, only owners, admins, and some custom users can create and delete collections.
|
||||
/// If set to false, any organization member can create a collection, and any member can delete a collection that
|
||||
/// they have Can Manage permissions for.
|
||||
/// </summary>
|
||||
public bool LimitCollectionCreationDeletion { get; set; }
|
||||
/// <summary>
|
||||
/// Refers to the ability for an organization to limit owner/admin access to all collection items
|
||||
/// <remarks>
|
||||
/// True: Owner/admins can access all items belonging to any collections
|
||||
/// False: Owner/admins can only access items for collections they are assigned
|
||||
/// </remarks>
|
||||
/// If set to true, admins, owners, and some custom users can read/write all collections and items in the Admin Console.
|
||||
/// If set to false, users generally need collection-level permissions to read/write a collection or its items.
|
||||
/// </summary>
|
||||
public bool AllowAdminAccessToAllCollectionItems { get; set; }
|
||||
/// <summary>
|
||||
/// True if the organization is using the Flexible Collections permission changes, false otherwise.
|
||||
/// For existing organizations, this must only be set to true once data migrations have been run for this organization.
|
||||
/// This is an organization-level feature flag (not controlled via LaunchDarkly) to onboard organizations to the
|
||||
/// Flexible Collections MVP changes. This has been fully released and must always be set to TRUE for all organizations.
|
||||
/// AC-1714 will remove this flag after all old code has been removed.
|
||||
/// </summary>
|
||||
public bool FlexibleCollections { get; set; }
|
||||
|
||||
|
@ -0,0 +1,32 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
|
||||
public class OrgDeleteTokenable : Tokens.ExpiringTokenable
|
||||
{
|
||||
public const string ClearTextPrefix = "";
|
||||
public const string DataProtectorPurpose = "OrgDeleteDataProtector";
|
||||
public const string TokenIdentifier = "OrgDelete";
|
||||
public string Identifier { get; set; } = TokenIdentifier;
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[JsonConstructor]
|
||||
public OrgDeleteTokenable(DateTime expirationDate)
|
||||
{
|
||||
ExpirationDate = expirationDate;
|
||||
}
|
||||
|
||||
public OrgDeleteTokenable(Organization organization, int hoursTillExpiration)
|
||||
{
|
||||
Id = organization.Id;
|
||||
ExpirationDate = DateTime.UtcNow.AddHours(hoursTillExpiration);
|
||||
}
|
||||
|
||||
public bool IsValid(Organization organization)
|
||||
{
|
||||
return Id == organization.Id;
|
||||
}
|
||||
|
||||
protected override bool TokenIsValid() => Identifier == TokenIdentifier && Id != default;
|
||||
}
|
@ -34,6 +34,7 @@ public interface IOrganizationService
|
||||
/// </summary>
|
||||
Task<(Organization organization, OrganizationUser organizationUser)> SignUpAsync(OrganizationLicense license, User owner,
|
||||
string ownerKey, string collectionName, string publicKey, string privateKey);
|
||||
Task InitiateDeleteAsync(Organization organization, string orgAdminEmail);
|
||||
Task DeleteAsync(Organization organization);
|
||||
Task EnableAsync(Guid organizationId, DateTime? expirationDate);
|
||||
Task DisableAsync(Guid organizationId, DateTime? expirationDate);
|
||||
@ -49,7 +50,7 @@ public interface IOrganizationService
|
||||
Task<OrganizationUser> InviteUserAsync(Guid organizationId, Guid? invitingUserId, string email,
|
||||
OrganizationUserType type, bool accessAll, string externalId, ICollection<CollectionAccessSelection> collections, IEnumerable<Guid> groups);
|
||||
Task<OrganizationUser> InviteUserAsync(Guid organizationId, EventSystemUser systemUser, string email,
|
||||
OrganizationUserType type, bool accessAll, string externalId, IEnumerable<CollectionAccessSelection> collections, IEnumerable<Guid> groups);
|
||||
OrganizationUserType type, bool accessAll, string externalId, IEnumerable<CollectionAccessSelection> collections, IEnumerable<Guid> groups, bool accessSecretsManager);
|
||||
Task<IEnumerable<Tuple<OrganizationUser, string>>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable<Guid> organizationUsersId);
|
||||
Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false);
|
||||
Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,
|
||||
|
@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Business;
|
||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
@ -60,6 +61,7 @@ public class OrganizationService : IOrganizationService
|
||||
private readonly IProviderUserRepository _providerUserRepository;
|
||||
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
|
||||
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
|
||||
private readonly IDataProtectorTokenFactory<OrgDeleteTokenable> _orgDeleteTokenDataFactory;
|
||||
private readonly IProviderRepository _providerRepository;
|
||||
private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory;
|
||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||
@ -94,6 +96,7 @@ public class OrganizationService : IOrganizationService
|
||||
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
|
||||
IDataProtectorTokenFactory<OrgDeleteTokenable> orgDeleteTokenDataFactory,
|
||||
IProviderRepository providerRepository,
|
||||
IFeatureService featureService)
|
||||
{
|
||||
@ -123,6 +126,7 @@ public class OrganizationService : IOrganizationService
|
||||
_providerUserRepository = providerUserRepository;
|
||||
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
|
||||
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
|
||||
_orgDeleteTokenDataFactory = orgDeleteTokenDataFactory;
|
||||
_providerRepository = providerRepository;
|
||||
_orgUserInviteTokenableFactory = orgUserInviteTokenableFactory;
|
||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
||||
@ -434,9 +438,6 @@ public class OrganizationService : IOrganizationService
|
||||
|
||||
ValidatePlan(plan, signup.AdditionalSeats, "Password Manager");
|
||||
|
||||
var flexibleCollectionsSignupEnabled =
|
||||
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup);
|
||||
|
||||
var flexibleCollectionsV1Enabled =
|
||||
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1);
|
||||
|
||||
@ -478,14 +479,12 @@ public class OrganizationService : IOrganizationService
|
||||
// Secrets Manager not available for purchase with Consolidated Billing.
|
||||
UseSecretsManager = false,
|
||||
|
||||
// This feature flag indicates that new organizations should be automatically onboarded to
|
||||
// Flexible Collections enhancements
|
||||
FlexibleCollections = flexibleCollectionsSignupEnabled,
|
||||
// Flexible Collections MVP is fully released and all organizations must always have this setting enabled.
|
||||
// AC-1714 will remove this flag after all old code has been removed.
|
||||
FlexibleCollections = true,
|
||||
|
||||
// These collection management settings smooth the migration for existing organizations by disabling some FC behavior.
|
||||
// If the organization is onboarded to Flexible Collections on signup, we turn them OFF to enable all new behaviour.
|
||||
// If the organization is NOT onboarded now, they will have to be migrated later, so they default to ON to limit FC changes on migration.
|
||||
LimitCollectionCreationDeletion = !flexibleCollectionsSignupEnabled,
|
||||
// This is a transitional setting that defaults to ON until Flexible Collections v1 is released
|
||||
// (to preserve existing behavior) and defaults to OFF after release (enabling new behavior)
|
||||
AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1Enabled
|
||||
};
|
||||
|
||||
@ -529,9 +528,6 @@ public class OrganizationService : IOrganizationService
|
||||
await ValidateSignUpPoliciesAsync(signup.Owner.Id);
|
||||
}
|
||||
|
||||
var flexibleCollectionsSignupEnabled =
|
||||
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup);
|
||||
|
||||
var flexibleCollectionsV1IsEnabled =
|
||||
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1);
|
||||
|
||||
@ -573,14 +569,12 @@ public class OrganizationService : IOrganizationService
|
||||
UsePasswordManager = true,
|
||||
UseSecretsManager = signup.UseSecretsManager,
|
||||
|
||||
// This feature flag indicates that new organizations should be automatically onboarded to
|
||||
// Flexible Collections enhancements
|
||||
FlexibleCollections = flexibleCollectionsSignupEnabled,
|
||||
// Flexible Collections MVP is fully released and all organizations must always have this setting enabled.
|
||||
// AC-1714 will remove this flag after all old code has been removed.
|
||||
FlexibleCollections = true,
|
||||
|
||||
// These collection management settings smooth the migration for existing organizations by disabling some FC behavior.
|
||||
// If the organization is onboarded to Flexible Collections on signup, we turn them OFF to enable all new behaviour.
|
||||
// If the organization is NOT onboarded now, they will have to be migrated later, so they default to ON to limit FC changes on migration.
|
||||
LimitCollectionCreationDeletion = !flexibleCollectionsSignupEnabled,
|
||||
// This is a transitional setting that defaults to ON until Flexible Collections v1 is released
|
||||
// (to preserve existing behavior) and defaults to OFF after release (enabling new behavior)
|
||||
AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1IsEnabled
|
||||
};
|
||||
|
||||
@ -661,9 +655,6 @@ public class OrganizationService : IOrganizationService
|
||||
|
||||
await ValidateSignUpPoliciesAsync(owner.Id);
|
||||
|
||||
var flexibleCollectionsSignupEnabled =
|
||||
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup);
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Name = license.Name,
|
||||
@ -709,7 +700,7 @@ public class OrganizationService : IOrganizationService
|
||||
|
||||
// This feature flag indicates that new organizations should be automatically onboarded to
|
||||
// Flexible Collections enhancements
|
||||
FlexibleCollections = flexibleCollectionsSignupEnabled,
|
||||
FlexibleCollections = true,
|
||||
};
|
||||
|
||||
var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false);
|
||||
@ -811,6 +802,23 @@ public class OrganizationService : IOrganizationService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task InitiateDeleteAsync(Organization organization, string orgAdminEmail)
|
||||
{
|
||||
var orgAdmin = await _userRepository.GetByEmailAsync(orgAdminEmail);
|
||||
if (orgAdmin == null)
|
||||
{
|
||||
throw new BadRequestException("Org admin not found.");
|
||||
}
|
||||
var orgAdminOrgUser = await _organizationUserRepository.GetDetailsByUserAsync(orgAdmin.Id, organization.Id);
|
||||
if (orgAdminOrgUser == null || orgAdminOrgUser.Status != OrganizationUserStatusType.Confirmed ||
|
||||
(orgAdminOrgUser.Type != OrganizationUserType.Admin && orgAdminOrgUser.Type != OrganizationUserType.Owner))
|
||||
{
|
||||
throw new BadRequestException("Org admin not found.");
|
||||
}
|
||||
var token = _orgDeleteTokenDataFactory.Protect(new OrgDeleteTokenable(organization, 1));
|
||||
await _mailService.SendInitiateDeleteOrganzationEmailAsync(orgAdminEmail, organization, token);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Organization organization)
|
||||
{
|
||||
await ValidateDeleteOrganizationAsync(organization);
|
||||
@ -1323,7 +1331,6 @@ public class OrganizationService : IOrganizationService
|
||||
var validOrganizationUserIds = validOrganizationUsers.Select(u => u.UserId.Value).ToList();
|
||||
|
||||
var organization = await GetOrgById(organizationId);
|
||||
var policies = await _policyRepository.GetManyByOrganizationIdAsync(organizationId);
|
||||
var usersOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(validOrganizationUserIds);
|
||||
var users = await _userRepository.GetManyAsync(validOrganizationUserIds);
|
||||
|
||||
@ -1355,7 +1362,7 @@ public class OrganizationService : IOrganizationService
|
||||
}
|
||||
}
|
||||
|
||||
await CheckPolicies(policies, organizationId, user, orgUsers, userService);
|
||||
await CheckPolicies(organizationId, user, orgUsers, userService);
|
||||
orgUser.Status = OrganizationUserStatusType.Confirmed;
|
||||
orgUser.Key = keys[orgUser.Id];
|
||||
orgUser.Email = null;
|
||||
@ -1449,22 +1456,29 @@ public class OrganizationService : IOrganizationService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckPolicies(ICollection<Policy> policies, Guid organizationId, User user,
|
||||
private async Task CheckPolicies(Guid organizationId, User user,
|
||||
ICollection<OrganizationUser> userOrgs, IUserService userService)
|
||||
{
|
||||
var usingTwoFactorPolicy = policies.Any(p => p.Type == PolicyType.TwoFactorAuthentication && p.Enabled);
|
||||
if (usingTwoFactorPolicy && !await userService.TwoFactorIsEnabledAsync(user))
|
||||
// Enforce Two Factor Authentication Policy for this organization
|
||||
var orgRequiresTwoFactor = (await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication)).Any(p => p.OrganizationId == organizationId);
|
||||
if (orgRequiresTwoFactor && !await userService.TwoFactorIsEnabledAsync(user))
|
||||
{
|
||||
throw new BadRequestException("User does not have two-step login enabled.");
|
||||
}
|
||||
|
||||
var usingSingleOrgPolicy = policies.Any(p => p.Type == PolicyType.SingleOrg && p.Enabled);
|
||||
if (usingSingleOrgPolicy)
|
||||
var hasOtherOrgs = userOrgs.Any(ou => ou.OrganizationId != organizationId);
|
||||
var singleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg);
|
||||
var otherSingleOrgPolicies =
|
||||
singleOrgPolicies.Where(p => p.OrganizationId != organizationId);
|
||||
// Enforce Single Organization Policy for this organization
|
||||
if (hasOtherOrgs && singleOrgPolicies.Any(p => p.OrganizationId == organizationId))
|
||||
{
|
||||
if (userOrgs.Any(ou => ou.OrganizationId != organizationId && ou.Status != OrganizationUserStatusType.Invited))
|
||||
{
|
||||
throw new BadRequestException("User is a member of another organization.");
|
||||
throw new BadRequestException("Cannot confirm this member to the organization until they leave or remove all other organizations.");
|
||||
}
|
||||
// Enforce Single Organization Policy of other organizations user is a member of
|
||||
if (otherSingleOrgPolicies.Any())
|
||||
{
|
||||
throw new BadRequestException("Cannot confirm this member to the organization because they are in another organization which forbids it.");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1673,14 +1687,14 @@ public class OrganizationService : IOrganizationService
|
||||
|
||||
public async Task<OrganizationUser> InviteUserAsync(Guid organizationId, EventSystemUser systemUser, string email,
|
||||
OrganizationUserType type, bool accessAll, string externalId, IEnumerable<CollectionAccessSelection> collections,
|
||||
IEnumerable<Guid> groups)
|
||||
IEnumerable<Guid> groups, bool accessSecretsManager)
|
||||
{
|
||||
// Collection associations validation not required as they are always an empty list - created via system user (scim)
|
||||
return await SaveUserSendInviteAsync(organizationId, invitingUserId: null, systemUser, email, type, accessAll, externalId, collections, groups);
|
||||
return await SaveUserSendInviteAsync(organizationId, invitingUserId: null, systemUser, email, type, accessAll, externalId, collections, groups, accessSecretsManager);
|
||||
}
|
||||
|
||||
private async Task<OrganizationUser> SaveUserSendInviteAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, string email,
|
||||
OrganizationUserType type, bool accessAll, string externalId, IEnumerable<CollectionAccessSelection> collections, IEnumerable<Guid> groups)
|
||||
OrganizationUserType type, bool accessAll, string externalId, IEnumerable<CollectionAccessSelection> collections, IEnumerable<Guid> groups, bool accessSecretsManager = false)
|
||||
{
|
||||
var invite = new OrganizationUserInvite()
|
||||
{
|
||||
@ -1688,7 +1702,8 @@ public class OrganizationService : IOrganizationService
|
||||
Type = type,
|
||||
AccessAll = accessAll,
|
||||
Collections = collections,
|
||||
Groups = groups
|
||||
Groups = groups,
|
||||
AccessSecretsManager = accessSecretsManager
|
||||
};
|
||||
var results = systemUser.HasValue ? await InviteUsersAsync(organizationId, systemUser.Value,
|
||||
new (OrganizationUserInvite, string)[] { (invite, externalId) }) : await InviteUsersAsync(organizationId, invitingUserId,
|
||||
@ -1787,6 +1802,8 @@ public class OrganizationService : IOrganizationService
|
||||
enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count;
|
||||
}
|
||||
|
||||
var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization);
|
||||
|
||||
var userInvites = new List<(OrganizationUserInvite, string)>();
|
||||
foreach (var user in newUsers)
|
||||
{
|
||||
@ -1803,6 +1820,7 @@ public class OrganizationService : IOrganizationService
|
||||
Type = OrganizationUserType.User,
|
||||
AccessAll = false,
|
||||
Collections = new List<CollectionAccessSelection>(),
|
||||
AccessSecretsManager = hasStandaloneSecretsManager
|
||||
};
|
||||
userInvites.Add((invite, user.ExternalId));
|
||||
}
|
||||
|
@ -9,4 +9,5 @@ public interface IAuthRequestRepository : IRepository<AuthRequest, Guid>
|
||||
Task<ICollection<AuthRequest>> GetManyByUserIdAsync(Guid userId);
|
||||
Task<ICollection<OrganizationAdminAuthRequest>> GetManyPendingByOrganizationIdAsync(Guid organizationId);
|
||||
Task<ICollection<OrganizationAdminAuthRequest>> GetManyAdminApprovalRequestsByManyIdsAsync(Guid organizationId, IEnumerable<Guid> ids);
|
||||
Task UpdateManyAsync(IEnumerable<AuthRequest> authRequests);
|
||||
}
|
||||
|
@ -22,6 +22,10 @@ public static class BillingExtensions
|
||||
PlanType: PlanType.TeamsMonthly or PlanType.EnterpriseMonthly
|
||||
};
|
||||
|
||||
public static bool IsStripeEnabled(this Organization organization)
|
||||
=> !string.IsNullOrEmpty(organization.GatewayCustomerId) &&
|
||||
!string.IsNullOrEmpty(organization.GatewaySubscriptionId);
|
||||
|
||||
public static bool SupportsConsolidatedBilling(this PlanType planType)
|
||||
=> planType is PlanType.TeamsMonthly or PlanType.EnterpriseMonthly;
|
||||
}
|
||||
|
@ -102,11 +102,6 @@ public static class AuthenticationSchemes
|
||||
|
||||
public static class FeatureFlagKeys
|
||||
{
|
||||
public const string DisplayEuEnvironment = "display-eu-environment";
|
||||
public const string DisplayLowKdfIterationWarning = "display-kdf-iteration-warning";
|
||||
public const string PasswordlessLogin = "passwordless-login";
|
||||
public const string TrustedDeviceEncryption = "trusted-device-encryption";
|
||||
public const string Fido2VaultCredentials = "fido2-vault-credentials";
|
||||
public const string VaultOnboarding = "vault-onboarding";
|
||||
public const string BrowserFilelessImport = "browser-fileless-import";
|
||||
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
|
||||
@ -120,10 +115,6 @@ public static class FeatureFlagKeys
|
||||
public const string KeyRotationImprovements = "key-rotation-improvements";
|
||||
public const string DuoRedirect = "duo-redirect";
|
||||
/// <summary>
|
||||
/// Enables flexible collections improvements for new organizations on creation
|
||||
/// </summary>
|
||||
public const string FlexibleCollectionsSignup = "flexible-collections-signup";
|
||||
/// <summary>
|
||||
/// Exposes a migration button in the web vault which allows users to migrate an existing organization to
|
||||
/// flexible collections
|
||||
/// </summary>
|
||||
@ -140,6 +131,7 @@ public static class FeatureFlagKeys
|
||||
public const string AnhFcmv1Migration = "anh-fcmv1-migration";
|
||||
public const string ExtensionRefresh = "extension-refresh";
|
||||
public const string RestrictProviderAccess = "restrict-provider-access";
|
||||
public const string VaultBulkManagementAction = "vault-bulk-management-action";
|
||||
|
||||
public static List<string> GetAllKeys()
|
||||
{
|
||||
@ -154,11 +146,8 @@ public static class FeatureFlagKeys
|
||||
// place overriding values when needed locally (offline), or return null
|
||||
return new Dictionary<string, string>()
|
||||
{
|
||||
{ TrustedDeviceEncryption, "true" },
|
||||
{ Fido2VaultCredentials, "true" },
|
||||
{ DuoRedirect, "true" },
|
||||
{ UnassignedItemsBanner, "true"},
|
||||
{ FlexibleCollectionsSignup, "true" }
|
||||
{ UnassignedItemsBanner, "true"}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -21,19 +21,19 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
|
||||
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.300.86" />
|
||||
<PackageReference Include="AWSSDK.SQS" Version="3.7.300.86" />
|
||||
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.300.93" />
|
||||
<PackageReference Include="AWSSDK.SQS" Version="3.7.301.6" />
|
||||
<PackageReference Include="Azure.Data.Tables" Version="12.8.3" />
|
||||
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.2" />
|
||||
<PackageReference Include="Azure.Messaging.ServiceBus" Version="7.15.0" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.14.1" />
|
||||
<PackageReference Include="Azure.Storage.Queues" Version="12.12.0" />
|
||||
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
|
||||
<PackageReference Include="Azure.Messaging.ServiceBus" Version="7.17.5" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.19.1" />
|
||||
<PackageReference Include="Azure.Storage.Queues" Version="12.17.1" />
|
||||
<PackageReference Include="BitPay.Light" Version="1.0.1907" />
|
||||
<PackageReference Include="DuoUniversal" Version="1.2.3" />
|
||||
<PackageReference Include="DuoUniversal" Version="1.2.4" />
|
||||
<PackageReference Include="DnsClient" Version="1.7.0" />
|
||||
<PackageReference Include="Fido2.AspNet" Version="3.0.1" />
|
||||
<PackageReference Include="Handlebars.Net" Version="2.1.6" />
|
||||
<PackageReference Include="MailKit" Version="4.5.0" />
|
||||
<PackageReference Include="MailKit" Version="4.6.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.25" />
|
||||
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.39.1" />
|
||||
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />
|
||||
@ -57,7 +57,7 @@
|
||||
<PackageReference Include="Otp.NET" Version="1.3.0" />
|
||||
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="6.0.25" />
|
||||
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.4.0" />
|
||||
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.5.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
@ -0,0 +1,39 @@
|
||||
{{#>FullHtmlLayout}}
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: left;" valign="top" align="center">
|
||||
We recently received your request to permanently delete the following Bitwarden organization:
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
|
||||
<b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">Name:</b> {{OrganizationName}}<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
<b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">ID:</b> {{OrganizationId}}<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
<b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">Created:</b> {{OrganizationCreationDate}} at {{OrganizationCreationTime}} {{TimeZone}}<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
<b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">Plan:</b> {{OrganizationPlan}}<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
<b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">Number of seats:</b> {{OrganizationSeats}}<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
<b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">Billing email address:</b> {{OrganizationBillingEmail}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: left;" valign="top" align="center">
|
||||
Click the link below to delete your Bitwarden organization.
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none; text-align: left;" valign="top" align="center">
|
||||
If you did not request this email to delete your Bitwarden organization, please contact us.
|
||||
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
|
||||
<a href="{{{Url}}}" clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #175DDC; border-color: #175DDC; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
Delete Your Organization
|
||||
</a>
|
||||
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{/FullHtmlLayout}}
|
@ -0,0 +1,17 @@
|
||||
{{#>BasicTextLayout}}
|
||||
We recently received your request to permanently delete the following Bitwarden organization:
|
||||
|
||||
- Name: {{OrganizationName}}
|
||||
- ID: {{OrganizationId}}
|
||||
- Created: {{OrganizationCreationDate}} at {{OrganizationCreationTime}} {{TimeZone}}
|
||||
- Plan: {{OrganizationPlan}}
|
||||
- Number of seats: {{OrganizationSeats}}
|
||||
- Billing email address: {{OrganizationBillingEmail}}
|
||||
|
||||
Click the link below to complete the deletion of your organization.
|
||||
|
||||
If you did not request this email to delete your Bitwarden organization, please contact us.
|
||||
|
||||
{{{Url}}}
|
||||
|
||||
{{/BasicTextLayout}}
|
@ -14,4 +14,9 @@ public class CollectionAdminDetails : CollectionDetails
|
||||
/// Flag for whether the user has been explicitly assigned to the collection either directly or through a group.
|
||||
/// </summary>
|
||||
public bool Assigned { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Flag for whether a collection is managed by an active user or group.
|
||||
/// </summary>
|
||||
public bool Unmanaged { get; set; }
|
||||
}
|
||||
|
23
src/Core/Models/Mail/OrganizationInitiateDeleteModel.cs
Normal file
23
src/Core/Models/Mail/OrganizationInitiateDeleteModel.cs
Normal file
@ -0,0 +1,23 @@
|
||||
using Bit.Core.Models.Mail;
|
||||
|
||||
namespace Bit.Core.Auth.Models.Mail;
|
||||
|
||||
public class OrganizationInitiateDeleteModel : BaseMailModel
|
||||
{
|
||||
public string Url => string.Format("{0}/verify-recover-delete-org?orgId={1}&token={2}&name={3}",
|
||||
WebVaultUrl,
|
||||
OrganizationId,
|
||||
Token,
|
||||
OrganizationNameUrlEncoded);
|
||||
|
||||
public string Token { get; set; }
|
||||
public Guid OrganizationId { get; set; }
|
||||
public string OrganizationName { get; set; }
|
||||
public string OrganizationNameUrlEncoded { get; set; }
|
||||
public string OrganizationPlan { get; set; }
|
||||
public string OrganizationSeats { get; set; }
|
||||
public string OrganizationBillingEmail { get; set; }
|
||||
public string OrganizationCreationDate { get; set; }
|
||||
public string OrganizationCreationTime { get; set; }
|
||||
public string TimeZone { get; set; }
|
||||
}
|
@ -28,6 +28,8 @@ public record TeamsStarterPlan : Plan
|
||||
|
||||
PasswordManager = new TeamsStarterPasswordManagerFeatures();
|
||||
SecretsManager = new TeamsStarterSecretsManagerFeatures();
|
||||
|
||||
LegacyYear = 2024;
|
||||
}
|
||||
|
||||
private record TeamsStarterSecretsManagerFeatures : SecretsManagerPlanFeatures
|
||||
|
@ -79,5 +79,6 @@ public interface IMailService
|
||||
Task SendTrustedDeviceAdminApprovalEmailAsync(string email, DateTime utcNow, string ip, string deviceTypeAndIdentifier);
|
||||
Task SendTrialInitiationEmailAsync(string email);
|
||||
Task SendInitiateDeletProviderEmailAsync(string email, Provider provider, string token);
|
||||
Task SendInitiateDeleteOrganzationEmailAsync(string email, Organization organization, string token);
|
||||
}
|
||||
|
||||
|
@ -57,4 +57,5 @@ public interface IPaymentService
|
||||
Task<string> AddSecretsManagerToSubscription(Organization org, Plan plan, int additionalSmSeats,
|
||||
int additionalServiceAccount, DateTime? prorationDate = null);
|
||||
Task<bool> RisksSubscriptionFailure(Organization organization);
|
||||
Task<bool> HasSecretsManagerStandalone(Organization organization);
|
||||
}
|
||||
|
@ -4,8 +4,6 @@ using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Fido2NetLib;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
@ -39,8 +37,6 @@ public interface IUserService
|
||||
Task<IdentityResult> UpdateTempPasswordAsync(User user, string newMasterPassword, string key, string hint);
|
||||
Task<IdentityResult> ChangeKdfAsync(User user, string masterPassword, string newMasterPassword, string key,
|
||||
KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism);
|
||||
Task<IdentityResult> UpdateKeyAsync(User user, string masterPassword, string key, string privateKey,
|
||||
IEnumerable<Cipher> ciphers, IEnumerable<Folder> folders, IEnumerable<Send> sends);
|
||||
Task<IdentityResult> RefreshSecurityStampAsync(User user, string masterPasswordHash);
|
||||
Task UpdateTwoFactorProviderAsync(User user, TwoFactorProviderType type, bool setEnabled = true, bool logEvent = true);
|
||||
Task DisableTwoFactorProviderAsync(User user, TwoFactorProviderType type,
|
||||
|
@ -1002,6 +1002,30 @@ public class HandlebarsMailService : IMailService
|
||||
await _mailDeliveryService.SendEmailAsync(message);
|
||||
}
|
||||
|
||||
public async Task SendInitiateDeleteOrganzationEmailAsync(string email, Organization organization, string token)
|
||||
{
|
||||
var message = CreateDefaultMessage("Request to Delete Your Organization", email);
|
||||
var model = new OrganizationInitiateDeleteModel
|
||||
{
|
||||
Token = WebUtility.UrlEncode(token),
|
||||
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
|
||||
SiteName = _globalSettings.SiteName,
|
||||
OrganizationId = organization.Id,
|
||||
OrganizationName = CoreHelpers.SanitizeForEmail(organization.DisplayName(), false),
|
||||
OrganizationNameUrlEncoded = WebUtility.UrlEncode(organization.Name),
|
||||
OrganizationBillingEmail = organization.BillingEmail,
|
||||
OrganizationPlan = organization.Plan,
|
||||
OrganizationSeats = organization.Seats.ToString(),
|
||||
OrganizationCreationDate = organization.CreationDate.ToLongDateString(),
|
||||
OrganizationCreationTime = organization.CreationDate.ToShortTimeString(),
|
||||
TimeZone = _utcTimeZoneDisplay,
|
||||
};
|
||||
await AddMessageContentAsync(message, "InitiateDeleteOrganzation", model);
|
||||
message.MetaData.Add("SendGridBypassListManagement", true);
|
||||
message.Category = "InitiateDeleteOrganzation";
|
||||
await _mailDeliveryService.SendEmailAsync(message);
|
||||
}
|
||||
|
||||
private static string GetUserIdentifier(string email, string userName)
|
||||
{
|
||||
return string.IsNullOrEmpty(userName) ? email : CoreHelpers.SanitizeForEmail(userName, false);
|
||||
|
@ -1800,6 +1800,18 @@ public class StripePaymentService : IPaymentService
|
||||
return paymentSource == null;
|
||||
}
|
||||
|
||||
public async Task<bool> HasSecretsManagerStandalone(Organization organization)
|
||||
{
|
||||
if (string.IsNullOrEmpty(organization.GatewayCustomerId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var customer = await _stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId);
|
||||
|
||||
return customer?.Discount?.Coupon?.Id == SecretsManagerStandaloneDiscountId;
|
||||
}
|
||||
|
||||
private PaymentMethod GetLatestCardPaymentMethod(string customerId)
|
||||
{
|
||||
var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging(
|
||||
|
@ -14,12 +14,10 @@ using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tokens;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.Enums;
|
||||
using Bit.Core.Tools.Models.Business;
|
||||
using Bit.Core.Tools.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Core.Vault.Repositories;
|
||||
using Fido2NetLib;
|
||||
using Fido2NetLib.Objects;
|
||||
@ -862,39 +860,6 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch());
|
||||
}
|
||||
|
||||
public async Task<IdentityResult> UpdateKeyAsync(User user, string masterPassword, string key, string privateKey,
|
||||
IEnumerable<Cipher> ciphers, IEnumerable<Folder> folders, IEnumerable<Send> sends)
|
||||
{
|
||||
if (user == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(user));
|
||||
}
|
||||
|
||||
if (await CheckPasswordAsync(user, masterPassword))
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
user.RevisionDate = user.AccountRevisionDate = now;
|
||||
user.LastKeyRotationDate = now;
|
||||
user.SecurityStamp = Guid.NewGuid().ToString();
|
||||
user.Key = key;
|
||||
user.PrivateKey = privateKey;
|
||||
if (ciphers.Any() || folders.Any() || sends.Any())
|
||||
{
|
||||
await _cipherRepository.UpdateUserKeysAndCiphersAsync(user, ciphers, folders, sends);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _userRepository.ReplaceAsync(user);
|
||||
}
|
||||
|
||||
await _pushService.PushLogOutAsync(user.Id, excludeCurrentContextFromPush: true);
|
||||
return IdentityResult.Success;
|
||||
}
|
||||
|
||||
Logger.LogWarning("Update key failed for user {userId}.", user.Id);
|
||||
return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch());
|
||||
}
|
||||
|
||||
public async Task<IdentityResult> RefreshSecurityStampAsync(User user, string secret)
|
||||
{
|
||||
if (user == null)
|
||||
|
@ -270,5 +270,10 @@ public class NoopMailService : IMailService
|
||||
}
|
||||
|
||||
public Task SendInitiateDeletProviderEmailAsync(string email, Provider provider, string token) => throw new NotImplementedException();
|
||||
|
||||
public Task SendInitiateDeleteOrganzationEmailAsync(string email, Organization organization, string token)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -8,4 +8,6 @@ public enum ReferenceEventSource
|
||||
Organization,
|
||||
[EnumMember(Value = "user")]
|
||||
User,
|
||||
[EnumMember(Value = "provider")]
|
||||
Provider,
|
||||
}
|
||||
|
16
src/Core/Utilities/ModelStateExtensions.cs
Normal file
16
src/Core/Utilities/ModelStateExtensions.cs
Normal file
@ -0,0 +1,16 @@
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
|
||||
namespace Bit.Core.Utilities;
|
||||
|
||||
public static class ModelStateExtensions
|
||||
{
|
||||
public static string GetErrorMessage(this ModelStateDictionary modelState)
|
||||
{
|
||||
var errors = modelState.Values
|
||||
.SelectMany(v => v.Errors)
|
||||
.Select(e => e.ErrorMessage)
|
||||
.ToList();
|
||||
|
||||
return string.Join("; ", errors);
|
||||
}
|
||||
}
|
@ -1,7 +1,6 @@
|
||||
using Bit.Core.Auth.UserFeatures.UserKey;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Core.Vault.Models.Data;
|
||||
|
||||
@ -30,7 +29,6 @@ public interface ICipherRepository : IRepository<Cipher, Guid>
|
||||
Task MoveAsync(IEnumerable<Guid> ids, Guid? folderId, Guid userId, bool useFlexibleCollections);
|
||||
Task DeleteByUserIdAsync(Guid userId);
|
||||
Task DeleteByOrganizationIdAsync(Guid organizationId);
|
||||
Task UpdateUserKeysAndCiphersAsync(User user, IEnumerable<Cipher> ciphers, IEnumerable<Folder> folders, IEnumerable<Send> sends);
|
||||
Task UpdateCiphersAsync(Guid userId, IEnumerable<Cipher> ciphers);
|
||||
Task CreateAsync(IEnumerable<Cipher> ciphers, IEnumerable<Folder> folders);
|
||||
Task CreateAsync(IEnumerable<Cipher> ciphers, IEnumerable<Collection> collections,
|
||||
|
@ -14,8 +14,6 @@ using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Identity.Controllers;
|
||||
|
||||
// TODO: 2023-10-16, Remove account alias (https://bitwarden.atlassian.net/browse/PM-1247)
|
||||
[Route("account/[action]")]
|
||||
[Route("sso/[action]")]
|
||||
public class SsoController : Controller
|
||||
{
|
||||
|
@ -39,6 +39,7 @@ public static class ServiceCollectionExtensions
|
||||
}
|
||||
options.InputLengthRestrictions.UserName = 256;
|
||||
options.KeyManagement.Enabled = false;
|
||||
options.UserInteraction.LoginUrl = "/sso/Login";
|
||||
})
|
||||
.AddInMemoryCaching()
|
||||
.AddInMemoryApiResources(ApiResources.GetApiResources())
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.Data;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Repositories;
|
||||
@ -74,4 +75,20 @@ public class AuthRequestRepository : Repository<AuthRequest, Guid>, IAuthRequest
|
||||
return results.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateManyAsync(IEnumerable<AuthRequest> authRequests)
|
||||
{
|
||||
if (!authRequests.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var results = await connection.ExecuteAsync(
|
||||
$"[dbo].[AuthRequest_UpdateMany]",
|
||||
new { jsonData = JsonSerializer.Serialize(authRequests) },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -380,170 +380,6 @@ public class CipherRepository : Repository<Cipher, Guid>, ICipherRepository
|
||||
};
|
||||
}
|
||||
|
||||
public Task UpdateUserKeysAndCiphersAsync(User user, IEnumerable<Cipher> ciphers, IEnumerable<Folder> folders, IEnumerable<Send> sends)
|
||||
{
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
connection.Open();
|
||||
|
||||
using (var transaction = connection.BeginTransaction())
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. Update user.
|
||||
|
||||
using (var cmd = new SqlCommand("[dbo].[User_UpdateKeys]", connection, transaction))
|
||||
{
|
||||
cmd.CommandType = CommandType.StoredProcedure;
|
||||
cmd.Parameters.Add("@Id", SqlDbType.UniqueIdentifier).Value = user.Id;
|
||||
cmd.Parameters.Add("@SecurityStamp", SqlDbType.NVarChar).Value = user.SecurityStamp;
|
||||
cmd.Parameters.Add("@Key", SqlDbType.VarChar).Value = user.Key;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(user.PrivateKey))
|
||||
{
|
||||
cmd.Parameters.Add("@PrivateKey", SqlDbType.VarChar).Value = DBNull.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
cmd.Parameters.Add("@PrivateKey", SqlDbType.VarChar).Value = user.PrivateKey;
|
||||
}
|
||||
|
||||
cmd.Parameters.Add("@RevisionDate", SqlDbType.DateTime2).Value = user.RevisionDate;
|
||||
cmd.Parameters.Add("@AccountRevisionDate", SqlDbType.DateTime2).Value = user.AccountRevisionDate;
|
||||
cmd.Parameters.Add("@LastKeyRotationDate", SqlDbType.DateTime2).Value = user.LastKeyRotationDate;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// 2. Create temp tables to bulk copy into.
|
||||
|
||||
var sqlCreateTemp = @"
|
||||
SELECT TOP 0 *
|
||||
INTO #TempCipher
|
||||
FROM [dbo].[Cipher]
|
||||
|
||||
SELECT TOP 0 *
|
||||
INTO #TempFolder
|
||||
FROM [dbo].[Folder]
|
||||
|
||||
SELECT TOP 0 *
|
||||
INTO #TempSend
|
||||
FROM [dbo].[Send]";
|
||||
|
||||
using (var cmd = new SqlCommand(sqlCreateTemp, connection, transaction))
|
||||
{
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// 3. Bulk copy into temp tables.
|
||||
|
||||
if (ciphers.Any())
|
||||
{
|
||||
using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction))
|
||||
{
|
||||
bulkCopy.DestinationTableName = "#TempCipher";
|
||||
var dataTable = BuildCiphersTable(bulkCopy, ciphers);
|
||||
bulkCopy.WriteToServer(dataTable);
|
||||
}
|
||||
}
|
||||
|
||||
if (folders.Any())
|
||||
{
|
||||
using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction))
|
||||
{
|
||||
bulkCopy.DestinationTableName = "#TempFolder";
|
||||
var dataTable = BuildFoldersTable(bulkCopy, folders);
|
||||
bulkCopy.WriteToServer(dataTable);
|
||||
}
|
||||
}
|
||||
|
||||
if (sends.Any())
|
||||
{
|
||||
using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction))
|
||||
{
|
||||
bulkCopy.DestinationTableName = "#TempSend";
|
||||
var dataTable = BuildSendsTable(bulkCopy, sends);
|
||||
bulkCopy.WriteToServer(dataTable);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Insert into real tables from temp tables and clean up.
|
||||
|
||||
var sql = string.Empty;
|
||||
|
||||
if (ciphers.Any())
|
||||
{
|
||||
sql += @"
|
||||
UPDATE
|
||||
[dbo].[Cipher]
|
||||
SET
|
||||
[Data] = TC.[Data],
|
||||
[Attachments] = TC.[Attachments],
|
||||
[RevisionDate] = TC.[RevisionDate],
|
||||
[Key] = TC.[Key]
|
||||
FROM
|
||||
[dbo].[Cipher] C
|
||||
INNER JOIN
|
||||
#TempCipher TC ON C.Id = TC.Id
|
||||
WHERE
|
||||
C.[UserId] = @UserId";
|
||||
}
|
||||
|
||||
if (folders.Any())
|
||||
{
|
||||
sql += @"
|
||||
UPDATE
|
||||
[dbo].[Folder]
|
||||
SET
|
||||
[Name] = TF.[Name],
|
||||
[RevisionDate] = TF.[RevisionDate]
|
||||
FROM
|
||||
[dbo].[Folder] F
|
||||
INNER JOIN
|
||||
#TempFolder TF ON F.Id = TF.Id
|
||||
WHERE
|
||||
F.[UserId] = @UserId";
|
||||
}
|
||||
|
||||
if (sends.Any())
|
||||
{
|
||||
sql += @"
|
||||
UPDATE
|
||||
[dbo].[Send]
|
||||
SET
|
||||
[Key] = TS.[Key],
|
||||
[RevisionDate] = TS.[RevisionDate]
|
||||
FROM
|
||||
[dbo].[Send] S
|
||||
INNER JOIN
|
||||
#TempSend TS ON S.Id = TS.Id
|
||||
WHERE
|
||||
S.[UserId] = @UserId";
|
||||
}
|
||||
|
||||
sql += @"
|
||||
DROP TABLE #TempCipher
|
||||
DROP TABLE #TempFolder
|
||||
DROP TABLE #TempSend";
|
||||
|
||||
using (var cmd = new SqlCommand(sql, connection, transaction))
|
||||
{
|
||||
cmd.Parameters.Add("@UserId", SqlDbType.UniqueIdentifier).Value = user.Id;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public async Task UpdateCiphersAsync(Guid userId, IEnumerable<Cipher> ciphers)
|
||||
{
|
||||
if (!ciphers.Any())
|
||||
|
@ -69,4 +69,29 @@ public class AuthRequestRepository : Repository<Core.Auth.Entities.AuthRequest,
|
||||
return orgUserAuthRequests;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateManyAsync(IEnumerable<Core.Auth.Entities.AuthRequest> authRequests)
|
||||
{
|
||||
if (!authRequests.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var entities = new List<AuthRequest>();
|
||||
foreach (var authRequest in authRequests)
|
||||
{
|
||||
if (!authRequest.Id.Equals(default))
|
||||
{
|
||||
var entity = Mapper.Map<AuthRequest>(authRequest);
|
||||
entities.Add(entity);
|
||||
}
|
||||
}
|
||||
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
{
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
dbContext.UpdateRange(entities);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -340,7 +340,7 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
|
||||
ExternalId = collectionGroup.Key.ExternalId,
|
||||
ReadOnly = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.ReadOnly))),
|
||||
HidePasswords = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))),
|
||||
Manage = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.Manage))),
|
||||
Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))),
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
@ -365,7 +365,7 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
|
||||
ExternalId = collectionGroup.Key.ExternalId,
|
||||
ReadOnly = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.ReadOnly))),
|
||||
HidePasswords = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))),
|
||||
Manage = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.Manage))),
|
||||
Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))),
|
||||
}).ToListAsync();
|
||||
}
|
||||
}
|
||||
@ -391,7 +391,8 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
|
||||
c.Name,
|
||||
c.CreationDate,
|
||||
c.RevisionDate,
|
||||
c.ExternalId
|
||||
c.ExternalId,
|
||||
c.Unmanaged
|
||||
}).Select(collectionGroup => new CollectionAdminDetails
|
||||
{
|
||||
Id = collectionGroup.Key.Id,
|
||||
@ -404,7 +405,8 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
|
||||
HidePasswords =
|
||||
Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))),
|
||||
Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))),
|
||||
Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned)))
|
||||
Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))),
|
||||
Unmanaged = collectionGroup.Key.Unmanaged
|
||||
}).ToList();
|
||||
}
|
||||
else
|
||||
@ -417,7 +419,8 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
|
||||
c.Name,
|
||||
c.CreationDate,
|
||||
c.RevisionDate,
|
||||
c.ExternalId
|
||||
c.ExternalId,
|
||||
c.Unmanaged
|
||||
}
|
||||
into collectionGroup
|
||||
select new CollectionAdminDetails
|
||||
@ -432,7 +435,8 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
|
||||
HidePasswords =
|
||||
Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))),
|
||||
Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))),
|
||||
Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned)))
|
||||
Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))),
|
||||
Unmanaged = collectionGroup.Key.Unmanaged
|
||||
}).ToListAsync();
|
||||
}
|
||||
|
||||
@ -511,7 +515,8 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
|
||||
HidePasswords =
|
||||
Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))),
|
||||
Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))),
|
||||
Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned)))
|
||||
Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))),
|
||||
Unmanaged = collectionGroup.Select(c => c.Unmanaged).FirstOrDefault()
|
||||
}).FirstOrDefault();
|
||||
}
|
||||
else
|
||||
@ -539,7 +544,8 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
|
||||
HidePasswords =
|
||||
Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))),
|
||||
Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))),
|
||||
Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned)))
|
||||
Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))),
|
||||
Unmanaged = collectionGroup.Select(c => c.Unmanaged).FirstOrDefault()
|
||||
}).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Repositories.Queries;
|
||||
|
||||
@ -46,6 +47,17 @@ public class CollectionAdminDetailsQuery : IQuery<CollectionAdminDetails>
|
||||
from cg in cg_g.DefaultIfEmpty()
|
||||
select new { c, cu, cg };
|
||||
|
||||
// Subqueries to determine if a colection is managed by an active user or group.
|
||||
var activeUserManageRights = from cu in dbContext.CollectionUsers
|
||||
join ou in dbContext.OrganizationUsers
|
||||
on cu.OrganizationUserId equals ou.Id
|
||||
where ou.Status == OrganizationUserStatusType.Confirmed && cu.Manage
|
||||
select cu.CollectionId;
|
||||
|
||||
var activeGroupManageRights = from cg in dbContext.CollectionGroups
|
||||
where cg.Manage
|
||||
select cg.CollectionId;
|
||||
|
||||
if (_organizationId.HasValue)
|
||||
{
|
||||
baseCollectionQuery = baseCollectionQuery.Where(x => x.c.OrganizationId == _organizationId);
|
||||
@ -71,6 +83,7 @@ public class CollectionAdminDetailsQuery : IQuery<CollectionAdminDetails>
|
||||
HidePasswords = (bool?)x.cu.HidePasswords ?? (bool?)x.cg.HidePasswords ?? false,
|
||||
Manage = (bool?)x.cu.Manage ?? (bool?)x.cg.Manage ?? false,
|
||||
Assigned = x.cu != null || x.cg != null,
|
||||
Unmanaged = !activeUserManageRights.Contains(x.c.Id) && !activeGroupManageRights.Contains(x.c.Id),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -19,7 +19,6 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NS = Newtonsoft.Json;
|
||||
using NSL = Newtonsoft.Json.Linq;
|
||||
using User = Bit.Core.Entities.User;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Vault.Repositories;
|
||||
|
||||
@ -865,23 +864,6 @@ public class CipherRepository : Repository<Core.Vault.Entities.Cipher, Cipher, G
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
public async Task UpdateUserKeysAndCiphersAsync(User user, IEnumerable<Core.Vault.Entities.Cipher> ciphers, IEnumerable<Core.Vault.Entities.Folder> folders, IEnumerable<Core.Tools.Entities.Send> sends)
|
||||
{
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
{
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
await UserUpdateKeys(user);
|
||||
var cipherEntities = Mapper.Map<List<Cipher>>(ciphers);
|
||||
await dbContext.BulkCopyAsync(base.DefaultBulkCopyOptions, cipherEntities);
|
||||
var folderEntities = Mapper.Map<List<Folder>>(folders);
|
||||
await dbContext.BulkCopyAsync(base.DefaultBulkCopyOptions, folderEntities);
|
||||
var sendEntities = Mapper.Map<List<Send>>(sends);
|
||||
await dbContext.BulkCopyAsync(base.DefaultBulkCopyOptions, sendEntities);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(CipherDetails cipher)
|
||||
{
|
||||
if (cipher.Id.Equals(default))
|
||||
|
@ -7,8 +7,8 @@
|
||||
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Notifications' " />
|
||||
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Notifications-SelfHost' " />
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="8.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="8.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
@ -150,6 +150,13 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
public static void AddTokenizers(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IDataProtectorTokenFactory<OrgDeleteTokenable>>(serviceProvider =>
|
||||
new DataProtectorTokenFactory<OrgDeleteTokenable>(
|
||||
OrgDeleteTokenable.ClearTextPrefix,
|
||||
OrgDeleteTokenable.DataProtectorPurpose,
|
||||
serviceProvider.GetDataProtectionProvider(),
|
||||
serviceProvider.GetRequiredService<ILogger<DataProtectorTokenFactory<OrgDeleteTokenable>>>())
|
||||
);
|
||||
services.AddSingleton<IDataProtectorTokenFactory<EmergencyAccessInviteTokenable>>(serviceProvider =>
|
||||
new DataProtectorTokenFactory<EmergencyAccessInviteTokenable>(
|
||||
EmergencyAccessInviteTokenable.ClearTextPrefix,
|
||||
|
@ -0,0 +1,45 @@
|
||||
CREATE PROCEDURE AuthRequest_UpdateMany
|
||||
@jsonData NVARCHAR(MAX)
|
||||
AS
|
||||
BEGIN
|
||||
UPDATE AR
|
||||
SET
|
||||
[Id] = ARI.[Id],
|
||||
[UserId] = ARI.[UserId],
|
||||
[Type] = ARI.[Type],
|
||||
[RequestDeviceIdentifier] = ARI.[RequestDeviceIdentifier],
|
||||
[RequestDeviceType] = ARI.[RequestDeviceType],
|
||||
[RequestIpAddress] = ARI.[RequestIpAddress],
|
||||
[ResponseDeviceId] = ARI.[ResponseDeviceId],
|
||||
[AccessCode] = ARI.[AccessCode],
|
||||
[PublicKey] = ARI.[PublicKey],
|
||||
[Key] = ARI.[Key],
|
||||
[MasterPasswordHash] = ARI.[MasterPasswordHash],
|
||||
[Approved] = ARI.[Approved],
|
||||
[CreationDate] = ARI.[CreationDate],
|
||||
[ResponseDate] = ARI.[ResponseDate],
|
||||
[AuthenticationDate] = ARI.[AuthenticationDate],
|
||||
[OrganizationId] = ARI.[OrganizationId]
|
||||
FROM
|
||||
[dbo].[AuthRequest] AR
|
||||
INNER JOIN
|
||||
OPENJSON(@jsonData)
|
||||
WITH (
|
||||
Id UNIQUEIDENTIFIER '$.Id',
|
||||
UserId UNIQUEIDENTIFIER '$.UserId',
|
||||
Type SMALLINT '$.Type',
|
||||
RequestDeviceIdentifier NVARCHAR(50) '$.RequestDeviceIdentifier',
|
||||
RequestDeviceType SMALLINT '$.RequestDeviceType',
|
||||
RequestIpAddress VARCHAR(50) '$.RequestIpAddress',
|
||||
ResponseDeviceId UNIQUEIDENTIFIER '$.ResponseDeviceId',
|
||||
AccessCode VARCHAR(25) '$.AccessCode',
|
||||
PublicKey VARCHAR(MAX) '$.PublicKey',
|
||||
[Key] VARCHAR(MAX) '$.Key',
|
||||
MasterPasswordHash VARCHAR(MAX) '$.MasterPasswordHash',
|
||||
Approved BIT '$.Approved',
|
||||
CreationDate DATETIME2 '$.CreationDate',
|
||||
ResponseDate DATETIME2 '$.ResponseDate',
|
||||
AuthenticationDate DATETIME2 '$.AuthenticationDate',
|
||||
OrganizationId UNIQUEIDENTIFIER '$.OrganizationId'
|
||||
) ARI ON AR.Id = ARI.Id;
|
||||
END
|
@ -31,7 +31,29 @@ BEGIN
|
||||
CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL
|
||||
THEN 0
|
||||
ELSE 1
|
||||
END) AS [Assigned]
|
||||
END) AS [Assigned],
|
||||
CASE
|
||||
WHEN
|
||||
-- No active user or group has manage rights
|
||||
NOT EXISTS(
|
||||
SELECT 1
|
||||
FROM [dbo].[CollectionUser] CU2
|
||||
JOIN [dbo].[OrganizationUser] OU2 ON CU2.[OrganizationUserId] = OU2.[Id]
|
||||
WHERE
|
||||
CU2.[CollectionId] = C.[Id] AND
|
||||
OU2.[Status] = 2 AND
|
||||
CU2.[Manage] = 1
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM [dbo].[CollectionGroup] CG2
|
||||
WHERE
|
||||
CG2.[CollectionId] = C.[Id] AND
|
||||
CG2.[Manage] = 1
|
||||
)
|
||||
THEN 1
|
||||
ELSE 0
|
||||
END AS [Unmanaged]
|
||||
FROM
|
||||
[dbo].[CollectionView] C
|
||||
LEFT JOIN
|
||||
|
@ -31,7 +31,29 @@ BEGIN
|
||||
CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL
|
||||
THEN 0
|
||||
ELSE 1
|
||||
END) AS [Assigned]
|
||||
END) AS [Assigned],
|
||||
CASE
|
||||
WHEN
|
||||
-- No active user or group has manage rights
|
||||
NOT EXISTS(
|
||||
SELECT 1
|
||||
FROM [dbo].[CollectionUser] CU2
|
||||
JOIN [dbo].[OrganizationUser] OU2 ON CU2.[OrganizationUserId] = OU2.[Id]
|
||||
WHERE
|
||||
CU2.[CollectionId] = C.[Id] AND
|
||||
OU2.[Status] = 2 AND
|
||||
CU2.[Manage] = 1
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM [dbo].[CollectionGroup] CG2
|
||||
WHERE
|
||||
CG2.[CollectionId] = C.[Id] AND
|
||||
CG2.[Manage] = 1
|
||||
)
|
||||
THEN 1
|
||||
ELSE 0
|
||||
END AS [Unmanaged]
|
||||
FROM
|
||||
[dbo].[CollectionView] C
|
||||
LEFT JOIN
|
||||
|
@ -13,7 +13,7 @@ BEGIN
|
||||
ExternalId,
|
||||
MIN([ReadOnly]) AS [ReadOnly],
|
||||
MIN([HidePasswords]) AS [HidePasswords],
|
||||
MIN([Manage]) AS [Manage]
|
||||
MAX([Manage]) AS [Manage]
|
||||
FROM
|
||||
[dbo].[UserCollectionDetails](@UserId)
|
||||
WHERE
|
||||
|
@ -13,7 +13,7 @@ BEGIN
|
||||
ExternalId,
|
||||
MIN([ReadOnly]) AS [ReadOnly],
|
||||
MIN([HidePasswords]) AS [HidePasswords],
|
||||
MIN([Manage]) AS [Manage]
|
||||
MAX([Manage]) AS [Manage]
|
||||
FROM
|
||||
[dbo].[UserCollectionDetails_V2](@UserId)
|
||||
WHERE
|
||||
|
@ -13,7 +13,7 @@ BEGIN
|
||||
ExternalId,
|
||||
MIN([ReadOnly]) AS [ReadOnly],
|
||||
MIN([HidePasswords]) AS [HidePasswords],
|
||||
MIN([Manage]) AS [Manage]
|
||||
MAX([Manage]) AS [Manage]
|
||||
FROM
|
||||
[dbo].[UserCollectionDetails](@UserId)
|
||||
GROUP BY
|
||||
|
@ -13,7 +13,7 @@ BEGIN
|
||||
ExternalId,
|
||||
MIN([ReadOnly]) AS [ReadOnly],
|
||||
MIN([HidePasswords]) AS [HidePasswords],
|
||||
MIN([Manage]) AS [Manage]
|
||||
MAX([Manage]) AS [Manage]
|
||||
FROM
|
||||
[dbo].[UserCollectionDetails_V2](@UserId)
|
||||
GROUP BY
|
||||
|
@ -260,6 +260,54 @@ public class GroupsControllerTests
|
||||
Assert.Equal(groupRequestModel.AccessAll, response.AccessAll);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task Put_UpdateMembers_AdminsCannotAccessAllCollections_ProviderUser_Success(Organization organization, Group group,
|
||||
GroupRequestModel groupRequestModel, List<Guid> currentGroupUsers, Guid savingUserId,
|
||||
SutProvider<GroupsController> sutProvider)
|
||||
{
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
// Enable FC and v1
|
||||
sutProvider.GetDependency<IApplicationCacheService>().GetOrganizationAbilityAsync(organization.Id).Returns(
|
||||
new OrganizationAbility
|
||||
{
|
||||
Id = organization.Id,
|
||||
AllowAdminAccessToAllCollectionItems = false,
|
||||
FlexibleCollections = true
|
||||
});
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1).Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||
sutProvider.GetDependency<IGroupRepository>().GetByIdWithCollectionsAsync(group.Id)
|
||||
.Returns(new Tuple<Group, ICollection<CollectionAccessSelection>>(group, new List<CollectionAccessSelection>()));
|
||||
sutProvider.GetDependency<ICurrentContext>().ManageGroups(organization.Id).Returns(true);
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(organization.Id, Arg.Any<Guid>())
|
||||
.Returns((OrganizationUser)null); // Provider is not an OrganizationUser, so it will always return null
|
||||
sutProvider.GetDependency<IUserService>().GetProperUserId(Arg.Any<ClaimsPrincipal>())
|
||||
.Returns(savingUserId);
|
||||
sutProvider.GetDependency<IGroupRepository>().GetManyUserIdsByIdAsync(group.Id)
|
||||
.Returns(currentGroupUsers);
|
||||
|
||||
// Make collection authorization pass, it's not being tested here
|
||||
groupRequestModel.Collections = Array.Empty<SelectionReadOnlyRequestModel>();
|
||||
|
||||
var response = await sutProvider.Sut.Put(organization.Id, group.Id, groupRequestModel);
|
||||
|
||||
await sutProvider.GetDependency<ICurrentContext>().Received(1).ManageGroups(organization.Id);
|
||||
await sutProvider.GetDependency<IUpdateGroupCommand>().Received(1).UpdateGroupAsync(
|
||||
Arg.Is<Group>(g =>
|
||||
g.OrganizationId == organization.Id && g.Name == groupRequestModel.Name &&
|
||||
g.AccessAll == groupRequestModel.AccessAll),
|
||||
Arg.Is<Organization>(o => o.Id == organization.Id),
|
||||
Arg.Any<ICollection<CollectionAccessSelection>>(),
|
||||
Arg.Any<IEnumerable<Guid>>());
|
||||
Assert.Equal(groupRequestModel.Name, response.Name);
|
||||
Assert.Equal(organization.Id, response.OrganizationId);
|
||||
Assert.Equal(groupRequestModel.AccessAll, response.AccessAll);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task Put_UpdateCollections_OnlyUpdatesCollectionsTheSavingUserCanUpdate(GroupRequestModel groupRequestModel,
|
||||
|
@ -1,12 +1,11 @@
|
||||
using System.Security.Claims;
|
||||
using AutoFixture.Xunit2;
|
||||
using Bit.Api.AdminConsole.Controllers;
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.Auth.Models.Request.Accounts;
|
||||
using Bit.Api.Models.Request.Organizations;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
@ -16,21 +15,15 @@ using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Auth.Services;
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Queries;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Tools.Services;
|
||||
using Bit.Core.Tokens;
|
||||
using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
using Xunit;
|
||||
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
|
||||
|
||||
@ -43,7 +36,6 @@ public class OrganizationsControllerTests : IDisposable
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IPaymentService _paymentService;
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||
private readonly ISsoConfigService _ssoConfigService;
|
||||
@ -51,20 +43,13 @@ public class OrganizationsControllerTests : IDisposable
|
||||
private readonly IGetOrganizationApiKeyQuery _getOrganizationApiKeyQuery;
|
||||
private readonly IRotateOrganizationApiKeyCommand _rotateOrganizationApiKeyCommand;
|
||||
private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository;
|
||||
private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery;
|
||||
private readonly ICreateOrganizationApiKeyCommand _createOrganizationApiKeyCommand;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly ILicensingService _licensingService;
|
||||
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
|
||||
private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand;
|
||||
private readonly IAddSecretsManagerSubscriptionCommand _addSecretsManagerSubscriptionCommand;
|
||||
private readonly IPushNotificationService _pushNotificationService;
|
||||
private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand;
|
||||
private readonly ISubscriberQueries _subscriberQueries;
|
||||
private readonly IReferenceEventService _referenceEventService;
|
||||
private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand;
|
||||
private readonly IProviderRepository _providerRepository;
|
||||
private readonly IScaleSeatsCommand _scaleSeatsCommand;
|
||||
private readonly IDataProtectorTokenFactory<OrgDeleteTokenable> _orgDeleteTokenDataFactory;
|
||||
|
||||
private readonly OrganizationsController _sut;
|
||||
|
||||
@ -75,7 +60,6 @@ public class OrganizationsControllerTests : IDisposable
|
||||
_organizationRepository = Substitute.For<IOrganizationRepository>();
|
||||
_organizationService = Substitute.For<IOrganizationService>();
|
||||
_organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
|
||||
_paymentService = Substitute.For<IPaymentService>();
|
||||
_policyRepository = Substitute.For<IPolicyRepository>();
|
||||
_ssoConfigRepository = Substitute.For<ISsoConfigRepository>();
|
||||
_ssoConfigService = Substitute.For<ISsoConfigService>();
|
||||
@ -83,20 +67,13 @@ public class OrganizationsControllerTests : IDisposable
|
||||
_rotateOrganizationApiKeyCommand = Substitute.For<IRotateOrganizationApiKeyCommand>();
|
||||
_organizationApiKeyRepository = Substitute.For<IOrganizationApiKeyRepository>();
|
||||
_userService = Substitute.For<IUserService>();
|
||||
_cloudGetOrganizationLicenseQuery = Substitute.For<ICloudGetOrganizationLicenseQuery>();
|
||||
_createOrganizationApiKeyCommand = Substitute.For<ICreateOrganizationApiKeyCommand>();
|
||||
_featureService = Substitute.For<IFeatureService>();
|
||||
_licensingService = Substitute.For<ILicensingService>();
|
||||
_updateSecretsManagerSubscriptionCommand = Substitute.For<IUpdateSecretsManagerSubscriptionCommand>();
|
||||
_upgradeOrganizationPlanCommand = Substitute.For<IUpgradeOrganizationPlanCommand>();
|
||||
_addSecretsManagerSubscriptionCommand = Substitute.For<IAddSecretsManagerSubscriptionCommand>();
|
||||
_pushNotificationService = Substitute.For<IPushNotificationService>();
|
||||
_cancelSubscriptionCommand = Substitute.For<ICancelSubscriptionCommand>();
|
||||
_subscriberQueries = Substitute.For<ISubscriberQueries>();
|
||||
_referenceEventService = Substitute.For<IReferenceEventService>();
|
||||
_organizationEnableCollectionEnhancementsCommand = Substitute.For<IOrganizationEnableCollectionEnhancementsCommand>();
|
||||
_providerRepository = Substitute.For<IProviderRepository>();
|
||||
_scaleSeatsCommand = Substitute.For<IScaleSeatsCommand>();
|
||||
_orgDeleteTokenDataFactory = Substitute.For<IDataProtectorTokenFactory<OrgDeleteTokenable>>();
|
||||
|
||||
_sut = new OrganizationsController(
|
||||
_organizationRepository,
|
||||
@ -104,7 +81,6 @@ public class OrganizationsControllerTests : IDisposable
|
||||
_policyRepository,
|
||||
_organizationService,
|
||||
_userService,
|
||||
_paymentService,
|
||||
_currentContext,
|
||||
_ssoConfigRepository,
|
||||
_ssoConfigService,
|
||||
@ -112,20 +88,13 @@ public class OrganizationsControllerTests : IDisposable
|
||||
_rotateOrganizationApiKeyCommand,
|
||||
_createOrganizationApiKeyCommand,
|
||||
_organizationApiKeyRepository,
|
||||
_cloudGetOrganizationLicenseQuery,
|
||||
_featureService,
|
||||
_globalSettings,
|
||||
_licensingService,
|
||||
_updateSecretsManagerSubscriptionCommand,
|
||||
_upgradeOrganizationPlanCommand,
|
||||
_addSecretsManagerSubscriptionCommand,
|
||||
_pushNotificationService,
|
||||
_cancelSubscriptionCommand,
|
||||
_subscriberQueries,
|
||||
_referenceEventService,
|
||||
_organizationEnableCollectionEnhancementsCommand,
|
||||
_providerRepository,
|
||||
_scaleSeatsCommand);
|
||||
_scaleSeatsCommand,
|
||||
_orgDeleteTokenDataFactory);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@ -193,196 +162,6 @@ public class OrganizationsControllerTests : IDisposable
|
||||
await _organizationService.Received(1).DeleteUserAsync(orgId, user.Id);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task OrganizationsController_PostUpgrade_UserCannotEditSubscription_ThrowsNotFoundException(
|
||||
Guid organizationId,
|
||||
OrganizationUpgradeRequestModel model)
|
||||
{
|
||||
_currentContext.EditSubscription(organizationId).Returns(false);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.PostUpgrade(organizationId.ToString(), model));
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task OrganizationsController_PostUpgrade_NonSMUpgrade_ReturnsCorrectResponse(
|
||||
Guid organizationId,
|
||||
OrganizationUpgradeRequestModel model,
|
||||
bool success,
|
||||
string paymentIntentClientSecret)
|
||||
{
|
||||
model.UseSecretsManager = false;
|
||||
|
||||
_currentContext.EditSubscription(organizationId).Returns(true);
|
||||
|
||||
_upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any<OrganizationUpgrade>())
|
||||
.Returns(new Tuple<bool, string>(success, paymentIntentClientSecret));
|
||||
|
||||
var response = await _sut.PostUpgrade(organizationId.ToString(), model);
|
||||
|
||||
Assert.Equal(success, response.Success);
|
||||
Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task OrganizationsController_PostUpgrade_SMUpgrade_ProvidesAccess_ReturnsCorrectResponse(
|
||||
Guid organizationId,
|
||||
Guid userId,
|
||||
OrganizationUpgradeRequestModel model,
|
||||
bool success,
|
||||
string paymentIntentClientSecret,
|
||||
OrganizationUser organizationUser)
|
||||
{
|
||||
model.UseSecretsManager = true;
|
||||
organizationUser.AccessSecretsManager = false;
|
||||
|
||||
_currentContext.EditSubscription(organizationId).Returns(true);
|
||||
|
||||
_upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any<OrganizationUpgrade>())
|
||||
.Returns(new Tuple<bool, string>(success, paymentIntentClientSecret));
|
||||
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
|
||||
_organizationUserRepository.GetByOrganizationAsync(organizationId, userId).Returns(organizationUser);
|
||||
|
||||
var response = await _sut.PostUpgrade(organizationId.ToString(), model);
|
||||
|
||||
Assert.Equal(success, response.Success);
|
||||
Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret);
|
||||
|
||||
await _organizationUserRepository.Received(1).ReplaceAsync(Arg.Is<OrganizationUser>(orgUser =>
|
||||
orgUser.Id == organizationUser.Id && orgUser.AccessSecretsManager == true));
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task OrganizationsController_PostUpgrade_SMUpgrade_NullOrgUser_ReturnsCorrectResponse(
|
||||
Guid organizationId,
|
||||
Guid userId,
|
||||
OrganizationUpgradeRequestModel model,
|
||||
bool success,
|
||||
string paymentIntentClientSecret)
|
||||
{
|
||||
model.UseSecretsManager = true;
|
||||
|
||||
_currentContext.EditSubscription(organizationId).Returns(true);
|
||||
|
||||
_upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any<OrganizationUpgrade>())
|
||||
.Returns(new Tuple<bool, string>(success, paymentIntentClientSecret));
|
||||
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
|
||||
_organizationUserRepository.GetByOrganizationAsync(organizationId, userId).ReturnsNull();
|
||||
|
||||
var response = await _sut.PostUpgrade(organizationId.ToString(), model);
|
||||
|
||||
Assert.Equal(success, response.Success);
|
||||
Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret);
|
||||
|
||||
await _organizationUserRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any<OrganizationUser>());
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_NullOrg_ThrowsNotFoundException(
|
||||
Guid organizationId,
|
||||
SecretsManagerSubscribeRequestModel model)
|
||||
{
|
||||
_organizationRepository.GetByIdAsync(organizationId).ReturnsNull();
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.PostSubscribeSecretsManagerAsync(organizationId, model));
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_UserCannotEditSubscription_ThrowsNotFoundException(
|
||||
Guid organizationId,
|
||||
SecretsManagerSubscribeRequestModel model,
|
||||
Organization organization)
|
||||
{
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
_currentContext.EditSubscription(organizationId).Returns(false);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.PostSubscribeSecretsManagerAsync(organizationId, model));
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_ProvidesAccess_ReturnsCorrectResponse(
|
||||
Guid organizationId,
|
||||
SecretsManagerSubscribeRequestModel model,
|
||||
Organization organization,
|
||||
Guid userId,
|
||||
OrganizationUser organizationUser,
|
||||
OrganizationUserOrganizationDetails organizationUserOrganizationDetails)
|
||||
{
|
||||
organizationUser.AccessSecretsManager = false;
|
||||
|
||||
var ssoConfigurationData = new SsoConfigurationData
|
||||
{
|
||||
MemberDecryptionType = MemberDecryptionType.KeyConnector,
|
||||
KeyConnectorUrl = "https://example.com"
|
||||
};
|
||||
|
||||
organizationUserOrganizationDetails.Permissions = string.Empty;
|
||||
organizationUserOrganizationDetails.SsoConfig = ssoConfigurationData.Serialize();
|
||||
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
_currentContext.EditSubscription(organizationId).Returns(true);
|
||||
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
|
||||
_organizationUserRepository.GetByOrganizationAsync(organization.Id, userId).Returns(organizationUser);
|
||||
|
||||
_organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, OrganizationUserStatusType.Confirmed)
|
||||
.Returns(organizationUserOrganizationDetails);
|
||||
|
||||
var response = await _sut.PostSubscribeSecretsManagerAsync(organizationId, model);
|
||||
|
||||
Assert.Equal(response.Id, organizationUserOrganizationDetails.OrganizationId);
|
||||
Assert.Equal(response.Name, organizationUserOrganizationDetails.Name);
|
||||
|
||||
await _addSecretsManagerSubscriptionCommand.Received(1)
|
||||
.SignUpAsync(organization, model.AdditionalSmSeats, model.AdditionalServiceAccounts);
|
||||
await _organizationUserRepository.Received(1).ReplaceAsync(Arg.Is<OrganizationUser>(orgUser =>
|
||||
orgUser.Id == organizationUser.Id && orgUser.AccessSecretsManager == true));
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_NullOrgUser_ReturnsCorrectResponse(
|
||||
Guid organizationId,
|
||||
SecretsManagerSubscribeRequestModel model,
|
||||
Organization organization,
|
||||
Guid userId,
|
||||
OrganizationUserOrganizationDetails organizationUserOrganizationDetails)
|
||||
{
|
||||
var ssoConfigurationData = new SsoConfigurationData
|
||||
{
|
||||
MemberDecryptionType = MemberDecryptionType.KeyConnector,
|
||||
KeyConnectorUrl = "https://example.com"
|
||||
};
|
||||
|
||||
organizationUserOrganizationDetails.Permissions = string.Empty;
|
||||
organizationUserOrganizationDetails.SsoConfig = ssoConfigurationData.Serialize();
|
||||
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
_currentContext.EditSubscription(organizationId).Returns(true);
|
||||
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
|
||||
_organizationUserRepository.GetByOrganizationAsync(organization.Id, userId).ReturnsNull();
|
||||
|
||||
_organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, OrganizationUserStatusType.Confirmed)
|
||||
.Returns(organizationUserOrganizationDetails);
|
||||
|
||||
var response = await _sut.PostSubscribeSecretsManagerAsync(organizationId, model);
|
||||
|
||||
Assert.Equal(response.Id, organizationUserOrganizationDetails.OrganizationId);
|
||||
Assert.Equal(response.Name, organizationUserOrganizationDetails.Name);
|
||||
|
||||
await _addSecretsManagerSubscriptionCommand.Received(1)
|
||||
.SignUpAsync(organization, model.AdditionalSmSeats, model.AdditionalServiceAccounts);
|
||||
await _organizationUserRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any<OrganizationUser>());
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task EnableCollectionEnhancements_Success(Organization organization)
|
||||
{
|
||||
|
@ -0,0 +1,317 @@
|
||||
using System.Security.Claims;
|
||||
using AutoFixture.Xunit2;
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.Billing.Controllers;
|
||||
using Bit.Api.Models.Request.Organizations;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Auth.Services;
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Queries;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Tools.Services;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
using Xunit;
|
||||
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
|
||||
|
||||
namespace Bit.Api.Test.Billing.Controllers;
|
||||
|
||||
public class OrganizationsControllerTests : IDisposable
|
||||
{
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IPaymentService _paymentService;
|
||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||
private readonly IUserService _userService;
|
||||
private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery;
|
||||
private readonly ILicensingService _licensingService;
|
||||
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
|
||||
private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand;
|
||||
private readonly IAddSecretsManagerSubscriptionCommand _addSecretsManagerSubscriptionCommand;
|
||||
private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand;
|
||||
private readonly ISubscriberQueries _subscriberQueries;
|
||||
private readonly IReferenceEventService _referenceEventService;
|
||||
|
||||
private readonly OrganizationsController _sut;
|
||||
|
||||
public OrganizationsControllerTests()
|
||||
{
|
||||
_currentContext = Substitute.For<ICurrentContext>();
|
||||
_globalSettings = Substitute.For<GlobalSettings>();
|
||||
_organizationRepository = Substitute.For<IOrganizationRepository>();
|
||||
_organizationService = Substitute.For<IOrganizationService>();
|
||||
_organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
|
||||
_paymentService = Substitute.For<IPaymentService>();
|
||||
Substitute.For<IPolicyRepository>();
|
||||
_ssoConfigRepository = Substitute.For<ISsoConfigRepository>();
|
||||
Substitute.For<ISsoConfigService>();
|
||||
_userService = Substitute.For<IUserService>();
|
||||
_cloudGetOrganizationLicenseQuery = Substitute.For<ICloudGetOrganizationLicenseQuery>();
|
||||
_licensingService = Substitute.For<ILicensingService>();
|
||||
_updateSecretsManagerSubscriptionCommand = Substitute.For<IUpdateSecretsManagerSubscriptionCommand>();
|
||||
_upgradeOrganizationPlanCommand = Substitute.For<IUpgradeOrganizationPlanCommand>();
|
||||
_addSecretsManagerSubscriptionCommand = Substitute.For<IAddSecretsManagerSubscriptionCommand>();
|
||||
_cancelSubscriptionCommand = Substitute.For<ICancelSubscriptionCommand>();
|
||||
_subscriberQueries = Substitute.For<ISubscriberQueries>();
|
||||
_referenceEventService = Substitute.For<IReferenceEventService>();
|
||||
|
||||
_sut = new OrganizationsController(
|
||||
_organizationRepository,
|
||||
_organizationUserRepository,
|
||||
_organizationService,
|
||||
_userService,
|
||||
_paymentService,
|
||||
_currentContext,
|
||||
_cloudGetOrganizationLicenseQuery,
|
||||
_globalSettings,
|
||||
_licensingService,
|
||||
_updateSecretsManagerSubscriptionCommand,
|
||||
_upgradeOrganizationPlanCommand,
|
||||
_addSecretsManagerSubscriptionCommand,
|
||||
_cancelSubscriptionCommand,
|
||||
_subscriberQueries,
|
||||
_referenceEventService);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_sut?.Dispose();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineAutoData(true, false)]
|
||||
[InlineAutoData(false, true)]
|
||||
[InlineAutoData(false, false)]
|
||||
public async Task OrganizationsController_UserCanLeaveOrganizationThatDoesntProvideKeyConnector(
|
||||
bool keyConnectorEnabled, bool userUsesKeyConnector, Guid orgId, User user)
|
||||
{
|
||||
var ssoConfig = new SsoConfig
|
||||
{
|
||||
Id = default,
|
||||
Data = new SsoConfigurationData
|
||||
{
|
||||
MemberDecryptionType = keyConnectorEnabled
|
||||
? MemberDecryptionType.KeyConnector
|
||||
: MemberDecryptionType.MasterPassword
|
||||
}.Serialize(),
|
||||
Enabled = true,
|
||||
OrganizationId = orgId,
|
||||
};
|
||||
|
||||
user.UsesKeyConnector = userUsesKeyConnector;
|
||||
|
||||
_currentContext.OrganizationUser(orgId).Returns(true);
|
||||
_ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig);
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||
|
||||
await _organizationService.DeleteUserAsync(orgId, user.Id);
|
||||
await _organizationService.Received(1).DeleteUserAsync(orgId, user.Id);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task OrganizationsController_PostUpgrade_UserCannotEditSubscription_ThrowsNotFoundException(
|
||||
Guid organizationId,
|
||||
OrganizationUpgradeRequestModel model)
|
||||
{
|
||||
_currentContext.EditSubscription(organizationId).Returns(false);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.PostUpgrade(organizationId, model));
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task OrganizationsController_PostUpgrade_NonSMUpgrade_ReturnsCorrectResponse(
|
||||
Guid organizationId,
|
||||
OrganizationUpgradeRequestModel model,
|
||||
bool success,
|
||||
string paymentIntentClientSecret)
|
||||
{
|
||||
model.UseSecretsManager = false;
|
||||
|
||||
_currentContext.EditSubscription(organizationId).Returns(true);
|
||||
|
||||
_upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any<OrganizationUpgrade>())
|
||||
.Returns(new Tuple<bool, string>(success, paymentIntentClientSecret));
|
||||
|
||||
var response = await _sut.PostUpgrade(organizationId, model);
|
||||
|
||||
Assert.Equal(success, response.Success);
|
||||
Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task OrganizationsController_PostUpgrade_SMUpgrade_ProvidesAccess_ReturnsCorrectResponse(
|
||||
Guid organizationId,
|
||||
Guid userId,
|
||||
OrganizationUpgradeRequestModel model,
|
||||
bool success,
|
||||
string paymentIntentClientSecret,
|
||||
OrganizationUser organizationUser)
|
||||
{
|
||||
model.UseSecretsManager = true;
|
||||
organizationUser.AccessSecretsManager = false;
|
||||
|
||||
_currentContext.EditSubscription(organizationId).Returns(true);
|
||||
|
||||
_upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any<OrganizationUpgrade>())
|
||||
.Returns(new Tuple<bool, string>(success, paymentIntentClientSecret));
|
||||
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
|
||||
_organizationUserRepository.GetByOrganizationAsync(organizationId, userId).Returns(organizationUser);
|
||||
|
||||
var response = await _sut.PostUpgrade(organizationId, model);
|
||||
|
||||
Assert.Equal(success, response.Success);
|
||||
Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret);
|
||||
|
||||
await _organizationUserRepository.Received(1).ReplaceAsync(Arg.Is<OrganizationUser>(orgUser =>
|
||||
orgUser.Id == organizationUser.Id && orgUser.AccessSecretsManager == true));
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task OrganizationsController_PostUpgrade_SMUpgrade_NullOrgUser_ReturnsCorrectResponse(
|
||||
Guid organizationId,
|
||||
Guid userId,
|
||||
OrganizationUpgradeRequestModel model,
|
||||
bool success,
|
||||
string paymentIntentClientSecret)
|
||||
{
|
||||
model.UseSecretsManager = true;
|
||||
|
||||
_currentContext.EditSubscription(organizationId).Returns(true);
|
||||
|
||||
_upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any<OrganizationUpgrade>())
|
||||
.Returns(new Tuple<bool, string>(success, paymentIntentClientSecret));
|
||||
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
|
||||
_organizationUserRepository.GetByOrganizationAsync(organizationId, userId).ReturnsNull();
|
||||
|
||||
var response = await _sut.PostUpgrade(organizationId, model);
|
||||
|
||||
Assert.Equal(success, response.Success);
|
||||
Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret);
|
||||
|
||||
await _organizationUserRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any<OrganizationUser>());
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_NullOrg_ThrowsNotFoundException(
|
||||
Guid organizationId,
|
||||
SecretsManagerSubscribeRequestModel model)
|
||||
{
|
||||
_organizationRepository.GetByIdAsync(organizationId).ReturnsNull();
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.PostSubscribeSecretsManagerAsync(organizationId, model));
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_UserCannotEditSubscription_ThrowsNotFoundException(
|
||||
Guid organizationId,
|
||||
SecretsManagerSubscribeRequestModel model,
|
||||
Organization organization)
|
||||
{
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
_currentContext.EditSubscription(organizationId).Returns(false);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.PostSubscribeSecretsManagerAsync(organizationId, model));
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_ProvidesAccess_ReturnsCorrectResponse(
|
||||
Guid organizationId,
|
||||
SecretsManagerSubscribeRequestModel model,
|
||||
Organization organization,
|
||||
Guid userId,
|
||||
OrganizationUser organizationUser,
|
||||
OrganizationUserOrganizationDetails organizationUserOrganizationDetails)
|
||||
{
|
||||
organizationUser.AccessSecretsManager = false;
|
||||
|
||||
var ssoConfigurationData = new SsoConfigurationData
|
||||
{
|
||||
MemberDecryptionType = MemberDecryptionType.KeyConnector,
|
||||
KeyConnectorUrl = "https://example.com"
|
||||
};
|
||||
|
||||
organizationUserOrganizationDetails.Permissions = string.Empty;
|
||||
organizationUserOrganizationDetails.SsoConfig = ssoConfigurationData.Serialize();
|
||||
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
_currentContext.EditSubscription(organizationId).Returns(true);
|
||||
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
|
||||
_organizationUserRepository.GetByOrganizationAsync(organization.Id, userId).Returns(organizationUser);
|
||||
|
||||
_organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, OrganizationUserStatusType.Confirmed)
|
||||
.Returns(organizationUserOrganizationDetails);
|
||||
|
||||
var response = await _sut.PostSubscribeSecretsManagerAsync(organizationId, model);
|
||||
|
||||
Assert.Equal(response.Id, organizationUserOrganizationDetails.OrganizationId);
|
||||
Assert.Equal(response.Name, organizationUserOrganizationDetails.Name);
|
||||
|
||||
await _addSecretsManagerSubscriptionCommand.Received(1)
|
||||
.SignUpAsync(organization, model.AdditionalSmSeats, model.AdditionalServiceAccounts);
|
||||
await _organizationUserRepository.Received(1).ReplaceAsync(Arg.Is<OrganizationUser>(orgUser =>
|
||||
orgUser.Id == organizationUser.Id && orgUser.AccessSecretsManager == true));
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_NullOrgUser_ReturnsCorrectResponse(
|
||||
Guid organizationId,
|
||||
SecretsManagerSubscribeRequestModel model,
|
||||
Organization organization,
|
||||
Guid userId,
|
||||
OrganizationUserOrganizationDetails organizationUserOrganizationDetails)
|
||||
{
|
||||
var ssoConfigurationData = new SsoConfigurationData
|
||||
{
|
||||
MemberDecryptionType = MemberDecryptionType.KeyConnector,
|
||||
KeyConnectorUrl = "https://example.com"
|
||||
};
|
||||
|
||||
organizationUserOrganizationDetails.Permissions = string.Empty;
|
||||
organizationUserOrganizationDetails.SsoConfig = ssoConfigurationData.Serialize();
|
||||
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
_currentContext.EditSubscription(organizationId).Returns(true);
|
||||
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
|
||||
_organizationUserRepository.GetByOrganizationAsync(organization.Id, userId).ReturnsNull();
|
||||
|
||||
_organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, OrganizationUserStatusType.Confirmed)
|
||||
.Returns(organizationUserOrganizationDetails);
|
||||
|
||||
var response = await _sut.PostSubscribeSecretsManagerAsync(organizationId, model);
|
||||
|
||||
Assert.Equal(response.Id, organizationUserOrganizationDetails.OrganizationId);
|
||||
Assert.Equal(response.Name, organizationUserOrganizationDetails.Name);
|
||||
|
||||
await _addSecretsManagerSubscriptionCommand.Received(1)
|
||||
.SignUpAsync(organization, model.AdditionalSmSeats, model.AdditionalServiceAccounts);
|
||||
await _organizationUserRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any<OrganizationUser>());
|
||||
}
|
||||
}
|
@ -301,7 +301,7 @@ public class ProviderClientsControllerTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAsync_NoContent(
|
||||
public async Task UpdateAsync_AssignedSeats_NoContent(
|
||||
Guid providerId,
|
||||
Guid providerOrganizationId,
|
||||
UpdateClientOrganizationRequestBody requestBody,
|
||||
@ -333,6 +333,50 @@ public class ProviderClientsControllerTests
|
||||
organization,
|
||||
requestBody.AssignedSeats);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationRepository>().Received(1)
|
||||
.ReplaceAsync(Arg.Is<Organization>(org => org.Name == requestBody.Name));
|
||||
|
||||
Assert.IsType<Ok>(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAsync_Name_NoContent(
|
||||
Guid providerId,
|
||||
Guid providerOrganizationId,
|
||||
UpdateClientOrganizationRequestBody requestBody,
|
||||
Provider provider,
|
||||
ProviderOrganization providerOrganization,
|
||||
Organization organization,
|
||||
SutProvider<ProviderClientsController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(providerId)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(providerId)
|
||||
.Returns(provider);
|
||||
|
||||
sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganizationId)
|
||||
.Returns(providerOrganization);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(providerOrganization.OrganizationId)
|
||||
.Returns(organization);
|
||||
|
||||
requestBody.AssignedSeats = organization.Seats!.Value;
|
||||
|
||||
var result = await sutProvider.Sut.UpdateAsync(providerId, providerOrganizationId, requestBody);
|
||||
|
||||
await sutProvider.GetDependency<IAssignSeatsToClientOrganizationCommand>().DidNotReceiveWithAnyArgs()
|
||||
.AssignSeatsToClientOrganization(
|
||||
Arg.Any<Provider>(),
|
||||
Arg.Any<Organization>(),
|
||||
Arg.Any<int>());
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationRepository>().Received(1)
|
||||
.ReplaceAsync(Arg.Is<Organization>(org => org.Name == requestBody.Name));
|
||||
|
||||
Assert.IsType<Ok>(result);
|
||||
}
|
||||
#endregion
|
||||
|
@ -828,108 +828,169 @@ public class BulkCollectionAuthorizationHandlerTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData, CollectionCustomization]
|
||||
public async Task CanUpdateUsers_WithManageUsersCustomPermission_Success(
|
||||
SutProvider<BulkCollectionAuthorizationHandler> sutProvider,
|
||||
ICollection<Collection> collections,
|
||||
CurrentContextOrganization organization)
|
||||
public async Task CanUpdateUsers_WithManageUsersCustomPermission_V1Disabled_Success(
|
||||
SutProvider<BulkCollectionAuthorizationHandler> sutProvider, ICollection<Collection> collections,
|
||||
CurrentContextOrganization organization, Guid actingUserId)
|
||||
{
|
||||
var actingUserId = Guid.NewGuid();
|
||||
|
||||
organization.Type = OrganizationUserType.Custom;
|
||||
organization.Permissions = new Permissions
|
||||
{
|
||||
ManageUsers = true
|
||||
};
|
||||
|
||||
var operationsToTest = new[]
|
||||
{
|
||||
BulkCollectionOperations.ModifyUserAccess,
|
||||
};
|
||||
|
||||
foreach (var op in operationsToTest)
|
||||
{
|
||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(actingUserId);
|
||||
sutProvider.GetDependency<ICurrentContext>().GetOrganization(organization.Id).Returns(organization);
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1)
|
||||
.Returns(false);
|
||||
|
||||
var context = new AuthorizationHandlerContext(
|
||||
new[] { op },
|
||||
new[] { BulkCollectionOperations.ModifyUserAccess },
|
||||
new ClaimsPrincipal(),
|
||||
collections);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(context);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
|
||||
// Recreate the SUT to reset the mocks/dependencies between tests
|
||||
sutProvider.Recreate();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory, BitAutoData, CollectionCustomization]
|
||||
public async Task CanUpdateGroups_WithManageGroupsCustomPermission_Success(
|
||||
SutProvider<BulkCollectionAuthorizationHandler> sutProvider,
|
||||
ICollection<Collection> collections,
|
||||
CurrentContextOrganization organization)
|
||||
public async Task CanUpdateUsers_WithManageUsersCustomPermission_AllowAdminAccessIsTrue_Success(
|
||||
SutProvider<BulkCollectionAuthorizationHandler> sutProvider, ICollection<Collection> collections,
|
||||
CurrentContextOrganization organization, Guid actingUserId)
|
||||
{
|
||||
var actingUserId = Guid.NewGuid();
|
||||
organization.Type = OrganizationUserType.Custom;
|
||||
organization.Permissions = new Permissions
|
||||
{
|
||||
ManageUsers = true
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(actingUserId);
|
||||
sutProvider.GetDependency<ICurrentContext>().GetOrganization(organization.Id).Returns(organization);
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1)
|
||||
.Returns(true);
|
||||
sutProvider.GetDependency<IApplicationCacheService>().GetOrganizationAbilityAsync(organization.Id)
|
||||
.Returns(new OrganizationAbility { AllowAdminAccessToAllCollectionItems = true });
|
||||
|
||||
var context = new AuthorizationHandlerContext(
|
||||
new[] { BulkCollectionOperations.ModifyUserAccess },
|
||||
new ClaimsPrincipal(),
|
||||
collections);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(context);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData, CollectionCustomization]
|
||||
public async Task CanUpdateUsers_WithManageUsersCustomPermission_AllowAdminAccessIsFalse_Failure(
|
||||
SutProvider<BulkCollectionAuthorizationHandler> sutProvider, ICollection<Collection> collections,
|
||||
CurrentContextOrganization organization, Guid actingUserId)
|
||||
{
|
||||
organization.Type = OrganizationUserType.Custom;
|
||||
organization.Permissions = new Permissions
|
||||
{
|
||||
ManageUsers = true
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(actingUserId);
|
||||
sutProvider.GetDependency<ICurrentContext>().GetOrganization(organization.Id).Returns(organization);
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1)
|
||||
.Returns(true);
|
||||
sutProvider.GetDependency<IApplicationCacheService>().GetOrganizationAbilityAsync(organization.Id)
|
||||
.Returns(new OrganizationAbility { AllowAdminAccessToAllCollectionItems = false });
|
||||
|
||||
var context = new AuthorizationHandlerContext(
|
||||
new[] { BulkCollectionOperations.ModifyUserAccess },
|
||||
new ClaimsPrincipal(),
|
||||
collections);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(context);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData, CollectionCustomization]
|
||||
public async Task CanUpdateGroups_WithManageGroupsCustomPermission_V1Disabled_Success(
|
||||
SutProvider<BulkCollectionAuthorizationHandler> sutProvider, ICollection<Collection> collections,
|
||||
CurrentContextOrganization organization, Guid actingUserId)
|
||||
{
|
||||
organization.Type = OrganizationUserType.Custom;
|
||||
organization.Permissions = new Permissions
|
||||
{
|
||||
ManageGroups = true
|
||||
};
|
||||
|
||||
var operationsToTest = new[]
|
||||
{
|
||||
BulkCollectionOperations.ModifyGroupAccess,
|
||||
};
|
||||
|
||||
foreach (var op in operationsToTest)
|
||||
{
|
||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(actingUserId);
|
||||
sutProvider.GetDependency<ICurrentContext>().GetOrganization(organization.Id).Returns(organization);
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1)
|
||||
.Returns(false);
|
||||
|
||||
var context = new AuthorizationHandlerContext(
|
||||
new[] { op },
|
||||
new[] { BulkCollectionOperations.ModifyGroupAccess },
|
||||
new ClaimsPrincipal(),
|
||||
collections);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(context);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
|
||||
// Recreate the SUT to reset the mocks/dependencies between tests
|
||||
sutProvider.Recreate();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory, CollectionCustomization]
|
||||
[BitAutoData(OrganizationUserType.Admin)]
|
||||
[BitAutoData(OrganizationUserType.Owner)]
|
||||
public async Task CanDeleteAsync_WhenAdminOrOwner_Success(
|
||||
OrganizationUserType userType,
|
||||
Guid userId, SutProvider<BulkCollectionAuthorizationHandler> sutProvider,
|
||||
ICollection<Collection> collections,
|
||||
CurrentContextOrganization organization)
|
||||
[Theory, BitAutoData, CollectionCustomization]
|
||||
public async Task CanUpdateGroups_WithManageGroupsCustomPermission_AllowAdminAccessIsTrue_Success(
|
||||
SutProvider<BulkCollectionAuthorizationHandler> sutProvider, ICollection<Collection> collections,
|
||||
CurrentContextOrganization organization, Guid actingUserId)
|
||||
{
|
||||
organization.Type = userType;
|
||||
organization.Permissions = new Permissions();
|
||||
organization.Type = OrganizationUserType.Custom;
|
||||
organization.Permissions = new Permissions
|
||||
{
|
||||
ManageGroups = true
|
||||
};
|
||||
|
||||
ArrangeOrganizationAbility(sutProvider, organization, true);
|
||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(actingUserId);
|
||||
sutProvider.GetDependency<ICurrentContext>().GetOrganization(organization.Id).Returns(organization);
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1)
|
||||
.Returns(true);
|
||||
sutProvider.GetDependency<IApplicationCacheService>().GetOrganizationAbilityAsync(organization.Id)
|
||||
.Returns(new OrganizationAbility { AllowAdminAccessToAllCollectionItems = true });
|
||||
|
||||
var context = new AuthorizationHandlerContext(
|
||||
new[] { BulkCollectionOperations.Delete },
|
||||
new[] { BulkCollectionOperations.ModifyGroupAccess },
|
||||
new ClaimsPrincipal(),
|
||||
collections);
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(userId);
|
||||
sutProvider.GetDependency<ICurrentContext>().GetOrganization(organization.Id).Returns(organization);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(context);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData, CollectionCustomization]
|
||||
public async Task CanUpdateGroups_WithManageGroupsCustomPermission_AllowAdminAccessIsFalse_Failure(
|
||||
SutProvider<BulkCollectionAuthorizationHandler> sutProvider, ICollection<Collection> collections,
|
||||
CurrentContextOrganization organization, Guid actingUserId)
|
||||
{
|
||||
organization.Type = OrganizationUserType.Custom;
|
||||
organization.Permissions = new Permissions
|
||||
{
|
||||
ManageGroups = true
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(actingUserId);
|
||||
sutProvider.GetDependency<ICurrentContext>().GetOrganization(organization.Id).Returns(organization);
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1)
|
||||
.Returns(true);
|
||||
sutProvider.GetDependency<IApplicationCacheService>().GetOrganizationAbilityAsync(organization.Id)
|
||||
.Returns(new OrganizationAbility { AllowAdminAccessToAllCollectionItems = false });
|
||||
|
||||
var context = new AuthorizationHandlerContext(
|
||||
new[] { BulkCollectionOperations.ModifyGroupAccess },
|
||||
new ClaimsPrincipal(),
|
||||
collections);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(context);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData, CollectionCustomization]
|
||||
public async Task CanDeleteAsync_WithDeleteAnyCollectionPermission_Success(
|
||||
SutProvider<BulkCollectionAuthorizationHandler> sutProvider,
|
||||
@ -959,8 +1020,63 @@ public class BulkCollectionAuthorizationHandlerTests
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory, CollectionCustomization]
|
||||
[BitAutoData(OrganizationUserType.Admin)]
|
||||
[BitAutoData(OrganizationUserType.Owner)]
|
||||
public async Task CanDeleteAsync_WhenAdminOrOwner_AllowAdminAccessToAllCollectionItemsTrue_Success(
|
||||
OrganizationUserType userType,
|
||||
Guid userId, SutProvider<BulkCollectionAuthorizationHandler> sutProvider,
|
||||
ICollection<Collection> collections,
|
||||
CurrentContextOrganization organization)
|
||||
{
|
||||
organization.Type = userType;
|
||||
organization.Permissions = new Permissions();
|
||||
|
||||
ArrangeOrganizationAbility(sutProvider, organization, true);
|
||||
|
||||
var context = new AuthorizationHandlerContext(
|
||||
new[] { BulkCollectionOperations.Delete },
|
||||
new ClaimsPrincipal(),
|
||||
collections);
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(userId);
|
||||
sutProvider.GetDependency<ICurrentContext>().GetOrganization(organization.Id).Returns(organization);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(context);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory, CollectionCustomization]
|
||||
[BitAutoData(OrganizationUserType.Admin)]
|
||||
[BitAutoData(OrganizationUserType.Owner)]
|
||||
public async Task CanDeleteAsync_WhenAdminOrOwner_V1FlagDisabled_Success(
|
||||
OrganizationUserType userType,
|
||||
Guid userId, SutProvider<BulkCollectionAuthorizationHandler> sutProvider,
|
||||
ICollection<Collection> collections,
|
||||
CurrentContextOrganization organization)
|
||||
{
|
||||
organization.Type = userType;
|
||||
organization.Permissions = new Permissions();
|
||||
|
||||
ArrangeOrganizationAbility(sutProvider, organization, true, false);
|
||||
|
||||
var context = new AuthorizationHandlerContext(
|
||||
new[] { BulkCollectionOperations.Delete },
|
||||
new ClaimsPrincipal(),
|
||||
collections);
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(userId);
|
||||
sutProvider.GetDependency<ICurrentContext>().GetOrganization(organization.Id).Returns(organization);
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1).Returns(false);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(context);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData, CollectionCustomization]
|
||||
public async Task CanDeleteAsync_WithManageCollectionPermission_Success(
|
||||
public async Task CanDeleteAsync_WhenUser_LimitCollectionCreationDeletionFalse_WithCanManagePermission_Success(
|
||||
SutProvider<BulkCollectionAuthorizationHandler> sutProvider,
|
||||
ICollection<CollectionDetails> collections,
|
||||
CurrentContextOrganization organization)
|
||||
@ -991,6 +1107,184 @@ public class BulkCollectionAuthorizationHandlerTests
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory, CollectionCustomization]
|
||||
[BitAutoData(OrganizationUserType.Admin)]
|
||||
[BitAutoData(OrganizationUserType.Owner)]
|
||||
[BitAutoData(OrganizationUserType.User)]
|
||||
public async Task CanDeleteAsync_LimitCollectionCreationDeletionFalse_AllowAdminAccessToAllCollectionItemsFalse_WithCanManagePermission_Success(
|
||||
OrganizationUserType userType,
|
||||
SutProvider<BulkCollectionAuthorizationHandler> sutProvider,
|
||||
ICollection<CollectionDetails> collections,
|
||||
CurrentContextOrganization organization)
|
||||
{
|
||||
var actingUserId = Guid.NewGuid();
|
||||
|
||||
organization.Type = userType;
|
||||
organization.Permissions = new Permissions();
|
||||
|
||||
ArrangeOrganizationAbility(sutProvider, organization, false, false);
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(actingUserId);
|
||||
sutProvider.GetDependency<ICurrentContext>().GetOrganization(organization.Id).Returns(organization);
|
||||
sutProvider.GetDependency<ICollectionRepository>().GetManyByUserIdAsync(actingUserId, Arg.Any<bool>()).Returns(collections);
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1).Returns(true);
|
||||
|
||||
foreach (var c in collections)
|
||||
{
|
||||
c.Manage = true;
|
||||
}
|
||||
|
||||
var context = new AuthorizationHandlerContext(
|
||||
new[] { BulkCollectionOperations.Delete },
|
||||
new ClaimsPrincipal(),
|
||||
collections);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(context);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory, CollectionCustomization]
|
||||
[BitAutoData(OrganizationUserType.Admin)]
|
||||
[BitAutoData(OrganizationUserType.Owner)]
|
||||
public async Task CanDeleteAsync_WhenAdminOrOwner_LimitCollectionCreationDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_WithCanManagePermission_Success(
|
||||
OrganizationUserType userType,
|
||||
SutProvider<BulkCollectionAuthorizationHandler> sutProvider,
|
||||
ICollection<CollectionDetails> collections,
|
||||
CurrentContextOrganization organization)
|
||||
{
|
||||
var actingUserId = Guid.NewGuid();
|
||||
|
||||
organization.Type = userType;
|
||||
organization.Permissions = new Permissions();
|
||||
|
||||
ArrangeOrganizationAbility(sutProvider, organization, true, false);
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(actingUserId);
|
||||
sutProvider.GetDependency<ICurrentContext>().GetOrganization(organization.Id).Returns(organization);
|
||||
sutProvider.GetDependency<ICollectionRepository>().GetManyByUserIdAsync(actingUserId, Arg.Any<bool>()).Returns(collections);
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1).Returns(true);
|
||||
|
||||
foreach (var c in collections)
|
||||
{
|
||||
c.Manage = true;
|
||||
}
|
||||
|
||||
var context = new AuthorizationHandlerContext(
|
||||
new[] { BulkCollectionOperations.Delete },
|
||||
new ClaimsPrincipal(),
|
||||
collections);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(context);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory, CollectionCustomization]
|
||||
[BitAutoData(OrganizationUserType.Admin)]
|
||||
[BitAutoData(OrganizationUserType.Owner)]
|
||||
public async Task CanDeleteAsync_WhenAdminOrOwner_LimitCollectionCreationDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_WithoutCanManagePermission_Failure(
|
||||
OrganizationUserType userType,
|
||||
SutProvider<BulkCollectionAuthorizationHandler> sutProvider,
|
||||
ICollection<CollectionDetails> collections,
|
||||
CurrentContextOrganization organization)
|
||||
{
|
||||
var actingUserId = Guid.NewGuid();
|
||||
|
||||
organization.Type = userType;
|
||||
organization.Permissions = new Permissions();
|
||||
|
||||
ArrangeOrganizationAbility(sutProvider, organization, true, false);
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(actingUserId);
|
||||
sutProvider.GetDependency<ICurrentContext>().GetOrganization(organization.Id).Returns(organization);
|
||||
sutProvider.GetDependency<ICollectionRepository>().GetManyByUserIdAsync(actingUserId, Arg.Any<bool>()).Returns(collections);
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1).Returns(true);
|
||||
sutProvider.GetDependency<ICurrentContext>().ProviderUserForOrgAsync(Arg.Any<Guid>()).Returns(false);
|
||||
|
||||
foreach (var c in collections)
|
||||
{
|
||||
c.Manage = false;
|
||||
}
|
||||
|
||||
var context = new AuthorizationHandlerContext(
|
||||
new[] { BulkCollectionOperations.Delete },
|
||||
new ClaimsPrincipal(),
|
||||
collections);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(context);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData, CollectionCustomization]
|
||||
public async Task CanDeleteAsync_WhenUser_LimitCollectionCreationDeletionTrue_AllowAdminAccessToAllCollectionItemsTrue_Failure(
|
||||
SutProvider<BulkCollectionAuthorizationHandler> sutProvider,
|
||||
ICollection<CollectionDetails> collections,
|
||||
CurrentContextOrganization organization)
|
||||
{
|
||||
var actingUserId = Guid.NewGuid();
|
||||
|
||||
organization.Type = OrganizationUserType.User;
|
||||
organization.Permissions = new Permissions();
|
||||
|
||||
ArrangeOrganizationAbility(sutProvider, organization, true);
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(actingUserId);
|
||||
sutProvider.GetDependency<ICurrentContext>().GetOrganization(organization.Id).Returns(organization);
|
||||
sutProvider.GetDependency<ICollectionRepository>().GetManyByUserIdAsync(actingUserId, Arg.Any<bool>()).Returns(collections);
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1).Returns(true);
|
||||
sutProvider.GetDependency<ICurrentContext>().ProviderUserForOrgAsync(Arg.Any<Guid>()).Returns(false);
|
||||
|
||||
foreach (var c in collections)
|
||||
{
|
||||
c.Manage = true;
|
||||
}
|
||||
|
||||
var context = new AuthorizationHandlerContext(
|
||||
new[] { BulkCollectionOperations.Delete },
|
||||
new ClaimsPrincipal(),
|
||||
collections);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(context);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData, CollectionCustomization]
|
||||
public async Task CanDeleteAsync_WhenUser_LimitCollectionCreationDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_Failure(
|
||||
SutProvider<BulkCollectionAuthorizationHandler> sutProvider,
|
||||
ICollection<CollectionDetails> collections,
|
||||
CurrentContextOrganization organization)
|
||||
{
|
||||
var actingUserId = Guid.NewGuid();
|
||||
|
||||
organization.Type = OrganizationUserType.User;
|
||||
organization.Permissions = new Permissions();
|
||||
|
||||
ArrangeOrganizationAbility(sutProvider, organization, true, false);
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(actingUserId);
|
||||
sutProvider.GetDependency<ICurrentContext>().GetOrganization(organization.Id).Returns(organization);
|
||||
sutProvider.GetDependency<ICollectionRepository>().GetManyByUserIdAsync(actingUserId, Arg.Any<bool>()).Returns(collections);
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1).Returns(true);
|
||||
sutProvider.GetDependency<ICurrentContext>().ProviderUserForOrgAsync(Arg.Any<Guid>()).Returns(false);
|
||||
|
||||
foreach (var c in collections)
|
||||
{
|
||||
c.Manage = true;
|
||||
}
|
||||
|
||||
var context = new AuthorizationHandlerContext(
|
||||
new[] { BulkCollectionOperations.Delete },
|
||||
new ClaimsPrincipal(),
|
||||
collections);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(context);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory, CollectionCustomization]
|
||||
[BitAutoData(OrganizationUserType.User)]
|
||||
[BitAutoData(OrganizationUserType.Custom)]
|
||||
@ -1102,7 +1396,8 @@ public class BulkCollectionAuthorizationHandlerTests
|
||||
{ collections.First().OrganizationId,
|
||||
new OrganizationAbility
|
||||
{
|
||||
LimitCollectionCreationDeletion = true
|
||||
LimitCollectionCreationDeletion = true,
|
||||
AllowAdminAccessToAllCollectionItems = true
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -1177,12 +1472,14 @@ public class BulkCollectionAuthorizationHandlerTests
|
||||
|
||||
private static void ArrangeOrganizationAbility(
|
||||
SutProvider<BulkCollectionAuthorizationHandler> sutProvider,
|
||||
CurrentContextOrganization organization, bool limitCollectionCreationDeletion)
|
||||
CurrentContextOrganization organization, bool limitCollectionCreationDeletion,
|
||||
bool allowAdminAccessToAllCollectionItems = true)
|
||||
{
|
||||
var organizationAbility = new OrganizationAbility();
|
||||
organizationAbility.Id = organization.Id;
|
||||
organizationAbility.FlexibleCollections = true;
|
||||
organizationAbility.LimitCollectionCreationDeletion = limitCollectionCreationDeletion;
|
||||
organizationAbility.AllowAdminAccessToAllCollectionItems = allowAdminAccessToAllCollectionItems;
|
||||
|
||||
sutProvider.GetDependency<IApplicationCacheService>().GetOrganizationAbilityAsync(organizationAbility.Id)
|
||||
.Returns(organizationAbility);
|
||||
|
@ -3,9 +3,11 @@ using Bit.Api.Vault.Controllers;
|
||||
using Bit.Api.Vault.Models.Request;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Core.Vault.Models.Data;
|
||||
@ -14,7 +16,9 @@ using Bit.Core.Vault.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
using Xunit;
|
||||
using CipherType = Bit.Core.Vault.Enums.CipherType;
|
||||
|
||||
namespace Bit.Api.Test.Controllers;
|
||||
|
||||
@ -50,6 +54,92 @@ public class CiphersControllerTests
|
||||
Assert.Equal(isFavorite, result.Favorite);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task PutCollections_vNextShouldThrowExceptionWhenCipherIsNullOrNoOrgValue(Guid id, CipherCollectionsRequestModel model, Guid userId,
|
||||
SutProvider<CiphersController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IUserService>().GetProperUserId(default).Returns(userId);
|
||||
sutProvider.GetDependency<ICurrentContext>().OrganizationUser(Guid.NewGuid()).Returns(false);
|
||||
sutProvider.GetDependency<ICipherRepository>().GetByIdAsync(id, userId, true).ReturnsNull();
|
||||
|
||||
var requestAction = async () => await sutProvider.Sut.PutCollections_vNext(id, model);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(requestAction);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task PutCollections_vNextShouldSaveUpdatedCipher(Guid id, CipherCollectionsRequestModel model, Guid userId, SutProvider<CiphersController> sutProvider)
|
||||
{
|
||||
SetupUserAndOrgMocks(id, userId, sutProvider);
|
||||
var cipherDetails = CreateCipherDetailsMock(id, userId);
|
||||
sutProvider.GetDependency<ICipherRepository>().GetByIdAsync(id, userId, true).ReturnsForAnyArgs(cipherDetails);
|
||||
|
||||
sutProvider.GetDependency<ICollectionCipherRepository>().GetManyByUserIdCipherIdAsync(userId, id, Arg.Any<bool>()).Returns((ICollection<CollectionCipher>)new List<CollectionCipher>());
|
||||
var cipherService = sutProvider.GetDependency<ICipherService>();
|
||||
|
||||
await sutProvider.Sut.PutCollections_vNext(id, model);
|
||||
|
||||
await cipherService.ReceivedWithAnyArgs().SaveCollectionsAsync(default, default, default, default);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task PutCollections_vNextReturnOptionalDetailsCipherUnavailableFalse(Guid id, CipherCollectionsRequestModel model, Guid userId, SutProvider<CiphersController> sutProvider)
|
||||
{
|
||||
SetupUserAndOrgMocks(id, userId, sutProvider);
|
||||
var cipherDetails = CreateCipherDetailsMock(id, userId);
|
||||
sutProvider.GetDependency<ICipherRepository>().GetByIdAsync(id, userId, true).ReturnsForAnyArgs(cipherDetails);
|
||||
|
||||
sutProvider.GetDependency<ICollectionCipherRepository>().GetManyByUserIdCipherIdAsync(userId, id, Arg.Any<bool>()).Returns((ICollection<CollectionCipher>)new List<CollectionCipher>());
|
||||
|
||||
var result = await sutProvider.Sut.PutCollections_vNext(id, model);
|
||||
|
||||
Assert.IsType<OptionalCipherDetailsResponseModel>(result);
|
||||
Assert.False(result.Unavailable);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task PutCollections_vNextReturnOptionalDetailsCipherUnavailableTrue(Guid id, CipherCollectionsRequestModel model, Guid userId, SutProvider<CiphersController> sutProvider)
|
||||
{
|
||||
SetupUserAndOrgMocks(id, userId, sutProvider);
|
||||
var cipherDetails = CreateCipherDetailsMock(id, userId);
|
||||
sutProvider.GetDependency<ICipherRepository>().GetByIdAsync(id, userId, true).ReturnsForAnyArgs(cipherDetails, [(CipherDetails)null]);
|
||||
|
||||
sutProvider.GetDependency<ICollectionCipherRepository>().GetManyByUserIdCipherIdAsync(userId, id, Arg.Any<bool>()).Returns((ICollection<CollectionCipher>)new List<CollectionCipher>());
|
||||
|
||||
var result = await sutProvider.Sut.PutCollections_vNext(id, model);
|
||||
|
||||
Assert.IsType<OptionalCipherDetailsResponseModel>(result);
|
||||
Assert.True(result.Unavailable);
|
||||
}
|
||||
|
||||
private void SetupUserAndOrgMocks(Guid id, Guid userId, SutProvider<CiphersController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
|
||||
sutProvider.GetDependency<ICurrentContext>().OrganizationUser(default).ReturnsForAnyArgs(true);
|
||||
sutProvider.GetDependency<ICollectionCipherRepository>().GetManyByUserIdCipherIdAsync(userId, id, Arg.Any<bool>()).Returns(new List<CollectionCipher>());
|
||||
}
|
||||
|
||||
private CipherDetails CreateCipherDetailsMock(Guid id, Guid userId)
|
||||
{
|
||||
return new CipherDetails
|
||||
{
|
||||
Id = id,
|
||||
UserId = userId,
|
||||
OrganizationId = Guid.NewGuid(),
|
||||
Type = CipherType.Login,
|
||||
Data = @"
|
||||
{
|
||||
""Uris"": [
|
||||
{
|
||||
""Uri"": ""https://bitwarden.com""
|
||||
}
|
||||
],
|
||||
""Username"": ""testuser"",
|
||||
""Password"": ""securepassword123""
|
||||
}"
|
||||
};
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(OrganizationUserType.Admin, true, true)]
|
||||
[BitAutoData(OrganizationUserType.Owner, true, true)]
|
||||
|
@ -0,0 +1,40 @@
|
||||
using System.Reflection;
|
||||
using AutoFixture;
|
||||
using AutoFixture.Xunit2;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
|
||||
namespace Bit.Core.Test.AdminConsole.AutoFixture;
|
||||
|
||||
internal class OrganizationUserPolicyDetailsCustomization : ICustomization
|
||||
{
|
||||
public PolicyType Type { get; set; }
|
||||
|
||||
public OrganizationUserPolicyDetailsCustomization(PolicyType type)
|
||||
{
|
||||
Type = type;
|
||||
}
|
||||
|
||||
public void Customize(IFixture fixture)
|
||||
{
|
||||
fixture.Customize<OrganizationUserPolicyDetails>(composer => composer
|
||||
.With(o => o.OrganizationId, Guid.NewGuid())
|
||||
.With(o => o.PolicyType, Type)
|
||||
.With(o => o.PolicyEnabled, true));
|
||||
}
|
||||
}
|
||||
|
||||
public class OrganizationUserPolicyDetailsAttribute : CustomizeAttribute
|
||||
{
|
||||
private readonly PolicyType _type;
|
||||
|
||||
public OrganizationUserPolicyDetailsAttribute(PolicyType type)
|
||||
{
|
||||
_type = type;
|
||||
}
|
||||
|
||||
public override ICustomization GetCustomization(ParameterInfo parameter)
|
||||
{
|
||||
return new OrganizationUserPolicyDetailsCustomization(_type);
|
||||
}
|
||||
}
|
@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
@ -39,7 +40,6 @@ using NSubstitute.ReturnsExtensions;
|
||||
using Xunit;
|
||||
using Organization = Bit.Core.AdminConsole.Entities.Organization;
|
||||
using OrganizationUser = Bit.Core.Entities.OrganizationUser;
|
||||
using Policy = Bit.Core.AdminConsole.Entities.Policy;
|
||||
|
||||
namespace Bit.Core.Test.Services;
|
||||
|
||||
@ -252,7 +252,7 @@ public class OrganizationServiceTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(PlanType.FamiliesAnnually)]
|
||||
public async Task SignUp_WithFlexibleCollections_SetsAccessAllToFalse
|
||||
public async Task SignUp_EnablesFlexibleCollectionsFeatures
|
||||
(PlanType planType, OrganizationSignup signup, SutProvider<OrganizationService> sutProvider)
|
||||
{
|
||||
signup.Plan = planType;
|
||||
@ -261,10 +261,6 @@ public class OrganizationServiceTests
|
||||
signup.PremiumAccessAddon = false;
|
||||
signup.UseSecretsManager = false;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup)
|
||||
.Returns(true);
|
||||
|
||||
// Extract orgUserId when created
|
||||
Guid? orgUserId = null;
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
@ -272,6 +268,10 @@ public class OrganizationServiceTests
|
||||
|
||||
var result = await sutProvider.Sut.SignUpAsync(signup);
|
||||
|
||||
// Assert: Organization.FlexibleCollections is enabled
|
||||
await sutProvider.GetDependency<IOrganizationRepository>().Received(1)
|
||||
.CreateAsync(Arg.Is<Organization>(o => o.FlexibleCollections));
|
||||
|
||||
// Assert: AccessAll is not used
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).CreateAsync(
|
||||
Arg.Is<OrganizationUser>(o =>
|
||||
@ -295,33 +295,6 @@ public class OrganizationServiceTests
|
||||
Assert.NotNull(result.Item2);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(PlanType.FamiliesAnnually)]
|
||||
public async Task SignUp_WithoutFlexibleCollections_SetsAccessAllToTrue
|
||||
(PlanType planType, OrganizationSignup signup, SutProvider<OrganizationService> sutProvider)
|
||||
{
|
||||
signup.Plan = planType;
|
||||
var plan = StaticStore.GetPlan(signup.Plan);
|
||||
signup.AdditionalSeats = 0;
|
||||
signup.PaymentMethodType = PaymentMethodType.Card;
|
||||
signup.PremiumAccessAddon = false;
|
||||
signup.UseSecretsManager = false;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup)
|
||||
.Returns(false);
|
||||
|
||||
var result = await sutProvider.Sut.SignUpAsync(signup);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).CreateAsync(
|
||||
Arg.Is<OrganizationUser>(o =>
|
||||
o.UserId == signup.Owner.Id &&
|
||||
o.AccessAll == true));
|
||||
|
||||
Assert.NotNull(result.Item1);
|
||||
Assert.NotNull(result.Item2);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually)]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly)]
|
||||
@ -1441,15 +1414,15 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
|
||||
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ConfirmUser_SingleOrgPolicy(Organization org, OrganizationUser confirmingUser,
|
||||
public async Task ConfirmUser_AsUser_SingleOrgPolicy_AppliedFromConfirmingOrg_Throws(Organization org, OrganizationUser confirmingUser,
|
||||
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
|
||||
OrganizationUser orgUserAnotherOrg, [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
||||
OrganizationUser orgUserAnotherOrg, [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy,
|
||||
string key, SutProvider<OrganizationService> sutProvider)
|
||||
{
|
||||
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
var policyRepository = sutProvider.GetDependency<IPolicyRepository>();
|
||||
var userRepository = sutProvider.GetDependency<IUserRepository>();
|
||||
var policyService = sutProvider.GetDependency<IPolicyService>();
|
||||
var userService = Substitute.For<IUserService>();
|
||||
|
||||
org.PlanType = PlanType.EnterpriseAnnually;
|
||||
@ -1460,23 +1433,84 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
|
||||
organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg });
|
||||
organizationRepository.GetByIdAsync(org.Id).Returns(org);
|
||||
userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
|
||||
policyRepository.GetManyByOrganizationIdAsync(org.Id).Returns(new[] { singleOrgPolicy });
|
||||
singleOrgPolicy.OrganizationId = org.Id;
|
||||
policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg).Returns(new[] { singleOrgPolicy });
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService));
|
||||
Assert.Contains("User is a member of another organization.", exception.Message);
|
||||
Assert.Contains("Cannot confirm this member to the organization until they leave or remove all other organizations.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ConfirmUser_TwoFactorPolicy(Organization org, OrganizationUser confirmingUser,
|
||||
public async Task ConfirmUser_AsUser_SingleOrgPolicy_AppliedFromOtherOrg_Throws(Organization org, OrganizationUser confirmingUser,
|
||||
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
|
||||
OrganizationUser orgUserAnotherOrg, [Policy(PolicyType.TwoFactorAuthentication)] Policy twoFactorPolicy,
|
||||
OrganizationUser orgUserAnotherOrg, [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy,
|
||||
string key, SutProvider<OrganizationService> sutProvider)
|
||||
{
|
||||
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
var policyRepository = sutProvider.GetDependency<IPolicyRepository>();
|
||||
var userRepository = sutProvider.GetDependency<IUserRepository>();
|
||||
var policyService = sutProvider.GetDependency<IPolicyService>();
|
||||
var userService = Substitute.For<IUserService>();
|
||||
|
||||
org.PlanType = PlanType.EnterpriseAnnually;
|
||||
orgUser.Status = OrganizationUserStatusType.Accepted;
|
||||
orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id;
|
||||
orgUser.UserId = orgUserAnotherOrg.UserId = user.Id;
|
||||
organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
|
||||
organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg });
|
||||
organizationRepository.GetByIdAsync(org.Id).Returns(org);
|
||||
userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
|
||||
singleOrgPolicy.OrganizationId = orgUserAnotherOrg.Id;
|
||||
policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg).Returns(new[] { singleOrgPolicy });
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService));
|
||||
Assert.Contains("Cannot confirm this member to the organization because they are in another organization which forbids it.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(OrganizationUserType.Admin)]
|
||||
[BitAutoData(OrganizationUserType.Owner)]
|
||||
public async Task ConfirmUser_AsOwnerOrAdmin_SingleOrgPolicy_ExcludedViaUserType_Success(
|
||||
OrganizationUserType userType, Organization org, OrganizationUser confirmingUser,
|
||||
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
|
||||
OrganizationUser orgUserAnotherOrg,
|
||||
string key, SutProvider<OrganizationService> sutProvider)
|
||||
{
|
||||
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
var userRepository = sutProvider.GetDependency<IUserRepository>();
|
||||
var userService = Substitute.For<IUserService>();
|
||||
|
||||
org.PlanType = PlanType.EnterpriseAnnually;
|
||||
orgUser.Type = userType;
|
||||
orgUser.Status = OrganizationUserStatusType.Accepted;
|
||||
orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id;
|
||||
orgUser.UserId = orgUserAnotherOrg.UserId = user.Id;
|
||||
organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
|
||||
organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg });
|
||||
organizationRepository.GetByIdAsync(org.Id).Returns(org);
|
||||
userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
|
||||
|
||||
await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService);
|
||||
|
||||
await sutProvider.GetDependency<IEventService>().Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed);
|
||||
await sutProvider.GetDependency<IMailService>().Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email);
|
||||
await organizationUserRepository.Received(1).ReplaceManyAsync(Arg.Is<List<OrganizationUser>>(users => users.Contains(orgUser) && users.Count == 1));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ConfirmUser_TwoFactorPolicy_NotEnabled_Throws(Organization org, OrganizationUser confirmingUser,
|
||||
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
|
||||
OrganizationUser orgUserAnotherOrg,
|
||||
[OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy,
|
||||
string key, SutProvider<OrganizationService> sutProvider)
|
||||
{
|
||||
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
var userRepository = sutProvider.GetDependency<IUserRepository>();
|
||||
var policyService = sutProvider.GetDependency<IPolicyService>();
|
||||
var userService = Substitute.For<IUserService>();
|
||||
|
||||
org.PlanType = PlanType.EnterpriseAnnually;
|
||||
@ -1486,7 +1520,8 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
|
||||
organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg });
|
||||
organizationRepository.GetByIdAsync(org.Id).Returns(org);
|
||||
userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
|
||||
policyRepository.GetManyByOrganizationIdAsync(org.Id).Returns(new[] { twoFactorPolicy });
|
||||
twoFactorPolicy.OrganizationId = org.Id;
|
||||
policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy });
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService));
|
||||
@ -1494,15 +1529,15 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ConfirmUser_Success(Organization org, OrganizationUser confirmingUser,
|
||||
public async Task ConfirmUser_TwoFactorPolicy_Enabled_Success(Organization org, OrganizationUser confirmingUser,
|
||||
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
|
||||
[Policy(PolicyType.TwoFactorAuthentication)] Policy twoFactorPolicy,
|
||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, string key, SutProvider<OrganizationService> sutProvider)
|
||||
[OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy,
|
||||
string key, SutProvider<OrganizationService> sutProvider)
|
||||
{
|
||||
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
var policyRepository = sutProvider.GetDependency<IPolicyRepository>();
|
||||
var userRepository = sutProvider.GetDependency<IUserRepository>();
|
||||
var policyService = sutProvider.GetDependency<IPolicyService>();
|
||||
var userService = Substitute.For<IUserService>();
|
||||
|
||||
org.PlanType = PlanType.EnterpriseAnnually;
|
||||
@ -1511,7 +1546,8 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
|
||||
organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
|
||||
organizationRepository.GetByIdAsync(org.Id).Returns(org);
|
||||
userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
|
||||
policyRepository.GetManyByOrganizationIdAsync(org.Id).Returns(new[] { twoFactorPolicy, singleOrgPolicy });
|
||||
twoFactorPolicy.OrganizationId = org.Id;
|
||||
policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy });
|
||||
userService.TwoFactorIsEnabledAsync(user).Returns(true);
|
||||
|
||||
await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService);
|
||||
@ -1524,13 +1560,14 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
|
||||
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser2,
|
||||
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser3,
|
||||
OrganizationUser anotherOrgUser, User user1, User user2, User user3,
|
||||
[Policy(PolicyType.TwoFactorAuthentication)] Policy twoFactorPolicy,
|
||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, string key, SutProvider<OrganizationService> sutProvider)
|
||||
[OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy,
|
||||
[OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy,
|
||||
string key, SutProvider<OrganizationService> sutProvider)
|
||||
{
|
||||
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
var policyRepository = sutProvider.GetDependency<IPolicyRepository>();
|
||||
var userRepository = sutProvider.GetDependency<IUserRepository>();
|
||||
var policyService = sutProvider.GetDependency<IPolicyService>();
|
||||
var userService = Substitute.For<IUserService>();
|
||||
|
||||
org.PlanType = PlanType.EnterpriseAnnually;
|
||||
@ -1543,10 +1580,14 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
|
||||
organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(orgUsers);
|
||||
organizationRepository.GetByIdAsync(org.Id).Returns(org);
|
||||
userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user1, user2, user3 });
|
||||
policyRepository.GetManyByOrganizationIdAsync(org.Id).Returns(new[] { twoFactorPolicy, singleOrgPolicy });
|
||||
twoFactorPolicy.OrganizationId = org.Id;
|
||||
policyService.GetPoliciesApplicableToUserAsync(Arg.Any<Guid>(), PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy });
|
||||
userService.TwoFactorIsEnabledAsync(user1).Returns(true);
|
||||
userService.TwoFactorIsEnabledAsync(user2).Returns(false);
|
||||
userService.TwoFactorIsEnabledAsync(user3).Returns(true);
|
||||
singleOrgPolicy.OrganizationId = org.Id;
|
||||
policyService.GetPoliciesApplicableToUserAsync(user3.Id, PolicyType.SingleOrg)
|
||||
.Returns(new[] { singleOrgPolicy });
|
||||
organizationUserRepository.GetManyByManyUsersAsync(default)
|
||||
.ReturnsForAnyArgs(new[] { orgUser1, orgUser2, orgUser3, anotherOrgUser });
|
||||
|
||||
@ -1554,7 +1595,7 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
|
||||
var result = await sutProvider.Sut.ConfirmUsersAsync(confirmingUser.OrganizationId, keys, confirmingUser.Id, userService);
|
||||
Assert.Contains("", result[0].Item2);
|
||||
Assert.Contains("User does not have two-step login enabled.", result[1].Item2);
|
||||
Assert.Contains("User is a member of another organization.", result[2].Item2);
|
||||
Assert.Contains("Cannot confirm this member to the organization until they leave or remove all other organizations.", result[2].Item2);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
|
@ -0,0 +1,99 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Models.Business;
|
||||
|
||||
public class SeatSubscriptionUpdateTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly2019)]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly2020)]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually2019)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually2020)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually)]
|
||||
[BitAutoData(PlanType.TeamsMonthly2019)]
|
||||
[BitAutoData(PlanType.TeamsMonthly2020)]
|
||||
[BitAutoData(PlanType.TeamsMonthly)]
|
||||
[BitAutoData(PlanType.TeamsAnnually2019)]
|
||||
[BitAutoData(PlanType.TeamsAnnually2020)]
|
||||
[BitAutoData(PlanType.TeamsAnnually)]
|
||||
[BitAutoData(PlanType.TeamsStarter)]
|
||||
|
||||
public void UpgradeItemsOptions_ReturnsCorrectOptions(PlanType planType, Organization organization)
|
||||
{
|
||||
var plan = StaticStore.GetPlan(planType);
|
||||
organization.PlanType = planType;
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new ()
|
||||
{
|
||||
Id = "subscription_item",
|
||||
Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId },
|
||||
Quantity = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
var update = new SeatSubscriptionUpdate(organization, plan, 100);
|
||||
|
||||
var options = update.UpgradeItemsOptions(subscription);
|
||||
|
||||
Assert.Single(options);
|
||||
Assert.Equal(plan.PasswordManager.StripeSeatPlanId, options[0].Plan);
|
||||
Assert.Equal(100, options[0].Quantity);
|
||||
Assert.Null(options[0].Deleted);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly2019)]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly2020)]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually2019)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually2020)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually)]
|
||||
[BitAutoData(PlanType.TeamsMonthly2019)]
|
||||
[BitAutoData(PlanType.TeamsMonthly2020)]
|
||||
[BitAutoData(PlanType.TeamsMonthly)]
|
||||
[BitAutoData(PlanType.TeamsAnnually2019)]
|
||||
[BitAutoData(PlanType.TeamsAnnually2020)]
|
||||
[BitAutoData(PlanType.TeamsAnnually)]
|
||||
public void RevertItemsOptions_ReturnsCorrectOptions(PlanType planType, Organization organization)
|
||||
{
|
||||
var plan = StaticStore.GetPlan(planType);
|
||||
organization.PlanType = planType;
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new ()
|
||||
{
|
||||
Id = "subscription_item",
|
||||
Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId },
|
||||
Quantity = 100
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
var update = new SeatSubscriptionUpdate(organization, plan, 100);
|
||||
update.UpgradeItemsOptions(subscription);
|
||||
|
||||
var options = update.RevertItemsOptions(subscription);
|
||||
|
||||
Assert.Single(options);
|
||||
Assert.Equal(plan.PasswordManager.StripeSeatPlanId, options[0].Plan);
|
||||
Assert.Equal(organization.Seats, options[0].Quantity);
|
||||
Assert.Null(options[0].Deleted);
|
||||
}
|
||||
}
|
@ -0,0 +1,100 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Models.Business;
|
||||
|
||||
public class ServiceAccountSubscriptionUpdateTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly2019)]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly2020)]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually2019)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually2020)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually)]
|
||||
[BitAutoData(PlanType.TeamsMonthly2019)]
|
||||
[BitAutoData(PlanType.TeamsMonthly2020)]
|
||||
[BitAutoData(PlanType.TeamsMonthly)]
|
||||
[BitAutoData(PlanType.TeamsAnnually2019)]
|
||||
[BitAutoData(PlanType.TeamsAnnually2020)]
|
||||
[BitAutoData(PlanType.TeamsAnnually)]
|
||||
[BitAutoData(PlanType.TeamsStarter)]
|
||||
|
||||
public void UpgradeItemsOptions_ReturnsCorrectOptions(PlanType planType, Organization organization)
|
||||
{
|
||||
var plan = StaticStore.GetPlan(planType);
|
||||
organization.PlanType = planType;
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new ()
|
||||
{
|
||||
Id = "subscription_item",
|
||||
Price = new Price { Id = plan.SecretsManager.StripeServiceAccountPlanId },
|
||||
Quantity = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
var update = new ServiceAccountSubscriptionUpdate(organization, plan, 3);
|
||||
|
||||
var options = update.UpgradeItemsOptions(subscription);
|
||||
|
||||
Assert.Single(options);
|
||||
Assert.Equal(plan.SecretsManager.StripeServiceAccountPlanId, options[0].Plan);
|
||||
Assert.Equal(3, options[0].Quantity);
|
||||
Assert.Null(options[0].Deleted);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly2019)]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly2020)]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually2019)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually2020)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually)]
|
||||
[BitAutoData(PlanType.TeamsMonthly2019)]
|
||||
[BitAutoData(PlanType.TeamsMonthly2020)]
|
||||
[BitAutoData(PlanType.TeamsMonthly)]
|
||||
[BitAutoData(PlanType.TeamsAnnually2019)]
|
||||
[BitAutoData(PlanType.TeamsAnnually2020)]
|
||||
[BitAutoData(PlanType.TeamsAnnually)]
|
||||
public void RevertItemsOptions_ReturnsCorrectOptions(PlanType planType, Organization organization)
|
||||
{
|
||||
var plan = StaticStore.GetPlan(planType);
|
||||
organization.PlanType = planType;
|
||||
var quantity = 5;
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new ()
|
||||
{
|
||||
Id = "subscription_item",
|
||||
Price = new Price { Id = plan.SecretsManager.StripeServiceAccountPlanId },
|
||||
Quantity = quantity
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
var update = new ServiceAccountSubscriptionUpdate(organization, plan, quantity);
|
||||
update.UpgradeItemsOptions(subscription);
|
||||
|
||||
var options = update.RevertItemsOptions(subscription);
|
||||
|
||||
Assert.Single(options);
|
||||
Assert.Equal(plan.SecretsManager.StripeServiceAccountPlanId, options[0].Plan);
|
||||
Assert.Equal(quantity, options[0].Quantity);
|
||||
Assert.Null(options[0].Deleted);
|
||||
}
|
||||
}
|
101
test/Core.Test/Models/Business/SmSeatSubscriptionUpdateTests.cs
Normal file
101
test/Core.Test/Models/Business/SmSeatSubscriptionUpdateTests.cs
Normal file
@ -0,0 +1,101 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Models.Business;
|
||||
|
||||
public class SmSeatSubscriptionUpdateTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly2019)]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly2020)]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually2019)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually2020)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually)]
|
||||
[BitAutoData(PlanType.TeamsMonthly2019)]
|
||||
[BitAutoData(PlanType.TeamsMonthly2020)]
|
||||
[BitAutoData(PlanType.TeamsMonthly)]
|
||||
[BitAutoData(PlanType.TeamsAnnually2019)]
|
||||
[BitAutoData(PlanType.TeamsAnnually2020)]
|
||||
[BitAutoData(PlanType.TeamsAnnually)]
|
||||
[BitAutoData(PlanType.TeamsStarter)]
|
||||
|
||||
public void UpgradeItemsOptions_ReturnsCorrectOptions(PlanType planType, Organization organization)
|
||||
{
|
||||
var plan = StaticStore.GetPlan(planType);
|
||||
organization.PlanType = planType;
|
||||
var quantity = 3;
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new ()
|
||||
{
|
||||
Id = "subscription_item",
|
||||
Price = new Price { Id = plan.SecretsManager.StripeSeatPlanId },
|
||||
Quantity = quantity
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
var update = new SmSeatSubscriptionUpdate(organization, plan, quantity);
|
||||
|
||||
var options = update.UpgradeItemsOptions(subscription);
|
||||
|
||||
Assert.Single(options);
|
||||
Assert.Equal(plan.SecretsManager.StripeSeatPlanId, options[0].Plan);
|
||||
Assert.Equal(quantity, options[0].Quantity);
|
||||
Assert.Null(options[0].Deleted);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly2019)]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly2020)]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually2019)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually2020)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually)]
|
||||
[BitAutoData(PlanType.TeamsMonthly2019)]
|
||||
[BitAutoData(PlanType.TeamsMonthly2020)]
|
||||
[BitAutoData(PlanType.TeamsMonthly)]
|
||||
[BitAutoData(PlanType.TeamsAnnually2019)]
|
||||
[BitAutoData(PlanType.TeamsAnnually2020)]
|
||||
[BitAutoData(PlanType.TeamsAnnually)]
|
||||
public void RevertItemsOptions_ReturnsCorrectOptions(PlanType planType, Organization organization)
|
||||
{
|
||||
var plan = StaticStore.GetPlan(planType);
|
||||
organization.PlanType = planType;
|
||||
var quantity = 5;
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new ()
|
||||
{
|
||||
Id = "subscription_item",
|
||||
Price = new Price { Id = plan.SecretsManager.StripeSeatPlanId },
|
||||
Quantity = quantity
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
var update = new SmSeatSubscriptionUpdate(organization, plan, quantity);
|
||||
update.UpgradeItemsOptions(subscription);
|
||||
|
||||
var options = update.RevertItemsOptions(subscription);
|
||||
|
||||
Assert.Single(options);
|
||||
Assert.Equal(plan.SecretsManager.StripeSeatPlanId, options[0].Plan);
|
||||
Assert.Equal(organization.SmSeats, options[0].Quantity);
|
||||
Assert.Null(options[0].Deleted);
|
||||
}
|
||||
}
|
106
test/Core.Test/Models/Business/StorageSubscriptionUpdateTests.cs
Normal file
106
test/Core.Test/Models/Business/StorageSubscriptionUpdateTests.cs
Normal file
@ -0,0 +1,106 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Models.Business;
|
||||
|
||||
public class StorageSubscriptionUpdateTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly2019)]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly2020)]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually2019)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually2020)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually)]
|
||||
[BitAutoData(PlanType.TeamsMonthly2019)]
|
||||
[BitAutoData(PlanType.TeamsMonthly2020)]
|
||||
[BitAutoData(PlanType.TeamsMonthly)]
|
||||
[BitAutoData(PlanType.TeamsAnnually2019)]
|
||||
[BitAutoData(PlanType.TeamsAnnually2020)]
|
||||
[BitAutoData(PlanType.TeamsAnnually)]
|
||||
[BitAutoData(PlanType.TeamsStarter)]
|
||||
|
||||
public void UpgradeItemsOptions_ReturnsCorrectOptions(PlanType planType)
|
||||
{
|
||||
var plan = StaticStore.GetPlan(planType);
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new ()
|
||||
{
|
||||
Id = "subscription_item",
|
||||
Price = new Price { Id = plan.PasswordManager.StripeStoragePlanId },
|
||||
Quantity = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
var update = new StorageSubscriptionUpdate("plan_id", 100);
|
||||
|
||||
var options = update.UpgradeItemsOptions(subscription);
|
||||
|
||||
Assert.Single(options);
|
||||
Assert.Equal("plan_id", options[0].Plan);
|
||||
Assert.Equal(100, options[0].Quantity);
|
||||
Assert.Null(options[0].Deleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RevertItemsOptions_ThrowsExceptionIfPrevStorageIsNull()
|
||||
{
|
||||
var subscription = new Subscription();
|
||||
var update = new StorageSubscriptionUpdate("plan_id", 100);
|
||||
|
||||
Assert.Throws<Exception>(() => update.RevertItemsOptions(subscription));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly2019)]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly2020)]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually2019)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually2020)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually)]
|
||||
[BitAutoData(PlanType.TeamsMonthly2019)]
|
||||
[BitAutoData(PlanType.TeamsMonthly2020)]
|
||||
[BitAutoData(PlanType.TeamsMonthly)]
|
||||
[BitAutoData(PlanType.TeamsAnnually2019)]
|
||||
[BitAutoData(PlanType.TeamsAnnually2020)]
|
||||
[BitAutoData(PlanType.TeamsAnnually)]
|
||||
[BitAutoData(PlanType.TeamsStarter)]
|
||||
public void RevertItemsOptions_ReturnsCorrectOptions(PlanType planType)
|
||||
{
|
||||
var plan = StaticStore.GetPlan(planType);
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new ()
|
||||
{
|
||||
Id = "subscription_item",
|
||||
Price = new Price { Id = plan.PasswordManager.StripeStoragePlanId },
|
||||
Quantity = 100
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
var update = new StorageSubscriptionUpdate(plan.PasswordManager.StripeStoragePlanId, 100);
|
||||
update.UpgradeItemsOptions(subscription);
|
||||
|
||||
var options = update.RevertItemsOptions(subscription);
|
||||
|
||||
Assert.Single(options);
|
||||
Assert.Equal(plan.PasswordManager.StripeStoragePlanId, options[0].Plan);
|
||||
Assert.Equal(100, options[0].Quantity);
|
||||
Assert.Null(options[0].Deleted);
|
||||
}
|
||||
}
|
@ -72,6 +72,113 @@ public class AuthRequestRepositoryTests
|
||||
Assert.Equal(4, numberOfDeleted);
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task UpdateManyAsync_Works(
|
||||
IAuthRequestRepository authRequestRepository,
|
||||
IUserRepository userRepository)
|
||||
{
|
||||
// Create two distinct real users for foreign key requirements
|
||||
var user1 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "First Test User",
|
||||
Email = $"test+{Guid.NewGuid()}@email.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
var user2 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Second Test User",
|
||||
Email = $"test+{Guid.NewGuid()}@email.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
var user3 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Third Test User",
|
||||
Email = $"test+{Guid.NewGuid()}@email.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
// Create two different and still valid (not expired or responded to) auth requests
|
||||
var authRequests = new List<AuthRequest>
|
||||
{
|
||||
await authRequestRepository.CreateAsync(CreateAuthRequest(user1.Id, AuthRequestType.AdminApproval, DateTime.UtcNow.AddMinutes(-5))),
|
||||
await authRequestRepository.CreateAsync(CreateAuthRequest(user3.Id, AuthRequestType.AdminApproval, DateTime.UtcNow.AddMinutes(-7))),
|
||||
await authRequestRepository.CreateAsync(CreateAuthRequest(user2.Id, AuthRequestType.AdminApproval, DateTime.UtcNow.AddMinutes(-10))),
|
||||
// This last auth request is not created manually, and will be
|
||||
// used to make sure entity framework's `UpdateRange` method
|
||||
// doesn't create requests too.
|
||||
CreateAuthRequest(user2.Id, AuthRequestType.AdminApproval, DateTime.UtcNow.AddMinutes(-11))
|
||||
};
|
||||
|
||||
// Update some properties on two auth request, but leave the other one
|
||||
// alone to be a control value
|
||||
var authRequestToBeUpdated1 = authRequests[0];
|
||||
var authRequestToBeUpdated2 = authRequests[1];
|
||||
var authRequestNotToBeUpdated = authRequests[2];
|
||||
authRequests[0].Approved = true;
|
||||
authRequests[0].ResponseDate = DateTime.UtcNow.AddMinutes(-1);
|
||||
authRequests[0].Key = "UPDATED_KEY_1";
|
||||
authRequests[0].MasterPasswordHash = "UPDATED_MASTERPASSWORDHASH_1";
|
||||
|
||||
authRequests[1].Approved = false;
|
||||
authRequests[1].ResponseDate = DateTime.UtcNow.AddMinutes(-2);
|
||||
|
||||
// Run the method being tested
|
||||
await authRequestRepository.UpdateManyAsync(authRequests);
|
||||
|
||||
// Define what "Equality" really means in this context
|
||||
// This includes stripping milliseconds off of dates, because we can't
|
||||
// reliably compare that deep
|
||||
static DateTime? TrimMilliseconds(DateTime? dt)
|
||||
{
|
||||
if (!dt.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return new DateTime(dt.Value.Year, dt.Value.Month, dt.Value.Day, dt.Value.Hour, dt.Value.Minute, dt.Value.Second, 0, dt.Value.Kind);
|
||||
}
|
||||
|
||||
bool AuthRequestEquals(AuthRequest x, AuthRequest y)
|
||||
{
|
||||
return
|
||||
x.Id == y.Id &&
|
||||
x.UserId == y.UserId &&
|
||||
x.Type == y.Type &&
|
||||
x.RequestDeviceIdentifier == y.RequestDeviceIdentifier &&
|
||||
x.RequestDeviceType == y.RequestDeviceType &&
|
||||
x.RequestIpAddress == y.RequestIpAddress &&
|
||||
x.ResponseDeviceId == y.ResponseDeviceId &&
|
||||
x.AccessCode == y.AccessCode &&
|
||||
x.PublicKey == y.PublicKey &&
|
||||
x.Key == y.Key &&
|
||||
x.MasterPasswordHash == y.MasterPasswordHash &&
|
||||
x.Approved == y.Approved &&
|
||||
TrimMilliseconds(x.CreationDate) == TrimMilliseconds(y.CreationDate) &&
|
||||
TrimMilliseconds(x.ResponseDate) == TrimMilliseconds(y.ResponseDate) &&
|
||||
TrimMilliseconds(x.AuthenticationDate) == TrimMilliseconds(y.AuthenticationDate) &&
|
||||
x.OrganizationId == y.OrganizationId;
|
||||
}
|
||||
|
||||
// Assert that the unchanged auth request is still unchanged
|
||||
var skippedAuthRequest = await authRequestRepository.GetByIdAsync(authRequestNotToBeUpdated.Id);
|
||||
Assert.True(AuthRequestEquals(skippedAuthRequest, authRequestNotToBeUpdated));
|
||||
|
||||
// Assert that the values updated on the changed auth requests were updated, and no others
|
||||
var updatedAuthRequest1 = await authRequestRepository.GetByIdAsync(authRequestToBeUpdated1.Id);
|
||||
Assert.True(AuthRequestEquals(authRequestToBeUpdated1, updatedAuthRequest1));
|
||||
var updatedAuthRequest2 = await authRequestRepository.GetByIdAsync(authRequestToBeUpdated2.Id);
|
||||
Assert.True(AuthRequestEquals(authRequestToBeUpdated2, updatedAuthRequest2));
|
||||
|
||||
// Assert that the auth request we never created is not created by
|
||||
// the update method.
|
||||
var uncreatedAuthRequest = await authRequestRepository.GetByIdAsync(authRequests[3].Id);
|
||||
Assert.Null(uncreatedAuthRequest);
|
||||
}
|
||||
|
||||
private static AuthRequest CreateAuthRequest(Guid userId, AuthRequestType authRequestType, DateTime creationDate, bool? approved = null, DateTime? responseDate = null)
|
||||
{
|
||||
return new AuthRequest
|
||||
|
@ -310,6 +310,7 @@ public class CollectionRepositoryTests
|
||||
Assert.True(c1.Manage);
|
||||
Assert.False(c1.ReadOnly);
|
||||
Assert.False(c1.HidePasswords);
|
||||
Assert.False(c1.Unmanaged);
|
||||
}, c2 =>
|
||||
{
|
||||
Assert.NotNull(c2);
|
||||
@ -319,6 +320,7 @@ public class CollectionRepositoryTests
|
||||
Assert.False(c2.Manage);
|
||||
Assert.True(c2.ReadOnly);
|
||||
Assert.False(c2.HidePasswords);
|
||||
Assert.True(c2.Unmanaged);
|
||||
}, c3 =>
|
||||
{
|
||||
Assert.NotNull(c3);
|
||||
@ -328,6 +330,7 @@ public class CollectionRepositoryTests
|
||||
Assert.False(c3.Manage);
|
||||
Assert.False(c3.ReadOnly);
|
||||
Assert.False(c3.HidePasswords);
|
||||
Assert.False(c3.Unmanaged);
|
||||
});
|
||||
}
|
||||
|
||||
@ -436,6 +439,7 @@ public class CollectionRepositoryTests
|
||||
Assert.True(c1.Manage);
|
||||
Assert.False(c1.ReadOnly);
|
||||
Assert.False(c1.HidePasswords);
|
||||
Assert.False(c1.Unmanaged);
|
||||
}, c2 =>
|
||||
{
|
||||
Assert.NotNull(c2);
|
||||
@ -445,6 +449,7 @@ public class CollectionRepositoryTests
|
||||
Assert.False(c2.Manage);
|
||||
Assert.True(c2.ReadOnly);
|
||||
Assert.False(c2.HidePasswords);
|
||||
Assert.True(c2.Unmanaged);
|
||||
}, c3 =>
|
||||
{
|
||||
Assert.NotNull(c3);
|
||||
@ -454,6 +459,7 @@ public class CollectionRepositoryTests
|
||||
Assert.True(c3.Manage); // Group 2 is Manage
|
||||
Assert.False(c3.ReadOnly);
|
||||
Assert.False(c3.HidePasswords);
|
||||
Assert.False(c3.Unmanaged);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,45 @@
|
||||
CREATE PROCEDURE AuthRequest_UpdateMany
|
||||
@jsonData NVARCHAR(MAX)
|
||||
AS
|
||||
BEGIN
|
||||
UPDATE AR
|
||||
SET
|
||||
[Id] = ARI.[Id],
|
||||
[UserId] = ARI.[UserId],
|
||||
[Type] = ARI.[Type],
|
||||
[RequestDeviceIdentifier] = ARI.[RequestDeviceIdentifier],
|
||||
[RequestDeviceType] = ARI.[RequestDeviceType],
|
||||
[RequestIpAddress] = ARI.[RequestIpAddress],
|
||||
[ResponseDeviceId] = ARI.[ResponseDeviceId],
|
||||
[AccessCode] = ARI.[AccessCode],
|
||||
[PublicKey] = ARI.[PublicKey],
|
||||
[Key] = ARI.[Key],
|
||||
[MasterPasswordHash] = ARI.[MasterPasswordHash],
|
||||
[Approved] = ARI.[Approved],
|
||||
[CreationDate] = ARI.[CreationDate],
|
||||
[ResponseDate] = ARI.[ResponseDate],
|
||||
[AuthenticationDate] = ARI.[AuthenticationDate],
|
||||
[OrganizationId] = ARI.[OrganizationId]
|
||||
FROM
|
||||
[dbo].[AuthRequest] AR
|
||||
INNER JOIN
|
||||
OPENJSON(@jsonData)
|
||||
WITH (
|
||||
Id UNIQUEIDENTIFIER '$.Id',
|
||||
UserId UNIQUEIDENTIFIER '$.UserId',
|
||||
Type SMALLINT '$.Type',
|
||||
RequestDeviceIdentifier NVARCHAR(50) '$.RequestDeviceIdentifier',
|
||||
RequestDeviceType SMALLINT '$.RequestDeviceType',
|
||||
RequestIpAddress VARCHAR(50) '$.RequestIpAddress',
|
||||
ResponseDeviceId UNIQUEIDENTIFIER '$.ResponseDeviceId',
|
||||
AccessCode VARCHAR(25) '$.AccessCode',
|
||||
PublicKey VARCHAR(MAX) '$.PublicKey',
|
||||
[Key] VARCHAR(MAX) '$.Key',
|
||||
MasterPasswordHash VARCHAR(MAX) '$.MasterPasswordHash',
|
||||
Approved BIT '$.Approved',
|
||||
CreationDate DATETIME2 '$.CreationDate',
|
||||
ResponseDate DATETIME2 '$.ResponseDate',
|
||||
AuthenticationDate DATETIME2 '$.AuthenticationDate',
|
||||
OrganizationId UNIQUEIDENTIFIER '$.OrganizationId'
|
||||
) ARI ON AR.Id = ARI.Id;
|
||||
END
|
@ -0,0 +1,171 @@
|
||||
CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByOrganizationIdWithPermissions]
|
||||
@OrganizationId UNIQUEIDENTIFIER,
|
||||
@UserId UNIQUEIDENTIFIER,
|
||||
@IncludeAccessRelationships BIT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
C.*,
|
||||
MIN(CASE
|
||||
WHEN
|
||||
COALESCE(CU.[ReadOnly], CG.[ReadOnly], 0) = 0
|
||||
THEN 0
|
||||
ELSE 1
|
||||
END) AS [ReadOnly],
|
||||
MIN(CASE
|
||||
WHEN
|
||||
COALESCE(CU.[HidePasswords], CG.[HidePasswords], 0) = 0
|
||||
THEN 0
|
||||
ELSE 1
|
||||
END) AS [HidePasswords],
|
||||
MAX(CASE
|
||||
WHEN
|
||||
COALESCE(CU.[Manage], CG.[Manage], 0) = 0
|
||||
THEN 0
|
||||
ELSE 1
|
||||
END) AS [Manage],
|
||||
MAX(CASE
|
||||
WHEN
|
||||
CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL
|
||||
THEN 0
|
||||
ELSE 1
|
||||
END) AS [Assigned],
|
||||
CASE
|
||||
WHEN
|
||||
-- No active user or group has manage rights
|
||||
NOT EXISTS(
|
||||
SELECT 1
|
||||
FROM [dbo].[CollectionUser] CU2
|
||||
JOIN [dbo].[OrganizationUser] OU2 ON CU2.[OrganizationUserId] = OU2.[Id]
|
||||
WHERE
|
||||
CU2.[CollectionId] = C.[Id] AND
|
||||
OU2.[Status] = 2 AND
|
||||
CU2.[Manage] = 1
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM [dbo].[CollectionGroup] CG2
|
||||
WHERE
|
||||
CG2.[CollectionId] = C.[Id] AND
|
||||
CG2.[Manage] = 1
|
||||
)
|
||||
THEN 1
|
||||
ELSE 0
|
||||
END AS [Unmanaged]
|
||||
FROM
|
||||
[dbo].[CollectionView] C
|
||||
LEFT JOIN
|
||||
[dbo].[OrganizationUser] OU ON C.[OrganizationId] = OU.[OrganizationId] AND OU.[UserId] = @UserId
|
||||
LEFT JOIN
|
||||
[dbo].[CollectionUser] CU ON CU.[CollectionId] = C.[Id] AND CU.[OrganizationUserId] = [OU].[Id]
|
||||
LEFT JOIN
|
||||
[dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND GU.[OrganizationUserId] = OU.[Id]
|
||||
LEFT JOIN
|
||||
[dbo].[Group] G ON G.[Id] = GU.[GroupId]
|
||||
LEFT JOIN
|
||||
[dbo].[CollectionGroup] CG ON CG.[CollectionId] = C.[Id] AND CG.[GroupId] = GU.[GroupId]
|
||||
WHERE
|
||||
C.[OrganizationId] = @OrganizationId
|
||||
GROUP BY
|
||||
C.[Id],
|
||||
C.[OrganizationId],
|
||||
C.[Name],
|
||||
C.[CreationDate],
|
||||
C.[RevisionDate],
|
||||
C.[ExternalId]
|
||||
|
||||
IF (@IncludeAccessRelationships = 1)
|
||||
BEGIN
|
||||
EXEC [dbo].[CollectionGroup_ReadByOrganizationId] @OrganizationId
|
||||
EXEC [dbo].[CollectionUser_ReadByOrganizationId] @OrganizationId
|
||||
END
|
||||
END
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByIdWithPermissions]
|
||||
@CollectionId UNIQUEIDENTIFIER,
|
||||
@UserId UNIQUEIDENTIFIER,
|
||||
@IncludeAccessRelationships BIT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
C.*,
|
||||
MIN(CASE
|
||||
WHEN
|
||||
COALESCE(CU.[ReadOnly], CG.[ReadOnly], 0) = 0
|
||||
THEN 0
|
||||
ELSE 1
|
||||
END) AS [ReadOnly],
|
||||
MIN (CASE
|
||||
WHEN
|
||||
COALESCE(CU.[HidePasswords], CG.[HidePasswords], 0) = 0
|
||||
THEN 0
|
||||
ELSE 1
|
||||
END) AS [HidePasswords],
|
||||
MAX(CASE
|
||||
WHEN
|
||||
COALESCE(CU.[Manage], CG.[Manage], 0) = 0
|
||||
THEN 0
|
||||
ELSE 1
|
||||
END) AS [Manage],
|
||||
MAX(CASE
|
||||
WHEN
|
||||
CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL
|
||||
THEN 0
|
||||
ELSE 1
|
||||
END) AS [Assigned],
|
||||
CASE
|
||||
WHEN
|
||||
-- No active user or group has manage rights
|
||||
NOT EXISTS(
|
||||
SELECT 1
|
||||
FROM [dbo].[CollectionUser] CU2
|
||||
JOIN [dbo].[OrganizationUser] OU2 ON CU2.[OrganizationUserId] = OU2.[Id]
|
||||
WHERE
|
||||
CU2.[CollectionId] = C.[Id] AND
|
||||
OU2.[Status] = 2 AND
|
||||
CU2.[Manage] = 1
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM [dbo].[CollectionGroup] CG2
|
||||
WHERE
|
||||
CG2.[CollectionId] = C.[Id] AND
|
||||
CG2.[Manage] = 1
|
||||
)
|
||||
THEN 1
|
||||
ELSE 0
|
||||
END AS [Unmanaged]
|
||||
FROM
|
||||
[dbo].[CollectionView] C
|
||||
LEFT JOIN
|
||||
[dbo].[OrganizationUser] OU ON C.[OrganizationId] = OU.[OrganizationId] AND OU.[UserId] = @UserId
|
||||
LEFT JOIN
|
||||
[dbo].[CollectionUser] CU ON CU.[CollectionId] = C.[Id] AND CU.[OrganizationUserId] = [OU].[Id]
|
||||
LEFT JOIN
|
||||
[dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND GU.[OrganizationUserId] = OU.[Id]
|
||||
LEFT JOIN
|
||||
[dbo].[Group] G ON G.[Id] = GU.[GroupId]
|
||||
LEFT JOIN
|
||||
[dbo].[CollectionGroup] CG ON CG.[CollectionId] = C.[Id] AND CG.[GroupId] = GU.[GroupId]
|
||||
WHERE
|
||||
C.[Id] = @CollectionId
|
||||
GROUP BY
|
||||
C.[Id],
|
||||
C.[OrganizationId],
|
||||
C.[Name],
|
||||
C.[CreationDate],
|
||||
C.[RevisionDate],
|
||||
C.[ExternalId]
|
||||
|
||||
IF (@IncludeAccessRelationships = 1)
|
||||
BEGIN
|
||||
EXEC [dbo].[CollectionGroup_ReadByCollectionId] @CollectionId
|
||||
EXEC [dbo].[CollectionUser_ReadByCollectionId] @CollectionId
|
||||
END
|
||||
END
|
||||
GO
|
124
util/Migrator/DbScripts/2024-05-20_00_FixManageAggregation.sql
Normal file
124
util/Migrator/DbScripts/2024-05-20_00_FixManageAggregation.sql
Normal file
@ -0,0 +1,124 @@
|
||||
-- We were aggregating CollectionGroup permissions using MIN([Manage]) instead of MAX.
|
||||
-- If the user is a member of multiple groups with overlapping collection permissions, they should get the most
|
||||
-- generous permissions, not the least. This is consistent with ReadOnly and HidePasswords columns.
|
||||
-- Updating both current and V2 sprocs out of caution and because they still need to be reviewed/cleaned up.
|
||||
|
||||
-- Collection_ReadByIdUserId
|
||||
CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByIdUserId]
|
||||
@Id UNIQUEIDENTIFIER,
|
||||
@UserId UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
SELECT
|
||||
Id,
|
||||
OrganizationId,
|
||||
[Name],
|
||||
CreationDate,
|
||||
RevisionDate,
|
||||
ExternalId,
|
||||
MIN([ReadOnly]) AS [ReadOnly],
|
||||
MIN([HidePasswords]) AS [HidePasswords],
|
||||
MAX([Manage]) AS [Manage]
|
||||
FROM
|
||||
[dbo].[UserCollectionDetails](@UserId)
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
GROUP BY
|
||||
Id,
|
||||
OrganizationId,
|
||||
[Name],
|
||||
CreationDate,
|
||||
RevisionDate,
|
||||
ExternalId
|
||||
END
|
||||
GO;
|
||||
|
||||
-- Collection_ReadByIdUserId_V2
|
||||
CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByIdUserId_V2]
|
||||
@Id UNIQUEIDENTIFIER,
|
||||
@UserId UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
SELECT
|
||||
Id,
|
||||
OrganizationId,
|
||||
[Name],
|
||||
CreationDate,
|
||||
RevisionDate,
|
||||
ExternalId,
|
||||
MIN([ReadOnly]) AS [ReadOnly],
|
||||
MIN([HidePasswords]) AS [HidePasswords],
|
||||
MAX([Manage]) AS [Manage]
|
||||
FROM
|
||||
[dbo].[UserCollectionDetails_V2](@UserId)
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
GROUP BY
|
||||
Id,
|
||||
OrganizationId,
|
||||
[Name],
|
||||
CreationDate,
|
||||
RevisionDate,
|
||||
ExternalId
|
||||
END
|
||||
GO;
|
||||
|
||||
-- Collection_ReadByUserId
|
||||
CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByUserId]
|
||||
@UserId UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
Id,
|
||||
OrganizationId,
|
||||
[Name],
|
||||
CreationDate,
|
||||
RevisionDate,
|
||||
ExternalId,
|
||||
MIN([ReadOnly]) AS [ReadOnly],
|
||||
MIN([HidePasswords]) AS [HidePasswords],
|
||||
MAX([Manage]) AS [Manage]
|
||||
FROM
|
||||
[dbo].[UserCollectionDetails](@UserId)
|
||||
GROUP BY
|
||||
Id,
|
||||
OrganizationId,
|
||||
[Name],
|
||||
CreationDate,
|
||||
RevisionDate,
|
||||
ExternalId
|
||||
END
|
||||
GO;
|
||||
|
||||
-- Collection_ReadByUserId_V2
|
||||
CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByUserId_V2]
|
||||
@UserId UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
Id,
|
||||
OrganizationId,
|
||||
[Name],
|
||||
CreationDate,
|
||||
RevisionDate,
|
||||
ExternalId,
|
||||
MIN([ReadOnly]) AS [ReadOnly],
|
||||
MIN([HidePasswords]) AS [HidePasswords],
|
||||
MAX([Manage]) AS [Manage]
|
||||
FROM
|
||||
[dbo].[UserCollectionDetails_V2](@UserId)
|
||||
GROUP BY
|
||||
Id,
|
||||
OrganizationId,
|
||||
[Name],
|
||||
CreationDate,
|
||||
RevisionDate,
|
||||
ExternalId
|
||||
END
|
||||
GO;
|
@ -0,0 +1,42 @@
|
||||
-- This script will enable collection enhancements for organizations that don't have Collection Enhancements enabled.
|
||||
-- This is a copy/paste of an earlier migration script: 2024-04-25_00_EnableAllOrgCollectionEnhancements.sql.
|
||||
-- The earlier migration was accidentally released for self-host before the feature was enabled for new organizations,
|
||||
-- so there was a window in time where existing self-host organizations were migrated, but it was still possible to create
|
||||
-- a new organization that needed migration.
|
||||
-- This script is being re-run to catch any organizations created during that window.
|
||||
|
||||
-- Step 1: Create a temporary table to store the Organizations with FlexibleCollections = 0
|
||||
SELECT [Id] AS [OrganizationId]
|
||||
INTO #TempOrg
|
||||
FROM [dbo].[Organization]
|
||||
WHERE [FlexibleCollections] = 0
|
||||
|
||||
-- Step 2: Execute the stored procedure for each OrganizationId
|
||||
DECLARE @OrganizationId UNIQUEIDENTIFIER;
|
||||
|
||||
DECLARE OrgCursor CURSOR FOR
|
||||
SELECT [OrganizationId]
|
||||
FROM #TempOrg;
|
||||
|
||||
OPEN OrgCursor;
|
||||
|
||||
FETCH NEXT FROM OrgCursor INTO @OrganizationId;
|
||||
|
||||
WHILE (@@FETCH_STATUS = 0)
|
||||
BEGIN
|
||||
-- Execute the stored procedure for the current OrganizationId
|
||||
EXEC [dbo].[Organization_EnableCollectionEnhancements] @OrganizationId;
|
||||
|
||||
-- Update the Organization to set FlexibleCollections = 1
|
||||
UPDATE [dbo].[Organization]
|
||||
SET [FlexibleCollections] = 1
|
||||
WHERE [Id] = @OrganizationId;
|
||||
|
||||
FETCH NEXT FROM OrgCursor INTO @OrganizationId;
|
||||
END;
|
||||
|
||||
CLOSE OrgCursor;
|
||||
DEALLOCATE OrgCursor;
|
||||
|
||||
-- Step 3: Drop the temporary table
|
||||
DROP TABLE #TempOrg;
|
Loading…
x
Reference in New Issue
Block a user