1
0
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:
Vince Grassia 2024-05-23 08:38:06 -04:00
commit 632f9bcdbe
No known key found for this signature in database
GPG Key ID: 9AD7505E8448CC08
94 changed files with 3444 additions and 1327 deletions

View File

@ -198,6 +198,13 @@ jobs:
PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }} PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }}
run: gh pr merge $PR_NUMBER --squash --auto --delete-branch 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: cut_rc:
name: Cut RC branch name: Cut RC branch

View File

@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Version>2024.5.0</Version> <Version>2024.5.1</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace> <RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>

View File

@ -46,6 +46,13 @@ public class CreateProviderCommand : ICreateProviderCommand
throw new BadRequestException("Invalid owner. Owner must be an existing Bitwarden user."); 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); await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Pending);
var providerUser = new ProviderUser var providerUser = new ProviderUser
@ -56,8 +63,6 @@ public class CreateProviderCommand : ICreateProviderCommand
Status = ProviderUserStatusType.Confirmed, Status = ProviderUserStatusType.Confirmed,
}; };
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
if (isConsolidatedBillingEnabled) if (isConsolidatedBillingEnabled)
{ {
var providerPlans = new List<ProviderPlan> var providerPlans = new List<ProviderPlan>

View File

@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Commands; using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -76,6 +77,35 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
organization.BillingEmail = organizationOwnerEmails.MinBy(email => email); 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 var customerUpdateOptions = new CustomerUpdateOptions
{ {
Coupon = string.Empty, Coupon = string.Empty,
@ -84,11 +114,10 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, customerUpdateOptions); await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, customerUpdateOptions);
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
if (isConsolidatedBillingEnabled && provider.Status == ProviderStatusType.Billable) if (isConsolidatedBillingEnabled && provider.Status == ProviderStatusType.Billable)
{ {
var plan = StaticStore.GetPlan(organization.PlanType).PasswordManager; var plan = StaticStore.GetPlan(organization.PlanType).PasswordManager;
var subscriptionCreateOptions = new SubscriptionCreateOptions var subscriptionCreateOptions = new SubscriptionCreateOptions
{ {
Customer = organization.GatewayCustomerId, Customer = organization.GatewayCustomerId,
@ -103,8 +132,11 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
Items = [new SubscriptionItemOptions { Price = plan.StripeSeatPlanId, Quantity = organization.Seats }] Items = [new SubscriptionItemOptions { Price = plan.StripeSeatPlanId, Quantity = organization.Seats }]
}; };
var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
organization.GatewaySubscriptionId = subscription.Id; organization.GatewaySubscriptionId = subscription.Id;
await _scaleSeatsCommand.ScalePasswordManagerSeats(provider, organization.PlanType, await _scaleSeatsCommand.ScalePasswordManagerSeats(provider, organization.PlanType,
-(organization.Seats ?? 0)); -(organization.Seats ?? 0));
} }
@ -115,21 +147,14 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
CollectionMethod = "send_invoice", CollectionMethod = "send_invoice",
DaysUntilDue = 30 DaysUntilDue = 30
}; };
await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, subscriptionUpdateOptions); await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, subscriptionUpdateOptions);
} }
await _organizationRepository.ReplaceAsync(organization);
await _mailService.SendProviderUpdatePaymentMethod( await _mailService.SendProviderUpdatePaymentMethod(
organization.Id, organization.Id,
organization.Name, organization.Name,
provider.Name, provider.Name,
organizationOwnerEmails); organizationOwnerEmails);
await _providerOrganizationRepository.DeleteAsync(providerOrganization);
await _eventService.LogProviderOrganizationEventAsync(
providerOrganization,
EventType.ProviderOrganization_Removed);
} }
} }

View File

@ -14,17 +14,23 @@ namespace Bit.Scim.Users;
public class PostUserCommand : IPostUserCommand public class PostUserCommand : IPostUserCommand
{ {
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationService _organizationService; private readonly IOrganizationService _organizationService;
private readonly IPaymentService _paymentService;
private readonly IScimContext _scimContext; private readonly IScimContext _scimContext;
public PostUserCommand( public PostUserCommand(
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService, IOrganizationService organizationService,
IPaymentService paymentService,
IScimContext scimContext) IScimContext scimContext)
{ {
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_organizationService = organizationService; _organizationService = organizationService;
_paymentService = paymentService;
_scimContext = scimContext; _scimContext = scimContext;
} }
@ -80,8 +86,13 @@ public class PostUserCommand : IPostUserCommand
throw new ConflictException(); throw new ConflictException();
} }
var organization = await _organizationRepository.GetByIdAsync(organizationId);
var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization);
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, EventSystemUser.SCIM, email, 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); var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
return orgUser; return orgUser;

View File

@ -14,6 +14,7 @@ using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute; using NSubstitute;
using Stripe; using Stripe;
using Xunit; using Xunit;
using IMailService = Bit.Core.Services.IMailService;
namespace Bit.Commercial.Core.Test.AdminConsole.ProviderFeatures; 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); 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] [Theory, BitAutoData]
public async Task RemoveOrganizationFromProvider_MakesCorrectInvocations__FeatureFlagOff( public async Task RemoveOrganizationFromProvider_MakesCorrectInvocations__FeatureFlagOff(
Provider provider, Provider provider,

View File

@ -1,4 +1,5 @@
using Bit.Core.Enums; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Data.Organizations.OrganizationUsers;
@ -19,7 +20,7 @@ public class PostUserCommandTests
{ {
[Theory] [Theory]
[BitAutoData] [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 var scimUserRequestModel = new ScimUserRequestModel
{ {
@ -33,16 +34,20 @@ public class PostUserCommandTests
.GetManyDetailsByOrganizationAsync(organizationId) .GetManyDetailsByOrganizationAsync(organizationId)
.Returns(organizationUsers); .Returns(organizationUsers);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
sutProvider.GetDependency<IPaymentService>().HasSecretsManagerStandalone(organization).Returns(true);
sutProvider.GetDependency<IOrganizationService>() sutProvider.GetDependency<IOrganizationService>()
.InviteUserAsync(organizationId, EventSystemUser.SCIM, scimUserRequestModel.PrimaryEmail.ToLowerInvariant(), .InviteUserAsync(organizationId, EventSystemUser.SCIM, scimUserRequestModel.PrimaryEmail.ToLowerInvariant(),
OrganizationUserType.User, false, externalId, Arg.Any<List<CollectionAccessSelection>>(), OrganizationUserType.User, false, externalId, Arg.Any<List<CollectionAccessSelection>>(),
Arg.Any<List<Guid>>()) Arg.Any<List<Guid>>(), true)
.Returns(newUser); .Returns(newUser);
var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel); var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel);
await sutProvider.GetDependency<IOrganizationService>().Received(1).InviteUserAsync(organizationId, EventSystemUser.SCIM, scimUserRequestModel.PrimaryEmail.ToLowerInvariant(), 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); await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).GetDetailsByIdAsync(newUser.Id);
} }

View File

@ -269,6 +269,35 @@ public class OrganizationsController : Controller
return RedirectToAction("Index"); 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) public async Task<IActionResult> TriggerBillingSync(Guid id)
{ {
var organization = await _organizationRepository.GetByIdAsync(id); var organization = await _organizationRepository.GetByIdAsync(id);
@ -349,7 +378,10 @@ public class OrganizationsController : Controller
providerOrganization, providerOrganization,
organization); organization);
if (organization.IsStripeEnabled())
{
await _removePaymentMethodCommand.RemovePaymentMethod(organization); await _removePaymentMethodCommand.RemovePaymentMethod(organization);
}
return Json(null); return Json(null);
} }

View File

@ -3,6 +3,7 @@ using Bit.Admin.Utilities;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Commands; using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Extensions;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Utilities; using Bit.Core.Utilities;
@ -69,7 +70,10 @@ public class ProviderOrganizationsController : Controller
} }
if (organization.IsStripeEnabled())
{
await _removePaymentMethodCommand.RemovePaymentMethod(organization); await _removePaymentMethodCommand.RemovePaymentMethod(organization);
}
return Json(null); return Json(null);
} }

View File

