From c55ccad25d847ca6cf1c730b31cd41f342df5a3f Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Fri, 31 Jan 2025 16:02:06 -0600 Subject: [PATCH 01/19] Adding InviteOrganizationUserCommand initial models --- .../Models/Business/OrganizationDto.cs | 5 ++ .../InviteOrganizationUsersCommand.cs | 58 +++++++++++++++++++ .../Utilities/StrictEmailAddressAttribute.cs | 36 +----------- src/Core/Utilities/StringExtension.cs | 44 ++++++++++++++ .../InviteOrganizationUserRequestTests.cs | 52 +++++++++++++++++ 5 files changed, 161 insertions(+), 34 deletions(-) create mode 100644 src/Core/AdminConsole/Models/Business/OrganizationDto.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Organizations/InviteOrganizationUsersCommand.cs create mode 100644 src/Core/Utilities/StringExtension.cs create mode 100644 test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUserRequestTests.cs diff --git a/src/Core/AdminConsole/Models/Business/OrganizationDto.cs b/src/Core/AdminConsole/Models/Business/OrganizationDto.cs new file mode 100644 index 000000000000..a5be8a0603de --- /dev/null +++ b/src/Core/AdminConsole/Models/Business/OrganizationDto.cs @@ -0,0 +1,5 @@ +namespace Bit.Core.AdminConsole.Models.Business; + +public record OrganizationDto( + Guid OrganizationId +); diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/InviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/InviteOrganizationUsersCommand.cs new file mode 100644 index 000000000000..50b3e123819f --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/InviteOrganizationUsersCommand.cs @@ -0,0 +1,58 @@ +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; + +public interface IInviteOrganizationUsersCommand +{ + Task InviteOrganizationUserAsync(InviteOrganizationUserRequest request); + Task InviteOrganizationUsersAsync(InviteOrganizationUsersRequest request); +} + +public class InviteOrganizationUsersCommand : IInviteOrganizationUsersCommand +{ + public Task InviteOrganizationUserAsync(InviteOrganizationUserRequest request) => throw new NotImplementedException(); + + public Task InviteOrganizationUsersAsync(InviteOrganizationUsersRequest request) => throw new NotImplementedException(); +} + +public record InviteOrganizationUsersRequest(); + +public record InviteOrganizationUserRequest( + OrganizationUserSingleInvite Invite, + OrganizationDto Organization, + Guid performedBy, + DateTimeOffset performedAt); + +public class OrganizationUserSingleInvite +{ + public const string InvalidEmailErrorMessage = "The email address is not valid."; + public const string InvalidCollecitonConfigurationErrorMessage = "The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true."; + + public string Email { get; private init; } = string.Empty; + public Guid[] AccessibleCollections { get; private init; } = []; + + public static OrganizationUserSingleInvite Create(string email, IEnumerable accessibleCollections) + { + if (!email.IsValidEmail()) + { + throw new BadRequestException(InvalidEmailErrorMessage); + } + + if (accessibleCollections?.Any(ValidateCollectionConfiguration) ?? false) + { + throw new BadRequestException(InvalidCollecitonConfigurationErrorMessage); + } + + return new OrganizationUserSingleInvite + { + Email = email, + AccessibleCollections = accessibleCollections.Select(x => x.Id).ToArray() + }; + } + + private static Func ValidateCollectionConfiguration => collectionAccessSelection => + collectionAccessSelection.Manage && (collectionAccessSelection.ReadOnly || collectionAccessSelection.HidePasswords); +} diff --git a/src/Core/Utilities/StrictEmailAddressAttribute.cs b/src/Core/Utilities/StrictEmailAddressAttribute.cs index eeb95093d05a..cf95dd32d934 100644 --- a/src/Core/Utilities/StrictEmailAddressAttribute.cs +++ b/src/Core/Utilities/StrictEmailAddressAttribute.cs @@ -1,6 +1,4 @@ using System.ComponentModel.DataAnnotations; -using System.Text.RegularExpressions; -using MimeKit; namespace Bit.Core.Utilities; @@ -13,38 +11,8 @@ public StrictEmailAddressAttribute() public override bool IsValid(object value) { var emailAddress = value?.ToString(); - if (emailAddress == null) - { - return false; - } - try - { - var parsedEmailAddress = MailboxAddress.Parse(emailAddress).Address; - if (parsedEmailAddress != emailAddress) - { - return false; - } - } - catch (ParseException) - { - return false; - } - - // The regex below is intended to catch edge cases that are not handled by the general parsing check above. - // This enforces the following rules: - // * Requires ASCII only in the local-part (code points 0-127) - // * Requires an @ symbol - // * Allows any char in second-level domain name, including unicode and symbols - // * Requires at least one period (.) separating SLD from TLD - // * Must end in a letter (including unicode) - // See the unit tests for examples of what is allowed. - var emailFormat = @"^[\x00-\x7F]+@.+\.\p{L}+$"; - if (!Regex.IsMatch(emailAddress, emailFormat)) - { - return false; - } - - return new EmailAddressAttribute().IsValid(emailAddress); + return emailAddress.IsValidEmail() && + new EmailAddressAttribute().IsValid(emailAddress); } } diff --git a/src/Core/Utilities/StringExtension.cs b/src/Core/Utilities/StringExtension.cs new file mode 100644 index 000000000000..f1e4d4f99f83 --- /dev/null +++ b/src/Core/Utilities/StringExtension.cs @@ -0,0 +1,44 @@ +using System.Text.RegularExpressions; +using MimeKit; + +namespace Bit.Core.Utilities; + +public static class StringExtension +{ + public static bool IsValidEmail(this string emailAddress) + { + if (string.IsNullOrWhiteSpace(emailAddress)) + { + return false; + } + + try + { + var parsedEmailAddress = MailboxAddress.Parse(emailAddress).Address; + if (parsedEmailAddress != emailAddress) + { + return false; + } + } + catch (ParseException) + { + return false; + } + + // The regex below is intended to catch edge cases that are not handled by the general parsing check above. + // This enforces the following rules: + // * Requires ASCII only in the local-part (code points 0-127) + // * Requires an @ symbol + // * Allows any char in second-level domain name, including unicode and symbols + // * Requires at least one period (.) separating SLD from TLD + // * Must end in a letter (including unicode) + // See the unit tests for examples of what is allowed. + var emailFormat = @"^[\x00-\x7F]+@.+\.\p{L}+$"; + if (!Regex.IsMatch(emailAddress, emailFormat)) + { + return false; + } + + return true; + } +} diff --git a/test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUserRequestTests.cs b/test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUserRequestTests.cs new file mode 100644 index 000000000000..606574060807 --- /dev/null +++ b/test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUserRequestTests.cs @@ -0,0 +1,52 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.Models.Business; + +public class InviteOrganizationUserRequestTests +{ + [Theory] + [BitAutoData] + public void Create_WhenPassedInvalidEmail_ThrowsException(string email) + { + var action = () => OrganizationUserSingleInvite.Create(email, []); + + var exception = Assert.Throws(action); + + Assert.Equal(OrganizationUserSingleInvite.InvalidEmailErrorMessage, exception.Message); + } + + [Fact] + public void Create_WhenPassedInvalidCollectionAccessConfiguration_ThrowsException() + { + var validEmail = "test@email.com"; + + var invalidCollectionConfiguration = new CollectionAccessSelection + { + Manage = true, + HidePasswords = true + }; + + var action = () => OrganizationUserSingleInvite.Create(validEmail, [invalidCollectionConfiguration]); + + var exception = Assert.Throws(action); + + Assert.Equal(OrganizationUserSingleInvite.InvalidCollecitonConfigurationErrorMessage, exception.Message); + } + + [Fact] + public void Create_WhenPassedValidArguments_ReturnsInvite() + { + const string validEmail = "test@email.com"; + var validCollectionConfiguration = new CollectionAccessSelection { Id = Guid.NewGuid(), Manage = true }; + + var invite = OrganizationUserSingleInvite.Create(validEmail, [validCollectionConfiguration]); + + Assert.NotNull(invite); + Assert.Equal(validEmail, invite.Email); + Assert.Contains(validCollectionConfiguration.Id, invite.AccessibleCollections); + } +} From 81ee2d0e5deda3fb6f638f1be372575644a18108 Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Thu, 6 Feb 2025 11:59:07 -0600 Subject: [PATCH 02/19] Validation and tests for inviting users and checking results against current settings. --- .../PasswordManagerInviteUserValidation.cs | 26 ++++++++ .../PasswordManagerSubscriptionUpdate.cs | 36 +++++++++++ ...asswordManagerInviteUserValidationTests.cs | 63 +++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerInviteUserValidation.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerSubscriptionUpdate.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerInviteUserValidationTests.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerInviteUserValidation.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerInviteUserValidation.cs new file mode 100644 index 000000000000..6b497556a2d9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerInviteUserValidation.cs @@ -0,0 +1,26 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; + +public static class PasswordManagerInviteUserValidation +{ + public static ValidationResult Validate(PasswordManagerSubscriptionUpdate subscriptionUpdate) + { + if (subscriptionUpdate.Seats is null) + { + return new Valid(subscriptionUpdate); + } + + if (subscriptionUpdate.AdditionalSeats == 0) + { + return new Valid(subscriptionUpdate); + } + + if (subscriptionUpdate.UpdatedSeatTotal is not null && subscriptionUpdate.MaxAutoScaleSeats is not null && + subscriptionUpdate.UpdatedSeatTotal > subscriptionUpdate.MaxAutoScaleSeats) + { + return new Invalid(InviteUserValidationErrorMessages + .SeatLimitHasBeenReachedError); + } + + return new Valid(subscriptionUpdate); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerSubscriptionUpdate.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerSubscriptionUpdate.cs new file mode 100644 index 000000000000..c481547b43a9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerSubscriptionUpdate.cs @@ -0,0 +1,36 @@ +using Bit.Core.AdminConsole.Models.Business; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; + +public record PasswordManagerSubscriptionUpdate +{ + /// + /// Seats the organization has + /// + public int? Seats { get; private init; } + + public int? MaxAutoScaleSeats { get; private init; } + + public int OccupiedSeats { get; private init; } + + public int AdditionalSeats { get; private init; } + + public int? AvailableSeats => Seats - OccupiedSeats; + + public int SeatsRequiredToAdd => AdditionalSeats - AvailableSeats ?? 0; + + public int? UpdatedSeatTotal => Seats + SeatsRequiredToAdd; + + private PasswordManagerSubscriptionUpdate(int? organizationSeats, int? organizationAutoScaleSeatLimit, int currentSeats, int seatsToAdd) + { + Seats = organizationSeats; + MaxAutoScaleSeats = organizationAutoScaleSeatLimit; + OccupiedSeats = currentSeats; + AdditionalSeats = seatsToAdd; + } + + public static PasswordManagerSubscriptionUpdate Create(OrganizationDto organizationDto, int occupiedSeats, int seatsToAdd) + { + return new PasswordManagerSubscriptionUpdate(organizationDto.Seats, organizationDto.MaxAutoScaleSeats, occupiedSeats, seatsToAdd); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerInviteUserValidationTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerInviteUserValidationTests.cs new file mode 100644 index 000000000000..03469465dbba --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerInviteUserValidationTests.cs @@ -0,0 +1,63 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; + +public class PasswordManagerInviteUserValidationTests +{ + + [Theory] + [BitAutoData] + public void Validate_OrganizationDoesNotHaveSeatsLimit_ShouldReturnValidResult(Organization organization) + { + organization.Seats = null; + + var organizationDto = OrganizationDto.FromOrganization(organization); + + var subscriptionUpdate = PasswordManagerSubscriptionUpdate.Create(organizationDto, 0, 0); + + var result = PasswordManagerInviteUserValidation.Validate(subscriptionUpdate); + + Assert.IsType>(result); + } + + [Theory] + [BitAutoData] + public void Validate_NumberOfSeatsToAddMatchesSeatsAvailable_ShouldReturnValidResult(Organization organization) + { + organization.Seats = 8; + var seatsOccupiedByUsers = 4; + var additionalSeats = 4; + + var organizationDto = OrganizationDto.FromOrganization(organization); + + var subscriptionUpdate = PasswordManagerSubscriptionUpdate.Create(organizationDto, seatsOccupiedByUsers, additionalSeats); + + var result = PasswordManagerInviteUserValidation.Validate(subscriptionUpdate); + + Assert.IsType>(result); + } + + [Theory] + [BitAutoData] + public void Validate_NumberOfSeatsToAddIsGreaterThanMaxSeatsAllowed_ShouldBeInvalidWithSeatLimitMessage(Organization organization) + { + organization.Seats = 4; + organization.MaxAutoscaleSeats = 4; + var seatsOccupiedByUsers = 4; + var additionalSeats = 1; + + var organizationDto = OrganizationDto.FromOrganization(organization); + + var subscriptionUpdate = PasswordManagerSubscriptionUpdate.Create(organizationDto, seatsOccupiedByUsers, additionalSeats); + + var result = PasswordManagerInviteUserValidation.Validate(subscriptionUpdate); + + Assert.IsType>(result); + Assert.Equal(InviteUserValidationErrorMessages.SeatLimitHasBeenReachedError, result.ErrorMessageString); + } + +} From d0d8047db451e789fadef2ee6f42589a9bce708b Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Thu, 6 Feb 2025 11:59:51 -0600 Subject: [PATCH 03/19] Error messages --- .../Validation/InviteUserValidationErrorMessages.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserValidationErrorMessages.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserValidationErrorMessages.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserValidationErrorMessages.cs new file mode 100644 index 000000000000..0f606f45179f --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserValidationErrorMessages.cs @@ -0,0 +1,13 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; + +public static class InviteUserValidationErrorMessages +{ + // + public const string CannotAutoScaleOnSelfHostedError = "Cannot autoscale on self-hosted instance."; + public const string SeatLimitHasBeenReachedError = "Seat limit has been reached."; + public const string ProviderBillableSeatLimitError = "Seat limit has been reached. Please contact your provider to add more seats."; + public const string ProviderResellerSeatLimitError = "Seat limit has been reached. Contact your provider to purchase additional seats."; + public const string CancelledSubscriptionError = "Cannot autoscale with a canceled subscription."; + public const string NoPaymentMethodFoundError = "No payment method found."; + public const string NoSubscriptionFoundError = "No subscription found."; +} From f19595e312a284538d33b93e2fc8cd11f32413b5 Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Thu, 6 Feb 2025 12:01:12 -0600 Subject: [PATCH 04/19] ValidationResult records --- .../Validation/ValidationResult.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/ValidationResult.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/ValidationResult.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/ValidationResult.cs new file mode 100644 index 000000000000..d5759014d841 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/ValidationResult.cs @@ -0,0 +1,15 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; + +public abstract record ValidationResult(T Value, IEnumerable Errors) +{ + public bool IsValid => !Errors.Any(); + + public string ErrorMessageString => string.Join(" ", Errors); +} + +public record Valid(T Value) : ValidationResult(Value, []); + +public record Invalid(IEnumerable Errors) : ValidationResult(default, Errors) +{ + public Invalid(string error) : this([error]) { } +} From 37884ec7e2a69cf1162aec57daec0430226e530b Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Thu, 6 Feb 2025 12:02:07 -0600 Subject: [PATCH 05/19] Spacing --- .../Validation/PasswordManagerInviteUserValidationTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerInviteUserValidationTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerInviteUserValidationTests.cs index 03469465dbba..a195a8b5ac98 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerInviteUserValidationTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerInviteUserValidationTests.cs @@ -8,7 +8,6 @@ namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.Vali public class PasswordManagerInviteUserValidationTests { - [Theory] [BitAutoData] public void Validate_OrganizationDoesNotHaveSeatsLimit_ShouldReturnValidResult(Organization organization) From 57d6ab2e1d593446d7633c39f40586033ac17313 Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Thu, 6 Feb 2025 16:03:59 -0600 Subject: [PATCH 06/19] Organization validation --- .../Models/Business/OrganizationDto.cs | 56 ++++++++++++++++++- ...itingUserOrganizationProviderValidation.cs | 39 +++++++++++++ .../InvitingUserOrganizationValidation.cs | 27 +++++++++ .../InviteUserOrganizationValidationTests.cs | 56 +++++++++++++++++++ 4 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InvitingUserOrganizationProviderValidation.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InvitingUserOrganizationValidation.cs create mode 100644 test/Core.Test/AdminConsole/Models/Business/InviteUserOrganizationValidationTests.cs diff --git a/src/Core/AdminConsole/Models/Business/OrganizationDto.cs b/src/Core/AdminConsole/Models/Business/OrganizationDto.cs index a5be8a0603de..eed8d69fdc5f 100644 --- a/src/Core/AdminConsole/Models/Business/OrganizationDto.cs +++ b/src/Core/AdminConsole/Models/Business/OrganizationDto.cs @@ -1,5 +1,55 @@ -namespace Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.StaticStore; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.Models.Business; public record OrganizationDto( - Guid OrganizationId -); + Guid OrganizationId, + bool UseCustomPermissions, + int? Seats, + int? MaxAutoScaleSeats, + int? SmSeats, + int? SmMaxAutoScaleSeats, + Plan Plan, + string GatewayCustomerId, + string GatewaySubscriptionId +) : ISubscriber +{ + public Guid Id => OrganizationId; + public GatewayType? Gateway { get; set; } + public string GatewayCustomerId { get; set; } = GatewayCustomerId; + public string GatewaySubscriptionId { get; set; } = GatewaySubscriptionId; + public string BillingEmailAddress() => throw new NotImplementedException(); + + public string BillingName() => throw new NotImplementedException(); + + public string SubscriberName() => throw new NotImplementedException(); + + public string BraintreeCustomerIdPrefix() => throw new NotImplementedException(); + + public string BraintreeIdField() => throw new NotImplementedException(); + + public string BraintreeCloudRegionField() => throw new NotImplementedException(); + + public bool IsOrganization() => throw new NotImplementedException(); + + public bool IsUser() => throw new NotImplementedException(); + + public string SubscriberType() => throw new NotImplementedException(); + + public bool IsExpired() => throw new NotImplementedException(); + + public static OrganizationDto FromOrganization(Organization organization) => + new(organization.Id, + organization.UseCustomPermissions, + organization.Seats, + organization.MaxAutoscaleSeats, + organization.SmSeats, + organization.MaxAutoscaleSmSeats, + StaticStore.GetPlan(organization.PlanType), + organization.GatewayCustomerId, + organization.GatewaySubscriptionId); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InvitingUserOrganizationProviderValidation.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InvitingUserOrganizationProviderValidation.cs new file mode 100644 index 000000000000..884e39ed9515 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InvitingUserOrganizationProviderValidation.cs @@ -0,0 +1,39 @@ +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.Billing.Extensions; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; + +public static class InvitingUserOrganizationProviderValidation +{ + public static ValidationResult Validate(ProviderDto provider) + { + if (provider is { Enabled: true }) + { + if (provider.IsBillable()) + { + return new Invalid(InviteUserValidationErrorMessages.ProviderBillableSeatLimitError); + } + + if (provider.Type == ProviderType.Reseller) + { + return new Invalid(InviteUserValidationErrorMessages.ProviderResellerSeatLimitError); + } + } + + return new Valid(provider); + } +} + +public record ProviderDto +{ + public Guid ProviderId { get; init; } + public ProviderType Type { get; init; } + public ProviderStatusType Status { get; init; } + public bool Enabled { get; init; } + + public static ProviderDto FromProviderEntity(Provider provider) + { + return new ProviderDto { ProviderId = provider.Id, Type = provider.Type, Status = provider.Status, Enabled = provider.Enabled }; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InvitingUserOrganizationValidation.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InvitingUserOrganizationValidation.cs new file mode 100644 index 000000000000..754a5906f7f9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InvitingUserOrganizationValidation.cs @@ -0,0 +1,27 @@ +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.Billing.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; + +public static class InvitingUserOrganizationValidation +{ + public static ValidationResult Validate(OrganizationDto organization) + { + if (organization.Plan is { ProductTier: ProductTierType.Free }) + { + return new Valid(organization); + } + + if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) + { + return new Invalid(InviteUserValidationErrorMessages.NoPaymentMethodFoundError); + } + + if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) + { + return new Invalid(InviteUserValidationErrorMessages.NoSubscriptionFoundError); + } + + return new Valid(organization); + } +} diff --git a/test/Core.Test/AdminConsole/Models/Business/InviteUserOrganizationValidationTests.cs b/test/Core.Test/AdminConsole/Models/Business/InviteUserOrganizationValidationTests.cs new file mode 100644 index 000000000000..319f5d2a1633 --- /dev/null +++ b/test/Core.Test/AdminConsole/Models/Business/InviteUserOrganizationValidationTests.cs @@ -0,0 +1,56 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; +using Bit.Core.Billing.Enums; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.Models.Business; + +public class InviteUserOrganizationValidationTests +{ + + [Theory] + [BitAutoData] + public void Validate_WhenOrganizationIsFreeTier_ShouldReturnValidResponse(Organization organization) + { + organization.PlanType = PlanType.Free; + var organizationDto = OrganizationDto.FromOrganization(organization); + + var result = InvitingUserOrganizationValidation.Validate(organizationDto); + + Assert.IsType>(result); + } + + [Theory] + [BitAutoData] + public void Validate_WhenOrganizationDoesNotHavePaymentMethod_ShouldReturnInvalidResponseWithPaymentMethodMessage( + Organization organization) + { + organization.PlanType = PlanType.EnterpriseMonthly; + organization.GatewayCustomerId = string.Empty; + + var organizationDto = OrganizationDto.FromOrganization(organization); + + var result = InvitingUserOrganizationValidation.Validate(organizationDto); + + Assert.IsType>(result); + Assert.Equal(InviteUserValidationErrorMessages.NoPaymentMethodFoundError, result.ErrorMessageString); + } + + [Theory] + [BitAutoData] + public void Validate_WhenOrganizationDoesNotHaveSubscription_ShouldReturnInvalidResponseWithSubscriptionMessage( + Organization organization) + { + organization.PlanType = PlanType.EnterpriseMonthly; + organization.GatewaySubscriptionId = string.Empty; + + var organizationDto = OrganizationDto.FromOrganization(organization); + + var result = InvitingUserOrganizationValidation.Validate(organizationDto); + + Assert.IsType>(result); + Assert.Equal(InviteUserValidationErrorMessages.NoSubscriptionFoundError, result.ErrorMessageString); + } +} From 1bbe4c52b4b2454bbecf55d763c952f3310588f1 Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Fri, 7 Feb 2025 14:18:22 -0600 Subject: [PATCH 07/19] Added payment validation to organization validation with tests --- .../Validation/InviteUserPaymentValidation.cs | 29 ++++++++ .../InvitingUserOrganizationValidation.cs | 19 ++++- .../Billing/Extensions/BillingExtensions.cs | 8 ++ .../InviteOrganizationUserRequestTests.cs | 35 +++++---- .../InviteOrganizationUsersRequestTests.cs | 53 +++++++++++++ .../InviteUserOrganizationValidationTests.cs | 56 -------------- .../InviteUserOrganizationValidationTests.cs | 74 +++++++++++++++++++ .../InviteUserPaymentValidationTests.cs | 32 ++++++++ 8 files changed, 234 insertions(+), 72 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserPaymentValidation.cs create mode 100644 test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUsersRequestTests.cs delete mode 100644 test/Core.Test/AdminConsole/Models/Business/InviteUserOrganizationValidationTests.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserOrganizationValidationTests.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserPaymentValidationTests.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserPaymentValidation.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserPaymentValidation.cs new file mode 100644 index 000000000000..8c37276ec444 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserPaymentValidation.cs @@ -0,0 +1,29 @@ +using Bit.Core.Billing.Constants; +using Bit.Core.Models.Business; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; + +public static class InviteUserPaymentValidation +{ + public static ValidationResult Validate(PaymentSubscriptionDto subscription) + { + if (subscription.SubscriptionStatus == StripeConstants.SubscriptionStatus.Canceled) + { + return new Invalid(InviteUserValidationErrorMessages.CancelledSubscriptionError); + } + + return new Valid(subscription); + } +} + +public record PaymentSubscriptionDto +{ + public string SubscriptionStatus { get; init; } + + public static PaymentSubscriptionDto FromSubscriptionInfo(SubscriptionInfo subscriptionInfo) => + new() + { + SubscriptionStatus = subscriptionInfo?.Subscription?.Status ?? string.Empty + }; +} + diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InvitingUserOrganizationValidation.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InvitingUserOrganizationValidation.cs index 754a5906f7f9..13a09330ec98 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InvitingUserOrganizationValidation.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InvitingUserOrganizationValidation.cs @@ -1,12 +1,15 @@ using Bit.Core.AdminConsole.Models.Business; using Bit.Core.Billing.Enums; +using Bit.Core.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; public static class InvitingUserOrganizationValidation { - public static ValidationResult Validate(OrganizationDto organization) + public static async Task> Validate(OrganizationValidationDto organizationDto) { + var (organization, paymentService) = (organizationDto.Organization, organizationDto.PaymentService); + if (organization.Plan is { ProductTier: ProductTierType.Free }) { return new Valid(organization); @@ -22,6 +25,20 @@ public static ValidationResult Validate(OrganizationDto organiz return new Invalid(InviteUserValidationErrorMessages.NoSubscriptionFoundError); } + var paymentSubscription = await paymentService.GetSubscriptionAsync(organization); + + if (InviteUserPaymentValidation.Validate(PaymentSubscriptionDto.FromSubscriptionInfo(paymentSubscription)) is + Invalid invalidPaymentValidation) + { + return new Invalid(invalidPaymentValidation.ErrorMessageString); + } + return new Valid(organization); } } + +public class OrganizationValidationDto +{ + public OrganizationDto Organization { get; init; } + public IPaymentService PaymentService { get; init; } +} diff --git a/src/Core/Billing/Extensions/BillingExtensions.cs b/src/Core/Billing/Extensions/BillingExtensions.cs index 39b92e95a248..f0c2a6a1d5ca 100644 --- a/src/Core/Billing/Extensions/BillingExtensions.cs +++ b/src/Core/Billing/Extensions/BillingExtensions.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; @@ -17,6 +18,13 @@ provider is Status: ProviderStatusType.Billable }; + public static bool IsBillable(this ProviderDto provider) => + provider is + { + Type: ProviderType.Msp or ProviderType.MultiOrganizationEnterprise, + Status: ProviderStatusType.Billable + }; + public static bool SupportsConsolidatedBilling(this ProviderType providerType) => providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise; diff --git a/test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUserRequestTests.cs b/test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUserRequestTests.cs index 606574060807..a211c87a3dae 100644 --- a/test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUserRequestTests.cs +++ b/test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUserRequestTests.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; using Bit.Test.Common.AutoFixture.Attributes; @@ -10,40 +11,44 @@ public class InviteOrganizationUserRequestTests { [Theory] [BitAutoData] - public void Create_WhenPassedInvalidEmail_ThrowsException(string email) + public void Create_WhenPassedInvalidEmail_ThrowsException(string email, string externalId, + OrganizationUserType type, Permissions permissions) { - var action = () => OrganizationUserSingleInvite.Create(email, []); + var action = () => OrganizationUserSingleEmailInvite.Create(email, [], externalId, type, permissions); var exception = Assert.Throws(action); - Assert.Equal(OrganizationUserSingleInvite.InvalidEmailErrorMessage, exception.Message); + Assert.Equal(OrganizationUserSingleEmailInvite.InvalidEmailErrorMessage, exception.Message); } - [Fact] - public void Create_WhenPassedInvalidCollectionAccessConfiguration_ThrowsException() + [Theory] + [BitAutoData] + public void Create_WhenPassedInvalidCollectionAccessConfiguration_ThrowsException(string externalId, + OrganizationUserType type, Permissions permissions) { var validEmail = "test@email.com"; - var invalidCollectionConfiguration = new CollectionAccessSelection - { - Manage = true, - HidePasswords = true - }; + var invalidCollectionConfiguration = new CollectionAccessSelection { Manage = true, HidePasswords = true }; - var action = () => OrganizationUserSingleInvite.Create(validEmail, [invalidCollectionConfiguration]); + var action = () => + OrganizationUserSingleEmailInvite.Create(validEmail, [invalidCollectionConfiguration], externalId, type, + permissions); var exception = Assert.Throws(action); - Assert.Equal(OrganizationUserSingleInvite.InvalidCollecitonConfigurationErrorMessage, exception.Message); + Assert.Equal(OrganizationUserSingleEmailInvite.InvalidCollectionConfigurationErrorMessage, exception.Message); } - [Fact] - public void Create_WhenPassedValidArguments_ReturnsInvite() + [Theory] + [BitAutoData] + public void Create_WhenPassedValidArguments_ReturnsInvite(string externalId, OrganizationUserType type, + Permissions permissions) { const string validEmail = "test@email.com"; var validCollectionConfiguration = new CollectionAccessSelection { Id = Guid.NewGuid(), Manage = true }; - var invite = OrganizationUserSingleInvite.Create(validEmail, [validCollectionConfiguration]); + var invite = OrganizationUserSingleEmailInvite.Create(validEmail, [validCollectionConfiguration], externalId, + type, permissions); Assert.NotNull(invite); Assert.Equal(validEmail, invite.Email); diff --git a/test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUsersRequestTests.cs b/test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUsersRequestTests.cs new file mode 100644 index 000000000000..224cf029fa3a --- /dev/null +++ b/test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUsersRequestTests.cs @@ -0,0 +1,53 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.Models.Business; + +public class InviteOrganizationUsersRequestTests +{ + [Theory] + [BitAutoData] + public void Create_WhenPassedInvalidEmails_ThrowsException(string[] emails, OrganizationUserType type, Permissions permissions, string externalId) + { + var action = () => OrganizationUserInvite.Create(emails, [], type, permissions, externalId); + + var exception = Assert.Throws(action); + + Assert.Contains(OrganizationUserInvite.InvalidEmailErrorMessage, exception.Message); + } + + [Fact] + public void Create_WhenPassedInvalidCollectionAccessConfiguration_ThrowsException() + { + var validEmail = "test@email.com"; + + var invalidCollectionConfiguration = new CollectionAccessSelection + { + Manage = true, + HidePasswords = true + }; + + var action = () => OrganizationUserInvite.Create([validEmail], [invalidCollectionConfiguration], default, default, default); + + var exception = Assert.Throws(action); + + Assert.Equal(OrganizationUserSingleEmailInvite.InvalidCollectionConfigurationErrorMessage, exception.Message); + } + + [Fact] + public void Create_WhenPassedValidArguments_ReturnsInvite() + { + const string validEmail = "test@email.com"; + var validCollectionConfiguration = new CollectionAccessSelection { Id = Guid.NewGuid(), Manage = true }; + + var invite = OrganizationUserInvite.Create([validEmail], [validCollectionConfiguration], default, default, default); + + Assert.NotNull(invite); + Assert.Contains(validEmail, invite.Emails); + Assert.Contains(validCollectionConfiguration.Id, invite.AccessibleCollections); + } +} diff --git a/test/Core.Test/AdminConsole/Models/Business/InviteUserOrganizationValidationTests.cs b/test/Core.Test/AdminConsole/Models/Business/InviteUserOrganizationValidationTests.cs deleted file mode 100644 index 319f5d2a1633..000000000000 --- a/test/Core.Test/AdminConsole/Models/Business/InviteUserOrganizationValidationTests.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Models.Business; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; -using Bit.Core.Billing.Enums; -using Bit.Test.Common.AutoFixture.Attributes; -using Xunit; - -namespace Bit.Core.Test.AdminConsole.Models.Business; - -public class InviteUserOrganizationValidationTests -{ - - [Theory] - [BitAutoData] - public void Validate_WhenOrganizationIsFreeTier_ShouldReturnValidResponse(Organization organization) - { - organization.PlanType = PlanType.Free; - var organizationDto = OrganizationDto.FromOrganization(organization); - - var result = InvitingUserOrganizationValidation.Validate(organizationDto); - - Assert.IsType>(result); - } - - [Theory] - [BitAutoData] - public void Validate_WhenOrganizationDoesNotHavePaymentMethod_ShouldReturnInvalidResponseWithPaymentMethodMessage( - Organization organization) - { - organization.PlanType = PlanType.EnterpriseMonthly; - organization.GatewayCustomerId = string.Empty; - - var organizationDto = OrganizationDto.FromOrganization(organization); - - var result = InvitingUserOrganizationValidation.Validate(organizationDto); - - Assert.IsType>(result); - Assert.Equal(InviteUserValidationErrorMessages.NoPaymentMethodFoundError, result.ErrorMessageString); - } - - [Theory] - [BitAutoData] - public void Validate_WhenOrganizationDoesNotHaveSubscription_ShouldReturnInvalidResponseWithSubscriptionMessage( - Organization organization) - { - organization.PlanType = PlanType.EnterpriseMonthly; - organization.GatewaySubscriptionId = string.Empty; - - var organizationDto = OrganizationDto.FromOrganization(organization); - - var result = InvitingUserOrganizationValidation.Validate(organizationDto); - - Assert.IsType>(result); - Assert.Equal(InviteUserValidationErrorMessages.NoSubscriptionFoundError, result.ErrorMessageString); - } -} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserOrganizationValidationTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserOrganizationValidationTests.cs new file mode 100644 index 000000000000..973736d5c71e --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserOrganizationValidationTests.cs @@ -0,0 +1,74 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; +using Bit.Core.Billing.Enums; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.Models.Business; + +public class InviteUserOrganizationValidationTests +{ + + [Theory] + [BitAutoData] + public async Task Validate_WhenOrganizationIsFreeTier_ShouldReturnValidResponse(Organization organization) + { + var paymentService = Substitute.For(); + + organization.PlanType = PlanType.Free; + var organizationDto = new OrganizationValidationDto + { + Organization = OrganizationDto.FromOrganization(organization), + PaymentService = paymentService + }; + + var result = await InvitingUserOrganizationValidation.Validate(organizationDto); + + Assert.IsType>(result); + } + + [Theory] + [BitAutoData] + public async Task Validate_WhenOrganizationDoesNotHavePaymentMethod_ShouldReturnInvalidResponseWithPaymentMethodMessage( + Organization organization) + { + var paymentService = Substitute.For(); + + organization.GatewayCustomerId = string.Empty; + organization.PlanType = PlanType.EnterpriseMonthly; + var organizationDto = new OrganizationValidationDto + { + Organization = OrganizationDto.FromOrganization(organization), + PaymentService = paymentService + }; + + var result = await InvitingUserOrganizationValidation.Validate(organizationDto); + + Assert.IsType>(result); + Assert.Equal(InviteUserValidationErrorMessages.NoPaymentMethodFoundError, result.ErrorMessageString); + } + + [Theory] + [BitAutoData] + public async Task Validate_WhenOrganizationDoesNotHaveSubscription_ShouldReturnInvalidResponseWithSubscriptionMessage( + Organization organization) + { + organization.PlanType = PlanType.EnterpriseMonthly; + organization.GatewaySubscriptionId = string.Empty; + var paymentService = Substitute.For(); + + var organizationDto = new OrganizationValidationDto + { + Organization = OrganizationDto.FromOrganization(organization), + PaymentService = paymentService + }; + + var result = await InvitingUserOrganizationValidation.Validate(organizationDto); + + Assert.IsType>(result); + Assert.Equal(InviteUserValidationErrorMessages.NoSubscriptionFoundError, result.ErrorMessageString); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserPaymentValidationTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserPaymentValidationTests.cs new file mode 100644 index 000000000000..78cdaaf7a3f5 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserPaymentValidationTests.cs @@ -0,0 +1,32 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; +using Bit.Core.Billing.Constants; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; + +public class InviteUserPaymentValidationTests +{ + + [Fact] + public void Validate_WhenSubscriptionIsCanceled_ReturnsInvalidResponse() + { + var result = InviteUserPaymentValidation.Validate(new PaymentSubscriptionDto + { + SubscriptionStatus = StripeConstants.SubscriptionStatus.Canceled + }); + + Assert.IsType>(result); + Assert.Equal(InviteUserValidationErrorMessages.CancelledSubscriptionError, result.ErrorMessageString); + } + + [Fact] + public void Validate_WhenSubscriptionIsActive_ReturnsValidResponse() + { + var result = InviteUserPaymentValidation.Validate(new PaymentSubscriptionDto + { + SubscriptionStatus = StripeConstants.SubscriptionStatus.Active + }); + + Assert.IsType>(result); + } +} From 08a1e135a1645988590e61ac8b74a0d8baf5c94d Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Fri, 7 Feb 2025 15:32:17 -0600 Subject: [PATCH 08/19] Moved payment validation back to its own thing --- .../Validation/InviteUserPaymentValidation.cs | 15 +++++-- .../InviteUserOrganizationValidationTests.cs | 40 +++---------------- .../InviteUserPaymentValidationTests.cs | 26 ++++++++++-- 3 files changed, 41 insertions(+), 40 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserPaymentValidation.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserPaymentValidation.cs index 8c37276ec444..22a96c09cbb6 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserPaymentValidation.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserPaymentValidation.cs @@ -1,4 +1,6 @@ -using Bit.Core.Billing.Constants; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; using Bit.Core.Models.Business; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; @@ -7,6 +9,11 @@ public static class InviteUserPaymentValidation { public static ValidationResult Validate(PaymentSubscriptionDto subscription) { + if (subscription.ProductTierType is ProductTierType.Free) + { + return new Valid(subscription); + } + if (subscription.SubscriptionStatus == StripeConstants.SubscriptionStatus.Canceled) { return new Invalid(InviteUserValidationErrorMessages.CancelledSubscriptionError); @@ -18,12 +25,14 @@ public static ValidationResult Validate(PaymentSubscript public record PaymentSubscriptionDto { + public ProductTierType ProductTierType { get; init; } public string SubscriptionStatus { get; init; } - public static PaymentSubscriptionDto FromSubscriptionInfo(SubscriptionInfo subscriptionInfo) => + public static PaymentSubscriptionDto FromSubscriptionInfo(SubscriptionInfo subscriptionInfo, OrganizationDto organizationDto) => new() { - SubscriptionStatus = subscriptionInfo?.Subscription?.Status ?? string.Empty + SubscriptionStatus = subscriptionInfo?.Subscription?.Status ?? string.Empty, + ProductTierType = organizationDto.Plan.ProductTier }; } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserOrganizationValidationTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserOrganizationValidationTests.cs index 973736d5c71e..66410f89f9a7 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserOrganizationValidationTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserOrganizationValidationTests.cs @@ -1,10 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; -using Bit.Core.Billing.Enums; -using Bit.Core.Services; using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; using Xunit; namespace Bit.Core.Test.AdminConsole.Models.Business; @@ -14,38 +11,21 @@ public class InviteUserOrganizationValidationTests [Theory] [BitAutoData] - public async Task Validate_WhenOrganizationIsFreeTier_ShouldReturnValidResponse(Organization organization) + public void Validate_WhenOrganizationIsFreeTier_ShouldReturnValidResponse(Organization organization) { - var paymentService = Substitute.For(); - - organization.PlanType = PlanType.Free; - var organizationDto = new OrganizationValidationDto - { - Organization = OrganizationDto.FromOrganization(organization), - PaymentService = paymentService - }; - - var result = await InvitingUserOrganizationValidation.Validate(organizationDto); + var result = InvitingUserOrganizationValidation.Validate(OrganizationDto.FromOrganization(organization)); Assert.IsType>(result); } [Theory] [BitAutoData] - public async Task Validate_WhenOrganizationDoesNotHavePaymentMethod_ShouldReturnInvalidResponseWithPaymentMethodMessage( + public void Validate_WhenOrganizationDoesNotHavePaymentMethod_ShouldReturnInvalidResponseWithPaymentMethodMessage( Organization organization) { - var paymentService = Substitute.For(); - organization.GatewayCustomerId = string.Empty; - organization.PlanType = PlanType.EnterpriseMonthly; - var organizationDto = new OrganizationValidationDto - { - Organization = OrganizationDto.FromOrganization(organization), - PaymentService = paymentService - }; - var result = await InvitingUserOrganizationValidation.Validate(organizationDto); + var result = InvitingUserOrganizationValidation.Validate(OrganizationDto.FromOrganization(organization)); Assert.IsType>(result); Assert.Equal(InviteUserValidationErrorMessages.NoPaymentMethodFoundError, result.ErrorMessageString); @@ -53,20 +33,12 @@ public async Task Validate_WhenOrganizationDoesNotHavePaymentMethod_ShouldReturn [Theory] [BitAutoData] - public async Task Validate_WhenOrganizationDoesNotHaveSubscription_ShouldReturnInvalidResponseWithSubscriptionMessage( + public void Validate_WhenOrganizationDoesNotHaveSubscription_ShouldReturnInvalidResponseWithSubscriptionMessage( Organization organization) { - organization.PlanType = PlanType.EnterpriseMonthly; organization.GatewaySubscriptionId = string.Empty; - var paymentService = Substitute.For(); - - var organizationDto = new OrganizationValidationDto - { - Organization = OrganizationDto.FromOrganization(organization), - PaymentService = paymentService - }; - var result = await InvitingUserOrganizationValidation.Validate(organizationDto); + var result = InvitingUserOrganizationValidation.Validate(OrganizationDto.FromOrganization(organization)); Assert.IsType>(result); Assert.Equal(InviteUserValidationErrorMessages.NoSubscriptionFoundError, result.ErrorMessageString); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserPaymentValidationTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserPaymentValidationTests.cs index 78cdaaf7a3f5..44c55bc62fbc 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserPaymentValidationTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserPaymentValidationTests.cs @@ -1,18 +1,37 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Test.Common.AutoFixture.Attributes; using Xunit; namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; public class InviteUserPaymentValidationTests { + [Theory] + [BitAutoData] + public void Validate_WhenPlanIsFree_ReturnsValidResponse(Organization organization) + { + organization.PlanType = PlanType.Free; + + var result = InviteUserPaymentValidation.Validate(new PaymentSubscriptionDto + { + SubscriptionStatus = StripeConstants.SubscriptionStatus.Active, + ProductTierType = OrganizationDto.FromOrganization(organization).Plan.ProductTier + }); + + Assert.IsType>(result); + } [Fact] public void Validate_WhenSubscriptionIsCanceled_ReturnsInvalidResponse() { var result = InviteUserPaymentValidation.Validate(new PaymentSubscriptionDto { - SubscriptionStatus = StripeConstants.SubscriptionStatus.Canceled + SubscriptionStatus = StripeConstants.SubscriptionStatus.Canceled, + ProductTierType = ProductTierType.Enterprise }); Assert.IsType>(result); @@ -24,7 +43,8 @@ public void Validate_WhenSubscriptionIsActive_ReturnsValidResponse() { var result = InviteUserPaymentValidation.Validate(new PaymentSubscriptionDto { - SubscriptionStatus = StripeConstants.SubscriptionStatus.Active + SubscriptionStatus = StripeConstants.SubscriptionStatus.Active, + ProductTierType = ProductTierType.Enterprise }); Assert.IsType>(result); From c93ac87daafa2b168a5b80fe2055ada33e7edfcb Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Fri, 7 Feb 2025 15:34:57 -0600 Subject: [PATCH 09/19] part of removing payment validation --- .../InvitingUserOrganizationValidation.cs | 24 +------------------ 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InvitingUserOrganizationValidation.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InvitingUserOrganizationValidation.cs index 13a09330ec98..93e114125205 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InvitingUserOrganizationValidation.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InvitingUserOrganizationValidation.cs @@ -1,19 +1,11 @@ using Bit.Core.AdminConsole.Models.Business; -using Bit.Core.Billing.Enums; -using Bit.Core.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; public static class InvitingUserOrganizationValidation { - public static async Task> Validate(OrganizationValidationDto organizationDto) + public static ValidationResult Validate(OrganizationDto organization) { - var (organization, paymentService) = (organizationDto.Organization, organizationDto.PaymentService); - - if (organization.Plan is { ProductTier: ProductTierType.Free }) - { - return new Valid(organization); - } if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) { @@ -25,20 +17,6 @@ public static async Task> Validate(Organizatio return new Invalid(InviteUserValidationErrorMessages.NoSubscriptionFoundError); } - var paymentSubscription = await paymentService.GetSubscriptionAsync(organization); - - if (InviteUserPaymentValidation.Validate(PaymentSubscriptionDto.FromSubscriptionInfo(paymentSubscription)) is - Invalid invalidPaymentValidation) - { - return new Invalid(invalidPaymentValidation.ErrorMessageString); - } - return new Valid(organization); } } - -public class OrganizationValidationDto -{ - public OrganizationDto Organization { get; init; } - public IPaymentService PaymentService { get; init; } -} From 05c2625cbe097d59dd77eb8be6b70d2d746bdd13 Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Mon, 10 Feb 2025 10:46:55 -0600 Subject: [PATCH 10/19] Added command result class to return data from result. --- src/Core/Models/Commands/CommandResult.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Core/Models/Commands/CommandResult.cs b/src/Core/Models/Commands/CommandResult.cs index 9e5d91e09cbb..34bbe41b3479 100644 --- a/src/Core/Models/Commands/CommandResult.cs +++ b/src/Core/Models/Commands/CommandResult.cs @@ -10,3 +10,8 @@ public CommandResult(string error) : this([error]) { } public CommandResult() : this(Array.Empty()) { } } + +public class CommandResult(T data) : CommandResult(Array.Empty()) +{ + public T Data { get; } = data; +} From c2e0825964ae3fd0b6984197ae633cdccd79307c Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Mon, 10 Feb 2025 11:01:07 -0600 Subject: [PATCH 11/19] Updating PostUserCommand to call new invite command. --- .../src/Scim/Users/PostUserCommand.cs | 74 ++++++++++--------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/bitwarden_license/src/Scim/Users/PostUserCommand.cs b/bitwarden_license/src/Scim/Users/PostUserCommand.cs index 26ddd205120a..340c298e7218 100644 --- a/bitwarden_license/src/Scim/Users/PostUserCommand.cs +++ b/bitwarden_license/src/Scim/Users/PostUserCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +using Bit.Core; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; @@ -9,31 +12,20 @@ namespace Bit.Scim.Users; -public class PostUserCommand : IPostUserCommand +public class PostUserCommand( + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationService organizationService, + IPaymentService paymentService, + IScimContext scimContext, + IFeatureService featureService, + IInviteOrganizationUsersCommand inviteOrganizationUsersCommand, + TimeProvider timeProvider) + : 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; - } - public async Task PostUserAsync(Guid organizationId, ScimUserRequestModel model) { - var scimProvider = _scimContext.RequestScimProvider; + var scimProvider = scimContext.RequestScimProvider; var invite = model.ToOrganizationUserInvite(scimProvider); var email = invite.Emails.Single(); @@ -44,27 +36,37 @@ public async Task PostUserAsync(Guid organizationId throw new BadRequestException(); } - var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); - var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email); - if (orgUserByEmail != null) + var existingUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); + + if (existingUsers.Any(ou => ou.Email?.ToLowerInvariant() == email || ou.ExternalId == externalId)) { throw new ConflictException(); } - var orgUserByExternalId = orgUsers.FirstOrDefault(ou => ou.ExternalId == externalId); - if (orgUserByExternalId != null) + var organization = await organizationRepository.GetByIdAsync(organizationId); + + var hasStandaloneSecretsManager = await paymentService.HasSecretsManagerStandalone(organization); + invite.AccessSecretsManager = hasStandaloneSecretsManager; + + if (featureService.IsEnabled(FeatureFlagKeys.ScimCreateUserRefactor)) { - throw new ConflictException(); + var organizationUser = (await inviteOrganizationUsersCommand.InviteScimOrganizationUserAsync( + InviteScimOrganizationUserRequest.Create( + OrganizationUserSingleEmailInvite.Create( + email, + invite.Collections, + externalId, invite.Type ?? OrganizationUserType.User, + invite.Permissions), + OrganizationDto.FromOrganization(organization), + timeProvider.GetUtcNow()))).Data; + + return await organizationUserRepository.GetDetailsByIdAsync(organizationUser.Id); } - var organization = await _organizationRepository.GetByIdAsync(organizationId); - var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization); - invite.AccessSecretsManager = hasStandaloneSecretsManager; + var orgUser = await organizationService.InviteUserAsync(organizationId, null, EventSystemUser.SCIM, invite, + externalId); - var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM, - invite, externalId); - var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id); + return await organizationUserRepository.GetDetailsByIdAsync(orgUser.Id); - return orgUser; } } From 844b2aae94232b83ba914829961e1d936de50ce1 Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Mon, 10 Feb 2025 11:34:43 -0600 Subject: [PATCH 12/19] Added InviteOrganizationUsersRequest --- .../InviteOrganizationUsersRequest.cs | 50 +++++++++++++++++++ .../InviteOrganizationUsersRequestTests.cs | 4 +- 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/InviteOrganizationUsersRequest.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/InviteOrganizationUsersRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/InviteOrganizationUsersRequest.cs new file mode 100644 index 000000000000..9259b9988ea9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/InviteOrganizationUsersRequest.cs @@ -0,0 +1,50 @@ +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; + +public record InviteOrganizationUsersRequest +{ + public OrganizationUserInvite[] Invites { get; } = []; + public OrganizationDto Organization { get; } + public Guid PerformedBy { get; } + public DateTimeOffset PerformedAt { get; } + + private InviteOrganizationUsersRequest(OrganizationUserInvite[] Invites, + OrganizationDto Organization, + Guid PerformedBy, + DateTimeOffset PerformedAt) + { + this.Invites = Invites; + this.Organization = Organization; + this.PerformedBy = PerformedBy; + this.PerformedAt = PerformedAt; + } + + public static InviteOrganizationUsersRequest Create( + IEnumerable<(Bit.Core.Models.Business.OrganizationUserInvite invite, string externalId)> invites, + OrganizationDto organization, Guid performedBy, DateTimeOffset performedAt) => + new(invites.Select(inviteTuple => + OrganizationUserInvite.Create( + inviteTuple.invite.Emails.ToArray(), + inviteTuple.invite.Collections, + inviteTuple.invite.Type ?? OrganizationUserType.User, + inviteTuple.invite.Permissions, + inviteTuple.externalId)).ToArray(), + organization, + performedBy, + performedAt); + + public static InviteOrganizationUsersRequest Create(InviteOrganizationUserRequest request) => + new([OrganizationUserInvite.Create(request.Invite)], + request.Organization, + request.PerformedBy, + request.PerformedAt); + + public static InviteOrganizationUsersRequest Create(InviteScimOrganizationUserRequest request) => + new([ + OrganizationUserInvite.Create(request.Invite) + ], request.Organization, + Guid.Empty, + request.PerformedAt); +} diff --git a/test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUsersRequestTests.cs b/test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUsersRequestTests.cs index 224cf029fa3a..9d0903207761 100644 --- a/test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUsersRequestTests.cs +++ b/test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUsersRequestTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; @@ -50,4 +50,6 @@ public void Create_WhenPassedValidArguments_ReturnsInvite() Assert.Contains(validEmail, invite.Emails); Assert.Contains(validCollectionConfiguration.Id, invite.AccessibleCollections); } + + // TODO Add more tests. } From 08f8ea37b59c108856c0280f275c1fa773bed273 Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Mon, 10 Feb 2025 11:35:12 -0600 Subject: [PATCH 13/19] Added more validation method messages. --- .../Validation/InviteUserValidationErrorMessages.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserValidationErrorMessages.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserValidationErrorMessages.cs index 0f606f45179f..27912252b502 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserValidationErrorMessages.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUserValidationErrorMessages.cs @@ -10,4 +10,9 @@ public static class InviteUserValidationErrorMessages public const string CancelledSubscriptionError = "Cannot autoscale with a canceled subscription."; public const string NoPaymentMethodFoundError = "No payment method found."; public const string NoSubscriptionFoundError = "No subscription found."; + + // Secrets Manager Invite Users Error Messages + public const string OrganizationNoSecretsManager = "Organization has no access to Secrets Manager"; + public const string SecretsManagerSeatLimitReached = "Secrets Manager seat limit has been reached."; + public const string SecretsManagerCannotExceedPasswordManager = "You cannot have more Secrets Manager seats than Password Manager seats."; } From 9f13c2ae1739a1a92673b2e52436058320d7768c Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Tue, 11 Feb 2025 14:04:09 -0600 Subject: [PATCH 14/19] Added InviteOrganizationUsersAuthorizationHandler. --- .../Models/Business/OrganizationDto.cs | 6 +- ...teOrganizationUsersAuthorizationHandler.cs | 175 +++++ .../InviteOrganizationUsersRequest.cs | 5 +- ...OrganizationServiceCollectionExtensions.cs | 3 + .../InviteOrganizationUsersRequestTests.cs | 10 +- ...anizationUsersAuthorizationHandlerTests.cs | 601 ++++++++++++++++++ 6 files changed, 791 insertions(+), 9 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Authorization/InviteOrganizationUsersAuthorizationHandler.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Authorization/InviteOrganizationUsersAuthorizationHandlerTests.cs diff --git a/src/Core/AdminConsole/Models/Business/OrganizationDto.cs b/src/Core/AdminConsole/Models/Business/OrganizationDto.cs index eed8d69fdc5f..0b79d67198f1 100644 --- a/src/Core/AdminConsole/Models/Business/OrganizationDto.cs +++ b/src/Core/AdminConsole/Models/Business/OrganizationDto.cs @@ -15,7 +15,8 @@ public record OrganizationDto( int? SmMaxAutoScaleSeats, Plan Plan, string GatewayCustomerId, - string GatewaySubscriptionId + string GatewaySubscriptionId, + bool UseSecretsManager ) : ISubscriber { public Guid Id => OrganizationId; @@ -51,5 +52,6 @@ public static OrganizationDto FromOrganization(Organization organization) => organization.MaxAutoscaleSmSeats, StaticStore.GetPlan(organization.PlanType), organization.GatewayCustomerId, - organization.GatewaySubscriptionId); + organization.GatewaySubscriptionId, + organization.UseSecretsManager); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Authorization/InviteOrganizationUsersAuthorizationHandler.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Authorization/InviteOrganizationUsersAuthorizationHandler.cs new file mode 100644 index 000000000000..a614b8f66dde --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Authorization/InviteOrganizationUsersAuthorizationHandler.cs @@ -0,0 +1,175 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; +using Bit.Core.Context; +using Bit.Core.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Infrastructure; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; + +public class InviteOrganizationUsersAuthorizationHandler(ICurrentContext currentContext) + : AuthorizationHandler +{ + public const string OwnerCanOnlyConfigureAnotherOwnersAccount = "Only an Owner can configure another Owner's account."; + public const string DoesNotHavePermissionToMangeUsers = "Your account does not have permission to manage users."; + public const string CustomUsersCannotManageAdminsOrOwners = "Custom users cannot manage Admins or Owners."; + public const string EnableCustomPermissionsOrganizationMustBeEnterprise = + "To enable custom permissions the organization must be on an Enterprise plan."; + public const string CustomUsersOnlyGrantSamePermissions = "Custom users can only grant the same custom permissions that they have."; + + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, + InviteOrganizationUserOperationRequirement requirement, + InviteOrganizationUsersRequest inviteOrganizationUsersRequest) + { + var authorized = requirement switch + { + not null when requirement.Name == nameof(InviteOrganizationUserOperations.Invite) => + await CanInviteOrganizationUsersAsync(inviteOrganizationUsersRequest, + currentContext, + context, + this), + _ => false + }; + + if (authorized) + { + context.Succeed(requirement); + } + } + + public static async Task CanInviteOrganizationUsersAsync(InviteOrganizationUsersRequest request, + ICurrentContext currentContext, + AuthorizationHandlerContext context, + IAuthorizationHandler handler) + { + if (await currentContext.OrganizationOwner(request.Organization.OrganizationId)) + { + return true; + } + + foreach (var invite in request.Invites) + { + if (invite.Type == OrganizationUserType.Owner) + { + context.Fail(new AuthorizationFailureReason(handler, OwnerCanOnlyConfigureAnotherOwnersAccount)); + return false; + } + + if (await currentContext.OrganizationAdmin(request.Organization.OrganizationId)) + { + continue; + } + + if (!await currentContext.ManageUsers(request.Organization.OrganizationId)) + { + context.Fail(new AuthorizationFailureReason(handler, DoesNotHavePermissionToMangeUsers)); + return false; + } + + if (invite.Type == OrganizationUserType.Admin) + { + context.Fail(new AuthorizationFailureReason(handler, CustomUsersCannotManageAdminsOrOwners)); + return false; + } + + if (invite.Type == OrganizationUserType.Custom) + { + if (!request.Organization.UseCustomPermissions) + { + context.Fail(new AuthorizationFailureReason(handler, EnableCustomPermissionsOrganizationMustBeEnterprise)); + return false; + } + + if (invite.Permissions is null) + { + continue; + } + + if (!await ValidateCustomPermissionsGrantAsync(invite, request.Organization.OrganizationId, currentContext)) + { + context.Fail(new AuthorizationFailureReason(handler, CustomUsersOnlyGrantSamePermissions)); + return false; + } + } + } + + return true; + } + + public static async Task ValidateCustomPermissionsGrantAsync(OrganizationUserInvite invite, Guid organizationId, ICurrentContext currentContext) + { + if (invite.Permissions.ManageUsers && !await currentContext.ManageUsers(organizationId)) + { + return false; + } + + if (invite.Permissions.AccessReports && !await currentContext.AccessReports(organizationId)) + { + return false; + } + + if (invite.Permissions.ManageGroups && !await currentContext.ManageGroups(organizationId)) + { + return false; + } + + if (invite.Permissions.ManagePolicies && !await currentContext.ManagePolicies(organizationId)) + { + return false; + } + + if (invite.Permissions.ManageScim && !await currentContext.ManageScim(organizationId)) + { + return false; + } + + if (invite.Permissions.ManageSso && !await currentContext.ManageSso(organizationId)) + { + return false; + } + + if (invite.Permissions.AccessEventLogs && !await currentContext.AccessEventLogs(organizationId)) + { + return false; + } + + if (invite.Permissions.AccessImportExport && !await currentContext.AccessImportExport(organizationId)) + { + return false; + } + + if (invite.Permissions.EditAnyCollection && !await currentContext.EditAnyCollection(organizationId)) + { + return false; + } + + if (invite.Permissions.ManageResetPassword && !await currentContext.ManageResetPassword(organizationId)) + { + return false; + } + + var org = currentContext.GetOrganization(organizationId); + if (org == null) + { + return false; + } + + if (invite.Permissions.CreateNewCollections && !org.Permissions.CreateNewCollections) + { + return false; + } + + if (invite.Permissions.DeleteAnyCollection && !org.Permissions.DeleteAnyCollection) + { + return false; + } + + return true; + } +} + +public class InviteOrganizationUserOperationRequirement : OperationAuthorizationRequirement; + +public static class InviteOrganizationUserOperations +{ + public static readonly InviteOrganizationUserOperationRequirement Invite = new() { Name = nameof(Invite) }; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/InviteOrganizationUsersRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/InviteOrganizationUsersRequest.cs index 9259b9988ea9..f53e53f9d94d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/InviteOrganizationUsersRequest.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/InviteOrganizationUsersRequest.cs @@ -10,7 +10,7 @@ public record InviteOrganizationUsersRequest public Guid PerformedBy { get; } public DateTimeOffset PerformedAt { get; } - private InviteOrganizationUsersRequest(OrganizationUserInvite[] Invites, + public InviteOrganizationUsersRequest(OrganizationUserInvite[] Invites, OrganizationDto Organization, Guid PerformedBy, DateTimeOffset PerformedAt) @@ -30,7 +30,8 @@ public static InviteOrganizationUsersRequest Create( inviteTuple.invite.Collections, inviteTuple.invite.Type ?? OrganizationUserType.User, inviteTuple.invite.Permissions, - inviteTuple.externalId)).ToArray(), + inviteTuple.externalId, + inviteTuple.invite.AccessSecretsManager)).ToArray(), organization, performedBy, performedAt); diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 9d2e6e51e648..c037b30a9749 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -161,6 +161,9 @@ private static void AddOrganizationUserCommandsQueries(this IServiceCollection s services.AddScoped(); services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); } diff --git a/test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUsersRequestTests.cs b/test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUsersRequestTests.cs index 9d0903207761..208e75cf6a79 100644 --- a/test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUsersRequestTests.cs +++ b/test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUsersRequestTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; @@ -13,7 +13,7 @@ public class InviteOrganizationUsersRequestTests [BitAutoData] public void Create_WhenPassedInvalidEmails_ThrowsException(string[] emails, OrganizationUserType type, Permissions permissions, string externalId) { - var action = () => OrganizationUserInvite.Create(emails, [], type, permissions, externalId); + var action = () => OrganizationUserInvite.Create(emails, [], type, permissions, externalId, false); var exception = Assert.Throws(action); @@ -23,7 +23,7 @@ public void Create_WhenPassedInvalidEmails_ThrowsException(string[] emails, Orga [Fact] public void Create_WhenPassedInvalidCollectionAccessConfiguration_ThrowsException() { - var validEmail = "test@email.com"; + const string validEmail = "test@email.com"; var invalidCollectionConfiguration = new CollectionAccessSelection { @@ -31,7 +31,7 @@ public void Create_WhenPassedInvalidCollectionAccessConfiguration_ThrowsExceptio HidePasswords = true }; - var action = () => OrganizationUserInvite.Create([validEmail], [invalidCollectionConfiguration], default, default, default); + var action = () => OrganizationUserInvite.Create([validEmail], [invalidCollectionConfiguration], default, default, default, false); var exception = Assert.Throws(action); @@ -44,7 +44,7 @@ public void Create_WhenPassedValidArguments_ReturnsInvite() const string validEmail = "test@email.com"; var validCollectionConfiguration = new CollectionAccessSelection { Id = Guid.NewGuid(), Manage = true }; - var invite = OrganizationUserInvite.Create([validEmail], [validCollectionConfiguration], default, default, default); + var invite = OrganizationUserInvite.Create([validEmail], [validCollectionConfiguration], default, default, default, false); Assert.NotNull(invite); Assert.Contains(validEmail, invite.Emails); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Authorization/InviteOrganizationUsersAuthorizationHandlerTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Authorization/InviteOrganizationUsersAuthorizationHandlerTests.cs new file mode 100644 index 000000000000..c3aa9c945454 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/Authorization/InviteOrganizationUsersAuthorizationHandlerTests.cs @@ -0,0 +1,601 @@ +using System.Security.Claims; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Authorization; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; + +[SutProviderCustomize] +public class InviteOrganizationUsersAuthorizationHandlerTests +{ + + [Theory] + [BitAutoData] + public async Task HandleRequirementAsync_WhenCurrentUserIsOwner_ThenWouldBeAllowedToInviteUsers(Organization organization, DateTime performedAt, Guid performedBy, SutProvider sutProvider) + { + var invite = OrganizationUserInvite.Create(["test@email.com"], [], OrganizationUserType.User, new Permissions(), + string.Empty, false); + + var request = new InviteOrganizationUsersRequest([invite], OrganizationDto.FromOrganization(organization), performedBy, performedAt); + + var context = new AuthorizationHandlerContext( + [InviteOrganizationUserOperations.Invite], + new ClaimsPrincipal(), + request + ); + + sutProvider.GetDependency().UserId.Returns(performedBy); + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasSucceeded); + } + + [Theory] + [BitAutoData] + public async Task HandleRequirementAsync_GivenCurrentUserIsNotAnOwner_WhenInvitingAnOwner_ThenNotAllowedToInvite(Organization organization, DateTime performedAt, Guid performedBy, SutProvider sutProvider) + { + var invite = OrganizationUserInvite.Create(["test@email.com"], [], OrganizationUserType.Owner, new Permissions(), + string.Empty, false); + + var request = new InviteOrganizationUsersRequest([invite], OrganizationDto.FromOrganization(organization), performedBy, performedAt); + + var context = new AuthorizationHandlerContext( + [InviteOrganizationUserOperations.Invite], + new ClaimsPrincipal(), + request + ); + + sutProvider.GetDependency().UserId.Returns(performedBy); + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(false); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasFailed); + Assert.True(context.FailureReasons.All(x => + x.Message == InviteOrganizationUsersAuthorizationHandler.OwnerCanOnlyConfigureAnotherOwnersAccount)); + } + + [Theory] + [BitAutoData] + public async Task HandleRequirementAsync_GivenCurrentUserIsAdmin_WhenInvitingNonOwner_ThenWouldBeAllowedToInviteUsers(Organization organization, DateTime performedAt, Guid performedBy, SutProvider sutProvider) + { + var invite = OrganizationUserInvite.Create(["test@email.com"], [], OrganizationUserType.Admin, new Permissions(), + string.Empty, false); + + var request = new InviteOrganizationUsersRequest([invite], OrganizationDto.FromOrganization(organization), performedBy, performedAt); + + var context = new AuthorizationHandlerContext( + [InviteOrganizationUserOperations.Invite], + new ClaimsPrincipal(), + request + ); + + sutProvider.GetDependency().UserId.Returns(performedBy); + sutProvider.GetDependency().OrganizationAdmin(organization.Id).Returns(true); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasSucceeded); + } + + [Theory] + [BitAutoData] + public async Task HandleRequirementAsync_GivenCurrentUserCannotManageUsers_ThenNotAllowedToInviteUsers(Organization organization, DateTime performedAt, Guid performedBy, SutProvider sutProvider) + { + var invite = OrganizationUserInvite.Create(["test@email.com"], [], OrganizationUserType.User, new Permissions(), + string.Empty, false); + + var request = new InviteOrganizationUsersRequest([invite], OrganizationDto.FromOrganization(organization), performedBy, performedAt); + + var context = new AuthorizationHandlerContext( + [InviteOrganizationUserOperations.Invite], + new ClaimsPrincipal(), + request + ); + + sutProvider.GetDependency().UserId.Returns(performedBy); + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(false); + sutProvider.GetDependency().OrganizationAdmin(organization.Id).Returns(false); + sutProvider.GetDependency().ManageUsers(organization.Id).Returns(false); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasFailed); + Assert.True(context.FailureReasons.All(x => + x.Message == InviteOrganizationUsersAuthorizationHandler.DoesNotHavePermissionToMangeUsers)); + } + + [Theory] + [BitAutoData] + public async Task HandleRequirementAsync_GivenCurrentUserCanManageUsers_WhenInvitingAnAdmin_ThenNotAllowedToInvite(Organization organization, DateTime performedAt, Guid performedBy, SutProvider sutProvider) + { + var invite = OrganizationUserInvite.Create(["test@email.com"], [], OrganizationUserType.Admin, new Permissions(), + string.Empty, false); + + var request = new InviteOrganizationUsersRequest([invite], OrganizationDto.FromOrganization(organization), performedBy, performedAt); + + var context = new AuthorizationHandlerContext( + [InviteOrganizationUserOperations.Invite], + new ClaimsPrincipal(), + request + ); + + sutProvider.GetDependency().UserId.Returns(performedBy); + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(false); + sutProvider.GetDependency().OrganizationAdmin(organization.Id).Returns(false); + sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasFailed); + Assert.True(context.FailureReasons.All(x => + x.Message == InviteOrganizationUsersAuthorizationHandler.CustomUsersCannotManageAdminsOrOwners)); + } + + [Theory] + [BitAutoData] + public async Task HandleRequirementAsync_GivenCurrentUserCanManageUsers_WhenOrganizationDoesNotHaveCustomPermissions_ThenNotAllowedToInvite(Organization organization, DateTime performedAt, Guid performedBy, SutProvider sutProvider) + { + var invite = OrganizationUserInvite.Create(["test@email.com"], [], OrganizationUserType.Custom, new Permissions(), + string.Empty, false); + + organization.UseCustomPermissions = false; + + var request = new InviteOrganizationUsersRequest([invite], OrganizationDto.FromOrganization(organization), performedBy, performedAt); + + var context = new AuthorizationHandlerContext( + [InviteOrganizationUserOperations.Invite], + new ClaimsPrincipal(), + request + ); + + sutProvider.GetDependency().UserId.Returns(performedBy); + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(false); + sutProvider.GetDependency().OrganizationAdmin(organization.Id).Returns(false); + sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasFailed); + Assert.True(context.FailureReasons.All(x => + x.Message == InviteOrganizationUsersAuthorizationHandler.EnableCustomPermissionsOrganizationMustBeEnterprise)); + } + + [Theory] + [BitAutoData] + public async Task HandleRequirementAsync_GivenCurrentUserCanManageUsers_WhenNoPermissionsAreProvided_ThenShouldNotBeAbleToInvite(Organization organization, DateTime performedAt, Guid performedBy, SutProvider sutProvider) + { + var invite = OrganizationUserInvite.Create(["test@email.com"], [], OrganizationUserType.Custom, null, + string.Empty, false); + + organization.UseCustomPermissions = true; + + var request = new InviteOrganizationUsersRequest([invite], OrganizationDto.FromOrganization(organization), performedBy, performedAt); + + var context = new AuthorizationHandlerContext( + [InviteOrganizationUserOperations.Invite], + new ClaimsPrincipal(), + request + ); + + sutProvider.GetDependency().UserId.Returns(performedBy); + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(false); + sutProvider.GetDependency().OrganizationAdmin(organization.Id).Returns(false); + sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasSucceeded); + } + + [Theory] + [BitAutoData] + public async Task HandleRequirementAsync_GivenUserCannotAccessReports_WhenInvitedUserCanAccessReports_ThenShouldNotBeAbleToInvite(Organization organization, DateTime performedAt, Guid performedBy, SutProvider sutProvider) + { + var permissions = new Permissions + { + AccessReports = true + }; + + var invite = OrganizationUserInvite.Create(["test@email.com"], [], OrganizationUserType.Custom, permissions, + string.Empty, false); + + organization.UseCustomPermissions = true; + + var request = new InviteOrganizationUsersRequest([invite], OrganizationDto.FromOrganization(organization), performedBy, performedAt); + + var context = new AuthorizationHandlerContext( + [InviteOrganizationUserOperations.Invite], + new ClaimsPrincipal(), + request + ); + + sutProvider.GetDependency().UserId.Returns(performedBy); + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(false); + sutProvider.GetDependency().OrganizationAdmin(organization.Id).Returns(false); + sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); + sutProvider.GetDependency().AccessReports(organization.Id).Returns(false); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasFailed); + Assert.True(context.FailureReasons.All(x => + x.Message == InviteOrganizationUsersAuthorizationHandler.CustomUsersOnlyGrantSamePermissions)); + } + + [Theory] + [BitAutoData] + public async Task HandleRequirementAsync_GivenUserCannotManageGroups_WhenInvitedUserCanManageGroups_ThenShouldNotBeAbleToInvite(Organization organization, DateTime performedAt, Guid performedBy, SutProvider sutProvider) + { + var permissions = new Permissions + { + ManageGroups = true + }; + + var invite = OrganizationUserInvite.Create(["test@email.com"], [], OrganizationUserType.Custom, permissions, + string.Empty, false); + + organization.UseCustomPermissions = true; + + var request = new InviteOrganizationUsersRequest([invite], OrganizationDto.FromOrganization(organization), performedBy, performedAt); + + var context = new AuthorizationHandlerContext( + [InviteOrganizationUserOperations.Invite], + new ClaimsPrincipal(), + request + ); + + sutProvider.GetDependency().UserId.Returns(performedBy); + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(false); + sutProvider.GetDependency().OrganizationAdmin(organization.Id).Returns(false); + sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); + sutProvider.GetDependency().ManageGroups(organization.Id).Returns(false); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasFailed); + Assert.True(context.FailureReasons.All(x => + x.Message == InviteOrganizationUsersAuthorizationHandler.CustomUsersOnlyGrantSamePermissions)); + } + + [Theory] + [BitAutoData] + public async Task HandleRequirementAsync_GivenUserCannotManagePolicies_WhenInvitedUserCanManagePolicies_ThenShouldNotBeAbleToInvite(Organization organization, DateTime performedAt, Guid performedBy, SutProvider sutProvider) + { + var permissions = new Permissions + { + ManagePolicies = true + }; + + var invite = OrganizationUserInvite.Create(["test@email.com"], [], OrganizationUserType.Custom, permissions, + string.Empty, false); + + organization.UseCustomPermissions = true; + + var request = new InviteOrganizationUsersRequest([invite], OrganizationDto.FromOrganization(organization), performedBy, performedAt); + + var context = new AuthorizationHandlerContext( + [InviteOrganizationUserOperations.Invite], + new ClaimsPrincipal(), + request + ); + + sutProvider.GetDependency().UserId.Returns(performedBy); + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(false); + sutProvider.GetDependency().OrganizationAdmin(organization.Id).Returns(false); + sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); + sutProvider.GetDependency().ManagePolicies(organization.Id).Returns(false); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasFailed); + Assert.True(context.FailureReasons.All(x => + x.Message == InviteOrganizationUsersAuthorizationHandler.CustomUsersOnlyGrantSamePermissions)); + } + + [Theory] + [BitAutoData] + public async Task HandleRequirementAsync_GivenUserCannotManageScim_WhenInvitedUserCanManageScim_ThenShouldNotBeAbleToInvite(Organization organization, DateTime performedAt, Guid performedBy, SutProvider sutProvider) + { + var permissions = new Permissions + { + ManageScim = true + }; + + var invite = OrganizationUserInvite.Create(["test@email.com"], [], OrganizationUserType.Custom, permissions, + string.Empty, false); + + organization.UseCustomPermissions = true; + + var request = new InviteOrganizationUsersRequest([invite], OrganizationDto.FromOrganization(organization), performedBy, performedAt); + + var context = new AuthorizationHandlerContext( + [InviteOrganizationUserOperations.Invite], + new ClaimsPrincipal(), + request + ); + + sutProvider.GetDependency().UserId.Returns(performedBy); + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(false); + sutProvider.GetDependency().OrganizationAdmin(organization.Id).Returns(false); + sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); + sutProvider.GetDependency().ManageScim(organization.Id).Returns(false); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasFailed); + Assert.True(context.FailureReasons.All(x => + x.Message == InviteOrganizationUsersAuthorizationHandler.CustomUsersOnlyGrantSamePermissions)); + } + + [Theory] + [BitAutoData] + public async Task HandleRequirementAsync_GivenUserCannotManageSso_WhenInvitedUserCanManageSso_ThenShouldNotBeAbleToInvite(Organization organization, DateTime performedAt, Guid performedBy, SutProvider sutProvider) + { + var permissions = new Permissions + { + ManageSso = true + }; + + var invite = OrganizationUserInvite.Create(["test@email.com"], [], OrganizationUserType.Custom, permissions, + string.Empty, false); + + organization.UseCustomPermissions = true; + + var request = new InviteOrganizationUsersRequest([invite], OrganizationDto.FromOrganization(organization), performedBy, performedAt); + + var context = new AuthorizationHandlerContext( + [InviteOrganizationUserOperations.Invite], + new ClaimsPrincipal(), + request + ); + + sutProvider.GetDependency().UserId.Returns(performedBy); + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(false); + sutProvider.GetDependency().OrganizationAdmin(organization.Id).Returns(false); + sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); + sutProvider.GetDependency().ManageSso(organization.Id).Returns(false); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasFailed); + Assert.True(context.FailureReasons.All(x => + x.Message == InviteOrganizationUsersAuthorizationHandler.CustomUsersOnlyGrantSamePermissions)); + } + [Theory] + [BitAutoData] + public async Task HandleRequirementAsync_GivenUserCannotAccessEventLogs_WhenInvitedUserCanAccessEventLogs_ThenShouldNotBeAbleToInvite(Organization organization, DateTime performedAt, Guid performedBy, SutProvider sutProvider) + { + var permissions = new Permissions + { + AccessEventLogs = true + }; + + var invite = OrganizationUserInvite.Create(["test@email.com"], [], OrganizationUserType.Custom, permissions, + string.Empty, false); + + organization.UseCustomPermissions = true; + + var request = new InviteOrganizationUsersRequest([invite], OrganizationDto.FromOrganization(organization), performedBy, performedAt); + + var context = new AuthorizationHandlerContext( + [InviteOrganizationUserOperations.Invite], + new ClaimsPrincipal(), + request + ); + + sutProvider.GetDependency().UserId.Returns(performedBy); + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(false); + sutProvider.GetDependency().OrganizationAdmin(organization.Id).Returns(false); + sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); + sutProvider.GetDependency().AccessEventLogs(organization.Id).Returns(false); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasFailed); + Assert.True(context.FailureReasons.All(x => + x.Message == InviteOrganizationUsersAuthorizationHandler.CustomUsersOnlyGrantSamePermissions)); + } + + [Theory] + [BitAutoData] + public async Task HandleRequirementAsync_GivenUserCannotAccessImportExport_WhenInvitedUserCanAccessImportExport_ThenShouldNotBeAbleToInvite(Organization organization, DateTime performedAt, Guid performedBy, SutProvider sutProvider) + { + var permissions = new Permissions + { + AccessImportExport = true + }; + + var invite = OrganizationUserInvite.Create(["test@email.com"], [], OrganizationUserType.Custom, permissions, + string.Empty, false); + + organization.UseCustomPermissions = true; + + var request = new InviteOrganizationUsersRequest([invite], OrganizationDto.FromOrganization(organization), performedBy, performedAt); + + var context = new AuthorizationHandlerContext( + [InviteOrganizationUserOperations.Invite], + new ClaimsPrincipal(), + request + ); + + sutProvider.GetDependency().UserId.Returns(performedBy); + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(false); + sutProvider.GetDependency().OrganizationAdmin(organization.Id).Returns(false); + sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); + sutProvider.GetDependency().AccessImportExport(organization.Id).Returns(false); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasFailed); + Assert.True(context.FailureReasons.All(x => + x.Message == InviteOrganizationUsersAuthorizationHandler.CustomUsersOnlyGrantSamePermissions)); + } + + [Theory] + [BitAutoData] + public async Task HandleRequirementAsync_GivenUserCannotEditAnyCollection_WhenInvitedUserCanEditAnyCollection_ThenShouldNotBeAbleToInvite(Organization organization, DateTime performedAt, Guid performedBy, SutProvider sutProvider) + { + var permissions = new Permissions + { + EditAnyCollection = true + }; + + var invite = OrganizationUserInvite.Create(["test@email.com"], [], OrganizationUserType.Custom, permissions, + string.Empty, false); + + organization.UseCustomPermissions = true; + + var request = new InviteOrganizationUsersRequest([invite], OrganizationDto.FromOrganization(organization), performedBy, performedAt); + + var context = new AuthorizationHandlerContext( + [InviteOrganizationUserOperations.Invite], + new ClaimsPrincipal(), + request + ); + + sutProvider.GetDependency().UserId.Returns(performedBy); + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(false); + sutProvider.GetDependency().OrganizationAdmin(organization.Id).Returns(false); + sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); + sutProvider.GetDependency().EditAnyCollection(organization.Id).Returns(false); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasFailed); + Assert.True(context.FailureReasons.All(x => + x.Message == InviteOrganizationUsersAuthorizationHandler.CustomUsersOnlyGrantSamePermissions)); + } + + [Theory] + [BitAutoData] + public async Task HandleRequirementAsync_GivenUserCannotManageResetPassword_WhenInvitedUserCanManageResetPassword_ThenShouldNotBeAbleToInvite(Organization organization, DateTime performedAt, Guid performedBy, SutProvider sutProvider) + { + var permissions = new Permissions + { + ManageResetPassword = true + }; + + var invite = OrganizationUserInvite.Create(["test@email.com"], [], OrganizationUserType.Custom, permissions, + string.Empty, false); + + organization.UseCustomPermissions = true; + + var request = new InviteOrganizationUsersRequest([invite], OrganizationDto.FromOrganization(organization), performedBy, performedAt); + + var context = new AuthorizationHandlerContext( + [InviteOrganizationUserOperations.Invite], + new ClaimsPrincipal(), + request + ); + + sutProvider.GetDependency().UserId.Returns(performedBy); + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(false); + sutProvider.GetDependency().OrganizationAdmin(organization.Id).Returns(false); + sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); + sutProvider.GetDependency().ManageResetPassword(organization.Id).Returns(false); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasFailed); + Assert.True(context.FailureReasons.All(x => + x.Message == InviteOrganizationUsersAuthorizationHandler.CustomUsersOnlyGrantSamePermissions)); + } + + [Theory] + [BitAutoData] + public async Task HandleRequirementAsync_GivenOrganizationPermissionCannotCreateNewCollections_WhenInvitedUserCanCreateNewCollections_ThenShouldNotBeAbleToInvite(Organization organization, DateTime performedAt, Guid performedBy, SutProvider sutProvider) + { + var permissions = new Permissions + { + CreateNewCollections = true + }; + + var invite = OrganizationUserInvite.Create(["test@email.com"], [], OrganizationUserType.Custom, permissions, + string.Empty, false); + + organization.UseCustomPermissions = true; + + var request = new InviteOrganizationUsersRequest([invite], OrganizationDto.FromOrganization(organization), performedBy, performedAt); + + var context = new AuthorizationHandlerContext( + [InviteOrganizationUserOperations.Invite], + new ClaimsPrincipal(), + request + ); + + sutProvider.GetDependency().UserId.Returns(performedBy); + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(false); + sutProvider.GetDependency().OrganizationAdmin(organization.Id).Returns(false); + sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); + + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(new CurrentContextOrganization + { + Permissions = new Permissions + { + CreateNewCollections = false + } + }); + + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasFailed); + Assert.True(context.FailureReasons.All(x => + x.Message == InviteOrganizationUsersAuthorizationHandler.CustomUsersOnlyGrantSamePermissions)); + } + + [Theory] + [BitAutoData] + public async Task HandleRequirementAsync_GivenOrganizationPermissionCannotDeleteAnyCollection_WhenInvitedUserCanDeleteAnyCollection_ThenShouldNotBeAbleToInvite(Organization organization, DateTime performedAt, Guid performedBy, SutProvider sutProvider) + { + var permissions = new Permissions + { + DeleteAnyCollection = true + }; + + var invite = OrganizationUserInvite.Create(["test@email.com"], [], OrganizationUserType.Custom, permissions, + string.Empty, false); + + organization.UseCustomPermissions = true; + + var request = new InviteOrganizationUsersRequest([invite], OrganizationDto.FromOrganization(organization), performedBy, performedAt); + + var context = new AuthorizationHandlerContext( + [InviteOrganizationUserOperations.Invite], + new ClaimsPrincipal(), + request + ); + + sutProvider.GetDependency().UserId.Returns(performedBy); + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(false); + sutProvider.GetDependency().OrganizationAdmin(organization.Id).Returns(false); + sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); + + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(new CurrentContextOrganization + { + Permissions = new Permissions + { + DeleteAnyCollection = false + } + }); + + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasFailed); + Assert.True(context.FailureReasons.All(x => + x.Message == InviteOrganizationUsersAuthorizationHandler.CustomUsersOnlyGrantSamePermissions)); + } +} From d166a78bfc3510cc41799d3deed88e5033cec6cb Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Tue, 11 Feb 2025 14:06:54 -0600 Subject: [PATCH 15/19] Adding invite user invite --- .../Requests/OrganizationUserInvite.cs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/OrganizationUserInvite.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/OrganizationUserInvite.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/OrganizationUserInvite.cs new file mode 100644 index 000000000000..1fd9ef38a471 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/OrganizationUserInvite.cs @@ -0,0 +1,64 @@ +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; + +public record OrganizationUserInvite +{ + public const string InvalidEmailErrorMessage = "is not a valid email address."; + public const string InvalidCollectionConfigurationErrorMessage = "The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true."; + + public string[] Emails { get; private init; } = []; + public Guid[] AccessibleCollections { get; private init; } = []; + public OrganizationUserType Type { get; private init; } = OrganizationUserType.User; // Need to set + + public Permissions Permissions { get; private init; } = new(); // Need to set + public string ExternalId { get; private init; } = string.Empty; + public bool AccessSecretsManager { get; private init; } + + public static OrganizationUserInvite Create(string[] emails, + IEnumerable accessibleCollections, + OrganizationUserType type, + Permissions permissions, + string externalId, + bool accessSecretsManager) + { + if (accessibleCollections?.Any(Functions.ValidateCollectionConfiguration) ?? false) + { + throw new BadRequestException(InvalidCollectionConfigurationErrorMessage); + } + + return Create(emails, accessibleCollections?.Select(x => x.Id), type, permissions, externalId, accessSecretsManager); + } + + private static OrganizationUserInvite Create(string[] emails, IEnumerable accessibleCollections, OrganizationUserType type, Permissions permissions, string externalId, bool accessSecretsManager) + { + ValidateEmailAddresses(emails); + + return new OrganizationUserInvite + { + Emails = emails, + AccessibleCollections = accessibleCollections.ToArray(), + Type = type, + Permissions = permissions, + ExternalId = externalId, + AccessSecretsManager = accessSecretsManager + }; + } + + public static OrganizationUserInvite Create(OrganizationUserSingleEmailInvite invite) => + Create([invite.Email], invite.AccessibleCollections, invite.Type, invite.Permissions, invite.ExternalId, invite.AccessSecretsManager); + + private static void ValidateEmailAddresses(string[] emails) + { + foreach (var email in emails) + { + if (!email.IsValidEmail()) + { + throw new BadRequestException($"{email} {InvalidEmailErrorMessage}"); + } + } + } +} From ddffa6225d0fcfe226f2f511a4194b8d32e3e216 Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Tue, 11 Feb 2025 14:07:31 -0600 Subject: [PATCH 16/19] Adding OrgUser Single Email invite --- .../OrganizationUserSingleEmailInvite.cs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/OrganizationUserSingleEmailInvite.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/OrganizationUserSingleEmailInvite.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/OrganizationUserSingleEmailInvite.cs new file mode 100644 index 000000000000..60ea795bdf6e --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/OrganizationUserSingleEmailInvite.cs @@ -0,0 +1,47 @@ +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; + +public class OrganizationUserSingleEmailInvite +{ + public const string InvalidEmailErrorMessage = "The email address is not valid."; + public const string InvalidCollectionConfigurationErrorMessage = "The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true."; + + public string Email { get; private init; } = string.Empty; + public Guid[] AccessibleCollections { get; private init; } = []; + public string ExternalId { get; private init; } = string.Empty; + public Permissions Permissions { get; private init; } = new(); + public OrganizationUserType Type { get; private init; } = OrganizationUserType.User; // Need to set + public bool AccessSecretsManager { get; private init; } + + public static OrganizationUserSingleEmailInvite Create(string email, + IEnumerable accessibleCollections, + string externalId, + OrganizationUserType type, + Permissions permissions, + bool accessSecretsManager) + { + if (!email.IsValidEmail()) + { + throw new BadRequestException(InvalidEmailErrorMessage); + } + + if (accessibleCollections?.Any(Functions.ValidateCollectionConfiguration) ?? false) + { + throw new BadRequestException(InvalidCollectionConfigurationErrorMessage); + } + + return new OrganizationUserSingleEmailInvite + { + Email = email, + AccessibleCollections = accessibleCollections.Select(x => x.Id).ToArray(), + ExternalId = externalId, + Type = type, + Permissions = permissions, + AccessSecretsManager = accessSecretsManager + }; + } +} From 14c8dff272552f7248d1bb26b96b75e3fe5de4ba Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Tue, 11 Feb 2025 15:01:37 -0600 Subject: [PATCH 17/19] rest of changes. --- .../src/Scim/Users/PostUserCommand.cs | 6 +- .../Public/Controllers/MembersController.cs | 34 +++- .../Request/MemberCreateRequestModel.cs | 13 +- .../InviteOrganizationUsersCommand.cs | 179 ++++++++++++++++++ .../Requests/InviteOrganizationUserRequest.cs | 26 +++ .../InviteScimOrganizationUserRequest.cs | 23 +++ .../Validation/InviteUsersValidation.cs | 150 +++++++++++++++ .../PasswordManagerInviteUserValidation.cs | 3 + .../PasswordManagerSubscriptionUpdate.cs | 6 + .../SecretsManagerInviteUserValidation.cs | 82 ++++++++ .../InviteOrganizationUsersCommand.cs | 58 ------ .../Implementations/OrganizationService.cs | 29 ++- src/Core/Constants.cs | 1 + ...OrganizationServiceCollectionExtensions.cs | 4 + .../InviteOrganizationUserRequestTests.cs | 14 +- .../Services/OrganizationServiceTests.cs | 37 ++++ 16 files changed, 595 insertions(+), 70 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteOrganizationUsersCommand.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/InviteOrganizationUserRequest.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/InviteScimOrganizationUserRequest.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUsersValidation.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/SecretsManagerInviteUserValidation.cs delete mode 100644 src/Core/AdminConsole/OrganizationFeatures/Organizations/InviteOrganizationUsersCommand.cs diff --git a/bitwarden_license/src/Scim/Users/PostUserCommand.cs b/bitwarden_license/src/Scim/Users/PostUserCommand.cs index 340c298e7218..7766b3654f20 100644 --- a/bitwarden_license/src/Scim/Users/PostUserCommand.cs +++ b/bitwarden_license/src/Scim/Users/PostUserCommand.cs @@ -1,6 +1,7 @@ using Bit.Core; using Bit.Core.AdminConsole.Models.Business; -using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations.OrganizationUsers; @@ -56,7 +57,8 @@ public async Task PostUserAsync(Guid organizationId email, invite.Collections, externalId, invite.Type ?? OrganizationUserType.User, - invite.Permissions), + invite.Permissions, + invite.AccessSecretsManager), OrganizationDto.FromOrganization(organization), timeProvider.GetUtcNow()))).Data; diff --git a/src/Api/AdminConsole/Public/Controllers/MembersController.cs b/src/Api/AdminConsole/Public/Controllers/MembersController.cs index 76bd29d38e5a..a7dec84e5c95 100644 --- a/src/Api/AdminConsole/Public/Controllers/MembersController.cs +++ b/src/Api/AdminConsole/Public/Controllers/MembersController.cs @@ -2,7 +2,11 @@ using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Api.Models.Public.Response; +using Bit.Core; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Context; @@ -29,6 +33,9 @@ public class MembersController : Controller private readonly IOrganizationRepository _organizationRepository; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; + private readonly IInviteOrganizationUsersCommand _inviteOrganizationUsersCommand; + private readonly IFeatureService _featureService; + private readonly TimeProvider _timeProvider; public MembersController( IOrganizationUserRepository organizationUserRepository, @@ -42,7 +49,10 @@ public MembersController( IPaymentService paymentService, IOrganizationRepository organizationRepository, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IRemoveOrganizationUserCommand removeOrganizationUserCommand) + IRemoveOrganizationUserCommand removeOrganizationUserCommand, + IInviteOrganizationUsersCommand inviteOrganizationUsersCommand, + IFeatureService featureService, + TimeProvider timeProvider) { _organizationUserRepository = organizationUserRepository; _groupRepository = groupRepository; @@ -56,6 +66,9 @@ public MembersController( _organizationRepository = organizationRepository; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _removeOrganizationUserCommand = removeOrganizationUserCommand; + _inviteOrganizationUsersCommand = inviteOrganizationUsersCommand; + _featureService = featureService; + _timeProvider = timeProvider; } /// @@ -151,8 +164,27 @@ public async Task Post([FromBody] MemberCreateRequestModel model) invite.AccessSecretsManager = hasStandaloneSecretsManager; + if (_featureService.IsEnabled(FeatureFlagKeys.ScimCreateUserRefactor)) + { + var invitedUserResult = await _inviteOrganizationUsersCommand.InvitePublicApiOrganizationUserAsync( + InviteOrganizationUserRequest.Create( + model.ToOrganizationUserSingleEmailInvite(hasStandaloneSecretsManager), + OrganizationDto.FromOrganization(organization), + Guid.Empty, + _timeProvider.GetUtcNow() + )); + + if (invitedUserResult is { Success: true }) + { + return new JsonResult(new MemberResponseModel(invitedUserResult.Data, invite.Collections)); + } + + return new EmptyResult(); // TODO figure out something better to put here + } + var user = await _organizationService.InviteUserAsync(_currentContext.OrganizationId.Value, null, systemUser: null, invite, model.ExternalId); + var response = new MemberResponseModel(user, invite.Collections); return new JsonResult(response); } diff --git a/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs index f6b2c4d4af72..c257d15c72ca 100644 --- a/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs @@ -1,8 +1,10 @@ using System.ComponentModel.DataAnnotations; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Models.Business; +using Bit.Core.Models.Data; using Bit.Core.Utilities; +using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite; namespace Bit.Api.AdminConsole.Public.Models.Request; @@ -40,4 +42,13 @@ public OrganizationUserInvite ToOrganizationUserInvite() return invite; } + + public OrganizationUserSingleEmailInvite ToOrganizationUserSingleEmailInvite(bool hasSecretsManager) => + OrganizationUserSingleEmailInvite.Create( + Email, + Collections.Select(c => c.ToCollectionAccessSelection()).ToArray(), + string.Empty, + Type.Value, + Type is OrganizationUserType.Custom && Permissions is not null ? Permissions.ToData() : new Permissions(), + hasSecretsManager); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteOrganizationUsersCommand.cs new file mode 100644 index 000000000000..3f31ca1d2eaf --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteOrganizationUsersCommand.cs @@ -0,0 +1,179 @@ +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Commands; +using Bit.Core.Models.Data; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Authorization; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; + +public interface IInviteOrganizationUsersCommand +{ + Task> InviteOrganizationUserAsync(InviteOrganizationUserRequest request); + Task>> InviteOrganizationUserListAsync(InviteOrganizationUsersRequest request); + Task> InviteScimOrganizationUserAsync(InviteScimOrganizationUserRequest request); + Task> InvitePublicApiOrganizationUserAsync(InviteOrganizationUserRequest request); +} + +public class InviteOrganizationUsersCommand(IEventService eventService, + IAuthorizationService authorizationService, + IOrganizationUserRepository organizationUserRepository, + ICurrentContext currentContext, + IInviteUsersValidation inviteUsersValidation + ) : IInviteOrganizationUsersCommand +{ + public async Task> InviteOrganizationUserAsync(InviteOrganizationUserRequest request) => + new((await InviteOrganizationUserListAsync(InviteOrganizationUsersRequest.Create(request))).Data.FirstOrDefault()); + + public async Task>> InviteOrganizationUserListAsync(InviteOrganizationUsersRequest request) + { + var authorized = await authorizationService.AuthorizeAsync(currentContext.HttpContext.User, request, + InviteOrganizationUserOperations.Invite); + + if (authorized.Failure is not null) + { + throw new UnauthorizedAccessException(authorized.Failure.FailureReasons.ToString()); + } + + var result = await InviteOrganizationUsersAsync(request); + + IEnumerable<(OrganizationUser, EventType, DateTime?)> log = result.Data.Select(invite => + (invite, EventType.OrganizationUser_Invited, (DateTime?)request.PerformedAt.UtcDateTime)); + + await eventService.LogOrganizationUserEventsAsync(log); + + return result; + } + + public async Task> InviteScimOrganizationUserAsync(InviteScimOrganizationUserRequest request) + { + var result = await InviteOrganizationUsersAsync(InviteOrganizationUsersRequest.Create(request)); + + if (result.Data.Any()) + { + (OrganizationUser User, EventType type, EventSystemUser system, DateTime performedAt) log = (result.Data.First(), EventType.OrganizationUser_Invited, EventSystemUser.SCIM, request.PerformedAt.UtcDateTime); + + await eventService.LogOrganizationUserEventsAsync([log]); + } + + return new CommandResult(result.Data.FirstOrDefault()); + } + + public async Task> InvitePublicApiOrganizationUserAsync(InviteOrganizationUserRequest request) + { + var result = await InviteOrganizationUsersAsync(InviteOrganizationUsersRequest.Create(request)); + + if (result.Data.Any()) + { + (OrganizationUser User, EventType type, DateTime performedAt) log = (result.Data.First(), EventType.OrganizationUser_Invited, request.PerformedAt.UtcDateTime); + + await eventService.LogOrganizationUserEventsAsync([log]); + } + + return new CommandResult(result.Data.FirstOrDefault()); + } + + private async Task>> InviteOrganizationUsersAsync(InviteOrganizationUsersRequest request) + { + var existingEmails = new HashSet(await organizationUserRepository.SelectKnownEmailsAsync( + request.Organization.OrganizationId, request.Invites.SelectMany(i => i.Emails), false), + StringComparer.InvariantCultureIgnoreCase); + + var invitesToSend = request.Invites + .SelectMany(invite => invite.Emails + .Where(email => !existingEmails.Contains(email)) + .Select(email => OrganizationUserForInvite.Create(email, invite)) + ); + + // Validate we can add those seats + var validationResult = await inviteUsersValidation.ValidateAsync(new InviteOrganizationUserRefined + { + Invites = invitesToSend.ToArray(), + Organization = request.Organization, + PerformedBy = request.PerformedBy, + PerformedAt = request.PerformedAt, + OccupiedPmSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(request.Organization.Id), + OccupiedSmSeats = await organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(request.Organization.Id) + }); + + try + { + // save organization users + // org users + // collections + // groups + + // save new seat totals + // password manager + // secrets manager + // update stripe + + // send invites + + // notify owners + // seats added + // autoscaling + // max seat limit has been reached + + // publish events + // Reference events + + // update cache + } + catch (Exception ex) + { + // rollback saves + // remove org users + // remove collections + // remove groups + // correct stripe + } + + return null; + } +} + +public class OrganizationUserForInvite +{ + public string Email { get; private init; } = string.Empty; + public Guid[] AccessibleCollections { get; private init; } = []; + public string ExternalId { get; private init; } = string.Empty; + public Permissions Permissions { get; private init; } = new(); + public OrganizationUserType Type { get; private init; } = OrganizationUserType.User; + public bool AccessSecretsManager { get; private init; } + + public static OrganizationUserForInvite Create(string email, OrganizationUserInvite invite) + { + return new OrganizationUserForInvite + { + Email = email, + AccessibleCollections = invite.AccessibleCollections, + ExternalId = invite.ExternalId, + Type = invite.Type, + Permissions = invite.Permissions, + AccessSecretsManager = invite.AccessSecretsManager + }; + } +} + +public record InviteOrganizationUserRefined +{ + public OrganizationUserForInvite[] Invites { get; init; } = []; + public OrganizationDto Organization { get; init; } + public Guid PerformedBy { get; init; } + public DateTimeOffset PerformedAt { get; init; } + public int OccupiedPmSeats { get; init; } + public int OccupiedSmSeats { get; init; } +} + +public static class Functions +{ + public static Func ValidateCollectionConfiguration => collectionAccessSelection => + collectionAccessSelection.Manage && (collectionAccessSelection.ReadOnly || collectionAccessSelection.HidePasswords); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/InviteOrganizationUserRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/InviteOrganizationUserRequest.cs new file mode 100644 index 000000000000..9bd142c87f59 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/InviteOrganizationUserRequest.cs @@ -0,0 +1,26 @@ +using Bit.Core.AdminConsole.Models.Business; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; + +public record InviteOrganizationUserRequest +{ + public OrganizationUserSingleEmailInvite Invite { get; } + public OrganizationDto Organization { get; } + public Guid PerformedBy { get; } + public DateTimeOffset PerformedAt { get; } + + private InviteOrganizationUserRequest(OrganizationUserSingleEmailInvite Invite, + OrganizationDto Organization, + Guid PerformedBy, + DateTimeOffset PerformedAt) + { + this.Invite = Invite; + this.Organization = Organization; + this.PerformedBy = PerformedBy; + this.PerformedAt = PerformedAt; + } + + public static InviteOrganizationUserRequest Create(OrganizationUserSingleEmailInvite invite, + OrganizationDto organization, Guid performedBy, DateTimeOffset performedAt) => + new(invite, organization, performedBy, performedAt); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/InviteScimOrganizationUserRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/InviteScimOrganizationUserRequest.cs new file mode 100644 index 000000000000..a2c659928b23 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/InviteScimOrganizationUserRequest.cs @@ -0,0 +1,23 @@ +using Bit.Core.AdminConsole.Models.Business; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; + +public record InviteScimOrganizationUserRequest +{ + public OrganizationUserSingleEmailInvite Invite { get; } + public OrganizationDto Organization { get; } + public DateTimeOffset PerformedAt { get; } + + private InviteScimOrganizationUserRequest(OrganizationUserSingleEmailInvite Invite, + OrganizationDto Organization, + DateTimeOffset PerformedAt) + { + this.Invite = Invite; + this.Organization = Organization; + this.PerformedAt = PerformedAt; + } + + public static InviteScimOrganizationUserRequest Create(OrganizationUserSingleEmailInvite invite, + OrganizationDto organization, DateTimeOffset performedAt) => + new(invite, organization, performedAt); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUsersValidation.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUsersValidation.cs new file mode 100644 index 000000000000..fdac368969bd --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/InviteUsersValidation.cs @@ -0,0 +1,150 @@ +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; + +public interface IInviteUsersValidation +{ + Task> ValidateAsync( + InviteOrganizationUserRefined request); +} + +public class InviteUsersValidation( + IGlobalSettings globalSettings, + IProviderRepository providerRepository, + IPaymentService paymentService) : IInviteUsersValidation +{ + public async Task> ValidateAsync( + InviteOrganizationUserRefined request) + { + if (ValidateEnvironment(globalSettings) is Invalid invalidEnvironment) + { + return new Invalid(invalidEnvironment.ErrorMessageString); + } + + if (InvitingUserOrganizationValidation.Validate(request.Organization) is Invalid organizationValidation) + { + return new Invalid(organizationValidation.ErrorMessageString); + } + + var subscriptionUpdate = PasswordManagerSubscriptionUpdate.Create(request); + + if (PasswordManagerInviteUserValidation.Validate(subscriptionUpdate) is + Invalid invalidSubscriptionUpdate) + { + return new Invalid(invalidSubscriptionUpdate.ErrorMessageString); + } + + var smSubscriptionUpdate = SecretsManagerSubscriptionUpdate.Create(request, subscriptionUpdate); + + if (SecretsManagerInviteUserValidation.Validate(smSubscriptionUpdate) is + Invalid invalidSmSubscriptionUpdate) + { + return new Invalid(invalidSmSubscriptionUpdate.ErrorMessageString); + } + + var provider = await providerRepository.GetByOrganizationIdAsync(request.Organization.OrganizationId); + + if (InvitingUserOrganizationProviderValidation.Validate(ProviderDto.FromProviderEntity(provider)) is + Invalid invalidProviderValidation) + { + return new Invalid(invalidProviderValidation.ErrorMessageString); + } + + var paymentSubscription = await paymentService.GetSubscriptionAsync(request.Organization); + + if (InviteUserPaymentValidation.Validate(PaymentSubscriptionDto.FromSubscriptionInfo(paymentSubscription, request.Organization)) is + Invalid invalidPaymentValidation) + { + return new Invalid(invalidPaymentValidation.ErrorMessageString); + } + + return new Valid(null); + } + + public static ValidationResult ValidateEnvironment(IGlobalSettings globalSettings) => + globalSettings.SelfHosted + ? new Invalid(InviteUserValidationErrorMessages.CannotAutoScaleOnSelfHostedError) + : new Valid(globalSettings); +} + +public record OrganizationUserInviteUpgrade +{ + public PasswordManagerSubscriptionUpgrade PasswordManagerSubscriptionUpgrade { get; } + public SecretManagerSubscriptionUpgrade SecretManagerSubscriptionUpgrade { get; } + public OrganizationUserForInvite[] OrganizationUsers { get; } + + private OrganizationUserInviteUpgrade() + { + } + + public static OrganizationUserInviteUpgrade Create() + { + return new(); + } +} + +public abstract record ProductSubscription +{ + public int? Seats { get; protected set; } +} + +public record PasswordManagerSubscription : ProductSubscription +{ + private PasswordManagerSubscription(int? seats) + { + Seats = seats; + } + + public static PasswordManagerSubscription Create(int? seats) => new(seats); +}; + +public record SecretManagerSubscription : ProductSubscription +{ + private SecretManagerSubscription(int? seats) + { + Seats = seats; + } + + public static SecretManagerSubscription Create(int? seats) => new(seats); +}; + +public abstract record SubscriptionUpgrade where T : ProductSubscription +{ + public T Current { get; protected set; } + public T Upgrade { get; protected set; } + public int NewSeatsRequired => Upgrade.Seats - Current.Seats ?? 0; + public int? MaxAutoScaleSeats { get; protected set; } +} + +public record PasswordManagerSubscriptionUpgrade : SubscriptionUpgrade +{ + private PasswordManagerSubscriptionUpgrade(PasswordManagerSubscription current, PasswordManagerSubscription upgrade, + int? maxAutoScale) + { + Current = current; + Upgrade = upgrade; + MaxAutoScaleSeats = maxAutoScale; + } + + public static PasswordManagerSubscriptionUpgrade Create(PasswordManagerSubscription current, + PasswordManagerSubscription upgrade, int? maxAutoScale) => + new(current, upgrade, maxAutoScale); +} + +public record SecretManagerSubscriptionUpgrade : SubscriptionUpgrade +{ + private SecretManagerSubscriptionUpgrade(SecretManagerSubscription current, SecretManagerSubscription upgrade, + int? maxAutoScale) + { + Current = current; + Upgrade = upgrade; + MaxAutoScaleSeats = maxAutoScale; + } + + public static SecretManagerSubscriptionUpgrade Create(SecretManagerSubscription current, + SecretManagerSubscription upgrade, int? maxAutoScale) => + new(current, upgrade, maxAutoScale); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerInviteUserValidation.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerInviteUserValidation.cs index 6b497556a2d9..ab85956f5ee3 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerInviteUserValidation.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerInviteUserValidation.cs @@ -2,6 +2,9 @@ public static class PasswordManagerInviteUserValidation { + + // TODO need to add plan validation from AdjustSeatsAsync + public static ValidationResult Validate(PasswordManagerSubscriptionUpdate subscriptionUpdate) { if (subscriptionUpdate.Seats is null) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerSubscriptionUpdate.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerSubscriptionUpdate.cs index c481547b43a9..5e5eddbeba0d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerSubscriptionUpdate.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/PasswordManagerSubscriptionUpdate.cs @@ -33,4 +33,10 @@ public static PasswordManagerSubscriptionUpdate Create(OrganizationDto organizat { return new PasswordManagerSubscriptionUpdate(organizationDto.Seats, organizationDto.MaxAutoScaleSeats, occupiedSeats, seatsToAdd); } + + public static PasswordManagerSubscriptionUpdate Create(InviteOrganizationUserRefined refined) + { + return new PasswordManagerSubscriptionUpdate(refined.Organization.Seats, refined.Organization.MaxAutoScaleSeats, + refined.OccupiedPmSeats, refined.Invites.Length); + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/SecretsManagerInviteUserValidation.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/SecretsManagerInviteUserValidation.cs new file mode 100644 index 000000000000..61d692ac2207 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/SecretsManagerInviteUserValidation.cs @@ -0,0 +1,82 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; + +public class SecretsManagerInviteUserValidation +{ + // Do we need to check if they are attempting to subtract seats? (no I don't think so because this is for inviting a User) + public static ValidationResult Validate(SecretsManagerSubscriptionUpdate subscriptionUpdate) + { + if (subscriptionUpdate.UseSecretsManger) + { + return new Invalid(InviteUserValidationErrorMessages.OrganizationNoSecretsManager); + } + + if (subscriptionUpdate.Seats == null) + { + return new Valid(subscriptionUpdate); // no need to adjust seats...continue on + } + + // if (update.Autoscaling && update.SmSeats.Value < organization.SmSeats.Value) + // { + // throw new BadRequestException("Cannot use autoscaling to subtract seats."); + // } + + // Might need to check plan + + // Check plan maximum seats + // if (!plan.SecretsManager.HasAdditionalSeatsOption || + // (plan.SecretsManager.MaxAdditionalSeats.HasValue && update.SmSeatsExcludingBase > plan.SecretsManager.MaxAdditionalSeats.Value)) + // { + // var planMaxSeats = plan.SecretsManager.BaseSeats + plan.SecretsManager.MaxAdditionalSeats.GetValueOrDefault(); + // throw new BadRequestException($"You have reached the maximum number of Secrets Manager seats ({planMaxSeats}) for this plan."); + // } + + // Check autoscale maximum seats + if (subscriptionUpdate.UpdatedSeatTotal is not null && subscriptionUpdate.MaxAutoScaleSeats is not null && + subscriptionUpdate.UpdatedSeatTotal > subscriptionUpdate.MaxAutoScaleSeats) + { + return new Invalid(InviteUserValidationErrorMessages + .SecretsManagerSeatLimitReached); + } + + // if (update.MaxAutoscaleSmSeats.HasValue && update.SmSeats.Value > update.MaxAutoscaleSmSeats.Value) + // { + // var message = update.Autoscaling + // ? "Secrets Manager seat limit has been reached." + // : "Cannot set max seat autoscaling below seat count."; + // throw new BadRequestException(message); + // } + + // Inviting a user... this shouldn't matter + // + // Check minimum seats included with plan + // if (plan.SecretsManager.BaseSeats > update.SmSeats.Value) + // { + // throw new BadRequestException($"Plan has a minimum of {plan.SecretsManager.BaseSeats} Secrets Manager seats."); + // } + + // Check minimum seats required by business logic + // if (update.SmSeats.Value <= 0) + // { + // throw new BadRequestException("You must have at least 1 Secrets Manager seat."); + // } + + // Check minimum seats currently in use by the organization + // if (organization.SmSeats.Value > update.SmSeats.Value) + // { + // var occupiedSeats = await _organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id); + // if (occupiedSeats > update.SmSeats.Value) + // { + // throw new BadRequestException($"{occupiedSeats} users are currently occupying Secrets Manager seats. " + + // "You cannot decrease your subscription below your current occupied seat count."); + // } + // } + + // Check that SM seats aren't greater than password manager seats + if (subscriptionUpdate.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal < subscriptionUpdate.UpdatedSeatTotal) + { + return new Invalid(InviteUserValidationErrorMessages.SecretsManagerCannotExceedPasswordManager); + } + + return new Valid(subscriptionUpdate); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/InviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/InviteOrganizationUsersCommand.cs deleted file mode 100644 index 50b3e123819f..000000000000 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/InviteOrganizationUsersCommand.cs +++ /dev/null @@ -1,58 +0,0 @@ -using Bit.Core.AdminConsole.Models.Business; -using Bit.Core.Exceptions; -using Bit.Core.Models.Data; -using Bit.Core.Utilities; - -namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; - -public interface IInviteOrganizationUsersCommand -{ - Task InviteOrganizationUserAsync(InviteOrganizationUserRequest request); - Task InviteOrganizationUsersAsync(InviteOrganizationUsersRequest request); -} - -public class InviteOrganizationUsersCommand : IInviteOrganizationUsersCommand -{ - public Task InviteOrganizationUserAsync(InviteOrganizationUserRequest request) => throw new NotImplementedException(); - - public Task InviteOrganizationUsersAsync(InviteOrganizationUsersRequest request) => throw new NotImplementedException(); -} - -public record InviteOrganizationUsersRequest(); - -public record InviteOrganizationUserRequest( - OrganizationUserSingleInvite Invite, - OrganizationDto Organization, - Guid performedBy, - DateTimeOffset performedAt); - -public class OrganizationUserSingleInvite -{ - public const string InvalidEmailErrorMessage = "The email address is not valid."; - public const string InvalidCollecitonConfigurationErrorMessage = "The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true."; - - public string Email { get; private init; } = string.Empty; - public Guid[] AccessibleCollections { get; private init; } = []; - - public static OrganizationUserSingleInvite Create(string email, IEnumerable accessibleCollections) - { - if (!email.IsValidEmail()) - { - throw new BadRequestException(InvalidEmailErrorMessage); - } - - if (accessibleCollections?.Any(ValidateCollectionConfiguration) ?? false) - { - throw new BadRequestException(InvalidCollecitonConfigurationErrorMessage); - } - - return new OrganizationUserSingleInvite - { - Email = email, - AccessibleCollections = accessibleCollections.Select(x => x.Id).ToArray() - }; - } - - private static Func ValidateCollectionConfiguration => collectionAccessSelection => - collectionAccessSelection.Manage && (collectionAccessSelection.ReadOnly || collectionAccessSelection.HidePasswords); -} diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 70a3227a7139..634760365f35 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -5,7 +5,9 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; @@ -36,6 +38,7 @@ using Bit.Core.Utilities; using Microsoft.Extensions.Logging; using Stripe; +using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite; namespace Bit.Core.Services; @@ -74,6 +77,8 @@ public class OrganizationService : IOrganizationService private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IOrganizationBillingService _organizationBillingService; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; + private readonly IInviteOrganizationUsersCommand _inviteOrganizationUsersCommand; + private readonly TimeProvider _timeProvider; public OrganizationService( IOrganizationRepository organizationRepository, @@ -108,7 +113,9 @@ public OrganizationService( IFeatureService featureService, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IOrganizationBillingService organizationBillingService, - IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery) + IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, + IInviteOrganizationUsersCommand inviteOrganizationUsersCommand, + TimeProvider timeProvider) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -143,6 +150,8 @@ public OrganizationService( _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _organizationBillingService = organizationBillingService; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; + _inviteOrganizationUsersCommand = inviteOrganizationUsersCommand; + _timeProvider = timeProvider; } public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken, @@ -846,6 +855,24 @@ public async Task InviteUserAsync(Guid organizationId, Guid? i public async Task> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, IEnumerable<(OrganizationUserInvite invite, string externalId)> invites) { + + if (_featureService.IsEnabled(FeatureFlagKeys.ScimCreateUserRefactor)) + { + var organization = await GetOrgById(organizationId); + + var request = InviteOrganizationUsersRequest.Create(invites, OrganizationDto.FromOrganization(organization), + invitingUserId ?? Guid.Empty, _timeProvider.GetUtcNow()); + + var commandResult = await _inviteOrganizationUsersCommand.InviteOrganizationUserListAsync(request); + + if (commandResult.Success) + { + return commandResult.Data?.ToList() ?? []; + } + + return []; // TODO return more meaningful result + } + var inviteTypes = new HashSet(invites.Where(i => i.invite.Type.HasValue) .Select(i => i.invite.Type.Value)); diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 5643ed765475..43d9d4af00ea 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -154,6 +154,7 @@ public static class FeatureFlagKeys public const string SecurityTasks = "security-tasks"; public const string DisableFreeFamiliesSponsorship = "PM-12274-disable-free-families-sponsorship"; public const string MacOsNativeCredentialSync = "macos-native-credential-sync"; + public const string ScimCreateUserRefactor = "scim-create-user-refactor"; public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form"; public const string InlineMenuTotp = "inline-menu-totp"; public const string SelfHostLicenseRefactor = "pm-11516-self-host-license-refactor"; diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index c037b30a9749..9d55269ee5d9 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -13,6 +13,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; using Bit.Core.Models.Business.Tokenables; using Bit.Core.OrganizationFeatures.OrganizationCollections; using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; @@ -159,6 +160,9 @@ private static void AddOrganizationUserCommandsQueries(this IServiceCollection s services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); diff --git a/test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUserRequestTests.cs b/test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUserRequestTests.cs index a211c87a3dae..86e4ead2893d 100644 --- a/test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUserRequestTests.cs +++ b/test/Core.Test/AdminConsole/Models/Business/InviteOrganizationUserRequestTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; @@ -12,9 +12,9 @@ public class InviteOrganizationUserRequestTests [Theory] [BitAutoData] public void Create_WhenPassedInvalidEmail_ThrowsException(string email, string externalId, - OrganizationUserType type, Permissions permissions) + OrganizationUserType type, Permissions permissions, bool accessSecretsManager) { - var action = () => OrganizationUserSingleEmailInvite.Create(email, [], externalId, type, permissions); + var action = () => OrganizationUserSingleEmailInvite.Create(email, [], externalId, type, permissions, accessSecretsManager); var exception = Assert.Throws(action); @@ -24,7 +24,7 @@ public void Create_WhenPassedInvalidEmail_ThrowsException(string email, string e [Theory] [BitAutoData] public void Create_WhenPassedInvalidCollectionAccessConfiguration_ThrowsException(string externalId, - OrganizationUserType type, Permissions permissions) + OrganizationUserType type, Permissions permissions, bool accessSecretsManager) { var validEmail = "test@email.com"; @@ -32,7 +32,7 @@ public void Create_WhenPassedInvalidCollectionAccessConfiguration_ThrowsExceptio var action = () => OrganizationUserSingleEmailInvite.Create(validEmail, [invalidCollectionConfiguration], externalId, type, - permissions); + permissions, accessSecretsManager); var exception = Assert.Throws(action); @@ -42,13 +42,13 @@ public void Create_WhenPassedInvalidCollectionAccessConfiguration_ThrowsExceptio [Theory] [BitAutoData] public void Create_WhenPassedValidArguments_ReturnsInvite(string externalId, OrganizationUserType type, - Permissions permissions) + Permissions permissions, bool accessSecretsManager) { const string validEmail = "test@email.com"; var validCollectionConfiguration = new CollectionAccessSelection { Id = Guid.NewGuid(), Manage = true }; var invite = OrganizationUserSingleEmailInvite.Create(validEmail, [validCollectionConfiguration], externalId, - type, permissions); + type, permissions, accessSecretsManager); Assert.NotNull(invite); Assert.Equal(validEmail, invite.Email); diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index cd680f2ef0d3..259e0efd2356 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -2,7 +2,9 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Entities; @@ -15,6 +17,7 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; +using Bit.Core.Models.Commands; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Mail; @@ -39,6 +42,7 @@ using Xunit; using Organization = Bit.Core.AdminConsole.Entities.Organization; using OrganizationUser = Bit.Core.Entities.OrganizationUser; +using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite; #nullable enable @@ -666,6 +670,39 @@ await sutProvider.GetDependency().Received(1) await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync(Arg.Any>()); } + [Theory] + [BitAutoData] + public async Task InviteUserAsync_InviteOrganizationRefactorCommandFlagIsOn_ShouldCallCommand( + Guid organizationId, + Guid performedBy, + string externalId, + Organization organization, + SutProvider sutProvider) + { + var validEmail = "email@test.com"; + + var invite = new OrganizationUserInvite + { + Emails = [validEmail], + Collections = [], + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.ScimCreateUserRefactor) + .Returns(true); + + var command = sutProvider.GetDependency(); + command.InviteOrganizationUserListAsync(Arg.Any()) + .Returns(new CommandResult>([])); + + await sutProvider.Sut.InviteUsersAsync(organizationId, performedBy, systemUser: null, [(invite, externalId)]); + + command.Received(1).InviteOrganizationUserListAsync( + Arg.Any()); + } + [Theory] [OrganizationInviteCustomize( InviteeUserType = OrganizationUserType.User, From d2b0bbeddbefa2cfeedc20542b7f46cb60a77604 Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Tue, 11 Feb 2025 15:11:38 -0600 Subject: [PATCH 18/19] partial secrets manger update class --- .../SecretsManagerSubscriptionUpdate.cs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/SecretsManagerSubscriptionUpdate.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/SecretsManagerSubscriptionUpdate.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/SecretsManagerSubscriptionUpdate.cs new file mode 100644 index 000000000000..f405a4aa69a5 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Validation/SecretsManagerSubscriptionUpdate.cs @@ -0,0 +1,33 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validation; + +public class SecretsManagerSubscriptionUpdate +{ + public bool UseSecretsManger { get; private init; } + public int? Seats { get; private init; } + public int? MaxAutoScaleSeats { get; private init; } + public int OccupiedSeats { get; private init; } + public int AdditionalSeats { get; private init; } + public PasswordManagerSubscriptionUpdate PasswordManagerSubscriptionUpdate { get; private init; } + public int? AvailableSeats => Seats - OccupiedSeats; + public int SeatsRequiredToAdd => AdditionalSeats - AvailableSeats ?? 0; + public int? UpdatedSeatTotal => Seats + SeatsRequiredToAdd; + + private SecretsManagerSubscriptionUpdate(bool useSecretsManger, int? organizationSeats, + int? organizationAutoScaleSeatLimit, int currentSeats, int seatsToAdd, PasswordManagerSubscriptionUpdate passwordManagerSeats) + { + UseSecretsManger = useSecretsManger; + Seats = organizationSeats; + MaxAutoScaleSeats = organizationAutoScaleSeatLimit; + OccupiedSeats = currentSeats; + AdditionalSeats = seatsToAdd; + PasswordManagerSubscriptionUpdate = passwordManagerSeats; + } + + public static SecretsManagerSubscriptionUpdate Create(InviteOrganizationUserRefined refined, PasswordManagerSubscriptionUpdate passwordManagerSubscriptionUpdate) + { + return new SecretsManagerSubscriptionUpdate(refined.Organization.UseSecretsManager, + refined.Organization.SmSeats, refined.Organization.SmMaxAutoScaleSeats, + refined.OccupiedPmSeats, refined.Invites.Count(x => x.AccessSecretsManager), + passwordManagerSubscriptionUpdate); + } +} From ec76bb75c83d3ee27ce29865f600742adccd6f0a Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Tue, 11 Feb 2025 15:46:16 -0600 Subject: [PATCH 19/19] removing unusued variable --- .../OrganizationUsers/InviteOrganizationUsersCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteOrganizationUsersCommand.cs index 3f31ca1d2eaf..666181390aa2 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteOrganizationUsersCommand.cs @@ -126,7 +126,7 @@ private async Task>> InviteOrganizat // update cache } - catch (Exception ex) + catch (Exception) { // rollback saves // remove org users