diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index b848b9918a..57c8ac6fca 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -198,6 +198,13 @@ jobs: PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }} run: gh pr merge $PR_NUMBER --squash --auto --delete-branch + - name: Report upcoming release version to Slack + if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} + uses: bitwarden/gh-actions/report-upcoming-release-version@main + with: + version: ${{ steps.set-final-version-output.outputs.version }} + project: ${{ github.repository }} + AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} cut_rc: name: Cut RC branch diff --git a/Directory.Build.props b/Directory.Build.props index 804c958d34..be81357bbe 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2024.5.0 + 2024.5.1 Bit.$(MSBuildProjectName) enable diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs index 1f96202d86..16d62d69c3 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs @@ -46,6 +46,13 @@ public class CreateProviderCommand : ICreateProviderCommand throw new BadRequestException("Invalid owner. Owner must be an existing Bitwarden user."); } + var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); + + if (isConsolidatedBillingEnabled) + { + provider.Gateway = GatewayType.Stripe; + } + await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Pending); var providerUser = new ProviderUser @@ -56,8 +63,6 @@ public class CreateProviderCommand : ICreateProviderCommand Status = ProviderUserStatusType.Confirmed, }; - var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); - if (isConsolidatedBillingEnabled) { var providerPlans = new List diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs index 9207c64ac4..3fb0b05988 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs @@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Extensions; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -76,6 +77,35 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv organization.BillingEmail = organizationOwnerEmails.MinBy(email => email); + await ResetOrganizationBillingAsync(organization, provider, organizationOwnerEmails); + + await _organizationRepository.ReplaceAsync(organization); + + await _providerOrganizationRepository.DeleteAsync(providerOrganization); + + await _eventService.LogProviderOrganizationEventAsync( + providerOrganization, + EventType.ProviderOrganization_Removed); + } + + /// + /// 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. + /// + private async Task ResetOrganizationBillingAsync( + Organization organization, + Provider provider, + IEnumerable organizationOwnerEmails) + { + if (!organization.IsStripeEnabled()) + { + return; + } + + var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); + var customerUpdateOptions = new CustomerUpdateOptions { Coupon = string.Empty, @@ -84,11 +114,10 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, customerUpdateOptions); - var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); - if (isConsolidatedBillingEnabled && provider.Status == ProviderStatusType.Billable) { var plan = StaticStore.GetPlan(organization.PlanType).PasswordManager; + var subscriptionCreateOptions = new SubscriptionCreateOptions { Customer = organization.GatewayCustomerId, @@ -103,8 +132,11 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, Items = [new SubscriptionItemOptions { Price = plan.StripeSeatPlanId, Quantity = organization.Seats }] }; + var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); + organization.GatewaySubscriptionId = subscription.Id; + await _scaleSeatsCommand.ScalePasswordManagerSeats(provider, organization.PlanType, -(organization.Seats ?? 0)); } @@ -115,21 +147,14 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv CollectionMethod = "send_invoice", DaysUntilDue = 30 }; + await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, subscriptionUpdateOptions); } - await _organizationRepository.ReplaceAsync(organization); - await _mailService.SendProviderUpdatePaymentMethod( organization.Id, organization.Name, provider.Name, organizationOwnerEmails); - - await _providerOrganizationRepository.DeleteAsync(providerOrganization); - - await _eventService.LogProviderOrganizationEventAsync( - providerOrganization, - EventType.ProviderOrganization_Removed); } } diff --git a/bitwarden_license/src/Scim/Users/PostUserCommand.cs b/bitwarden_license/src/Scim/Users/PostUserCommand.cs index 920ca6c77b..a96633e013 100644 --- a/bitwarden_license/src/Scim/Users/PostUserCommand.cs +++ b/bitwarden_license/src/Scim/Users/PostUserCommand.cs @@ -14,17 +14,23 @@ namespace Bit.Scim.Users; public class PostUserCommand : IPostUserCommand { + private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationService _organizationService; + private readonly IPaymentService _paymentService; private readonly IScimContext _scimContext; public PostUserCommand( + IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, IOrganizationService organizationService, + IPaymentService paymentService, IScimContext scimContext) { + _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _organizationService = organizationService; + _paymentService = paymentService; _scimContext = scimContext; } @@ -80,8 +86,13 @@ public class PostUserCommand : IPostUserCommand throw new ConflictException(); } + var organization = await _organizationRepository.GetByIdAsync(organizationId); + + var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization); + var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, EventSystemUser.SCIM, email, - OrganizationUserType.User, false, externalId, new List(), new List()); + OrganizationUserType.User, false, externalId, new List(), new List(), hasStandaloneSecretsManager); + var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id); return orgUser; diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs index e175b653d9..e5b0a4e3d3 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs @@ -14,6 +14,7 @@ using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Stripe; using Xunit; +using IMailService = Bit.Core.Services.IMailService; namespace Bit.Commercial.Core.Test.AdminConsole.ProviderFeatures; @@ -83,6 +84,55 @@ public class RemoveOrganizationFromProviderCommandTests Assert.Equal("Organization must have at least one confirmed owner.", exception.Message); } + [Theory, BitAutoData] + public async Task RemoveOrganizationFromProvider_NoStripeObjects_MakesCorrectInvocations( + Provider provider, + ProviderOrganization providerOrganization, + Organization organization, + SutProvider sutProvider) + { + organization.GatewayCustomerId = null; + organization.GatewaySubscriptionId = null; + + providerOrganization.ProviderId = provider.Id; + + var organizationRepository = sutProvider.GetDependency(); + + sutProvider.GetDependency().HasConfirmedOwnersExceptAsync( + providerOrganization.OrganizationId, + Array.Empty(), + includeProvider: false) + .Returns(true); + + var organizationOwnerEmails = new List { "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( + org => org.Id == organization.Id && org.BillingEmail == "a@gmail.com")); + + var stripeAdapter = sutProvider.GetDependency(); + + await stripeAdapter.DidNotReceiveWithAnyArgs().CustomerUpdateAsync(Arg.Any(), Arg.Any()); + + await stripeAdapter.DidNotReceiveWithAnyArgs().SubscriptionUpdateAsync(Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().SendProviderUpdatePaymentMethod( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + + await sutProvider.GetDependency().Received(1) + .DeleteAsync(providerOrganization); + + await sutProvider.GetDependency().Received(1).LogProviderOrganizationEventAsync( + providerOrganization, + EventType.ProviderOrganization_Removed); + } + [Theory, BitAutoData] public async Task RemoveOrganizationFromProvider_MakesCorrectInvocations__FeatureFlagOff( Provider provider, diff --git a/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs b/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs index 304a290709..4c67f0173e 100644 --- a/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs +++ b/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs @@ -1,4 +1,5 @@ -using Bit.Core.Enums; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; @@ -19,7 +20,7 @@ public class PostUserCommandTests { [Theory] [BitAutoData] - public async Task PostUser_Success(SutProvider sutProvider, string externalId, Guid organizationId, List emails, ICollection organizationUsers, Core.Entities.OrganizationUser newUser) + public async Task PostUser_Success(SutProvider sutProvider, string externalId, Guid organizationId, List emails, ICollection organizationUsers, Core.Entities.OrganizationUser newUser, Organization organization) { var scimUserRequestModel = new ScimUserRequestModel { @@ -33,16 +34,20 @@ public class PostUserCommandTests .GetManyDetailsByOrganizationAsync(organizationId) .Returns(organizationUsers); + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + sutProvider.GetDependency().HasSecretsManagerStandalone(organization).Returns(true); + sutProvider.GetDependency() .InviteUserAsync(organizationId, EventSystemUser.SCIM, scimUserRequestModel.PrimaryEmail.ToLowerInvariant(), OrganizationUserType.User, false, externalId, Arg.Any>(), - Arg.Any>()) + Arg.Any>(), true) .Returns(newUser); var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel); await sutProvider.GetDependency().Received(1).InviteUserAsync(organizationId, EventSystemUser.SCIM, scimUserRequestModel.PrimaryEmail.ToLowerInvariant(), - OrganizationUserType.User, false, scimUserRequestModel.ExternalId, Arg.Any>(), Arg.Any>()); + OrganizationUserType.User, false, scimUserRequestModel.ExternalId, Arg.Any>(), Arg.Any>(), true); await sutProvider.GetDependency().Received(1).GetDetailsByIdAsync(newUser.Id); } diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 5d89be0c42..88c0467460 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -269,6 +269,35 @@ public class OrganizationsController : Controller return RedirectToAction("Index"); } + [HttpPost] + [ValidateAntiForgeryToken] + [RequirePermission(Permission.Org_Delete)] + public async Task 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 TriggerBillingSync(Guid id) { var organization = await _organizationRepository.GetByIdAsync(id); @@ -349,7 +378,10 @@ public class OrganizationsController : Controller providerOrganization, organization); - await _removePaymentMethodCommand.RemovePaymentMethod(organization); + if (organization.IsStripeEnabled()) + { + await _removePaymentMethodCommand.RemovePaymentMethod(organization); + } return Json(null); } diff --git a/src/Admin/AdminConsole/Controllers/ProviderOrganizationsController.cs b/src/Admin/AdminConsole/Controllers/ProviderOrganizationsController.cs index a017b2b435..b143a2c42b 100644 --- a/src/Admin/AdminConsole/Controllers/ProviderOrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/ProviderOrganizationsController.cs @@ -3,6 +3,7 @@ using Bit.Admin.Utilities; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Extensions; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Utilities; @@ -69,7 +70,10 @@ public class ProviderOrganizationsController : Controller } - await _removePaymentMethodCommand.RemovePaymentMethod(organization); + if (organization.IsStripeEnabled()) + { + await _removePaymentMethodCommand.RemovePaymentMethod(organization); + } return Json(null); } diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index 0d3f7f996a..160af7893e 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -17,7 +17,6 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -36,7 +35,6 @@ public class ProvidersController : Controller private readonly GlobalSettings _globalSettings; private readonly IApplicationCacheService _applicationCacheService; private readonly IProviderService _providerService; - private readonly IReferenceEventService _referenceEventService; private readonly IUserService _userService; private readonly ICreateProviderCommand _createProviderCommand; private readonly IFeatureService _featureService; @@ -51,7 +49,6 @@ public class ProvidersController : Controller IProviderService providerService, GlobalSettings globalSettings, IApplicationCacheService applicationCacheService, - IReferenceEventService referenceEventService, IUserService userService, ICreateProviderCommand createProviderCommand, IFeatureService featureService, @@ -65,7 +62,6 @@ public class ProvidersController : Controller _providerService = providerService; _globalSettings = globalSettings; _applicationCacheService = applicationCacheService; - _referenceEventService = referenceEventService; _userService = userService; _createProviderCommand = createProviderCommand; _featureService = featureService; @@ -104,8 +100,8 @@ public class ProvidersController : Controller return View(new CreateProviderModel { OwnerEmail = ownerEmail, - TeamsMinimumSeats = teamsMinimumSeats, - EnterpriseMinimumSeats = enterpriseMinimumSeats + TeamsMonthlySeatMinimum = teamsMinimumSeats, + EnterpriseMonthlySeatMinimum = enterpriseMinimumSeats }); } @@ -123,8 +119,11 @@ public class ProvidersController : Controller switch (provider.Type) { case ProviderType.Msp: - await _createProviderCommand.CreateMspAsync(provider, model.OwnerEmail, model.TeamsMinimumSeats, - model.EnterpriseMinimumSeats); + await _createProviderCommand.CreateMspAsync( + provider, + model.OwnerEmail, + model.TeamsMonthlySeatMinimum, + model.EnterpriseMonthlySeatMinimum); break; case ProviderType.Reseller: await _createProviderCommand.CreateResellerAsync(provider); @@ -167,8 +166,9 @@ public class ProvidersController : Controller return View(new ProviderEditModel(provider, users, providerOrganizations, new List())); } - var providerPlan = await _providerPlanRepository.GetByProviderId(id); - return View(new ProviderEditModel(provider, users, providerOrganizations, providerPlan)); + var providerPlans = await _providerPlanRepository.GetByProviderId(id); + + return View(new ProviderEditModel(provider, users, providerOrganizations, providerPlans.ToList())); } [HttpPost] @@ -177,14 +177,15 @@ public class ProvidersController : Controller [RequirePermission(Permission.Provider_Edit)] public async Task Edit(Guid id, ProviderEditModel model) { - var providerPlans = await _providerPlanRepository.GetByProviderId(id); var provider = await _providerRepository.GetByIdAsync(id); + if (provider == null) { return RedirectToAction("Index"); } model.ToProvider(provider); + await _providerRepository.ReplaceAsync(provider); await _applicationCacheService.UpsertProviderAbilityAsync(provider); @@ -195,13 +196,14 @@ public class ProvidersController : Controller return RedirectToAction("Edit", new { id }); } - model.ToProviderPlan(providerPlans); + var providerPlans = await _providerPlanRepository.GetByProviderId(id); + if (providerPlans.Count == 0) { var newProviderPlans = new List { - new() {ProviderId = provider.Id, PlanType = PlanType.TeamsMonthly, SeatMinimum= model.TeamsMinimumSeats, PurchasedSeats = 0, AllocatedSeats = 0}, - new() {ProviderId = provider.Id, PlanType = PlanType.EnterpriseMonthly, SeatMinimum= model.EnterpriseMinimumSeats, PurchasedSeats = 0, AllocatedSeats = 0} + new () { ProviderId = provider.Id, PlanType = PlanType.TeamsMonthly, SeatMinimum = model.TeamsMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 }, + new () { ProviderId = provider.Id, PlanType = PlanType.EnterpriseMonthly, SeatMinimum = model.EnterpriseMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 } }; foreach (var newProviderPlan in newProviderPlans) @@ -213,6 +215,15 @@ public class ProvidersController : Controller { foreach (var providerPlan in providerPlans) { + if (providerPlan.PlanType == PlanType.EnterpriseMonthly) + { + providerPlan.SeatMinimum = model.EnterpriseMonthlySeatMinimum; + } + else if (providerPlan.PlanType == PlanType.TeamsMonthly) + { + providerPlan.SeatMinimum = model.TeamsMonthlySeatMinimum; + } + await _providerPlanRepository.ReplaceAsync(providerPlan); } } @@ -294,9 +305,8 @@ public class ProvidersController : Controller return RedirectToAction("Index"); } - var flexibleCollectionsSignupEnabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup); var flexibleCollectionsV1Enabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1); - var organization = model.CreateOrganization(provider, flexibleCollectionsSignupEnabled, flexibleCollectionsV1Enabled); + var organization = model.CreateOrganization(provider, flexibleCollectionsV1Enabled); await _organizationService.CreatePendingOrganization(organization, model.Owners, User, _userService, model.SalesAssistedTrialStarted); await _providerService.AddOrganization(providerId, organization.Id, null); diff --git a/src/Admin/AdminConsole/Models/CreateProviderModel.cs b/src/Admin/AdminConsole/Models/CreateProviderModel.cs index 2efbbb54f6..07bb1b6e4c 100644 --- a/src/Admin/AdminConsole/Models/CreateProviderModel.cs +++ b/src/Admin/AdminConsole/Models/CreateProviderModel.cs @@ -24,11 +24,11 @@ public class CreateProviderModel : IValidatableObject [Display(Name = "Primary Billing Email")] public string BillingEmail { get; set; } - [Display(Name = "Teams minimum seats")] - public int TeamsMinimumSeats { get; set; } + [Display(Name = "Teams (Monthly) Seat Minimum")] + public int TeamsMonthlySeatMinimum { get; set; } - [Display(Name = "Enterprise minimum seats")] - public int EnterpriseMinimumSeats { get; set; } + [Display(Name = "Enterprise (Monthly) Seat Minimum")] + public int EnterpriseMonthlySeatMinimum { get; set; } public virtual Provider ToProvider() { @@ -51,14 +51,14 @@ public class CreateProviderModel : IValidatableObject var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute()?.GetName() ?? nameof(OwnerEmail); yield return new ValidationResult($"The {ownerEmailDisplayName} field is required."); } - if (TeamsMinimumSeats < 0) + if (TeamsMonthlySeatMinimum < 0) { - var teamsMinimumSeatsDisplayName = nameof(TeamsMinimumSeats).GetDisplayAttribute()?.GetName() ?? nameof(TeamsMinimumSeats); + var teamsMinimumSeatsDisplayName = nameof(TeamsMonthlySeatMinimum).GetDisplayAttribute()?.GetName() ?? nameof(TeamsMonthlySeatMinimum); yield return new ValidationResult($"The {teamsMinimumSeatsDisplayName} field can not be negative."); } - if (EnterpriseMinimumSeats < 0) + if (EnterpriseMonthlySeatMinimum < 0) { - var enterpriseMinimumSeatsDisplayName = nameof(EnterpriseMinimumSeats).GetDisplayAttribute()?.GetName() ?? nameof(EnterpriseMinimumSeats); + var enterpriseMinimumSeatsDisplayName = nameof(EnterpriseMonthlySeatMinimum).GetDisplayAttribute()?.GetName() ?? nameof(EnterpriseMonthlySeatMinimum); yield return new ValidationResult($"The {enterpriseMinimumSeatsDisplayName} field can not be negative."); } break; diff --git a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs index abb7bdaa6c..54d13d8196 100644 --- a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs @@ -162,19 +162,18 @@ public class OrganizationEditModel : OrganizationViewModel { "baseServiceAccount", p.SecretsManager.BaseServiceAccount } }); - public Organization CreateOrganization(Provider provider, bool flexibleCollectionsSignupEnabled, bool flexibleCollectionsV1Enabled) + public Organization CreateOrganization(Provider provider, bool flexibleCollectionsV1Enabled) { BillingEmail = provider.BillingEmail; var newOrg = new Organization { - // This feature flag indicates that new organizations should be automatically onboarded to - // Flexible Collections enhancements - FlexibleCollections = flexibleCollectionsSignupEnabled, - // These collection management settings smooth the migration for existing organizations by disabling some FC behavior. - // If the organization is onboarded to Flexible Collections on signup, we turn them OFF to enable all new behaviour. - // If the organization is NOT onboarded now, they will have to be migrated later, so they default to ON to limit FC changes on migration. - LimitCollectionCreationDeletion = !flexibleCollectionsSignupEnabled, + // Flexible Collections MVP is fully released and all organizations must always have this setting enabled. + // AC-1714 will remove this flag after all old code has been removed. + FlexibleCollections = true, + + // This is a transitional setting that defaults to ON until Flexible Collections v1 is released + // (to preserve existing behavior) and defaults to OFF after release (enabling new behavior) AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1Enabled }; return ToOrganization(newOrg); diff --git a/src/Admin/AdminConsole/Models/OrganizationInitiateDeleteModel.cs b/src/Admin/AdminConsole/Models/OrganizationInitiateDeleteModel.cs new file mode 100644 index 0000000000..5e9055be55 --- /dev/null +++ b/src/Admin/AdminConsole/Models/OrganizationInitiateDeleteModel.cs @@ -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; } +} diff --git a/src/Admin/AdminConsole/Models/ProviderEditModel.cs b/src/Admin/AdminConsole/Models/ProviderEditModel.cs index 1055d0cba4..078731aaa7 100644 --- a/src/Admin/AdminConsole/Models/ProviderEditModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderEditModel.cs @@ -10,16 +10,21 @@ public class ProviderEditModel : ProviderViewModel { public ProviderEditModel() { } - public ProviderEditModel(Provider provider, IEnumerable providerUsers, - IEnumerable organizations, IEnumerable providerPlans) - : base(provider, providerUsers, organizations) + public ProviderEditModel( + Provider provider, + IEnumerable providerUsers, + IEnumerable organizations, + IReadOnlyCollection providerPlans) : base(provider, providerUsers, organizations) { Name = provider.DisplayName(); BusinessName = provider.DisplayBusinessName(); BillingEmail = provider.BillingEmail; BillingPhone = provider.BillingPhone; - TeamsMinimumSeats = GetMinimumSeats(providerPlans, PlanType.TeamsMonthly); - EnterpriseMinimumSeats = GetMinimumSeats(providerPlans, PlanType.EnterpriseMonthly); + TeamsMonthlySeatMinimum = GetSeatMinimum(providerPlans, PlanType.TeamsMonthly); + EnterpriseMonthlySeatMinimum = GetSeatMinimum(providerPlans, PlanType.EnterpriseMonthly); + Gateway = provider.Gateway; + GatewayCustomerId = provider.GatewayCustomerId; + GatewaySubscriptionId = provider.GatewaySubscriptionId; } [Display(Name = "Billing Email")] @@ -29,38 +34,28 @@ public class ProviderEditModel : ProviderViewModel [Display(Name = "Business Name")] public string BusinessName { get; set; } public string Name { get; set; } - [Display(Name = "Teams minimum seats")] - public int TeamsMinimumSeats { get; set; } + [Display(Name = "Teams (Monthly) Seat Minimum")] + public int TeamsMonthlySeatMinimum { get; set; } - [Display(Name = "Enterprise minimum seats")] - public int EnterpriseMinimumSeats { get; set; } - [Display(Name = "Events")] + [Display(Name = "Enterprise (Monthly) Seat Minimum")] + public int EnterpriseMonthlySeatMinimum { get; set; } + [Display(Name = "Gateway")] + public GatewayType? Gateway { get; set; } + [Display(Name = "Gateway Customer Id")] + public string GatewayCustomerId { get; set; } + [Display(Name = "Gateway Subscription Id")] + public string GatewaySubscriptionId { get; set; } - public IEnumerable ToProviderPlan(IEnumerable existingProviderPlans) + public virtual Provider ToProvider(Provider existingProvider) { - var providerPlans = existingProviderPlans.ToList(); - foreach (var existingProviderPlan in providerPlans) - { - existingProviderPlan.SeatMinimum = existingProviderPlan.PlanType switch - { - PlanType.TeamsMonthly => TeamsMinimumSeats, - PlanType.EnterpriseMonthly => EnterpriseMinimumSeats, - _ => existingProviderPlan.SeatMinimum - }; - } - return providerPlans; - } - - public Provider ToProvider(Provider existingProvider) - { - existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant()?.Trim(); - existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant()?.Trim(); + existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant().Trim(); + existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant().Trim(); + existingProvider.Gateway = Gateway; + existingProvider.GatewayCustomerId = GatewayCustomerId; + existingProvider.GatewaySubscriptionId = GatewaySubscriptionId; return existingProvider; } - - private int GetMinimumSeats(IEnumerable providerPlans, PlanType planType) - { - return (from providerPlan in providerPlans where providerPlan.PlanType == planType select (int)providerPlan.SeatMinimum).FirstOrDefault(); - } + private static int GetSeatMinimum(IEnumerable providerPlans, PlanType planType) + => providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == planType)?.SeatMinimum ?? 0; } diff --git a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml index 23ba63bbeb..ad64e6e4f5 100644 --- a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml @@ -1,4 +1,4 @@ -@using Bit.Admin.Enums; +@using Bit.Admin.Enums; @using Bit.Admin.Models @using Bit.Core.Enums @inject Bit.Admin.Services.IAccessControlService AccessControlService @@ -18,24 +18,43 @@ } + @if (TempData["Success"] != null) + { + + } @RenderSection("Scripts", required: false) diff --git a/src/Admin/Views/Tools/StripeSubscriptions.cshtml b/src/Admin/Views/Tools/StripeSubscriptions.cshtml index c8900125d2..a8de5ba904 100644 --- a/src/Admin/Views/Tools/StripeSubscriptions.cshtml +++ b/src/Admin/Views/Tools/StripeSubscriptions.cshtml @@ -109,7 +109,7 @@ @foreach (var price in Model.Prices) { - + } @@ -119,7 +119,7 @@ @foreach (var clock in Model.TestClocks) { - + } @@ -278,4 +278,4 @@ - \ No newline at end of file + diff --git a/src/Api/AdminConsole/Controllers/GroupsController.cs b/src/Api/AdminConsole/Controllers/GroupsController.cs index 9e5b8ec264..e0e057ff80 100644 --- a/src/Api/AdminConsole/Controllers/GroupsController.cs +++ b/src/Api/AdminConsole/Controllers/GroupsController.cs @@ -137,7 +137,9 @@ public class GroupsController : Controller } // Flexible Collections - check the user has permission to grant access to the collections for the new group - if (await FlexibleCollectionsIsEnabledAsync(orgId) && _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1)) + if (await FlexibleCollectionsIsEnabledAsync(orgId) && + _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) && + model.Collections?.Any() == true) { var collections = await _collectionRepository.GetManyByManyIdsAsync(model.Collections.Select(a => a.Id)); var authorized = @@ -198,7 +200,8 @@ public class GroupsController : Controller var userId = _userService.GetProperUserId(User).Value; var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(orgId, userId); var currentGroupUsers = await _groupRepository.GetManyUserIdsByIdAsync(id); - if (!currentGroupUsers.Contains(organizationUser.Id) && model.Users.Contains(organizationUser.Id)) + // OrganizationUser may be null if the current user is a provider + if (organizationUser != null && !currentGroupUsers.Contains(organizationUser.Id) && model.Users.Contains(organizationUser.Id)) { throw new BadRequestException("You cannot add yourself to groups."); } diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 78127cbfd2..544dbb87a7 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -195,7 +195,9 @@ public class OrganizationUsersController : Controller } // Flexible Collections - check the user has permission to grant access to the collections for the new user - if (await FlexibleCollectionsIsEnabledAsync(orgId) && _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1)) + if (await FlexibleCollectionsIsEnabledAsync(orgId) && + _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) && + model.Collections?.Any() == true) { var collections = await _collectionRepository.GetManyByManyIdsAsync(model.Collections.Select(a => a.Id)); var authorized = diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index a05fe050e8..979c5d16d4 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -6,12 +6,12 @@ using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Auth.Models.Request.Organizations; using Bit.Api.Auth.Models.Response.Organizations; -using Bit.Api.Models.Request; using Bit.Api.Models.Request.Accounts; using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Response; using Bit.Core; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements.Interfaces; @@ -21,20 +21,13 @@ using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Services; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Extensions; -using Bit.Core.Billing.Models; -using Bit.Core.Billing.Queries; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; -using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; -using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; +using Bit.Core.Tokens; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -50,7 +43,6 @@ public class OrganizationsController : Controller private readonly IPolicyRepository _policyRepository; private readonly IOrganizationService _organizationService; private readonly IUserService _userService; - private readonly IPaymentService _paymentService; private readonly ICurrentContext _currentContext; private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoConfigService _ssoConfigService; @@ -58,20 +50,13 @@ public class OrganizationsController : Controller private readonly IRotateOrganizationApiKeyCommand _rotateOrganizationApiKeyCommand; private readonly ICreateOrganizationApiKeyCommand _createOrganizationApiKeyCommand; private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; - private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery; private readonly IFeatureService _featureService; private readonly GlobalSettings _globalSettings; - private readonly ILicensingService _licensingService; - private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; - private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand; - private readonly IAddSecretsManagerSubscriptionCommand _addSecretsManagerSubscriptionCommand; private readonly IPushNotificationService _pushNotificationService; - private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand; - private readonly ISubscriberQueries _subscriberQueries; - private readonly IReferenceEventService _referenceEventService; private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand; private readonly IProviderRepository _providerRepository; private readonly IScaleSeatsCommand _scaleSeatsCommand; + private readonly IDataProtectorTokenFactory _orgDeleteTokenDataFactory; public OrganizationsController( IOrganizationRepository organizationRepository, @@ -79,7 +64,6 @@ public class OrganizationsController : Controller IPolicyRepository policyRepository, IOrganizationService organizationService, IUserService userService, - IPaymentService paymentService, ICurrentContext currentContext, ISsoConfigRepository ssoConfigRepository, ISsoConfigService ssoConfigService, @@ -87,27 +71,19 @@ public class OrganizationsController : Controller IRotateOrganizationApiKeyCommand rotateOrganizationApiKeyCommand, ICreateOrganizationApiKeyCommand createOrganizationApiKeyCommand, IOrganizationApiKeyRepository organizationApiKeyRepository, - ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery, IFeatureService featureService, GlobalSettings globalSettings, - ILicensingService licensingService, - IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, - IUpgradeOrganizationPlanCommand upgradeOrganizationPlanCommand, - IAddSecretsManagerSubscriptionCommand addSecretsManagerSubscriptionCommand, IPushNotificationService pushNotificationService, - ICancelSubscriptionCommand cancelSubscriptionCommand, - ISubscriberQueries subscriberQueries, - IReferenceEventService referenceEventService, IOrganizationEnableCollectionEnhancementsCommand organizationEnableCollectionEnhancementsCommand, IProviderRepository providerRepository, - IScaleSeatsCommand scaleSeatsCommand) + IScaleSeatsCommand scaleSeatsCommand, + IDataProtectorTokenFactory orgDeleteTokenDataFactory) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _policyRepository = policyRepository; _organizationService = organizationService; _userService = userService; - _paymentService = paymentService; _currentContext = currentContext; _ssoConfigRepository = ssoConfigRepository; _ssoConfigService = ssoConfigService; @@ -115,20 +91,13 @@ public class OrganizationsController : Controller _rotateOrganizationApiKeyCommand = rotateOrganizationApiKeyCommand; _createOrganizationApiKeyCommand = createOrganizationApiKeyCommand; _organizationApiKeyRepository = organizationApiKeyRepository; - _cloudGetOrganizationLicenseQuery = cloudGetOrganizationLicenseQuery; _featureService = featureService; _globalSettings = globalSettings; - _licensingService = licensingService; - _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; - _upgradeOrganizationPlanCommand = upgradeOrganizationPlanCommand; - _addSecretsManagerSubscriptionCommand = addSecretsManagerSubscriptionCommand; _pushNotificationService = pushNotificationService; - _cancelSubscriptionCommand = cancelSubscriptionCommand; - _subscriberQueries = subscriberQueries; - _referenceEventService = referenceEventService; _organizationEnableCollectionEnhancementsCommand = organizationEnableCollectionEnhancementsCommand; _providerRepository = providerRepository; _scaleSeatsCommand = scaleSeatsCommand; + _orgDeleteTokenDataFactory = orgDeleteTokenDataFactory; } [HttpGet("{id}")] @@ -149,83 +118,6 @@ public class OrganizationsController : Controller return new OrganizationResponseModel(organization); } - [HttpGet("{id}/billing")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task 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 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 GetLicense(string id, [FromQuery] Guid installationId) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.OrganizationOwner(orgIdGuid)) - { - throw new NotFoundException(); - } - - var org = await _organizationRepository.GetByIdAsync(new Guid(id)); - var license = await _cloudGetOrganizationLicenseQuery.GetLicenseAsync(org, installationId); - if (license == null) - { - throw new NotFoundException(); - } - - return license; - } - [HttpGet("")] public async Task> GetUser() { @@ -268,21 +160,6 @@ public class OrganizationsController : Controller return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false); } - [HttpGet("{id}/billing-status")] - public async Task GetBillingStatus(Guid id) - { - if (!await _currentContext.EditPaymentMethods(id)) - { - throw new NotFoundException(); - } - - var organization = await _organizationRepository.GetByIdAsync(id); - - var risksSubscriptionFailure = await _paymentService.RisksSubscriptionFailure(organization); - - return new OrganizationBillingStatusResponseModel(organization, risksSubscriptionFailure); - } - [HttpPost("")] [SelfHosted(NotSelfHostedOnly = true)] public async Task Post([FromBody] OrganizationCreateRequestModel model) @@ -326,124 +203,6 @@ public class OrganizationsController : Controller return new OrganizationResponseModel(organization); } - [HttpPost("{id}/payment")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostPayment(string id, [FromBody] PaymentRequestModel model) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.EditPaymentMethods(orgIdGuid)) - { - throw new NotFoundException(); - } - - await _organizationService.ReplacePaymentMethodAsync(orgIdGuid, model.PaymentToken, - model.PaymentMethodType.Value, new TaxInfo - { - BillingAddressLine1 = model.Line1, - BillingAddressLine2 = model.Line2, - BillingAddressState = model.State, - BillingAddressCity = model.City, - BillingAddressPostalCode = model.PostalCode, - BillingAddressCountry = model.Country, - TaxIdNumber = model.TaxId, - }); - } - - [HttpPost("{id}/upgrade")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task 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 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 PostSeat(string id, [FromBody] OrganizationSeatRequestModel model) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.EditSubscription(orgIdGuid)) - { - throw new NotFoundException(); - } - - var result = await _organizationService.AdjustSeatsAsync(orgIdGuid, model.SeatAdjustment.Value); - return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result }; - } - [HttpPost("{id}/storage")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostStorage(string id, [FromBody] StorageRequestModel model) @@ -458,67 +217,6 @@ public class OrganizationsController : Controller return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result }; } - [HttpPost("{id}/verify-bank")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostVerifyBank(string id, [FromBody] OrganizationVerifyBankRequestModel model) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.EditSubscription(orgIdGuid)) - { - throw new NotFoundException(); - } - - await _organizationService.VerifyBankAsync(orgIdGuid, model.Amount1.Value, model.Amount2.Value); - } - - [HttpPost("{id}/cancel")] - public async Task PostCancel(Guid id, [FromBody] SubscriptionCancellationRequestModel request) - { - if (!await _currentContext.EditSubscription(id)) - { - throw new NotFoundException(); - } - - var organization = await _organizationRepository.GetByIdAsync(id); - - if (organization == null) - { - throw new NotFoundException(); - } - - var subscription = await _subscriberQueries.GetSubscriptionOrThrow(organization); - - await _cancelSubscriptionCommand.CancelSubscription(subscription, - new OffboardingSurveyResponse - { - UserId = _currentContext.UserId!.Value, - Reason = request.Reason, - Feedback = request.Feedback - }, - organization.IsExpired()); - - await _referenceEventService.RaiseEventAsync(new ReferenceEvent( - ReferenceEventType.CancelSubscription, - organization, - _currentContext) - { - EndOfPeriod = organization.IsExpired() - }); - } - - [HttpPost("{id}/reinstate")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostReinstate(string id) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.EditSubscription(orgIdGuid)) - { - throw new NotFoundException(); - } - - await _organizationService.ReinstateSubscriptionAsync(orgIdGuid); - } - [HttpPost("{id}/leave")] public async Task Leave(string id) { @@ -586,6 +284,37 @@ public class OrganizationsController : Controller await _organizationService.DeleteAsync(organization); } + [HttpPost("{id}/delete-recover-token")] + [AllowAnonymous] + public async Task PostDeleteRecoverToken(Guid id, [FromBody] OrganizationVerifyDeleteRecoverRequestModel model) + { + var organization = await _organizationRepository.GetByIdAsync(id); + if (organization == null) + { + throw new NotFoundException(); + } + + if (!_orgDeleteTokenDataFactory.TryUnprotect(model.Token, out var data) || !data.IsValid(organization)) + { + throw new BadRequestException("Invalid token."); + } + + var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); + if (consolidatedBillingEnabled && organization.IsValidClient()) + { + var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id); + if (provider.IsBillable()) + { + await _scaleSeatsCommand.ScalePasswordManagerSeats( + provider, + organization.PlanType, + -organization.Seats ?? 0); + } + } + + await _organizationService.DeleteAsync(organization); + } + [HttpPost("{id}/import")] public async Task Import(string id, [FromBody] ImportOrganizationUsersRequestModel model) { @@ -722,55 +451,6 @@ public class OrganizationsController : Controller }; } - [HttpGet("{id}/tax")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task GetTaxInfo(string id) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.OrganizationOwner(orgIdGuid)) - { - throw new NotFoundException(); - } - - var organization = await _organizationRepository.GetByIdAsync(orgIdGuid); - if (organization == null) - { - throw new NotFoundException(); - } - - var taxInfo = await _paymentService.GetTaxInfoAsync(organization); - return new TaxInfoResponseModel(taxInfo); - } - - [HttpPut("{id}/tax")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PutTaxInfo(string id, [FromBody] ExpandedTaxInfoUpdateRequestModel model) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.OrganizationOwner(orgIdGuid)) - { - throw new NotFoundException(); - } - - var organization = await _organizationRepository.GetByIdAsync(orgIdGuid); - if (organization == null) - { - throw new NotFoundException(); - } - - var taxInfo = new TaxInfo - { - TaxIdNumber = model.TaxId, - BillingAddressLine1 = model.Line1, - BillingAddressLine2 = model.Line2, - BillingAddressCity = model.City, - BillingAddressState = model.State, - BillingAddressPostalCode = model.PostalCode, - BillingAddressCountry = model.Country, - }; - await _paymentService.SaveTaxInfoAsync(organization, taxInfo); - } - [HttpGet("{id}/public-key")] public async Task GetPublicKey(string id) { @@ -912,15 +592,4 @@ public class OrganizationsController : Controller ou.Type is OrganizationUserType.Admin or OrganizationUserType.Owner) .Select(ou => _pushNotificationService.PushSyncOrganizationsAsync(ou.UserId.Value))); } - - private async Task TryGrantOwnerAccessToSecretsManagerAsync(Guid organizationId, Guid userId) - { - var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId, userId); - - if (organizationUser != null) - { - organizationUser.AccessSecretsManager = true; - await _organizationUserRepository.ReplaceAsync(organizationUser); - } - } } diff --git a/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs b/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs index 136119848a..e2becc9b89 100644 --- a/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs @@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Extensions; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -112,6 +113,9 @@ public class ProviderOrganizationsController : Controller providerOrganization, organization); - await _removePaymentMethodCommand.RemovePaymentMethod(organization); + if (organization.IsStripeEnabled()) + { + await _removePaymentMethodCommand.RemovePaymentMethod(organization); + } } } diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationVerifyDeleteRecoverRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationVerifyDeleteRecoverRequestModel.cs new file mode 100644 index 0000000000..36dba6ed98 --- /dev/null +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationVerifyDeleteRecoverRequestModel.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.AdminConsole.Models.Request.Organizations; + +public class OrganizationVerifyDeleteRecoverRequestModel +{ + [Required] + public string Token { get; set; } +} diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj index 532c0f6e12..d8b2ab4753 100644 --- a/src/Api/Api.csproj +++ b/src/Api/Api.csproj @@ -34,7 +34,7 @@ - + diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 28135c4714..da76f35400 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -43,7 +43,6 @@ using Bit.Core.Utilities; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Repositories; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Auth.Controllers; @@ -438,59 +437,19 @@ public class AccountsController : Controller throw new UnauthorizedAccessException(); } - IdentityResult result; - if (_featureService.IsEnabled(FeatureFlagKeys.KeyRotationImprovements)) + var dataModel = new RotateUserKeyData { - var dataModel = new RotateUserKeyData - { - MasterPasswordHash = model.MasterPasswordHash, - Key = model.Key, - PrivateKey = model.PrivateKey, - Ciphers = await _cipherValidator.ValidateAsync(user, model.Ciphers), - Folders = await _folderValidator.ValidateAsync(user, model.Folders), - Sends = await _sendValidator.ValidateAsync(user, model.Sends), - EmergencyAccesses = await _emergencyAccessValidator.ValidateAsync(user, model.EmergencyAccessKeys), - OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.ResetPasswordKeys) - }; - - result = await _rotateUserKeyCommand.RotateUserKeyAsync(user, dataModel); - } - else - { - var ciphers = new List(); - 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(); - 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(); - 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); - } + MasterPasswordHash = model.MasterPasswordHash, + Key = model.Key, + PrivateKey = model.PrivateKey, + Ciphers = await _cipherValidator.ValidateAsync(user, model.Ciphers), + Folders = await _folderValidator.ValidateAsync(user, model.Folders), + Sends = await _sendValidator.ValidateAsync(user, model.Sends), + EmergencyAccesses = await _emergencyAccessValidator.ValidateAsync(user, model.EmergencyAccessKeys), + OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.ResetPasswordKeys) + }; + var result = await _rotateUserKeyCommand.RotateUserKeyAsync(user, dataModel); if (result.Succeeded) { diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index d26de81113..a16c0c42fd 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -1,5 +1,11 @@ using Bit.Api.Billing.Models.Responses; +using Bit.Api.Models.Response; using Bit.Core.Billing.Queries; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -8,7 +14,10 @@ namespace Bit.Api.Billing.Controllers; [Route("organizations/{organizationId:guid}/billing")] [Authorize("Application")] public class OrganizationBillingController( - IOrganizationBillingQueries organizationBillingQueries) : Controller + IOrganizationBillingQueries organizationBillingQueries, + ICurrentContext currentContext, + IOrganizationRepository organizationRepository, + IPaymentService paymentService) : Controller { [HttpGet("metadata")] public async Task GetMetadataAsync([FromRoute] Guid organizationId) @@ -24,4 +33,23 @@ public class OrganizationBillingController( return TypedResults.Ok(response); } + + [HttpGet] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task 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); + } } diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs new file mode 100644 index 0000000000..f418e07f97 --- /dev/null +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -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 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 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 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 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 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 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 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); + } + + /// + /// Tries to grant owner access to the Secrets Manager for the organization + /// + /// + /// + 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); + } + } + + /// + /// Adjusts the organization seats for the Secrets Manager trial to match the new seat count for secrets manager + /// + /// + /// + /// + private async Task 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); + } +} diff --git a/src/Api/Billing/Controllers/ProviderClientsController.cs b/src/Api/Billing/Controllers/ProviderClientsController.cs index 6f7bd809fd..a47ab568bc 100644 --- a/src/Api/Billing/Controllers/ProviderClientsController.cs +++ b/src/Api/Billing/Controllers/ProviderClientsController.cs @@ -133,10 +133,17 @@ public class ProviderClientsController( return TypedResults.Problem(); } - await assignSeatsToClientOrganizationCommand.AssignSeatsToClientOrganization( - provider, - clientOrganization, - requestBody.AssignedSeats); + if (clientOrganization.Seats != requestBody.AssignedSeats) + { + await assignSeatsToClientOrganizationCommand.AssignSeatsToClientOrganization( + provider, + clientOrganization, + requestBody.AssignedSeats); + } + + clientOrganization.Name = requestBody.Name; + + await organizationRepository.ReplaceAsync(clientOrganization); return TypedResults.Ok(); } diff --git a/src/Api/Billing/Models/Requests/UpdateClientOrganizationRequestBody.cs b/src/Api/Billing/Models/Requests/UpdateClientOrganizationRequestBody.cs index c6e04aa798..6ed1083b42 100644 --- a/src/Api/Billing/Models/Requests/UpdateClientOrganizationRequestBody.cs +++ b/src/Api/Billing/Models/Requests/UpdateClientOrganizationRequestBody.cs @@ -7,4 +7,7 @@ public class UpdateClientOrganizationRequestBody [Required] [Range(0, int.MaxValue, ErrorMessage = "You cannot assign negative seats to a client organization.")] public int AssignedSeats { get; set; } + + [Required] + public string Name { get; set; } } diff --git a/src/Api/Models/Response/CollectionResponseModel.cs b/src/Api/Models/Response/CollectionResponseModel.cs index 253acbfdff..d56ef5469a 100644 --- a/src/Api/Models/Response/CollectionResponseModel.cs +++ b/src/Api/Models/Response/CollectionResponseModel.cs @@ -89,6 +89,7 @@ public class CollectionAccessDetailsResponseModel : CollectionResponseModel ReadOnly = collection.ReadOnly; HidePasswords = collection.HidePasswords; Manage = collection.Manage; + Unmanaged = collection.Unmanaged; Groups = collection.Groups?.Select(g => new SelectionReadOnlyResponseModel(g)) ?? Enumerable.Empty(); Users = collection.Users?.Select(g => new SelectionReadOnlyResponseModel(g)) ?? Enumerable.Empty(); } @@ -104,4 +105,5 @@ public class CollectionAccessDetailsResponseModel : CollectionResponseModel public bool ReadOnly { get; set; } public bool HidePasswords { get; set; } public bool Manage { get; set; } + public bool Unmanaged { get; set; } } diff --git a/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs b/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs index 497c8b9a18..d836b18e36 100644 --- a/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs +++ b/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs @@ -217,34 +217,49 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler CanUpdateUserAccessAsync(ICollection resources, CurrentContextOrganization? org) { - return await CanUpdateCollectionAsync(resources, org) || org?.Permissions.ManageUsers == true; - } - - private async Task CanUpdateGroupAccessAsync(ICollection resources, CurrentContextOrganization? org) - { - return await CanUpdateCollectionAsync(resources, org) || org?.Permissions.ManageGroups == true; - } - - private async Task CanDeleteAsync(ICollection resources, CurrentContextOrganization? org) - { - // Owners, Admins, and users with DeleteAnyCollection permission can always delete collections - if (org is - { Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or - { Permissions.DeleteAnyCollection: true }) + if (await AllowAdminAccessToAllCollectionItems(org) && org?.Permissions.ManageUsers == true) { return true; } - // Check for non-null org here: the user must be apart of the organization for this setting to take affect - // The limit collection management setting is disabled, - // ensure acting user has manage permissions for all collections being deleted - if (await GetOrganizationAbilityAsync(org) is { LimitCollectionCreationDeletion: false }) + return await CanUpdateCollectionAsync(resources, org); + } + + private async Task CanUpdateGroupAccessAsync(ICollection resources, CurrentContextOrganization? org) + { + if (await AllowAdminAccessToAllCollectionItems(org) && org?.Permissions.ManageGroups == true) { - var canManageCollections = await CanManageCollectionsAsync(resources, org); - if (canManageCollections) - { - return true; - } + return true; + } + + return await CanUpdateCollectionAsync(resources, org); + } + + private async Task CanDeleteAsync(ICollection resources, CurrentContextOrganization? org) + { + // Users with DeleteAnyCollection permission can always delete collections + if (org is { Permissions.DeleteAnyCollection: true }) + { + return true; + } + + // If AllowAdminAccessToAllCollectionItems is true, Owners and Admins can delete any collection, regardless of LimitCollectionCreationDeletion setting + var organizationAbility = await GetOrganizationAbilityAsync(org); + var allowAdminAccessToAllCollectionItems = !_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) || + organizationAbility is { AllowAdminAccessToAllCollectionItems: true }; + if (allowAdminAccessToAllCollectionItems && org is { Type: OrganizationUserType.Owner or OrganizationUserType.Admin }) + { + return true; + } + + // If LimitCollectionCreationDeletion is false, AllowAdminAccessToAllCollectionItems setting is irrelevant. + // Ensure acting user has manage permissions for all collections being deleted + // If LimitCollectionCreationDeletion is true, only Owners and Admins can delete collections they manage + var canDeleteManagedCollections = organizationAbility is { LimitCollectionCreationDeletion: false } || + org is { Type: OrganizationUserType.Owner or OrganizationUserType.Admin }; + if (canDeleteManagedCollections && await CanManageCollectionsAsync(resources, org)) + { + return true; } // Allow providers to delete collections if they are a provider for the target organization @@ -308,4 +323,11 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler AllowAdminAccessToAllCollectionItems(CurrentContextOrganization? org) + { + var organizationAbility = await GetOrganizationAbilityAsync(org); + return !_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) || + organizationAbility is { AllowAdminAccessToAllCollectionItems: true }; + } } diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 96de4317fd..0eb6469024 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -619,9 +619,39 @@ public class CiphersController : Controller var updatedCipher = await GetByIdAsync(id, userId); var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, id, UseFlexibleCollections); + return new CipherDetailsResponseModel(updatedCipher, _globalSettings, collectionCiphers); } + [HttpPut("{id}/collections_v2")] + [HttpPost("{id}/collections_v2")] + public async Task PutCollections_vNext(Guid id, [FromBody] CipherCollectionsRequestModel model) + { + var userId = _userService.GetProperUserId(User).Value; + var cipher = await GetByIdAsync(id, userId); + if (cipher == null || !cipher.OrganizationId.HasValue || + !await _currentContext.OrganizationUser(cipher.OrganizationId.Value)) + { + throw new NotFoundException(); + } + + await _cipherService.SaveCollectionsAsync(cipher, + model.CollectionIds.Select(c => new Guid(c)), userId, false); + + var updatedCipher = await GetByIdAsync(id, userId); + var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, id, UseFlexibleCollections); + // If a user removes the last Can Manage access of a cipher, the "updatedCipher" will return null + // We will be returning an "Unavailable" property so the client knows the user can no longer access this + var response = new OptionalCipherDetailsResponseModel() + { + Unavailable = updatedCipher is null, + Cipher = updatedCipher is null + ? null + : new CipherDetailsResponseModel(updatedCipher, _globalSettings, collectionCiphers) + }; + return response; + } + [HttpPut("{id}/collections-admin")] [HttpPost("{id}/collections-admin")] public async Task PutCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model) diff --git a/src/Api/Vault/Models/Response/OptionalCipherDetailsResponseModel.cs b/src/Api/Vault/Models/Response/OptionalCipherDetailsResponseModel.cs new file mode 100644 index 0000000000..018185e04f --- /dev/null +++ b/src/Api/Vault/Models/Response/OptionalCipherDetailsResponseModel.cs @@ -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") + { } +} diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index bc6434e6b7..9e298b6865 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -3,6 +3,7 @@ using Bit.Billing.Models; using Bit.Billing.Services; using Bit.Core; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Context; using Bit.Core.Enums; @@ -55,6 +56,7 @@ public class StripeController : Controller private readonly IStripeEventService _stripeEventService; private readonly IStripeFacade _stripeFacade; private readonly IFeatureService _featureService; + private readonly IProviderRepository _providerRepository; public StripeController( GlobalSettings globalSettings, @@ -74,7 +76,8 @@ public class StripeController : Controller ICurrentContext currentContext, IStripeEventService stripeEventService, IStripeFacade stripeFacade, - IFeatureService featureService) + IFeatureService featureService, + IProviderRepository providerRepository) { _billingSettings = billingSettings?.Value; _hostingEnvironment = hostingEnvironment; @@ -102,6 +105,7 @@ public class StripeController : Controller _stripeEventService = stripeEventService; _stripeFacade = stripeFacade; _featureService = featureService; + _providerRepository = providerRepository; } [HttpPost("webhook")] @@ -425,7 +429,61 @@ public class StripeController : Controller } var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata); - if (organizationId.HasValue) + + if (providerId.HasValue) + { + var provider = await _providerRepository.GetByIdAsync(providerId.Value); + + if (provider == null) + { + _logger.LogError( + "Received invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) that does not exist", + parsedEvent.Id, + providerId.Value); + + return; + } + + var teamsMonthly = StaticStore.GetPlan(PlanType.TeamsMonthly); + + var enterpriseMonthly = StaticStore.GetPlan(PlanType.EnterpriseMonthly); + + var teamsMonthlyLineItem = + subscription.Items.Data.FirstOrDefault(item => + item.Plan.Id == teamsMonthly.PasswordManager.StripeSeatPlanId); + + var enterpriseMonthlyLineItem = + subscription.Items.Data.FirstOrDefault(item => + item.Plan.Id == enterpriseMonthly.PasswordManager.StripeSeatPlanId); + + if (teamsMonthlyLineItem == null || enterpriseMonthlyLineItem == null) + { + _logger.LogError("invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) indicates missing subscription line items", + parsedEvent.Id, + provider.Id); + + return; + } + + await _referenceEventService.RaiseEventAsync(new ReferenceEvent + { + Type = ReferenceEventType.Rebilled, + Source = ReferenceEventSource.Provider, + Id = provider.Id, + PlanType = PlanType.TeamsMonthly, + Seats = (int)teamsMonthlyLineItem.Quantity + }); + + await _referenceEventService.RaiseEventAsync(new ReferenceEvent + { + Type = ReferenceEventType.Rebilled, + Source = ReferenceEventSource.Provider, + Id = provider.Id, + PlanType = PlanType.EnterpriseMonthly, + Seats = (int)enterpriseMonthlyLineItem.Quantity + }); + } + else if (organizationId.HasValue) { if (!subscription.Items.Any(i => StaticStore.Plans.Any(p => p.PasswordManager.StripePlanId == i.Plan.Id))) @@ -657,6 +715,23 @@ public class StripeController : Controller await SendEmails(new List { 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 { provider.BillingEmail }); + + } return; diff --git a/src/Core/AdminConsole/Entities/Organization.cs b/src/Core/AdminConsole/Entities/Organization.cs index fc8e515bd9..f12baf5729 100644 --- a/src/Core/AdminConsole/Entities/Organization.cs +++ b/src/Core/AdminConsole/Entities/Organization.cs @@ -86,20 +86,20 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable, public int? MaxAutoscaleSmSeats { get; set; } public int? MaxAutoscaleSmServiceAccounts { get; set; } /// - /// 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. /// public bool LimitCollectionCreationDeletion { get; set; } /// - /// Refers to the ability for an organization to limit owner/admin access to all collection items - /// - /// True: Owner/admins can access all items belonging to any collections - /// False: Owner/admins can only access items for collections they are assigned - /// + /// If set to true, admins, owners, and some custom users can read/write all collections and items in the Admin Console. + /// If set to false, users generally need collection-level permissions to read/write a collection or its items. /// public bool AllowAdminAccessToAllCollectionItems { get; set; } /// - /// True if the organization is using the Flexible Collections permission changes, false otherwise. - /// For existing organizations, this must only be set to true once data migrations have been run for this organization. + /// This is an organization-level feature flag (not controlled via LaunchDarkly) to onboard organizations to the + /// Flexible Collections MVP changes. This has been fully released and must always be set to TRUE for all organizations. + /// AC-1714 will remove this flag after all old code has been removed. /// public bool FlexibleCollections { get; set; } diff --git a/src/Core/AdminConsole/Models/Business/Tokenables/OrgDeleteTokenable.cs b/src/Core/AdminConsole/Models/Business/Tokenables/OrgDeleteTokenable.cs new file mode 100644 index 0000000000..6a769010a2 --- /dev/null +++ b/src/Core/AdminConsole/Models/Business/Tokenables/OrgDeleteTokenable.cs @@ -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; +} diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index 32e3ca0ef1..0d2472b95c 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -34,6 +34,7 @@ public interface IOrganizationService /// Task<(Organization organization, OrganizationUser organizationUser)> SignUpAsync(OrganizationLicense license, User owner, string ownerKey, string collectionName, string publicKey, string privateKey); + Task InitiateDeleteAsync(Organization organization, string orgAdminEmail); Task DeleteAsync(Organization organization); Task EnableAsync(Guid organizationId, DateTime? expirationDate); Task DisableAsync(Guid organizationId, DateTime? expirationDate); @@ -49,7 +50,7 @@ public interface IOrganizationService Task InviteUserAsync(Guid organizationId, Guid? invitingUserId, string email, OrganizationUserType type, bool accessAll, string externalId, ICollection collections, IEnumerable groups); Task InviteUserAsync(Guid organizationId, EventSystemUser systemUser, string email, - OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections, IEnumerable groups); + OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections, IEnumerable groups, bool accessSecretsManager); Task>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable organizationUsersId); Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false); Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index dca45ceba0..e71b9c1beb 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; @@ -60,6 +61,7 @@ public class OrganizationService : IOrganizationService private readonly IProviderUserRepository _providerUserRepository; private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery; private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; + private readonly IDataProtectorTokenFactory _orgDeleteTokenDataFactory; private readonly IProviderRepository _providerRepository; private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory; private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; @@ -94,6 +96,7 @@ public class OrganizationService : IOrganizationService IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory, IDataProtectorTokenFactory orgUserInviteTokenDataFactory, IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, + IDataProtectorTokenFactory orgDeleteTokenDataFactory, IProviderRepository providerRepository, IFeatureService featureService) { @@ -123,6 +126,7 @@ public class OrganizationService : IOrganizationService _providerUserRepository = providerUserRepository; _countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery; _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; + _orgDeleteTokenDataFactory = orgDeleteTokenDataFactory; _providerRepository = providerRepository; _orgUserInviteTokenableFactory = orgUserInviteTokenableFactory; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; @@ -434,9 +438,6 @@ public class OrganizationService : IOrganizationService ValidatePlan(plan, signup.AdditionalSeats, "Password Manager"); - var flexibleCollectionsSignupEnabled = - _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup); - var flexibleCollectionsV1Enabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1); @@ -478,14 +479,12 @@ public class OrganizationService : IOrganizationService // Secrets Manager not available for purchase with Consolidated Billing. UseSecretsManager = false, - // This feature flag indicates that new organizations should be automatically onboarded to - // Flexible Collections enhancements - FlexibleCollections = flexibleCollectionsSignupEnabled, + // Flexible Collections MVP is fully released and all organizations must always have this setting enabled. + // AC-1714 will remove this flag after all old code has been removed. + FlexibleCollections = true, - // These collection management settings smooth the migration for existing organizations by disabling some FC behavior. - // If the organization is onboarded to Flexible Collections on signup, we turn them OFF to enable all new behaviour. - // If the organization is NOT onboarded now, they will have to be migrated later, so they default to ON to limit FC changes on migration. - LimitCollectionCreationDeletion = !flexibleCollectionsSignupEnabled, + // This is a transitional setting that defaults to ON until Flexible Collections v1 is released + // (to preserve existing behavior) and defaults to OFF after release (enabling new behavior) AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1Enabled }; @@ -529,9 +528,6 @@ public class OrganizationService : IOrganizationService await ValidateSignUpPoliciesAsync(signup.Owner.Id); } - var flexibleCollectionsSignupEnabled = - _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup); - var flexibleCollectionsV1IsEnabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1); @@ -573,14 +569,12 @@ public class OrganizationService : IOrganizationService UsePasswordManager = true, UseSecretsManager = signup.UseSecretsManager, - // This feature flag indicates that new organizations should be automatically onboarded to - // Flexible Collections enhancements - FlexibleCollections = flexibleCollectionsSignupEnabled, + // Flexible Collections MVP is fully released and all organizations must always have this setting enabled. + // AC-1714 will remove this flag after all old code has been removed. + FlexibleCollections = true, - // These collection management settings smooth the migration for existing organizations by disabling some FC behavior. - // If the organization is onboarded to Flexible Collections on signup, we turn them OFF to enable all new behaviour. - // If the organization is NOT onboarded now, they will have to be migrated later, so they default to ON to limit FC changes on migration. - LimitCollectionCreationDeletion = !flexibleCollectionsSignupEnabled, + // This is a transitional setting that defaults to ON until Flexible Collections v1 is released + // (to preserve existing behavior) and defaults to OFF after release (enabling new behavior) AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1IsEnabled }; @@ -661,9 +655,6 @@ public class OrganizationService : IOrganizationService await ValidateSignUpPoliciesAsync(owner.Id); - var flexibleCollectionsSignupEnabled = - _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup); - var organization = new Organization { Name = license.Name, @@ -709,7 +700,7 @@ public class OrganizationService : IOrganizationService // This feature flag indicates that new organizations should be automatically onboarded to // Flexible Collections enhancements - FlexibleCollections = flexibleCollectionsSignupEnabled, + FlexibleCollections = true, }; var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false); @@ -811,6 +802,23 @@ public class OrganizationService : IOrganizationService } } + public async Task InitiateDeleteAsync(Organization organization, string orgAdminEmail) + { + var orgAdmin = await _userRepository.GetByEmailAsync(orgAdminEmail); + if (orgAdmin == null) + { + throw new BadRequestException("Org admin not found."); + } + var orgAdminOrgUser = await _organizationUserRepository.GetDetailsByUserAsync(orgAdmin.Id, organization.Id); + if (orgAdminOrgUser == null || orgAdminOrgUser.Status != OrganizationUserStatusType.Confirmed || + (orgAdminOrgUser.Type != OrganizationUserType.Admin && orgAdminOrgUser.Type != OrganizationUserType.Owner)) + { + throw new BadRequestException("Org admin not found."); + } + var token = _orgDeleteTokenDataFactory.Protect(new OrgDeleteTokenable(organization, 1)); + await _mailService.SendInitiateDeleteOrganzationEmailAsync(orgAdminEmail, organization, token); + } + public async Task DeleteAsync(Organization organization) { await ValidateDeleteOrganizationAsync(organization); @@ -1323,7 +1331,6 @@ public class OrganizationService : IOrganizationService var validOrganizationUserIds = validOrganizationUsers.Select(u => u.UserId.Value).ToList(); var organization = await GetOrgById(organizationId); - var policies = await _policyRepository.GetManyByOrganizationIdAsync(organizationId); var usersOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(validOrganizationUserIds); var users = await _userRepository.GetManyAsync(validOrganizationUserIds); @@ -1355,7 +1362,7 @@ public class OrganizationService : IOrganizationService } } - await CheckPolicies(policies, organizationId, user, orgUsers, userService); + await CheckPolicies(organizationId, user, orgUsers, userService); orgUser.Status = OrganizationUserStatusType.Confirmed; orgUser.Key = keys[orgUser.Id]; orgUser.Email = null; @@ -1449,22 +1456,29 @@ public class OrganizationService : IOrganizationService } } - private async Task CheckPolicies(ICollection policies, Guid organizationId, User user, + private async Task CheckPolicies(Guid organizationId, User user, ICollection userOrgs, IUserService userService) { - var usingTwoFactorPolicy = policies.Any(p => p.Type == PolicyType.TwoFactorAuthentication && p.Enabled); - if (usingTwoFactorPolicy && !await userService.TwoFactorIsEnabledAsync(user)) + // Enforce Two Factor Authentication Policy for this organization + var orgRequiresTwoFactor = (await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication)).Any(p => p.OrganizationId == organizationId); + if (orgRequiresTwoFactor && !await userService.TwoFactorIsEnabledAsync(user)) { throw new BadRequestException("User does not have two-step login enabled."); } - var usingSingleOrgPolicy = policies.Any(p => p.Type == PolicyType.SingleOrg && p.Enabled); - if (usingSingleOrgPolicy) + var hasOtherOrgs = userOrgs.Any(ou => ou.OrganizationId != organizationId); + var singleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg); + var otherSingleOrgPolicies = + singleOrgPolicies.Where(p => p.OrganizationId != organizationId); + // Enforce Single Organization Policy for this organization + if (hasOtherOrgs && singleOrgPolicies.Any(p => p.OrganizationId == organizationId)) { - if (userOrgs.Any(ou => ou.OrganizationId != organizationId && ou.Status != OrganizationUserStatusType.Invited)) - { - throw new BadRequestException("User is a member of another organization."); - } + throw new BadRequestException("Cannot confirm this member to the organization until they leave or remove all other organizations."); + } + // Enforce Single Organization Policy of other organizations user is a member of + if (otherSingleOrgPolicies.Any()) + { + throw new BadRequestException("Cannot confirm this member to the organization because they are in another organization which forbids it."); } } @@ -1673,14 +1687,14 @@ public class OrganizationService : IOrganizationService public async Task InviteUserAsync(Guid organizationId, EventSystemUser systemUser, string email, OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections, - IEnumerable groups) + IEnumerable groups, bool accessSecretsManager) { // Collection associations validation not required as they are always an empty list - created via system user (scim) - return await SaveUserSendInviteAsync(organizationId, invitingUserId: null, systemUser, email, type, accessAll, externalId, collections, groups); + return await SaveUserSendInviteAsync(organizationId, invitingUserId: null, systemUser, email, type, accessAll, externalId, collections, groups, accessSecretsManager); } private async Task SaveUserSendInviteAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, string email, - OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections, IEnumerable groups) + OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections, IEnumerable groups, bool accessSecretsManager = false) { var invite = new OrganizationUserInvite() { @@ -1688,7 +1702,8 @@ public class OrganizationService : IOrganizationService Type = type, AccessAll = accessAll, Collections = collections, - Groups = groups + Groups = groups, + AccessSecretsManager = accessSecretsManager }; var results = systemUser.HasValue ? await InviteUsersAsync(organizationId, systemUser.Value, new (OrganizationUserInvite, string)[] { (invite, externalId) }) : await InviteUsersAsync(organizationId, invitingUserId, @@ -1787,6 +1802,8 @@ public class OrganizationService : IOrganizationService enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count; } + var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization); + var userInvites = new List<(OrganizationUserInvite, string)>(); foreach (var user in newUsers) { @@ -1803,6 +1820,7 @@ public class OrganizationService : IOrganizationService Type = OrganizationUserType.User, AccessAll = false, Collections = new List(), + AccessSecretsManager = hasStandaloneSecretsManager }; userInvites.Add((invite, user.ExternalId)); } diff --git a/src/Core/Auth/Repositories/IAuthRequestRepository.cs b/src/Core/Auth/Repositories/IAuthRequestRepository.cs index b414b2206b..6662dd15fc 100644 --- a/src/Core/Auth/Repositories/IAuthRequestRepository.cs +++ b/src/Core/Auth/Repositories/IAuthRequestRepository.cs @@ -9,4 +9,5 @@ public interface IAuthRequestRepository : IRepository Task> GetManyByUserIdAsync(Guid userId); Task> GetManyPendingByOrganizationIdAsync(Guid organizationId); Task> GetManyAdminApprovalRequestsByManyIdsAsync(Guid organizationId, IEnumerable ids); + Task UpdateManyAsync(IEnumerable authRequests); } diff --git a/src/Core/Billing/Extensions/BillingExtensions.cs b/src/Core/Billing/Extensions/BillingExtensions.cs index 9b1bb9e92b..f0ee8989c4 100644 --- a/src/Core/Billing/Extensions/BillingExtensions.cs +++ b/src/Core/Billing/Extensions/BillingExtensions.cs @@ -22,6 +22,10 @@ public static class BillingExtensions PlanType: PlanType.TeamsMonthly or PlanType.EnterpriseMonthly }; + public static bool IsStripeEnabled(this Organization organization) + => !string.IsNullOrEmpty(organization.GatewayCustomerId) && + !string.IsNullOrEmpty(organization.GatewaySubscriptionId); + public static bool SupportsConsolidatedBilling(this PlanType planType) => planType is PlanType.TeamsMonthly or PlanType.EnterpriseMonthly; } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index fc3faa9d06..6804a9a223 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -102,11 +102,6 @@ public static class AuthenticationSchemes public static class FeatureFlagKeys { - public const string DisplayEuEnvironment = "display-eu-environment"; - public const string DisplayLowKdfIterationWarning = "display-kdf-iteration-warning"; - public const string PasswordlessLogin = "passwordless-login"; - public const string TrustedDeviceEncryption = "trusted-device-encryption"; - public const string Fido2VaultCredentials = "fido2-vault-credentials"; public const string VaultOnboarding = "vault-onboarding"; public const string BrowserFilelessImport = "browser-fileless-import"; public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; @@ -120,10 +115,6 @@ public static class FeatureFlagKeys public const string KeyRotationImprovements = "key-rotation-improvements"; public const string DuoRedirect = "duo-redirect"; /// - /// Enables flexible collections improvements for new organizations on creation - /// - public const string FlexibleCollectionsSignup = "flexible-collections-signup"; - /// /// Exposes a migration button in the web vault which allows users to migrate an existing organization to /// flexible collections /// @@ -140,6 +131,7 @@ public static class FeatureFlagKeys public const string AnhFcmv1Migration = "anh-fcmv1-migration"; public const string ExtensionRefresh = "extension-refresh"; public const string RestrictProviderAccess = "restrict-provider-access"; + public const string VaultBulkManagementAction = "vault-bulk-management-action"; public static List GetAllKeys() { @@ -154,11 +146,8 @@ public static class FeatureFlagKeys // place overriding values when needed locally (offline), or return null return new Dictionary() { - { TrustedDeviceEncryption, "true" }, - { Fido2VaultCredentials, "true" }, { DuoRedirect, "true" }, - { UnassignedItemsBanner, "true"}, - { FlexibleCollectionsSignup, "true" } + { UnassignedItemsBanner, "true"} }; } } diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index f0f18b86bf..48274b31b3 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -21,19 +21,19 @@ - - + + - - - - + + + + - + - + @@ -57,7 +57,7 @@ - + diff --git a/src/Core/MailTemplates/Handlebars/InitiateDeleteOrganzation.html.hbs b/src/Core/MailTemplates/Handlebars/InitiateDeleteOrganzation.html.hbs new file mode 100644 index 0000000000..7118fcfef4 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/InitiateDeleteOrganzation.html.hbs @@ -0,0 +1,39 @@ +{{#>FullHtmlLayout}} + + + + + + + + + + + + + + + + +
+ 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 delete your Bitwarden organization. +
+ If you did not request this email to delete your Bitwarden organization, please contact us. +
+
+
+ + Delete Your Organization + +
+
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/InitiateDeleteOrganzation.text.hbs b/src/Core/MailTemplates/Handlebars/InitiateDeleteOrganzation.text.hbs new file mode 100644 index 0000000000..0977438381 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/InitiateDeleteOrganzation.text.hbs @@ -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}} diff --git a/src/Core/Models/Data/CollectionAdminDetails.cs b/src/Core/Models/Data/CollectionAdminDetails.cs index 8b96eb4dbd..036f7d0371 100644 --- a/src/Core/Models/Data/CollectionAdminDetails.cs +++ b/src/Core/Models/Data/CollectionAdminDetails.cs @@ -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. /// public bool Assigned { get; set; } + + /// + /// Flag for whether a collection is managed by an active user or group. + /// + public bool Unmanaged { get; set; } } diff --git a/src/Core/Models/Mail/OrganizationInitiateDeleteModel.cs b/src/Core/Models/Mail/OrganizationInitiateDeleteModel.cs new file mode 100644 index 0000000000..4e13abf656 --- /dev/null +++ b/src/Core/Models/Mail/OrganizationInitiateDeleteModel.cs @@ -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; } +} diff --git a/src/Core/Models/StaticStore/Plans/TeamsStarterPlan.cs b/src/Core/Models/StaticStore/Plans/TeamsStarterPlan.cs index b1919376ea..9e308b5e57 100644 --- a/src/Core/Models/StaticStore/Plans/TeamsStarterPlan.cs +++ b/src/Core/Models/StaticStore/Plans/TeamsStarterPlan.cs @@ -28,6 +28,8 @@ public record TeamsStarterPlan : Plan PasswordManager = new TeamsStarterPasswordManagerFeatures(); SecretsManager = new TeamsStarterSecretsManagerFeatures(); + + LegacyYear = 2024; } private record TeamsStarterSecretsManagerFeatures : SecretsManagerPlanFeatures diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 7943eca955..4db8f14fd6 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -79,5 +79,6 @@ public interface IMailService Task SendTrustedDeviceAdminApprovalEmailAsync(string email, DateTime utcNow, string ip, string deviceTypeAndIdentifier); Task SendTrialInitiationEmailAsync(string email); Task SendInitiateDeletProviderEmailAsync(string email, Provider provider, string token); + Task SendInitiateDeleteOrganzationEmailAsync(string email, Organization organization, string token); } diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index e0d2e95dc9..3c78c585f9 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -57,4 +57,5 @@ public interface IPaymentService Task AddSecretsManagerToSubscription(Organization org, Plan plan, int additionalSmSeats, int additionalServiceAccount, DateTime? prorationDate = null); Task RisksSubscriptionFailure(Organization organization); + Task HasSecretsManagerStandalone(Organization organization); } diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index a8cd537e72..f4751623a0 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -4,8 +4,6 @@ using Bit.Core.Auth.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; -using Bit.Core.Tools.Entities; -using Bit.Core.Vault.Entities; using Fido2NetLib; using Microsoft.AspNetCore.Identity; @@ -39,8 +37,6 @@ public interface IUserService Task UpdateTempPasswordAsync(User user, string newMasterPassword, string key, string hint); Task ChangeKdfAsync(User user, string masterPassword, string newMasterPassword, string key, KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism); - Task UpdateKeyAsync(User user, string masterPassword, string key, string privateKey, - IEnumerable ciphers, IEnumerable folders, IEnumerable sends); Task RefreshSecurityStampAsync(User user, string masterPasswordHash); Task UpdateTwoFactorProviderAsync(User user, TwoFactorProviderType type, bool setEnabled = true, bool logEvent = true); Task DisableTwoFactorProviderAsync(User user, TwoFactorProviderType type, diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 496d0ea0c3..7e8de10ce8 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -1002,6 +1002,30 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendInitiateDeleteOrganzationEmailAsync(string email, Organization organization, string token) + { + var message = CreateDefaultMessage("Request to Delete Your Organization", email); + var model = new OrganizationInitiateDeleteModel + { + Token = WebUtility.UrlEncode(token), + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName, + OrganizationId = organization.Id, + OrganizationName = CoreHelpers.SanitizeForEmail(organization.DisplayName(), false), + OrganizationNameUrlEncoded = WebUtility.UrlEncode(organization.Name), + OrganizationBillingEmail = organization.BillingEmail, + OrganizationPlan = organization.Plan, + OrganizationSeats = organization.Seats.ToString(), + OrganizationCreationDate = organization.CreationDate.ToLongDateString(), + OrganizationCreationTime = organization.CreationDate.ToShortTimeString(), + TimeZone = _utcTimeZoneDisplay, + }; + await AddMessageContentAsync(message, "InitiateDeleteOrganzation", model); + message.MetaData.Add("SendGridBypassListManagement", true); + message.Category = "InitiateDeleteOrganzation"; + await _mailDeliveryService.SendEmailAsync(message); + } + private static string GetUserIdentifier(string email, string userName) { return string.IsNullOrEmpty(userName) ? email : CoreHelpers.SanitizeForEmail(userName, false); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index a0ab2378be..cc2bee06bb 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -1800,6 +1800,18 @@ public class StripePaymentService : IPaymentService return paymentSource == null; } + public async Task HasSecretsManagerStandalone(Organization organization) + { + if (string.IsNullOrEmpty(organization.GatewayCustomerId)) + { + return false; + } + + var customer = await _stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId); + + return customer?.Discount?.Coupon?.Id == SecretsManagerStandaloneDiscountId; + } + private PaymentMethod GetLatestCardPaymentMethod(string customerId) { var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging( diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 80e6aa8ca2..148c60a144 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -14,12 +14,10 @@ using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Repositories; using Bit.Core.Settings; using Bit.Core.Tokens; -using Bit.Core.Tools.Entities; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Services; using Bit.Core.Utilities; -using Bit.Core.Vault.Entities; using Bit.Core.Vault.Repositories; using Fido2NetLib; using Fido2NetLib.Objects; @@ -862,39 +860,6 @@ public class UserService : UserManager, IUserService, IDisposable return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); } - public async Task UpdateKeyAsync(User user, string masterPassword, string key, string privateKey, - IEnumerable ciphers, IEnumerable folders, IEnumerable 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 RefreshSecurityStampAsync(User user, string secret) { if (user == null) diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index f86d40a198..198738e3d8 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -270,5 +270,10 @@ public class NoopMailService : IMailService } public Task SendInitiateDeletProviderEmailAsync(string email, Provider provider, string token) => throw new NotImplementedException(); + + public Task SendInitiateDeleteOrganzationEmailAsync(string email, Organization organization, string token) + { + return Task.FromResult(0); + } } diff --git a/src/Core/Tools/Enums/ReferenceEventSource.cs b/src/Core/Tools/Enums/ReferenceEventSource.cs index 6f2bd72732..2c60a5a157 100644 --- a/src/Core/Tools/Enums/ReferenceEventSource.cs +++ b/src/Core/Tools/Enums/ReferenceEventSource.cs @@ -8,4 +8,6 @@ public enum ReferenceEventSource Organization, [EnumMember(Value = "user")] User, + [EnumMember(Value = "provider")] + Provider, } diff --git a/src/Core/Utilities/ModelStateExtensions.cs b/src/Core/Utilities/ModelStateExtensions.cs new file mode 100644 index 0000000000..dc4f95c156 --- /dev/null +++ b/src/Core/Utilities/ModelStateExtensions.cs @@ -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); + } +} diff --git a/src/Core/Vault/Repositories/ICipherRepository.cs b/src/Core/Vault/Repositories/ICipherRepository.cs index f801f6f6f7..80a1e260d6 100644 --- a/src/Core/Vault/Repositories/ICipherRepository.cs +++ b/src/Core/Vault/Repositories/ICipherRepository.cs @@ -1,7 +1,6 @@ using Bit.Core.Auth.UserFeatures.UserKey; using Bit.Core.Entities; using Bit.Core.Repositories; -using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; @@ -30,7 +29,6 @@ public interface ICipherRepository : IRepository Task MoveAsync(IEnumerable ids, Guid? folderId, Guid userId, bool useFlexibleCollections); Task DeleteByUserIdAsync(Guid userId); Task DeleteByOrganizationIdAsync(Guid organizationId); - Task UpdateUserKeysAndCiphersAsync(User user, IEnumerable ciphers, IEnumerable folders, IEnumerable sends); Task UpdateCiphersAsync(Guid userId, IEnumerable ciphers); Task CreateAsync(IEnumerable ciphers, IEnumerable folders); Task CreateAsync(IEnumerable ciphers, IEnumerable collections, diff --git a/src/Identity/Controllers/SsoController.cs b/src/Identity/Controllers/SsoController.cs index e5dfc0ac60..f3dc301a61 100644 --- a/src/Identity/Controllers/SsoController.cs +++ b/src/Identity/Controllers/SsoController.cs @@ -14,8 +14,6 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Identity.Controllers; -// TODO: 2023-10-16, Remove account alias (https://bitwarden.atlassian.net/browse/PM-1247) -[Route("account/[action]")] [Route("sso/[action]")] public class SsoController : Controller { diff --git a/src/Identity/Utilities/ServiceCollectionExtensions.cs b/src/Identity/Utilities/ServiceCollectionExtensions.cs index 6674e0c3b4..95002d8371 100644 --- a/src/Identity/Utilities/ServiceCollectionExtensions.cs +++ b/src/Identity/Utilities/ServiceCollectionExtensions.cs @@ -39,6 +39,7 @@ public static class ServiceCollectionExtensions } options.InputLengthRestrictions.UserName = 256; options.KeyManagement.Enabled = false; + options.UserInteraction.LoginUrl = "/sso/Login"; }) .AddInMemoryCaching() .AddInMemoryApiResources(ApiResources.GetApiResources()) diff --git a/src/Infrastructure.Dapper/Auth/Repositories/AuthRequestRepository.cs b/src/Infrastructure.Dapper/Auth/Repositories/AuthRequestRepository.cs index 67e636b4dd..df68c06d05 100644 --- a/src/Infrastructure.Dapper/Auth/Repositories/AuthRequestRepository.cs +++ b/src/Infrastructure.Dapper/Auth/Repositories/AuthRequestRepository.cs @@ -1,4 +1,5 @@ using System.Data; +using System.Text.Json; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Data; using Bit.Core.Repositories; @@ -74,4 +75,20 @@ public class AuthRequestRepository : Repository, IAuthRequest return results.ToList(); } } + + public async Task UpdateManyAsync(IEnumerable 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); + } + } } diff --git a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs index 5bfcac7b5e..9e767844e4 100644 --- a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs @@ -380,170 +380,6 @@ public class CipherRepository : Repository, ICipherRepository }; } - public Task UpdateUserKeysAndCiphersAsync(User user, IEnumerable ciphers, IEnumerable folders, IEnumerable 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 ciphers) { if (!ciphers.Any()) diff --git a/src/Infrastructure.EntityFramework/Auth/Repositories/AuthRequestRepository.cs b/src/Infrastructure.EntityFramework/Auth/Repositories/AuthRequestRepository.cs index af3ae195dc..11e5b3f65c 100644 --- a/src/Infrastructure.EntityFramework/Auth/Repositories/AuthRequestRepository.cs +++ b/src/Infrastructure.EntityFramework/Auth/Repositories/AuthRequestRepository.cs @@ -69,4 +69,29 @@ public class AuthRequestRepository : Repository authRequests) + { + if (!authRequests.Any()) + { + return; + } + + var entities = new List(); + foreach (var authRequest in authRequests) + { + if (!authRequest.Id.Equals(default)) + { + var entity = Mapper.Map(authRequest); + entities.Add(entity); + } + } + + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + dbContext.UpdateRange(entities); + await dbContext.SaveChangesAsync(); + } + } } diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs index ee6f1e8794..b1c5463733 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs @@ -340,7 +340,7 @@ public class CollectionRepository : Repository Convert.ToInt32(c.ReadOnly))), HidePasswords = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))), - Manage = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.Manage))), + Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), }) .ToList(); } @@ -365,7 +365,7 @@ public class CollectionRepository : Repository Convert.ToInt32(c.ReadOnly))), HidePasswords = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))), - Manage = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.Manage))), + Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), }).ToListAsync(); } } @@ -391,7 +391,8 @@ public class CollectionRepository : Repository new CollectionAdminDetails { Id = collectionGroup.Key.Id, @@ -404,7 +405,8 @@ public class CollectionRepository : Repository Convert.ToInt32(c.HidePasswords))), Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), - Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))) + Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))), + Unmanaged = collectionGroup.Key.Unmanaged }).ToList(); } else @@ -417,7 +419,8 @@ public class CollectionRepository : Repository Convert.ToInt32(c.HidePasswords))), Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), - Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))) + Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))), + Unmanaged = collectionGroup.Key.Unmanaged }).ToListAsync(); } @@ -511,7 +515,8 @@ public class CollectionRepository : Repository Convert.ToInt32(c.HidePasswords))), Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), - Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))) + Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))), + Unmanaged = collectionGroup.Select(c => c.Unmanaged).FirstOrDefault() }).FirstOrDefault(); } else @@ -539,7 +544,8 @@ public class CollectionRepository : Repository Convert.ToInt32(c.HidePasswords))), Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), - Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))) + Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))), + Unmanaged = collectionGroup.Select(c => c.Unmanaged).FirstOrDefault() }).FirstOrDefaultAsync(); } diff --git a/src/Infrastructure.EntityFramework/Repositories/Queries/CollectionAdminDetailsQuery.cs b/src/Infrastructure.EntityFramework/Repositories/Queries/CollectionAdminDetailsQuery.cs index f7b4984946..6b0c313a34 100644 --- a/src/Infrastructure.EntityFramework/Repositories/Queries/CollectionAdminDetailsQuery.cs +++ b/src/Infrastructure.EntityFramework/Repositories/Queries/CollectionAdminDetailsQuery.cs @@ -1,4 +1,5 @@ -using Bit.Core.Models.Data; +using Bit.Core.Enums; +using Bit.Core.Models.Data; namespace Bit.Infrastructure.EntityFramework.Repositories.Queries; @@ -46,6 +47,17 @@ public class CollectionAdminDetailsQuery : IQuery from cg in cg_g.DefaultIfEmpty() select new { c, cu, cg }; + // Subqueries to determine if a colection is managed by an active user or group. + var activeUserManageRights = from cu in dbContext.CollectionUsers + join ou in dbContext.OrganizationUsers + on cu.OrganizationUserId equals ou.Id + where ou.Status == OrganizationUserStatusType.Confirmed && cu.Manage + select cu.CollectionId; + + var activeGroupManageRights = from cg in dbContext.CollectionGroups + where cg.Manage + select cg.CollectionId; + if (_organizationId.HasValue) { baseCollectionQuery = baseCollectionQuery.Where(x => x.c.OrganizationId == _organizationId); @@ -71,6 +83,7 @@ public class CollectionAdminDetailsQuery : IQuery HidePasswords = (bool?)x.cu.HidePasswords ?? (bool?)x.cg.HidePasswords ?? false, Manage = (bool?)x.cu.Manage ?? (bool?)x.cg.Manage ?? false, Assigned = x.cu != null || x.cg != null, + Unmanaged = !activeUserManageRights.Contains(x.c.Id) && !activeGroupManageRights.Contains(x.c.Id), }); } diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs index b199fd06ef..05f660ca4e 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs @@ -19,7 +19,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using NS = Newtonsoft.Json; using NSL = Newtonsoft.Json.Linq; -using User = Bit.Core.Entities.User; namespace Bit.Infrastructure.EntityFramework.Vault.Repositories; @@ -865,23 +864,6 @@ public class CipherRepository : Repository ciphers, IEnumerable folders, IEnumerable sends) - { - using (var scope = ServiceScopeFactory.CreateScope()) - { - var dbContext = GetDatabaseContext(scope); - await UserUpdateKeys(user); - var cipherEntities = Mapper.Map>(ciphers); - await dbContext.BulkCopyAsync(base.DefaultBulkCopyOptions, cipherEntities); - var folderEntities = Mapper.Map>(folders); - await dbContext.BulkCopyAsync(base.DefaultBulkCopyOptions, folderEntities); - var sendEntities = Mapper.Map>(sends); - await dbContext.BulkCopyAsync(base.DefaultBulkCopyOptions, sendEntities); - await dbContext.SaveChangesAsync(); - } - } - public async Task UpsertAsync(CipherDetails cipher) { if (cipher.Id.Equals(default)) diff --git a/src/Notifications/Notifications.csproj b/src/Notifications/Notifications.csproj index d6a38ff0d4..6d63155f4a 100644 --- a/src/Notifications/Notifications.csproj +++ b/src/Notifications/Notifications.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index bc3c68e87e..66048f91ac 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -150,6 +150,13 @@ public static class ServiceCollectionExtensions public static void AddTokenizers(this IServiceCollection services) { + services.AddSingleton>(serviceProvider => + new DataProtectorTokenFactory( + OrgDeleteTokenable.ClearTextPrefix, + OrgDeleteTokenable.DataProtectorPurpose, + serviceProvider.GetDataProtectionProvider(), + serviceProvider.GetRequiredService>>()) + ); services.AddSingleton>(serviceProvider => new DataProtectorTokenFactory( EmergencyAccessInviteTokenable.ClearTextPrefix, diff --git a/src/Sql/Auth/dbo/Stored Procedures/AuthRequest_UpdateMany.sql b/src/Sql/Auth/dbo/Stored Procedures/AuthRequest_UpdateMany.sql new file mode 100644 index 0000000000..227abbb3e1 --- /dev/null +++ b/src/Sql/Auth/dbo/Stored Procedures/AuthRequest_UpdateMany.sql @@ -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 diff --git a/src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByIdWithPermissions.sql b/src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByIdWithPermissions.sql index 5cd6cc93ae..0d1df79c37 100644 --- a/src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByIdWithPermissions.sql +++ b/src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByIdWithPermissions.sql @@ -31,7 +31,29 @@ BEGIN CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL THEN 0 ELSE 1 - END) AS [Assigned] + END) AS [Assigned], + CASE + WHEN + -- No active user or group has manage rights + NOT EXISTS( + SELECT 1 + FROM [dbo].[CollectionUser] CU2 + JOIN [dbo].[OrganizationUser] OU2 ON CU2.[OrganizationUserId] = OU2.[Id] + WHERE + CU2.[CollectionId] = C.[Id] AND + OU2.[Status] = 2 AND + CU2.[Manage] = 1 + ) + AND NOT EXISTS ( + SELECT 1 + FROM [dbo].[CollectionGroup] CG2 + WHERE + CG2.[CollectionId] = C.[Id] AND + CG2.[Manage] = 1 + ) + THEN 1 + ELSE 0 + END AS [Unmanaged] FROM [dbo].[CollectionView] C LEFT JOIN diff --git a/src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByOrganizationIdWithPermissions.sql b/src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByOrganizationIdWithPermissions.sql index 88905eb6c3..61384852b1 100644 --- a/src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByOrganizationIdWithPermissions.sql +++ b/src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByOrganizationIdWithPermissions.sql @@ -31,7 +31,29 @@ BEGIN CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL THEN 0 ELSE 1 - END) AS [Assigned] + END) AS [Assigned], + CASE + WHEN + -- No active user or group has manage rights + NOT EXISTS( + SELECT 1 + FROM [dbo].[CollectionUser] CU2 + JOIN [dbo].[OrganizationUser] OU2 ON CU2.[OrganizationUserId] = OU2.[Id] + WHERE + CU2.[CollectionId] = C.[Id] AND + OU2.[Status] = 2 AND + CU2.[Manage] = 1 + ) + AND NOT EXISTS ( + SELECT 1 + FROM [dbo].[CollectionGroup] CG2 + WHERE + CG2.[CollectionId] = C.[Id] AND + CG2.[Manage] = 1 + ) + THEN 1 + ELSE 0 + END AS [Unmanaged] FROM [dbo].[CollectionView] C LEFT JOIN diff --git a/src/Sql/dbo/Stored Procedures/Collection_ReadByIdUserId.sql b/src/Sql/dbo/Stored Procedures/Collection_ReadByIdUserId.sql index 85f2584359..b38709507e 100644 --- a/src/Sql/dbo/Stored Procedures/Collection_ReadByIdUserId.sql +++ b/src/Sql/dbo/Stored Procedures/Collection_ReadByIdUserId.sql @@ -13,7 +13,7 @@ BEGIN ExternalId, MIN([ReadOnly]) AS [ReadOnly], MIN([HidePasswords]) AS [HidePasswords], - MIN([Manage]) AS [Manage] + MAX([Manage]) AS [Manage] FROM [dbo].[UserCollectionDetails](@UserId) WHERE diff --git a/src/Sql/dbo/Stored Procedures/Collection_ReadByIdUserId_V2.sql b/src/Sql/dbo/Stored Procedures/Collection_ReadByIdUserId_V2.sql index 42b61671a6..e753fc89ac 100644 --- a/src/Sql/dbo/Stored Procedures/Collection_ReadByIdUserId_V2.sql +++ b/src/Sql/dbo/Stored Procedures/Collection_ReadByIdUserId_V2.sql @@ -13,7 +13,7 @@ BEGIN ExternalId, MIN([ReadOnly]) AS [ReadOnly], MIN([HidePasswords]) AS [HidePasswords], - MIN([Manage]) AS [Manage] + MAX([Manage]) AS [Manage] FROM [dbo].[UserCollectionDetails_V2](@UserId) WHERE diff --git a/src/Sql/dbo/Stored Procedures/Collection_ReadByUserId.sql b/src/Sql/dbo/Stored Procedures/Collection_ReadByUserId.sql index b31c0c2b4e..f0eab509ac 100644 --- a/src/Sql/dbo/Stored Procedures/Collection_ReadByUserId.sql +++ b/src/Sql/dbo/Stored Procedures/Collection_ReadByUserId.sql @@ -13,7 +13,7 @@ BEGIN ExternalId, MIN([ReadOnly]) AS [ReadOnly], MIN([HidePasswords]) AS [HidePasswords], - MIN([Manage]) AS [Manage] + MAX([Manage]) AS [Manage] FROM [dbo].[UserCollectionDetails](@UserId) GROUP BY diff --git a/src/Sql/dbo/Stored Procedures/Collection_ReadByUserId_V2.sql b/src/Sql/dbo/Stored Procedures/Collection_ReadByUserId_V2.sql index adcca40a6d..4538dc8da0 100644 --- a/src/Sql/dbo/Stored Procedures/Collection_ReadByUserId_V2.sql +++ b/src/Sql/dbo/Stored Procedures/Collection_ReadByUserId_V2.sql @@ -13,7 +13,7 @@ BEGIN ExternalId, MIN([ReadOnly]) AS [ReadOnly], MIN([HidePasswords]) AS [HidePasswords], - MIN([Manage]) AS [Manage] + MAX([Manage]) AS [Manage] FROM [dbo].[UserCollectionDetails_V2](@UserId) GROUP BY diff --git a/test/Api.Test/AdminConsole/Controllers/GroupsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/GroupsControllerTests.cs index 161f48f70c..526838f360 100644 --- a/test/Api.Test/AdminConsole/Controllers/GroupsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/GroupsControllerTests.cs @@ -260,6 +260,54 @@ public class GroupsControllerTests Assert.Equal(groupRequestModel.AccessAll, response.AccessAll); } + [Theory] + [BitAutoData] + public async Task Put_UpdateMembers_AdminsCannotAccessAllCollections_ProviderUser_Success(Organization organization, Group group, + GroupRequestModel groupRequestModel, List currentGroupUsers, Guid savingUserId, + SutProvider sutProvider) + { + group.OrganizationId = organization.Id; + + // Enable FC and v1 + sutProvider.GetDependency().GetOrganizationAbilityAsync(organization.Id).Returns( + new OrganizationAbility + { + Id = organization.Id, + AllowAdminAccessToAllCollectionItems = false, + FlexibleCollections = true + }); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1).Returns(true); + + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency().GetByIdWithCollectionsAsync(group.Id) + .Returns(new Tuple>(group, new List())); + sutProvider.GetDependency().ManageGroups(organization.Id).Returns(true); + sutProvider.GetDependency() + .GetByOrganizationAsync(organization.Id, Arg.Any()) + .Returns((OrganizationUser)null); // Provider is not an OrganizationUser, so it will always return null + sutProvider.GetDependency().GetProperUserId(Arg.Any()) + .Returns(savingUserId); + sutProvider.GetDependency().GetManyUserIdsByIdAsync(group.Id) + .Returns(currentGroupUsers); + + // Make collection authorization pass, it's not being tested here + groupRequestModel.Collections = Array.Empty(); + + var response = await sutProvider.Sut.Put(organization.Id, group.Id, groupRequestModel); + + await sutProvider.GetDependency().Received(1).ManageGroups(organization.Id); + await sutProvider.GetDependency().Received(1).UpdateGroupAsync( + Arg.Is(g => + g.OrganizationId == organization.Id && g.Name == groupRequestModel.Name && + g.AccessAll == groupRequestModel.AccessAll), + Arg.Is(o => o.Id == organization.Id), + Arg.Any>(), + Arg.Any>()); + Assert.Equal(groupRequestModel.Name, response.Name); + Assert.Equal(organization.Id, response.OrganizationId); + Assert.Equal(groupRequestModel.AccessAll, response.AccessAll); + } + [Theory] [BitAutoData] public async Task Put_UpdateCollections_OnlyUpdatesCollectionsTheSavingUserCanUpdate(GroupRequestModel groupRequestModel, diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 831212f1b9..a6844d8c2d 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -1,12 +1,11 @@ using System.Security.Claims; using AutoFixture.Xunit2; using Bit.Api.AdminConsole.Controllers; -using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Auth.Models.Request.Accounts; -using Bit.Api.Models.Request.Organizations; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements.Interfaces; using Bit.Core.AdminConsole.Repositories; @@ -16,21 +15,15 @@ using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Services; using Bit.Core.Billing.Commands; -using Bit.Core.Billing.Queries; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; -using Bit.Core.Models.Data.Organizations.OrganizationUsers; -using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; -using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Services; +using Bit.Core.Tokens; using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; using NSubstitute; -using NSubstitute.ReturnsExtensions; using Xunit; using GlobalSettings = Bit.Core.Settings.GlobalSettings; @@ -43,7 +36,6 @@ public class OrganizationsControllerTests : IDisposable private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationService _organizationService; private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IPaymentService _paymentService; private readonly IPolicyRepository _policyRepository; private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoConfigService _ssoConfigService; @@ -51,20 +43,13 @@ public class OrganizationsControllerTests : IDisposable private readonly IGetOrganizationApiKeyQuery _getOrganizationApiKeyQuery; private readonly IRotateOrganizationApiKeyCommand _rotateOrganizationApiKeyCommand; private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; - private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery; private readonly ICreateOrganizationApiKeyCommand _createOrganizationApiKeyCommand; private readonly IFeatureService _featureService; - private readonly ILicensingService _licensingService; - private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; - private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand; - private readonly IAddSecretsManagerSubscriptionCommand _addSecretsManagerSubscriptionCommand; private readonly IPushNotificationService _pushNotificationService; - private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand; - private readonly ISubscriberQueries _subscriberQueries; - private readonly IReferenceEventService _referenceEventService; private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand; private readonly IProviderRepository _providerRepository; private readonly IScaleSeatsCommand _scaleSeatsCommand; + private readonly IDataProtectorTokenFactory _orgDeleteTokenDataFactory; private readonly OrganizationsController _sut; @@ -75,7 +60,6 @@ public class OrganizationsControllerTests : IDisposable _organizationRepository = Substitute.For(); _organizationService = Substitute.For(); _organizationUserRepository = Substitute.For(); - _paymentService = Substitute.For(); _policyRepository = Substitute.For(); _ssoConfigRepository = Substitute.For(); _ssoConfigService = Substitute.For(); @@ -83,20 +67,13 @@ public class OrganizationsControllerTests : IDisposable _rotateOrganizationApiKeyCommand = Substitute.For(); _organizationApiKeyRepository = Substitute.For(); _userService = Substitute.For(); - _cloudGetOrganizationLicenseQuery = Substitute.For(); _createOrganizationApiKeyCommand = Substitute.For(); _featureService = Substitute.For(); - _licensingService = Substitute.For(); - _updateSecretsManagerSubscriptionCommand = Substitute.For(); - _upgradeOrganizationPlanCommand = Substitute.For(); - _addSecretsManagerSubscriptionCommand = Substitute.For(); _pushNotificationService = Substitute.For(); - _cancelSubscriptionCommand = Substitute.For(); - _subscriberQueries = Substitute.For(); - _referenceEventService = Substitute.For(); _organizationEnableCollectionEnhancementsCommand = Substitute.For(); _providerRepository = Substitute.For(); _scaleSeatsCommand = Substitute.For(); + _orgDeleteTokenDataFactory = Substitute.For>(); _sut = new OrganizationsController( _organizationRepository, @@ -104,7 +81,6 @@ public class OrganizationsControllerTests : IDisposable _policyRepository, _organizationService, _userService, - _paymentService, _currentContext, _ssoConfigRepository, _ssoConfigService, @@ -112,20 +88,13 @@ public class OrganizationsControllerTests : IDisposable _rotateOrganizationApiKeyCommand, _createOrganizationApiKeyCommand, _organizationApiKeyRepository, - _cloudGetOrganizationLicenseQuery, _featureService, _globalSettings, - _licensingService, - _updateSecretsManagerSubscriptionCommand, - _upgradeOrganizationPlanCommand, - _addSecretsManagerSubscriptionCommand, _pushNotificationService, - _cancelSubscriptionCommand, - _subscriberQueries, - _referenceEventService, _organizationEnableCollectionEnhancementsCommand, _providerRepository, - _scaleSeatsCommand); + _scaleSeatsCommand, + _orgDeleteTokenDataFactory); } public void Dispose() @@ -193,196 +162,6 @@ public class OrganizationsControllerTests : IDisposable await _organizationService.Received(1).DeleteUserAsync(orgId, user.Id); } - [Theory, AutoData] - public async Task OrganizationsController_PostUpgrade_UserCannotEditSubscription_ThrowsNotFoundException( - Guid organizationId, - OrganizationUpgradeRequestModel model) - { - _currentContext.EditSubscription(organizationId).Returns(false); - - await Assert.ThrowsAsync(() => _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()) - .Returns(new Tuple(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()) - .Returns(new Tuple(success, paymentIntentClientSecret)); - - _userService.GetProperUserId(Arg.Any()).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(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()) - .Returns(new Tuple(success, paymentIntentClientSecret)); - - _userService.GetProperUserId(Arg.Any()).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()); - } - - [Theory, AutoData] - public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_NullOrg_ThrowsNotFoundException( - Guid organizationId, - SecretsManagerSubscribeRequestModel model) - { - _organizationRepository.GetByIdAsync(organizationId).ReturnsNull(); - - await Assert.ThrowsAsync(() => _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(() => _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()).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(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()).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()); - } - [Theory, AutoData] public async Task EnableCollectionEnhancements_Success(Organization organization) { diff --git a/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs new file mode 100644 index 0000000000..b5737837e8 --- /dev/null +++ b/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs @@ -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(); + _globalSettings = Substitute.For(); + _organizationRepository = Substitute.For(); + _organizationService = Substitute.For(); + _organizationUserRepository = Substitute.For(); + _paymentService = Substitute.For(); + Substitute.For(); + _ssoConfigRepository = Substitute.For(); + Substitute.For(); + _userService = Substitute.For(); + _cloudGetOrganizationLicenseQuery = Substitute.For(); + _licensingService = Substitute.For(); + _updateSecretsManagerSubscriptionCommand = Substitute.For(); + _upgradeOrganizationPlanCommand = Substitute.For(); + _addSecretsManagerSubscriptionCommand = Substitute.For(); + _cancelSubscriptionCommand = Substitute.For(); + _subscriberQueries = Substitute.For(); + _referenceEventService = Substitute.For(); + + _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()).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(() => _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()) + .Returns(new Tuple(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()) + .Returns(new Tuple(success, paymentIntentClientSecret)); + + _userService.GetProperUserId(Arg.Any()).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(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()) + .Returns(new Tuple(success, paymentIntentClientSecret)); + + _userService.GetProperUserId(Arg.Any()).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()); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_NullOrg_ThrowsNotFoundException( + Guid organizationId, + SecretsManagerSubscribeRequestModel model) + { + _organizationRepository.GetByIdAsync(organizationId).ReturnsNull(); + + await Assert.ThrowsAsync(() => _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(() => _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()).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(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()).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()); + } +} diff --git a/test/Api.Test/Billing/Controllers/ProviderClientsControllerTests.cs b/test/Api.Test/Billing/Controllers/ProviderClientsControllerTests.cs index b6acb73e48..e0c9a27a62 100644 --- a/test/Api.Test/Billing/Controllers/ProviderClientsControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/ProviderClientsControllerTests.cs @@ -301,7 +301,7 @@ public class ProviderClientsControllerTests } [Theory, BitAutoData] - public async Task UpdateAsync_NoContent( + public async Task UpdateAsync_AssignedSeats_NoContent( Guid providerId, Guid providerOrganizationId, UpdateClientOrganizationRequestBody requestBody, @@ -333,6 +333,50 @@ public class ProviderClientsControllerTests organization, requestBody.AssignedSeats); + await sutProvider.GetDependency().Received(1) + .ReplaceAsync(Arg.Is(org => org.Name == requestBody.Name)); + + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_Name_NoContent( + Guid providerId, + Guid providerOrganizationId, + UpdateClientOrganizationRequestBody requestBody, + Provider provider, + ProviderOrganization providerOrganization, + Organization organization, + SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) + .Returns(true); + + sutProvider.GetDependency().ProviderProviderAdmin(providerId) + .Returns(true); + + sutProvider.GetDependency().GetByIdAsync(providerId) + .Returns(provider); + + sutProvider.GetDependency().GetByIdAsync(providerOrganizationId) + .Returns(providerOrganization); + + sutProvider.GetDependency().GetByIdAsync(providerOrganization.OrganizationId) + .Returns(organization); + + requestBody.AssignedSeats = organization.Seats!.Value; + + var result = await sutProvider.Sut.UpdateAsync(providerId, providerOrganizationId, requestBody); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .AssignSeatsToClientOrganization( + Arg.Any(), + Arg.Any(), + Arg.Any()); + + await sutProvider.GetDependency().Received(1) + .ReplaceAsync(Arg.Is(org => org.Name == requestBody.Name)); + Assert.IsType(result); } #endregion diff --git a/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs b/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs index dd8bacaa9e..dbc076bf75 100644 --- a/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs +++ b/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs @@ -828,108 +828,169 @@ public class BulkCollectionAuthorizationHandlerTests } [Theory, BitAutoData, CollectionCustomization] - public async Task CanUpdateUsers_WithManageUsersCustomPermission_Success( - SutProvider sutProvider, - ICollection collections, - CurrentContextOrganization organization) + public async Task CanUpdateUsers_WithManageUsersCustomPermission_V1Disabled_Success( + SutProvider sutProvider, ICollection collections, + CurrentContextOrganization organization, Guid actingUserId) { - var actingUserId = Guid.NewGuid(); - organization.Type = OrganizationUserType.Custom; organization.Permissions = new Permissions { ManageUsers = true }; - var operationsToTest = new[] - { - BulkCollectionOperations.ModifyUserAccess, - }; + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) + .Returns(false); - foreach (var op in operationsToTest) - { - sutProvider.GetDependency().UserId.Returns(actingUserId); - sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.ModifyUserAccess }, + new ClaimsPrincipal(), + collections); - var context = new AuthorizationHandlerContext( - new[] { op }, - new ClaimsPrincipal(), - collections); + await sutProvider.Sut.HandleAsync(context); - await sutProvider.Sut.HandleAsync(context); - - Assert.True(context.HasSucceeded); - - // Recreate the SUT to reset the mocks/dependencies between tests - sutProvider.Recreate(); - } + Assert.True(context.HasSucceeded); } [Theory, BitAutoData, CollectionCustomization] - public async Task CanUpdateGroups_WithManageGroupsCustomPermission_Success( - SutProvider sutProvider, - ICollection collections, - CurrentContextOrganization organization) + public async Task CanUpdateUsers_WithManageUsersCustomPermission_AllowAdminAccessIsTrue_Success( + SutProvider sutProvider, ICollection collections, + CurrentContextOrganization organization, Guid actingUserId) { - var actingUserId = Guid.NewGuid(); + organization.Type = OrganizationUserType.Custom; + organization.Permissions = new Permissions + { + ManageUsers = true + }; + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) + .Returns(true); + sutProvider.GetDependency().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 sutProvider, ICollection collections, + CurrentContextOrganization organization, Guid actingUserId) + { + organization.Type = OrganizationUserType.Custom; + organization.Permissions = new Permissions + { + ManageUsers = true + }; + + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) + .Returns(true); + sutProvider.GetDependency().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 sutProvider, ICollection collections, + CurrentContextOrganization organization, Guid actingUserId) + { organization.Type = OrganizationUserType.Custom; organization.Permissions = new Permissions { ManageGroups = true }; - var operationsToTest = new[] - { - BulkCollectionOperations.ModifyGroupAccess, - }; - - foreach (var op in operationsToTest) - { - sutProvider.GetDependency().UserId.Returns(actingUserId); - sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - - var context = new AuthorizationHandlerContext( - new[] { op }, - new ClaimsPrincipal(), - collections); - - await sutProvider.Sut.HandleAsync(context); - - Assert.True(context.HasSucceeded); - - // Recreate the SUT to reset the mocks/dependencies between tests - sutProvider.Recreate(); - } - } - - [Theory, CollectionCustomization] - [BitAutoData(OrganizationUserType.Admin)] - [BitAutoData(OrganizationUserType.Owner)] - public async Task CanDeleteAsync_WhenAdminOrOwner_Success( - OrganizationUserType userType, - Guid userId, SutProvider sutProvider, - ICollection collections, - CurrentContextOrganization organization) - { - organization.Type = userType; - organization.Permissions = new Permissions(); - - ArrangeOrganizationAbility(sutProvider, organization, true); + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) + .Returns(false); var context = new AuthorizationHandlerContext( - new[] { BulkCollectionOperations.Delete }, + new[] { BulkCollectionOperations.ModifyGroupAccess }, new ClaimsPrincipal(), collections); - sutProvider.GetDependency().UserId.Returns(userId); - sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - await sutProvider.Sut.HandleAsync(context); Assert.True(context.HasSucceeded); } + [Theory, BitAutoData, CollectionCustomization] + public async Task CanUpdateGroups_WithManageGroupsCustomPermission_AllowAdminAccessIsTrue_Success( + SutProvider sutProvider, ICollection collections, + CurrentContextOrganization organization, Guid actingUserId) + { + organization.Type = OrganizationUserType.Custom; + organization.Permissions = new Permissions + { + ManageGroups = true + }; + + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) + .Returns(true); + sutProvider.GetDependency().GetOrganizationAbilityAsync(organization.Id) + .Returns(new OrganizationAbility { AllowAdminAccessToAllCollectionItems = true }); + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.ModifyGroupAccess }, + new ClaimsPrincipal(), + collections); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasSucceeded); + } + + [Theory, BitAutoData, CollectionCustomization] + public async Task CanUpdateGroups_WithManageGroupsCustomPermission_AllowAdminAccessIsFalse_Failure( + SutProvider sutProvider, ICollection collections, + CurrentContextOrganization organization, Guid actingUserId) + { + organization.Type = OrganizationUserType.Custom; + organization.Permissions = new Permissions + { + ManageGroups = true + }; + + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) + .Returns(true); + sutProvider.GetDependency().GetOrganizationAbilityAsync(organization.Id) + .Returns(new OrganizationAbility { AllowAdminAccessToAllCollectionItems = false }); + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.ModifyGroupAccess }, + new ClaimsPrincipal(), + collections); + + await sutProvider.Sut.HandleAsync(context); + + Assert.False(context.HasSucceeded); + } + [Theory, BitAutoData, CollectionCustomization] public async Task CanDeleteAsync_WithDeleteAnyCollectionPermission_Success( SutProvider sutProvider, @@ -959,8 +1020,63 @@ public class BulkCollectionAuthorizationHandlerTests Assert.True(context.HasSucceeded); } + [Theory, CollectionCustomization] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Owner)] + public async Task CanDeleteAsync_WhenAdminOrOwner_AllowAdminAccessToAllCollectionItemsTrue_Success( + OrganizationUserType userType, + Guid userId, SutProvider sutProvider, + ICollection 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().UserId.Returns(userId); + sutProvider.GetDependency().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 sutProvider, + ICollection 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().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1).Returns(false); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasSucceeded); + } + [Theory, BitAutoData, CollectionCustomization] - public async Task CanDeleteAsync_WithManageCollectionPermission_Success( + public async Task CanDeleteAsync_WhenUser_LimitCollectionCreationDeletionFalse_WithCanManagePermission_Success( SutProvider sutProvider, ICollection collections, CurrentContextOrganization organization) @@ -991,6 +1107,184 @@ public class BulkCollectionAuthorizationHandlerTests Assert.True(context.HasSucceeded); } + [Theory, CollectionCustomization] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Owner)] + [BitAutoData(OrganizationUserType.User)] + public async Task CanDeleteAsync_LimitCollectionCreationDeletionFalse_AllowAdminAccessToAllCollectionItemsFalse_WithCanManagePermission_Success( + OrganizationUserType userType, + SutProvider sutProvider, + ICollection collections, + CurrentContextOrganization organization) + { + var actingUserId = Guid.NewGuid(); + + organization.Type = userType; + organization.Permissions = new Permissions(); + + ArrangeOrganizationAbility(sutProvider, organization, false, false); + + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId, Arg.Any()).Returns(collections); + sutProvider.GetDependency().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 sutProvider, + ICollection collections, + CurrentContextOrganization organization) + { + var actingUserId = Guid.NewGuid(); + + organization.Type = userType; + organization.Permissions = new Permissions(); + + ArrangeOrganizationAbility(sutProvider, organization, true, false); + + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId, Arg.Any()).Returns(collections); + sutProvider.GetDependency().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 sutProvider, + ICollection collections, + CurrentContextOrganization organization) + { + var actingUserId = Guid.NewGuid(); + + organization.Type = userType; + organization.Permissions = new Permissions(); + + ArrangeOrganizationAbility(sutProvider, organization, true, false); + + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId, Arg.Any()).Returns(collections); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1).Returns(true); + sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).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 sutProvider, + ICollection collections, + CurrentContextOrganization organization) + { + var actingUserId = Guid.NewGuid(); + + organization.Type = OrganizationUserType.User; + organization.Permissions = new Permissions(); + + ArrangeOrganizationAbility(sutProvider, organization, true); + + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId, Arg.Any()).Returns(collections); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1).Returns(true); + sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).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 sutProvider, + ICollection collections, + CurrentContextOrganization organization) + { + var actingUserId = Guid.NewGuid(); + + organization.Type = OrganizationUserType.User; + organization.Permissions = new Permissions(); + + ArrangeOrganizationAbility(sutProvider, organization, true, false); + + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId, Arg.Any()).Returns(collections); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1).Returns(true); + sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); + + foreach (var c in collections) + { + c.Manage = true; + } + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.Delete }, + new ClaimsPrincipal(), + collections); + + await sutProvider.Sut.HandleAsync(context); + + Assert.False(context.HasSucceeded); + } + [Theory, CollectionCustomization] [BitAutoData(OrganizationUserType.User)] [BitAutoData(OrganizationUserType.Custom)] @@ -1102,7 +1396,8 @@ public class BulkCollectionAuthorizationHandlerTests { collections.First().OrganizationId, new OrganizationAbility { - LimitCollectionCreationDeletion = true + LimitCollectionCreationDeletion = true, + AllowAdminAccessToAllCollectionItems = true } } }; @@ -1177,12 +1472,14 @@ public class BulkCollectionAuthorizationHandlerTests private static void ArrangeOrganizationAbility( SutProvider sutProvider, - CurrentContextOrganization organization, bool limitCollectionCreationDeletion) + CurrentContextOrganization organization, bool limitCollectionCreationDeletion, + bool allowAdminAccessToAllCollectionItems = true) { var organizationAbility = new OrganizationAbility(); organizationAbility.Id = organization.Id; organizationAbility.FlexibleCollections = true; organizationAbility.LimitCollectionCreationDeletion = limitCollectionCreationDeletion; + organizationAbility.AllowAdminAccessToAllCollectionItems = allowAdminAccessToAllCollectionItems; sutProvider.GetDependency().GetOrganizationAbilityAsync(organizationAbility.Id) .Returns(organizationAbility); diff --git a/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs b/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs index 14f42db723..13f172af64 100644 --- a/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs +++ b/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs @@ -3,9 +3,11 @@ using Bit.Api.Vault.Controllers; using Bit.Api.Vault.Models.Request; using Bit.Core; using Bit.Core.Context; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; @@ -14,7 +16,9 @@ using Bit.Core.Vault.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; +using NSubstitute.ReturnsExtensions; using Xunit; +using CipherType = Bit.Core.Vault.Enums.CipherType; namespace Bit.Api.Test.Controllers; @@ -50,6 +54,92 @@ public class CiphersControllerTests Assert.Equal(isFavorite, result.Favorite); } + [Theory, BitAutoData] + public async Task PutCollections_vNextShouldThrowExceptionWhenCipherIsNullOrNoOrgValue(Guid id, CipherCollectionsRequestModel model, Guid userId, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetProperUserId(default).Returns(userId); + sutProvider.GetDependency().OrganizationUser(Guid.NewGuid()).Returns(false); + sutProvider.GetDependency().GetByIdAsync(id, userId, true).ReturnsNull(); + + var requestAction = async () => await sutProvider.Sut.PutCollections_vNext(id, model); + + await Assert.ThrowsAsync(requestAction); + } + + [Theory, BitAutoData] + public async Task PutCollections_vNextShouldSaveUpdatedCipher(Guid id, CipherCollectionsRequestModel model, Guid userId, SutProvider sutProvider) + { + SetupUserAndOrgMocks(id, userId, sutProvider); + var cipherDetails = CreateCipherDetailsMock(id, userId); + sutProvider.GetDependency().GetByIdAsync(id, userId, true).ReturnsForAnyArgs(cipherDetails); + + sutProvider.GetDependency().GetManyByUserIdCipherIdAsync(userId, id, Arg.Any()).Returns((ICollection)new List()); + var cipherService = sutProvider.GetDependency(); + + 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 sutProvider) + { + SetupUserAndOrgMocks(id, userId, sutProvider); + var cipherDetails = CreateCipherDetailsMock(id, userId); + sutProvider.GetDependency().GetByIdAsync(id, userId, true).ReturnsForAnyArgs(cipherDetails); + + sutProvider.GetDependency().GetManyByUserIdCipherIdAsync(userId, id, Arg.Any()).Returns((ICollection)new List()); + + var result = await sutProvider.Sut.PutCollections_vNext(id, model); + + Assert.IsType(result); + Assert.False(result.Unavailable); + } + + [Theory, BitAutoData] + public async Task PutCollections_vNextReturnOptionalDetailsCipherUnavailableTrue(Guid id, CipherCollectionsRequestModel model, Guid userId, SutProvider sutProvider) + { + SetupUserAndOrgMocks(id, userId, sutProvider); + var cipherDetails = CreateCipherDetailsMock(id, userId); + sutProvider.GetDependency().GetByIdAsync(id, userId, true).ReturnsForAnyArgs(cipherDetails, [(CipherDetails)null]); + + sutProvider.GetDependency().GetManyByUserIdCipherIdAsync(userId, id, Arg.Any()).Returns((ICollection)new List()); + + var result = await sutProvider.Sut.PutCollections_vNext(id, model); + + Assert.IsType(result); + Assert.True(result.Unavailable); + } + + private void SetupUserAndOrgMocks(Guid id, Guid userId, SutProvider sutProvider) + { + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(userId); + sutProvider.GetDependency().OrganizationUser(default).ReturnsForAnyArgs(true); + sutProvider.GetDependency().GetManyByUserIdCipherIdAsync(userId, id, Arg.Any()).Returns(new List()); + } + + private CipherDetails CreateCipherDetailsMock(Guid id, Guid userId) + { + return new CipherDetails + { + Id = id, + UserId = userId, + OrganizationId = Guid.NewGuid(), + Type = CipherType.Login, + Data = @" + { + ""Uris"": [ + { + ""Uri"": ""https://bitwarden.com"" + } + ], + ""Username"": ""testuser"", + ""Password"": ""securepassword123"" + }" + }; + } + [Theory] [BitAutoData(OrganizationUserType.Admin, true, true)] [BitAutoData(OrganizationUserType.Owner, true, true)] diff --git a/test/Core.Test/AdminConsole/AutoFixture/OrganizationUserPolicyDetailsFixtures.cs b/test/Core.Test/AdminConsole/AutoFixture/OrganizationUserPolicyDetailsFixtures.cs new file mode 100644 index 0000000000..634b234e70 --- /dev/null +++ b/test/Core.Test/AdminConsole/AutoFixture/OrganizationUserPolicyDetailsFixtures.cs @@ -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(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); + } +} diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index fa2090d37d..48a66dff0e 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Business.Tokenables; @@ -39,7 +40,6 @@ using NSubstitute.ReturnsExtensions; using Xunit; using Organization = Bit.Core.AdminConsole.Entities.Organization; using OrganizationUser = Bit.Core.Entities.OrganizationUser; -using Policy = Bit.Core.AdminConsole.Entities.Policy; namespace Bit.Core.Test.Services; @@ -252,7 +252,7 @@ public class OrganizationServiceTests [Theory] [BitAutoData(PlanType.FamiliesAnnually)] - public async Task SignUp_WithFlexibleCollections_SetsAccessAllToFalse + public async Task SignUp_EnablesFlexibleCollectionsFeatures (PlanType planType, OrganizationSignup signup, SutProvider sutProvider) { signup.Plan = planType; @@ -261,10 +261,6 @@ public class OrganizationServiceTests signup.PremiumAccessAddon = false; signup.UseSecretsManager = false; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup) - .Returns(true); - // Extract orgUserId when created Guid? orgUserId = null; await sutProvider.GetDependency() @@ -272,6 +268,10 @@ public class OrganizationServiceTests var result = await sutProvider.Sut.SignUpAsync(signup); + // Assert: Organization.FlexibleCollections is enabled + await sutProvider.GetDependency().Received(1) + .CreateAsync(Arg.Is(o => o.FlexibleCollections)); + // Assert: AccessAll is not used await sutProvider.GetDependency().Received(1).CreateAsync( Arg.Is(o => @@ -295,33 +295,6 @@ public class OrganizationServiceTests Assert.NotNull(result.Item2); } - [Theory] - [BitAutoData(PlanType.FamiliesAnnually)] - public async Task SignUp_WithoutFlexibleCollections_SetsAccessAllToTrue - (PlanType planType, OrganizationSignup signup, SutProvider 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() - .IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup) - .Returns(false); - - var result = await sutProvider.Sut.SignUpAsync(signup); - - await sutProvider.GetDependency().Received(1).CreateAsync( - Arg.Is(o => - o.UserId == signup.Owner.Id && - o.AccessAll == true)); - - Assert.NotNull(result.Item1); - Assert.NotNull(result.Item2); - } - [Theory] [BitAutoData(PlanType.EnterpriseAnnually)] [BitAutoData(PlanType.EnterpriseMonthly)] @@ -1441,15 +1414,15 @@ OrganizationUserInvite invite, SutProvider sutProvider) [Theory, BitAutoData] - public async Task ConfirmUser_SingleOrgPolicy(Organization org, OrganizationUser confirmingUser, + public async Task ConfirmUser_AsUser_SingleOrgPolicy_AppliedFromConfirmingOrg_Throws(Organization org, OrganizationUser confirmingUser, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, - OrganizationUser orgUserAnotherOrg, [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + OrganizationUser orgUserAnotherOrg, [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, string key, SutProvider sutProvider) { var organizationUserRepository = sutProvider.GetDependency(); var organizationRepository = sutProvider.GetDependency(); - var policyRepository = sutProvider.GetDependency(); var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); var userService = Substitute.For(); org.PlanType = PlanType.EnterpriseAnnually; @@ -1460,23 +1433,84 @@ OrganizationUserInvite invite, SutProvider sutProvider) organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); organizationRepository.GetByIdAsync(org.Id).Returns(org); userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - policyRepository.GetManyByOrganizationIdAsync(org.Id).Returns(new[] { singleOrgPolicy }); + singleOrgPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg).Returns(new[] { singleOrgPolicy }); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService)); - Assert.Contains("User is a member of another organization.", exception.Message); + Assert.Contains("Cannot confirm this member to the organization until they leave or remove all other organizations.", exception.Message); } [Theory, BitAutoData] - public async Task ConfirmUser_TwoFactorPolicy(Organization org, OrganizationUser confirmingUser, + public async Task ConfirmUser_AsUser_SingleOrgPolicy_AppliedFromOtherOrg_Throws(Organization org, OrganizationUser confirmingUser, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, - OrganizationUser orgUserAnotherOrg, [Policy(PolicyType.TwoFactorAuthentication)] Policy twoFactorPolicy, + OrganizationUser orgUserAnotherOrg, [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, string key, SutProvider sutProvider) { var organizationUserRepository = sutProvider.GetDependency(); var organizationRepository = sutProvider.GetDependency(); - var policyRepository = sutProvider.GetDependency(); var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); + var userService = Substitute.For(); + + 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( + () => 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 sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var userService = Substitute.For(); + + 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().Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); + await sutProvider.GetDependency().Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email); + await organizationUserRepository.Received(1).ReplaceManyAsync(Arg.Is>(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 sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); var userService = Substitute.For(); org.PlanType = PlanType.EnterpriseAnnually; @@ -1486,7 +1520,8 @@ OrganizationUserInvite invite, SutProvider sutProvider) organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); organizationRepository.GetByIdAsync(org.Id).Returns(org); userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - policyRepository.GetManyByOrganizationIdAsync(org.Id).Returns(new[] { twoFactorPolicy }); + twoFactorPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService)); @@ -1494,15 +1529,15 @@ OrganizationUserInvite invite, SutProvider sutProvider) } [Theory, BitAutoData] - public async Task ConfirmUser_Success(Organization org, OrganizationUser confirmingUser, + public async Task ConfirmUser_TwoFactorPolicy_Enabled_Success(Organization org, OrganizationUser confirmingUser, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, - [Policy(PolicyType.TwoFactorAuthentication)] Policy twoFactorPolicy, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, string key, SutProvider sutProvider) + [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, + string key, SutProvider sutProvider) { var organizationUserRepository = sutProvider.GetDependency(); var organizationRepository = sutProvider.GetDependency(); - var policyRepository = sutProvider.GetDependency(); var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); var userService = Substitute.For(); org.PlanType = PlanType.EnterpriseAnnually; @@ -1511,7 +1546,8 @@ OrganizationUserInvite invite, SutProvider sutProvider) organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); organizationRepository.GetByIdAsync(org.Id).Returns(org); userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - policyRepository.GetManyByOrganizationIdAsync(org.Id).Returns(new[] { twoFactorPolicy, singleOrgPolicy }); + twoFactorPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); userService.TwoFactorIsEnabledAsync(user).Returns(true); await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService); @@ -1524,13 +1560,14 @@ OrganizationUserInvite invite, SutProvider sutProvider) [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser2, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser3, OrganizationUser anotherOrgUser, User user1, User user2, User user3, - [Policy(PolicyType.TwoFactorAuthentication)] Policy twoFactorPolicy, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, string key, SutProvider sutProvider) + [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, + [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, + string key, SutProvider sutProvider) { var organizationUserRepository = sutProvider.GetDependency(); var organizationRepository = sutProvider.GetDependency(); - var policyRepository = sutProvider.GetDependency(); var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); var userService = Substitute.For(); org.PlanType = PlanType.EnterpriseAnnually; @@ -1543,10 +1580,14 @@ OrganizationUserInvite invite, SutProvider sutProvider) organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(orgUsers); organizationRepository.GetByIdAsync(org.Id).Returns(org); userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user1, user2, user3 }); - policyRepository.GetManyByOrganizationIdAsync(org.Id).Returns(new[] { twoFactorPolicy, singleOrgPolicy }); + twoFactorPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(Arg.Any(), PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); userService.TwoFactorIsEnabledAsync(user1).Returns(true); userService.TwoFactorIsEnabledAsync(user2).Returns(false); userService.TwoFactorIsEnabledAsync(user3).Returns(true); + singleOrgPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(user3.Id, PolicyType.SingleOrg) + .Returns(new[] { singleOrgPolicy }); organizationUserRepository.GetManyByManyUsersAsync(default) .ReturnsForAnyArgs(new[] { orgUser1, orgUser2, orgUser3, anotherOrgUser }); @@ -1554,7 +1595,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) var result = await sutProvider.Sut.ConfirmUsersAsync(confirmingUser.OrganizationId, keys, confirmingUser.Id, userService); Assert.Contains("", result[0].Item2); Assert.Contains("User does not have two-step login enabled.", result[1].Item2); - Assert.Contains("User is a member of another organization.", result[2].Item2); + Assert.Contains("Cannot confirm this member to the organization until they leave or remove all other organizations.", result[2].Item2); } [Theory, BitAutoData] diff --git a/test/Core.Test/Models/Business/SeatSubscriptionUpdateTests.cs b/test/Core.Test/Models/Business/SeatSubscriptionUpdateTests.cs new file mode 100644 index 0000000000..5cf4b8fbf6 --- /dev/null +++ b/test/Core.Test/Models/Business/SeatSubscriptionUpdateTests.cs @@ -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 + { + Data = new List + { + 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 + { + Data = new List + { + 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); + } +} diff --git a/test/Core.Test/Models/Business/ServiceAccountSubscriptionUpdateTests.cs b/test/Core.Test/Models/Business/ServiceAccountSubscriptionUpdateTests.cs new file mode 100644 index 0000000000..259a53bda0 --- /dev/null +++ b/test/Core.Test/Models/Business/ServiceAccountSubscriptionUpdateTests.cs @@ -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 + { + Data = new List + { + 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 + { + Data = new List + { + 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); + } +} diff --git a/test/Core.Test/Models/Business/SmSeatSubscriptionUpdateTests.cs b/test/Core.Test/Models/Business/SmSeatSubscriptionUpdateTests.cs new file mode 100644 index 0000000000..f7ce31167e --- /dev/null +++ b/test/Core.Test/Models/Business/SmSeatSubscriptionUpdateTests.cs @@ -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 + { + Data = new List + { + 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 + { + Data = new List + { + 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); + } +} diff --git a/test/Core.Test/Models/Business/StorageSubscriptionUpdateTests.cs b/test/Core.Test/Models/Business/StorageSubscriptionUpdateTests.cs new file mode 100644 index 0000000000..aae4e64bc7 --- /dev/null +++ b/test/Core.Test/Models/Business/StorageSubscriptionUpdateTests.cs @@ -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 + { + Data = new List + { + 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(() => 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 + { + Data = new List + { + 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); + } +} diff --git a/test/Infrastructure.IntegrationTest/Auth/Repositories/AuthRequestRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Auth/Repositories/AuthRequestRepositoryTests.cs index 6c5bf135e1..835d1da74c 100644 --- a/test/Infrastructure.IntegrationTest/Auth/Repositories/AuthRequestRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Auth/Repositories/AuthRequestRepositoryTests.cs @@ -72,6 +72,113 @@ public class AuthRequestRepositoryTests Assert.Equal(4, numberOfDeleted); } + [DatabaseTheory, DatabaseData] + public async Task UpdateManyAsync_Works( + IAuthRequestRepository authRequestRepository, + IUserRepository userRepository) + { + // Create two distinct real users for foreign key requirements + var user1 = await userRepository.CreateAsync(new User + { + Name = "First Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var user2 = await userRepository.CreateAsync(new User + { + Name = "Second Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var user3 = await userRepository.CreateAsync(new User + { + Name = "Third Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + // Create two different and still valid (not expired or responded to) auth requests + var authRequests = new List + { + await authRequestRepository.CreateAsync(CreateAuthRequest(user1.Id, AuthRequestType.AdminApproval, DateTime.UtcNow.AddMinutes(-5))), + await authRequestRepository.CreateAsync(CreateAuthRequest(user3.Id, AuthRequestType.AdminApproval, DateTime.UtcNow.AddMinutes(-7))), + await authRequestRepository.CreateAsync(CreateAuthRequest(user2.Id, AuthRequestType.AdminApproval, DateTime.UtcNow.AddMinutes(-10))), + // This last auth request is not created manually, and will be + // used to make sure entity framework's `UpdateRange` method + // doesn't create requests too. + CreateAuthRequest(user2.Id, AuthRequestType.AdminApproval, DateTime.UtcNow.AddMinutes(-11)) + }; + + // Update some properties on two auth request, but leave the other one + // alone to be a control value + var authRequestToBeUpdated1 = authRequests[0]; + var authRequestToBeUpdated2 = authRequests[1]; + var authRequestNotToBeUpdated = authRequests[2]; + authRequests[0].Approved = true; + authRequests[0].ResponseDate = DateTime.UtcNow.AddMinutes(-1); + authRequests[0].Key = "UPDATED_KEY_1"; + authRequests[0].MasterPasswordHash = "UPDATED_MASTERPASSWORDHASH_1"; + + authRequests[1].Approved = false; + authRequests[1].ResponseDate = DateTime.UtcNow.AddMinutes(-2); + + // Run the method being tested + await authRequestRepository.UpdateManyAsync(authRequests); + + // Define what "Equality" really means in this context + // This includes stripping milliseconds off of dates, because we can't + // reliably compare that deep + static DateTime? TrimMilliseconds(DateTime? dt) + { + if (!dt.HasValue) + { + return null; + } + return new DateTime(dt.Value.Year, dt.Value.Month, dt.Value.Day, dt.Value.Hour, dt.Value.Minute, dt.Value.Second, 0, dt.Value.Kind); + } + + bool AuthRequestEquals(AuthRequest x, AuthRequest y) + { + return + x.Id == y.Id && + x.UserId == y.UserId && + x.Type == y.Type && + x.RequestDeviceIdentifier == y.RequestDeviceIdentifier && + x.RequestDeviceType == y.RequestDeviceType && + x.RequestIpAddress == y.RequestIpAddress && + x.ResponseDeviceId == y.ResponseDeviceId && + x.AccessCode == y.AccessCode && + x.PublicKey == y.PublicKey && + x.Key == y.Key && + x.MasterPasswordHash == y.MasterPasswordHash && + x.Approved == y.Approved && + TrimMilliseconds(x.CreationDate) == TrimMilliseconds(y.CreationDate) && + TrimMilliseconds(x.ResponseDate) == TrimMilliseconds(y.ResponseDate) && + TrimMilliseconds(x.AuthenticationDate) == TrimMilliseconds(y.AuthenticationDate) && + x.OrganizationId == y.OrganizationId; + } + + // Assert that the unchanged auth request is still unchanged + var skippedAuthRequest = await authRequestRepository.GetByIdAsync(authRequestNotToBeUpdated.Id); + Assert.True(AuthRequestEquals(skippedAuthRequest, authRequestNotToBeUpdated)); + + // Assert that the values updated on the changed auth requests were updated, and no others + var updatedAuthRequest1 = await authRequestRepository.GetByIdAsync(authRequestToBeUpdated1.Id); + Assert.True(AuthRequestEquals(authRequestToBeUpdated1, updatedAuthRequest1)); + var updatedAuthRequest2 = await authRequestRepository.GetByIdAsync(authRequestToBeUpdated2.Id); + Assert.True(AuthRequestEquals(authRequestToBeUpdated2, updatedAuthRequest2)); + + // Assert that the auth request we never created is not created by + // the update method. + var uncreatedAuthRequest = await authRequestRepository.GetByIdAsync(authRequests[3].Id); + Assert.Null(uncreatedAuthRequest); + } + private static AuthRequest CreateAuthRequest(Guid userId, AuthRequestType authRequestType, DateTime creationDate, bool? approved = null, DateTime? responseDate = null) { return new AuthRequest diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionRepositoryTests.cs index dc420c8764..9b7e8f5196 100644 --- a/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionRepositoryTests.cs @@ -310,6 +310,7 @@ public class CollectionRepositoryTests Assert.True(c1.Manage); Assert.False(c1.ReadOnly); Assert.False(c1.HidePasswords); + Assert.False(c1.Unmanaged); }, c2 => { Assert.NotNull(c2); @@ -319,6 +320,7 @@ public class CollectionRepositoryTests Assert.False(c2.Manage); Assert.True(c2.ReadOnly); Assert.False(c2.HidePasswords); + Assert.True(c2.Unmanaged); }, c3 => { Assert.NotNull(c3); @@ -328,6 +330,7 @@ public class CollectionRepositoryTests Assert.False(c3.Manage); Assert.False(c3.ReadOnly); Assert.False(c3.HidePasswords); + Assert.False(c3.Unmanaged); }); } @@ -436,6 +439,7 @@ public class CollectionRepositoryTests Assert.True(c1.Manage); Assert.False(c1.ReadOnly); Assert.False(c1.HidePasswords); + Assert.False(c1.Unmanaged); }, c2 => { Assert.NotNull(c2); @@ -445,6 +449,7 @@ public class CollectionRepositoryTests Assert.False(c2.Manage); Assert.True(c2.ReadOnly); Assert.False(c2.HidePasswords); + Assert.True(c2.Unmanaged); }, c3 => { Assert.NotNull(c3); @@ -454,6 +459,7 @@ public class CollectionRepositoryTests Assert.True(c3.Manage); // Group 2 is Manage Assert.False(c3.ReadOnly); Assert.False(c3.HidePasswords); + Assert.False(c3.Unmanaged); }); } } diff --git a/util/Migrator/DbScripts/2024-05-05_00_UpdateManyAuthRequests.sql b/util/Migrator/DbScripts/2024-05-05_00_UpdateManyAuthRequests.sql new file mode 100644 index 0000000000..227abbb3e1 --- /dev/null +++ b/util/Migrator/DbScripts/2024-05-05_00_UpdateManyAuthRequests.sql @@ -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 diff --git a/util/Migrator/DbScripts/2024-05-17_00_CollectionWithPermissionsAndUnmanagedQueries.sql b/util/Migrator/DbScripts/2024-05-17_00_CollectionWithPermissionsAndUnmanagedQueries.sql new file mode 100644 index 0000000000..0e1526ca2a --- /dev/null +++ b/util/Migrator/DbScripts/2024-05-17_00_CollectionWithPermissionsAndUnmanagedQueries.sql @@ -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 diff --git a/util/Migrator/DbScripts/2024-05-20_00_FixManageAggregation.sql b/util/Migrator/DbScripts/2024-05-20_00_FixManageAggregation.sql new file mode 100644 index 0000000000..d4bd662787 --- /dev/null +++ b/util/Migrator/DbScripts/2024-05-20_00_FixManageAggregation.sql @@ -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; diff --git a/util/Migrator/DbScripts/2024-05-22_00_EnableAllOrgCollectionEnhancements_Rerun.sql b/util/Migrator/DbScripts/2024-05-22_00_EnableAllOrgCollectionEnhancements_Rerun.sql new file mode 100644 index 0000000000..208d8fcb16 --- /dev/null +++ b/util/Migrator/DbScripts/2024-05-22_00_EnableAllOrgCollectionEnhancements_Rerun.sql @@ -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;