@ -17,7 +17,6 @@ using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Tools.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -36,7 +35,6 @@ public class ProvidersController : Controller
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly IApplicationCacheService _applicationCacheService; private readonly IApplicationCacheService _applicationCacheService;
private readonly IProviderService _providerService; private readonly IProviderService _providerService;
private readonly IReferenceEventService _referenceEventService;
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly ICreateProviderCommand _createProviderCommand; private readonly ICreateProviderCommand _createProviderCommand;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
@ -51,7 +49,6 @@ public class ProvidersController : Controller
IProviderService providerService, IProviderService providerService,
GlobalSettings globalSettings, GlobalSettings globalSettings,
IApplicationCacheService applicationCacheService, IApplicationCacheService applicationCacheService,
IReferenceEventService referenceEventService,
IUserService userService, IUserService userService,
ICreateProviderCommand createProviderCommand, ICreateProviderCommand createProviderCommand,
IFeatureService featureService, IFeatureService featureService,
@ -65,7 +62,6 @@ public class ProvidersController : Controller
_providerService = providerService; _providerService = providerService;
_globalSettings = globalSettings; _globalSettings = globalSettings;
_applicationCacheService = applicationCacheService; _applicationCacheService = applicationCacheService;
_referenceEventService = referenceEventService;
_userService = userService; _userService = userService;
_createProviderCommand = createProviderCommand; _createProviderCommand = createProviderCommand;
_featureService = featureService; _featureService = featureService;
@ -104,8 +100,8 @@ public class ProvidersController : Controller
return View(new CreateProviderModel return View(new CreateProviderModel
{ {
OwnerEmail = ownerEmail, OwnerEmail = ownerEmail,
TeamsMinimumSeats = teamsMinimumSeats, TeamsMonthlySeatMinimum = teamsMinimumSeats,
EnterpriseMinimumSeats = enterpriseMinimumSeats EnterpriseMonthlySeatMinimum = enterpriseMinimumSeats
}); });
} }
@ -123,8 +119,11 @@ public class ProvidersController : Controller
switch (provider.Type) switch (provider.Type)
{ {
case ProviderType.Msp: case ProviderType.Msp:
await _createProviderCommand.CreateMspAsync(provider, model.OwnerEmail, model.TeamsMinimumSeats, await _createProviderCommand.CreateMspAsync(
model.EnterpriseMinimumSeats); provider,
model.OwnerEmail,
model.TeamsMonthlySeatMinimum,
model.EnterpriseMonthlySeatMinimum);
break; break;
case ProviderType.Reseller: case ProviderType.Reseller:
await _createProviderCommand.CreateResellerAsync(provider); await _createProviderCommand.CreateResellerAsync(provider);
@ -167,8 +166,9 @@ public class ProvidersController : Controller
return View(new ProviderEditModel(provider, users, providerOrganizations, new List<ProviderPlan>())); return View(new ProviderEditModel(provider, users, providerOrganizations, new List<ProviderPlan>()));
} }
var providerPlan = await _providerPlanRepository.GetByProviderId(id); var providerPlans = await _providerPlanRepository.GetByProviderId(id);
return View(new ProviderEditModel(provider, users, providerOrganizations, providerPlan));
return View(new ProviderEditModel(provider, users, providerOrganizations, providerPlans.ToList()));
} }
[HttpPost] [HttpPost]
@ -177,14 +177,15 @@ public class ProvidersController : Controller
[RequirePermission(Permission.Provider_Edit)] [RequirePermission(Permission.Provider_Edit)]
public async Task<IActionResult> Edit(Guid id, ProviderEditModel model) public async Task<IActionResult> Edit(Guid id, ProviderEditModel model)
{ {
var providerPlans = await _providerPlanRepository.GetByProviderId(id);
var provider = await _providerRepository.GetByIdAsync(id); var provider = await _providerRepository.GetByIdAsync(id);
if (provider == null) if (provider == null)
{ {
return RedirectToAction("Index"); return RedirectToAction("Index");
} }
model.ToProvider(provider); model.ToProvider(provider);
await _providerRepository.ReplaceAsync(provider); await _providerRepository.ReplaceAsync(provider);
await _applicationCacheService.UpsertProviderAbilityAsync(provider); await _applicationCacheService.UpsertProviderAbilityAsync(provider);
@ -195,13 +196,14 @@ public class ProvidersController : Controller
return RedirectToAction("Edit", new { id }); return RedirectToAction("Edit", new { id });
} }
model.ToProviderPlan(providerPlans); var providerPlans = await _providerPlanRepository.GetByProviderId(id);
if (providerPlans.Count == 0) if (providerPlans.Count == 0)
{ {
var newProviderPlans = new List<ProviderPlan> 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.TeamsMonthly, SeatMinimum = model.TeamsMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 },
new() {ProviderId = provider.Id, PlanType = PlanType.EnterpriseMonthly, SeatMinimum= model.EnterpriseMinimumSeats, PurchasedSeats = 0, AllocatedSeats = 0} new () { ProviderId = provider.Id, PlanType = PlanType.EnterpriseMonthly, SeatMinimum = model.EnterpriseMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 }
}; };
foreach (var newProviderPlan in newProviderPlans) foreach (var newProviderPlan in newProviderPlans)
@ -213,6 +215,15 @@ public class ProvidersController : Controller
{ {
foreach (var providerPlan in providerPlans) 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); await _providerPlanRepository.ReplaceAsync(providerPlan);
} }
} }
@ -294,9 +305,8 @@ public class ProvidersController : Controller
return RedirectToAction("Index"); return RedirectToAction("Index");
} }
var flexibleCollectionsSignupEnabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup);
var flexibleCollectionsV1Enabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1); 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 _organizationService.CreatePendingOrganization(organization, model.Owners, User, _userService, model.SalesAssistedTrialStarted);
await _providerService.AddOrganization(providerId, organization.Id, null); await _providerService.AddOrganization(providerId, organization.Id, null);

View File

@ -24,11 +24,11 @@ public class CreateProviderModel : IValidatableObject
[Display(Name = "Primary Billing Email")] [Display(Name = "Primary Billing Email")]
public string BillingEmail { get; set; } public string BillingEmail { get; set; }
[Display(Name = "Teams minimum seats")] [Display(Name = "Teams (Monthly) Seat Minimum")]
public int TeamsMinimumSeats { get; set; } public int TeamsMonthlySeatMinimum { get; set; }
[Display(Name = "Enterprise minimum seats")] [Display(Name = "Enterprise (Monthly) Seat Minimum")]
public int EnterpriseMinimumSeats { get; set; } public int EnterpriseMonthlySeatMinimum { get; set; }
public virtual Provider ToProvider() public virtual Provider ToProvider()
{ {
@ -51,14 +51,14 @@ public class CreateProviderModel : IValidatableObject
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(OwnerEmail); var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(OwnerEmail);
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required."); 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."); 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."); yield return new ValidationResult($"The {enterpriseMinimumSeatsDisplayName} field can not be negative.");
} }
break; break;

View File

@ -162,19 +162,18 @@ public class OrganizationEditModel : OrganizationViewModel
{ "baseServiceAccount", p.SecretsManager.BaseServiceAccount } { "baseServiceAccount", p.SecretsManager.BaseServiceAccount }
}); });
public Organization CreateOrganization(Provider provider, bool flexibleCollectionsSignupEnabled, bool flexibleCollectionsV1Enabled) public Organization CreateOrganization(Provider provider, bool flexibleCollectionsV1Enabled)
{ {
BillingEmail = provider.BillingEmail; BillingEmail = provider.BillingEmail;
var newOrg = new Organization var newOrg = new Organization
{ {
// This feature flag indicates that new organizations should be automatically onboarded to // Flexible Collections MVP is fully released and all organizations must always have this setting enabled.
// Flexible Collections enhancements // AC-1714 will remove this flag after all old code has been removed.
FlexibleCollections = flexibleCollectionsSignupEnabled, 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. // This is a transitional setting that defaults to ON until Flexible Collections v1 is released
// 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. // (to preserve existing behavior) and defaults to OFF after release (enabling new behavior)
LimitCollectionCreationDeletion = !flexibleCollectionsSignupEnabled,
AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1Enabled AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1Enabled
}; };
return ToOrganization(newOrg); return ToOrganization(newOrg);

View File

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

View File

@ -10,16 +10,21 @@ public class ProviderEditModel : ProviderViewModel
{ {
public ProviderEditModel() { } public ProviderEditModel() { }
public ProviderEditModel(Provider provider, IEnumerable<ProviderUserUserDetails> providerUsers, public ProviderEditModel(
IEnumerable<ProviderOrganizationOrganizationDetails> organizations, IEnumerable<ProviderPlan> providerPlans) Provider provider,
: base(provider, providerUsers, organizations) IEnumerable<ProviderUserUserDetails> providerUsers,
IEnumerable<ProviderOrganizationOrganizationDetails> organizations,
IReadOnlyCollection<ProviderPlan> providerPlans) : base(provider, providerUsers, organizations)
{ {
Name = provider.DisplayName(); Name = provider.DisplayName();
BusinessName = provider.DisplayBusinessName(); BusinessName = provider.DisplayBusinessName();
BillingEmail = provider.BillingEmail; BillingEmail = provider.BillingEmail;
BillingPhone = provider.BillingPhone; BillingPhone = provider.BillingPhone;
TeamsMinimumSeats = GetMinimumSeats(providerPlans, PlanType.TeamsMonthly); TeamsMonthlySeatMinimum = GetSeatMinimum(providerPlans, PlanType.TeamsMonthly);
EnterpriseMinimumSeats = GetMinimumSeats(providerPlans, PlanType.EnterpriseMonthly); EnterpriseMonthlySeatMinimum = GetSeatMinimum(providerPlans, PlanType.EnterpriseMonthly);
Gateway = provider.Gateway;
GatewayCustomerId = provider.GatewayCustomerId;
GatewaySubscriptionId = provider.GatewaySubscriptionId;
} }
[Display(Name = "Billing Email")] [Display(Name = "Billing Email")]
@ -29,38 +34,28 @@ public class ProviderEditModel : ProviderViewModel
[Display(Name = "Business Name")] [Display(Name = "Business Name")]
public string BusinessName { get; set; } public string BusinessName { get; set; }
public string Name { get; set; } public string Name { get; set; }
[Display(Name = "Teams minimum seats")] [Display(Name = "Teams (Monthly) Seat Minimum")]
public int TeamsMinimumSeats { get; set; } public int TeamsMonthlySeatMinimum { get; set; }
[Display(Name = "Enterprise minimum seats")] [Display(Name = "Enterprise (Monthly) Seat Minimum")]
public int EnterpriseMinimumSeats { get; set; } public int EnterpriseMonthlySeatMinimum { get; set; }
[Display(Name = "Events")] [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(); existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant().Trim();
foreach (var existingProviderPlan in providerPlans) existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant().Trim();
{ existingProvider.Gateway = Gateway;
existingProviderPlan.SeatMinimum = existingProviderPlan.PlanType switch existingProvider.GatewayCustomerId = GatewayCustomerId;
{ existingProvider.GatewaySubscriptionId = GatewaySubscriptionId;
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();
return existingProvider; return existingProvider;
} }
private static int GetSeatMinimum(IEnumerable<ProviderPlan> providerPlans, PlanType planType)
private int GetMinimumSeats(IEnumerable<ProviderPlan> providerPlans, PlanType planType) => providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == planType)?.SeatMinimum ?? 0;
{
return (from providerPlan in providerPlans where providerPlan.PlanType == planType select (int)providerPlan.SeatMinimum).FirstOrDefault();
}
} }

View File

@ -1,4 +1,4 @@
@using Bit.Admin.Enums; @using Bit.Admin.Enums;
@using Bit.Admin.Models @using Bit.Admin.Models
@using Bit.Core.Enums @using Bit.Core.Enums
@inject Bit.Admin.Services.IAccessControlService AccessControlService @inject Bit.Admin.Services.IAccessControlService AccessControlService
@ -18,7 +18,9 @@
<script> <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)') { if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
alert('Organization is not on a free plan.'); alert('Organization is not on a free plan.');
return; return;
@ -27,7 +29,11 @@
togglePlanFeatures('@((byte)PlanType.TeamsAnnually)'); togglePlanFeatures('@((byte)PlanType.TeamsAnnually)');
document.getElementById('@(nameof(Model.Plan))').value = 'Teams (Trial)'; 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)') { if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
alert('Organization is not on a free plan.'); alert('Organization is not on a free plan.');
return; return;
@ -36,6 +42,19 @@
togglePlanFeatures('@((byte)PlanType.EnterpriseAnnually)'); togglePlanFeatures('@((byte)PlanType.EnterpriseAnnually)');
document.getElementById('@(nameof(Model.Plan))').value = 'Enterprise (Trial)'; 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) { function setTrialDefaults(planType) {
// Plan // Plan
@ -95,18 +114,20 @@
} }
@if (canUnlinkFromProvider && Model.Provider is not null) @if (canUnlinkFromProvider && Model.Provider is not null)
{ {
<button <button class="btn btn-outline-danger mr-2"
class="btn btn-outline-danger mr-2" onclick="return unlinkProvider('@Model.Organization.Id');">
onclick="return unlinkProvider('@Model.Organization.Id');"
>
Unlink provider Unlink provider
</button> </button>
} }
@if (canDelete) @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" <form asp-action="Delete" asp-route-id="@Model.Organization.Id"
onsubmit="return confirm('Are you sure you want to delete this organization?')"> onsubmit="return confirm('Are you sure you want to hard delete this organization?')">
<button class="btn btn-danger" type="submit">Delete</button> <button class="btn btn-outline-danger" type="submit">Delete</button>
</form> </form>
} }
</div> </div>

View File

@ -46,14 +46,14 @@
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="form-group">
<label asp-for="TeamsMinimumSeats"></label> <label asp-for="TeamsMonthlySeatMinimum"></label>
<input type="number" class="form-control" asp-for="TeamsMinimumSeats"> <input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
</div> </div>
</div> </div>
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="form-group">
<label asp-for="EnterpriseMinimumSeats"></label> <label asp-for="EnterpriseMonthlySeatMinimum"></label>
<input type="number" class="form-control" asp-for="EnterpriseMinimumSeats"> <input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
</div> </div>
</div> </div>
</div> </div>

View File

@ -48,22 +48,62 @@
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="form-group">
<label asp-for="TeamsMinimumSeats"></label> <label asp-for="TeamsMonthlySeatMinimum"></label>
<input type="number" class="form-control" asp-for="TeamsMinimumSeats"> <input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
</div> </div>
</div> </div>
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="form-group">
<label asp-for="EnterpriseMinimumSeats"></label> <label asp-for="EnterpriseMonthlySeatMinimum"></label>
<input type="number" class="form-control" asp-for="EnterpriseMinimumSeats"> <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> </div>
</div> </div>
} }
</form> </form>
@await Html.PartialAsync("Organizations", Model) @await Html.PartialAsync("Organizations", Model)
@if (canEdit) @if (canEdit)
{ {
<!-- Modals --> <!-- Modals -->
<div class="modal fade rounded" id="requestDeletionModal" tabindex="-1" aria-labelledby="requestDeletionModal" aria-hidden="true"> <div class="modal fade rounded" id="requestDeletionModal" tabindex="-1" aria-labelledby="requestDeletionModal" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
@ -146,5 +186,4 @@
</div> </div>
} }
</div> </div>
}
}

View File

@ -70,9 +70,10 @@
@{ @{
var planTypes = Enum.GetValues<PlanType>() var planTypes = Enum.GetValues<PlanType>()
.Where(p => .Where(p =>
Model.Provider == null || (Model.Provider == null ||
(Model.Provider != null p is >= PlanType.TeamsMonthly2019 and <= PlanType.EnterpriseAnnually2019 or
&& p is >= PlanType.TeamsMonthly2019 and <= PlanType.EnterpriseAnnually2019 or >= PlanType.TeamsMonthly2020 and <= PlanType.EnterpriseAnnually) >= PlanType.TeamsMonthly2020 and <= PlanType.EnterpriseAnnually) &&
p != PlanType.TeamsStarter
) )
.Select(e => new SelectListItem .Select(e => new SelectListItem
{ {

View File

@ -174,6 +174,14 @@
}); });
</script> </script>
} }
@if (TempData["Success"] != null)
{
<script>
$(document).ready(function () {
toastr.success("@TempData["Success"]")
});
</script>
}
@RenderSection("Scripts", required: false) @RenderSection("Scripts", required: false)
</body> </body>

View File

@ -109,7 +109,7 @@
<option asp-selected="Model.Filter.Price == null" value="@null">All</option> <option asp-selected="Model.Filter.Price == null" value="@null">All</option>
@foreach (var price in Model.Prices) @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> </select>
</div> </div>
@ -119,7 +119,7 @@
<option asp-selected="Model.Filter.TestClock == null" value="@null">All</option> <option asp-selected="Model.Filter.TestClock == null" value="@null">All</option>
@foreach (var clock in Model.TestClocks) @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> </select>
</div> </div>

View File

@ -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 // 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 collections = await _collectionRepository.GetManyByManyIdsAsync(model.Collections.Select(a => a.Id));
var authorized = var authorized =
@ -198,7 +200,8 @@ public class GroupsController : Controller
var userId = _userService.GetProperUserId(User).Value; var userId = _userService.GetProperUserId(User).Value;
var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(orgId, userId); var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(orgId, userId);
var currentGroupUsers = await _groupRepository.GetManyUserIdsByIdAsync(id); 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."); throw new BadRequestException("You cannot add yourself to groups.");
} }

View File

@ -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 // 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 collections = await _collectionRepository.GetManyByManyIdsAsync(model.Collections.Select(a => a.Id));
var authorized = var authorized =

View File

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

View File

@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Commands; using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Extensions;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -112,6 +113,9 @@ public class ProviderOrganizationsController : Controller
providerOrganization, providerOrganization,
organization); organization);
if (organization.IsStripeEnabled())
{
await _removePaymentMethodCommand.RemovePaymentMethod(organization); await _removePaymentMethodCommand.RemovePaymentMethod(organization);
} }
}
} }

View File

@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
public class OrganizationVerifyDeleteRecoverRequestModel
{
[Required]
public string Token { get; set; }
}

View File

@ -34,7 +34,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.Uris" 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" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup> </ItemGroup>

View File

@ -43,7 +43,6 @@ using Bit.Core.Utilities;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Repositories; using Bit.Core.Vault.Repositories;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Auth.Controllers; namespace Bit.Api.Auth.Controllers;
@ -438,9 +437,6 @@ public class AccountsController : Controller
throw new UnauthorizedAccessException(); throw new UnauthorizedAccessException();
} }
IdentityResult result;
if (_featureService.IsEnabled(FeatureFlagKeys.KeyRotationImprovements))
{
var dataModel = new RotateUserKeyData var dataModel = new RotateUserKeyData
{ {
MasterPasswordHash = model.MasterPasswordHash, MasterPasswordHash = model.MasterPasswordHash,
@ -453,44 +449,7 @@ public class AccountsController : Controller
OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.ResetPasswordKeys) OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.ResetPasswordKeys)
}; };
result = await _rotateUserKeyCommand.RotateUserKeyAsync(user, dataModel); var 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);
}
if (result.Succeeded) if (result.Succeeded)
{ {

View File

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

View File

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

View File

@ -133,10 +133,17 @@ public class ProviderClientsController(
return TypedResults.Problem(); return TypedResults.Problem();
} }
if (clientOrganization.Seats != requestBody.AssignedSeats)
{
await assignSeatsToClientOrganizationCommand.AssignSeatsToClientOrganization( await assignSeatsToClientOrganizationCommand.AssignSeatsToClientOrganization(
provider, provider,
clientOrganization, clientOrganization,
requestBody.AssignedSeats); requestBody.AssignedSeats);
}
clientOrganization.Name = requestBody.Name;
await organizationRepository.ReplaceAsync(clientOrganization);
return TypedResults.Ok(); return TypedResults.Ok();
} }

View File

@ -7,4 +7,7 @@ public class UpdateClientOrganizationRequestBody
[Required] [Required]
[Range(0, int.MaxValue, ErrorMessage = "You cannot assign negative seats to a client organization.")] [Range(0, int.MaxValue, ErrorMessage = "You cannot assign negative seats to a client organization.")]
public int AssignedSeats { get; set; } public int AssignedSeats { get; set; }
[Required]
public string Name { get; set; }
} }

View File

@ -89,6 +89,7 @@ public class CollectionAccessDetailsResponseModel : CollectionResponseModel
ReadOnly = collection.ReadOnly; ReadOnly = collection.ReadOnly;
HidePasswords = collection.HidePasswords; HidePasswords = collection.HidePasswords;
Manage = collection.Manage; Manage = collection.Manage;
Unmanaged = collection.Unmanaged;
Groups = collection.Groups?.Select(g => new SelectionReadOnlyResponseModel(g)) ?? Enumerable.Empty<SelectionReadOnlyResponseModel>(); Groups = collection.Groups?.Select(g => new SelectionReadOnlyResponseModel(g)) ?? Enumerable.Empty<SelectionReadOnlyResponseModel>();
Users = collection.Users?.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 ReadOnly { get; set; }
public bool HidePasswords { get; set; } public bool HidePasswords { get; set; }
public bool Manage { get; set; } public bool Manage { get; set; }
public bool Unmanaged { get; set; }
} }

View File

@ -217,34 +217,49 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
private async Task<bool> CanUpdateUserAccessAsync(ICollection<Collection> resources, CurrentContextOrganization? org) 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) 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) private async Task<bool> CanDeleteAsync(ICollection<Collection> resources, CurrentContextOrganization? org)
{ {
// Owners, Admins, and users with DeleteAnyCollection permission can always delete collections // Users with DeleteAnyCollection permission can always delete collections
if (org is if (org is { Permissions.DeleteAnyCollection: true })
{ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
{ Permissions.DeleteAnyCollection: true })
{ {
return true; return true;
} }
// Check for non-null org here: the user must be apart of the organization for this setting to take affect // If AllowAdminAccessToAllCollectionItems is true, Owners and Admins can delete any collection, regardless of LimitCollectionCreationDeletion setting
// The limit collection management setting is disabled, var organizationAbility = await GetOrganizationAbilityAsync(org);
// ensure acting user has manage permissions for all collections being deleted var allowAdminAccessToAllCollectionItems = !_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) ||
if (await GetOrganizationAbilityAsync(org) is { LimitCollectionCreationDeletion: false }) organizationAbility is { AllowAdminAccessToAllCollectionItems: true };
{ if (allowAdminAccessToAllCollectionItems && org is { Type: OrganizationUserType.Owner or OrganizationUserType.Admin })
var canManageCollections = await CanManageCollectionsAsync(resources, org);
if (canManageCollections)
{ {
return true; 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 // 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); 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 };
}
} }

View File

@ -619,9 +619,39 @@ public class CiphersController : Controller
var updatedCipher = await GetByIdAsync(id, userId); var updatedCipher = await GetByIdAsync(id, userId);
var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, id, UseFlexibleCollections); var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, id, UseFlexibleCollections);
return new CipherDetailsResponseModel(updatedCipher, _globalSettings, collectionCiphers); 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")] [HttpPut("{id}/collections-admin")]
[HttpPost("{id}/collections-admin")] [HttpPost("{id}/collections-admin")]
public async Task PutCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model) public async Task PutCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model)

View File

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

View File

@ -3,6 +3,7 @@ using Bit.Billing.Models;
using Bit.Billing.Services; using Bit.Billing.Services;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
@ -55,6 +56,7 @@ public class StripeController : Controller
private readonly IStripeEventService _stripeEventService; private readonly IStripeEventService _stripeEventService;
private readonly IStripeFacade _stripeFacade; private readonly IStripeFacade _stripeFacade;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IProviderRepository _providerRepository;
public StripeController( public StripeController(
GlobalSettings globalSettings, GlobalSettings globalSettings,
@ -74,7 +76,8 @@ public class StripeController : Controller
ICurrentContext currentContext, ICurrentContext currentContext,
IStripeEventService stripeEventService, IStripeEventService stripeEventService,
IStripeFacade stripeFacade, IStripeFacade stripeFacade,
IFeatureService featureService) IFeatureService featureService,
IProviderRepository providerRepository)
{ {
_billingSettings = billingSettings?.Value; _billingSettings = billingSettings?.Value;
_hostingEnvironment = hostingEnvironment; _hostingEnvironment = hostingEnvironment;
@ -102,6 +105,7 @@ public class StripeController : Controller
_stripeEventService = stripeEventService; _stripeEventService = stripeEventService;
_stripeFacade = stripeFacade; _stripeFacade = stripeFacade;
_featureService = featureService; _featureService = featureService;
_providerRepository = providerRepository;
} }
[HttpPost("webhook")] [HttpPost("webhook")]
@ -425,7 +429,61 @@ public class StripeController : Controller
} }
var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata); 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 => if (!subscription.Items.Any(i =>
StaticStore.Plans.Any(p => p.PasswordManager.StripePlanId == i.Plan.Id))) 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 }); 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; return;

View File

@ -86,20 +86,20 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
public int? MaxAutoscaleSmSeats { get; set; } public int? MaxAutoscaleSmSeats { get; set; }
public int? MaxAutoscaleSmServiceAccounts { get; set; } public int? MaxAutoscaleSmServiceAccounts { get; set; }
/// <summary> /// <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> /// </summary>
public bool LimitCollectionCreationDeletion { get; set; } public bool LimitCollectionCreationDeletion { get; set; }
/// <summary> /// <summary>
/// Refers to the ability for an organization to limit owner/admin access to all collection items /// If set to true, admins, owners, and some custom users can read/write all collections and items in the Admin Console.
/// <remarks> /// If set to false, users generally need collection-level permissions to read/write a collection or its items.
/// True: Owner/admins can access all items belonging to any collections
/// False: Owner/admins can only access items for collections they are assigned
/// </remarks>
/// </summary> /// </summary>
public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; }
/// <summary> /// <summary>
/// True if the organization is using the Flexible Collections permission changes, false otherwise. /// This is an organization-level feature flag (not controlled via LaunchDarkly) to onboard organizations to the
/// For existing organizations, this must only be set to true once data migrations have been run for this organization. /// 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> /// </summary>
public bool FlexibleCollections { get; set; } public bool FlexibleCollections { get; set; }

View File

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

View File

@ -34,6 +34,7 @@ public interface IOrganizationService
/// </summary> /// </summary>
Task<(Organization organization, OrganizationUser organizationUser)> SignUpAsync(OrganizationLicense license, User owner, Task<(Organization organization, OrganizationUser organizationUser)> SignUpAsync(OrganizationLicense license, User owner,
string ownerKey, string collectionName, string publicKey, string privateKey); string ownerKey, string collectionName, string publicKey, string privateKey);
Task InitiateDeleteAsync(Organization organization, string orgAdminEmail);
Task DeleteAsync(Organization organization); Task DeleteAsync(Organization organization);
Task EnableAsync(Guid organizationId, DateTime? expirationDate); Task EnableAsync(Guid organizationId, DateTime? expirationDate);
Task DisableAsync(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, Task<OrganizationUser> InviteUserAsync(Guid organizationId, Guid? invitingUserId, string email,
OrganizationUserType type, bool accessAll, string externalId, ICollection<CollectionAccessSelection> collections, IEnumerable<Guid> groups); OrganizationUserType type, bool accessAll, string externalId, ICollection<CollectionAccessSelection> collections, IEnumerable<Guid> groups);
Task<OrganizationUser> InviteUserAsync(Guid organizationId, EventSystemUser systemUser, string email, 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<IEnumerable<Tuple<OrganizationUser, string>>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable<Guid> organizationUsersId);
Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false); Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false);
Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,

View File

@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Business; 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.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
@ -60,6 +61,7 @@ public class OrganizationService : IOrganizationService
private readonly IProviderUserRepository _providerUserRepository; private readonly IProviderUserRepository _providerUserRepository;
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery; private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
private readonly IDataProtectorTokenFactory<OrgDeleteTokenable> _orgDeleteTokenDataFactory;
private readonly IProviderRepository _providerRepository; private readonly IProviderRepository _providerRepository;
private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory; private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory;
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory; private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
@ -94,6 +96,7 @@ public class OrganizationService : IOrganizationService
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory, IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory, IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
IDataProtectorTokenFactory<OrgDeleteTokenable> orgDeleteTokenDataFactory,
IProviderRepository providerRepository, IProviderRepository providerRepository,
IFeatureService featureService) IFeatureService featureService)
{ {
@ -123,6 +126,7 @@ public class OrganizationService : IOrganizationService
_providerUserRepository = providerUserRepository; _providerUserRepository = providerUserRepository;
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery; _countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
_orgDeleteTokenDataFactory = orgDeleteTokenDataFactory;
_providerRepository = providerRepository; _providerRepository = providerRepository;
_orgUserInviteTokenableFactory = orgUserInviteTokenableFactory; _orgUserInviteTokenableFactory = orgUserInviteTokenableFactory;
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
@ -434,9 +438,6 @@ public class OrganizationService : IOrganizationService
ValidatePlan(plan, signup.AdditionalSeats, "Password Manager"); ValidatePlan(plan, signup.AdditionalSeats, "Password Manager");
var flexibleCollectionsSignupEnabled =
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup);
var flexibleCollectionsV1Enabled = var flexibleCollectionsV1Enabled =
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1); _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1);
@ -478,14 +479,12 @@ public class OrganizationService : IOrganizationService
// Secrets Manager not available for purchase with Consolidated Billing. // Secrets Manager not available for purchase with Consolidated Billing.
UseSecretsManager = false, UseSecretsManager = false,
// This feature flag indicates that new organizations should be automatically onboarded to // Flexible Collections MVP is fully released and all organizations must always have this setting enabled.
// Flexible Collections enhancements // AC-1714 will remove this flag after all old code has been removed.
FlexibleCollections = flexibleCollectionsSignupEnabled, FlexibleCollections = true,
// These collection management settings smooth the migration for existing organizations by disabling some FC behavior. // This is a transitional setting that defaults to ON until Flexible Collections v1 is released
// If the organization is onboarded to Flexible Collections on signup, we turn them OFF to enable all new behaviour. // (to preserve existing behavior) and defaults to OFF after release (enabling new behavior)
// 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,
AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1Enabled AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1Enabled
}; };
@ -529,9 +528,6 @@ public class OrganizationService : IOrganizationService
await ValidateSignUpPoliciesAsync(signup.Owner.Id); await ValidateSignUpPoliciesAsync(signup.Owner.Id);
} }
var flexibleCollectionsSignupEnabled =
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup);
var flexibleCollectionsV1IsEnabled = var flexibleCollectionsV1IsEnabled =
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1); _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1);
@ -573,14 +569,12 @@ public class OrganizationService : IOrganizationService
UsePasswordManager = true, UsePasswordManager = true,
UseSecretsManager = signup.UseSecretsManager, UseSecretsManager = signup.UseSecretsManager,
// This feature flag indicates that new organizations should be automatically onboarded to // Flexible Collections MVP is fully released and all organizations must always have this setting enabled.
// Flexible Collections enhancements // AC-1714 will remove this flag after all old code has been removed.
FlexibleCollections = flexibleCollectionsSignupEnabled, FlexibleCollections = true,
// These collection management settings smooth the migration for existing organizations by disabling some FC behavior. // This is a transitional setting that defaults to ON until Flexible Collections v1 is released
// If the organization is onboarded to Flexible Collections on signup, we turn them OFF to enable all new behaviour. // (to preserve existing behavior) and defaults to OFF after release (enabling new behavior)
// 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,
AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1IsEnabled AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1IsEnabled
}; };
@ -661,9 +655,6 @@ public class OrganizationService : IOrganizationService
await ValidateSignUpPoliciesAsync(owner.Id); await ValidateSignUpPoliciesAsync(owner.Id);
var flexibleCollectionsSignupEnabled =
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup);
var organization = new Organization var organization = new Organization
{ {
Name = license.Name, Name = license.Name,
@ -709,7 +700,7 @@ public class OrganizationService : IOrganizationService
// This feature flag indicates that new organizations should be automatically onboarded to // This feature flag indicates that new organizations should be automatically onboarded to
// Flexible Collections enhancements // Flexible Collections enhancements
FlexibleCollections = flexibleCollectionsSignupEnabled, FlexibleCollections = true,
}; };
var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false); 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) public async Task DeleteAsync(Organization organization)
{ {
await ValidateDeleteOrganizationAsync(organization); await ValidateDeleteOrganizationAsync(organization);
@ -1323,7 +1331,6 @@ public class OrganizationService : IOrganizationService
var validOrganizationUserIds = validOrganizationUsers.Select(u => u.UserId.Value).ToList(); var validOrganizationUserIds = validOrganizationUsers.Select(u => u.UserId.Value).ToList();
var organization = await GetOrgById(organizationId); var organization = await GetOrgById(organizationId);
var policies = await _policyRepository.GetManyByOrganizationIdAsync(organizationId);
var usersOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(validOrganizationUserIds); var usersOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(validOrganizationUserIds);
var users = await _userRepository.GetManyAsync(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.Status = OrganizationUserStatusType.Confirmed;
orgUser.Key = keys[orgUser.Id]; orgUser.Key = keys[orgUser.Id];
orgUser.Email = null; 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) ICollection<OrganizationUser> userOrgs, IUserService userService)
{ {
var usingTwoFactorPolicy = policies.Any(p => p.Type == PolicyType.TwoFactorAuthentication && p.Enabled); // Enforce Two Factor Authentication Policy for this organization
if (usingTwoFactorPolicy && !await userService.TwoFactorIsEnabledAsync(user)) 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."); throw new BadRequestException("User does not have two-step login enabled.");
} }
var usingSingleOrgPolicy = policies.Any(p => p.Type == PolicyType.SingleOrg && p.Enabled); var hasOtherOrgs = userOrgs.Any(ou => ou.OrganizationId != organizationId);
if (usingSingleOrgPolicy) 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("Cannot confirm this member to the organization until they leave or remove all other organizations.");
{
throw new BadRequestException("User is a member of another organization.");
} }
// 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, public async Task<OrganizationUser> InviteUserAsync(Guid organizationId, EventSystemUser systemUser, string email,
OrganizationUserType type, bool accessAll, string externalId, IEnumerable<CollectionAccessSelection> collections, 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) // 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, 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() var invite = new OrganizationUserInvite()
{ {
@ -1688,7 +1702,8 @@ public class OrganizationService : IOrganizationService
Type = type, Type = type,
AccessAll = accessAll, AccessAll = accessAll,
Collections = collections, Collections = collections,
Groups = groups Groups = groups,
AccessSecretsManager = accessSecretsManager
}; };
var results = systemUser.HasValue ? await InviteUsersAsync(organizationId, systemUser.Value, var results = systemUser.HasValue ? await InviteUsersAsync(organizationId, systemUser.Value,
new (OrganizationUserInvite, string)[] { (invite, externalId) }) : await InviteUsersAsync(organizationId, invitingUserId, new (OrganizationUserInvite, string)[] { (invite, externalId) }) : await InviteUsersAsync(organizationId, invitingUserId,
@ -1787,6 +1802,8 @@ public class OrganizationService : IOrganizationService
enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count; enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count;
} }
var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization);
var userInvites = new List<(OrganizationUserInvite, string)>(); var userInvites = new List<(OrganizationUserInvite, string)>();
foreach (var user in newUsers) foreach (var user in newUsers)
{ {
@ -1803,6 +1820,7 @@ public class OrganizationService : IOrganizationService
Type = OrganizationUserType.User, Type = OrganizationUserType.User,
AccessAll = false, AccessAll = false,
Collections = new List<CollectionAccessSelection>(), Collections = new List<CollectionAccessSelection>(),
AccessSecretsManager = hasStandaloneSecretsManager
}; };
userInvites.Add((invite, user.ExternalId)); userInvites.Add((invite, user.ExternalId));
} }

View File

@ -9,4 +9,5 @@ public interface IAuthRequestRepository : IRepository<AuthRequest, Guid>
Task<ICollection<AuthRequest>> GetManyByUserIdAsync(Guid userId); Task<ICollection<AuthRequest>> GetManyByUserIdAsync(Guid userId);
Task<ICollection<OrganizationAdminAuthRequest>> GetManyPendingByOrganizationIdAsync(Guid organizationId); Task<ICollection<OrganizationAdminAuthRequest>> GetManyPendingByOrganizationIdAsync(Guid organizationId);
Task<ICollection<OrganizationAdminAuthRequest>> GetManyAdminApprovalRequestsByManyIdsAsync(Guid organizationId, IEnumerable<Guid> ids); Task<ICollection<OrganizationAdminAuthRequest>> GetManyAdminApprovalRequestsByManyIdsAsync(Guid organizationId, IEnumerable<Guid> ids);
Task UpdateManyAsync(IEnumerable<AuthRequest> authRequests);
} }

View File

@ -22,6 +22,10 @@ public static class BillingExtensions
PlanType: PlanType.TeamsMonthly or PlanType.EnterpriseMonthly 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) public static bool SupportsConsolidatedBilling(this PlanType planType)
=> planType is PlanType.TeamsMonthly or PlanType.EnterpriseMonthly; => planType is PlanType.TeamsMonthly or PlanType.EnterpriseMonthly;
} }

View File

@ -102,11 +102,6 @@ public static class AuthenticationSchemes
public static class FeatureFlagKeys 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 VaultOnboarding = "vault-onboarding";
public const string BrowserFilelessImport = "browser-fileless-import"; public const string BrowserFilelessImport = "browser-fileless-import";
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; 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 KeyRotationImprovements = "key-rotation-improvements";
public const string DuoRedirect = "duo-redirect"; public const string DuoRedirect = "duo-redirect";
/// <summary> /// <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 /// Exposes a migration button in the web vault which allows users to migrate an existing organization to
/// flexible collections /// flexible collections
/// </summary> /// </summary>
@ -140,6 +131,7 @@ public static class FeatureFlagKeys
public const string AnhFcmv1Migration = "anh-fcmv1-migration"; public const string AnhFcmv1Migration = "anh-fcmv1-migration";
public const string ExtensionRefresh = "extension-refresh"; public const string ExtensionRefresh = "extension-refresh";
public const string RestrictProviderAccess = "restrict-provider-access"; public const string RestrictProviderAccess = "restrict-provider-access";
public const string VaultBulkManagementAction = "vault-bulk-management-action";
public static List<string> GetAllKeys() public static List<string> GetAllKeys()
{ {
@ -154,11 +146,8 @@ public static class FeatureFlagKeys
// place overriding values when needed locally (offline), or return null // place overriding values when needed locally (offline), or return null
return new Dictionary<string, string>() return new Dictionary<string, string>()
{ {
{ TrustedDeviceEncryption, "true" },
{ Fido2VaultCredentials, "true" },
{ DuoRedirect, "true" }, { DuoRedirect, "true" },
{ UnassignedItemsBanner, "true"}, { UnassignedItemsBanner, "true"}
{ FlexibleCollectionsSignup, "true" }
}; };
} }
} }

View File

@ -21,19 +21,19 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" /> <PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.300.86" /> <PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.300.93" />
<PackageReference Include="AWSSDK.SQS" Version="3.7.300.86" /> <PackageReference Include="AWSSDK.SQS" Version="3.7.301.6" />
<PackageReference Include="Azure.Data.Tables" Version="12.8.3" /> <PackageReference Include="Azure.Data.Tables" Version="12.8.3" />
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.2" /> <PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
<PackageReference Include="Azure.Messaging.ServiceBus" Version="7.15.0" /> <PackageReference Include="Azure.Messaging.ServiceBus" Version="7.17.5" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.14.1" /> <PackageReference Include="Azure.Storage.Blobs" Version="12.19.1" />
<PackageReference Include="Azure.Storage.Queues" Version="12.12.0" /> <PackageReference Include="Azure.Storage.Queues" Version="12.17.1" />
<PackageReference Include="BitPay.Light" Version="1.0.1907" /> <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="DnsClient" Version="1.7.0" />
<PackageReference Include="Fido2.AspNet" Version="3.0.1" /> <PackageReference Include="Fido2.AspNet" Version="3.0.1" />
<PackageReference Include="Handlebars.Net" Version="2.1.6" /> <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.AspNetCore.Authentication.JwtBearer" Version="6.0.25" />
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.39.1" /> <PackageReference Include="Microsoft.Azure.Cosmos" Version="3.39.1" />
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" /> <PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />
@ -57,7 +57,7 @@
<PackageReference Include="Otp.NET" Version="1.3.0" /> <PackageReference Include="Otp.NET" Version="1.3.0" />
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" /> <PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="6.0.25" /> <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>
<ItemGroup> <ItemGroup>

View File

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

View File

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

View File

@ -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. /// Flag for whether the user has been explicitly assigned to the collection either directly or through a group.
/// </summary> /// </summary>
public bool Assigned { get; set; } 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; }
} }

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

View File

@ -28,6 +28,8 @@ public record TeamsStarterPlan : Plan
PasswordManager = new TeamsStarterPasswordManagerFeatures(); PasswordManager = new TeamsStarterPasswordManagerFeatures();
SecretsManager = new TeamsStarterSecretsManagerFeatures(); SecretsManager = new TeamsStarterSecretsManagerFeatures();
LegacyYear = 2024;
} }
private record TeamsStarterSecretsManagerFeatures : SecretsManagerPlanFeatures private record TeamsStarterSecretsManagerFeatures : SecretsManagerPlanFeatures

View File

@ -79,5 +79,6 @@ public interface IMailService
Task SendTrustedDeviceAdminApprovalEmailAsync(string email, DateTime utcNow, string ip, string deviceTypeAndIdentifier); Task SendTrustedDeviceAdminApprovalEmailAsync(string email, DateTime utcNow, string ip, string deviceTypeAndIdentifier);
Task SendTrialInitiationEmailAsync(string email); Task SendTrialInitiationEmailAsync(string email);
Task SendInitiateDeletProviderEmailAsync(string email, Provider provider, string token); Task SendInitiateDeletProviderEmailAsync(string email, Provider provider, string token);
Task SendInitiateDeleteOrganzationEmailAsync(string email, Organization organization, string token);
} }

View File

@ -57,4 +57,5 @@ public interface IPaymentService
Task<string> AddSecretsManagerToSubscription(Organization org, Plan plan, int additionalSmSeats, Task<string> AddSecretsManagerToSubscription(Organization org, Plan plan, int additionalSmSeats,
int additionalServiceAccount, DateTime? prorationDate = null); int additionalServiceAccount, DateTime? prorationDate = null);
Task<bool> RisksSubscriptionFailure(Organization organization); Task<bool> RisksSubscriptionFailure(Organization organization);
Task<bool> HasSecretsManagerStandalone(Organization organization);
} }

View File

@ -4,8 +4,6 @@ using Bit.Core.Auth.Models;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities;
using Fido2NetLib; using Fido2NetLib;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
@ -39,8 +37,6 @@ public interface IUserService
Task<IdentityResult> UpdateTempPasswordAsync(User user, string newMasterPassword, string key, string hint); Task<IdentityResult> UpdateTempPasswordAsync(User user, string newMasterPassword, string key, string hint);
Task<IdentityResult> ChangeKdfAsync(User user, string masterPassword, string newMasterPassword, string key, Task<IdentityResult> ChangeKdfAsync(User user, string masterPassword, string newMasterPassword, string key,
KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism); 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<IdentityResult> RefreshSecurityStampAsync(User user, string masterPasswordHash);
Task UpdateTwoFactorProviderAsync(User user, TwoFactorProviderType type, bool setEnabled = true, bool logEvent = true); Task UpdateTwoFactorProviderAsync(User user, TwoFactorProviderType type, bool setEnabled = true, bool logEvent = true);
Task DisableTwoFactorProviderAsync(User user, TwoFactorProviderType type, Task DisableTwoFactorProviderAsync(User user, TwoFactorProviderType type,

View File

@ -1002,6 +1002,30 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message); 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) private static string GetUserIdentifier(string email, string userName)
{ {
return string.IsNullOrEmpty(userName) ? email : CoreHelpers.SanitizeForEmail(userName, false); return string.IsNullOrEmpty(userName) ? email : CoreHelpers.SanitizeForEmail(userName, false);

View File

@ -1800,6 +1800,18 @@ public class StripePaymentService : IPaymentService
return paymentSource == null; 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) private PaymentMethod GetLatestCardPaymentMethod(string customerId)
{ {
var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging( var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging(

View File

@ -14,12 +14,10 @@ using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Tokens; using Bit.Core.Tokens;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Enums; using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Models.Business;
using Bit.Core.Tools.Services; using Bit.Core.Tools.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Repositories; using Bit.Core.Vault.Repositories;
using Fido2NetLib; using Fido2NetLib;
using Fido2NetLib.Objects; using Fido2NetLib.Objects;
@ -862,39 +860,6 @@ public class UserService : UserManager<User>, IUserService, IDisposable
return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); 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) public async Task<IdentityResult> RefreshSecurityStampAsync(User user, string secret)
{ {
if (user == null) if (user == null)

View File

@ -270,5 +270,10 @@ public class NoopMailService : IMailService
} }
public Task SendInitiateDeletProviderEmailAsync(string email, Provider provider, string token) => throw new NotImplementedException(); 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);
}
} }

View File

@ -8,4 +8,6 @@ public enum ReferenceEventSource
Organization, Organization,
[EnumMember(Value = "user")] [EnumMember(Value = "user")]
User, User,
[EnumMember(Value = "provider")]
Provider,
} }

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

View File

@ -1,7 +1,6 @@
using Bit.Core.Auth.UserFeatures.UserKey; using Bit.Core.Auth.UserFeatures.UserKey;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Models.Data; 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 MoveAsync(IEnumerable<Guid> ids, Guid? folderId, Guid userId, bool useFlexibleCollections);
Task DeleteByUserIdAsync(Guid userId); Task DeleteByUserIdAsync(Guid userId);
Task DeleteByOrganizationIdAsync(Guid organizationId); 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 UpdateCiphersAsync(Guid userId, IEnumerable<Cipher> ciphers);
Task CreateAsync(IEnumerable<Cipher> ciphers, IEnumerable<Folder> folders); Task CreateAsync(IEnumerable<Cipher> ciphers, IEnumerable<Folder> folders);
Task CreateAsync(IEnumerable<Cipher> ciphers, IEnumerable<Collection> collections, Task CreateAsync(IEnumerable<Cipher> ciphers, IEnumerable<Collection> collections,

View File

@ -14,8 +14,6 @@ using Microsoft.AspNetCore.Mvc;
namespace Bit.Identity.Controllers; namespace Bit.Identity.Controllers;
// TODO: 2023-10-16, Remove account alias (https://bitwarden.atlassian.net/browse/PM-1247)
[Route("account/[action]")]
[Route("sso/[action]")] [Route("sso/[action]")]
public class SsoController : Controller public class SsoController : Controller
{ {

View File

@ -39,6 +39,7 @@ public static class ServiceCollectionExtensions
} }
options.InputLengthRestrictions.UserName = 256; options.InputLengthRestrictions.UserName = 256;
options.KeyManagement.Enabled = false; options.KeyManagement.Enabled = false;
options.UserInteraction.LoginUrl = "/sso/Login";
}) })
.AddInMemoryCaching() .AddInMemoryCaching()
.AddInMemoryApiResources(ApiResources.GetApiResources()) .AddInMemoryApiResources(ApiResources.GetApiResources())

View File

@ -1,4 +1,5 @@
using System.Data; using System.Data;
using System.Text.Json;
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Models.Data;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -74,4 +75,20 @@ public class AuthRequestRepository : Repository<AuthRequest, Guid>, IAuthRequest
return results.ToList(); 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);
}
}
} }

View File

@ -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) public async Task UpdateCiphersAsync(Guid userId, IEnumerable<Cipher> ciphers)
{ {
if (!ciphers.Any()) if (!ciphers.Any())

View File

@ -69,4 +69,29 @@ public class AuthRequestRepository : Repository<Core.Auth.Entities.AuthRequest,
return orgUserAuthRequests; 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();
}
}
} }

View File

@ -340,7 +340,7 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
ExternalId = collectionGroup.Key.ExternalId, ExternalId = collectionGroup.Key.ExternalId,
ReadOnly = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.ReadOnly))), ReadOnly = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.ReadOnly))),
HidePasswords = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))), 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(); .ToList();
} }
@ -365,7 +365,7 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
ExternalId = collectionGroup.Key.ExternalId, ExternalId = collectionGroup.Key.ExternalId,
ReadOnly = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.ReadOnly))), ReadOnly = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.ReadOnly))),
HidePasswords = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))), 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(); }).ToListAsync();
} }
} }
@ -391,7 +391,8 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
c.Name, c.Name,
c.CreationDate, c.CreationDate,
c.RevisionDate, c.RevisionDate,
c.ExternalId c.ExternalId,
c.Unmanaged
}).Select(collectionGroup => new CollectionAdminDetails }).Select(collectionGroup => new CollectionAdminDetails
{ {
Id = collectionGroup.Key.Id, Id = collectionGroup.Key.Id,
@ -404,7 +405,8 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
HidePasswords = HidePasswords =
Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))), Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))),
Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), 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(); }).ToList();
} }
else else
@ -417,7 +419,8 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
c.Name, c.Name,
c.CreationDate, c.CreationDate,
c.RevisionDate, c.RevisionDate,
c.ExternalId c.ExternalId,
c.Unmanaged
} }
into collectionGroup into collectionGroup
select new CollectionAdminDetails select new CollectionAdminDetails
@ -432,7 +435,8 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
HidePasswords = HidePasswords =
Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))), Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))),
Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), 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(); }).ToListAsync();
} }
@ -511,7 +515,8 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
HidePasswords = HidePasswords =
Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))), Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))),
Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), 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(); }).FirstOrDefault();
} }
else else
@ -539,7 +544,8 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
HidePasswords = HidePasswords =
Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))), Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))),
Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), 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(); }).FirstOrDefaultAsync();
} }

View File

@ -1,4 +1,5 @@
using Bit.Core.Models.Data; using Bit.Core.Enums;
using Bit.Core.Models.Data;
namespace Bit.Infrastructure.EntityFramework.Repositories.Queries; namespace Bit.Infrastructure.EntityFramework.Repositories.Queries;
@ -46,6 +47,17 @@ public class CollectionAdminDetailsQuery : IQuery<CollectionAdminDetails>
from cg in cg_g.DefaultIfEmpty() from cg in cg_g.DefaultIfEmpty()
select new { c, cu, cg }; 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) if (_organizationId.HasValue)
{ {
baseCollectionQuery = baseCollectionQuery.Where(x => x.c.OrganizationId == _organizationId); 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, HidePasswords = (bool?)x.cu.HidePasswords ?? (bool?)x.cg.HidePasswords ?? false,
Manage = (bool?)x.cu.Manage ?? (bool?)x.cg.Manage ?? false, Manage = (bool?)x.cu.Manage ?? (bool?)x.cg.Manage ?? false,
Assigned = x.cu != null || x.cg != null, Assigned = x.cu != null || x.cg != null,
Unmanaged = !activeUserManageRights.Contains(x.c.Id) && !activeGroupManageRights.Contains(x.c.Id),
}); });
} }

View File

@ -19,7 +19,6 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NS = Newtonsoft.Json; using NS = Newtonsoft.Json;
using NSL = Newtonsoft.Json.Linq; using NSL = Newtonsoft.Json.Linq;
using User = Bit.Core.Entities.User;
namespace Bit.Infrastructure.EntityFramework.Vault.Repositories; 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) public async Task UpsertAsync(CipherDetails cipher)
{ {
if (cipher.Id.Equals(default)) if (cipher.Id.Equals(default))

View File

@ -7,8 +7,8 @@
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Notifications' " /> <PropertyGroup Condition=" '$(RunConfiguration)' == 'Notifications' " />
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Notifications-SelfHost' " /> <PropertyGroup Condition=" '$(RunConfiguration)' == 'Notifications-SelfHost' " />
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="8.0.4" /> <PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="8.0.5" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="8.0.4" /> <PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="8.0.5" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -150,6 +150,13 @@ public static class ServiceCollectionExtensions
public static void AddTokenizers(this IServiceCollection services) 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 => services.AddSingleton<IDataProtectorTokenFactory<EmergencyAccessInviteTokenable>>(serviceProvider =>
new DataProtectorTokenFactory<EmergencyAccessInviteTokenable>( new DataProtectorTokenFactory<EmergencyAccessInviteTokenable>(
EmergencyAccessInviteTokenable.ClearTextPrefix, EmergencyAccessInviteTokenable.ClearTextPrefix,

View File

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

View File

@ -31,7 +31,29 @@ BEGIN
CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL
THEN 0 THEN 0
ELSE 1 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 FROM
[dbo].[CollectionView] C [dbo].[CollectionView] C
LEFT JOIN LEFT JOIN

View File

@ -31,7 +31,29 @@ BEGIN
CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL
THEN 0 THEN 0
ELSE 1 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 FROM
[dbo].[CollectionView] C [dbo].[CollectionView] C
LEFT JOIN LEFT JOIN

View File

@ -13,7 +13,7 @@ BEGIN
ExternalId, ExternalId,
MIN([ReadOnly]) AS [ReadOnly], MIN([ReadOnly]) AS [ReadOnly],
MIN([HidePasswords]) AS [HidePasswords], MIN([HidePasswords]) AS [HidePasswords],
MIN([Manage]) AS [Manage] MAX([Manage]) AS [Manage]
FROM FROM
[dbo].[UserCollectionDetails](@UserId) [dbo].[UserCollectionDetails](@UserId)
WHERE WHERE

View File

@ -13,7 +13,7 @@ BEGIN
ExternalId, ExternalId,
MIN([ReadOnly]) AS [ReadOnly], MIN([ReadOnly]) AS [ReadOnly],
MIN([HidePasswords]) AS [HidePasswords], MIN([HidePasswords]) AS [HidePasswords],
MIN([Manage]) AS [Manage] MAX([Manage]) AS [Manage]
FROM FROM
[dbo].[UserCollectionDetails_V2](@UserId) [dbo].[UserCollectionDetails_V2](@UserId)
WHERE WHERE

View File

@ -13,7 +13,7 @@ BEGIN
ExternalId, ExternalId,
MIN([ReadOnly]) AS [ReadOnly], MIN([ReadOnly]) AS [ReadOnly],
MIN([HidePasswords]) AS [HidePasswords], MIN([HidePasswords]) AS [HidePasswords],
MIN([Manage]) AS [Manage] MAX([Manage]) AS [Manage]
FROM FROM
[dbo].[UserCollectionDetails](@UserId) [dbo].[UserCollectionDetails](@UserId)
GROUP BY GROUP BY

View File

@ -13,7 +13,7 @@ BEGIN
ExternalId, ExternalId,
MIN([ReadOnly]) AS [ReadOnly], MIN([ReadOnly]) AS [ReadOnly],
MIN([HidePasswords]) AS [HidePasswords], MIN([HidePasswords]) AS [HidePasswords],
MIN([Manage]) AS [Manage] MAX([Manage]) AS [Manage]
FROM FROM
[dbo].[UserCollectionDetails_V2](@UserId) [dbo].[UserCollectionDetails_V2](@UserId)
GROUP BY GROUP BY

View File

@ -260,6 +260,54 @@ public class GroupsControllerTests
Assert.Equal(groupRequestModel.AccessAll, response.AccessAll); 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] [Theory]
[BitAutoData] [BitAutoData]
public async Task Put_UpdateCollections_OnlyUpdatesCollectionsTheSavingUserCanUpdate(GroupRequestModel groupRequestModel, public async Task Put_UpdateCollections_OnlyUpdatesCollectionsTheSavingUserCanUpdate(GroupRequestModel groupRequestModel,

View File

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

View File

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

View File

@ -301,7 +301,7 @@ public class ProviderClientsControllerTests
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task UpdateAsync_NoContent( public async Task UpdateAsync_AssignedSeats_NoContent(
Guid providerId, Guid providerId,
Guid providerOrganizationId, Guid providerOrganizationId,
UpdateClientOrganizationRequestBody requestBody, UpdateClientOrganizationRequestBody requestBody,
@ -333,6 +333,50 @@ public class ProviderClientsControllerTests
organization, organization,
requestBody.AssignedSeats); 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); Assert.IsType<Ok>(result);
} }
#endregion #endregion

View File

@ -828,108 +828,169 @@ public class BulkCollectionAuthorizationHandlerTests
} }
[Theory, BitAutoData, CollectionCustomization] [Theory, BitAutoData, CollectionCustomization]
public async Task CanUpdateUsers_WithManageUsersCustomPermission_Success( public async Task CanUpdateUsers_WithManageUsersCustomPermission_V1Disabled_Success(
SutProvider<BulkCollectionAuthorizationHandler> sutProvider, SutProvider<BulkCollectionAuthorizationHandler> sutProvider, ICollection<Collection> collections,
ICollection<Collection> collections, CurrentContextOrganization organization, Guid actingUserId)
CurrentContextOrganization organization)
{ {
var actingUserId = Guid.NewGuid();
organization.Type = OrganizationUserType.Custom; organization.Type = OrganizationUserType.Custom;
organization.Permissions = new Permissions organization.Permissions = new Permissions
{ {
ManageUsers = true ManageUsers = true
}; };
var operationsToTest = new[]
{
BulkCollectionOperations.ModifyUserAccess,
};
foreach (var op in operationsToTest)
{
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(actingUserId); sutProvider.GetDependency<ICurrentContext>().UserId.Returns(actingUserId);
sutProvider.GetDependency<ICurrentContext>().GetOrganization(organization.Id).Returns(organization); sutProvider.GetDependency<ICurrentContext>().GetOrganization(organization.Id).Returns(organization);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1)
.Returns(false);
var context = new AuthorizationHandlerContext( var context = new AuthorizationHandlerContext(
new[] { op }, new[] { BulkCollectionOperations.ModifyUserAccess },
new ClaimsPrincipal(), new ClaimsPrincipal(),
collections); collections);
await sutProvider.Sut.HandleAsync(context); await sutProvider.Sut.HandleAsync(context);
Assert.True(context.HasSucceeded); Assert.True(context.HasSucceeded);
// Recreate the SUT to reset the mocks/dependencies between tests
sutProvider.Recreate();
}
} }
[Theory, BitAutoData, CollectionCustomization] [Theory, BitAutoData, CollectionCustomization]
public async Task CanUpdateGroups_WithManageGroupsCustomPermission_Success( public async Task CanUpdateUsers_WithManageUsersCustomPermission_AllowAdminAccessIsTrue_Success(
SutProvider<BulkCollectionAuthorizationHandler> sutProvider, SutProvider<BulkCollectionAuthorizationHandler> sutProvider, ICollection<Collection> collections,
ICollection<Collection> collections, CurrentContextOrganization organization, Guid actingUserId)
CurrentContextOrganization organization)
{ {
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.Type = OrganizationUserType.Custom;
organization.Permissions = new Permissions organization.Permissions = new Permissions
{ {
ManageGroups = true ManageGroups = true
}; };
var operationsToTest = new[]
{
BulkCollectionOperations.ModifyGroupAccess,
};
foreach (var op in operationsToTest)
{
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(actingUserId); sutProvider.GetDependency<ICurrentContext>().UserId.Returns(actingUserId);
sutProvider.GetDependency<ICurrentContext>().GetOrganization(organization.Id).Returns(organization); sutProvider.GetDependency<ICurrentContext>().GetOrganization(organization.Id).Returns(organization);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1)
.Returns(false);
var context = new AuthorizationHandlerContext( var context = new AuthorizationHandlerContext(
new[] { op }, new[] { BulkCollectionOperations.ModifyGroupAccess },
new ClaimsPrincipal(), new ClaimsPrincipal(),
collections); collections);
await sutProvider.Sut.HandleAsync(context); await sutProvider.Sut.HandleAsync(context);
Assert.True(context.HasSucceeded); Assert.True(context.HasSucceeded);
// Recreate the SUT to reset the mocks/dependencies between tests
sutProvider.Recreate();
}
} }
[Theory, CollectionCustomization] [Theory, BitAutoData, CollectionCustomization]
[BitAutoData(OrganizationUserType.Admin)] public async Task CanUpdateGroups_WithManageGroupsCustomPermission_AllowAdminAccessIsTrue_Success(
[BitAutoData(OrganizationUserType.Owner)] SutProvider<BulkCollectionAuthorizationHandler> sutProvider, ICollection<Collection> collections,
public async Task CanDeleteAsync_WhenAdminOrOwner_Success( CurrentContextOrganization organization, Guid actingUserId)
OrganizationUserType userType,
Guid userId, SutProvider<BulkCollectionAuthorizationHandler> sutProvider,
ICollection<Collection> collections,
CurrentContextOrganization organization)
{ {
organization.Type = userType; organization.Type = OrganizationUserType.Custom;
organization.Permissions = new Permissions(); 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( var context = new AuthorizationHandlerContext(
new[] { BulkCollectionOperations.Delete }, new[] { BulkCollectionOperations.ModifyGroupAccess },
new ClaimsPrincipal(), new ClaimsPrincipal(),
collections); collections);
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(userId);
sutProvider.GetDependency<ICurrentContext>().GetOrganization(organization.Id).Returns(organization);
await sutProvider.Sut.HandleAsync(context); await sutProvider.Sut.HandleAsync(context);
Assert.True(context.HasSucceeded); 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] [Theory, BitAutoData, CollectionCustomization]
public async Task CanDeleteAsync_WithDeleteAnyCollectionPermission_Success( public async Task CanDeleteAsync_WithDeleteAnyCollectionPermission_Success(
SutProvider<BulkCollectionAuthorizationHandler> sutProvider, SutProvider<BulkCollectionAuthorizationHandler> sutProvider,
@ -959,8 +1020,63 @@ public class BulkCollectionAuthorizationHandlerTests
Assert.True(context.HasSucceeded); 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] [Theory, BitAutoData, CollectionCustomization]
public async Task CanDeleteAsync_WithManageCollectionPermission_Success( public async Task CanDeleteAsync_WhenUser_LimitCollectionCreationDeletionFalse_WithCanManagePermission_Success(
SutProvider<BulkCollectionAuthorizationHandler> sutProvider, SutProvider<BulkCollectionAuthorizationHandler> sutProvider,
ICollection<CollectionDetails> collections, ICollection<CollectionDetails> collections,
CurrentContextOrganization organization) CurrentContextOrganization organization)
@ -991,6 +1107,184 @@ public class BulkCollectionAuthorizationHandlerTests
Assert.True(context.HasSucceeded); 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] [Theory, CollectionCustomization]
[BitAutoData(OrganizationUserType.User)] [BitAutoData(OrganizationUserType.User)]
[BitAutoData(OrganizationUserType.Custom)] [BitAutoData(OrganizationUserType.Custom)]
@ -1102,7 +1396,8 @@ public class BulkCollectionAuthorizationHandlerTests
{ collections.First().OrganizationId, { collections.First().OrganizationId,
new OrganizationAbility new OrganizationAbility
{ {
LimitCollectionCreationDeletion = true LimitCollectionCreationDeletion = true,
AllowAdminAccessToAllCollectionItems = true
} }
} }
}; };
@ -1177,12 +1472,14 @@ public class BulkCollectionAuthorizationHandlerTests
private static void ArrangeOrganizationAbility( private static void ArrangeOrganizationAbility(
SutProvider<BulkCollectionAuthorizationHandler> sutProvider, SutProvider<BulkCollectionAuthorizationHandler> sutProvider,
CurrentContextOrganization organization, bool limitCollectionCreationDeletion) CurrentContextOrganization organization, bool limitCollectionCreationDeletion,
bool allowAdminAccessToAllCollectionItems = true)
{ {
var organizationAbility = new OrganizationAbility(); var organizationAbility = new OrganizationAbility();
organizationAbility.Id = organization.Id; organizationAbility.Id = organization.Id;
organizationAbility.FlexibleCollections = true; organizationAbility.FlexibleCollections = true;
organizationAbility.LimitCollectionCreationDeletion = limitCollectionCreationDeletion; organizationAbility.LimitCollectionCreationDeletion = limitCollectionCreationDeletion;
organizationAbility.AllowAdminAccessToAllCollectionItems = allowAdminAccessToAllCollectionItems;
sutProvider.GetDependency<IApplicationCacheService>().GetOrganizationAbilityAsync(organizationAbility.Id) sutProvider.GetDependency<IApplicationCacheService>().GetOrganizationAbilityAsync(organizationAbility.Id)
.Returns(organizationAbility); .Returns(organizationAbility);

View File

@ -3,9 +3,11 @@ using Bit.Api.Vault.Controllers;
using Bit.Api.Vault.Models.Request; using Bit.Api.Vault.Models.Request;
using Bit.Core; using Bit.Core;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Data.Organizations;
using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Models.Data; 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;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute; using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Xunit; using Xunit;
using CipherType = Bit.Core.Vault.Enums.CipherType;
namespace Bit.Api.Test.Controllers; namespace Bit.Api.Test.Controllers;
@ -50,6 +54,92 @@ public class CiphersControllerTests
Assert.Equal(isFavorite, result.Favorite); 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] [Theory]
[BitAutoData(OrganizationUserType.Admin, true, true)] [BitAutoData(OrganizationUserType.Admin, true, true)]
[BitAutoData(OrganizationUserType.Owner, true, true)] [BitAutoData(OrganizationUserType.Owner, true, true)]

View File

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

View File

@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Business.Tokenables;
@ -39,7 +40,6 @@ using NSubstitute.ReturnsExtensions;
using Xunit; using Xunit;
using Organization = Bit.Core.AdminConsole.Entities.Organization; using Organization = Bit.Core.AdminConsole.Entities.Organization;
using OrganizationUser = Bit.Core.Entities.OrganizationUser; using OrganizationUser = Bit.Core.Entities.OrganizationUser;
using Policy = Bit.Core.AdminConsole.Entities.Policy;
namespace Bit.Core.Test.Services; namespace Bit.Core.Test.Services;
@ -252,7 +252,7 @@ public class OrganizationServiceTests
[Theory] [Theory]
[BitAutoData(PlanType.FamiliesAnnually)] [BitAutoData(PlanType.FamiliesAnnually)]
public async Task SignUp_WithFlexibleCollections_SetsAccessAllToFalse public async Task SignUp_EnablesFlexibleCollectionsFeatures
(PlanType planType, OrganizationSignup signup, SutProvider<OrganizationService> sutProvider) (PlanType planType, OrganizationSignup signup, SutProvider<OrganizationService> sutProvider)
{ {
signup.Plan = planType; signup.Plan = planType;
@ -261,10 +261,6 @@ public class OrganizationServiceTests
signup.PremiumAccessAddon = false; signup.PremiumAccessAddon = false;
signup.UseSecretsManager = false; signup.UseSecretsManager = false;
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup)
.Returns(true);
// Extract orgUserId when created // Extract orgUserId when created
Guid? orgUserId = null; Guid? orgUserId = null;
await sutProvider.GetDependency<IOrganizationUserRepository>() await sutProvider.GetDependency<IOrganizationUserRepository>()
@ -272,6 +268,10 @@ public class OrganizationServiceTests
var result = await sutProvider.Sut.SignUpAsync(signup); 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 // Assert: AccessAll is not used
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).CreateAsync( await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).CreateAsync(
Arg.Is<OrganizationUser>(o => Arg.Is<OrganizationUser>(o =>
@ -295,33 +295,6 @@ public class OrganizationServiceTests
Assert.NotNull(result.Item2); 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] [Theory]
[BitAutoData(PlanType.EnterpriseAnnually)] [BitAutoData(PlanType.EnterpriseAnnually)]
[BitAutoData(PlanType.EnterpriseMonthly)] [BitAutoData(PlanType.EnterpriseMonthly)]
@ -1441,15 +1414,15 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
[Theory, BitAutoData] [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(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) string key, SutProvider<OrganizationService> sutProvider)
{ {
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>(); var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>(); var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
var policyRepository = sutProvider.GetDependency<IPolicyRepository>();
var userRepository = sutProvider.GetDependency<IUserRepository>(); var userRepository = sutProvider.GetDependency<IUserRepository>();
var policyService = sutProvider.GetDependency<IPolicyService>();
var userService = Substitute.For<IUserService>(); var userService = Substitute.For<IUserService>();
org.PlanType = PlanType.EnterpriseAnnually; org.PlanType = PlanType.EnterpriseAnnually;
@ -1460,23 +1433,84 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg });
organizationRepository.GetByIdAsync(org.Id).Returns(org); organizationRepository.GetByIdAsync(org.Id).Returns(org);
userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); 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>( var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService)); () => 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] [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(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) string key, SutProvider<OrganizationService> sutProvider)
{ {
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>(); var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>(); var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
var policyRepository = sutProvider.GetDependency<IPolicyRepository>();
var userRepository = sutProvider.GetDependency<IUserRepository>(); 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>(); var userService = Substitute.For<IUserService>();
org.PlanType = PlanType.EnterpriseAnnually; org.PlanType = PlanType.EnterpriseAnnually;
@ -1486,7 +1520,8 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg });
organizationRepository.GetByIdAsync(org.Id).Returns(org); organizationRepository.GetByIdAsync(org.Id).Returns(org);
userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); 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>( var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService)); () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService));
@ -1494,15 +1529,15 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
} }
[Theory, BitAutoData] [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, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
[Policy(PolicyType.TwoFactorAuthentication)] Policy twoFactorPolicy, [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, string key, SutProvider<OrganizationService> sutProvider) string key, SutProvider<OrganizationService> sutProvider)
{ {
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>(); var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>(); var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
var policyRepository = sutProvider.GetDependency<IPolicyRepository>();
var userRepository = sutProvider.GetDependency<IUserRepository>(); var userRepository = sutProvider.GetDependency<IUserRepository>();
var policyService = sutProvider.GetDependency<IPolicyService>();
var userService = Substitute.For<IUserService>(); var userService = Substitute.For<IUserService>();
org.PlanType = PlanType.EnterpriseAnnually; org.PlanType = PlanType.EnterpriseAnnually;
@ -1511,7 +1546,8 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
organizationRepository.GetByIdAsync(org.Id).Returns(org); organizationRepository.GetByIdAsync(org.Id).Returns(org);
userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); 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); userService.TwoFactorIsEnabledAsync(user).Returns(true);
await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService); 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 orgUser2,
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser3, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser3,
OrganizationUser anotherOrgUser, User user1, User user2, User user3, OrganizationUser anotherOrgUser, User user1, User user2, User user3,
[Policy(PolicyType.TwoFactorAuthentication)] Policy twoFactorPolicy, [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, string key, SutProvider<OrganizationService> sutProvider) [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy,
string key, SutProvider<OrganizationService> sutProvider)
{ {
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>(); var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>(); var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
var policyRepository = sutProvider.GetDependency<IPolicyRepository>();
var userRepository = sutProvider.GetDependency<IUserRepository>(); var userRepository = sutProvider.GetDependency<IUserRepository>();
var policyService = sutProvider.GetDependency<IPolicyService>();
var userService = Substitute.For<IUserService>(); var userService = Substitute.For<IUserService>();
org.PlanType = PlanType.EnterpriseAnnually; org.PlanType = PlanType.EnterpriseAnnually;
@ -1543,10 +1580,14 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(orgUsers); organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(orgUsers);
organizationRepository.GetByIdAsync(org.Id).Returns(org); organizationRepository.GetByIdAsync(org.Id).Returns(org);
userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user1, user2, user3 }); 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(user1).Returns(true);
userService.TwoFactorIsEnabledAsync(user2).Returns(false); userService.TwoFactorIsEnabledAsync(user2).Returns(false);
userService.TwoFactorIsEnabledAsync(user3).Returns(true); userService.TwoFactorIsEnabledAsync(user3).Returns(true);
singleOrgPolicy.OrganizationId = org.Id;
policyService.GetPoliciesApplicableToUserAsync(user3.Id, PolicyType.SingleOrg)
.Returns(new[] { singleOrgPolicy });
organizationUserRepository.GetManyByManyUsersAsync(default) organizationUserRepository.GetManyByManyUsersAsync(default)
.ReturnsForAnyArgs(new[] { orgUser1, orgUser2, orgUser3, anotherOrgUser }); .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); var result = await sutProvider.Sut.ConfirmUsersAsync(confirmingUser.OrganizationId, keys, confirmingUser.Id, userService);
Assert.Contains("", result[0].Item2); Assert.Contains("", result[0].Item2);
Assert.Contains("User does not have two-step login enabled.", result[1].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] [Theory, BitAutoData]

View File

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

View File

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

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

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

View File

@ -72,6 +72,113 @@ public class AuthRequestRepositoryTests
Assert.Equal(4, numberOfDeleted); 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) private static AuthRequest CreateAuthRequest(Guid userId, AuthRequestType authRequestType, DateTime creationDate, bool? approved = null, DateTime? responseDate = null)
{ {
return new AuthRequest return new AuthRequest

View File

@ -310,6 +310,7 @@ public class CollectionRepositoryTests
Assert.True(c1.Manage); Assert.True(c1.Manage);
Assert.False(c1.ReadOnly); Assert.False(c1.ReadOnly);
Assert.False(c1.HidePasswords); Assert.False(c1.HidePasswords);
Assert.False(c1.Unmanaged);
}, c2 => }, c2 =>
{ {
Assert.NotNull(c2); Assert.NotNull(c2);
@ -319,6 +320,7 @@ public class CollectionRepositoryTests
Assert.False(c2.Manage); Assert.False(c2.Manage);
Assert.True(c2.ReadOnly); Assert.True(c2.ReadOnly);
Assert.False(c2.HidePasswords); Assert.False(c2.HidePasswords);
Assert.True(c2.Unmanaged);
}, c3 => }, c3 =>
{ {
Assert.NotNull(c3); Assert.NotNull(c3);
@ -328,6 +330,7 @@ public class CollectionRepositoryTests
Assert.False(c3.Manage); Assert.False(c3.Manage);
Assert.False(c3.ReadOnly); Assert.False(c3.ReadOnly);
Assert.False(c3.HidePasswords); Assert.False(c3.HidePasswords);
Assert.False(c3.Unmanaged);
}); });
} }
@ -436,6 +439,7 @@ public class CollectionRepositoryTests
Assert.True(c1.Manage); Assert.True(c1.Manage);
Assert.False(c1.ReadOnly); Assert.False(c1.ReadOnly);
Assert.False(c1.HidePasswords); Assert.False(c1.HidePasswords);
Assert.False(c1.Unmanaged);
}, c2 => }, c2 =>
{ {
Assert.NotNull(c2); Assert.NotNull(c2);
@ -445,6 +449,7 @@ public class CollectionRepositoryTests
Assert.False(c2.Manage); Assert.False(c2.Manage);
Assert.True(c2.ReadOnly); Assert.True(c2.ReadOnly);
Assert.False(c2.HidePasswords); Assert.False(c2.HidePasswords);
Assert.True(c2.Unmanaged);
}, c3 => }, c3 =>
{ {
Assert.NotNull(c3); Assert.NotNull(c3);
@ -454,6 +459,7 @@ public class CollectionRepositoryTests
Assert.True(c3.Manage); // Group 2 is Manage Assert.True(c3.Manage); // Group 2 is Manage
Assert.False(c3.ReadOnly); Assert.False(c3.ReadOnly);
Assert.False(c3.HidePasswords); Assert.False(c3.HidePasswords);
Assert.False(c3.Unmanaged);
}); });
} }
} }

View File

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

View File

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

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

View File

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